卷 I · 物CH 05深度 5/24

四种对象,一座数据库

blob、tree、commit、tag——Git 的对象只有这四种,你已经全见过了。这一章把它们拼起来,跑一遍真实的提交流程,然后数一数每次提交到底新增了几个对象。那个数字,就是第 1 章那个问题(「存快照不会爆炸吗」)的完整答案。

对象库去重可达性gc

全景图

先把四种对象的关系摆清楚:

对象存什么指向谁格式
BLOB文件的字节谁也不指(叶子)原始字节
TREE文件名 + 模式blob 和其他 tree二进制
COMMIT作者、时间、信息一棵 tree + 若干 parent纯文本
TAG标签名、标注者任意一个对象(通常是 commit)纯文本

注意这个方向:指针永远从「新」指向「旧」、从「外」指向「内」。commit 指向 tree,tree 指向 blob,commit 指向父 commit。

反过来的指针一个都没有。blob 不知道哪些树引用了它,提交不知道自己有没有子提交。这个单向性会在很多地方冒出来——第 15 章会专门讲它带来的后果。

◆ 一句话说清 Git 的存储

Git 的对象库就是一个键值数据库:键是内容的 SHA-1,值是内容本身。

它只支持两个操作:按哈希存按哈希取。没有更新,没有删除(gc 那是另一回事),没有查询语言,没有索引。

这么一个简陋到不能再简陋的东西,撑起了整个 Git。剩下的一切——分支、合并、变基、远程协作——都是在这个键值库上面加的一层薄薄的约定。

数一数

下面这台迷你 Git 是真的:真的算 SHA-1、真的按 git 的格式建对象、真的维护对象库。它算出来的提交号和真 git 完全一致——待会儿会给你核对的命令。

▶ 动手 · 迷你 Git

按顺序点 ① ② ③。每一步之后看那个「这一步新增」的数字。

第 ② 步是重点:只改了一个文件,看看新增了几个对象,又有几个是「复用」的

第 ③ 步更有意思:把文件改回原样,看看新增的里面还有没有 blob。

那三个数字

第二步的答案是 3:一个新 blob、一棵新根树、一个新 commit。

lib/ 那棵树和它底下的 blob,一个字节都没有重写——它们在列表里显示为「复用」。

把这个规律推广一下,就得到了一个很实用的估算:

◆ 一次提交到底写多少东西

假设你改了 1 个文件,这个文件在 3 层目录深处。那么这次提交新增:

  • 1 个 blob(改过的那个文件)
  • 3 棵 tree(那个文件所在的目录,以及它上面每一层,直到根)
  • 1 个 commit

一共 5 个对象。无论你的仓库有 100 个文件还是 10 万个文件,这个数字不变——它只取决于你改了多少文件、埋得多深。

这就是「存快照」不爆炸的完整答案:快照是概念上的,物理上只写变化的那条路径。而这一切不需要任何「增量检测」逻辑,纯粹是内容寻址的副产品。

注意 tree 的数量:改一个深处的文件,会重建它上面的每一层目录。这是 Merkle 树的必然代价(第 3 章)。所以一个目录层级很深的项目,tree 对象的数量会比你直觉中多得多——第 21 章会算这笔账,你会发现 tree 常常比 blob 还多。

第三步那个反直觉的结果

第 ③ 步把 a.txt 改回了第一版的内容。新增的对象里既没有 blob,也没有根树——只有一个 commit。

为什么?因为那份内容(hello world\n)和那棵根树,第一步就已经在库里了。内容一样 → 哈希一样 → 就是同一个对象。

这件事值得停下来想一想,因为它有个更强的表述:

◆ Git 没有「查重」这个步骤

大多数带去重的系统,是这么工作的:存之前先查一下有没有一样的,有就复用。去重是一个主动执行的动作。

Git 不是。它直接把内容写到「以哈希为名」的位置上。如果那个位置已经有东西了,那就已经有了——写不写都一样。

换句话说:在 Git 的地址系统里,「两份相同的内容」这个概念无法表达。你想让同一份内容存两次,做不到,因为它们会落到同一个地址。

这就是内容寻址的威力:去重不是一个功能,是一个不可能违反的性质。

顺带解释一个常见的困惑:为什么你 git checkout 回一个旧版本,Git 一点都不费劲?因为那个版本的每一个对象都还在库里,一个都没少。checkout 只是照着树把它们摆回工作区而已。

⌗ 掀开 .git 看一眼

自己复现书里那两个提交号。把时间戳和作者固定住,你会拿到和这本书一模一样的哈希:

$ mkdir /tmp/demo && cd /tmp/demo && git init -q -b main

$ export GIT_AUTHOR_NAME=Sakura GIT_AUTHOR_EMAIL=sakura@example.com
$ export GIT_COMMITTER_NAME=Sakura GIT_COMMITTER_EMAIL=sakura@example.com
$ export GIT_AUTHOR_DATE="1700000000 +0800" GIT_COMMITTER_DATE="1700000000 +0800"

$ printf 'hello world\n' > a.txt
$ mkdir lib && printf 'v1\n' > lib/x.txt
$ git add . && git commit -q -m first
$ git rev-parse HEAD
06b431f5f556d4b4d9b2f2fa4240e4d12fc6c975

和这本书里的一模一样。接着第二个提交:

