📚 Git 深入课(以 sourcepack 仓库为标本)· 第 4/12 章
全部示例均来自真机实测;实验请在隔离克隆里做。
本章目标:搞清
.git/index到底是什么、git add究竟写了哪些字节,
以及为什么"同一个文件可以既是 staged 又是 unstaged"。
学完你会理解 Git 的三棵树模型,并能解释 sourcepack 里
staged · unstaged · untracked这三个计数是怎么来的——
以及它在 v1.8.0 里为什么恒为 0。
4.1 索引是"三棵树"里的中间那棵
Git 的日常工作围绕三棵树:
| 树 | 存在哪 | 含义 |
|---|---|---|
| HEAD | 对象库(上一章的引用指向的 commit 里的 tree) | 上次提交的快照 |
| 索引(index / staging area) | .git/index(二进制文件) |
下一次提交的候选快照 |
| 工作区(working tree) | 你的文件系统 | 你此刻正在编辑的东西 |
git status 干的事就是三方比对:
git diff --cached git diff
HEAD ──────────────────► 索引 ──────────────────► 工作区
(上次提交) (下次提交) (此刻)
git diff --cached= HEAD ↔ 索引 → 这就是 staged(已暂存)git diff= 索引 ↔ 工作区 → 这就是 unstaged(未暂存)??= 工作区里有、但索引里没有 → untracked(未跟踪)
记住这张图,"staged 是什么意思"就再也不会混了:
staged = 索引里的内容与 HEAD 不同,而不是"我运行过 add"。
4.2 索引是二进制文件:逐字节看
$ ls -l .git/index
2225 字节
$ xxd .git/index | head -3
00000000: 4449 5243 0000 0002 0000 001a 6acc febc DIRC........j...
00000010: 328d f414 6acc febc 328d f414 0100 0010 2...j...2.......
00000020: 1357 452a 0000 81a4 0000 01f5 0000 0000 .WE*............
- 前 4 字节
DIRC= "directory cache"(索引的旧称); - 接着 4 字节版本号(
00000002= v2)、4 字节条目数(0000001a= 26,
与git ls-files --stage | wc -l一致); - 之后是每个条目一整块。(本节实测环境:
git version 2.39.5 (Apple Git-154))
用 Python 把条目解出来(索引格式的最佳学习方式):
import struct
data = open('.git/index','rb').read()
ver, n = struct.unpack('>II', data[4:12])
print(f'魔数={data[:4].decode()} 版本={ver} 条目数={n}')
off = 12
for i in range(3):
start = off
ctime_s, ctime_n, mtime_s, mtime_n, dev, ino, mode, uid, gid, size = struct.unpack('>10I', data[off:off+40])
sha = data[off+40:off+60].hex()
flags, = struct.unpack('>H', data[off+60:off+62])
namelen = flags & 0x0FFF
name = data[off+62:off+62+namelen].decode()
print(f'条目{i}: mode={mode:o} size={size} sha={sha[:10]}… name={name}')
off = start + 62 + namelen
off = start + ((off - start + 7) // 8) * 8 # v2:条目按 8 字节对齐
实测输出:
魔数=DIRC 版本=2 条目数=26 文件大小=2225
条目0: mode=100644 size=322 uid=501 gid=0 sha=c9f80462af… name=.gitattributes
条目1: mode=100644 size=4579 uid=501 gid=0 sha=eb018ce0ca… name=.github/workflows/release.yml
条目2: mode=100644 size=1436 uid=501 gid=0 sha=582b8e75c5… name=.github/workflows/test.yml
(size 与 stat -f%z 的结果逐字节吻合,说明这就是工作区文件的 stat 缓存。)
每条包含三类信息(这三类缺一不可):
- stat 信息:
ctime / mtime / dev / ino / uid / gid / size—— 用于快速判断文件是否变过; - 对象引用:
mode+blob 哈希—— 暂存的内容在对象库里的位置; - 路径名 + 标志位(名字长度、是否冲突等)。
这是索引的第一个身份:缓存。
git status不必读遍所有文件内容——
先比 stat(mtime/size/ino),只有对不上的才去读内容算哈希。
上万个文件的项目里git status依然飞快,靠的就是这块 stat 缓存
(第 10 章会展开 racy timestamp 的坑)。
4.3 git add 到底做了什么
两步,都可验证:
# ① 把内容写进对象库(第 2 章的 blob)
$ git hash-object -w newfile.txt
# ② 更新索引条目(路径 → 该 blob 的哈希 + 记录当前 stat)
# 手工模拟 git add 的第 ② 步:
$ git update-index --add --cacheinfo 100644,<blob-sha>,newfile.txt
验证"索引里存的哈希 == 工作区内容的哈希 == HEAD 里的哈希"(未修改的文件):
$ git ls-files -s .gitattributes
100644 c9f80462afe787371af06c0ae9a94e152dc469b8 0 .gitattributes
$ git rev-parse HEAD:.gitattributes
c9f80462afe787371af06c0ae9a94e152dc469b8
$ git hash-object .gitattributes
c9f80462afe787371af06c0ae9a94e152dc469b8
三者一致 = 这个文件"干净"(clean)。改任意一处,三者就不再同时相等——
git status 的全部逻辑就是比较这三个哈希。
git ls-files -s输出的中间那个数字是阶段号(stage),正常是 0,
冲突时会出现 1/2/3(见 4.7)。
4.4 三种状态与 porcelain 输出的两种列
git status --porcelain 的每行前两个字符是两列:
第一列 = 索引 vs HEAD(staged),第二列 = 工作区 vs 索引(unstaged)。
实测四种组合 [lab]:
$ printf 'package main\n// 未暂存改动\n' >> main.go # 只改工作区
$ printf 'package main\n' > staged_only.go && git add staged_only.go # 只进索引
$ printf 'untracked\n' > brand_new.txt # 什么都不做
$ git status --porcelain
M main.go # ← 第二列 M:工作区与索引不同(unstaged)
A staged_only.go # ← 第一列 A:索引与 HEAD 不同(staged,新增)
?? brand_new.txt # ← 未跟踪
| porcelain | 含义 |
|---|---|
M |
工作区改了,没 add |
M |
add 过了,工作区没再改 |
MM |
先 add、又改了工作区(两份内容同时存在) |
A / D |
索引里新增 / 删除 |
?? |
未跟踪 |
UU |
冲突(both modified) |
4.5 三种 git diff 各看什么
这是最容易搞混的地方,一次测清楚 [lab]:
$ git diff --name-only | wc -l
1 # 工作区 vs 索引 → unstaged
$ git diff --cached --name-only | wc -l
1 # 索引 vs HEAD → staged
$ git diff HEAD --name-only | wc -l
2 # 工作区 vs HEAD → 两者之和
一句话记忆:git diff 默认不看索引之外(它比的是"索引 ↔ 工作区"),
要看已暂存的必须加 --cached(等价写法 --staged)。
对 sourcepack 的意义:我们的
--staged模式正是把命令换成
git diff --cached(changeset.go:342-351),而不是"先拿全部再过滤"。
4.6 MM:同一个文件的两份内容并存
$ git add main.go # 此刻的内容进索引
$ printf '// 又改了第三行\n' >> main.go # 工作区再改
$ git status --porcelain main.go
MM main.go
$ git diff --cached main.go | wc -l
10 # 索引 vs HEAD:第一次 add 时的内容
$ git diff main.go | wc -l
9 # 工作区 vs 索引:后来又改的那部分
索引里存的是"你 add 那一刻的内容"(一个完整 blob),
它和 HEAD、和工作区都可以不同。这就是"暂存区"能"部分提交"的原理:
git add -p(交互式按块暂存)本质是拼一个只包含部分改动的 blob 放进索引;git commit只把索引写成一个 tree(工作区里剩下的改动完全不进这次提交)。
4.7 索引的第二个身份:合并工作台(stage 1/2/3)
平时一个路径在索引里只有 1 条记录(stage 0)。冲突时会有 3 条:
$ git merge ctheirs
CONFLICT (content): Merge conflict in conflict.txt
$ git status --porcelain conflict.txt
UU conflict.txt
$ git ls-files -u conflict.txt # -u = 只列未合并(冲突)条目
100644 df967b96a579e45a18b8251732d16804b2e56a55 1 conflict.txt
100644 b19a1e93bec1317dc6097229e12afaffbfa74dc2 2 conflict.txt
100644 950b81b7eee953d050aa05a641f8e056c85dd1bd 3 conflict.txt
| 阶段号 | 含义 |
|---|---|
| 1 | 共同祖先(merge base)里的版本 |
| 2 | ours:当前分支的版本 |
| 3 | theirs:被合并进来的版本 |
同一个路径,三个版本同时躺在索引里——这就是"三方合并"在磁盘上的样子。
冲突标记(<<<<<<< / ======= / >>>>>>>)只是 Git 为了让人看懂而写进工作区的文本提示;
真正的三方数据在索引里。
解决冲突 = 把工作区内容 add 进去,让三条归一:
$ printf 'resolved\n' > conflict.txt && git add conflict.txt
$ git ls-files --stage conflict.txt
100644 2ab19ae607aabda796309682e0448237aab03047 0 conflict.txt # ← 回到 stage 0
所以"解决冲突"在 Git 内部就是一次
add:用你的内容覆盖掉那三条。
4.8 其它常用索引操作
| 命令 | 作用 |
|---|---|
git ls-files --stage |
列出索引条目(模式 / 哈希 / 阶段 / 路径) |
git ls-files --others --exclude-standard |
未跟踪文件(尊重 ignore 规则) |
git ls-files -u |
只列冲突条目 |
git rm --cached <f> |
从索引移除、保留工作区文件("取消跟踪") |
git update-index --assume-unchanged <f> |
告诉 Git"别管这个文件的变化"(临时性能手段,不是忽略) |
git update-index --skip-worktree <f> |
本地配置类文件常用(同样的坑:别当 ignore 用) |
git update-index --refresh |
刷新 stat 缓存 |
git reset <f> |
用 HEAD 版本覆盖索引条目(撤销 add) |
--assume-unchanged/--skip-worktree都不是.gitignore的替代品:
它们只影响本地索引行为,克隆到别处就失效(第 7 章讲真正的忽略规则)。
4.9 回到 sourcepack:三个计数是怎么来的
4.9.1 取三个集合(代码原样,changeset.go:366-388)
unstagedSet := map[string]bool{}
if !config.GitStaged && !cs.Unborn {
stagedArgs := append([]string{"diff", "--no-ext-diff", "--name-status", "-z", "-M", "--cached", base}, g.scoped()...)
if out, cerr := g.run(stagedArgs...); cerr == nil {
markPaths(out, stagedSet, true)
}
unstagedArgs := append([]string{"diff", "--no-ext-diff", "--name-status", "-z", "-M"}, g.scoped()...)
if out, uerr := g.run(unstagedArgs...); uerr == nil {
markPaths(out, unstagedSet, false)
}
}
// 3. 未跟踪文件(--staged 时不适用)
var untracked []string
if !config.GitStaged {
lsArgs := append([]string{"ls-files", "--others", "--exclude-standard", "--full-name", "-z"}, g.scoped()...)
...
}
三个集合对应三棵树的两条边 + 索引之外,与 4.1 那张图完全一致:
| 集合 | 命令 | 对应 |
|---|---|---|
| staged | diff --name-status --cached |
HEAD ↔ 索引 |
| unstaged | diff --name-status |
索引 ↔ 工作区 |
| untracked | ls-files --others --exclude-standard |
索引里没有的 |
4.9.2 用 git 的真值校验我们的输出
实测(三个状态各造一个文件) [lab]:
$ printf 'pkg\n' > only_staged.go && git add only_staged.go
$ printf '// unstaged\n' >> main.go
$ printf 'untracked\n' > untracked_中文.txt
# git 的真值
$ echo "staged=$(git diff --cached --name-only|wc -l) unstaged=$(git diff --name-only|wc -l) untracked=$(git ls-files --others --exclude-standard|wc -l)"
staged=1 unstaged=1 untracked=1
# sourcepack 的输出
$ gx -g --dry-run -d .
Files changed: 3 (+3 −0)
staged 1 · unstaged 1 · untracked 1
逐项吻合。 产物里每条变更也带标记:
| 1 | `main.go` | modified | +1 −0 | unstaged |
| 2 | `only_staged.go` | added | +1 −0 | staged |
| 3 | `untracked_中文.txt` | untracked | +1 −0 | untracked |
4.9.3 为什么 v1.8.0 里这三个计数恒为 0
这是一次真实事故,根因就在 4.9.1 那段的 if 条件里:
if !config.GitStaged && !cs.Unborn { // ← --staged 模式时整段被跳过
--staged 模式下 stagedSet / unstagedSet 都不填充(因为清单本身来自
git diff --cached,当时的作者认为"不必再算一遍")。而计数函数是这么写的
(changeset.go:561-577):
func summarizeChangeSet(cs *ChangeSet) {
cs.StagedCount, cs.UnstagedCount, cs.UntrackedCount = 0, 0, 0
for _, grp := range cs.Commits {
for _, ch := range grp.Changes {
switch {
case ch.Untracked:
cs.UntrackedCount++
case ch.Staged && ch.Unstaged:
cs.StagedCount++ ...
case ch.Staged:
cs.StagedCount++
...
两个集合都是空的 → 每条变更的 Staged/Untracked 全为 false →
switch 的四个分支全部落空 → 三个计数全是 0。于是终端上出现自相矛盾的输出:
Files changed: 3
staged 0 · unstaged 0 · untracked 0 ← 下面却列着 3 个变更
v1.8.1 的修法(changeset.go:416-423):
if config.GitStaged {
// 上面的 stagedSet 只在非 --staged 时才填充,所以这里必须显式标,
// 否则 summarizeChangeSet 的 switch 全落空、StagedCount 恒为 0。
for _, ch := range changes {
ch.Staged = true
}
}
为什么这样是对的:--staged 的清单来源本身就是证据——
它来自 git diff --cached,所以每一条都必然是 staged。不需要再跑一次 diff 去"确认"。
修复后的实测:
$ gx -g --staged --dry-run -d .
Files changed: 1 (+1 −0)
staged 1 · unstaged 0 · untracked 0 # ← 与 git diff --cached 一致
这一节的教训值得单独记住:三个计数"看起来只是显示问题",但它让用户
无法判断"我到底暂存了什么"。一个 展示层 的恒零 bug,削弱的是整个
-g --staged工作流的可信度——这也解释了为什么它被排在 v1.8.1 的必修清单里。
顺带:--staged时untracked计数本就应该为 0,因为未跟踪文件
不可能被暂存(ls-files --others那段也整段跳过)。
4.10 动手实验 [lab]
cd /tmp/git-lab
# 1) 看清索引的字节
xxd .git/index | head -3
git ls-files --stage | head -5
git ls-files --stage | wc -l
# 2) 三棵树与三种状态
printf 'x\n' > a.txt && git add a.txt # staged
printf 'y\n' >> README.md # unstaged
printf 'z\n' > b.txt # untracked
git status --porcelain
git diff --name-only ; git diff --cached --name-only ; git diff HEAD --name-only
# 3) MM:两份内容并存
git add README.md && printf '// 再改\n' >> README.md
git status --porcelain README.md
git diff --cached README.md | head -5
git diff README.md | head -5
# 4) 冲突时索引里的三份
git checkout -q -B base4 HEAD && printf 'base\n' > c.txt && git add c.txt && git commit -qm base
git checkout -q -B theirs4 && printf 'theirs\n' > c.txt && git commit -qam theirs
git checkout -q base4 && printf 'ours\n' > c.txt && git commit -qam ours
git merge theirs4 || true
git ls-files -u c.txt # ← stage 1/2/3
printf 'resolved\n' > c.txt && git add c.txt && git ls-files --stage c.txt # ← 归一
# 5) 对照 sourcepack 的计数(先回到干净分支)
git merge --abort ; git checkout -q main 2>/dev/null || git checkout -q -B main HEAD
printf 'only\n' > only_staged.go && git add only_staged.go
printf '// u\n' >> main.go
printf 'z\n' > b2.txt
gx -g --dry-run -d . | grep -E "staged|Files changed"
自测题:
- "staged" 的准确含义是什么?(对照 4.1 那张图回答)
git add之后又改了工作区,git commit提交的是哪一版?为什么?- 冲突时为什么索引里同一路径能有 3 条记录?它们分别是什么?
git status为什么不必读完所有文件内容就能判断谁变了?- sourcepack 的
--staged模式为什么可以"直接标记 Staged=true",而不必再跑 diff?
4.11 小结
- 索引就是三棵树的中间那棵:
.git/index(二进制,魔数DIRC),
每条含 stat 缓存 + blob 哈希 + 路径; - staged = 索引与 HEAD 不同,unstaged = 工作区与索引不同,
porcelain 的两列正好对应这两条边; - 三种 diff 各看一条边:
git diff(索引↔工作区)、git diff --cached(HEAD↔索引)、
git diff HEAD(两端之和); MM是"同一个文件两份内容并存"——这是"部分提交"(add -p)的基础;- 冲突时索引里同一路径有 stage 1/2/3(base/ours/theirs),
解决冲突 = 一次add把三条归一为 stage 0; - 索引还是性能结构(stat 缓存让
git status不必读全部内容); - sourcepack:三个集合 =
diff --cached/diff/ls-files --others;
三个计数由summarizeChangeSet的switch得出;
v1.8.0 的--staged恒为 0 是因为两个集合在--staged下被整段跳过,
修法是"清单来源即证据",直接标Staged = true。
下一章:提交图与合并——commit 的 parent 边构成有向无环图,
merge 的两条父边、fast-forward、merge-base,以及 sourcepack 为什么要取
"第一父"、以及 PR 审查场景下"应该和 merge-base 比而不是和分支尖端比"。