Skip to content

从 merge-base 理解 Git 三方合并

feature-a 合入 master 时没有冲突,并不能证明基于 A 开发的 feature-b 之后同步 master 也不会冲突。

两次操作比较的不是同一组提交:

  • 第一次比较 feature-a 与当时 master 的并行修改。
  • 第二次比较 feature-bmaster 在新的共同祖先之后的修改。

只要 feature-bmaster 从同一个旧版本出发,修改了同一段内容,第二次合并就可能冲突。判断这类问题的关键不是合并命令的方向,而是参与合并的两个分支、它们的 merge-base,以及共同祖先之后的修改范围。

本文以 普通 merge 为前提,解释这个场景的提交关系和三方合并过程,再给出实际排查方法以及 mergerebase 的选择建议。squash mergerebase mergecherry-pick 导致的祖先关系变化放在边界章节说明。

先看结论:两次 merge 比较的范围不同

假设 feature-bfeature-aA2 继续开发:

text
             B1---B2  feature-b
            /
       A1---A2        feature-a
      /
O----+
      \
       M1---M2        master

此时执行第一次合并:

bash
git switch master
git merge feature-a

假设合并成功并产生 MA

text
             B1---B2  feature-b
            /
       A1---A2-------+
      /                \
O----+                  MA  master
      \                /
       M1---M2--------+

MA 有两个父提交:A2M2。因此,A2 既是 feature-b 的祖先,也是当前 master 的祖先。

随后在 feature-b 上同步 master

bash
git switch feature-b
git merge master

两次合并的比较范围如下:

操作merge base当前分支的修改目标分支的修改
master 合并 feature-aOO -> M2O -> A2
feature-b 合并 masterA2A2 -> B2A2 -> MA

第一次没有冲突,只能说明 A 的修改与当时 master 的修改可以自动组合;第二次是否冲突,还要看 B 的修改与 masterA2 之后的修改是否重叠。

一、还原完整的提交关系

本文使用以下提交表示不同阶段的修改:

提交所属分支含义
O公共历史两个功能分支和 master 的分叉点
A1A2feature-a第一阶段功能的提交
B1B2feature-b基于 A2 继续开发的第二阶段功能
M1M2masterA 开发期间主干上的并行提交
MAmasterfeature-a 合入 master 后产生的 merge commit

合并前,feature-b 已经包含 feature-a 的提交:

text
             B1---B2  feature-b
            /
       A1---A2        feature-a
      /
O----+
      \
       M1---M2        master

master 上执行 git merge feature-a 后,master 同时包含 A 和主干原有的提交:

text
             B1---B2  feature-b
            /
       A1---A2-------+
      /                \
O----+                  MA  master
      \                /
       M1---M2--------+

注意,feature-b 的指针没有因为 feature-a 合入 master 而自动移动。它仍然指向 B2,只是它的祖先 A2 现在也出现在 master 的历史中。

二、Git merge 使用三方合并

Git 通常不会只比较两个分支的最终文件,而是以三个版本为输入进行三方合并:

输入含义
merge base两个分支 tip 的共同祖先
ours当前分支的 tip,也就是 HEAD
theirs正在合入的分支 tip

对每个文件,Git 关注两组变化:

text
merge base -> ours
merge base -> theirs

如果两组变化互不干扰,Git 可以把它们组合到同一个结果中;如果两边对相同区域做了无法自动协调的修改,就会标记冲突并要求人工处理。

ourstheirs 取决于当前命令

执行:

bash
git switch master
git merge feature-a

此时:

  • oursmaster
  • theirsfeature-a

执行:

bash
git switch feature-b
git merge master

此时:

  • oursfeature-b
  • theirsmaster

因此,ourstheirs 会随着当前分支和合入目标变化。若参与合并的是同一对分支 tip,仅仅交换合并方向不会改变共同祖先;本例之所以出现不同结果,是因为两次参与合并的分支 tip 已经不同。

冲突不只包括同一行修改

常见冲突类型包括:

  • 两边将同一行修改成不同内容。
  • 一边修改文件,另一边删除文件。
  • 两边新增同名文件。
  • 文件重命名与内容修改无法自动协调。
  • 文件与目录使用了冲突的名称。

