卷 I · 物CH 03深度 3/24

树:目录是怎么被存下来的

上一章那个 blob 里只有 hello world 五个字。它不知道自己叫 a.txt,也不知道自己在哪个目录下。那些信息存在别处——存在 tree 对象里。这一章会让你看到「改一个字节,一路往上每一层的名字都会变」这件事,以及它为什么如此有用。

tree 对象Merkle 树文件模式排序规则

blob 里没有名字

再确认一次这件事,因为它比想象中重要:

$ git cat-file -p 3b18e51
hello world

就这些。没有文件名,没有路径,没有权限,没有修改时间。一个 blob 就是一坨字节,仅此而已。

这是个刻意的设计。因为「内容」和「这份内容叫什么名字、放在哪儿」是两件独立的事——同一份内容可以叫不同的名字、出现在不同的目录、甚至同时出现在好几个地方。把它们分开存,同一份内容就只需要存一次。

那名字存在哪儿?tree 对象里。

tree 长什么样

$ git cat-file -p HEAD^{tree}
100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	a.txt
040000 tree 23e7965750dd0809412d15063e52eb3e49abe2d3	lib

一张目录清单。每行四列:模式、类型、哈希、名字

注意第二行——一个 tree 里可以指向另一个 tree。这就是子目录。整棵目录结构就这么递归地搭起来了:树指向树,树指向 blob,一层套一层。

但要小心:上面那个输出是 cat-file -p 美化过的,不是磁盘上真正的样子。真实格式要古怪得多。

真实的字节

tree 是四种对象里唯一的二进制格式(另外三种都是纯文本)。它的每一条记录长这样:

<模式> <名字>\0<20 字节的裸 SHA>

条目一条接一条,中间没有任何分隔符,也没有换行。解析全靠「读到 NUL 就是名字结束,然后固定读 20 个字节」。

三个容易被坑到的点:

  • 哈希是 20 字节的裸二进制,不是你看到的那 40 个十六进制字符。所以 tree 对象直接 cat 出来是乱码。
  • 模式没有前导零。目录是 40000,不是 040000——cat-file -p 显示时会补一个零让它对齐,但存的时候没有。这一个字符的差别,会让你算出的哈希和真 git 对不上。
  • 类型不存。你在美化输出里看到的 blobtree,是 Git 从模式推断出来的(40000 就是树,其余是文件),并不在字节里。

下面这台构造器就是照着这个格式真的拼字节、真的算哈希的。

▶ 动手 · tree 构造器

底下两个输入框可以直接改。先只改 lib/x.txt 的内容,盯着三个哈希看——你会看到它们一起变

然后试试只改文件名,不改内容:blob 的哈希纹丝不动,但树变了。

Merkle 树:一路往上传染

刚才那个「三个哈希一起变」不是巧合,是这个结构的核心性质。

想想为什么:lib/x.txt 的内容变了 → 它的 blob 哈希变了 → lib 这棵树的内容里写着那个哈希,所以树的内容变了 → 树的哈希变了 → 根树的内容里写着 lib 的哈希,所以根树也变了。

一路传染到顶。

这种「每个节点的哈希由它所有子节点的哈希决定」的结构,叫 Merkle 树。比特币、IPFS、Certificate Transparency 用的都是同一个东西。

◆ Merkle 树给你的两个方向

往上看:一个哈希验证一整棵树。

根树的哈希相同 → 整棵目录树保证一模一样,每个文件的每个字节都一样。不用比任何别的东西。

这就是为什么提交对象里只需要写一行 tree <哈希>,就锁定了那一刻项目的完整状态。

往下看:找不同可以剪枝。

两个根树哈希不同,就比它们的子条目:哈希相同的整棵子树直接跳过,只往哈希不同的那条路径钻下去。

一个有十万个文件的仓库,改了一个文件,比较两个版本只需要走那一条路径——大概几层、十几次比较。而不是十万次。

这个剪枝就是 git diffgit statusgit merge 在大仓库里还能保持响应的原因。它们做的第一件事永远是「两棵树的哈希一样吗?一样就整个跳过」。

那些模式数字

tree 里的模式看起来像 Unix 权限位,但 Git 只认几个固定值

模式含义说明
100644普通文件绝大多数文件都是这个
100755可执行文件Git 只记录「可不可执行」这一个位
120000符号链接blob 的内容就是链接指向的路径字符串
40000目录(子树)注意没有前导零
160000submodule指向另一个仓库的提交,那个对象根本不在本仓库里

这张表短得有点意外,但它说明了一件事:Git 不是备份工具。

不记录:文件的读写权限细节(644640 在 Git 眼里一样)、属主和属组、修改时间、扩展属性、ACL。

为什么?因为 Git 关心的是源代码的内容,而这些元数据在不同机器上本来就不该一致。硬要记录,只会让每次 clone 都产生一堆无意义的差异。

⚠ 这个设计真的会咬人

「我在 CI 里给脚本加了执行权限,怎么下次又没了?」

因为 chmod +x 之后你得提交这个改动——它是 tree 里模式那一列的变化,Git 会认(100644100755),但你不 commit 它就不会跟着走。

反过来,chmod 640 这种改动 Git 完全看不见,因为它只存「可不可执行」这一个位。

