删了 1.2GB,.git 仍是 1.2GB
结论:git rm 删的是工作区和最新一次提交,删不掉历史里的对象。清理、gc --aggressive、清 reflog 都动不了它,只有重写历史、再删掉 refs/original 才会真正变小。
三条可核对的事实(本次单次测量,环境 macOS 27.2、git 2.54.0):
- 提交 4 个 300MiB 文件后
.git是 1,258,703,097 字节;git rm并提交删除后是 1,258,703,335 字节,多了 238 字节。 git gc --prune=now --aggressive跑了 45.1 秒,.git是 1,258,707,612 字节,没变小。- 重写历史并删掉
.git/refs/original后再gc,.git变成 30,968 字节,其中 pack 只有 1.82 KiB;这一步的 gc 耗时 0.054 秒。
测试数据是合成的:/dev/urandom 生成 4 个 300MiB 文件,共 1,258,291,200 字节,不是从真实项目里拿的。随机字节几乎不可压缩,所以下面的体积基本等于原始字节数。
先看体积是怎么一步步卡住的
| 步骤 | 做了什么 | .git 字节数 | 相对上一步 |
|---|---|---|---|
| 0 | 空仓库,一次 README/src 提交 | 28,163 | --- |
| 1 | 提交 4×300MiB | 1,258,703,097 | +1.26GB |
| 2 | git rm -r data 并提交 |
1,258,703,335 | +238 B |
| 3 | gc --prune=now --aggressive(45.1s) |
1,258,707,612 | +4,277 B |
| 4 | 清 reflog + gc --prune=now(2.0s) |
1,258,706,726 | −886 B |
| 5 | filter-branch 重写历史(0.29s) |
1,258,707,244 | +518 B |
| 6 | 删 refs/original + 清 reflog + gc(0.05s) |
30,968 | −1.26GB |
本文的体积按 find .git -type f 后对文件字节数求和统计,不用 du。du -sh 因块对齐会显示更大------最终这一步 du 是 118,784 B(116 KiB),和 30,968 B 差近 4 倍。
第 3、4 步的几百到几千字节波动是重新打包造成的噪声,不是省下来的空间。真正的跳变只有第 6 步。
删除只是「新增了一个状态」
Git 存的是对象图,不是文件夹快照。一次提交指向一棵树,树指向文件内容(blob),提交再指向上一个提交。删文件的那次提交,只是让新树的这个位置空出来,父提交的树仍然指着 4 个 blob:
text
c1 init ──► c2 加 data/ ──► c3 删 data/ ← main
tree 里没有 data
c2 的 tree 里仍有 4 个 blob(各 314,572,800 B)
git gc 判断能不能删,看的是「从 refs、index、reflog 出发还能不能走到」。c3 是 main 的最新提交,它的父提交是 c2,c2 的树能走到那 4 个 blob,所以它们全都算可达,一个都不能删。你当前工作区里有没有这些文件,gc 根本不看。
这也解释了第 2 步为什么反而变大:这次删除新增了 commit 和树对象(loose 对象从 12 增到 15),.git 净增 238 字节(压缩后),不含重打包差异。删东西在 Git 里是「加一个删除记录」,不是「减数据」。
两种膨胀,处理方式不同
同样叫「仓库大」,有两种情况:
大文件还在最新提交里。 比如现在还在用的模型权重、测试视频。这种情况下重写历史没用,因为最新那棵树本身就必须引用它;要减体积只能把它移出仓库(LFS、制品库、下载脚本),或者接受它。
大文件已经从代码里删了,但历史里还有。 本文测的就是这种。工作区干净、ls 看不到、git log 也只剩一句「remove dataset」,但它还在每一个旧提交里躺着。绝大多数「删了怎么没小」的疑问都属于这一类。
判断方法很简单:git ls-tree -r HEAD 里有没有它。有,就是第一种;没有,就是第二种。
gc --aggressive 为什么白跑 45 秒
--aggressive 只是把重新打包做得更彻底:更大的 delta 窗口、更慢的压缩。它不会改变可达性判断,所以 1.2GB 的 blob 该留还是留。45.1 秒花在重新打包上------按 gc 的工作方式推测,大头是对这 1.2GB 不可压数据重算对象、重新压缩;我没做 profile,拆不出各项占比。
顺带一提 --prune=now:不加它,gc 默认只清理两周以前就不可达的对象(gc.pruneExpire)。很多人跑完 git gc 觉得「怎么没变」,一部分原因是对象还新,另一部分原因是它们根本没不可达。
reflog 不是垃圾桶
reflog 记的是 HEAD 和分支指针的移动历史。清 reflog 只删这些指针记录,不删对象本身;只要提交还挂在 main 上,清完之后从 main 照样能走到所有旧树和旧 blob。
所以第 4 步 expiring 整个 reflog 只换来 886 字节的负增长,那是清 reflog 和 gc 重打包两步的净差,拆不开,量级可以忽略,不是「清掉了 886 字节垃圾」。想让 reflog 帮上忙,前提是这些提交已经不被任何分支引用------比如刚 reset 掉的提交,那才是 reflog 会短暂保住的对象。
先花不到 1 秒看清是谁在占地方
不用急着重写,先列出历史里最大的对象:
bash
git rev-list --objects --all |
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
awk '$1=="blob" {print $3, $4}' | sort -nr | head -20
【实测】在这个 1.2GB 的仓库上,整条命令几乎瞬时跑完(这次没单独计时,远小于 1 秒),输出四行,每行 314,572,800 字节,对应 data/blob1.bin 到 blob4.bin。这么快是因为它只读 pack 索引里的元数据,不解压内容。也就是说「找出元凶」几乎不花时间,慢的是后面的重打包(45 秒)和真正的历史重写。
重写历史:0.29 秒跑完,但体积没动
真正有效的是把 data/ 从每一次提交里摘掉,让历史里不再有指向这些 blob 的树。我用的是 git 自带的 filter-branch:
bash
git filter-branch --force \
--index-filter 'git rm -r --cached --ignore-unmatch data' \
--prune-empty -- --all
3 个提交、0.29 秒,后两个提交的哈希变了,init 因为树没改,哈希保持不变;data/ 从所有树里消失。但 .git 是 1,258,707,244 字节,比之前还多 518 字节。
顺带一个可观察的结果:--prune-empty 把重写后变空或与上一条完全相同的提交去掉,原来的 3 个提交最后只剩 2 条:init 和 remove dataset(后者动了 README,所以留下来了)。
原因是 filter-branch 默认在 refs/original/refs/heads/main 留一份旧分支的备份,怕你改错了回不去。这个 ref 仍然指向旧提交,旧提交仍然能走到那 4 个 blob------在 gc 眼里它们依旧可达。第 5 步的量测就是这个状态。
补上这一步才结束:
bash
rm -rf .git/refs/original
git reflog expire --expire=now --all
git gc --prune=now
reflog expire 0.008 秒 + gc 0.054 秒,.git 从 1.26GB 掉到 30,968 字节,git count-objects -vH 显示 8 个对象、pack 1.82 KiB,git fsck --unreachable 零行。剩下的 30KB 和空仓库的 28,163 字节是同一量级,具体构成我没拆。
现在推荐的工具是 git-filter-repo,一条命令等价:
bash
git filter-repo --path data --invert-paths --force
它会自己清掉备份 ref 并跑 gc,不用记得删 refs/original。但这台机器装不了(没网,pip 和 GitHub 都不通),所以上面的数字来自 filter-branch。两者思路一样:重写提交、让旧对象不可达;差别在速度和边界处理:filter-repo 通常更快(这次没量),也会顺手移除 origin 防止你误推。
代价:这不是清理,是换一套历史
重写之后,所有受影响提交的哈希都变了,这不算副作用,这就是方法本身。跟着来的:
- 远端必须 force push;别人基于旧历史的分支、PR、CI 缓存、镜像、备份都还拿着旧对象。有人从旧 clone 推一次,旧对象会重新变可达,远端占用回到 GB 级(机制推断,我没实测)。
- 签名提交全部失效;打了 tag 的还要单独重写 tag。
- 团队里每个人都要重新 clone。让他们
git pull是最容易出事的做法。 - 服务端不一定会立刻回收。GitHub 上的旧对象可能因为 fork、PR 引用继续存在,这部分我没法在本机验证,属于没测。
所以这类操作的正确顺序是:先在 clone 出来的副本上做,确认 git log、git fsck、关键文件都对,再 force push;推之前通知所有协作者。生产仓库不要直接在唯一一份工作副本上跑。
如果只是想「拿到一个干净的仓库」
不是所有场景都值得改历史。两个更省事的办法:
浅克隆。 同一个 1.2GB 仓库,全量 git clone 用了 5.40 秒、.git 1,230,440 KiB;git clone --depth 1 只用 0.033 秒、.git 124 KiB。因为最新提交的树里已经没有那些文件,浅克隆不需要下载旧历史。代价是没有历史、不能直接基于它做复杂 rebase 和推送,适合 CI、构建机、只看最新代码的人。
另起一个仓库。 把当前代码复制出去 git init 重新提交,旧仓库归档保留。历史没了,但零风险、零工具依赖,对个人小项目往往比改历史划算。
预防的角度:大的二进制文件本来就不适合直接进 Git。git-lfs 把内容放到单独存储,仓库里只留指针,这是常见做法;我没在这次实测里跑 LFS 迁移,不给数字。
操作清单
bash
# 0. 先备份,别在唯一副本上操作
git clone --no-local <repo> repo-clean && cd repo-clean
# 1. 找出大的历史对象(可选,先看清楚再动手)
git rev-list --objects --all |
git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' |
awk '$1=="blob" {print $3, $4}' | sort -nr | head -20
# 2. 重写(filter-repo 优先;没有就用 filter-branch)
git filter-repo --path data --invert-paths --force
# 或
git filter-branch --force \
--index-filter 'git rm -r --cached --ignore-unmatch data' \
--prune-empty -- --all
rm -rf .git/refs/original
# 3. 收尾并核对
git reflog expire --expire=now --all
git gc --prune=now
git count-objects -vH
git fsck --unreachable
git log --oneline
第 1 步的 rev-list 那行只列出对象,不改任何东西,可以放心先跑。真正动手前确认三件事:仓库有没有别人在用、远端能不能 force push、旧 clone 会不会再推回来。
我没测什么
- 没装
git-filter-repo(机器没网),所有数字来自filter-branch;两者的体积结果应该一致,但速度差异我没量。 - 没测 GitHub / GitLab 服务端的对象回收,也没测 fork 和 PR 对旧对象的引用。
- 没测 BFG、
git-lfs migrate,不给对比结论。 - 每个步骤只跑了一次,耗时受页缓存和磁盘状态影响;体积是精确字节数,不受影响。
- 合成数据是不可压缩的随机字节。真实仓库里的图片、视频、压缩包压缩率不同,绝对体积会差,但「删了不掉、只有重写才掉」的机制不变。