卷 IV · 网CH 17深度 17/24

三路合并:冲突到底是怎么产生的

冲突大概是 Git 里最招人烦的东西。但它一点都不神秘——它是一个可以推导的结论,规则简单到能用三句话说完。这一章跑一台真的 diff3 引擎,你可以亲手把冲突造出来。

三路合并diff3冲突标记zdiff3

规则只有三句话

拿到 base、ours、theirs 三个版本(第 16 章),Git 逐个区域地问:

◆ 三路合并的全部规则

对每一个区域:

  1. 只有一边改了 → 取改了的那一边。
  2. 两边改成了一样的 → 取那个结果(没什么可争的)。
  3. 两边改了,而且不一样冲突。Git 不猜,把两个版本原样并排写进文件,停下来等你。

就这三条。没有启发式,没有「智能判断」,没有优先级。

所以「为什么这里冲突了」永远有一个确定的答案:因为你俩都动了同一个区域,而且动得不一样。

▶ 动手 · 真三路合并

五个案例过一遍,然后三个框都能自己改

建议顺序:① 看「改了不同地方」怎么自动合并 → ② 看「改了同一行」怎么冲突 → ③ 看「改成一样」为什么冲突 → ④ 自己造一个冲突出来。

那些标记只是文本

冲突之后,你的文件里会出现这个:

版本 = 1.0
<<<<<<< HEAD
端口 = 3000
=======
端口 = 8080
>>>>>>> feat
作者 = 佚名
◆ 请把这件事记牢

这些标记不是什么特殊格式,就是普普通通的文本行,直接写进了你的源文件。

Git 没有「冲突模式」,没有在别处存一份原始文件。它就是把这几行插进去了。

两个后果:

  • 忘了删就会连着提交上去,然后编译器用一句莫名其妙的语法错误提醒你。这是每个人都干过一次的事。
  • 「解决冲突」的本质就是:把这段编辑成你想要的样子。你可以留 ours、留 theirs、两个都留、或者写一个全新的第三种写法——Git 完全不在乎,它只看你最后交出什么。

然后 git add 告诉它你处理完了。

git add 在这里的技术含义,第 11 章已经埋好了伏笔:

$ git ls-files --stage conflict.txt
100644 aaaaaaa... 1	conflict.txt    ← base
100644 bbbbbbb... 2	conflict.txt    ← ours
100644 ccccccc... 3	conflict.txt    ← theirs

冲突时,索引里同一个路径有三条记录,槽位分别是 1/2/3。

git add conflict.txt 干的事是:把这三条删掉,换成一条槽位 0 的记录,指向你编辑后的内容。

这也解释了为什么冲突没解决完就不让你提交——索引里还有非 0 槽位的记录,write-tree 根本没法折成一棵树(第 11 章),它不知道该用哪一版。

顺带,这三个槽位还能直接取出来看:

$ git show :1:conflict.txt    # base(分开时的样子)
$ git show :2:conflict.txt    # ours
$ git show :3:conflict.txt    # theirs

冲突时想看清「本来长什么样」,这是最直接的办法。

把冲突标记改得更有用

默认的冲突标记有个大问题:它只给你 ours 和 theirs,不给你 base。

可你要判断「该保留谁」,恰恰需要知道原来是什么样——不然你只能看到两个都合理的版本在那儿干瞪眼。

换个冲突样式:

$ git config --global merge.conflictStyle zdiff3

然后冲突会变成这样:

<<<<<<< HEAD
端口 = 3000
||||||| base
端口 = 80
=======
端口 = 8080
>>>>>>> feat

多了中间那段 ||||||| base现在你能看出来:原来是 80,两边各自改成了 3000 和 8080。判断依据一下就有了。

◆ 这大概是全书最值得立刻去设的一个配置

zdiff3(Git 2.35+)比老的 diff3 更好:它会把两边共同的部分提到冲突块外面,让冲突区域更小、更聚焦。

成本:一行配置。收益:以后每一次冲突都更容易判断。

$ git config --global merge.conflictStyle zdiff3

为什么会冲突在「我没改过的行」上

这是最让人恼火的一类冲突。有两个原因,都不是 Git 在耍你。

原因一:区域是按块划的,不是按行

diff3 算法先用 LCS 找出「两边都原样保留的行」作为同步点,然后把同步点之间的整段拿出来做三方比较。

如果你改了第 10 行、同事改了第 11 行,而它们之间没有同步点(比如两行都被改了,中间没有未改动的行),那么这两行会被划进同一个区域——于是「两边都改了这个区域」,冲突。

你可以在上面那台 demo 里造出这种情况:把 ours 改第二行、theirs 改第三行,如果它们相邻,就会一起进冲突块。

解法:merge.conflictStyle zdiff3 会把这种情况的冲突块缩到最小,因为它会把共同部分提出去。

原因二:交叉合并的虚拟 base

第 16 章那个病例:两个 merge-base 时,Git 会递归造一个虚拟 base

那个 base 在历史上从未真实存在过。拿它做参照物,得出的「谁改了什么」可能跟你的直觉完全对不上。

