Skip to content

为什么 feature-a 合入 master 无冲突,feature-b 同步 master 却发生冲突

这两次 merge 比较的不是同一组提交。feature-a 合入 master 时,Git 比较 A 与当时 master 的修改;feature-b 随后同步 master 时,Git 比较 B 与 master 在另一个共同祖先之后的修改。因此,第一次能够自动合并,不能证明第二次也不会冲突。

本文假设 feature-a 通过普通 merge 进入 master,从而保留 A 的祖先关系。若仓库使用 squash merge、rebase merge 或 cherry-pick,提交关系会不同,文末会单独说明。

一、先建立完整的提交关系

假设 feature-a 和 master 从初始提交 O 分开开发,feature-b 又从 A 的最新提交 A2 创建:

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

各提交代表的修改如下:

提交所属分支含义
A1、A2feature-a第一阶段功能修改
B1、B2feature-b基于 A2 继续开发的第二阶段修改
M1、M2mastermaster 上并行进入的其他修改

在 master 上合并 feature-a:

bash
git switch master
git merge feature-a

合并完成后产生 merge commit MA

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

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

二、Git 在 merge 时比较什么

典型的双分支 merge 使用三个输入:

输入含义
merge base两个分支 tip 的共同祖先
ours当前分支的 tip,即 HEAD
theirs待合入分支的 tip

Git 分别计算以下两组修改:

text
merge base -> ours
merge base -> theirs

当两组修改能够组合时,Git 自动生成合并结果;当修改涉及相同内容且无法自动协调时,Git 将相关路径标记为冲突。

“同一行被修改成不同内容”是最常见的文本冲突,但不是唯一情况。Git 还可能报告 rename/delete、add/add、文件与目录冲突等。另一方面,即使 Git 没有报告文本冲突,合并结果仍可能存在业务语义不兼容。

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

第一次操作是在 master 上执行:

bash
git merge feature-a

这次三方合并的输入是:

项目对应提交或修改
merge baseO
oursmaster 的 M1、M2
theirsfeature-a 的 A1、A2

Git 实际比较:

text
O -> M2
O -> A2

如果 M1、M2A1、A2 修改了不同文件或可自动组合的代码区域,这次 merge 就不会产生冲突。

该结果只说明 A 与当时 master 的修改兼容,未涉及 B1、B2

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

feature-b 完成开发后,同步最新 master:

bash
git switch feature-b
git merge master

由于 A2 同时是 feature-b 和 master 的祖先,这次 merge 的共同祖先通常是 A2

项目对应提交或修改
merge baseA2
oursfeature-b 的 B1、B2
theirsmaster 相对 A2 引入的修改

Git 这次比较:

text
A2 -> B2
A2 -> MA

第一次 merge 比较 A 与 master,第二次 merge 比较 B 与 master。只要 B1、B2 修改了 master 相对 A2 也修改过的内容,第二次 merge 就可能冲突。

五、用一个文件修改说明差异

初始提交 O 中的配置为:

ini
gateway=legacy
timeout=30

feature-a 修改支付网关:

ini
gateway=payment-v2
timeout=30

master 上的并行提交修改超时时间:

ini
gateway=legacy
timeout=60

两边修改的是不同配置项,因此 feature-a 合入 master 后可以得到:

ini
gateway=payment-v2
timeout=60

但 feature-b 是从尚未包含 master 修改的 A2 创建的。它看到的 timeout 仍是 30,随后将其修改为 45

ini
gateway=payment-v2
timeout=45

feature-b 同步 master 时,三方比较为:

版本timeout
merge base A230
ours:feature-b45
theirs:master60

Git 无法判断最终应该使用 45 还是 60,因此需要人工解决冲突。

这个例子同时说明:第一次 merge 没有冲突,是因为 A 没有修改 timeout;第二次发生冲突,是因为 B 和 master 都从 timeout=30 出发做了不同修改。

六、合并方向本身不是根因

如果参与合并的是同一对分支 tip,仅交换 ours 和 theirs,merge base 不会因此改变。冲突标记中的 ours/theirs 会对调,但不能仅凭“合并方向相反”解释冲突数量发生变化。

本例两次操作结果不同,是因为参与比较的提交已经不同:

