2026/10/6 12:52:29

Gerrit下git subtree add提交历史混乱的清理与重构

Gerrit下git subtree add提交历史混乱的清理与重构 被Gerrit支配的团队肯定遇到过这种尴尬明明只是引入了一个第三方仓库推上去之后reviewer看到的却是一整片陌生提交几十上百条根本不知道你这个change到底干了什么。这类问题十有八九出在git subtree add身上。如果你直接把这个命令当作“把其他仓库复制进来”的工具等提交历史被淹没之后再想补救会比一开始用对参数痛苦得多。这篇文章不是讲subtree怎么用而是专门解决一个具体场景做完git subtree add之后如何推一个干净的commit log给Gerrit做code review。我会从subtree把历史搞乱的原理讲起给出三种可行的清理思路然后完整跑一遍从add到push的全过程最后把常见的报错和排查方法一起列出来。不管你现在是刚准备做subtree集成还是已经不小心把一坨历史推到了分支上照着操作基本都能救回来。1. 问题根源subtree的历史为什么会让Gerrit的commit log变脏1.1 subtree和submodule选型之前先想清楚先说一个很多人没意识到的区别。git submodule是把外部仓库当作一个“指针”挂进来你的主仓库里存的只是那个仓库的commit号外部代码本身不进入主仓库而git subtree是直接把外部仓库的代码快照合并进你的目录所有文件都存在于主仓库的tree里。因为文件真实存在它带来的好处很明显不依赖网络也能构建、clone一次就全部到位、团队成员不需要额外配置submodule。代价就是外部仓库的提交历史也会跟过来至少在没有显式禁止的情况下会跟过来。git subtree add内部实现很像是一次特殊的merge。它会把外部仓库的分支拉下来然后和你的当前分支做一次合并提交合并提交的其中一个parent就是外部仓库的完整历史链。如果外部仓库有200次提交那你这边的git log里也会出现那200次提交。你明明只做了一件事日志却变成了一片陌生历史。1.2 没加--squash时subtree add到底带进来了什么我们先看一下实际现象。假设主项目叫platform-web第三方目录是src/design直接执行git subtree add --prefixsrc/design https://github.com/demo/design-system.git main这条命令完成之后git log --graph --oneline -10长这样* 6d1e9e4 (HEAD - master) Add src/design from commit a3f8c21 |\ | * a3f8c21 (design-system/main) feat: 新增按钮组件 | * 74b93f1 fix: 调整主题变量 | * 9c7a1d5 test: 更新视觉回归用例 | * 2e0f9a8 refactor: 重构颜色计算 | * ...后面可能还有几百条 |/ * 7b2c4d3 feat(api): 增加订单模块 * 6a1e0a9 docs: 更新接口文档看到那串竖线引出的左侧历史了吗那就是外部仓库自己的提交记录。对你自己来说这些提交既不是你的代码也不是你的工作产物却被完整地塞进了主项目的提交线里。这时如果你直接执行git push origin HEAD:refs/for/masterGerrit会按照“提交到审查分支的commit链”来创建changes。轻则review页面上出现很多你不想出现的commit重则因为仓库配置禁merge commit直接被服务端reject。所以问题要先在本地处理干净再考虑push。1.3 Gerrit眼中的“干净”和我们理解的还不完全一样平时我们说提交日志干净通常指历史线简洁、信息规范、没有一堆wip。Gerrit里的“干净”还要加两层含义第一每个change应当是一个逻辑上完整的提交提交信息要能独立表达“我做了什么、为什么做”。reviewer在change列表页第一眼看到的只有Title和Commit Message写得含糊就会被反复问。第二push到refs/for/master的提交Gerrit默认会按提交链逐个创建change。如果你把外部仓库的200条提交全带上去就是200个change每个change看起来都毫无上下文也没法单独review。所以真正的目标很明确**主项目分支上只保留属于这个项目自己的提交历史外部仓库的历史要么完全不进来要么进来之后被压缩成一个干净节点。**这也是下面所有方案的核心逻辑。2. 三种策略add时阻断、事后打平、拆分上游2.1 推荐add时加--squash让子仓库历史止步于一次提交最佳时机是在第一次集成时就把问题拦下来。git subtree add支持--squash参数加了之后不会再保留外部仓库的完整提交链而是把外部代码“摊平”成一次提交挂到主分支上。git subtree add --prefixsrc/design --squash https://github.com/demo/design-system.git main执行后的历史会变得非常干净* c8a1f2e (HEAD - master) Add src/design from commit a3f8c21 * 7b2c4d3 feat(api): 增加订单模块 * 6a1e0a9 docs: 更新接口文档外部仓库原来的a3f8c21提交被折叠成一条Add src/design from commit a3f8c21整个项目历史线是线性的没有merge节点也没有外部历史。这条提交的代码内容就是外部仓库当前那个commit的完整快照。后续如果外部仓库更新了需要拉取新版本也不要直接git merge而是用git subtree pull --prefixsrc/design --squash https://github.com/demo/design-system.git main这样每次更新同样只会产生一条“Merge commit”或“Update subtree”性质的提交不会把上游的细碎提交灌进来。这是一个非常符合Gerrit习惯的姿势历史线性、change数量少、reviewer对每一次变更都能快速定位范围。2.2 补救从合并提交里找出主线基线用reset --soft打平如果不巧你已经用了没有任何参数的git subtree add提交历史已经被那几百条外部提交污染了这时候不要慌只要这些提交还没有被push到Gerrit完全可以在本地重写干净。核心思路是找到subtree add产生的那个合并提交取它的第一个parent也就是合并前的主线位置然后用git reset --soft回到那里把所有差异一次性暂存最后重新提交一个干净节点。第一步先找到那个合并提交git log --graph --oneline -20假设你看到了类似这样的结构* M (HEAD - master) Add src/design from commit a3f8c21 |\ | * ...外部仓库历史链... |/ * A (这里是subtree add之前的主线节点)找到提交M然后验证它的两个parentgit show -s --format%P M输出会有两串commit id第一个就是主线parent也就是A。确认无误后执行git reset --soft A此时HEAD指针回到了A但工作区和暂存区仍然保留着M的全部内容。换句话说相当于把“引入design-system”这个动作从一次多历史合并变成了一堆待提交的暂存变更正准备凝结成新commit。接着把属于subtree目录的内容提交成一个节点git add src/design git commit -m refactor: 引入design-system作为src/design的subtree 保留外部仓库当前快照历史统一收敛为单一提交便于Gerrit审查。再看git log --oneline --first-parent整条线就清爽了* 3a9f2b1 refactor: 引入design-system作为src/design的subtree * 7b2c4d3 feat(api): 增加订单模块 * 6a1e0a9 docs: 更新接口文档注意--first-parent在前台看历史时非常关键。无论以前的图有多复杂只要看主线就不容易被旁支历史干扰。2.3 还有一条支线用subtree split管理子仓库上游还有一种情况是反过来的你在主项目里改了subtree目录中的代码需要反过来给上游仓库提交pr。这时git subtree配套的split命令就有用了。git subtree split --prefixsrc/design --branch dest upstream # 然后推给上游 git push upstream dest:refs/heads/your-feature它会把你所有作用于src/design目录的提交拎出来单独组成一条分支代码内容和你主项目里的改动完全一致但提交信息只会保留那些“真正改了src/design”的记录。这个动作不是用来给Gerrit清理提交历史的但在运维subtree场景时建议掌握。因为它能帮你把“主项目历史”和“上游仓库历史”彻底分离开各走各的review流程互相不污染。2.4 我的选择建议做技术选型时我一般按下面这个标准判断使用场景推荐方式首次把外部仓库作为subtree引入主项目git subtree add --squash已经用了非squash方式历史已乱且未pushgit reset --soft打平已经push到公共远程分支且Gerrit不允force push联系管理员同步或放弃该分支重建经常需要反向给上游仓库提pr配合git subtree split --branch只是快速看一条主线的演进养成加--first-parent的习惯这套组合用下来subtree的main项目历史基本都能保持在可控范围。3. 实操记录从add到Gerrit一次干净push现场还原3.1 完整干净方案add --squash 正常修改 push refs/for假设现在要新引入一个内部组件库到src/components/ui我们走一遍完整流程。第一步添加远程仓库并执行squash方式的subtree addgit remote add ui-lib gitcode.example.com:ui-lib.git git fetch ui-lib git subtree add --prefixsrc/components/ui --squash ui-lib main记录一下此时生成的提交* 55fa1e2 (HEAD - master) Add src/components/ui from commit a11b2c3 * 77f5c22 feat(dashboard): 完善埋点 * 4d2e8a9 chore: 更新依赖接下来在subtree目录里正常修改代码。比如调整一个按钮组件的颜色变量vim src/components/ui/styles/theme.css git add src/components/ui/styles/theme.css git commit -m feat(ui): 调整主按钮主题色为品牌蓝此时git log --oneline -3* 84fb91c (HEAD - master) feat(ui): 调整主按钮主题色为品牌蓝 * 55fa1e2 Add src/components/ui from commit a11b2c3 * 77f5c22 feat(dashboard): 完善埋点干净利落。没有外部仓库的一堆乱历史。接着推送Gerritgit push origin HEAD:refs/for/master如果没有特殊问题Gerrit上会新增一个changechange title就是feat(ui): 调整主按钮主题色为品牌蓝diff范围只有你改的那个文件。reviewer会很轻松地看完并approve。3.2 乱掉的现场用reset --soft重构之后再次push再看另一个场景仓库里已经有脏历史了但本地分支还没push到Gerrit。比如当前git log --graph显示有一个合并提交M外部历史全在那里。先确认主线位置git show -s --format%P M输出的第一个commit id就是要回去的位置。记下来假设是a1b2c3d。然后执行git reset --soft a1b2c3d git status此刻你能看到Changes to be committed里有大量新文件这些都是subtree带入的东西。接着提交成一条干净的记录git add src/components/ui git commit -m refactor: 将ui-lib以subtree方式引入src/components/ui 原外部仓库历史已本地收敛本次提交只保留可审查的最终代码快照。在确认提交信息无误之后git push origin HEAD:refs/for/master因为本地分支本身没有push过这个动作对远程不会有历史冲突。Gerrit上你能拿到一个干净、独立、可review的change完全符合预期。如果嫌commit信息太长想在push之前改一下可以直接git commit --amend修订一样有效。3.3 给Gerrit补上Change-Id这道手续Gerrit和普通github push不太一样。如果你直接push不带Change-Id的commit大概率会收到remote: ERROR: commit ...: missing Change-Id in commit message footer解决方式有几种。一种是确保仓库装好了commit-msg hook。在你的主项目里跑scp -p gerrit-host:hooks/commit-msg .git/hooks/ chmod x .git/hooks/commit-msg装好后的效果以后每次git commithook都会自动在commit message末尾追加一行类似Change-Id: I4a2e5f5b29c475ba6d3db1b60e63b5d18b8d1a2c如果你只是想先提交再补可以手动在commit message的末尾加一空行再写Change-Id: I加上40位十六进制字符串。为了生成一个好的ID最稳妥的做法还是触发一次re-commitgit commit --amend --no-edit git push origin HEAD:refs/for/masterhook会在amend时补上缺失的Change-Id。这一条对subtree场景尤其容易遇到因为git subtree add产生的那个提交信息是由工具自动生成的不会带Change-Id后续新增的提交也一样不看hook很容易漏。4. 常见问题与排查速查4.1 push被拒Missing Change-Id上面已经提到处理方法是装hook或amend补上footer里的Change-Id。如果你发现改完还是被拒先检查这个Change-Id是不是放在commit message最末尾并且前面空了一行。Gerrit要求Footer区域不空行会被当成正文的一部分。4.2 推送时提示merge commit forbidden有些Gerrit项目配置会直接拒绝包含merge commit的push! [remote rejected] HEAD - refs/for/master (merge commit is not allowed)这就是没加--squash的副作用。解决方案就是上一章说的git reset --soft打平或者干脆把这条分支重建一下。只要新的提交链里没有合并提交这个错误就会消失。4.3 push报unrelated histories合并不了如果你直接执行git subtree add有时会遇到fatal: refusing to merge unrelated histories原因是主项目和外部仓库的根提交完全没有公共祖先。两个仓库历史本来就是独立的不能靠普通merge合并。这种情况不要急着加--allow-unrelated-histories去硬合而是确认你用的是git subtree add而不是git merge。git subtree add内部有自己的处理逻辑通常不会触发这个错误如果触发了很可能你用的是旧版git优先升级。4.4 想用git push -f直接把远程历史改掉被拒在Gerrit工作流里一般你对refs/for/*的push本来就不应该指望force update。成员通常没有权限对refs/heads/master做force push即便有这种做法也会导致所有在途review彻底混乱。所以正确做法是本地重写干净提交之后push到refs/for/master生成新的change老的change在Gerrit上abandon掉。这样既能保持服务器端历史可控又能保证reviewer看到的是新代码。如果确确实实需要直接改远程真实分支只能找Gerrit管理员配合。4.5 subtree目录里改动的代码无法在历史里定位有时候会出现一个很实际的困扰主项目里src/design下面的代码改来改去想查某个变量是什么时候变的git log却只有一堆Add提交。这其实是squash的副作用。历史干净了但subtree目录内部的细粒度历史被丢弃了。我的做法是在真正需要追踪的目录里尽量通过外部仓库自己的review系统去查或者给subtree目录单独维护一个release note / changelog。如果确实需要完整的历史追踪那就在subtree pull之外再把外部仓库作为remote fetch一份需要时直接查那条外部分支。4.6 新同事接手时总是忘记加--squash这个坑不是单次性的而是团队性的。只要团队里有人直接抄git subtree add --prefixxxx repo main这种老写法第二次污染就会发生。我会把标准写法固化到项目文档或内部脚本里git subtree add \ --prefixsrc/components/ui \ --squash \ gitcode.example.com:ui-lib.git \ main同时在Gerrit的review规则里加一条口头共识凡涉及subtree的提交change下只允许出现“add类节点”或“业务改动节点”出现大量陌生历史直接驳回。用流程约束比事后抢救轻松太多。最后再说一下我个人的体会。在Gerrit这种重度code review环境里提交历史不只是记录它直接决定了审查效率。一个看似无关紧要的--squash参数能省下reviewer一整周的无效劳动。如果已经踩了坑也别硬扛git reset --soft重构很快唯一的成本就是记住那条主线parent的sha。我现在做任何subtree集成都会在终端里把add命令多核对一遍确认带了--squash才回车这个习惯帮团队省掉了不少麻烦。