分支落后主分支三个月,我们是怎么拆成小块分批合进去的

分支落后三个月还想一次性合回去,基本等于给自己挖坑。我们最后是把它拆成 27 个可独立验证的合并块,按依赖顺序分 6 批合入,每批间隔 2 到 3 天,最终冲突解决耗时从预估的「两周起步」压缩到了 4 个工作日左右。

先摸清差距,再谈拆分

三个月落后意味着什么?在我们这个项目里,是 412 个 commit、87 个文件变更、以及主分支上 11 次数据库迁移。不了解这些数字之前,任何拆分都是拍脑袋。

第一步做的事很土但有效:把两个分支的 diff 全部导出来,按目录和模块做统计。我们用一条命令先看整体规模:

git diff --stat main...feature-branch | sort -k3 -nr | head -50

结果发现 87 个文件里,有 23 个集中在订单模块,19 个在用户中心,剩下的分散在 30 多个目录里。这不是废话——它告诉我们,真正需要精细处理的是前两个模块,其余大部分文件改动是机械性的,可以整体打包。

同时把 commit 列表按作者和日期拉出来:

git log main..feature-branch --pretty=format:"%h %an %ad %s" --date=short

看这个输出能发现很多问题:哪些 commit 是连续开发的、哪些是中间救火临时加的、哪些已经过时了。我们当时就发现 7 个 commit 改的功能主分支上已经用另一种方式实现了,直接标记为「废弃,不合入」,这部分工作量直接归零。

拆分不是按 commit,是按「可独立验证的功能单元」

一开始团队里有人提议按 commit 顺序逐个 cherry-pick。我强烈反对。三个月里 commit 之间耦合严重,按 commit 拆会陷入无休止的依赖追踪。

我们的做法是先花了半天时间,把 feature 分支上的所有改动按业务功能重新分组。最终分了 27 块,每一块满足三个条件:

  1. 独立编译通过 —— 不依赖其他尚未合入的块;
  2. 有明确的验收标准 —— 能说清楚「这块合进去之后,什么行为变了」;
  3. 冲突面可控 —— 涉及的文件列表与其他块尽量少重叠。

举个例子,feature 分支里有一个「优惠券过期提醒」功能,它改了 6 个文件,但其中 2 个文件同时也被「会员等级重构」改动。如果这两块拆开合,那 2 个文件要解决两次冲突。我们最后把这两块合并成一个块,虽然块变大了,但冲突只解决一次。

这里有个反直觉的点:拆分的目标不是块越小越好,而是冲突次数最少、验证成本最低。有些块很小但牵扯面广,有些块大但文件完全独立,后者反而更容易处理。

分批合入的顺序:先基础设施,后业务功能

27 块之间是有依赖关系的。我们画了一张简单的依赖图,然后按以下顺序排了 6 批:

第 1 批(3 块):纯新增文件、工具类、常量定义。这些几乎不会产生冲突,先合进去能缩小后续 diff 的规模。

第 2 批(5 块):数据库迁移脚本。这个要单独说。三个月里 feature 分支积累了 11 个 migration 文件,如果和业务代码一起合,很容易出现「代码用了新字段但 migration 还没跑」的中间状态。我们把这 11 个 migration 拆成 3 块,按时间顺序合入,每合一批就在测试环境跑一遍完整迁移。

第 3 批(6 块):被多个功能共享的底层改动,比如订单状态机的枚举扩展、用户表的新增字段。这些是后面业务功能的地基。

第 4 批(8 块):核心业务功能,冲突最多的一批。订单模块和用户中心的改动主要在这一批。

第 5 批(4 块):次要功能和 UI 调整。

第 6 批(1 块):废弃代码清理和收尾。

每批合入后跑完整 CI,包括单元测试、集成测试和一次手动冒烟。第 4 批合完那天,我们专门留了一整天做回归,因为那批的冲突解决涉及订单状态流转的核心逻辑。

冲突解决的具体操作

