暂存区到底是个什么东西
上一章说索引是「一份清单」。这一章把那个文件真的打开——它的结构简单到有点朴素,而正是这份朴素,解释了 Git 好几个出了名的怪癖。
先看字节
$ xxd .git/index | head -12
00000000: 4449 5243 0000 0002 0000 0002 6a5c da64 DIRC........j\.d 00000010: 1844 531b 6a5c da64 1844 531b 0100 0010 .DS.j\.d.DS..... 00000020: 05c8 4c89 0000 81a4 0000 01f5 0000 0000 ..L............. 00000030: 0000 000f 533b a6aa e425 2858 5522 7a4a ....S;...%(XU"zJ 00000040: 95c4 8e95 553d f491 0005 612e 7478 7400 ....U=....a.txt. 00000050: 0000 0000 6a5c da64 14c9 bb3a 6a5c da64 ....j\.d...:j\.d 00000060: 14c9 bb3a 0100 0010 05c8 4c8b 0000 81a4 ...:......L..... 00000070: 0000 01f5 0000 0000 0000 0003 6267 99f0 ............bg.. 00000080: f853 26a8 c1fc 522d b584 e86c dfcc d51f .S&...R-...l.... 00000090: 0009 6c69 622f 782e 7478 7400 5452 4545 ..lib/x.txt.TREE
逐段读一遍:
4449 5243= ASCII 的DIRC——「Directory Cache」,这是签名。0000 0002= 版本 2。0000 0002= 条目数:2。- 然后是两条记录,每条前面 62 字节是元数据,接着 20 字节 SHA,最后是路径字符串。
现在看两条记录的路径部分——第一条是 a.txt,第二条是 lib/x.txt。
盯着第二条多看两秒。
lib/x.txt 是一整个字符串,不是「lib 目录下的 x.txt」。
索引是一张扁平的表:一个路径,一个 blob 哈希,一堆元数据。没有任何嵌套结构,没有目录这个概念。
对比第 3 章的 tree 对象——那个是真的树,一层套一层。而索引是把树摊平之后的样子。
所以 git commit 的第一步 write-tree,干的正是把这张扁平表折回成嵌套的 tree 对象。第 5 章那台迷你 Git 里就是这么做的。
用 Git 自己的命令看会清楚很多:
$ git ls-files --stage
100644 533ba6aae425285855227a4a95c48e95553df491 0 a.txt 100644 626799f0f85326a8c1fc522db584e86cdfccd51f 0 lib/x.txt
加几个更深的文件试试。注意无论嵌套多深,条目数只跟文件数有关,目录数永远是 0。
三个怪癖,一个原因
「索引是一张扁平的文件表」这一句,能直接推出 Git 三个出了名的行为。
怪癖一:Git 存不了空目录
你建了个 logs/ 目录,空的,想让它进版本库。git add logs/ 毫无反应。
原因现在很显然了:索引只登记文件。一个目录里没有文件,就没有任何条目会提到它——它在 Git 眼里压根不存在。
所谓的 .gitkeep 约定:
$ touch logs/.gitkeep $ git add logs/.gitkeep
这不是 Git 支持的什么特性,纯粹是「往空目录里塞一个假文件,好让这个目录有条目」的民间障眼法。文件名叫什么都行,.gitkeep 只是约定俗成。
为什么 Git 要这么设计?因为它管的是内容,而空目录没有内容。这跟第 3 章「不记录属主、时间戳、权限细节」是同一种取舍:Git 不是备份工具,是内容版本工具。
怪癖二:改了 .gitignore,文件还是被跟踪
你把 *.log 加进 .gitignore,但 debug.log 每次还是出现在 git status 里。
原因:.gitignore 只管「还没被跟踪」的文件。
一旦某个路径进了索引,它就是被跟踪的(tracked)。Git 每次都会检查它、报告它的变化——ignore 规则根本轮不到。
逻辑上也说得通:ignore 的作用是「别把这个加进来」,而它已经在里面了。Git 不会因为你加了条规则,就悄悄把一个已经在版本控制里的文件删掉。
解法是把它从索引里摘掉,但保留工作区的文件:
$ git rm --cached debug.log
--cached 是关键:只删索引里的条目,不删磁盘上的文件。(少了它,你的文件就真被删了。)
下次提交,这个文件就从版本库里消失了,之后 .gitignore 才开始对它生效。整目录的话:
$ git rm -r --cached node_modules/
如果一个目录被整个忽略了,你没法用规则「反忽略」它里面的东西。
# 这样不管用: build/ !build/keep.txt ← 没用!
因为 Git 为了性能,发现 build/ 被忽略就整个跳过,根本不会走进去看。里面的规则自然没机会生效。
正确写法是逐层放行:
build/* !build/keep.txt
注意 build/* 和 build/ 的区别——前者忽略「目录里的东西」(Git 仍会进去看),后者忽略「这个目录」(直接跳过)。
另外,.gitignore 对已提交的文件永远无效,这是上面那条的另一面。想「暂时别管这个文件的改动」应该用:
$ git update-index --skip-worktree config.local.json
(有人会推荐 --assume-unchanged,但那个语义是「向 Git 保证这文件不会变」,是性能提示不是意图声明,一遇到 checkout 就会被冲掉。要表达「我故意改了它,别烦我」,用 --skip-worktree。)
怪癖三:git status 在大仓库里会变慢
回头看那段 hexdump,每条记录前面有62 字节的元数据。里面装的是:
ctime 文件元数据的修改时间(秒 + 纳秒) mtime 文件内容的修改时间(秒 + 纳秒) dev 设备号 ino inode 号 mode 文件模式 uid 属主 gid 属组 size 文件大小
为什么索引要存这些?为了不用读文件内容就能判断「这个文件变了没有」。
这叫 stat 缓存。git status 的工作流程是:
- 对索引里的每一条记录,对磁盘上那个文件做一次
stat() - 把 mtime、size、inode 这些跟索引里存的比
- 全都一样 → 直接认定「没变」,连文件都不打开
- 有不一样的 → 才真的读文件、算哈希、比对
这个优化很关键:读一个文件算 SHA-1 要几十微秒到几毫秒,而一次 stat() 只要一两微秒。
但代价是:它必须对每一个文件都做一次 stat()。
10 万个文件的仓库,git status 至少要做 10 万次 stat() 系统调用,外加遍历目录找未跟踪文件。
每次系统调用要跨一次用户态/内核态的边界,大约 100 纳秒起步,加上文件系统本身的开销,实际每次一两微秒。10 万次就是零点几秒——而这还是缓存全命中的理想情况。
在 macOS 上更糟(APFS 的 stat 比 Linux 慢),在网络文件系统或 Docker 挂载卷上则是灾难级的。
这也是为什么 node_modules 让人痛苦:它有几万个文件。哪怕被 ignore 了,Git 仍然要遍历目录才能知道该忽略它们——直到 Git 学会用 build/ 这种「整个跳过」的规则(见上面那个坑)。
想深入了解这一层的代价,可以读《独占》第 3 章那张系统调用的价目表。
Git 提供了几个应对手段:
# 让文件系统主动通知变化,不用逐个 stat(macOS / Windows 效果显著) $ git config core.fsmonitor true # 把索引也做成增量更新,减少重写整个 index 文件的开销 $ git config core.untrackedCache true $ git config feature.manyFiles true # 一次性开一组大仓库优化
索引最后那一段还有个彩蛋。回看 hexdump 的末尾:
00000090: 0009 6c69 622f 782e 7478 7400 5452 4545 ..lib/x.txt.TREE 000000a0: 0000 0035 0032 2031 0afe 7944 f851 50c6 ...5.2 1..yD.QP. 000000b0: 8ee7 948b fe6f 5449 cb57 ed59 1f6c 6962 .....oTI.W.Y.lib
看到 TREE 了吗?这是一个扩展段,叫 cache tree。
它缓存了「这张扁平表折成 tree 之后,每个目录对应哪个哈希」。有了它,git write-tree 就不用每次都重新计算整棵树——没动过的目录直接用缓存的哈希。
这就是为什么在一个几万文件的仓库里改一个文件,git commit 依然是瞬间的:它只需要重算那个文件所在的那条路径上的几棵树(第 5 章那个「5 个对象」)。
可以直接看这个缓存:
$ git ls-files --debug | head $ test-tool read-cache --table # Git 源码自带的调试工具
顺带一提:索引损坏是可以修的,因为它是纯派生数据——所有信息都能从 HEAD 和工作区重建:
$ rm .git/index $ git reset # 从 HEAD 重建索引
这是 Git 里少数几个「删了也没事」的文件。对象库删了就完了,索引删了 git reset 一下就回来。
上一章见过 git ls-files --stage 输出里那个 0:
100644 533ba6aa... 0 a.txt
↑ 这个
平时永远是 0,意思是「正常状态,只有一个版本」。
但合并冲突时,同一个路径会在索引里出现三条记录:
100644 aaaaaaa... 1 conflict.txt ← base(共同祖先那一版) 100644 bbbbbbb... 2 conflict.txt ← ours(你的 HEAD) 100644 ccccccc... 3 conflict.txt ← theirs(要合进来的)
这就是「冲突状态」在 Git 内部的真实样子——索引里同一个路径有三个版本并存,而工作区里是那个带 <<<<<<< 标记的文件。
而 git add conflict.txt 解决冲突的原理也就清楚了:它把 1/2/3 三条记录删掉,换成一条槽位 0 的记录,指向你编辑后的内容。「告诉 Git 我处理完了」在技术上就是这么回事。
这也解释了为什么冲突没解决完就不让你 commit:索引里还有非 0 槽位的记录,write-tree 根本没法折成一棵树——它不知道该用哪一版。第 17 章会把这套机制完整走一遍。
「我不小心把 node_modules 提交上去了,现在每次 status 都要转好几秒。」
两件事要分开做,顺序不能反:
第一步,把它从索引里摘掉(文件留在磁盘上):
$ echo "node_modules/" >> .gitignore $ git rm -r --cached node_modules/ $ git commit -m "停止跟踪 node_modules"
做完这一步,status 会立刻变快——因为索引里少了几万条记录,就少了几万次 stat()。
第二步(可选),清理历史体积。
注意上面那一步完全没有减小 .git——那些 blob 还被老提交指着(第 1 章、第 5 章)。真要缩,得改写历史:
$ git filter-repo --path node_modules --invert-paths
但要清楚代价:所有提交号都会变(第 4 章那个连锁反应),团队里每个人都得重新 clone。这是个需要先跟人商量的操作,不是一个技术决定。
这一章的一句话
.git/index 是一张扁平的路径表——完整路径、blob 哈希、外加一份用来快速判断「变了没有」的 stat 缓存,里面没有目录这个概念。这一个事实同时解释了空目录存不了(没文件就没条目)、.gitignore 对已跟踪文件失灵(已经在表里了)、以及大仓库 status 变慢(每个文件一次 stat())。
下一章:把三棵树和你每天敲的命令对起来,做成一张表——每条命令到底动了哪几棵树。那张表能解释掉一大半日常困惑,也会指出整个 Git 里唯一一条真的会让你丢东西的命令。