上一篇的结论是:分支只是 .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 两个版本,你只能看出两边不一样,但没法判断是这边改了、那边改了,还是本来就该这样。有了基准这第三个快照,每个区域就能被判定成三种情况:
- 只有一边相对基准变了 → 采纳变了的那一边;
- 两边相对基准改成了相同内容 → 也是自动的,就用那一份;
- 两边都相对基准改了,而且改得不一样 → 冲突。
举例说明第 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:
- HEAD 不动,仍然指着 main 的尖端;
- 写入
MERGE_HEAD,记下对方尖端的哈希——这既是将来那个合并提交第二个 parent 的来源,也是“有一次合并正在进行”的标志; - 能干净合并的文件,索引和工作区都更新成合并结果;
- 冲突的文件,索引里为它记下最多三个版本: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。
自己验证一遍
1$ git log --oneline --graph --all # 先看清两条线在哪里分叉
2$ git switch main && git merge feature # 在 main 上合并,main 是 ours
3$ git ls-files -u # 冲突时:看索引里的三份版本
4$ git diff # 看工作区里留下的冲突标记
5$ git merge --continue # 解决并 add 之后,完成合并提交合并之后的 git log --oneline --graph --decorate --all 最值得反复看:快进合并后两条线会连成一条直线,非快进合并后会在汇合处出现一个带着两个分支的“结”。这个结的形状,就是前面讲的 parent 关系最直观的样子。下一步的自然延伸是:git pull 其实就是“取回远程的提交,再在本地合并一次”,所以远程协作里遇到的事情,都是这一篇的机制在起作用。