前面两篇:Git 是怎么给内容去重的 · Git 分支的本质
一是把散落的对象打包压缩,二是真的删掉没人要的对象——很多人把这两件事混成一件。
git gc(garbage collect)名字里有"垃圾回收",容易让人以为它只干"删东西"这一件事,实际上它同时做两件相对独立的工作:repack——把 .git/objects 下大量零散的松散对象文件,压缩打包成一个(或几个).pack 文件,相同/相似内容之间用增量(delta)编码,不再各自独立存一份完整内容;prune——真正清除那些没有任何分支/tag/reflog 引用得到的悬挂对象。日常敲的 git 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
$ 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 之后:
$ 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 36K93 个松散对象文件全部消失,变成了 3 个 pack 相关文件;.git/objects 目录从 496K 降到 36K,压掉了约 93%
93 个松散对象,每个都是独立的文件,文件系统给每个文件分配的最小磁盘空间(block size)往往比文件内容本身大得多——几十字节的小对象也要占掉一整个 block,这也是松散对象堆积时磁盘占用会显得格外"虚高"的原因之一。打包成一个 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 2000verify-pack 显示 size=38,但这个 blob 真实还原出来是 116321 字节、2000 行的完整文件——38 字节的增量指令,加上 base 对象,就能完整重建出一份 116KB 的内容
这就是 delta 压缩的核心:只要新版本和某个已有对象足够相似,git 就不再存一份完整内容,只记录"跟 base 比,哪几个字节区间不一样、换成什么"——一行文本的改动,哪怕文件有 2000 行,增量也可能只有几十字节。31 个版本里,除了第一个是完整存储,后面全都是链式的 delta(depth 从 1 一路加到 17,后一个 delta 的 base 是前一个 delta 还原出来的结果)。
承接上一篇的结尾——删掉分支后底层 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,这个宽限期都在默认生效,不会这么快把误删的东西弄没。