diff3 与三路合并
参考:Merging with diff3(James Coglan)
算法形式化可参考 Sanjeev Khanna、Keshav Kunal、Benjamin C. Pierce 的论文;Unix 上的 diff3 工具最早可追溯到 1988 年(Randy Smith)。
为什么合并不只是「两个快照拼起来」
两个分支各自改同一文件时,直觉上会想到「Alice 改了第 3 行、Bob 删了第 5 行」这类描述。但行号会随编辑漂移:先插入再删除时,「第 5 行」指的早已不是同一段内容,甚至可能误删对方新增的行。
根本原因是:并发分支上的提交在时间上只有偏序,没有唯一「谁先谁后」——这和实时协作里的操作变换(OT)面对的是同一类困难,只是 VCS 里通常用三路合并而不是在线 OT 协议。
Git 为何仍能「算出差量再合并」
不少系统以 patch/diff 为主存储提交;Git 存的是完整快照,并不持久化「版本之间的差异」。合并时仍可这样做:
- 取要合并的两个版本
A、B; - 再取它们的共同祖先(merge base)上的版本
O; - 分别算
O → A、O → B的差异,相当于从文本反推各自相对祖先改了什么; - 再把这些变更相对祖先对齐、综合,得到合并结果。
完成第 3、4 步的常见工具就是 diff3 合并算法。
输入输出:什么是「三路」
算法输入是三个文本(通常按行处理):
| 角色 | 含义 |
|---|---|
O(original / base) | 双方分叉前的共同版本 |
A | 一侧修改后的版本(如当前分支) |
B | 另一侧修改后的版本(如被合并进来的分支) |
输出是一个合并文本;若某段 A、B 相对 O 都有修改且内容不一致,算法无法语义裁决,就标成冲突,交给人工处理。因为始终用到三个文件,所以叫三路合并(three-way merge)。
算法在做什么(直观流程)
- 算两遍 diff:
diff(O, A)与diff(O, B)。底层用 Myers 或别的 diff 实现都可以(见前文 Myers、线性空间 Myers)。 - 找「在两边 diff 里都未改动的行」:这些行在三份文件里语义上对齐,用来把全文切成一段段 chunk。
- 按 chunk 决策:
- 三份相同:直接输出(无人修改)。
- 仅
A与O不同:采纳A的改法;仅B与O不同:采纳B。 A、B都与O不同:冲突——输出双方(以及可选的祖先片段)供用户解决。
文中「食材列表」例子(celery / garlic / onions / salmon / tomatoes / wine)说明:Alice 重排、Bob 重排后,中间一段在两边 diff 里都对不上祖先的同一段落,会被判为冲突;只有单侧相对祖先变化时则自动合并。
Git 里的冲突标记
默认冲突块大致为:
<<<<<<< A 侧名称
……
=======
……
>>>>>>> B 侧名称若设置 merge.conflictStyle = diff3(或使用独立 diff3 程序),冲突块中还会插入祖先 O 的内容,便于对照:
<<<<<<< A
……
||||||| Original(或 base)
……
=======
……
>>>>>>> B实现上的一点提示(match set)
工程实现里常先对 O 与 A、O 与 B 做 diff,为「等于关系」建行号映射(match set),再在扫描时用映射判断「当前位置在三份文件中是否仍对齐到同一条祖先行」,从而交替找出匹配块与不匹配块,最后按规则发出 Clean / Conflict 等 chunk。Coglan 文中给出了与上述文字对应的循环与 write_chunk 逻辑。
局限:严重依赖底层 diff
diff3 本身规则并不复杂,但合并结果(尤其是冲突边界与是否误报冲突)对底层 diff 很敏感:同一次三路合并,换一种 diff 或不同「最短路」选择,无冲突时的最终结果应一致,但冲突块的范围与内容可能变,甚至出现看起来很怪的冲突。更细的失败模式与缓解思路,博文后续篇目有展开。
小结
| 要点 | 内容 |
|---|---|
| 动机 | 两路快照 + 行号无法表达并发编辑的先后 |
| 做法 | O 为锚,对 A、B 各算相对 O 的 diff,再按 chunk 合并 |
| 自动合并 | 只有一侧相对 O 变化时,采信该侧 |
| 冲突 | 两侧相对 O 都变且不一致 |
| 配置 | merge.conflictStyle = diff3 可在冲突中显示祖先 |
相关:Myers 差分 · 线性空间 Myers