一次 git commit 之后,硬盘上到底多了什么

上周清理磁盘,顺手 du -sh 了一下博客仓库,.git 有 337MB。而我全部的源码、文章、图片加起来,工作区也才百来 MB。

更让我不爽的是:前几个月我误提交过一个几百 MB 的视频,后来删掉了,可 .git 的体积压根没变回去。这事我知道是"历史还在",但"历史还在"到底是什么意思?东西到底存在哪?凭什么删不掉?

用 git 十几年,我发现自己对它的理解和大部分人对冰箱制冷原理的理解差不多——知道结论,说不出过程。所以这次我干脆建了个测试仓库,把 .git 目录一层层拆开看。下面所有数字都是 git 2.47.3 在我机器上真跑出来的,你可以照着复现。

仓库货架上一排排贴着编号标签的纸箱,有的箱子还套在更大的箱子里

# 一、git add 那一瞬间:硬盘上多了个 26 字节的文件

从一个最干净的仓库开始:

$ git init gitdemo && cd gitdemo
$ printf 'hello git\n' > hello.txt
$ git add hello.txt
$ find .git/objects -type f
.git/objects/8d/0e41234f24b6da002d962a26c2495ea16a425f

git add 没碰任何数据库,没走任何服务。它就是往 .git/objects/ 下写了一个文件:前两位哈希当目录名,剩下 38 位当文件名。

这个文件是个 zlib 压缩包,解开后我愣了一下:

b'blob 10\x00hello git\n'

就三样东西:类型 blob、正文字节数 10、一个 \0、然后是文件内容本身。没有文件名,没有时间戳,什么元数据都没有。

更有意思的是大小关系:正文 10 字节,压缩后落盘的文件是 26 字节。压缩包比原文还大,zlib 的头部开销完胜这点内容。git 根本不在乎,照样存。

那个哈希是怎么算出来的?就是对"类型 + 长度 + \0 + 内容"这串字节做一次 SHA-1,git hash-object hello.txt 输出的值和目录名一字不差。这叫内容寻址:key 是由内容本身算出来的。

还有一个不起眼但很说明问题的细节,ls -l 看一下那个对象文件:

-r--r--r-- 1 bougie bougie 26 ... hello blob

444,只读。连仓库主人自己都没权限改。整个对象库的设计就是只追加、不修改——你永远只是往里加新对象,旧的一个字都不动。

# 内容寻址送你两件礼物

第一件是去重免费。我复制一份内容完全相同的文件再 add:

$ cp hello.txt hello-copy.txt
$ git add hello-copy.txt
$ git ls-files -s
100644 8d0e41234f... 0	hello-copy.txt
100644 8d0e41234f... 0	hello.txt

两行记录指向同一个 blob,.git/objects 下还是原来那几个文件。一千个文件内容相同也只存一份,不需要任何后台任务,寻址方式天然带这个性质。

第二件礼物就没那么愉快了:只要 add 过一次,内容就永久躺在 .git 里。我不小心 git add .env(里面有密钥),哪怕下一秒删掉重新 add,前一个 blob 还在硬盘上——rm 工作区文件对它毫无影响。后面讲 gc 的时候你会看到,它要等很久才会被清掉,而且清掉之前,任何一个知道这个哈希的人都能把它读出来。

一只手把一叠贴了标签的文件投进收纳箱

# 二、文件名不在文件里:tree 和 commit

blob 是"没有名字的内容",那文件名记在哪?答案是另一种对象。提交一次再看:

$ git commit -m first
$ find .git/objects -type f
.git/objects/07/ed5a7a...   ← tree
.git/objects/81/06ccba...   ← commit
.git/objects/8d/0e4123...   ← blob(同一个)

用 cat-file -p 把 tree 打开:

100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f	hello.txt

一行 = 一条权限 + 类型 + 哈希 + 文件名。我用 od 看过它的原始字节,就是 100644 hello.txt\0 后面直接跟 20 字节二进制哈希,紧凑到近乎抠门。

这个结构带来的结论比它本身重要:

改名不动内容。 git mv hello.txt hi.txt 之后 blob 还是 8d0e4123... 一个字节都没变,变的只是 tree 里的那一行。这也解释了一件事——git 的 rename 检测为什么是"猜"的:历史上根本没有"改名"这个记录,只是后来 git log 时发现"咦,这个新文件内容和某个老文件 90% 相同",才报给你说是 rename。文件名和内容从一开始就活在两个对象里。

元数据少得可怜。 tree 里的 mode 只有五种:100644、100755、120000(符号链接)、160000(子模块指针)、040000(目录)。没有 mtime、没有属主、没有 inode。你在一台机器上 commit,在另一台机器上 checkout,文件的修改时间全部变成 checkout 那一刻——不是 bug,是这些信息压根没存。

