先说个我自己踩的坑。
2019 年我接手一个前端项目,git clone 花了 23 分钟。一开始我以为是公司网络的问题,换了台机器、换了个 VPN,还是 20 分钟往上。后来 git count-objects -vH 一看,.git 目录 4.7 GB。往下查发现仓库里躺着 6 个设计稿 PSD、两段 4K 的产品演示视频,还有一个不知道谁提交的 node_modules——那次提交发生在 .gitignore 生效之前。
当时我第一反应是删掉文件再提交一次就行。没用。Git 的历史是 append-only 的,你删掉的只是最新那次快照里没有它,它仍然老老实实躺在历史对象里,clone 的时候照样得拉下来。这个认知我花了一段时间才真正接受。
后来我用 filter-repo 把仓库历史重写了一遍,从 4.7 GB 压到 380 MB,clone 时间掉到 40 秒左右。下面把过程和背后的东西一起写下来,中间会聊到几个我觉得比「merge 还是 rebase」更值得搞清楚的问题。
一、Git 存的到底是什么
多数教程会告诉你 Git 是「分布式版本控制」,然后开始讲 commit、branch、remote。但我觉得理解 Git 的关键在它的对象模型:blob、tree、commit、tag 四类对象,blob 是最底层的东西。
blob 是什么?就是一段字节流。Git 不知道你存的是 C++ 还是 PNG,它对内容不做任何语义解释。这个设计极其简洁,也是 Git 速度快的根本原因——它不需要解析你的代码,只需要算 hash、压缩、打包。
但代价也很明显:所有 diff 和 merge 都是纯文本的行级比对。重命名检测是「猜」出来的,git diff -M 里的相似度阈值默认 50%,意思是 Git 觉得两个文件有 50% 以上的行一样就认定是重命名。你跑一次 Prettier 把缩进从 2 空格改成 4 空格,整个文件在 Git 眼里就是全删全加。
对比一下:SVN 存的是每个文件相对上一个版本的 delta,服务器端要维护一堆版本链;Mercurial 用 revlog,每个文件一条 append-only 的日志,思路更接近「给每个文件单独记账」。这几种模型没有绝对优劣,但它们决定了你遇到麻烦时该往哪个方向找。
二、SHA-1 那件事,和它为什么拖了这么久
Git 用 SHA-1 是 2005 年 Linus 造轮子时定的,那时候 SHA-1 还没出事。
2017 年 Google 和 CWI 搞出了 SHAttered,两个不同的 PDF 文件产生了相同的 SHA-1 值。代价大概是 6500 个 CPU 年加 110 个 GPU 年,对普通人是天价,对国家级对手不算什么。
Git 社区的应对是加了一层碰撞检测(sha1collisiondetection 那个库),把已知攻击模式挡掉。同时开始做 SHA-256 支持的迁移,Git 2.29(2020 年发布)引入了 --object-format=sha256,到 2.42 它还在实验阶段,而且文档里明确写了 SHA-1 仓库和 SHA-256 仓库无法互操作。
为什么不干脆切掉?因为 commit hash 是整个协作网络的引用基石。你的 CI 配置里写死了某个 commit,别人的 fork 指向你的历史,GitHub 的 PR 引用,包管理器的锁文件,全都在引用那串 40 位十六进制。换算法等于全世界重新对一次账。这个困境和区块链硬分叉其实是一回事,只不过 Git 没有「算力投票」这一说,只能靠兼容层慢慢磨。
三、清理大文件的完整步骤
回到开头那个 4.7 GB 的仓库。我最后用的是 git filter-repo,作者是 Elijah Newren,他同时也是 Git 合并算法 merge-ort 的主要实现者。这工具比老的 git filter-branch 快得多,官方文档里直接说 filter-branch 在大型仓库上可能跑几天。
前置条件:Git >= 2.22.0,Python 3。装法:
pip install git-filter-repo
# 或者 macOS: brew install git-filter-repo
git filter-repo --version
第一步一定是做镜像克隆,不要在你正在用的工作副本上直接操作:
git clone --mirror git@your-host.com:team/project.git project-mirror.git
cd project-mirror.git
第二步,先摸清敌人是谁。列出历史里最大的 20 个对象:
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '/^blob/ {print $3, $4}' \
| sort -nr | head -20
第三步,按路径删。比如干掉所有 PSD 和视频:
git filter-repo --invert-paths \
--path-glob '*.psd' \
--path-glob '*.mp4' \
--path 'assets/demo-video/'
--invert-paths 的意思是反转选择,也就是保留除了这些以外的所有东西。
第四步,回收空间并核对:
git reflog expire --expire=now --all
git gc --prune=now --aggressive
du -sh .git
几个我踩过的坑:filter-repo 默认会把 origin 这个 remote 删掉(防止你手抖直接 push 上去),得重新 git remote add origin ...;所有 commit hash 都会变,任何人都不能再用旧 hash 引用这个仓库,所有 fork、所有 open 的 PR 全废,所以这事必须提前跟团队打招呼,最好挑个周五晚上做;如果只是想删「最近一次提交里的大文件」,用 git rm --cached 加 git commit --amend 就够了,别动用重写历史这种核武器。
四、Git 不是唯一的答案
有个反直觉的事实:世界上代码量最大的两个单体仓库,都不完全用原生 Git。
Google 的 Piper 是自研的,2016 年那篇论文里提到仓库有 20 亿行代码、86 TB(不过 Piper 是按需拉取文件的,本地不是全量)。它的设计目标和 Git 差得很远——Google 内部每天有 25000 多个工程师往同一个仓库提交,靠的是集中式的、带悲观锁的分支模型。
Facebook 这边更有意思。他们评估过从 Mercurial 换到 Git,最后没换。公开的说法是 Mercurial 的扩展 API 设计更干净,你可以在不碰核心代码的前提下加东西;Git 的很多子命令是 shell 脚本和 C 混着写的,想加个定制行为得往上打补丁。他们搞出了 fbshipit、remotefilelog,把 Mercurial 改得面目全非,但确实撑住了体量。
我不是说你应该去用 Mercurial 或者自研一套。我想说的是:当有人跟你讲「Git 就是版本控制的标准答案」时,这句话的成立范围是有边界的。对 99% 的团队来说 Git 足够了,但足够不等于唯一。
五、我个人现在的几条规矩
讲完工具讲点习惯上的东西。
一个 commit 只做一件事。格式化、重命名、修原理性 bug 这三样不要混在一次提交里,不然 review 的人会崩溃,出了问题也没法单独 revert。
commit message 第一行写「为什么」,不写「做了什么」。因为 diff 已经告诉你做了什么了。fix: 修复空数组导致的崩溃 比 fix bug 强,但不如 fix: 后端在无数据时返回 [] 而非 null,导致 map 报错 有用——三个月后的你会感谢现在这个多写了两行字的人。
主分支保持可构建。这不是洁癖,是因为一旦允许主分支坏掉,所有人对「主分支是绿的」这个共识就松动了,接着就会有人说「反正主分支本来就是坏的,我直接推了」。
最后一条也是最重要的一条:能自动化的别靠人记。pre-commit hook 拦格式,CI 拦测试,分支保护规则拦直接 push。规矩写在文档里没人看,写在机器里才生效。