两个分支的检查都通过了,合并之后主干挂了
两个合并请求,各自的流水线都是绿的,改动的文件互不重叠,合并时没有任何冲突。第二个合并进去之后,主干的构建红了。回头看两个请求的记录,每一项检查都确实通过了。
检查通过说的是「这个分支的这个提交没问题」。合并产生的是一个新的提交,它的内容是两边改动的叠加,这个叠加的结果在合并之前没有人运行过。大多数时候叠加是无害的,所以这个缺口平时不显眼。
本文用 git 把这种情况复现出来,说明检查应该跑在哪个提交上;再看两个相关的情况:撤销一个合并提交之后,为什么再次合并同一个分支拿不回原来的改动;压缩合并之后继续在原分支上开发会怎样。实验见文末。
一、复现
一个仓库里有函数 pricing.total,checkout.py 调用它。从同一个起点拉出两个分支:
- 分支 A:把
total改名为subtotal,并改掉当时全部的调用方。 - 分支 B:新增
invoice.py,里面调用pricing.total,附带自己的测试。
total 改了名,并改掉了当时所有的调用方;另一个新增了一个调用 total 的文件。两边各自的检查都通过,改的文件互不重叠,git 合并时没有冲突。可是合并出来的那个提交里,新文件调用了一个已经不存在的函数。检查跑过的是两个分支的顶端,没有人跑过合并之后的这个提交。| 检查的对象 | 结果 |
|---|---|
| 分支 A 的顶端 | 通过 |
| 分支 B 的顶端 | 通过 |
| 主干合入 A 之后再合入 B:git 报告的文本冲突 | 0 处 |
| 合并之后的主干 | 失败:AttributeError: module 'pricing' has no attribute 'total' |
git 的合并按文件、按行比较三个版本(共同的起点和两边的顶端)。两边改的是不同的文件,或者同一个文件里不相邻的行,就没有冲突。它不知道 invoice.py 里的 total 和 pricing.py 里被改名的 total 是同一个东西。
这类「文本上不冲突、语义上冲突」的情况有很多形态:
- 一边改了函数的签名、名字或返回值的含义,另一边新增了一处调用。
- 一边改了配置项的名字或默认值,另一边新增了读取它的代码。
- 两边各加了一个数据库迁移脚本,序号相同,或者都假设自己是最后一个。
- 一边删掉了一个「没人用」的依赖,另一边刚好开始用它。
- 两边各自新增了一个同名的测试、路由或者枚举值。
团队越大、主干变化越快,一个分支从拉出来到合并期间主干走得越远,这种叠加出问题的机会越多。
二、检查应该跑在哪个提交上
问题的根源是:被检查的提交和最终进入主干的提交不是同一个。有三个地方可以运行检查,它们检查的对象不同。
分支的顶端。 最常见的配置。它检查的是「分支拉出时的主干加上本分支的改动」,主干后来的变化不在里面。上面的实验里,分支 B 的检查通过,就是因为它眼里的 total 还在。
先让分支跟上主干,再检查。 把最新的主干合入(或变基到)分支,然后在分支上检查。实验里这样做之后,B 分支上的检查失败了,问题在合并之前暴露。代码托管平台上对应的设置通常叫「合并前要求分支是最新的」。它的代价在主干繁忙时很明显:每次有别的请求合入,所有排队的分支都要重新更新、重新跑一遍检查,最后只有一个能合进去,其余的再来一轮。
检查「如果现在合并会得到的那个提交」。 不动分支,直接算出合并的结果并检查它。git 2.38 起可以用 merge-tree 在不改动工作区的情况下算出这个结果:
tree=$(git merge-tree --write-tree main invoice) # 有文本冲突时返回非零
git archive "$tree" | tar -x -C /tmp/speculative # 把合并结果取到一个临时目录
# 在这个目录里运行检查实验里这个结果没有文本冲突,检查失败,和真正合并之后的失败是同一个错误。平台上的「合并队列」做的就是这件事,并且把顺序也管起来:请求依次进入队列,每个请求都在「主干加上排在它前面的所有请求」的基础上检查,通过了才真正合入。这样进入主干的每一个提交都恰好是被检查过的那一个。
有一个容易混淆的地方:有些平台在合并请求上运行的检查,对象本来就是「分支与目标分支的临时合并结果」,而不是分支顶端。但这个临时结果是在检查触发的那一刻计算的。检查通过之后目标分支又前进了,如果没有重新触发,绿色的结论对应的还是旧的那次计算。判断一条检查结论是否仍然有效,要看它针对的那个提交和现在要合并出来的提交是不是同一个。
合并之后再跑一遍主干的检查仍然有用,但它是事后发现:失败时主干已经坏了,其他人拉到的是坏的代码。
三、撤销一个合并提交之后
主干合入功能分支之后发现有问题,常见的处理是用 git revert -m 1 <合并提交> 把这次合并撤销。主干的内容回到了合并之前,但历史没有:在 git 看来,功能分支上的那些提交仍然「已经合并过了」。实验按顺序做了几件事:
| 操作 | 结果 |
|---|---|
合并 feature(新增 feature.txt,两个提交) | 主干上有 feature.txt |
| 撤销这个合并提交 | feature.txt 消失 |
| feature 没有新提交,再合并一次 | Already up to date.,什么也没发生 |
feature 上追加一个修复提交(新文件 feature_fix.txt),再合并 | 合并成功;主干上有 feature_fix.txt,没有 feature.txt |
feature 上再追加一个修改 feature.txt 的提交,再合并 | 冲突:主干认为这个文件已删除,feature 认为它被修改 |
| 先撤销「那次撤销」,再合并 feature | feature.txt 的全部内容与 feature_fix.txt 都在 |
第四行是最危险的:合并成功了,没有任何提示,但进来的只有修复提交,原来的功能没有回来。合并只带入「主干历史里还没有的提交」,功能分支最初的两个提交早就在主干的历史里了,它们的效果被后面的撤销提交抵消,而撤销提交本身也是主干历史的一部分,不会因为再次合并而失效。
把这段历史画出来:
feature_fix.txt,没有 feature.txt。先撤销那次撤销(R′),再合并,功能和修复才都在撤销合并提交之后,要重新引入这个分支,有两种正确的做法:
- 在主干上先撤销那次撤销(
git revert <撤销提交>),再合并分支上后续的修复。 - 放弃旧分支的历史:从最新的主干拉一个新分支,把改动重新提交一遍(提交的身份变了,git 会把它们当作全新的改动)。
撤销提交的说明里应当写清楚「之后要重新合入时,先撤销本提交」,否则几周之后接手的人很难想起这一步。
四、压缩合并之后继续用原分支
压缩合并(squash merge)把分支上的全部改动做成主干上的一个新提交,分支上原来的提交没有进入主干的历史。实验里:
- 压缩合并 topic 之后,
git branch --merged main不会列出 topic。 - 主干随后又修改了 topic 改过的同一行;topic 继续开发、追加一个与那一行无关的提交,再合并回主干:那一行冲突,尽管 topic 对它的修改早就在主干里了。
原因是两边没有共同的合并记录:git 仍然从最初的起点开始比较,把 topic 上的旧提交当作尚未合并的改动,与主干后来的修改撞在一起。压缩合并之后,原分支应当删除;需要继续做,就从最新的主干重新拉分支。
五、落到流程上
- 让进入主干的提交就是被检查过的提交:用合并队列,或者要求分支在合并前跟上主干。两者都做不到时,至少在合并后立即运行主干的检查,并约定主干失败时优先修复或回退。
- 分支的寿命越短越好。从拉出到合并的时间越长,主干走得越远,叠加出问题的机会越多。
- 改名、改签名、改配置项这类「影响别处」的改动,合并前在最新的主干上全局搜索一次调用方;必要时分两步走,先让新旧名字并存,再删除旧的(与数据库的扩展—收缩是同一个思路,见 能重新部署旧版本,不等于能回滚)。
- 撤销合并提交时,在说明里写下重新合入的步骤。
- 压缩合并之后删除原分支。
- 检查通过的结论要和提交绑定:流水线、审批记录、制品上都带着它所对应的提交标识,而不是分支名(镜像一侧的同一个问题见 同一个镜像 tag,两个节点上跑的不是同一个镜像)。
配套实验
- codesphere-labs/engineering/untested-merge-result:用 git 2.55.0 构造三组场景:两个分支各自通过而合并结果失败(含
merge-tree预先检查与先更新分支两种做法)、撤销合并提交后再次合并、压缩合并后继续在原分支开发(验证记录)
参考资料