commit 对象则是把一切串起来的那根线,原文就这么几行:

tree 07ed5a7aebb914e3a02edf6d622b82d364037e3c
author Bougie <b@b.com> 1790881361 +0800
committer Bougie <b@b.com> 1790881361 +0800

first

注意 author 和 committer 是两个独立字段。我以前 rebase 完看 git log 一堆时间没对上,还以为坏了——git amend 改的是 committer,原作者信息留在 author 里不动,这是特性不是事故。

连 tag 都有讲究。git tag v1 -m "release note" 创建的附注标签也是个对象:

object 8106ccba33a49ac8b5ddc081488d4e8d1b2180db
type commit
tag v1
tagger Bougie <b@b.com> 1790881510 +0800

release note

它自己有哈希、可以被签名,这是轻量标签(一个指向 commit 的指针)做不到的。

晾衣绳上夹着一排文件夹,一条虚线把它们按顺序串起来

最后看一眼"分支"是什么:

$ cat .git/HEAD
ref: refs/heads/master
$ cat .git/refs/heads/master
8106ccba33a49ac8b5ddc081488d4e8d1b2180db

一个分支就是一个 41 字节的纯文本文件,内容是一个哈希。没了。所以"git 开分支很便宜"不是营销话术,是字面意义上的便宜:git branch 就是在写一个 41 字节的文件,比创建一个空目录还轻。

# 三、gc 那一晚:744KB 压到 26KB

前面的一切都还只是"结构优雅"。真正让我看傻的是打包。

我做了个实验:生成一份 3000 行、约 174KB 的文档,每次随机改 3 行就提交一次,连提 31 次。31 个版本,摊开来是 5.26MB 的正文。这时候 .git/objects 里有 93 个松散对象(31 个 commit + 31 个 tree + 31 个 blob),实际落盘 486KB——松散对象逐个 zlib 压缩,已经省了一轮。

然后我跑了 git gc:

93 objects → 1 packfile
pack  23084 字节
idx    3676 字节
.rev    424 字节

26KB。 5.26MB 的历史,压到 26KB,百分之一都不到。

zlib 干不了这个——每个版本都是独立全文,zlib 顶多把每个版本再省一点。真正的杀招是 delta 压缩:把相似的 31 个版本排序,选一个存全文,其余的存"和上一个版本差在哪"。

用 git verify-pack -v 把这个 pack 剖开看(挑了几行):

对象 类型 原始大小 包内大小 delta 链深
637ca2cf blob 177,779 15,242 0(存全文)
b2de177d blob 144 94 1 → 基于 637ca2cf
9ccba9f8 blob 86 72 2 → 基于 b2de177d
…… ……
3cd94488 blob 88 78 30 → 基于 b089d1a1

基对象是 v0,存全文 15KB;v1 到 v30 每个只有 70~100 字节——因为每次只改 3 行,一个版本相对上一个版本的差异就只有那几行。串成一条 30 层的 delta 链。

# 一个我没想到的细节

自然的设计应该是"最新的版本存全文,越老的版本挂链上"——毕竟大家 99% 的时间都在 checkout 最新代码。但我核对了哈希:链尾挂的恰恰是 HEAD 指向的最新版本 v30,存全文的是最老的 v0。也就是说在这个仓库里,检出 HEAD 要把 30 层 delta 从头解一遍。

我以为是 --aggressive 的锅,又用默认参数 git gc 重跑了一遍对照组:结果一样,pack 体积一个字节都不差。Git 挑 delta 基对象主要看大小和相似度,并不承诺把最新版本放链头。实际项目里版本差异没这么极端、还有各种索引兜底,所以平时感觉不到,但原理上,"读最新版本"在这个格式里并不是免费的。

git gc 后 .git/objects/pack/ 里的三件套也值得认识一下:

  • .pack——对象本体,一锅端;
  • .idx——索引,内部是一张按哈希前缀分桶的 fanout 表,二分查找 O(log n),还带 CRC32 校验;
  • .rev——反向索引(从包内偏移映射回对象),Git 2.41 起默认生成,专门给"按位置扫包"的场景提速。

这些全是给"解 delta 链"的读放大擦屁股的。再往后你见到的 multi-pack-index、commit-graph、bitmap index,一代代加速层,都是在给同一个根本矛盾打补丁:存储压到极致,读就要付出重建的代价。

顺带回答一个经常有人问的问题:为什么图片仓库的 .git 特别大?因为 JPEG、PNG 本身就是压缩格式,字节近乎随机,两个相似图片之间找不出有意义的 delta,zlib 也挤不出水分——git 对它们几乎只能原样存一份。源代码是 delta 压缩的主场,二进制是它的地狱。

散落的纸片经过挤压变成一块致密的方块

一排多米诺骨牌,箭头沿着队伍向前传递

# 四、.git 为什么只增不减

