bigywater.tech
Git 内部原理 · 03

Git gc 到底做了什么:一个文件真实改 31 次,松散对象是怎么被压缩、清理掉的

第一篇验证了对象怎么按内容哈希存,第二篇验证了删分支之后底层 commit 对象其实没删、只是变成"没人指的悬挂对象"——留了个尾巴:这些对象最终去哪了?真实对同一个文件连续改了 31 次、真实跑一遍 git gc,边跑边测。

496K → 36K
同一批提交,gc 前后 .git/objects 目录的真实磁盘占用
93 → 0
gc 前的松散对象文件数,gc 之后真实全部被打包进一个 pack 文件

前面两篇:Git 是怎么给内容去重的 · Git 分支的本质

思路:gc 做的是两件不同的事

一是把散落的对象打包压缩,二是真的删掉没人要的对象——很多人把这两件事混成一件。

git gc(garbage collect)名字里有"垃圾回收",容易让人以为它只干"删东西"这一件事,实际上它同时做两件相对独立的工作:repack——把 .git/objects 下大量零散的松散对象文件,压缩打包成一个(或几个).pack 文件,相同/相似内容之间用增量(delta)编码,不再各自独立存一份完整内容;prune——真正清除那些没有任何分支/tag/reflog 引用得到的悬挂对象。日常敲的 git gc 默认两件事都做,但触发的时机、宽限期都不一样,拆开看才看得清楚。

真实验证:一个文件改 31 次,gc 前后磁盘占用差多少

造一个真实场景:同一个 2000 行的文件,每次提交只改其中一行,像日常开发里"反复小改同一个文件"那样。

造数据:提交 0 是完整文件,之后 30 次每次只改一行 python3 -c " lines = [f'line {i}: some repetitive content padding padding padding' for i in range(2000)] open('big.txt','w').write('\n'.join(lines)+'\n') " git add -A && git commit -m "commit 0: initial big file" for i in $(seq 1 30); do python3 -c "..." # 把第 i 行替换成 'line $i CHANGED at commit $i' git add -A && git commit -m "commit $i: tweak one line" done
真实实测(gc 之前)
$ git log --oneline | wc -l
31

$ find .git/objects -type f -not -path '*/pack/*' | wc -l
93

$ du -sh .git/objects
496K

31 次提交,每次只改一行,却在 .git/objects 下真实留下了 93 个松散对象文件——每次提交都要为改动后的完整文件内容生成一个全新的 blob(内容变了,哈希必然变,这是第一篇验证过的内容寻址规则),外加对应的 tree、commit 对象。跑一次 git gc 之后:

真实实测(gc 之后)
$ git gc --prune=now

$ find .git/objects -type f -not -path '*/pack/*' | wc -l
0

$ ls .git/objects/pack/
pack-0435413ca0075ad79cec23f6b314beb4eca17728.idx
pack-0435413ca0075ad79cec23f6b314beb4eca17728.pack
pack-0435413ca0075ad79cec23f6b314beb4eca17728.rev

$ du -sh .git/objects
36K
93 个松散对象文件全部消失,变成了 3 个 pack 相关文件;.git/objects 目录从 496K 降到 36K,压掉了约 93%

93 个松散对象,每个都是独立的文件,文件系统给每个文件分配的最小磁盘空间(block size)往往比文件内容本身大得多——几十字节的小对象也要占掉一整个 block,这也是松散对象堆积时磁盘占用会显得格外"虚高"的原因之一。打包成一个 pack 文件之后,这部分文件系统层面的浪费也随之消失。

真实验证:pack 内部到底怎么存的,一个反直觉的细节

31 个几乎相同的大文件版本,pack 内部大概率不是各存一份完整内容——用 git verify-pack 真实看一眼。

