三路合并:冲突到底是怎么产生的
冲突大概是 Git 里最招人烦的东西。但它一点都不神秘——它是一个可以推导的结论,规则简单到能用三句话说完。这一章跑一台真的 diff3 引擎,你可以亲手把冲突造出来。
规则只有三句话
拿到 base、ours、theirs 三个版本(第 16 章),Git 逐个区域地问:
对每一个区域:
- 只有一边改了 → 取改了的那一边。
- 两边改成了一样的 → 取那个结果(没什么可争的)。
- 两边改了,而且不一样 → 冲突。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 下留了一堆状态文件:
$ 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」的精确判据。