现在回到开头那个 337MB。先看我自己博客仓库的体检报告:

$ git count-objects -vH
count: 913          ← 913 个松散对象,42.11 MiB
in-pack: 8409       ← 已打包对象,162.42 MiB

那 913 个松散对象基本都是最近几周文章里的配图——每次 git add 一张图,就是一个几十到几百 KB 的新 blob 躺在 .git/objects 里等 gc。337MB 的构成是:objects 206MB,加上 .git/modules 里 dist 子模块自己的 131MB。

git 删除一个对象只有一条路:它不可达,并且过了期。所谓可达,是从某些"根"出发顺着指针能走到。根有四类:所有 refs(分支、标签)、HEAD、当前 index、还有 reflog。

reflog 是这里面的关键,也是最容易被忽略的一个。它记录每条引用的本地移动历史,.git/logs/ 下就是个纯文本追加日志。我做了个实验:

$ echo 'important work' > Rescue.md
$ git add . && git commit -m 'the one I will lose'
$ git reset --hard HEAD~1        ← "删掉"了它
$ git log --oneline | head -1    ← 历史里确实没了

$ git cat-file -t 422da9b8
commit                           ← 但对象还好好的
$ git fsck --lost-found
悬空 commit 422da9b84056589836fe92f0fd29f9d878b4a7de

reset --hard 没有删除任何东西,只是把分支指针往回挪了一格。那个 commit 变成"悬空"——从任何 ref 都走不到了,但 reflog 还记得它,所以它可达,所以 gc 不会动它。git reflog + git reset --hard HEAD@{1} 就能救回来。这就是"git 里几乎没有真删除"的机制基础。

但注意 reflog 是纯本地的,clone 的时候不传播。所以救援只能在丢东西的那台机器上做,推到远端再 clone 下来的仓库里,那些"废墟"已经不在了。

默认的过期时间:可达对象的 reflog 保 90 天,不可达的保 30 天,松散对象要等 2 周后才会被 prune。松散对象攒到 6700 个左右会触发一次自动 gc。把这套规则连起来,"删了大文件 .git 不变小"的完整答案就出来了:那个 blob 现在挂在历史提交的树上,历史提交被你的分支和 tag 达着,而且还有 reflog 兜底。要让它真的消失,得用 git filter-repo(或者 BFG)重写历史,把那个文件从每一个提交里抠出来,再强推所有分支——动静很大,也是为什么大家宁可让 .git 胖着。

对读多写少的大型仓库,倒有个温和得多的办法:git clone --filter=blob:none 的 partial clone,先不下载文件内容,checkout 到哪个 blob 再按需拉哪个。配合 --depth 1 的浅克隆,几百 GB 的仓库也能几分钟内可用。这是把"存储按内容寻址"的红利反向利用——既然 key 是哈希,那我可以只拿我要的哈希。

多说一句内容寻址的隐含前提:SHA-1 不会撞。Google 2017 年搞出的 shattered 碰撞给整个行业提了个醒,Git 随后加了对已知碰撞样本的检测,也早就支持 git init --object-format=sha256 建 SHA-256 仓库。只是生态(各种托管平台、工具链)还没跟上,这属于"理论上已修好、现实中再等等"的状态。

# 五、拆完之后,我的 git 命令清单变了

黑盒拆开之后,有几个命令的地位在我心里直接上升:

# 体检:对象数量、体积,一眼看出仓库胖在哪
git count-objects -vH

# 找出 pack 里最大的对象(按原始大小排序)
git verify-pack -v .git/objects/pack/*.idx | sort -k3 -n -r | head

# 遍历所有历史提交找大文件的真身
git rev-list --objects --all | grep <hash前缀>

# 直接读任何对象的内容,不需要 checkout
git cat-file -p <hash>        # -t 看类型,-s 看大小

# 误操作之后的第一反应不再是手脚冰凉
git reflog
git fsck --lost-found

也有几个习惯从此变了。以前我看到 .git 体积异常的第一反应是上网搜"git 仓库清理",照着命令背;现在我会先 count-objects 看是松散对象没 gc(正常,等等就行),还是 pack 里真的藏着大 blob(要么 filter-repo,要么认了)。

这次拆解最大的感受倒不是学到多少冷知识,而是 git 的设计比我以为的简单得多:一个只追加的 KV 存储,key 是内容的哈希;blob 存内容、tree 存名字、commit 串历史,剩下的一切——分支、tag、reflog、打包、加速索引——全都是围绕这个 KV 库的指针和缓存策略。

2005 年林纳斯花几天写出来的东西,二十年里核心格式几乎没动过,靠的不是先见之明,是把模型压得足够简单。删东西需要理由,存东西不需要——这种在分布式系统论文里被反复论证的设计,git 用一个目录名 objects 就讲完了。