
今天来从一个真实的项目案例聊聊 git 管理的仓库大小的问题,希望给大家一些借鉴和启发,经常使用 github 的人可能会出现偶尔推送代码到远端失败的案例,很可能是你的代码中包含太多的图片和视频资源,导致单次提交超过 github 单次提交的上限(单个普通 Git 文件/对象 最大 100 MiB),如果不小心提交了一些大文件我们应该如何处理?
我有个 Chrome 扩展项目,工作区总共才 2.5MB,可每次 git clone 下来,.git/objects 都有 112MB。我在很早前把演示视频从仓库里删了,改成 GitHub 外链,提交历史干干净净。但是当我重新 clone,眼看着进度条又爬过 100MB 时,第一反应是------这清理压根没生效?
清理是生效了的。只是删的不彻底。
一、重量不在工作区,在历史里
先做个体检。du -sh 一看,整个项目 115MB,其中 .git 自己就占了 112MB,工作区那点源码和截图加起来不到 3MB。也就是说,胖的是版本库本身,不是当前的文件。

git 是内容寻址的------每一个被提交过的文件,都会作为一个 blob 永久存在 .git/objects 里,靠它的 SHA 寻址。git rm 做的事情,只是在「下一次提交」里不再跟踪这个文件,让它在工作区和索引里消失。它没有、也不可能回头去抹掉历史提交里已经固化下来的那个 blob。所以那个 48.5MB 的演示 gif、那几个几十 MB 的 mp4,自打被 commit 进去的那一刻起,就成了仓库的永久负担,安安静静躺在历史深处。

把所有历史里的大对象揪出来:
bash
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1=="blob" && $3 > 1048576 {print $4}' | sort -u
输出一目了然:四个大视频文件,最大一个 gif 48.5MB。这就是 112MB 的去向。
二、重写历史:git-filter-repo
要把这些 blob 从历史里真正挖掉,得重写历史。官方工具是 git-filter-repo,老牌的 git filter-branch 和 BFG 都已被它取代------更快、更安全、更少踩坑。
bash
pip3 install git-filter-repo
# 重写前,先备份 .git(这步不可逆)
cp -R .git ~/youtube-live-translate.git-backup
git filter-repo --invert-paths \
--path public/demo-video-with-music.gif \
--path public/demo-video-with-music.mp4 \
--path dist/demo-video-with-music.mp4 \
--force
--invert-paths 是反向匹配:排除列出的这几个路径,保留其余一切。千万别把方向搞反------不加这个参数就是正向匹配,会变成「只保留这几个、删掉其余」,那是另一场灾难。
跑完,filter-repo 扫了全部 35 个提交,重写、repack,本地 .git 从 112MB 直降到 1.4MB。提交历史、commit message、顺序、文件内容,全都原样保留,变的只有 hash。历史里再搜大 blob,零个。
到这里,看起来是个干净利落的胜利。
三、然后,新 clone 又变回了 100MB
强推到远程,本地也确认干净了。过了一阵,他在另一台机器上重新 clone------.git/objects 又是 100MB。
第一次清理,难道白做了?
没有。但这次失败指向了一个比「历史里有大文件」更隐蔽的事实。
四、可达性:clone 体积的唯一真相
git clone 不会把仓库里所有 对象都拉下来,它只拉可达的对象------从分支、tag 这些引用出发,沿着 parent 链一路能走到的 commit 及其关联的 blob。一个对象哪怕还物理存在于服务端,只要没有任何引用能到达它,clone 时就不会传输它。
把这条规则套到现状上:本地 .git 确实干净了,远程 main 分支也确实指向了重写后的新历史。那为什么 clone 还在拉 100MB?只能是------远程上仍然有某个引用,能走到含视频的旧历史。

候选只有两个:分支,或 tag。分支只有 main,已经更新。那就只剩 tag。
bash
# 本地 tag(重写后指向新 hash)
git show-ref --tags
# 远程 tag
git ls-remote --tags origin | grep -v '\^{}'
一对比,破案了。远程有十来个版本 tag,其中从 v1.3.0 到 v1.6.1 这一批,hash 和本地对不上------远程的 tag 还指向重写前的旧 commit 。filter-repo 在本地把分支和 tag 一起重写了,但我们强推的时候只推了 main 分支,忘了推 tag。于是远程那几个旧 tag 依然锚定在旧历史上,旧历史里的视频 blob 对它们完全可达,clone 时照单全收。
修复只需要一行:
bash
git push origin --force --tags
推完再真实 clone 一次,.git/objects 终于落到 2.0MB,大 blob 一个都搜不到了。

五、为什么会踩这个坑
因为「重写历史」在我们的直觉里约等于「改 main」,而 tag 是那种提交完就再也没人管的东西。一旦打上,它就长在远程某个 commit 上,记录着「这是 v1.6.1 的样子」。filter-repo 在本地重写了 tag 指向,但本地和远程是两个世界------本地改了,远程不知道;推了 main,tag 还停在原地。
这个坑的可怕之处在于它的假性成功 :本地 .git 真的瘦了,git log 真的干净,远程 main 也真的更新了,所有自检命令都绿。唯有重新 clone 一次,才会被狠狠打脸。所以验证仓库瘦身,只有一个标准------真 clone 一遍:
bash
git clone <url> /tmp/check && du -sh /tmp/check/.git/objects
本地 du 会骗你,因为本地还残留着重写前的引用缓存(remote-tracking、reflog、备份);只有从一个对远程一无所知的全新 clone 出发,测出来的才是别人真正会下载的体积。
六、把大文件挡在门外
git 没有按文件大小忽略的能力,只能按扩展名。治本的办法是让大视频从一开始就进不来:
gitignore
# 大体积媒体文件 ------ 用外链/Release 托管,不入库
*.mp4
*.mov
*.webm
*.avi
*.mkv
*.flv
*.gif
# 截图类 *.webp / *.png / *.svg 体积小且有用,保持入库
演示视频这类东西,正确归宿是 GitHub Release 的附件,或者外部图床/对象存储的链接,而不是 git 仓库本身。
七、复盘:一个清单
整个过程的教训,可以压缩成五句话:
- 删文件删不掉历史。
git rm管未来,不管过去;blob 一旦 commit 就固化。 - 体积由可达性决定。 clone 只拉可达对象,分支和 tag 是唯二的入口。
- 重写历史后,分支和 tag 必须一起强推。 漏推 tag 是最隐蔽的坑,症状是「本地干净、远程 main 也干净、但 clone 还是胖」。
- 用真实 clone 验证,别信本地 du。
- 预防胜于治疗。
.gitignore按扩展名把大文件挡在门外。
最后一个小细节:filter-repo 跑完会主动删掉 origin 远程 ,这是它防止你手滑误推的设计。记得 git remote add origin <url> 加回来。而强推的时候,用 --force-with-lease 而不是裸 --force------如果远程在你重写之后被别人推过新提交,它会拒绝,避免你一巴掌把别人的工作盖掉。
112MB 到 1.4MB,技术上一行 filter-repo 就够了。真正花时间的,是理解为什么删了它还在、为什么推了还胖、以及怎么确信自己真的搞定了。git 的体积问题,本质上是一道关于可达性的题。