$ printf 'hello world v2\n' > a.txt
$ export GIT_AUTHOR_DATE="1700000060 +0800" GIT_COMMITTER_DATE="1700000060 +0800"
$ git add . && git commit -q -m second
$ git rev-parse HEAD
1bada9988e4c142f884f13990c310e024c36f1ea

现在数一数对象,验证「新增 3 个」这个说法:

$ git rev-list --objects --all | wc -l

再直接看那棵被复用的树——两个提交里它的哈希是同一个

$ git cat-file -p HEAD^{tree} | grep lib
$ git cat-file -p HEAD~1^{tree} | grep lib
040000 tree 23e7965750dd0809412d15063e52eb3e49abe2d3	lib
040000 tree 23e7965750dd0809412d15063e52eb3e49abe2d3	lib

一模一样。去重就发生在这一行上。

可达性:Git 眼里的「还有用」

既然对象只增不减,那什么时候才能删?答案引出一个贯穿全书的概念。

可达(reachable):从任何一个「根」出发,顺着指针能走到的对象。

根有哪些?

  • 所有分支(refs/heads/*
  • 所有标签(refs/tags/*
  • 所有远程跟踪分支(refs/remotes/*
  • HEAD,以及各种 stash
  • reflog 里记录过的每一个位置(第 9 章的关键)

从这些根出发,commit → parent commit、commit → tree、tree → 子 tree、tree → blob,一路走下去。走得到的就是可达的,走不到的就是「悬空的」(dangling)。

只有悬空的对象才有资格被 git gc 回收——而且默认还要先躺够两周(reflog 里的记录默认保留 90 天)。

◆ 「删除」在 Git 里的真正含义

你删掉一个分支、reset 掉几个提交、rebase 重建了历史——没有任何对象被删除

发生的事只有一件:某些对象失去了通往它们的路径。

它们还在 .git/objects 里,字节一个不少,只是没有名字能叫出它们了。这就是第 9 章的 reflog 能把它们叫回来的原因——reflog 本身就是一条根,它记着 HEAD 去过的每一个位置。

把这句话记住,卷 II 和卷 IV 会轻松很多:在 Git 里,「弄丢」几乎总是「暂时找不到路」,而不是「不存在了」。

⚠ 这也是为什么误提交的密钥必须当作已泄露

你不小心把一个 API key 提交上去了,赶紧删掉、再提交一次。没用。

那个 blob 还在库里,被之前的提交指着,完全可达。任何人 clone 下来都能翻出来:

$ git log -p --all -S 'AKIA' 

就算你改写了历史(filter-repo)并强推,也只是在你的仓库里把它变成悬空对象——别人已经 clone 走的副本里,它还好好地待着。而且服务端(GitHub 之类)的缓存和 fork 里可能还有。

所以正确的处理只有一个:立刻吊销那个密钥。清理历史是善后,不是补救。这不是 Git 的缺陷——恰恰是它「历史不可篡改」这个核心保证的另一面。

✎ 顺便:Git 到底有多少种「引用」

为了后面几章不混淆,这里先把命名理清楚。所有引用都在 .git/refs/ 下:

  • refs/heads/main —— 本地分支。你能移动它。
  • refs/tags/v1.0 —— 标签。约定上不移动。
  • refs/remotes/origin/main —— 远程跟踪分支你不该手动动它,它由 fetch/push 维护(第 8 章)。
  • refs/stash —— stash 栈的栈顶。

HEAD 比较特殊,它不在 refs/ 里,直接躺在 .git/HEAD——因为它是「你现在在哪」,不是「某个东西在哪」(第 7 章)。

还有一个你可能见过的:.git/packed-refs。分支多了之后 Git 会把它们打包进一个文件省 inode,这时候 .git/refs/heads/ 里可能是空的——别慌,分支没丢,用 git show-ref 看就行。

↩ 回到你的仓库

「我的 .git 有 1.2 GB,工作区才 80 MB。这正常吗?」

先搞清楚它大在哪:

$ git count-objects -vH
$ git rev-list --objects --all \
    | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
    | sort -k3 -n -r | head -20

第二条命令会列出历史上最大的 20 个对象,通常真相就在里面——某次误提交的构建产物、数据集、或者一个被反复修改的大二进制文件。

三种情况,三种处理:

  • 只是很久没打包:跑一下 git gc,很可能立刻小一半(第 21 章)。
  • 历史上有大文件,现在删了gc 没用,它可达。只能改写历史,代价是所有提交号改变。
  • 项目本身就有很多二进制资源:这是 Git 的天然弱项——二进制文件做不了有效 delta(第 22 章),每个版本都近乎全量。这就是 Git LFS 存在的理由:把大文件替换成一个指针 blob,真正的内容存到别处。

这一章的一句话

Git 的对象库是一个只有「按哈希存 / 按哈希取」两个操作的键值数据库,里面只有四种对象。改一个三层深的文件,一次提交只新增 5 个对象——无论仓库多大。去重不是一个功能而是一个不可违反的性质,而「删除」在这里的真正含义只是「失去了通往它的路径」。

卷 I 结束。你现在手里有一座只增不改的对象库了。

下一卷讲另外半边:那些可变的、随手撕来贴去的东西——分支、HEAD、远程分支。它们是 Git 里唯一会「变」的部分,也是你所有恐惧的来源。而你会发现,它们加起来不到一百个字节