另外在 Windows 上,文件系统压根没有执行位的概念,Git 会按 core.fileMode 的配置来处理——这就是为什么跨平台仓库里经常出现整批文件模式莫名其妙变来变去的假改动。遇到了就 git config core.fileMode false

那条会让你算错的排序规则

tree 里的条目必须排序,而且规则很讲究——排错了,算出来的哈希就和真 git 对不上。

规则是:按名字的字节序排,但目录参与比较时,末尾要当作带一个 /

举个会出错的例子。假设有一个文件叫 lib.txt,和一个目录叫 lib

# 如果按朴素的字节序排(错的):
lib          ← 'lib' < 'lib.txt',所以目录在前
lib.txt

# git 实际的排法(对的):
lib.txt      ← 比较的是 'lib.txt' vs 'lib/',而 '.' (0x2E) < '/' (0x2F)
lib

顺序反了。因为 . 的字节值是 0x2E/0x2F,所以 lib.txt 排在 lib/ 前面。

这个规则听起来像个古怪的历史包袱,但它有实际用处:它让树的遍历顺序和「把路径当整体排序」的结果一致,这样 .git/index(第 11 章那张扁平表,按完整路径排序)和 tree 的遍历就能对上,比较起来不用来回跳。

本书那台构造器里就照抄了这条规则——不然它算出来的哈希不会等于 3ec3615,这本书里所有跟真 git 对得上的宣称就全都作废了。

⌗ 掀开 .git 看一眼

亲眼看看 tree 的裸字节。cat-file 有一个不带美化的模式:

# -p 是美化,换成 blob 类型强制读原始字节
$ git cat-file blob $(git rev-parse HEAD^{tree}) | xxd | head

或者直接解压对象文件:

$ python3 -c "import zlib,sys; sys.stdout.buffer.write(zlib.decompress(
    open('.git/objects/3e/c3615098e7d29007e5639f4c25747dbabe2da0','rb').read()))" | xxd
00000000: 7472 6565 2036 3300 3130 3036 3434 2061  tree 63.100644 a
00000010: 2e74 7874 003b 18e5 12db a79e 4c83 00dd  .txt.;......L...
00000020: 08ae b37f 8e72 8b8d ad34 3030 3030 206c  .....r...40000 l
00000030: 6962 0023 e796 5750 dd08 0941 2d15 063e  ib.#..WP...A-..>
00000040: 52eb 3e49 abe2 d3                        R.>I...

逐段读一遍,全部对上:

  • tree 63 + NUL —— 对象头,内容 63 字节
  • 100644 a.txt + NUL + 3b18e512…(20 字节裸二进制)
  • 40000 lib + NUL + 23e79657…(20 字节)

注意 40000 那里确实没有前导零,和前面说的一致。

✎ 顺便解答:Git 到底记不记录重命名

不记录。这是个让很多人意外的事实。

git mv a.txt b.txt 之后,对象库里发生的事是:blob 完全没变(内容没变,哈希没变),只是新的 tree 里那一行的名字不同了。

git log --follow 和 diff 里那句 rename from … to … 是哪来的?现场推断的。Git 比较两棵树,发现「这边少了个文件、那边多了个文件,而且内容相似度超过 50%」,就报告这是一次重命名。

这解释了两个现象:

  • 改名的同时大改内容,Git 就认不出来了(相似度不够),diff 里会显示成「删了一个、加了一个」。想让它认出来可以调 -M 的阈值。
  • 重命名冲突特别难缠(第 17 章):因为参与合并的双方各自「推断」出的重命名可能都不一样,Git 得在两个推断结果之间做调和。

为什么不干脆记录下来?因为记录了就得信任它,而重命名信息在合并、变基、cherry-pick 里会不断被重新组合,越传越不可靠。每次现场重算,反而永远是对的。这是个很 Git 的取舍:宁可多算一点,也不存可能过期的元信息。

↩ 回到你的仓库

「我和同事改了完全不同的两个文件,为什么还会冲突?」

如果你俩改的是普通的文件内容,确实不会冲突——那是两个不同的 blob,两棵树里两行不同的记录,各取各的。

但如果你俩都动了同一个目录的结构——比如你把 utils/a.js 改名成 utils/helper.js,同事在 utils/ 里加了个新文件——那么utils 这棵 tree 对象两边都改了

冲突发生在树这一层,不是文件内容那一层。Git 报出来的会是 CONFLICT (rename/add) 之类你可能没见过的类型。

一旦你把「目录也是对象、也会被修改、也会冲突」这件事装进脑子,这类怪事就不再意外了。目录不是文件的容器,它本身就是一份被版本管理的数据。

这一章的一句话

文件名和目录结构存在 tree 对象里,格式是「模式 + 名字 + NUL + 20 字节裸 SHA」紧挨着排。树指向树、树指向 blob,构成一棵 Merkle 树——底下动一个字节,一路往上每层的名字都会变;反过来,根树的哈希一样,就保证整棵目录一模一样。

下一章:树锁定了「项目此刻长什么样」,但它不知道这是谁在什么时候做的,也不知道上一版是什么。那些信息在 commit 对象里——而它简单得让人失望,只有几行纯文本。但正是那几行文本,藏着 rebase 让所有哈希全变的原因。