明明 revert 了,代码怎么又回来了:多分支并行下的一次"复活"排查
一段一个月前就被 revert 掉的功能,好端端地躺在另一条分支里,而且只回来了一半:类没了,引用还在。翻了一下午历史才理清:revert 只在一条线上做,从 revert 之前拉出的特性分支会把功能原封不动带回别的线,之后再合并时无冲突的文件被干净回退、有冲突的文件靠人工解决却把引用留了下来。讲清这个"四步复活"的机制、为什么 git blame 在这里会骗人、用 --first-parent -S 和 --cc 追合并的排查法,顺带说一个 javac 跳过注解处理造成的"两百条错误只有二十条是真的"假象。
前两天修一条编不过的分支,翻到一个很诡异的东西:一段一个月前就被 revert 掉的功能,好端端地躺在另一条分支里。类文件在,配置在,引用它的地方也在。那次 revert 我记得清清楚楚,提交也确实还在历史里。那这堆代码是怎么回来的?
顺着历史翻了一下午,把来龙去脉理清了。这篇把它写下来,因为它几乎不是某个人的操作失误,而是多条长期分支并行开发时一个结构性的坑:revert 只在一条线上做,它就不是终点。
现象:一条编不过的主开发分支
先说现场。我们的一个 Java 项目有几条长期存在的分支:dev 做日常开发,release 用来发版,main 是稳定线。那天有人把 main 合进了 dev,冲突文件有五十个左右,解完就推了。然后 dev 就编不过了,而且整整一天没人发现,因为部署用的构建只在 CI 上跑、还跳过了测试,本地没人去全量编一遍。
拉下来一编,两百多条错误哗哗往外刷。粗看有这么几类:
- 好几个
import指向的类不存在了 - 同一个类里同一个方法出现了两遍,报
already defined - 一个接口方法改了签名,实现类跟着改了,调用方还是旧写法
- 一个工具方法在目标分支上早被删了,某个刚合入的分支还在调它
第一类最奇怪。被引用的那个类,是一个月前某个功能引入的,功能本身在 release 上提交后几天就被 revert 了。类没了说得通,因为 main 带着那个 revert 合了进来。可引用它的代码为什么还在?revert 应该把功能连根拔掉,引用方也一起回退才对。
根因:一次 revert,四步复活
把时间线摆出来,事情就清楚了。为了叙述方便,我用天数代替具体日期:
- 第 1 天,功能 F 提交到
release。它新增了一个配置类,改了一个编排类去引用它,还改了一份 YAML 模板。 - 第 3 天,同事从
release拉出一个特性分支干别的活。注意这个时间点:功能 F 已经在了,revert 还没发生。 - 第 4 天,功能 F 在
release上被 revert。release的净效果等于”从来没有过 F”。之后release合进main,main也干净。 - 第 15 天,那个特性分支合进
dev。dev此前既没有 F 也没有 revert,合并基线在 F 之前,于是 F 的全部改动,新类、新引用、新模板,作为特性分支的一部分干干净净地进了dev。功能在dev上复活了,没有任何冲突,没有任何人察觉。
到这里,dev 上有 F,main 上没有 F。第 30 天,main 合进 dev,三方合并开始干活。

对于 dev 没改过的文件,比如那个新类和 YAML 模板,main 侧的版本就是”删掉”或”回退”,dev 侧相对基线没动,三方合并直接采纳 main 侧,干净地把它们退掉了。但引用它的那个编排类是个热点文件,dev 上早就有一堆别的改动,两边都动过,冲突。解冲突的人面对一大段陌生代码,最保险的选择是”保留我们这边的”,于是那几行 import 和调用留了下来。
结果就是一个半套残骸:类没了,引用还在。
其他几类错误是同一次合并的副产品。两边都保留了同一个方法,就成了重复定义;接口在 main 侧改了签名,实现类有冲突、被人工对齐了,调用方没冲突、被自动合并,还是旧签名;特性分支基线太老,调用的方法目标线上早删了,这个连文本冲突都没有,只有编译才暴露。

