Git的“快照”信仰崩塌:你以为的增量存储其实是仓库癌变的根源
很多从SVN转岗来的人,都会下意识地认为Git是增量存储——每个版本只保存改动的那几行。这个认知错得离谱,但更荒诞的是,连不少用了Git十年的老手也这么想。Git的核心模型是“流式快照”:每次commit,它会把暂存区中所有文件的内容(即使没改过)写成一个新的树对象,再连同提交信息打包成commit对象。你可以亲自做实验:在一个仓库里连续提交两次,每次只改一个文件,然后运行git rev-list --objects --all,再配合git cat-file --batch-all-objects --batch-check='%(objecttype) %(objectname) %(objectsize)',你会发现第二次提交的树对象是全新的,所有未修改文件的blob对象依然被完整保留,只是被新的树对象再次引用。
这种设计有两个直接后果。第一,仓库历史越久、分支越杂,膨胀得越快,一个1GB的二进制文件哪怕只改了一个字节,只要被提交过两次,就会占用2GB的原始blob空间;更恶心的是,如果你在第三十次提交时把它删掉,那个文件的历史版本依然永久躺在.git里,除非你手工清理。第二,git clone之所以慢,不是因为网络差,而是服务器要把全部历史快照都打包传给你,哪怕你只需要最新代码。我最近帮一个朋友瘦身他的游戏素材库仓库,du -sh .git显示高达12.7GB,而工作树才600多MB,罪魁祸首就是十年前提交的几份PSD原稿,每份都有300MB,后来虽然删了源文件,但blob对象一个没少。这哪里是版本控制?这是数字垃圾场。
为什么git gc治标不治本
说到清理,大多数人第一反应是git gc --aggressive --prune=now。可惜这个命令对上述病情几乎无效。因为Git的垃圾回收只处理那些“没有任何分支或tag引用”的对象。只要你还保留着包含那个大文件的历史提交,该文件就永远被视为“活对象”,gc根本不会碰它。你需要的是重写历史——不是简单删除,而是从根上改写所有相关的commit hash,让那些大文件对象变成无引用孤儿,然后再用gc清理掉。具体步骤,先git clone --mirror裸克隆一份,避免污染原仓库,然后安装git-filter-repo(注意,官方推荐替代filter-branch,后者慢到让人绝望而且容易出错),执行git filter-repo --path path/to/large.psd --invert-paths,这个命令会遍历所有提交,把指定路径从每个版本中摘除,并自动重写后续所有commit的父指针和树对象,原提交hash全部失效。
完成之后,你的历史里就像从未存在过这个文件一样。接下来强制清理:rm -rf .git/refs/original/(filter-repo已经自动帮你处理了),然后git reflog expire --expire=now --all和git gc --prune=now --aggressive。注意--prune=now必须放在gc后面,否则会被忽略。执行完再跑一次git count-objects -vH,你会看到size-pack从刚才的12.7G缩到几十兆。但这只是第一步,你还得强制推送所有分支到远程,否则远端仓库仍然藏着旧对象,而且你需要通知所有协作者重新clone,因为任何基于旧历史的分支都会出现分叉。这对团队来说非常痛苦,所以我的独立观点是:Git快照模型本质上是功能强大的“写时复制”文件系统,适用于文本代码,但绝对不适合二进制资产和大文件。你需要的不是更勤快的gc,而是从制度上禁止提交大于20MB的二进制文件,并用Git LFS或独立资产服务器来管理。
手术之外:如何让仓库不再复发
瘦身只是急救,真正要命的是防止下次再犯。我推荐一个组合拳:第一,在.gitattributes中强制声明所有二进制文件的合并策略为binary,并设置*.psd filter=lfs diff=lfs merge=lfs -text,但这需要团队统一安装LFS钩子,否则有人忘记就会重新把大文件塞进去。第二,在服务端配置pre-receive钩子,检测每个新提交的blob对象大小,超过阈值直接拒绝。一个简单的shell脚本可以这样:git rev-list --objects --all | git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' | awk '$1=="blob" && $3>5000000 {print $4}',如果有输出就停止推送。第三,也是我特别想强调的,把历史“防腐”纳入日常review:不要只diff代码,还要用git log --stat --summary查看每次提交的文件大小变化,并打开CI流水线中的git check-attr校验。如果你觉得自己做不到这么严,那么至少每个月跑一次git count-objects -vH,当size-pack超过工作树的5倍时,就该警惕了。
重新思考Git的承诺
Git官方文档一直在吹嘘“近乎无限的扩展性”和“自由的分布式工作流”,但像我们这样真正处理过超大历史的人应该明白,这些承诺都有隐藏前提——你只存文本文件。当我把一个4GB的仓库瘦身到190MB后,我的同事问我“Git为什么如此脆弱”,我反而觉得这恰恰是它的诚实之处:快照模型让分支合并变得异常优雅,代价是存储空间的极度奢侈。你不可能两全其美。与其迷信git gc能包治百病,不如承认Git的边界,然后用filter-repo这样的工具定期给仓库做一次深度清创。数据比代码更贵,历史比现实更沉重——这句话,只有被blob撑爆过磁盘的人才懂。