从 merge-base 理解 Git 三方合并
feature-a 合入 master 时没有冲突,并不能证明基于 A 开发的 feature-b 之后同步 master 也不会冲突。
两次操作比较的不是同一组提交:
- 第一次比较
feature-a与当时master的并行修改。 - 第二次比较
feature-b与master在新的共同祖先之后的修改。
只要 feature-b 和 master 从同一个旧版本出发,修改了同一段内容,第二次合并就可能冲突。判断这类问题的关键不是合并命令的方向,而是参与合并的两个分支、它们的 merge-base,以及共同祖先之后的修改范围。
本文以 普通 merge 为前提,解释这个场景的提交关系和三方合并过程,再给出实际排查方法以及 merge、rebase 的选择建议。squash merge、rebase merge 和 cherry-pick 导致的祖先关系变化放在边界章节说明。
先看结论:两次 merge 比较的范围不同
假设 feature-b 从 feature-a 的 A2 继续开发:
B1---B2 feature-b
/
A1---A2 feature-a
/
O----+
\
M1---M2 master此时执行第一次合并:
git switch master
git merge feature-a假设合并成功并产生 MA:
B1---B2 feature-b
/
A1---A2-------+
/ \
O----+ MA master
\ /
M1---M2--------+MA 有两个父提交:A2 和 M2。因此,A2 既是 feature-b 的祖先,也是当前 master 的祖先。
随后在 feature-b 上同步 master:
git switch feature-b
git merge master两次合并的比较范围如下:
| 操作 | merge base | 当前分支的修改 | 目标分支的修改 |
|---|---|---|---|
master 合并 feature-a | O | O -> M2 | O -> A2 |
feature-b 合并 master | A2 | A2 -> B2 | A2 -> MA |
第一次没有冲突,只能说明 A 的修改与当时 master 的修改可以自动组合;第二次是否冲突,还要看 B 的修改与 master 在 A2 之后的修改是否重叠。
一、还原完整的提交关系
本文使用以下提交表示不同阶段的修改:
| 提交 | 所属分支 | 含义 |
|---|---|---|
O | 公共历史 | 两个功能分支和 master 的分叉点 |
A1、A2 | feature-a | 第一阶段功能的提交 |
B1、B2 | feature-b | 基于 A2 继续开发的第二阶段功能 |
M1、M2 | master | A 开发期间主干上的并行提交 |
MA | master | feature-a 合入 master 后产生的 merge commit |
合并前,feature-b 已经包含 feature-a 的提交:
B1---B2 feature-b
/
A1---A2 feature-a
/
O----+
\
M1---M2 master在 master 上执行 git merge feature-a 后,master 同时包含 A 和主干原有的提交:
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 关注两组变化:
merge base -> ours
merge base -> theirs如果两组变化互不干扰,Git 可以把它们组合到同一个结果中;如果两边对相同区域做了无法自动协调的修改,就会标记冲突并要求人工处理。
ours 和 theirs 取决于当前命令
执行:
git switch master
git merge feature-a此时:
ours是master。theirs是feature-a。
执行:
git switch feature-b
git merge master此时:
ours是feature-b。theirs是master。
因此,ours 和 theirs 会随着当前分支和合入目标变化。若参与合并的是同一对分支 tip,仅仅交换合并方向不会改变共同祖先;本例之所以出现不同结果,是因为两次参与合并的分支 tip 已经不同。
冲突不只包括同一行修改
常见冲突类型包括:
- 两边将同一行修改成不同内容。
- 一边修改文件,另一边删除文件。
- 两边新增同名文件。
- 文件重命名与内容修改无法自动协调。
- 文件与目录使用了冲突的名称。
反过来,Git 没有报告文本冲突,也不代表合并后的业务逻辑一定正确。Git 能判断文件树和文本能否组合,但不能判断接口调用、数据格式或业务流程是否兼容。合并完成后仍然需要运行测试、构建和必要的业务验证。
三、第一次合并为什么没有冲突
第一次操作是:
git switch master
git merge feature-a在合并前,master 和 feature-a 的共同祖先是 O。三方合并的输入可以表示为:
| 角色 | 对应内容 |
|---|---|
merge base | O |
ours | master 的 M1、M2 |
theirs | feature-a 的 A1、A2 |
Git 实际比较:
O -> M2
O -> A2如果 M1、M2 和 A1、A2 修改的是不同文件、不同配置项,或者修改区域可以自动拼接,Git 就能生成合并结果:
master = M2 + A2例如,初始提交 O 中有一个配置文件:
gateway=legacy
timeout=30feature-a 只修改网关:
gateway=payment-v2
timeout=30同期的 master 只修改超时时间:
gateway=legacy
timeout=60两边修改了不同配置项,Git 可以自动组合为:
gateway=payment-v2
timeout=60这次无冲突只说明:
feature-a 与当时 master 的修改可以自动组合它没有比较 feature-b 的 B1、B2,因此不能对 B 的后续合并作出结论。
四、第二次合并为什么可能冲突
feature-b 是从 A2 创建的。由于 A2 通过 MA 进入了 master,第二次合并时两个分支的共同祖先通常是 A2:
git switch feature-b
git merge master三方合并的输入变成:
| 角色 | 对应内容 |
|---|---|
merge base | A2 |
ours | feature-b 的 B1、B2 |
theirs | master 相对于 A2 的修改 |
Git 比较:
A2 -> B2
A2 -> MA从 A2 看,master 的树包含主干相对旧分叉点产生的变化,feature-b 的树包含 B 相对 A2 产生的变化。只要这两组变化涉及同一代码区域,就可能冲突。
继续使用上面的配置示例:
A2中的timeout仍然是30。master合入 A 后,timeout变成60。feature-b没有同步主干,基于旧值30将timeout改成45。
第二次合并时,Git 看到的三个版本是:
| 版本 | timeout 的值 |
|---|---|
merge base:A2 | 30 |
ours:feature-b | 45 |
theirs:master | 60 |
Git 无法仅凭提交历史判断业务上应该使用 45 还是 60,所以需要开发者确认意图并解决冲突。
这个例子揭示了冲突的直接原因:
- 第一次合并时,A 没有修改
timeout,所以 A 与主干的修改不重叠。 - 第二次合并时,B 和主干都从
timeout=30出发修改了同一配置项。 - 两次操作的共同祖先和比较范围不同,因此结果不同是正常的。
五、如何在实际仓库中定位冲突来源
不要只根据冲突标记猜测原因。先确认提交图和共同祖先,再比较两侧的修改范围。
1. 查看提交图
git log --graph --oneline --decorate --all重点确认:
feature-b是从哪个提交创建的。- A 是否已经进入
master。 - A 通过普通 merge、squash merge 还是其他方式进入主干。
master和feature-b当前各自指向哪个提交。
2. 计算共同祖先
git merge-base feature-b master可以把结果保存到变量中,供后续命令使用:
BASE=$(git merge-base feature-b master)
printf '%s\n' "$BASE"$base = git merge-base feature-b master
$base3. 分别查看两侧修改
查看 feature-b 相对共同祖先的修改:
git diff "$BASE..feature-b"git diff "$base..feature-b"查看 master 相对共同祖先的修改:
git diff "$BASE..master"git diff "$base..master"如果只想先确认涉及哪些文件:
git diff --name-status "$BASE..feature-b"
git diff --name-status "$BASE..master"重点寻找两侧同时修改的文件和相邻代码区域。若两边都改动同一个配置项、函数签名、导入区域或文件路径,通常就能解释冲突来源。
4. 查看提交的父节点
git show -s --format=%P <commit>普通提交通常只有一个父提交,merge commit 通常有两个父提交。这个命令可以帮助确认 MA 是否确实同时连接了 A2 和主干原来的 tip。
5. 预演合并但不立即创建提交
在工作区干净的前提下,可以先模拟合并:
git switch feature-b
git merge --no-commit --no-ff master检查结果后,如果不准备保留这次合并:
git merge --abort--no-commit 不能阻止 fast-forward。若需要无论如何都保留一个待提交的合并状态,可以使用 --no-ff;执行前仍应确认工作区没有未保存的修改。
六、发生冲突时使用 merge 同步主干
对于已经推送、多人共同使用或不希望改写历史的 feature-b,通常优先使用 merge:
git switch feature-b
git status
git fetch origin
git merge origin/master发生冲突后先查看状态:
git status然后逐个处理冲突文件,确认最终内容符合业务意图,再标记为已解决并提交:
git add <resolved-files>
git commit合并提交完成后,至少运行项目约定的测试和构建。例如:
npm test
npm run build具体命令应以项目的 package.json、贡献指南和 CI 配置为准。验证通过后再推送:
git push origin feature-bmerge 的特点是保留原有提交哈希,并新增一个 merge commit:
B1---B2------MB feature-b
/ /
A1---A2----------MA master
/ /
O----+-----------------+
\
M1---M2它保留了并行开发的历史,其他基于旧提交协作的人不需要因为你的本地整理而重新对齐历史。
七、使用 rebase 重新整理未共享分支
如果 feature-b 尚未共享,或者确认只有当前开发者使用,并且团队允许重写历史,可以让 B 的提交重新应用到最新的 master 之后:
git switch feature-b
git status
git fetch origin
git branch backup/feature-b-before-rebase
git rebase origin/masterrebase 前后可以抽象为:
rebase 前:
O---M1---M2---MA master
\
A1---A2---B1---B2 feature-b
rebase 后:
O---M1---M2---MA---B1'---B2' feature-b
↑
masterB1'、B2' 是重新生成的提交。它们的修改目的可能与原来的 B 提交相同,但父提交发生了变化,因此提交哈希也会变化。
Rebase 冲突的处理命令
rebase 会逐个重放提交,冲突可能按提交分批出现。解决当前提交后:
git add <resolved-files>
git rebase --continue取消整个 rebase:
git rebase --abort跳过当前正在重放的提交:
git rebase --skip--skip 会丢弃当前提交。只有确认该提交的修改已经存在,或者确实不再需要时才能使用。
Rebase 后推送
如果分支从未推送过,可以正常建立远程分支:
git push -u origin feature-b如果远程已经保存了 rebase 前的旧历史,通常需要:
git push --force-with-lease origin feature-b--force-with-lease 会检查远程分支是否仍然指向本地预期的旧提交。若其他人已经更新了远程分支,推送会被拒绝。它比 --force 多一层并发保护,但仍然会改写远程历史,不能把它当作普通 push 使用。
八、Merge 与 Rebase 如何选择
| 对比项 | git merge origin/master | git rebase origin/master |
|---|---|---|
| 原提交哈希 | 保留 | 重新生成 |
| 历史形态 | 保留分叉并新增 merge commit | 通常形成线性历史 |
| 冲突处理 | 通常集中在一次合并中 | 按提交逐个处理 |
| 是否需要强制推送 | 不需要 | 远程存在旧历史时通常需要 |
| 已共享分支 | 更适合 | 通常不适合 |
| 并行开发过程 | 保留 | 被重新组织 |
可以按分支的共享状态做判断:
个人本地分支、尚未共享:可以 rebase
已经推送但确认只有自己使用:协调后可以 rebase
多人共同维护的功能分支:优先 merge
master、main、release 等共享分支:不要随意 rebase这里的原则不是“merge 永远安全”或“rebase 永远更干净”,而是要避免在没有协调的情况下改写其他人依赖的提交历史。
九、不要把不同的合并策略混为一谈
前面的推导依赖一个重要前提:feature-a 通过普通 merge 进入 master,所以 A2 保留为 master 的祖先。
实际仓库可能使用其他策略:
Squash merge
squash merge 会把 A1、A2 的改动压缩为一个新提交,例如 S:
feature-a: O---A1---A2
master: O---M1---M2---SS 的内容可能包含 A 的修改,但 A2 通常不是 master 的祖先。此时 feature-b 与 master 的共同祖先可能仍然是 O,不能直接套用普通 merge 场景中“第二次的 merge base 是 A2”的结论。
Rebase merge 与 cherry-pick
rebase、cherry-pick 和删除后重建分支都会创建新的提交。即使提交标题和文件内容相似,提交哈希、父节点和祖先关系也可能不同。
因此,分析实际冲突时应查看:
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:
git fetch origin
git merge origin/master个人未共享分支也可以使用:
git fetch origin
git rebase origin/master频繁同步不能保证消除冲突,但能缩小单次需要处理的范围,也能让开发者在仍熟悉代码上下文时解决问题。
使用 rerere 复用解决结果
Git 可以记录冲突解决方式:
git config --global rerere.enabled true查看 Git 记录的冲突路径和解决差异:
git rerere status
git rerere diffrerere 只能复用过去的处理结果。自动应用后仍需检查文件并运行测试,不能把它当作业务验证的替代品。
明确团队历史策略
建议在 CONTRIBUTING.md 或团队开发规范中约定:
哪些分支允许 rebase
哪些分支禁止 force push
功能分支如何同步主干
PR 使用 merge、squash merge 还是 rebase merge
冲突由谁解决并负责验证工具配置无法替代团队约定。尤其是 pull.rebase、平台合并按钮和分支保护规则不一致时,开发者可能误以为自己执行的是另一种历史操作。
十一、遇到类似问题时的排查清单
可以按以下顺序分析:
- 当前要合并的两个分支 tip 分别是哪两个提交?
git merge-base <branch-a> <branch-b>返回了哪个共同祖先?- 两侧分别修改了共同祖先之后的哪些文件和代码区域?
- A 进入
master时使用的是普通 merge、squash merge、rebase merge 还是 cherry-pick? - B 是否已经推送,是否有人基于 B 继续开发?
- 团队是否允许对该分支执行 rebase 和 force push?
- 冲突解决后是否运行了测试、构建和必要的业务验证?
最小命令集如下:
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 行为,根因通常是两次操作使用了不同的共同祖先和不同的比较范围:
第一次:比较 feature-a 与 master 的修改
第二次:比较 feature-b 与 master 的修改在普通 merge 场景中,A 进入 master 后,A 的最新提交可能成为 B 与 master 的新 merge-base。此时 Git 不再比较 A 与主干的全部历史,而是比较 B 和主干从这个共同祖先之后各自产生的变化。若 B 和主干修改了同一内容,就需要人工解决冲突。
真正有效的排查顺序是:
确认两个分支 tip
确认 merge-base
比较共同祖先之后的两侧 diff
确认 A 进入主干的策略
根据分支共享状态选择 merge 或 rebase
完成冲突处理后运行测试和构建掌握 merge-base 和三方合并模型后,就可以从提交历史解释冲突,而不是把冲突归因于“合并方向不同”或 Git 行为随机。