排查:别信 blame,追合并
理清机制之后,回头看排查过程里几个值得记下来的手法。
第一步,找符号是从哪次合并进来的。git blame 在这里会骗人,它追的是每一行最初由谁写的,会把复活的代码归到第 1 天的原作者头上,看起来就像”这功能一直都在”。要回答的问题其实是”这段代码这次是怎么进到 dev 的”,得沿着 dev 的主线看每一次合并带进了什么:
git log --first-parent --format='%h %ad %an %s' --date=short \
-S'RouteConfig' dev -- src/main/java/.../ResourceOperator.java
--first-parent 让 git 只沿着主线走,并且把合并提交按”相对于第一个父提交”展开 diff;-S 找出让某个字符串出现次数发生变化的提交。两个合起来,直接告诉你哪次合并把这个符号带进来、哪次又带走了一半。
第二步,确认谁是复活的载体。功能提交和 revert 提交各在哪些分支上,一查就知道:
git branch -a --contains <feat-sha>
git merge-base --is-ancestor <revert-sha> <某分支> && echo "含 revert"
含 feat 不含 revert 的那条分支,就是把功能带回来的那一个。
第三步,看人工解冲突留下了什么。合并提交的 --cc 视图只显示”和两个父提交都不一样”的片段,也就是人手改过的地方,其他机器自动合的内容全部隐去,一眼就能看到关键几行。如果解冲突的人是在 IDE 里提交的,提交信息里往往还留着 git 自动生成的 # Conflicts: 文件清单,可以直接当排查名单用:
git show --cc <merge-sha> -- path/To/File.java
git log -1 --format=%B <merge-sha>
第四步,决定留哪一版。重复定义的方法,从两个父提交里各取一份出来比对:
git show <merge-sha>^1:path/To/File.java | grep -n 'buildPlan('
git show <merge-sha>^2:path/To/File.java | grep -n 'buildPlan('
完全相同就随便删一份;不同就用 -S 追每一版各自来自哪个提交,通常新一些的那侧是超集。十来处重复我写了个小脚本批量抽方法体做 diff,不凭感觉。
还有一个小坑。git merge-base 在这种交叉合并过的历史里可能有多个候选基线,命令默认只返回一个,而真正的合并用的是把这些候选再合一次得到的虚拟基线。所以”基线里到底有没有 F”这个问题,用单个 merge-base 去判断可能得出反直觉的结论,还是以 --first-parent -S 找到的实际进入点为准。
一个顺带的假象:两百条错误,二十条是真的
修的过程中还有一件事值得单独说。刚开始编译刷出两百多条错误,绝大多数是 cannot find symbol:找不到 log,找不到 getId(),找不到 setName()。这些全是 Lombok 该生成的东西。
原因是 javac 一旦在解析和符号录入这些早期阶段碰到错误,比如重复定义,就不再跑注解处理器了。Lombok 是注解处理器,它不跑,所有该生成的 getter、setter、log 字段全都不存在,于是连锁出上百条假错误。实际上真错误只有二十来条。
所以看到大面积的 Lombok 符号找不到,先别慌,grep 'already defined' 把重复定义修掉,再编一次,错误数会掉一个数量级。

复盘:revert 要落到每一条活着的线上
这次事故里没有哪一步是错的。功能提交没错,revert 没错,从 release 拉分支没错,把 main 合进 dev 也没错。每一步单独看都合理,凑在一起就把一个删掉的功能带了回来,还只带回了一半。
能做的防御有三条:
- revert 不是一条线上的事。要么把 revert 落到所有活跃的线上,要么让做了 revert 的那条线尽快合回其他线,缩短”从 revert 之前拉出的分支还活着”这个窗口。窗口越长,复活的概率越大。
- 解过冲突的合并提交之后,必须全量编译,主代码加测试代码一起,不能只编改动的模块。这次编不过一整天没人知道,就是因为没人在合并后本地全量编一遍。
- 合并大批冲突后,抽查几个关键类。拿
--first-parent -S看看有没有把已经删掉的东西带回来。花五分钟,比事后翻一天历史划算。
顺便说一句,这次的残骸好在都是 .java 文件,编译器替我们兜住了。同一种坏合并如果落在 MyBatis 的 mapper XML 或者 YAML 上,构建照样全绿,要到部署后启动那一刻才炸。那是另一个故事,改天单写。
最后一句话版本:revert 撤销的是一条线上的历史,不是一个功能的存在。只要还有一条分支带着它、不带着那个 revert,它就随时可能从那条分支爬回来。
评论