反过来,Git 没有报告文本冲突,也不代表合并后的业务逻辑一定正确。Git 能判断文件树和文本能否组合,但不能判断接口调用、数据格式或业务流程是否兼容。合并完成后仍然需要运行测试、构建和必要的业务验证。

三、第一次合并为什么没有冲突

第一次操作是:

bash
git switch master
git merge feature-a

在合并前,masterfeature-a 的共同祖先是 O。三方合并的输入可以表示为:

角色对应内容
merge baseO
oursmasterM1M2
theirsfeature-aA1A2

Git 实际比较:

text
O -> M2
O -> A2

如果 M1M2A1A2 修改的是不同文件、不同配置项,或者修改区域可以自动拼接,Git 就能生成合并结果:

text
master = M2 + A2

例如,初始提交 O 中有一个配置文件:

ini
gateway=legacy
timeout=30

feature-a 只修改网关:

ini
gateway=payment-v2
timeout=30

同期的 master 只修改超时时间:

ini
gateway=legacy
timeout=60

两边修改了不同配置项,Git 可以自动组合为:

ini
gateway=payment-v2
timeout=60

这次无冲突只说明:

text
feature-a 与当时 master 的修改可以自动组合

它没有比较 feature-bB1B2,因此不能对 B 的后续合并作出结论。

四、第二次合并为什么可能冲突

feature-b 是从 A2 创建的。由于 A2 通过 MA 进入了 master,第二次合并时两个分支的共同祖先通常是 A2

bash
git switch feature-b
git merge master

三方合并的输入变成:

角色对应内容
merge baseA2
oursfeature-bB1B2
theirsmaster 相对于 A2 的修改

Git 比较:

text
A2 -> B2
A2 -> MA

A2 看,master 的树包含主干相对旧分叉点产生的变化,feature-b 的树包含 B 相对 A2 产生的变化。只要这两组变化涉及同一代码区域,就可能冲突。

继续使用上面的配置示例:

  • A2 中的 timeout 仍然是 30
  • master 合入 A 后,timeout 变成 60
  • feature-b 没有同步主干,基于旧值 30timeout 改成 45

第二次合并时,Git 看到的三个版本是:

版本timeout 的值
merge baseA230
oursfeature-b45
theirsmaster60

Git 无法仅凭提交历史判断业务上应该使用 45 还是 60,所以需要开发者确认意图并解决冲突。

这个例子揭示了冲突的直接原因:

  1. 第一次合并时,A 没有修改 timeout,所以 A 与主干的修改不重叠。
  2. 第二次合并时,B 和主干都从 timeout=30 出发修改了同一配置项。
  3. 两次操作的共同祖先和比较范围不同,因此结果不同是正常的。

五、如何在实际仓库中定位冲突来源

不要只根据冲突标记猜测原因。先确认提交图和共同祖先,再比较两侧的修改范围。

1. 查看提交图

bash
git log --graph --oneline --decorate --all

重点确认:

  • feature-b 是从哪个提交创建的。
  • A 是否已经进入 master
  • A 通过普通 merge、squash merge 还是其他方式进入主干。
  • masterfeature-b 当前各自指向哪个提交。

2. 计算共同祖先

bash
git merge-base feature-b master

可以把结果保存到变量中,供后续命令使用:

bash
BASE=$(git merge-base feature-b master)
printf '%s\n' "$BASE"
powershell
$base = git merge-base feature-b master
$base

3. 分别查看两侧修改

查看 feature-b 相对共同祖先的修改:

bash
git diff "$BASE..feature-b"
powershell
git diff "$base..feature-b"

查看 master 相对共同祖先的修改:

bash
git diff "$BASE..master"
powershell
git diff "$base..master"

如果只想先确认涉及哪些文件:

bash
git diff --name-status "$BASE..feature-b"
git diff --name-status "$BASE..master"

重点寻找两侧同时修改的文件和相邻代码区域。若两边都改动同一个配置项、函数签名、导入区域或文件路径,通常就能解释冲突来源。

4. 查看提交的父节点

bash
git show -s --format=%P <commit>

