深入理解 git 合并操作

合并在 Git 中是一个十分常见的操作:整合不同分支之间的更改,或者对远程分支执行 pullpush 操作,都需要进行合并。

但对新手来说, git merge 这一命令有些令人生畏,因为在不同情况下,执行 merge 可能会得到不同的结果。这种对于结果的不确定性,使我很长一段时间都不敢主动去使用它,而是依赖 GitHub 的 Pull Request 或者 GitLab 的 Merge Request 等可视化界面手动合并。

为了今后可以放心大胆的 merge,今天我们就来对 merge 一探究竟。

认识合并

在版本控制系统中,合并是将一组文件中所发生的不同更改进行整合的基础操作。通常来说,我们在使用 Git 时会建立不同的分支,由不同的人对同一组文件执行新增、编辑等操作,最终我们需要合并这些协作的分支,整合所有的更改形成一份文件版本。

合并一般由 Git 根据算法自动执行,但如果发生了冲突,比如对同一文件的同一处内容执行了不同的更改,则需要我们手动合并。

递归三路合并算法

Git 在自动合并时会使用「递归三路合并」算法对不同文件进行差异分析,接下来我们简单了解一下该算法。

首先从「三路合并」算法开始,假设我们有以下提交历史:

Dec-29-2020 22-40-46

上图中我们在 master 合并了 feature 分支,现在我们回溯一下合并的过程:

此时 master 正指向提交 C,Git 首先找到两个分支最近的唯一共同祖先提交 A,然后分别对 A、C、F 提交的文件快照进行对比,我们下文称呼它们为 A、C、F 文件。接下来 Git 将逐「行」对三个文件的内容进行比较,如果三个文件中有两个文件该行的内容一致,则丢弃 A 文件中该行的内容,保留与 A 文件中不同的内容放到结果文件中。

具体来说,假如 A、C 内容一致,说明这是在 F 中更改的内容,需要保留该更改;A、F 内容一致同理;假如 C、F 内容一致,说明 C 和 F 都相对于 A 做了同样的更改,同样需要保留。除此之外的内容差异仅剩两种情况:如果 A、C、F 的内容都一致,说明什么都没有发生;如果该行在 A、C、F 的内容都不一致,说明发生了冲突,需要我们手动合并选择需要保留的内容。

结束对比后 Git 会以最终的结果文件快照创建一个新的 Merge 提交并指向它。

三路合并算法的基础是找到被合并文件的共同祖先,在一些简单的场景中这还能行的通,但在遇到十字交叉合并(criss-cross merge)时,不存在唯一的最近共同祖先,如下图:

20201229152228-criss-cross-merge

现在我们需要从 main 分支合并 feature 分支,即把 C7 合并到 C8,会发现 C8 和 C7 有两个共同祖先,这下怎么办呢?Git 采取的是递归三路合并(Recursive three-way merge),会先合并 C3 和 C5 这两个共同祖先创建一个虚拟的唯一最近祖先(假设为 C9),接着在 C9、C7、C8 之间执行三路合并,如果在合并 C3 和 C5 的过程中又发生没有唯一共同祖先的情况,则递归执行上述过程。

关于递归三路合并算法我们就了解到这里。

合并冲突

如果你在两个不同的分支中,对同一个文件的同一个部分进行了不同的修改,Git 就无法自动地合并它们,而是会暂停合并过程,等待你去手动解决冲突。

首先我们需要找到这些需要解决冲突的文件,使用 git status 可以查看这些因包含合并冲突而处于未合并状态的文件:

$ git status
On branch master
You have unmerged paths.
  (fix conflicts and run "git commit")

Unmerged paths:
  (use "git add <file>..." to mark resolution)

    both modified:      main.py

no changes added to commit (use "git add" and/or "git commit -a")

手动解决冲突类似于二选一的过程,Git 会在有冲突的文件中加入特殊的标记,看起来像下面这样:

<<<<<<< HEAD:main.py
print("Hello World")
=======
print("World Hello")
>>>>>>> feature:main.py

通过 ======= 进行分割,以 <<<<<<< HEAD:main.py 标记为上界的上半部分是当前分支 master 所做的更改,以 >>>>>>> feature:main.py 标记为下界的下半部分是要合并的 feature 对同一内容所做的不同更改。我们需要编辑文件删除这些标记,仅保留我们需要的内容:

print("Hello World")

当然也可以不从中选择,而是用一段全新的内容去替换它。

在解决了所有文件里的冲突之后,需要使用 git add 暂存这些文件来将其标记为冲突已解决。然后再执行 git commit 来完成合并提交。 Git 会将解决的这些冲突,加入到上文提到的新增的 Merge 提交里。

