一次 git commit 之后,硬盘上到底多了什么
- 作者:Bougie
- 创建于:2026-10-02
上周清理磁盘,顺手 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 就讲完了。