Skip to content

两个分支的检查都通过了,合并之后主干挂了 ​

两个合并请求,各自的流水线都是绿的,改动的文件互不重叠,合并时没有任何冲突。第二个合并进去之后,主干的构建红了。回头看两个请求的记录,每一项检查都确实通过了。

检查通过说的是「这个分支的这个提交没问题」。合并产生的是一个新的提交,它的内容是两边改动的叠加,这个叠加的结果在合并之前没有人运行过。大多数时候叠加是无害的,所以这个缺口平时不显眼。

本文用 git 把这种情况复现出来,说明检查应该跑在哪个提交上;再看两个相关的情况:撤销一个合并提交之后,为什么再次合并同一个分支拿不回原来的改动;压缩合并之后继续在原分支上开发会怎样。实验见文末。

一、复现 ​

一个仓库里有函数 pricing.total,checkout.py 调用它。从同一个起点拉出两个分支:

  • 分支 A:把 total 改名为 subtotal,并改掉当时全部的调用方。
  • 分支 B:新增 invoice.py,里面调用 pricing.total,附带自己的测试。
起点ABM1M2分支 A:total 改名为 subtotal检查通过分支 B:新文件调用 total检查通过(基于旧的起点)合入 A检查通过合入 B:无文本冲突检查失败total 已不存在这个提交第一次被运行在合并之前检查「如果现在合并会得到的那个提交」,才能把失败挡在主干之外
图 1 · 两个分支从同一个起点出发。一个把函数 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 在不改动工作区的情况下算出这个结果:

bash
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 认为它被修改
先撤销「那次撤销」,再合并 featurefeature.txt 的全部内容与 feature_fix.txt 都在

第四行是最危险的:合并成功了,没有任何提示,但进来的只有修复提交,原来的功能没有回来。合并只带入「主干历史里还没有的提交」,功能分支最初的两个提交早就在主干的历史里了,它们的效果被后面的撤销提交抵消,而撤销提交本身也是主干历史的一部分,不会因为再次合并而失效。

把这段历史画出来:

mainfeaturem0MRM2f1f2f3合并 feature撤销合并再合并没有 feature.txt只带入 f3正确的做法Rgit revert R → R′先撤销那次撤销再合并 feature带入 f3功能与修复都在或者从最新主干重新拉分支、重新提交;撤销提交的说明里写下重新合入的步骤
图 2 · 实测(git):feature 的两个提交 f1、f2 合并进主干后被撤销(R)。在 git 看来 f1、f2 已经在主干的历史里,之后再合并只带入新的修复提交 f3:合并成功、没有提示,主干上有 feature_fix.txt,没有 feature.txt。先撤销那次撤销(R′),再合并,功能和修复才都在

撤销合并提交之后,要重新引入这个分支,有两种正确的做法:

  • 在主干上先撤销那次撤销(git revert <撤销提交>),再合并分支上后续的修复。
  • 放弃旧分支的历史:从最新的主干拉一个新分支,把改动重新提交一遍(提交的身份变了,git 会把它们当作全新的改动)。

撤销提交的说明里应当写清楚「之后要重新合入时,先撤销本提交」,否则几周之后接手的人很难想起这一步。

四、压缩合并之后继续用原分支 ​

压缩合并(squash merge)把分支上的全部改动做成主干上的一个新提交,分支上原来的提交没有进入主干的历史。实验里:

  • 压缩合并 topic 之后,git branch --merged main 不会列出 topic。
  • 主干随后又修改了 topic 改过的同一行;topic 继续开发、追加一个与那一行无关的提交,再合并回主干:那一行冲突,尽管 topic 对它的修改早就在主干里了。

原因是两边没有共同的合并记录:git 仍然从最初的起点开始比较,把 topic 上的旧提交当作尚未合并的改动,与主干后来的修改撞在一起。压缩合并之后,原分支应当删除;需要继续做,就从最新的主干重新拉分支。

五、落到流程上 ​

  1. 让进入主干的提交就是被检查过的提交:用合并队列,或者要求分支在合并前跟上主干。两者都做不到时,至少在合并后立即运行主干的检查,并约定主干失败时优先修复或回退。
  2. 分支的寿命越短越好。从拉出到合并的时间越长,主干走得越远,叠加出问题的机会越多。
  3. 改名、改签名、改配置项这类「影响别处」的改动,合并前在最新的主干上全局搜索一次调用方;必要时分两步走,先让新旧名字并存,再删除旧的(与数据库的扩展—收缩是同一个思路,见 能重新部署旧版本,不等于能回滚)。
  4. 撤销合并提交时,在说明里写下重新合入的步骤。
  5. 压缩合并之后删除原分支。
  6. 检查通过的结论要和提交绑定:流水线、审批记录、制品上都带着它所对应的提交标识,而不是分支名(镜像一侧的同一个问题见 同一个镜像 tag,两个节点上跑的不是同一个镜像)。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权