普通提交通常只有一个父提交,merge commit 通常有两个父提交。这个命令可以帮助确认 MA 是否确实同时连接了 A2 和主干原来的 tip。

5. 预演合并但不立即创建提交

在工作区干净的前提下,可以先模拟合并:

bash
git switch feature-b
git merge --no-commit --no-ff master

检查结果后,如果不准备保留这次合并:

bash
git merge --abort

--no-commit 不能阻止 fast-forward。若需要无论如何都保留一个待提交的合并状态,可以使用 --no-ff;执行前仍应确认工作区没有未保存的修改。

六、发生冲突时使用 merge 同步主干

对于已经推送、多人共同使用或不希望改写历史的 feature-b,通常优先使用 merge:

bash
git switch feature-b
git status
git fetch origin
git merge origin/master

发生冲突后先查看状态:

bash
git status

然后逐个处理冲突文件,确认最终内容符合业务意图,再标记为已解决并提交:

bash
git add <resolved-files>
git commit

合并提交完成后,至少运行项目约定的测试和构建。例如:

bash
npm test
npm run build

具体命令应以项目的 package.json、贡献指南和 CI 配置为准。验证通过后再推送:

bash
git push origin feature-b

merge 的特点是保留原有提交哈希,并新增一个 merge commit:

text
             B1---B2------MB  feature-b
            /            /
       A1---A2----------MA   master
      /                 /
O----+-----------------+
      \
       M1---M2

它保留了并行开发的历史,其他基于旧提交协作的人不需要因为你的本地整理而重新对齐历史。

七、使用 rebase 重新整理未共享分支

如果 feature-b 尚未共享,或者确认只有当前开发者使用,并且团队允许重写历史,可以让 B 的提交重新应用到最新的 master 之后:

bash
git switch feature-b
git status
git fetch origin
git branch backup/feature-b-before-rebase
git rebase origin/master

rebase 前后可以抽象为:

text
rebase 前:
O---M1---M2---MA  master
 \
  A1---A2---B1---B2  feature-b

rebase 后:
O---M1---M2---MA---B1'---B2'  feature-b

                  master

B1'B2' 是重新生成的提交。它们的修改目的可能与原来的 B 提交相同,但父提交发生了变化,因此提交哈希也会变化。

Rebase 冲突的处理命令

rebase 会逐个重放提交,冲突可能按提交分批出现。解决当前提交后:

bash
git add <resolved-files>
git rebase --continue

取消整个 rebase:

bash
git rebase --abort

跳过当前正在重放的提交:

bash
git rebase --skip

--skip 会丢弃当前提交。只有确认该提交的修改已经存在,或者确实不再需要时才能使用。

Rebase 后推送

如果分支从未推送过,可以正常建立远程分支:

bash
git push -u origin feature-b

如果远程已经保存了 rebase 前的旧历史,通常需要:

bash
git push --force-with-lease origin feature-b

--force-with-lease 会检查远程分支是否仍然指向本地预期的旧提交。若其他人已经更新了远程分支,推送会被拒绝。它比 --force 多一层并发保护,但仍然会改写远程历史,不能把它当作普通 push 使用。

八、Merge 与 Rebase 如何选择

对比项git merge origin/mastergit rebase origin/master
原提交哈希保留重新生成
历史形态保留分叉并新增 merge commit通常形成线性历史
冲突处理通常集中在一次合并中按提交逐个处理
是否需要强制推送不需要远程存在旧历史时通常需要
已共享分支更适合通常不适合
并行开发过程保留被重新组织

可以按分支的共享状态做判断:

text
个人本地分支、尚未共享:可以 rebase
已经推送但确认只有自己使用:协调后可以 rebase
多人共同维护的功能分支:优先 merge
master、main、release 等共享分支:不要随意 rebase

这里的原则不是“merge 永远安全”或“rebase 永远更干净”,而是要避免在没有协调的情况下改写其他人依赖的提交历史。

九、不要把不同的合并策略混为一谈

前面的推导依赖一个重要前提:feature-a 通过普通 merge 进入 master,所以 A2 保留为 master 的祖先。

