卷 I · 物CH 01深度 1/24

一个你以为你的东西

先做一个小实验,只要三十秒。它会推翻你对 Git 最基本的那个印象——而这本书剩下的二十三章,都是从这次推翻开始的。

快照 vs 差量内容寻址全书主线

先看一眼你熟悉的东西

你敲过一万次 git log -p。它给你看的是这个:

commit 1bada9988e4c142f884f13990c310e024c36f1ea
Author: Sakura <sakura@example.com>
Date:   Tue Nov 14 2023 +0800

    second

diff --git a/a.txt b/a.txt
index 3b18e51..533ba6a 100644
--- a/a.txt
+++ b/a.txt
@@ -1 +1 @@
-hello world
+hello world v2

红一行,绿一行。改了什么,清清楚楚。

于是几乎每个人都会得出同一个结论——Git 存的是「每次改了什么」。一个提交就是一包改动,历史就是这些改动一层层叠起来。要看某个版本,就从头把改动依次应用一遍。

这个理解太自然了,自然到你从来没想过要验证它。而且它还挺好用——大部分日常操作里,这么想也不会出错。

但它是错的。而且错得很关键:你后面所有关于 Git 的困惑,几乎都能追溯到这一个误解上。

三十秒的实验

Git 有一个命令能让你直接读对象库里的原始内容,绕过所有的美化和渲染:git cat-file -p

我们拿上面那个提交开刀。先看这个提交对象本身:

$ git cat-file -p 1bada99
tree fe7944f85150c68ee7948bfe6f5449cb57ed591f
parent 06b431f5f556d4b4d9b2f2fa4240e4d12fc6c975
author Sakura <sakura@example.com> 1700000060 +0800
committer Sakura <sakura@example.com> 1700000060 +0800

second

停一下,仔细看。

没有 diff。没有 +,没有 -,没有「改了哪一行」。一个字都没有。

整个提交对象只有四样东西:一个 tree、一个 parent、作者和时间、还有那句提交信息。就这些。

tree 是什么?接着看:

$ git cat-file -p fe7944f
100644 blob 533ba6aae425285855227a4a95c48e95553df491	a.txt
040000 tree 23e7965750dd0809412d15063e52eb3e49abe2d3	lib

这是一张完整的目录清单。仓库根目录下有什么,它就列什么——不是「改动过的文件」,是全部文件

再往下走一层:

$ git cat-file -p 533ba6a
hello world v2

那是 a.txt完整内容。不是「把 world 改成 world v2」这条指令,是这个文件在那一刻完整的样子

◆ 这本书的主线(第一句)

Git 不存 diff,存快照。

每一次提交,都指向那一刻整棵目录树的完整样子。不是「相对上次改了什么」,是「这一刻长什么样」。

你在 git log -p 里看到的那些红红绿绿,是 Git 拿两个快照当场算给你看的。算完就扔,不存。第 14 章会给你一台真的差分引擎,你会看到这个「算」有多便宜。

那不会爆炸吗

第一个反应总是这个。

假设一个项目有 5000 个文件、总共 200 MB。提交一千次,每次都存整棵树的完整快照——那不就是 200 GB?

如果 Git 是笨着实现的,确实会。但它不是。答案藏在一个你已经见过、只是没在意的细节里:

回去看那两条 tree 输出。第一次提交的树是这样的:

100644 blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad	a.txt
040000 tree 23e7965750dd0809412d15063e52eb3e49abe2d3	lib

第二次提交的树是这样的:

100644 blob 533ba6aae425285855227a4a95c48e95553df491	a.txt
040000 tree 23e7965750dd0809412d15063e52eb3e49abe2d3	lib

lib 那一行。两次的哈希一模一样:23e7965

这不是巧合,也不是 Git 做了什么聪明的「检测重复」。原因朴素得让人愣一下:

◆ 这本书的主线(第一句的下半句)

对象的名字,就是它内容的哈希。

lib 这个目录的内容没变 → 算出来的哈希就没变 → 它在对象库里就是同一个对象

Git 不需要去「检查有没有重复」。重复在它的地址系统里根本无法表达——两份相同的内容,天然就会落到同一个地址上。想存两次都做不到。

所以第二次提交在概念上是一张完整快照,在物理上只写了三个新对象:一个新的 a.txt blob、一棵新的根树、一个新的 commit。lib 那棵树和它底下的东西,一个字节都没有重写。

下面这台引擎会把这件事演给你看。它跑的是真的 SHA-1 和真的 git 对象格式,右边每一个哈希都是当场算的。

▶ 动手 · 两种模型存的东西不一样

左边是「你以为的」差量模型,右边是 Git 实际的快照模型。点一下 C2、C3、C4 分别看看,注意右边那个「直接复用的对象」的数字。

再注意最后一行统计:要拿到完整内容,两种模型要走的步数完全不同。

为什么这个区别值得较真

你可能会想:模型是快照还是差量,反正结果一样,有那么重要吗?

非常重要。因为这个选择直接决定了哪些操作快、哪些操作慢、哪些东西能救回来。四个直接推论:

推论一:取任何一个版本,都是一步到位

差量模型要还原第一千个版本,得从第一版开始把一千层改动依次叠加。越老的历史越慢,或者说,越新的历史越慢——取决于你从哪头存。

快照模型呢?提交对象里写着树的哈希,树里写着每个文件的哈希,直接去拿就行。第 1 个版本和第 10000 个版本,取出来一样快。

这就是为什么 git checkout 一个五年前的提交,和 checkout 昨天的提交,感觉上没有区别