撞见莫名其妙的冲突时,先跑一句:

$ git merge-base --all main feat

输出超过一行,你就知道原因了。

⚠ 那些不是「内容冲突」的冲突

三路合并讲的是文件内容。但 Git 合并的是整棵树(第 3 章),所以还有一类冲突发生在树这一层,报错信息也长得不一样:

类型怎么发生的
rename/rename两边把同一个文件改成了不同的名字
rename/delete一边改名,另一边删了它
add/add两边各自新建了同名文件,内容不同
modify/delete一边改了内容,另一边把整个文件删了
directory/file一边把 x 变成目录,另一边把它当文件改

这类冲突没有冲突标记可以编辑——因为问题不在文件内部,而在「这个文件该不该存在、该叫什么」。你得用 git rm / git add 明确告诉 Git 你的决定。

重命名类的尤其常见,而根源在第 3 章那个事实:Git 不记录重命名,它是现场推断的。两边各自推断出的结果可能都不一样,Git 得在两个推断之间做调和。

⌗ 掀开 .git 看一眼

合并进行中时,Git 在 .git 下留了一堆状态文件:

$ ls .git/*MERGE* .git/MERGE_*
.git/MERGE_HEAD      正在合并进来的那个提交(「theirs」是谁)
.git/MERGE_MSG       预先生成的提交信息
.git/MERGE_MODE      合并模式(比如是不是 --no-ff)
.git/ORIG_HEAD       合并前你在哪(后悔时用)

MERGE_HEAD 存在 = 「合并进行中」。这就是为什么冲突期间 git status 会显示 You have unmerged paths,也是为什么 git commit 不用你写信息就能自动生成一句「Merge branch ...」。

放弃合并:

$ git merge --abort      # 干净地退回合并前(内部就是用 ORIG_HEAD)

还有一个能直接省掉重复劳动的功能,很多人不知道:

$ git config --global rerere.enabled true

rerere = Reuse Recorded Resolution(重用已记录的解决方案)。开了之后,Git 会把你每次解决冲突的方式记下来,下次遇到一模一样的冲突时自动套用。

什么时候会遇到一模一样的冲突?反复 rebase 同一个长期分支的时候——每次 rebase 都要重解一遍同样的冲突,简直折磨。开了 rerere 就只用解一次。

✎ 那些「合并策略」是什么

你可能见过 -X ours 之类的参数,容易和别的东西搞混,这里理一下:

写法意思
-X ours只在冲突的地方选我方;不冲突的地方照常正常合并
-X theirs同上,冲突处选对方
-s ours完全不同的东西:造一个合并提交,但内容完全用我方,对方的改动全部丢弃
-X ignore-all-space合并时忽略空白差异——处理缩进变更引发的冲突时非常好用

-X ours-s ours 差得非常远,别打错。前者是「冲突时听我的」,后者是「假装合并了但其实啥也没合」。

-s ours 有一个正当用途:标记「这个分支的内容我们决定不要了,但把它在图上并进来,免得以后反复提示要合并」。除此之外基本不该用。

↩ 回到你的仓库

「合并了一个大分支,二十几个文件冲突,我该从哪儿下手?」

先别一个个打开文件。按这个顺序:

# 1. 只看冲突的文件,别被其他改动干扰
$ git diff --name-only --diff-filter=U

# 2. 分类:内容冲突 vs 树冲突(后者要单独处理)
$ git status --short | grep -E '^(UU|AA|DU|UD|AU|UA|DD)'
#   UU = 两边都改了内容(最常见,编辑冲突标记即可)
#   AA = 两边都新建了同名文件
#   DU / UD = 一边删了一边改了

# 3. 对每个文件,先看清「本来是什么样」
$ git show :1:那个文件      # base

# 4. 有些文件可以直接决定,不用手工编辑
$ git checkout --ours 那个文件     # 整个文件用我方
$ git checkout --theirs 那个文件   # 整个文件用对方
$ git add 那个文件

# 5. 全部解决后
$ git commit

两个能大幅减少痛苦的习惯:

  • rerere——反复 rebase 时只用解一次。
  • zdiff3——每次都能看到 base。

还有一条更根本的:冲突的规模正比于「分开的时间」。一个开了两周的分支,冲突量会远大于两天的。频繁地把主干合进来(或 rebase 上去),是把一次大痛苦拆成很多次小痛苦——而小痛苦的总和,远小于那次大的。

这一章的一句话

三路合并的规则只有三条:一边改了取那边、两边改成一样取结果、两边改得不一样就冲突。冲突标记是普通的文本行,直接写在源文件里,而索引里此时同一路径有 1/2/3 三个槽位并存——git add 就是把它们塌缩成槽位 0。冲突不是 Git 的脾气,是一个可以推导的结论。

下一章:同样是调和两条线,rebase 走的是完全不同的路。它不产生合并提交,而是把你的提交重新做一遍——于是所有哈希都变了。我们会把那个连锁反应完整走一遍,并给出「什么时候能 rebase」的精确判据。