实际仓库可能使用其他策略:

Squash merge

squash merge 会把 A1A2 的改动压缩为一个新提交,例如 S

text
feature-a:  O---A1---A2

master:     O---M1---M2---S

S 的内容可能包含 A 的修改,但 A2 通常不是 master 的祖先。此时 feature-bmaster 的共同祖先可能仍然是 O,不能直接套用普通 merge 场景中“第二次的 merge base 是 A2”的结论。

Rebase merge 与 cherry-pick

rebase、cherry-pick 和删除后重建分支都会创建新的提交。即使提交标题和文件内容相似,提交哈希、父节点和祖先关系也可能不同。

因此,分析实际冲突时应查看:

bash
git log --graph --oneline --decorate --all
git merge-base feature-b master
git show -s --format=%P <commit>

不要只根据提交标题判断“这个改动已经进入主干”。

多个 merge base

复杂的 criss-cross 历史可能存在多个最佳共同祖先。Git 的合并策略可能先将这些祖先组合成虚拟的 merge base。本文只讨论具有单一共同祖先的常见场景,复杂历史需要结合完整提交图和具体合并策略分析。

十、降低长期分支的冲突成本

缩短同步间隔

不要等功能全部完成后才第一次同步主干。根据团队策略定期执行 merge 或 rebase:

bash
git fetch origin
git merge origin/master

个人未共享分支也可以使用:

bash
git fetch origin
git rebase origin/master

频繁同步不能保证消除冲突,但能缩小单次需要处理的范围,也能让开发者在仍熟悉代码上下文时解决问题。

使用 rerere 复用解决结果

Git 可以记录冲突解决方式:

bash
git config --global rerere.enabled true

查看 Git 记录的冲突路径和解决差异:

bash
git rerere status
git rerere diff

rerere 只能复用过去的处理结果。自动应用后仍需检查文件并运行测试,不能把它当作业务验证的替代品。

明确团队历史策略

建议在 CONTRIBUTING.md 或团队开发规范中约定:

text
哪些分支允许 rebase
哪些分支禁止 force push
功能分支如何同步主干
PR 使用 merge、squash merge 还是 rebase merge
冲突由谁解决并负责验证

工具配置无法替代团队约定。尤其是 pull.rebase、平台合并按钮和分支保护规则不一致时,开发者可能误以为自己执行的是另一种历史操作。

十一、遇到类似问题时的排查清单

可以按以下顺序分析:

  1. 当前要合并的两个分支 tip 分别是哪两个提交?
  2. git merge-base <branch-a> <branch-b> 返回了哪个共同祖先?
  3. 两侧分别修改了共同祖先之后的哪些文件和代码区域?
  4. A 进入 master 时使用的是普通 merge、squash merge、rebase merge 还是 cherry-pick?
  5. B 是否已经推送,是否有人基于 B 继续开发?
  6. 团队是否允许对该分支执行 rebase 和 force push?
  7. 冲突解决后是否运行了测试、构建和必要的业务验证?

最小命令集如下:

bash
git log --graph --oneline --decorate --all
git merge-base feature-b master
git diff <base>..feature-b
git diff <base>..master
git status

总结

“A 合入 master 无冲突,B 同步 master 却冲突”是正常的 Git 行为,根因通常是两次操作使用了不同的共同祖先和不同的比较范围:

text
第一次:比较 feature-a 与 master 的修改
第二次:比较 feature-b 与 master 的修改

在普通 merge 场景中,A 进入 master 后,A 的最新提交可能成为 B 与 master 的新 merge-base。此时 Git 不再比较 A 与主干的全部历史,而是比较 B 和主干从这个共同祖先之后各自产生的变化。若 B 和主干修改了同一内容,就需要人工解决冲突。

真正有效的排查顺序是:

text
确认两个分支 tip
确认 merge-base
比较共同祖先之后的两侧 diff
确认 A 进入主干的策略
根据分支共享状态选择 merge 或 rebase
完成冲突处理后运行测试和构建

掌握 merge-base 和三方合并模型后,就可以从提交历史解释冲突,而不是把冲突归因于“合并方向不同”或 Git 行为随机。

参考资料