三方合并必须找共同祖先,因为git需以该基准点区分“谁修改了哪行”:若无base,无法判断相同变更是双方共识还是单方操作,易致误冲突或静默丢代码。

三方合并为什么必须找共同祖先
Git 不是简单对比两个分支的最新文件,而是必须定位到 HEAD 和 MERGE_HEAD 的最近共同祖先(merge base)。没有这个基准点,就无法判断某行变化是“你加的”还是“对方删的”。比如两份文件都少了同一行,可能是你删了、对方也删了(应保留删除),也可能是你没动、对方删了(应采纳删除)——只有通过三方比对才能区分。
Git 底层用 get_merge_base() 在提交 DAG 中递归搜索,若存在多个候选祖先(如 Octopus 合并),还会进一步筛选最低公共祖先。一旦找错 base,后续所有差异计算都会失准,轻则误报冲突,重则静默丢代码。
冲突标记
当 diff_tree_merge() 发现同一文件在 ancestor→HEAD 和 ancestor→MERGE_HEAD 两组差异中,都修改了完全重叠的行范围时,Git 就会拒绝自动合并,并在工作区文件里写入冲突标记。
这些标记不是 Git “插入”的文本,而是由 merge-recursive.c 中的 traverse_trees_recursive() 在写入工作区时主动注入的:
到 <code>=======之间是HEAD分支的变更内容(即你本地暂存区 Stage 2 的快照)-
=======到>>>>>> feature-x之间是MERGE_HEAD分支的变更内容(即远端 Stage 3) - 中间不会出现 Stage 1(base)的原始内容——它只存在于 Index 中,供
git diff --ours或git diff --theirs调用
Index 里三个 stage 是怎么共存的
冲突发生后,git ls-files -u 会列出同一个文件名对应三行输出,每行带不同 stage 编号和 blob hash。这说明 Git 的 Index(暂存区)此时不再是一维表,而是一个支持多版本映射的结构。
Stage 1/2/3 并非临时状态,而是 Git 合并引擎的正式接口设计:
- Stage 1 存的是共同祖先版本的 blob hash,不可编辑,仅作比对依据
- Stage 2 是当前分支(
HEAD)修改后的版本,对应你本地工作区“看起来应该保留”的内容 - Stage 3 是待合并分支(
MERGE_HEAD)的版本,代表对方的意图 - 执行
git add <file></file>后,这三个 stage 记录会被清空,Index 回归 Stage 0 单一状态
很多人以为手动删掉冲突标记就完了,其实只要 Stage 1/2/3 还在 Index 里,git status 就永远显示 Unmerged paths,git commit 也会被拒绝。
为什么 git merge --no-ff 能帮你避开部分隐性冲突
--no-ff 强制创建 merge commit,表面看只是多一个提交节点,实际影响深远:它把每次合并的三方关系(base + ours + theirs)固化为一次明确的提交对象,后续 git blame 或 git log --merge 都能回溯到该次合并的完整上下文。
更关键的是,它避免了 fast-forward 合并带来的“历史扁平化”问题——如果连续几次 FF 合并,共同祖先可能退回到数周前的某个旧提交,导致后续合并时 base 过老,把本可自动解决的修改判为冲突。而带 merge commit 的历史,让 Git 总能找到更近、更准确的 base。
真正容易被忽略的点是:--no-ff 不改变三方算法本身,但它让 base 的选取更稳定、更可预期。这不是“防冲突”,而是“让冲突更可信”。











