卷 III · 三棵树CH 11深度 11/24

暂存区到底是个什么东西

上一章说索引是「一份清单」。这一章把那个文件真的打开——它的结构简单到有点朴素,而正是这份朴素,解释了 Git 好几个出了名的怪癖

.git/index扁平路径表空目录stat 缓存

先看字节

$ 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
▶ 动手 · index 真结构

加几个更深的文件试试。注意无论嵌套多深,条目数只跟文件数有关,目录数永远是 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/
⚠ 另一个常被误解的 ignore 规则

如果一个目录被整个忽略了,你没法用规则「反忽略」它里面的东西。

# 这样不管用:
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 的工作流程是:

  1. 对索引里的每一条记录,对磁盘上那个文件做一次 stat()
  2. 把 mtime、size、inode 这些跟索引里存的比
  3. 全都一样 → 直接认定「没变」,连文件都不打开
  4. 有不一样的 → 才真的读文件、算哈希、比对

这个优化很关键:读一个文件算 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    # 一次性开一组大仓库优化
⌗ 掀开 .git 看一眼

索引最后那一段还有个彩蛋。回看 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 里唯一一条真的会让你丢东西的命令