仓库 2.1 GB、clone 要九分钟:历史瘦身与 LFS 迁移
两年时间仓库从 40 MB 涨到 2.1 GB,而工作区只有 26 MB。体积全躺在历史里:设计稿、数据库备份、误提交的 node_modules。filter-repo 四分钟重写,再配三道护栏防复发。
新同事入职第一天发来消息:clone 卡在 62% 已经二十分钟了。我以为是他网络的问题,自己试了一下,九分四十一秒,2.1 GB。这个仓库两年前刚建的时候是 40 MB。
CI 那边也在受苦。每次流水线跑 actions/checkout,默认要拉全量历史,光这一步平均 70 秒;我们一天两百多次构建,加起来好几个小时耗在「把代码从 GitHub 搬下来」这件事上。
清理历史这件事,风险高、不可逆、还会影响所有人本地副本,所以拖了很久。真正让我下决心的是一个数字:git count-objects -vH 显示 1.9 GB,而工作区里所有文件加起来只有 26 MB。
一、先算清 1.9 GB 到底是谁
不要一上来就跑 filter-repo,先看清体积构成。三条命令,从粗到细:
# 1. 总量:松散对象、打包对象、以及 pack 文件大小
git count-objects -vH
# count: 0
# size-pack: 1.87 GiB
# in-pack: 284619
# prune-packable: 0
# garbage: 0
# size-garbage: 0
# 2. 每个历史版本的大文件排行(这是关键一步)
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '/^blob/ {print $3, $4}' \
| sort -rn \
| head -20 \
| numfmt --to=iec --field=1
# 3. 按目录聚合,看是哪个模块贡献的
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '/^blob/ {print $3, $4}' \
| awk '{split($2,a,"/"); size[a[1]]+=$1} END {for (d in size) printf "%.1f MB\t%s\n", size[d]/1048576, d}' \
| sort -rn | head
第二条命令的输出让我有点意外。排在前面的不是源码,是几个 .psd 设计稿、一个 380 MB 的数据库备份 .sql、以及一整个被误提交的 node_modules 目录(里面有个 40 MB 的二进制)。这些文件在当前分支上一个都不存在,全在历史里。
| 路径 | 体积 | 何时引入 | 当前是否还在 |
|---|---|---|---|
design/homepage-v3.psd |
412 MB | 2024-03 | 已删除 |
backup/prod-20240318.sql |
380 MB | 2024-03 | 已删除 |
node_modules/(部分二进制) |
268 MB | 2023-11 | 已删除 |
dist/app.bundle.js |
96 MB | 2023-08 | 已删除 |
| 其余源码与文档 | 26 MB | — | 在 |
这就是仓库膨胀的典型形态:删掉文件不会删掉历史。git rm 只是在当前提交里记了一笔「这个文件没了」,之前那几百个版本的对象仍然躺在 .git/objects 里,clone 的时候照样要下载。
二、git rm 为什么没用
这是最容易被误解的一点。Git 是内容寻址的存储,每个 blob 只要还被任何一个提交引用,就永远不会被回收。git rm 产生的新提交不再引用那个文件,但历史提交仍然引用它。
有人会说「那就 git gc --prune=now」。gc 只能清掉没有任何引用的对象,而历史提交就是引用。除非你把那些提交从历史里抹掉,否则 gc 一个字节都不会省。
抹掉提交有三条路:
第一,git rebase -i 逐条改写,适合只改最近几个提交,几百个提交的仓库不现实。
第二,git filter-branch,Git 官方自带,但它慢、坑多、官方文档自己写着「不推荐使用,请用 filter-repo」。
第三,git filter-repo,社区工具,用 Python 重写了整个流程,快、安全、有 dry-run。我们选了这个。
三、filter-repo 实操
先装:
pip install git-filter-repo
# 或者 macOS
brew install git-filter-repo
filter-repo 默认要求在一个「新鲜的 clone」上操作,因为它会删掉 origin 远程、重写所有引用。这一步很重要,不要在大家日常开发的那个工作副本上跑。
# 1. 镜像克隆,只拿 .git,不检出工作区(快很多)
git clone --mirror git@github.com:org/ispt-web.git ispt-web.git
cd ispt-web.git
# 2. 先干跑,看看会删掉什么、体积会变成多少
git filter-repo --analyze
# 会生成 .git/filter-repo/analysis/ 下的几个报告,
# 其中 blob-shas-and-paths.txt 就是「哪个大文件在哪些提交里」
# 3. 按路径剔除,注意 --invert-paths 是「删掉这些」的意思
git filter-repo \
--path design/homepage-v3.psd \
--path backup/ \
--path node_modules/ \
--path dist/ \
--invert-paths \
--force
# 4. 看结果
git count-objects -vH
跑完从 1.87 GB 降到 47 MB。整个重写过程在这个仓库上花了大约四分钟,比 filter-branch 快一个数量级——因为它内部用 git fast-export | git fast-import 管道,不逐条启动进程。
几个实操细节,都是踩过的:
第一,--path 是精确路径前缀匹配,--path design/ 会删掉整个目录,--path design/homepage-v3.psd 只删那一个文件。别写成 --path '*.psd',它不做 glob 展开,要通配得用 --path-glob '*.psd'。
第二,如果只是想「把某些文件挪到 LFS」,用 --to-subdirectory-filter 或者配合 --blob-callback 判断大小。我们的做法更简单:先剔除,再把当前还在用的大文件用 LFS 重新提交。
第三,重写会改变所有提交的 SHA。这意味着所有 tag、所有 PR 的引用、所有同事本地分支全部失效。这件事必须在动手前通知到人,不能事后解释。
四、强推之后:怎么让所有人不受影响
重写完成、验证通过之后,是推送到远程:
git push --force --all
git push --force --tags
这里要提前处理几件事:
分支保护。GitHub 上开了保护的分支不允许 force push,需要临时放开,推完再打开。忘了这一步会卡在 protected branch hook declined。
tag 重打。filter-repo 会重写 tag 指向,但如果之前有人打了 annotated tag 并把 tag 对象本身推送过,需要 --force --tags 一起推,否则远程还留着旧 tag。
同事的本地副本。这是最麻烦的部分。旧 clone 的历史和新历史完全对不上,git pull 会变成一次巨大的 merge,把删掉的文件又「合并」回来。正确的指引只有一条:
# 保留本地未提交的改动(如果有)
git stash push -u -m "before-history-rewrite"
# 最省事的做法:重新 clone
cd ..
mv ispt-web ispt-web-old
git clone git@github.com:org/ispt-web.git
# 把 stash 应用回来(注意:stash 里若含已删除的大文件,会重新引入)
git stash pop
我在通知里特意强调了 git stash 那条注意事项:如果有人 stash 里正好有 dist/ 或 node_modules/ 的东西,pop 回来就又把大文件带进新仓库了。实际执行时确实有两位同事中招,提交了一个 40 MB 的 bundle,被 pre-commit 的钩子拦下来了。
五、LFS:不是所有大文件都该删
清理完之后,剩下的 47 MB 里还有一类东西是「必须留在仓库、但确实很大」的:几张产品图、一份示例数据集、一个字体文件。这些文件会随着版本迭代不断新增,删了业务就跑不起来。
这类文件适合放 Git LFS。它的原理是仓库里只存一个指针文件,真实内容放在 LFS 服务器上,clone 时按需拉取。
# 1. 声明哪些文件走 LFS
git lfs install
git lfs track "assets/images/**/*.png"
git lfs track "assets/fonts/*.woff2"
git lfs track "fixtures/*.csv"
# 2. 把已经提交过的大文件迁到 LFS(会重写历史,同样要谨慎)
git lfs migrate import --include="assets/images/**/*.png" --everything
# 3. 确认指针生效
git lfs ls-files
.gitattributes 要一起提交,否则别人 clone 下来还是普通文件。
LFS 的取舍要说清楚:它省的是仓库体积,不省流量。CI 里如果每次都要 git lfs pull,那 47 MB 还是每次都要下载,只是从 git 对象变成了 LFS 对象。我们在 CI 里加了 GIT_LFS_SKIP_SMUDGE=1,只在真正需要图片的构建任务里才对特定目录 git lfs pull。
六、防复发:让下一次不可能发生
历史清理只做一次,真正有价值的是后面这几条护栏。
第一,.gitignore 加进去还不够,要加 pre-commit 检查体积。我们用 pre-commit 框架挂了一个钩子,任何超过 2 MB 的文件直接拒绝提交:
#!/bin/sh
# .git/hooks/pre-commit
limit=$((2 * 1024 * 1024))
git diff --cached --name-only --diff-filter=A | while read -r f; do
[ -f "$f" ] || continue
size=$(wc -c < "$f")
if [ "$size" -gt "$limit" ]; then
echo "拒绝提交:$f 有 $((size / 1024 / 1024)) MB,超过 2 MB 限制"
echo "如果是必要的二进制,请先 git lfs track"
exit 1
fi
done
第二,CI 侧优化 clone。历史清理之后 actions/checkout 从 70 秒降到 8 秒。如果不需要历史,再加 fetch-depth: 1 只取最新提交,能到 3 秒以内;需要 git describe 打版本号的流水线则用 fetch-depth: 0 配 filter: blob:none(部分克隆),只拉提交树不拉文件内容。
第三,把「仓库体积」变成一个可见指标。我们在每日构建里加了一行,超过阈值就发通知:
size=$(git count-objects -v | awk '/size-pack/ {print $2}')
echo "repo size: $((size / 1024)) MB"
[ "$size" -gt 204800 ] && echo "::warning::仓库超过 200 MB,请检查是否有大文件被提交"
这条告警在三个月后真的触发过一次:有人把一份 60 MB 的日志文件提交进了 debug/ 目录,当天就被拦下来了。如果等到 2 GB 再处理,又要重来一遍历史重写。
小结
- 仓库膨胀的根源不是当前文件大小,而是历史里的 blob;
git rm和git gc都不会删掉被提交引用的对象。 - 动手前先用
git rev-list --objects --all | git cat-file --batch-check排序,20 行输出就能定位 90% 的体积来源。 - 用
git filter-repo而不是filter-branch,在--mirror克隆上操作,先--analyze再--invert-paths。 - 重写会改变所有提交 SHA,必须提前通知全员重新 clone;
stash里如果有大文件,pop回来会重新引入。 - LFS 省仓库体积不省流量,CI 里要配
GIT_LFS_SKIP_SMUDGE=1,只在需要的任务里显式lfs pull。 - 真正的收益来自护栏:提交前的体积检查、CI 的浅克隆、仓库体积的每日告警,三件事比一次历史清理更重要。
正在加载评论…