仓库 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 的浅克隆、仓库体积的每日告警,三件事比一次历史清理更重要。
查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。