text
第一次:master 与 feature-a
第二次:feature-b 与包含 A 的 master

分析类似问题时,应先确认两次操作的分支 tip、祖先关系和合并策略,而不是只比较命令方向。

七、如何检查实际 merge base 和修改范围

查看提交图:

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

计算 feature-b 与 master 的共同祖先:

bash
git merge-base feature-b master

假设返回值为 <base>,分别检查两侧修改:

bash
git diff <base>..feature-b
git diff <base>..master

只查看文件范围:

bash
git diff --name-status <base>..feature-b
git diff --name-status <base>..master

检查提交的父节点:

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

普通提交通常有一个父提交,merge commit 通常有两个或更多父提交。这些命令可以确认文章中的提交图是否与仓库实际历史一致。

八、使用 merge 同步 master

对于已经推送或由多人维护的 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
git push origin feature-b

merge 会保留 feature-b 原有提交,并新增 merge commit。若 master 在此后没有新增提交,后续将 feature-b 合回 master 时可能可以快进;实际行为还取决于仓库保护规则和平台合并策略。

九、使用 rebase 同步 master

如果 feature-b 尚未共享,或者确认只有当前开发者使用,可以将其提交重新应用到最新 master:

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

rebase 会以最新 master 为新基点,依次重新创建 B1、B2。新提交的父节点发生变化,因此提交哈希也会变化。

遇到冲突时:

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

取消整个 rebase:

bash
git rebase --abort

跳过当前提交:

bash
git rebase --skip

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

如果 feature-b 从未推送,可以正常首次推送:

bash
git push -u origin feature-b

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

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

--force-with-lease 会检查远程分支是否仍指向本地预期的提交。它比 --force 多一层并发保护,但仍然会改写远程历史。

十、merge 与 rebase 的工程取舍

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

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

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

十一、需要单独处理的边界情况

Squash merge

如果 feature-a 通过 squash merge 进入 master,master 会得到一个新的压缩提交,而不是保留 A1、A2 的祖先关系。此时 A2 通常不是 master 的祖先,feature-b 与 master 的 merge base 可能仍是更早的提交。

因此,普通 merge 场景中的 merge-base 推导不能直接用于 squash merge。

Rebase merge 和 cherry-pick

rebase merge 和 cherry-pick 都会重新创建提交。即使提交标题和代码修改相似,新的提交哈希和父节点也可能不同。分析历史时应查看祖先关系,而不是只看提交说明。

多个 merge base

复杂的 criss-cross 历史可能存在多个最佳共同祖先。Git 的合并策略可能先将这些祖先合成为虚拟 merge base。本文的提交图属于单一 merge base 的常见场景。

语义不兼容

Git 自动合并成功只说明文本或树结构可以组合。例如,两边分别修改接口参数和调用方式时,最终代码仍可能无法编译。合并后必须运行测试、构建和必要的业务验证。

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

缩短同步间隔

定期同步 master 可以更早暴露冲突,并缩小单次需要处理的上下文范围:

bash
git fetch origin
git merge origin/master

个人未共享分支也可以根据团队约定使用 rebase。频繁同步不能保证消除冲突,但可以避免在功能结束时集中处理大量历史差异。

启用 rerere

Git 可以记录已解决冲突的处理结果:

bash
git config --global rerere.enabled true

查看 rerere 状态和记录差异:

bash
git rerere status
git rerere diff

自动复用的结果仍需通过测试确认。

明确团队策略

建议在 CONTRIBUTING.md 中约定:

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

总结

feature-a 合入 master 没有冲突,只能说明 A 与当时 master 的修改能够自动组合。feature-b 随后同步 master 时,参与合并的分支 tip 和 merge base 已经不同,Git 比较的是 B 与 master 在 A2 之后的修改。

判断此类问题时,应依次确认:

text
参与合并的是哪两个分支 tip
它们的 merge base 是哪个提交
两侧分别修改了 merge base 之后的哪些内容
feature-a 通过哪种策略进入 master
feature-b 是否已经共享

在此基础上,再根据分支共享状态选择 merge 或 rebase。共享分支优先保留历史,本地未共享分支可以通过 rebase 整理提交;无论采用哪种方式,冲突解决后都需要运行测试和构建。

参考资料