删了1.2GB,.git仍是1.2GB

删了 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,不给对比结论。
  • 每个步骤只跑了一次,耗时受页缓存和磁盘状态影响;体积是精确字节数,不受影响。
  • 合成数据是不可压缩的随机字节。真实仓库里的图片、视频、压缩包压缩率不同,绝对体积会差,但「删了不掉、只有重写才掉」的机制不变。
相关推荐
程序员沉梦听雨2 小时前
【Git】git-submodule子模块管理
git
全栈陈序员1 天前
Git】从入门到精通(八)—— git log:查看提交历史
大数据·git·elasticsearch
7177771 天前
Git自建用什么工具比较好:Gitea、Forgejo、GitLab CE与Gitee的选型对比
git·gitlab·gitea
lsc_blog2 天前
年底还在招的岗位,先确认缺口和到岗要求
git·缓存·ci/cd·职场和发展·github
全栈陈序员2 天前
【Git】从入门到精通(十一)—— git reset 三种模式详解
git·elasticsearch
全栈陈序员2 天前
【Git】从入门到精通(十)—— 撤销与恢复:git checkout与git restore
运维·网络·数据库·git
全栈陈序员3 天前
【Git】从入门到精通(五)—— git add:将文件加入暂存区
大数据·git·elasticsearch
全栈陈序员3 天前
【Git】从入门到精通(七)—— git status:查看仓库状态
大数据·git·elasticsearch
莫晓燚3 天前
Ansible 13 个常用模块---企业实战
git·github