ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Git仓库瘦身实战:用caveman压缩历史、清理.git目录膨胀

Git仓库瘦身实战:用caveman压缩历史、清理.git目录膨胀 前阵子帮一个老项目做仓库瘦身最后被一个叫caveman的工具救了一把过程挺有意思。那个仓库因为历史原因积累了近六年的提交记录.git目录膨胀到 2.3GB每次git clone都要等上十分钟CI 里拉代码成了日常痛点。传统的filter-repo、BFG 我都试过但面对这种“整个历史都藏着大文件”的场景要么得一条条写规则要么得花大量时间跑重写最后稳定性和心力都撑不住。后来我换了个思路这个项目其实已经进入维护期没人关心六年前的某个中间版本里代码长什么样大家真正需要的是“当前代码 最近几个月的提交历史”。那干脆把老历史全部压成一个起点提交像穴居人一样简单粗暴地截断时间线。这才找到caveman——一个专门干这件事的 Git 历史压缩工具。这篇就把我的实操过程、参数踩坑和经验心得整理出来给同样被仓库体积困扰的人一个可以直接参考的路径。1. 仓库是怎么变胖的为什么需要“穴居人”式处理1.1 三种最常见的膨胀来源Git 仓库体积失控绝大多数情况逃不出这三类。第一类是构建产物被误提交比如node_modules、vendor、dist、.jar、__pycache__之类一次误提交就几百 MB哪怕后来删掉了历史里依然保留着那一份快照。第二类是二进制文件和媒体资产比如设计稿 PSD、视频素材、数据库备份这类文件本身压缩率低Git 按内容保存占空间特别实在。第三类是依赖仓库被整包提交进去常见做法是把第三方源码直接拷进工程里一个lib/目录就顶半年的代码量。这些内容有个共同点它们“曾经存在过”这件事本身就被 Git 记录了下来。哪怕后来删了git log里对应的 commit 仍然持有这些 blob。只有克隆过完整历史的人才会在git fetch时把这些东西全部拖下来。仓库越老历史里的“废弃物”就越多最终把.git撑成一个沉重的包袱。1.2 常见瘦身手段为什么不够用技术圈里治仓库膨胀的老三样各有边界。git filter-branch是老牌工具但速度慢、容易出错官方自己也给了个“让我再用filter-repo”的提示。git filter-repo是目前最通用的方案可以做精准的对象删除、路径改写、作者信息修正功能确实强大但它的强大也意味着你需要想清楚规则到底是删文件改路径还是替换内容写错一条规则重写出来的历史可能不是你想要的样子而且它默认会重新生成所有 commit hash波及范围往往比预期大。BFG 更专注“删大文件”操作感很直观但它的场景是“从历史中抹除某些 blob”对“保留最近 N 天历史、老历史整体折叠”这种需求并没有提供直接参数。还有一个更朴素的做法删掉.git重新初始化然后提交当前代码。这种做法简单但缺点也致命——最近的提交历史也会一起丢掉团队的分支、标签、评审记录全部作废。所以真正的问题不是“怎么删除大文件”而是“怎么在保留近期上下文的前提下把老历史整体折叠成一个起点”。这正好是caveman的设计目标。1.3 什么时候该用 caveman什么时候不该用caveman的适用场景非常明确仓库进入维护期或需要长期归档团队成员主要关注主干分支和近期提交历史里存在大量不会再被检索的老版本快照。比如产品已经稳定迭代了三四年不需要回溯五年前的中间版本或者你打算把一个已经很老的 Monorepo 迁移到新平台想在新环境里以干净轻量的姿态开始同时保留近几个月的上下文。这时候用caveman把“时间线起点”打包成一个提交收益最大。但如果你的仓库还需要频繁做历史审计、需要精确检索每一条历史提交、或者有一堆依赖旧 commit hash 的外部脚本那就得慎重。caveman会重写所有保留 commit 的哈希严格来说就是一次历史改写任何引用旧 hash 的外部系统都可能失联。我个人的判断标准就一条团队未来三个月里会不会有人因为这个改动而去查一年前的某个 commit如果会就别急着用它。2. caveman 的核心设计与参数拆解2.1 它到底做了什么caveman的思路和传统工具不同它不做精细的对象级删洗而是把--min-age指定时间之前的所有提交全部压成一个squash commit。压缩之后新的历史线就变成这样* 当前 HEAD 的新 hash feat: 最近一次提交 ... * 当前 HEAD 之前某个时间点的 hash 最后一次被保留的提交 * caveman 生成的“起点”提交 把所有老历史合并成一个提交那一条 squash commit 包含了老历史的全量代码状态但没有任何中间过程。Git 只会为这份状态保存一棵树和对应的 blob所有老版本里存在过、但最终状态里已经不存在的内容自然就失去了引用变成了悬空对象。之后配合git gc做一次彻底的垃圾回收.git的体积就能大幅下降。要注意caveman重写的不只是历史层它会保留你在--include-refs里指定的分支和标签并按照“以某个根 commit 开头”的方式重建完整引用。所有被保留 commit 的父链都变了所以 hash 必然变化这是一次对仓库的“换血式”重构。2.2 参数表与常用配置把核心参数列成一张表后面实操就照着抄。参数含义我的推荐值--repo目标仓库路径本地仓库绝对路径--include-refs需要保留的分支/标签main或master多个用逗号分隔--min-age只保留最近多少天的提交项目维护频率越高天数越小通常 180 到 365--squash-message起点提交的 commit message建议写清楚时间边界比如chore: squash history before 2024-01-01--n--min-age的宽松版本保留最近多少次提交如果你是按提交次数而非日历天数来界定时用--dest-dir重写后仓库的输出目录建议指向一个临时目录方便检查--min-age和--n可以二选一也可以同时给出。两者都提供时caveman会取较靠后的那个锚点作为截断点理解成“两个条件必须同时满足或者按更保守值执行”就行。2.3 几个关键参数背后的“为什么”先说--include-refs。很多第一次用的人会下意识想“所有分支我都保留”但实际上如果仓库里有大量过期的功能分支把它们全带进新历史体积照样难看。而且新仓库里挂着几十个没人合并的分支团队协作界面也会很乱。我的建议是只带主干和正式发布的 release 分支其他分支反正有备份仓库在真需要时再从那里取。再说--squash-message。这个参数看着不起眼但它决定了未来维护者看git log时的第一眼信息。我习惯在 message 里写明“这段历史被压缩的时间边界”这样同事看到那个孤零零的根提交一眼就明白为什么这里没有更早的记录了。比如chore: squash repository history before 2024-06-01 Created by caveman, preserving code state at 2024-06-01. Files earlier than this date are no longer reachable.--min-age的选择也需要想清楚。如果团队会把刚提交两三天的代码就回退掉那保留的窗口期至少要覆盖一个完整迭代周期。我见过有人为了瘦身效果把--min-age设成 30结果隔一周就发现需要回滚到 40 天前的版本历史里却只剩一个 squash 根节点完全没法回退。宁可多保留 60 天也别为了省点体积牺牲近期可回退性。3. 从安装到执行的完整实操过程3.1 安装与环境准备caveman依赖 Git 的fast-export/fast-import能力所以前提是 Git 版本不能太老。现场跑之前先确认git --version git fast-export -h 2/dev/null | head -5如果git fast-export能正常输出帮助信息环境就达标了。caveman本身是个脚本工具克隆下来后把入口脚本放到 PATH 里即可。实操时我的习惯是单独建一个工作目录比如~/git-slim-lab把工具放那里和正式项目隔离后面生成临时文件也方便统一清掉。还有一件容易忽略的事操作前先把仓库里未提交的改动处理干净。如果你执行git status看到还有修改中的文件就先 commit 或 stash。caveman处理的是仓库历史未提交的改动虽然不会参与重写但在后续检查时容易混在一起干扰你对结果的判断。3.2 用真实场景执行一次压缩我用一个真实项目做演示仓库历史 2300 多天main分支是主分支团队希望保留最近 270 天左右的提交更早的记录全部折叠为起点提交。执行命令如下cd ~/git-slim-lab git clone --mirror /var/repos/legacy-app.git legacy-app-mirror.git caveman \ --repo /var/repos/legacy-app.git \ --include-refs main \ --min-age 270 \ --squash-message chore: squash history before $(date -d -270 days %F) \ --dest-dir /tmp/legacy-slimmed这里第一步先做 mirror 克隆是对原仓库的第一层保护。如果caveman命令写错了至少原仓库还安然无恙。--repo指向正式仓库也没问题它内部会自己创建备份但“多一层保险 少一次事故”我宁愿等待克隆的那几十秒。跑完之后/tmp/legacy-slimmed里会生成一个新的裸仓库镜像。先别急着动原仓库进目录看结构cd /tmp/legacy-slimmed git log --oneline --graph -20 git count-objects -vHgit log用来确认新历史是否符合预期应能看到一个 squash 根节点以及从它延伸下来的最近 270 天提交。git count-objects -vH则是看仓库体积变化我那次压缩完.git从 2.3GB 降到 220MB 左右效果显著。3.3 检查差异确认结果符合预期重写历史最怕的是“代码丢了”或者“当前状态变了”。验证方法很简单新旧仓库各自取一次当前 HEAD 树的 hash比对是否一致。# 旧仓库 git -C /var/repos/legacy-app.git rev-parse HEAD^{tree} # 新仓库 git -C /tmp/legacy-slimmed rev-parse HEAD^{tree}两个 tree hash 一致说明当前代码状态在重写前后是等价的。这个检查必做因为它能证明caveman在折叠老历史时没有改变工作区最终状态这是后续敢往远端推的前提。还可以抽查一个中间提交在新仓库里找到最近第 30 条提交记录到旧仓库找到对应 hash然后比较这两次提交的^{tree}。如果一致说明保留窗口内的历史也不是“表面上有记录、实质内容对不上”。我实际抽查过几次hash 都能对上说明工具对“保留区域内提交”的重写是比较忠实的。3.4 替换原仓库和推送远端的注意点确认无误后把新仓库替换到原位置。这里我不建议直接删除旧仓库目录而是把旧目录改名保留几天等团队确认没问题再清理。比如mv /var/repos/legacy-app.git /var/repos/legacy-app.git.bak-$(date %F) mv /tmp/legacy-slimmed /var/repos/legacy-app.git然后回到本地工作区更新远端地址对应的 fetch 记录用强制推送覆盖远端分支git push origin main --force强制推送前一定要提前通知团队仓库历史即将重写所有人本地旧 clone 都会失效必须重新 clone 或者用新的远端地址。如果有人本地还有未推送的提交先让他推到一个临时分支或者等推送完成后再 cherry-pick 到新主线。忽略这一步团队协作会直接乱套。4. 常见问题与排查技巧实录4.1 现象压缩后仓库体积没降多少这不是caveman失效而是“大对象仍然被某个保留分支或标签持有”造成的。比如仓库里有个用了三年的 release 标签指向的是一个远古提交标签本身是--include-refs的一部分它对应的那棵树就还活着。要解决就先列出几个体积极大的对象在哪里被引用git rev-list --objects --all | git cat-file --batch-check%(objecttype) %(objectname) %(objectsize) %(rest) \ | sort -k3 -n -r | head -20判断对应 blob 被哪个 ref 引用再决定是否需要把这个标签也排除在--include-refs之外。如果你确实想保留标签但不想保留它背后那一坨大文件那已经不是caveman的活需要用filter-repo做定向清除。两个工具配合使用才是完整的瘦身方案。4.2 现象命令跑一半报错仓库变成“断头”状态caveman的执行过程涉及多次临时仓库切换与 refs 重建任何一步出错都可能让当前仓库处于不一致状态。遇到这种情况我第一反应是回到备份。工具本身会在.git目录下做副本命令失败时先看有没有生成可恢复的备份分支或者.git/caveman-*目录。如果真的没有自动备份那就靠你自己提前做的 mirror 克隆兜底。这也是为什么我总强调“先 mirror 再执行”的原因——在重写历史的场景里多预备一份余额永远是最稳的策略。执行失败后不要慌马上用 mirror 目录拷回原仓库记下报错信息在干净副本上重新调整参数再试。4.3 现象强行推送后同事的本地仓库大量报错这是历史重写后最典型的连锁反应。同事的本地仓库里旧 commit hash 已经不在了git pull会产生 non-fast-forward 合并冲突git status也会显示一堆“deleted by us”之类的奇怪状态。这不算工具问题是工作流问题。应对方式有两种。最干净的方式是让所有人重新 clone代价是要重新拉一次依赖和配置。另一种是让同事把本地未推送的提交先整理成 patch重新 clone 后git am或git cherry-pick恢复。无论如何强制推送前必须有一个“已通知到位”的确认环节最好等所有同事都完成本地备份后再执行。4.4 现象只想保留最近 N 次提交不想按天数算caveman支持用--n指定保留提交条数。这个参数对那种“迭代节奏不固定”的项目更合适比如团队最近三个月只产出了 20 次提交但三个月前一个月能产出 40 次按日历天数截断会导致保留范围内的历史多寡不一按条数截断则能保证新历史线里至少保留最近 N 条提交。我实测时若同时给了--min-age 365 --n 100它会以距离当前更近的那个锚点为准。如果你想彻底按条数控制就把--min-age调成 1 或者干脆不传这个参数然后专注调--n。4.5 现象仓库里有子模块压缩后子模块引用乱了caveman处理的是主仓库自身的对象图对gitlink这类特殊对象并不会做额外内容清理。如果子模块的历史里也有大文件那是子模块仓库自己的问题需要单独对子模块仓库执行同样的压缩流程。主仓库重写后子模块 URL 和 commit 引用不会自动更新所以在做完主仓库瘦身后记得重新执行git submodule sync git submodule update --init --recursive否则同事克隆下来会看到一堆无效的 gitlink。5. 我的一点点经验与后续扩展实际操作中我发现caveman最让人舒服的地方不是“代码简单”而是“决策成本低”。它逼你先把问题定义清楚你到底需要多长的近期历史哪些分支必须保留老历史是否可以整体折叠想清楚这三个问题命令其实就那一行。我因为在测试环境跳过“tree hash 一致性检查”结果推进到生产环境后才发现新仓库的某次 release 标签代码和旧仓库对不上回滚折腾了一下午——从那以后每次重写完我都把“新旧仓库当前 HEAD tree 比对”当成必经步骤不接受任何例外。后续如果还有精力可以在这个基础上再做一步扩展给整理后的仓库配一个git gc策略再让 CI 在合并请求时直接拦截大型二进制文件比如超过 5MB 就拒绝合并防止仓库再次膨胀。瘦身不是一次性的手术而是给团队建立一个新的协作习惯。工具能帮你处理过去的问题但真正的长期收益是以后不会再有人把那个几 GB 的安装包随手推到主干里。
返回列表