合入方式上,我们没有用 git merge,而是用 git cherry-pick 配合 --no-commit,或者直接手动把改动搬过来。对于冲突较多的块,cherry-pick 反而比 merge 更清晰,因为你可以逐个文件决定「保留哪个版本」。

具体流程是这样的:

# 从 feature 分支上找到该功能块对应的 commit 范围
git log main..feature-branch --oneline -- path/to/module/

# 逐个 cherry-pick,不自动提交
git cherry-pick --no-commit <commit-hash>

# 解决冲突后,检查 diff 是否只包含该功能块的改动
git diff --cached --stat

# 确认无误后提交
git commit -m "feat: 合入优惠券过期提醒功能(渐进式合并第4批)"

这里有一个关键动作:每解决完一个块的冲突,逐文件审查 diff。不是信任 cherry-pick 的结果,而是把 git diff --cached 的完整输出过一遍。三个月的时间差里,主分支上很多代码被重构过,cherry-pick 有时候会「成功」应用,但逻辑上已经对不上了。我们在第 4 批就发现过一个案例:feature 分支调用了一个函数 OrderService.calculateRefund(),主分支上这个函数签名已经改成 calculateRefund(RefundContext context),cherry-pick 没有报冲突,但编译直接挂了。

对于这种「无冲突但逻辑不匹配」的情况,没有捷径,只能靠编译器和测试覆盖。这也是为什么每批合入后必须跑完整 CI,而不是等到最后一起跑。

时间是怎么省下来的

如果一次性合入,预估的冲突解决时间是多少?我们当时拉了两个分支做了一次 dry-run merge,冲突文件 41 个,涉及代码 3000 多行。按每小时能认真处理 200 行冲突代码的速度算,光解决冲突就要 15 小时以上,加上测试和修复,两周确实不夸张。

拆成 6 批之后,每一批的冲突文件数量分别是:0、2、5、11、3、0。最多的一批是 11 个文件,但那批的冲突大多是机械性的(导入语句、格式化差异),真正需要动脑子的核心逻辑冲突集中在 4 个文件里,半天就处理完了。

还有一个隐性收益:分批合入让代码评审变得可行。一次性 412 个 commit 的 PR,没人会认真看,最后就是「点了,过了」。拆成 27 个块之后,每个块对应一个独立的 PR,评审人能真正理解每个改动在做什么。第 4 批里有一个 PR 被评审人发现了一个多月前引入的边界条件 bug,如果是一次性合入,这个 bug 大概率就进去了。

常见问题

落后三个月的分支,拆成小块合入要花多久?

我们这次总共花了 8 个工作日,其中 2 天做差距分析和拆分规划,4 天执行合入和解决冲突,2 天做全量回归和收尾。如果一次性合入,预估是 10 到 14 个工作日,而且风险集中在最后几天。

拆分的时候怎么判断哪些改动可以废弃?

看主分支上是否已经有相同或替代的功能。具体做法是对照 commit message 和改动文件,如果 feature 分支上某个功能涉及的文件在主分支上已经被删除或大幅重构,就去找主分支上对应的实现,确认功能是否已经被覆盖。我们这次废弃了 7 个 commit,都是这种情况。

如果块之间的依赖关系很复杂,拆不动怎么办?

先拆「叶子节点」——那些不被其他块依赖的改动。把能独立合的先合掉,剩下的依赖关系会逐渐简化。如果最后剩下一团互相依赖的改动,就把它们合成一个大块,接受一次较大的冲突解决,但这时因为其他部分已经合入,这个「大块」的规模也已经比最初小很多了。

cherry-pick 和 merge 在渐进式合并里怎么选?

如果功能块的 commit 边界清晰、数量不多,cherry-pick 更可控,可以精确控制合入的内容。如果某个功能块的改动分散在大量 commit 里,用 git merge --squash 或者手动搬代码更实际。我们这次两种都用过,但无论哪种方式,合入后逐文件审查 diff 这一步不能省。