推论二:判断「两个版本一不一样」,只要比一个字符串

两棵树的哈希相同 → 整棵目录树保证一模一样,不用比任何文件。哈希不同 → 一定有地方不一样。

这个性质会一路往下传:根树一样,说明所有子目录都一样;根树不一样,就往下找哪个子树不一样,没变的整棵子树直接跳过。第 3 章会讲这个结构叫 Merkle 树。

所以 git statusgit diff 在大仓库里能撑得住,靠的就是这个「一路剪枝」。

推论三:历史是只增不改的

对象的名字由内容决定,那就意味着——你没法「修改」一个对象。改了内容,它就变成另一个对象,有另一个名字,老的那个还原封不动躺在那儿。

这解释了两件看起来相反的事:

  • 为什么 Git 这么难弄丢东西:提交过的东西不会被改写,只会被「失去引用」。第 9 章的 reflog 就是靠这个把它们叫回来的。
  • 为什么删掉大文件仓库不会变小:新提交只是「一棵不包含它的树」,那个 200 MB 的 blob一个字节都没少,还被老提交指着呢。

推论四:rebase 之后哈希必然全变

提交对象里写着 parent。父提交换了 → 提交对象的内容变了 → 哈希必然变。而它的哈希一变,它的子提交里那行 parent 又变了……一路连锁到分支顶端。

这不是 Git 的什么设计选择,是「名字由内容决定」这条规则的必然后果。第 18 章会把这个链条完整走一遍。

⌗ 掀开 .git 看一眼

不用相信我说的。你现在就能在任何一个 Git 仓库里验证:

# 拿到 HEAD 那个提交的原始内容
git cat-file -p HEAD

# 它给你的第一行是 tree <哈希>,拿去看
git cat-file -p HEAD^{tree}

# 随便挑一个 blob 的哈希看看
git cat-file -p <那个哈希>

你会一路看到:commit → tree → blob,层层都是完整内容,没有一处 diff

再看一眼对象库长什么样:

ls .git/objects/
06  1b  23  3b  3e  53  62  fe  info  pack

那些两位十六进制的目录名,是对象哈希的前两位——Git 拿它当文件夹名,剩下 38 位当文件名,纯粹是为了避免一个目录里塞几十万个文件。这就是整个数据库的全部结构:一个用哈希当键的键值存储。

✎ 一处常见的抬杠,值得先说清

读到这儿,知道点内情的人会说:「不对啊,packfile 里明明是存 delta 的,Git 还是用了差量。」

这话没错,但它说的是另一层。请把这两层分开:

  • 数据模型层:提交指向完整的树,树指向完整的 blob。概念上永远是快照,没有例外。
  • 存储层:打包的时候,Git 发现两个 blob 很像,就把其中一个存成「基于另一个的一串指令」,省地方。

关键在于这一层对上面完全透明:你 cat-file 一个被 delta 化的对象,Git 当场帮你还原,你看到的永远是完整内容。而且 delta 的基准不一定是它的上一个版本,可能是任何一个长得像的对象,甚至可能是「后来」的版本。

这个顺序至关重要:先有快照模型,再有存储优化。反过来的系统(先有差量,再想办法加速)就得靠周期性打「完整快照」来救场,取一个版本永远是 O(叠加层数)。第 22 章有一台真的 delta 编码器,到时候再细说。

顺便:那第二句话

这本书的主线有两句。第一句你已经拿到了。第二句留到卷 II,但这里先透个底,因为它同样能解释掉一大堆困惑:

分支只是一张贴纸。

一个分支就是 .git/refs/heads/ 下的一个小文件,里面写着一个哈希。41 个字节——40 个十六进制字符加一个换行。

$ cat .git/refs/heads/main
1bada9988e4c142f884f13990c310e024c36f1ea
$ wc -c .git/refs/heads/main
      41 .git/refs/heads/main

建分支 = 新建一个文件写 41 个字节。切分支 = 改一下 HEAD 里那行字。reset = 把贴纸撕下来贴到别的提交上。

把这两句话摆在一起,Git 的形状就出来了:一座只增不改的对象库,加上一把随手可以撕来贴去的贴纸。剩下的全是细节。

↩ 回到你的仓库

「为什么我删掉了那个 500 MB 的模型文件,还提交了,.git 却一点没变小?」

这大概是新手最常撞上的一堵墙——尤其是不小心把 node_modules 或者一个数据集提交上去之后。

现在你能自己解释了:提交是快照,删除文件只不过是「下一张快照里没有它」。那个 500 MB 的 blob 还被之前每一个提交的树指着,只要那些提交还在,它就一个字节都不会少。

所以「删掉再提交」对仓库体积毫无帮助。真要清掉,只能改写历史git filter-repo),把所有提到它的树全部重建——而那会让改写点之后的每一个提交号都变(推论四),等于让所有协作者的本地仓库作废。

结论很实在:大文件最好一开始就别提交。这不是洁癖,是因为这个操作在 Git 的模型里近乎不可逆

这一章的一句话

Git 的对象库里一条 diff 都没有——每个提交指向一整棵完整的树,而对象的名字就是它内容的哈希,所以没改过的东西天然只存一份。你看到的 diff 是算出来的,你怕的分支是一张 41 字节的贴纸。

下一章:那四十个十六进制字符到底是怎么算出来的?我们会亲手算一遍,并且和你终端里的 git hash-object 逐字符核对。你会发现它比想象中简单得多,也会撞见第一个反直觉的细节。