# 合并到底做了什么：为什么有时只是挪一下指针，有时要生成一个新提交

快进合并只改写分支指针，非快进时必须生成有两个 parent 的合并提交——搞清三路合并为什么需要共同祖先，冲突是在哪一步冒出来的

> Git 合并 · Git 分支 · Git 冲突处理 · 约 7 分钟 · 09 月 24 日

## 本篇要点

1. git merge 实际只处理两个提交：当前分支的尖端和要并入分支的尖端，其余差异都从这两个提交和它们的共同祖先推出。
2. 快进合并发生在要并入的尖端由当前分支尖端往后走即可到达时，此时不生成任何提交，只把当前分支指针改写到那个尖端，HEAD 文件不变。
3. 快进合并不留下合并痕迹，`--no-ff` 可强制生成合并提交，`--ff-only` 在无法快进时直接报错。
4. 两边都往前走过后必须生成合并提交，其内容来自三路合并：比较合并基准（最近共同祖先）和两个尖端。
5. 冲突的判定标准是两边相对基准改了同一块区域且结果不同，而不是两边都动过这个文件；同一文件可以部分自动合并、部分冲突。
6. 合并提交与普通提交的唯一结构差别是有两个 parent：HEAD 指向的提交和 MERGE_HEAD 指向的提交；它不复制或搬运任何已有提交。
7. 合并冲突时 Git 停在中间状态：HEAD 不动、写入 MERGE_HEAD、索引里为每个冲突文件留下本方与对方版本，工作区留下冲突标记。
8. 解决冲突必须 `git add`，因为这一步把索引里该文件的多份版本收敛成一份，表示已处理完毕，之后才能提交出合并提交。
9. `git merge --abort` 可以放弃合并，把索引和工作区恢复到合并前状态。
10. 三路合并只比较基准和两个尖端，因此看不见中间提交，也不会发现重命名之类的语义冲突；合并成功不代表结果正确。

---

上一篇的结论是：分支只是 `.git/refs/heads/` 里一个指向某个提交的文件，历史靠每个提交里的 parent 指针往回串。现在情况变成了两条分支各自往前走：main 上有你修的 bug，feature 上有你写的新功能。`git merge feature` 要达成的目标只有一个——确定“合并后的项目应该长什么样”，然后把这个结果记下来。

看起来要“合并两个分支”，但 Git 实际处理的只有**两个提交**：你现在所在分支的尖端，和你要并入的那条分支的尖端。所有差异，都是从这两个提交以及它们的共同祖先推出来的。搞清这一点，快进和合并提交的区别就不用背了。

## 先看两个尖端是什么关系

`git merge feature` 做的第一件事不是逐行比对文件，而是判断历史关系：**feature 的尖端，是不是当前分支尖端的后代？**换成日常说法：feature 分出去之后，main 上有没有再提交过。

如果没有——feature 的尖端是 main 尖端往后走就能到达的提交（最典型的情况就是 main 一直停在分叉点没动）——那么 feature 的历史里本来就包含了 main 的全部历史，合并后的内容就是 feature 尖端那个提交的快照本身，没有任何新东西需要记录。Git 只需要把 main 这个指针从旧位置挪到 feature 的尖端。

这就是**快进合并**（fast-forward）。用上一篇的语言说，它做的事就是改写 `.git/refs/heads/main` 里的那个哈希，一次提交都不生成，HEAD 文件里那行 `ref: refs/heads/main` 更是纹丝不动。官方文档的表述是：这种情况下不需要新提交来记录合并后的历史，HEAD 和索引直接更新到指定的那个提交 [1]。

副作用值得记住：快进合并不留下“这里发生过一次合并”的痕迹。合并之后你敲 `git log`，看起来就像你一直在 main 上连续提交，feature 那条线的存在感完全消失了。想要每次都留下合并记录，可以加 `--no-ff`，强制生成一个合并提交；反过来，`--ff-only` 会在无法快进时直接报错退出，用来防止意外的合并提交 [2]。

## 什么时候不能快进

只要两边都往前走过了（feature 分出去之后，main 上也有了新提交），main 的尖端就不是 feature 尖端的祖先。这时候如果还把 main 的指针挪到 feature 尖端，main 上那些提交就再也回不到这条线上了。所以必须创造一个**新提交**，把两条线接起来。

新提交的内容从哪来？从三路比较来。

## 三路合并：为什么必须看第三个版本

这里出现本篇最重要的一个概念：**合并基准**（merge base），也就是两条分支最近的共同祖先提交。注意它不是“上一个提交”——两条线各有一个自己的上一个提交，你没法只挑一个。

为什么非要有基准？假设某一行代码，你想知道它是不是被改过。只看 main 和 feature 两个版本，你只能看出两边不一样，但没法判断是这边改了、那边改了，还是本来就该这样。有了基准这第三个快照，每个区域就能被判定成三种情况：

1. 只有一边相对基准变了 → 采纳变了的那一边；
2. 两边相对基准改成了相同内容 → 也是自动的，就用那一份；
3. 两边都相对基准改了，而且改得不一样 → **冲突**。

举例说明第 3 种。基准版本里配置文件写着 `port = 8080`。你在 feature 上把它改成 `8090`，main 上同事把它改成 `8443`。同一块区域被改成两种不同的内容，Git 不会替你猜哪个对，它把两边都留在文件里让你决定。但如果基准之后，同事改的是文件里另一行的超时时间，那就属于“不重叠的改动”，Git 直接把两边都合进去，一句提示都不会有。

所以冲突的判定依据不是“两边都动过这个文件”，而是“两边动了同一块区域并且结果不同”。同一个文件完全可以一部分自动合并、一部分冲突。

