merge 为什么有时只是移动指针,有时会多一个 commit
前面讲checkout和reset时,我们一直围绕几样东西看:
HEAD:当前站在哪里。- 分支引用:
refs/heads/master这种名字指向哪个 commit。 index:下一次提交准备包含什么。worktree:磁盘上的文件内容。
理解merge也一样,不要先背命令结果,而是先问:
- 当前分支和要合并进来的分支,历史有没有真正分叉?
如果没有分叉,merge 可能只是 fast-forward。
如果已经分叉,merge 就要做三方合并,并产生一个新的 merge commit。
这两个结果看起来都叫merge,但本质很不一样。
第一种情况:fast-forward
先看最简单的历史:
- A — B master
- \
- C — D feature
如果当前在master,执行:
- git merge feature
这时master没有自己的新提交。它只是落后于feature。
所以 Git 不需要创建新 commit,只要把master从B移到D:
- A — B — C — D master, feature
这就是 fast-forward。
它的重点是:
- 当前分支可以直接向前走到目标分支,没有必要额外制造一个合并节点。
从状态变化看,fast-forward 做的是:
- master: B -> D
- HEAD: 仍然指向 master
- index: 恢复成 D 的 tree
- worktree: 恢复成 D 的 tree
也就是说,它最核心的动作是“移动当前分支指针”。
但为了让你磁盘上的文件跟上这个新提交,index和worktree也要更新到D的快照。
所以 fast-forward 不只是“改一个分支文件”这么简单。用户能看到文件变化,是因为工作区也被恢复到了目标提交。
第二种情况:真正分叉
再看另一种历史:
- C — D feature
- /
- A — B — E master
当前在master,要合并feature。
这时master已经有自己的提交E,feature也有自己的提交C、D。
master不能直接移动到D,否则E就会从当前分支历史里消失。
所以 Git 需要创建一个新的 commit:
- C — D
- / \
- A — B — E — M master
这个M就是 merge commit。
它和普通 commit 最大的区别是:它有两个 parent。
- parent 1 -> E
- parent 2 -> D
- tree -> 合并后的目录快照
这就是为什么普通提交一般只有一条父边,而 merge commit 会把两条历史接在一起。
merge-base 是什么
真正分叉时,Git 不能只看master和feature两个版本。
它还必须知道双方是从哪里分开的。
也就是共同祖先:
- base -> B
- ours -> E 当前分支 master
- theirs -> D 要合并进来的 feature
这个B就是 merge-base。
有了base,Git 才能判断:
- base -> ours 当前分支改了什么
- base -> theirs 对方分支改了什么
如果只看ours和theirs,你只能知道两个结果不同,却不知道是谁改了、怎么改的。
这也是三方合并的核心。
为什么叫三方合并
所谓三方,就是:
base:共同祖先。ours:当前分支。theirs:要合并进来的分支。
比如同一个文件:
base:
hello
ours:
hello master
theirs:
hello feature
两边都改了同一行,Git 很可能无法自动判断应该保留哪一个,于是产生冲突。
如果双方改的是不同文件,或者同一个文件里的不同位置,Git 就有机会自动合并。
所以 merge 不是简单把两个文件拼起来。
它是在问:
- 相比共同祖先,双方分别改了什么?这些修改能不能同时保留?
冲突时为什么会有 MERGE_HEAD
如果自动合并失败,Git 不能直接创建 merge commit。
因为合并结果还没有确定。
这时它会进入一个“合并未完成”的状态。
其中一个关键标记就是:
- .git/MERGE_HEAD
它记录的是“对方那个提交是谁”。
为什么要记录?
因为你解决冲突后再执行提交时,Git 需要知道这个提交不是普通 commit,而是一个 merge commit。
也就是说,新 commit 需要两个 parent:
- parent 1 -> 当前 HEAD
- parent 2 -> MERGE_HEAD 里记录的提交
如果没有MERGE_HEAD,冲突解决后的提交就会丢掉“我是在合并谁”这个信息。
mini-git 里怎么落地
mini-git 里 merge 的入口主要在:
- src/commands/cmd_merge.c
- src/core/graph.c
- src/core/linemerge.c
- src/core/worktree.c
大致流程可以拆成几步。
第一步,解析当前提交和目标提交:
- ours = 当前 HEAD 指向的 commit
- theirs = 要 merge 进来的 commit
第二步,先判断能不能 fast-forward。
如果当前提交是目标提交的祖先,就不需要创建 merge commit,只要移动当前分支到目标提交,再恢复index/worktree。
第三步,如果不能 fast-forward,就找 merge-base。
- base = ours 和 theirs 的共同祖先
第四步,读出三棵 tree:
- base tree
- ours tree
- theirs tree
第五步,做三方合并。
如果能自动合并,就把合并结果写成新的 tree,再创建一个有两个 parent 的 merge commit。
如果有冲突,就把冲突内容写进工作区,同时写入MERGE_HEAD,等用户解决冲突后再提交。
一个容易误解的点
很多人会把 merge 理解成:
- 把 feature 的代码复制到 master。
这个说法太粗了。
更准确的理解是:
- 把当前分支和目标分支从共同祖先之后的变化合到一起。
如果当前分支没有自己的变化,那就是 fast-forward。
如果双方都有变化,就需要三方合并。
如果三方合并能自动完成,就生成 merge commit。
如果不能自动完成,就进入冲突状态,等你人工决定最终内容。
面试里怎么说
可以这样回答:
- merge 先判断当前分支能否 fast-forward 到目标分支。如果可以,就只移动当前分支引用,并同步 index 和 worktree 到目标提交。如果不能 fast-forward,说明两边历史已经分叉,需要找到 merge-base,然后基于 base、ours、theirs 做三方合并。自动合并成功后会创建一个有两个 parent 的 merge commit;如果发生冲突,会写入冲突文件和 MERGE_HEAD,等待用户解决后再提交。
如果对方继续问 merge commit 为什么有两个 parent,可以补一句:
- 因为 merge commit 同时接住了当前分支和被合并分支两条历史。第一个 parent 通常是当前分支原来的 HEAD,第二个 parent 是被合并进来的提交。
总结
理解 merge,抓住这几句话就够了:
- 没有分叉时,merge 可以 fast-forward。
- fast-forward 本质是移动当前分支指针,同时同步
index/worktree。 - 分叉后,merge 要找 merge-base。
- 真正的合并是三方合并:
base、ours、theirs。 - 自动合并成功会生成一个双 parent 的 merge commit。
- 冲突时会留下
MERGE_HEAD,表示这次合并还没完成。
所以 merge 不是“复制代码”,而是在维护两条历史之间的关系。