快进合并

也有些时候,我们在执行了合并操作后,会发现并没有增加一个新的 Merge 提交。这种情况我们称之为快进(fast-forward)合并。

假设我们基于 master 创建了 feature 分支,并新增了一些提交。现在我们将 feature 的更改合入 master 分支:

$ git checkout master
$ git merge feature
Updating f42c576..3a0874c
Fast-forward
 main.py | 2 ++
 task.py | 3 ++
 worker.py | 1 ++
 3 file changed, 6 insertions(+)

过程示意如下:

Dec-29-2020 22-50-28

由于我们想要合并的分支 feature 所指向的提交 D 是 master 的直接后继, 因此 Git 会直接将 HEAD 指针向前移动。换句话说,如果顺着一个分支走下去一定能够到达另一个分支,那么 Git 在合并两者时只会简单的将指针向前推进(右移),因为这种情况下的合并操作没有需要解决的分歧——这就叫做快进(fast-forward)。

Git 的不同合并策略

我们在使用 Git 时,通常会基于主分支拉出若干条功能分支进行开发,开发完毕后再将功能分支合入主分支。有以下不同的分支合并策略:

  • 通过 merge 显式合并
  • 通过 rebasefast-forward 隐式合并
  • squash 后隐式合并

通过 merge 显式合并

这是最常见和最直接的合并方式,也是 GitHub 和 GitLab 等代码托管平台的默认实现方式。

Dec-29-2020 22-40-46

当我们将功能分支合入主分支时,Git 会对两个分支进行递归三路合并,并以合并结果创建一个新的 Merge 提交。这个 Merge 提交和普通的提交本质上是一样的,但是它有两个父提交:

$ git cat-file -p 44ba027
tree 5a1692ba62ef346b59e65e4aa441c731bebc51ff
parent 75bf5c59c2e7e493c98e026a415f16b8f0445e4a
parent bbbe6a4c02aa709299ac891779448daf8203df53
author xx <[email protected]> 1609141855 +0800
committer xx <[email protected]> 1609141855 +0800

Merge branch 'feature' into 'master'

我们能在提交历史中,很明了地根据 Merge 提交查看发生的合并事件。但另一方面,大量的 Merge 提交会使你的提交历史有很多分叉,甚至十分凌乱,有些开发者或者团队可能会想要一个看上去更加整洁的线性提交历史。

需要注意的是,默认情况下 Git 不会在快进合并的情况下创建单独的 Merge 提交。假如我们想在所有情况下都创建一个 Merge 提交,需要在执行 git merge 命令时添加 --no-ff 选项。

通过 rebasefast-forward 隐式合并

我们可以用 rebase 替换 merge 进行合并,我在之前的一篇文章git-rebase 浅析中详细介绍过 rebase 的原理和用法,简单来说 rebase 操作会找到两个分支的最近的祖先提交,并基于目标分支按顺序重新应用当前分支在祖先提交之后的更改。假设我们有如下图的 masterfeature 两个分支,执行下列操作:

$ git checkout feature
$ git rebase master
$ git checkout master
$ git merge feature

过程如下图所示:

Dec-31-2020-rebase&fast-forfward

我们首先用 rebasemaster 合并到了 feature,即使两个分支都有不同的提交,也得到了一条完全线性的 feature 分支,而且没有额外的 Merge 提交。

接着又切换到 master 分支合并了 featurerebase 之后的 feature 分支上,所有提交都是 master 的后继提交,因此我们将直接执行快进合并。快进合并只有在 master 分支中没有比 feature 更新的提交时才会发生(使用 rebase 能够确保该结果),在这种情况下,masterHEAD 可以直接右移到 feature 分支的最新提交。这样合并也不会生成单独的 Merge 提交,它只是将分支标签快速指向了新的提交。

通过 rebasefast-forward 隐式的合并,我们能够得到一条整洁线性的提交历史,但同时也会丢失这些提交曾经的上下文信息。

squash 后隐式合并

还有一种合并变更的策略是,在执行快进合并或 rebase 之前,将所有功能分支的提交通过 rebase 交互模式的 squash 命令压缩成一个提交。这样可以进一步保持主分支提交历史的线性和整洁。它将一个完整的功能单独保存在一次提交中,但也失去了对整个功能分支开发过程的记录和细节。具体的操作方法可参考重写提交历史

这三种策略都有明显的优缺点,我们可以根据具体的场景以及自己的需求进行选择。

参考链接

updatedupdated2021-09-162021-09-16