## 合并提交：一个有两个 parent 的提交

调和出来的结果被记成一个 commit 对象。它和普通提交在结构上只有一处差别：**parent 有两个**。第一个是执行合并时 HEAD 指向的提交（你所在分支的尖端），第二个是被并入那条分支的尖端，它被记在 `MERGE_HEAD` 这个引用里。

这解释了一件容易误解的事：合并提交并没有“搬运”对方的提交。feature 上那几个提交还好好待在原来的位置，内容哈希一个字符都没变。合并提交真正做的是把两条历史接上——从它出发沿着 parent 往回走，现在能同时到达两边的全部提交。于是合并之后在 main 上敲 `git log --graph`，你会看到 feature 那些提交出现了。这和上一篇说的“`git log` 默认只显示从 HEAD 出发能到达的提交”是同一件事：能不能看到，取决于能不能到达。

## 冲突是在哪一步冒出来的

当合并无法自动完成时，Git 不会报个错就结束，而是进入一个停在半路的中间状态。它按这个顺序做几件事 [1]：

1. HEAD 不动，仍然指着 main 的尖端；
2. 写入 `MERGE_HEAD`，记下对方尖端的哈希——这既是将来那个合并提交第二个 parent 的来源，也是“有一次合并正在进行”的标志；
3. 能干净合并的文件，索引和工作区都更新成合并结果；
4. 冲突的文件，索引里为它记下最多三个版本：stage 1 是基准版本，stage 2 是本方（HEAD）版本，stage 3 是对方（MERGE_HEAD）版本，用 `git ls-files -u` 可以看到；工作区文件里写上冲突标记。

冲突标记的读法是固定的：`<<<<<<<` 到 `=======` 之间是 HEAD 那边（ours），`=======` 到 `>>>>>>>` 之间是 MERGE_HEAD 那边（theirs）。谁叫 ours 完全取决于你站在哪条分支上执行合并——在 main 上 merge feature，main 是 ours；反过来在 feature 上 merge main，feature 才是 ours。

解决冲突的动作是：把文件编辑成你想要的样子、删干净所有标记、然后 `git add`。`git add` 不是走形式——它把索引里那个文件的三份版本收敛成一份，等于告诉 Git 这块已经处理完了。之后 `git commit`（或 `git merge --continue`）会生成那个有两个 parent 的合并提交。想推倒重来，`git merge --abort` 会把索引和工作区恢复到合并前的状态 [1]。

## 两个边界，避免把结论用过头

**合并成功不等于结果正确。** 三路合并是比较文本区域，看不出语义。对方重命名了一个函数，你这边新增了对旧名字的调用，两边改动位置不同，Git 会安安静静地把两边都合进去，直到编译或运行才暴露问题。冲突提示只能覆盖文本层面重叠的那部分。

**“一边改过又改回来”可能被当成没改。** 三路合并只看基准和两个尖端这三棵树，中间的提交不参与判断，所以某个改动在某条分支上被改回去之后，合并结果里可能又把它带回来 [3]。记住“合并只看三个快照”就不会觉得这是 bug。

## 自己验证一遍

```console
$ git log --oneline --graph --all       # 先看清两条线在哪里分叉
$ git switch main && git merge feature  # 在 main 上合并，main 是 ours
$ git ls-files -u                       # 冲突时：看索引里的三份版本
$ git diff                              # 看工作区里留下的冲突标记
$ git merge --continue                  # 解决并 add 之后，完成合并提交
```

合并之后的 `git log --oneline --graph --decorate --all` 最值得反复看：快进合并后两条线会连成一条直线，非快进合并后会在汇合处出现一个带着两个分支的“结”。这个结的形状，就是前面讲的 parent 关系最直观的样子。下一步的自然延伸是：`git pull` 其实就是“取回远程的提交，再在本地合并一次”，所以远程协作里遇到的事情，都是这一篇的机制在起作用。

## 术语表

- 快进合并（fast-forward）：目标提交已经包含当前分支全部历史时，只把当前分支指针移到目标提交，不生成新提交。
- 合并基准（merge base）：两条分支最近的共同祖先提交，三路合并用它判断每一侧相对“原来”改了什么。
- 三路合并：拿基准、当前分支尖端、对方尖端三个快照比较，把互不重叠的改动合成一份新快照，重叠且结果不同处报冲突。
- 合并提交：有两个 parent 的提交，第一个是 HEAD，第二个是 MERGE_HEAD；作用是把两条历史接上，而不是搬运提交。
- MERGE_HEAD：合并进行中记录对方尖端哈希的引用，既是第二个 parent 的来源，也是“合并尚未完成”的标志。
- 索引里的多份版本：冲突文件在索引中同时存着基准版本、本方版本、对方版本，`git add` 把它们收敛成一份表示已解决。
- 冲突标记：用 `<<<<<<<`、`=======`、`>>>>>>>` 把两边对同一区域的不同改法留在工作区文件里的写法。

## 来源

1. [git-merge 官方文档：FAST-FORWARD MERGE、MERGE_HEAD 与索引 stage 1/2/3、冲突呈现与解决](https://git-scm.com/docs/git-merge)
2. [git-merge 选项文档：--ff / --no-ff / --ff-only 与 merge.ff 的语义](https://git-scm.com/docs/merge-options)
3. [Git 合并策略文档：三路合并只看基准与两个尖端，以及改回后仍出现在合并结果中的说明](https://git-scm.com/docs/merge-strategies)

---

原文：https://pangzhengboyin.com/articles/git-merge-fast-forward-vs-merge-commit-31113dee

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