真实实测
$ git verify-pack -v .git/objects/pack/*.idx | grep " blob "
281394fcd29a42ba0dc7f8ae70bbbfbffbff9037 blob   116377 5304 4687
c2488e5399044c1fd51a648a12feaa85812e6e2a blob   38 51 9991 1 281394f...
52e7aae199c941b2974f8094a855d8592c865db8 blob   51 63 10042 2 c2488e5...
bf8d4092aba3917ba38c727b92fb41ddfd94f6fd blob   24 36 10105 3 52e7aae...
...(后面 27 个 blob 都是同样的模式,size 只有 24~50 字节左右)

第一个 blob(281394f...)是完整存储的:116377 字节的原始内容,压缩后占 5304 字节。从第二个开始,每一行末尾多出两列——depth 和 base,说明这些 blob 不是完整存储,是相对某个"底本"(base)算出来的增量(delta)。但这里有个容易读错的地方:c2488e5... 这一行显示的 size 是 38——这不是这个 blob 还原出来的真实内容大小,是"把 base 变成这个版本所需要的增量指令"的大小。真实内容用 git cat-file 查一下:

真实实测
$ git cat-file -s c2488e5399044c1fd51a648a12feaa85812e6e2a
116321

$ git cat-file -p c2488e5399044c1fd51a648a12feaa85812e6e2a | wc -l
2000
verify-pack 显示 size=38,但这个 blob 真实还原出来是 116321 字节、2000 行的完整文件——38 字节的增量指令,加上 base 对象,就能完整重建出一份 116KB 的内容

这就是 delta 压缩的核心:只要新版本和某个已有对象足够相似,git 就不再存一份完整内容,只记录"跟 base 比,哪几个字节区间不一样、换成什么"——一行文本的改动,哪怕文件有 2000 行,增量也可能只有几十字节。31 个版本里,除了第一个是完整存储,后面全都是链式的 delta(depth 从 1 一路加到 17,后一个 delta 的 base 是前一个 delta 还原出来的结果)。

真实验证:悬挂对象在宽限期内能读,强制 gc 后真的没了

承接上一篇的结尾——删掉分支后底层 commit 对象没被删,那它最终是被谁、什么时候真正清理掉的。

造一个悬挂 commit:提交、切分支、删分支 git commit -m "commit 1" git checkout -b feature-y echo "v2" >> f.txt && git commit -am "commit on feature-y" # 记下这次的哈希 git checkout main git branch -D feature-y
真实实测
$ git cat-file -t <刚才那个哈希>
commit

# 真实清空 reflog + 立即触发 gc,绕开默认 2 周的宽限期
$ git reflog expire --expire=now --all
$ git gc --prune=now

$ git cat-file -t <同一个哈希>
fatal: git cat-file: could not get object info
同一个哈希,gc 之前还能读出类型是 commit,强制过期 reflog 并 gc 之后,直接报错读取失败——对象被真实清理掉了

git gc 默认不会立刻清理悬挂对象,是因为 reflog 会暂时"兜"住最近的操作记录,而且悬挂对象本身也有默认 2 周的最短保留期(gc.pruneExpire)——这段时间是留给"删错了想反悔"用的。真实操作里,git reflog expire --expire=now --all 主动清空了这层兜底,git gc --prune=now 又主动绕开了 2 周宽限期,两步都是刻意为之才会立即触发真删除;日常正常使用 git gc,这个宽限期都在默认生效,不会这么快把误删的东西弄没。

那为什么 clone 下来的仓库,.git/objects 里通常只有 pack 文件、没有一堆松散文件?服务端(比如 GitHub)定期在仓库上跑 gc/repack 打包压缩;git clone 走的是打包传输协议,直接把对方已经打好的 pack 文件(或者按需重新打包一份)传过来,本地也就直接是 pack 形态,不用自己从零攒一堆松散对象再手动压。

参考与说明

  • 本文所有对象数量、文件大小、verify-pack 输出均在本机真实 git gc/git verify-pack/git cat-file 运行后得到,没有编造任何数字。
  • 对象内容寻址存储参见第一篇;分支/引用的本质和悬挂对象的成因参见第二篇。
  • 没有涉及:delta base 具体怎么挑选(git 用什么策略判断"哪个对象适合当谁的 base")、pack.window/pack.depth 等压缩参数的调优、以及服务端 git gc 的自动触发时机——这些更偏工程调优,留给系列后面按需再写。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电
京ICP备2026067502号-1京公网安备11010502063487号