在这里插入图片描述

使用 merge 命令来进行分支合并是 Git 中最重要的操作之一。虽然这一操作的底层算法很复杂,但调用起来却很简单。我们可以通过指定分支名称来选择待合并修改的分支。然后, Git 会基于合并的内容来创建一次新的提交。

下面,我们来看下图中的这个例子:在一群开发者在一个名为 feature 分支上开发新功能的同时,另一位开发者则刚刚修复了 master 分支上的某个错误(提交E) 。 然后过了不多久,feature 部分的任务也完成了,并将交付使用。因此 master 分支的下一个版本中应该同时包含被修复的部分和新的 feature 部分。这时候,我们要对这些分支使用 merge 命令,其结果会产生一次合并提交(即这里的提交 F), 该提交将会有两个父级提交 (D 和 E)。

> #on the branch "master"
> git merge feature

在这里插入图片描述


1️⃣ 合并过程中发生的事

Git的设计目标之一就是为了能让开发者之间的分布式协作变得尽可能容易一些。因此从 很大程度上来说,merge 命令应能自动对分支进行合并,完全不需要用户交互。但这是怎么做到的呢?

例如在下图中,我们会看到某一个文件有两个不同版本,它们分别属于分支a 和分支b。 我们很容易就能看出这其不同之处位于哪几行。但究竟哪一个才是正确的呢?是 “Freitag” 还是 “Montag”? 是 “Git” 还是 “Fit”? 合并算法应该如何作出决定呢?

在这里插入图片描述

问题关键就藏在其提交历史之中。这里的窍门就是要找到它们最后一个共同的祖辈提交。 换一种相对简单点的说法,就是要找到其提交路径上岔出分支的那个点。只要我们将该源版本与眼前的这两个分支的版本比对一下,整个画面就会变得更为清晰。

如你所见,在图下这个例子中,分支 b 中的第一行 “Freitagabend” 被替换成了 “Montagabend” 。 而在分支a 中,第一行则没有被修改。这在进行分支合并时是一个强烈信号, 它告诉我们应该采用包含 “Montagabend” 的版本。通过同样的方式,我们也可以安全地确认,对于最后一行我们应该采用包含“Git”的版本,而不是“Fit”的版本,其最终结果如图所示。

在这里插入图片描述
当然从事实上来说,真要想找到它们共同的祖辈提交可不是一件容易的事。为解决这个问题,Git 实现了3种不同的合并算法。其在默认情况下采用的是递归算法。但除此之外,它还实现了经典的3路算法和所谓的 “octopus” 算法。其中, “octopus” 还可以同时处理多个分支。


2️⃣ 冲突

Git 非常适合于在几个开发者对同一软件做多处修改时,被用来合并他们对程序源代码中 所做的修改。这些操作甚至常会涉及到那些受移动或重命名操作影响的文件。而不幸的是,这些文件往往会引发一些无法用 Git 自动化解决的冲突。

  • 编辑冲突:通常发生在两个开发者对同一行代码做了不同修改的时候。在这种情况下, Git 往往无法自行确定两种修改中的哪一种才是正确的。
  • 内容冲突:通常发生在两个开发者对某份代码的几个部分做出各自修改的时候。例如 这种情况就容易导致这类冲突:当一个开发者在修改某一函数的时候,另一个开发者 也在同一时间修改了同一函数。

3️⃣ 编辑冲突

当Git 遇到了自身无法解决的冲突时,就会显示以下错误消息。

> git merge one-branch
Auto-merging foo.txt
CONFLICT(content):Merge  conflict in foo.txt
Automatic merge failed; fix conflicts and then commit the result.

下面我们来看看具体发生了什么。

  1. Git 无法创建提交。Git 通常会在合并后自动创建提交。而在发生冲突的情况下,我们就必须要先解决问题,然后再手动创建提交了。
  2. .git/MERGE_HEAD 中将保存另一分支的提交散列值。
  3. 工作区中的文件代表了合并结果。
  4. 无冲突部分的修改合并将会被记录在暂存区中,以便纳入下一次提交。
  5. 将会有冲突标志被插入。
  6. 冲突所在之处将不会被注册到下一次提交中。

现在,根据 status 命令返回的信息,我们可以看到 “Changes to be committed” 这部分显示的是自动合并的文件。而 “Unmerged paths” 这部分就是用户必须进行手动编辑的文件。

> git status
#On branch master
#  (fix conflicts and run "git commit")
#
#Changes to be committed:
#
#modified:  blah.txt
#
#Unmerged  paths:
#    (use( "git add <file>..."to mark resolution)
#
#both  modified:  foo.txt
#

4️⃣ 冲突标志

冲突标志通常会描述两组修改。首先是这些被修改的行在当前分支(HEAD) 中的内容。
接下来又列出了他们在另外一个分支(即MERGE_HEAD, 在这里是 one-branch) 的内容:

In the early morning dew
<<<<<<< HEAD
to the valley
=======
for swimming
>>>>>>> one-branch
We're going.Fallera!;

出于各种历史原因,这些分支提交的共同祖辈在默认情况下是不显示的,但我们可以将 其配置成3路显示格式。

> git config merge.conflictstyle diff3

这样一来,编辑冲突就会如下所示。

In the early morning dew
<<<<<<< HEAD
to the valley
||||||| merged common ancestors
to mountains
=======
for swimming
>>>>>>> one-branch
We're going Fallera!;

5️⃣ 解决编辑冲突

解决编辑冲突最好的办法是使用像 kdiff3 这样的合并工具。在这里,我们可以从mergetool 命令启动合并工具。

> git mergetool

在这个工具中,我们可以解决冲突、保存修改以及终止这个应用程序。然后,合并之后 的修改将会出现在暂存区中,它们可以被确认为一次提交。

当然对于二进制文件来说,上面这种文本化的冲突标志是不存在的。在这种情况下,我 们就必须要去查看其原始版本。该文件的3个版本在冲突中扮演了各自的角色:即当前分(我们的)的版本、其他分支(他们的)的版本、以及这两个分支最后的共同祖先(祖辈版本)。

我们可以用 show 命令检出这些版本。

> git show :1:picture.png >ancestor.png
> git show :2:picture.png >ours.png
> git show :3:picture.png >theirs.txt

1a. 编辑受影响的文件
对于每一个冲突所在之处,我们都考虑自己想要采用的选项,然后在文本编辑器中删除冲突标志所在的剩余部分即可,但这种方法对二进制文件是不适用的,因此我们就需要用到步骤1b 了。


1b. 采用 --ours--theirs选项
或者,我们也可以用checkout 命令来完全选择只采用自己的(或者是别人的)那个版本的文件。

git checkout --theirs tests/


2. 注册修改
git add

3. 提交
git commit

另外,合并和比较工具往往也会将一些空白符方面的修改显示出来。例如,如果某个开 发者将制表符替换成了空格符,其涉及到的所有行都会被标记,尽管他没有在内容上着任何修改。这些工具通常会有相关的选项可以忽略掉空白符的修改,我们建议你使用这个选项。

当然,更好的选择是所有开发者都能用相同的工具来进行源代码的自动格式化,那我们
就等于解决了格式冲突的一个根源。

然而事情总有意外!如果我们在合并时犯了一个错误或者在解决冲突时出了错的话,就不应该再继续做下去了,相反,这时候我们应该果断地取消合并,这样我们就不会在工作区中留下合并操作的踪迹,并且 Git 中也不会在下轮提交中出现合并提交,合并操作可以通过reset 命令来取消。

> git reset --merge

6️⃣ 内容冲突

真正的麻烦是内容冲突,由于Git 无法识别这类冲突,自动化解决当然是肯定不用想了。 其真正的危险来自于当内容冲突存在时,merge 命令还是会生成有效的合并提交。
请注意! 这也就是说,即使所有的合并版本都是正确的,且 Git 也没有报告任何编辑冲
突,该合并提交也可能是坏的!

如果我们想避免内容冲突扰乱软件版本,就得要做更多事。

  • 借由自动化测试构建保护机制:如果这些测试能够定期进行,并且有一个很好的覆盖 面的话,各种内容冲突就能很快被发现。
  • 使用断言、以及前置与后置条件:基本上,我们执行越多明确的断言检查,就越能更 早地发现问题。
  • 定义清晰的接口,使其实现松耦合:以目前所讨论的点来说,显然体系结构设计得越 干净利落,其代码因不同地方被混入修改而引发意外副作用的可能性就越小。
  • 静态类型检查:只要我们的编程语言支持这一特性,那么任何签名变化所引发的问题 都将会在编译时被检测到。

顺便说一句, merge 命令在这里对于多分支的合并也是有效的,这就是 octopus 合并。


7️⃣ 快进合并

我们常常会遇到这样的情况:即若干个分支中中往往只有一个分支仍在持续工作。例如 在下图的这个项目中。开发者们一直都在 a-branch 分支下开发,而 b-branch 分支上则什么 事也没有发生。当 b-branch 与a-branch 这两个分支要进行合并时,Git 要做的工作就非常简单了:只要前移一下指针即可,不再需要产生合并提交了,我们称这种情况为快进合并。

> git checkout b-branch
> git merge a-branch
Updating 9d4caed..9332b08
Fast-forward
foo.txt | 2 +-
1 files changed,1 insertions(+),1 deletions(-)

在这里插入图片描述快进合并的优点是它能简化版本库的历史记录并使其保持线性发展。而缺点则是我们不能根据已经合并过的历史记录来看版本库的这一发展。正是因为它存在这样的缺点, 我们才需要在本书的一些工作流中使用 --no-ff 选项,以强制其产生一次新的提交。

> git merge --no-ff a-branch

在这里插入图片描述


8️⃣ 第一父级提交历史

合并提交通常都会有两个父级提交,甚至 octopus 合并中还会有两个以上的父级提交存
在。例如在下面的例子中,我们会看到两个父级提交 ed1c70e 和 fld55be。

> git log --merges
commit 7f3eae07c42df05f894fdd4754e38ab9e66a5051
Merge: ed1c70e f1d55be
Author: ...

这个例子中的第一次提交 (ed1c70e) 叫做第一父级提交,它是合并执行完后HEAD 所在的那个提交。代表的是该合并所发生的地方。如果所有的开发者都在同一分支上工作,那么它无论何时何地执行合并都不会影响结果。

在这种情况下,我们去深究哪一个是第一父级提交就显得毫无意义了。
另一方面,当我们需要将自己在某些特性分支上所开发的一个个特性集成到特定的特性 分支上时,这个集成后的结果分支 (即本例中的 master 分支) 就是一个合并提交的序列(见下图)。它的第一父级提交通常就是其上一级特性的合并提交。

在这里插入图片描述

如果我们沿着第一父级提交链一路追踪到根提交上,就会得到一份特性集成的概览。我 们将其称之为第一父级提交历史。你可以通过带 --first-parent 选项的log 命令来显示这份特性集成概览:

> git log --first-parent --oneline R1.0..master
7f3eae0 Merge branch 'Feature-C' Finished(M4)
ed1c70e Merge branch 'Feature-A' Finished(M3)
eeb6ec2 Merge branch 'Feature-B' Finished(M2)
8ce3213 Merge branch 'Feature-A' Partial delivery(M1)

第一父级提交历史的奇妙之处在于,它为我们提供了一份历史的总结报告。你可以从中
清楚地看到哪些已被集成的特性,无需再去侦查那些特性分支上的每一次提交。
请注意! 这只适用那些执行了快进合并的集成分支。否则,独立的特性分支提交只能被
直接放置到 master 分支的第一父级历史中。

还有一件事也需要注意! 我们不应该对集成分支(即这里的 master) 执行内部合并。相 反,我们要确保这些特性都是依次连续地被集成进来的,这样我们才能得到一份线性的特性合并历史。


9️⃣ 棘手的合并冲突

在 Git 中,大多数合并操作都可以在没有或者只有少量人工辅助的情况下自动完成。但
如果两个分支各自演变的轨迹非常的不同,也有可能会带来一些棘手的冲突。
当然,在这一节我们只讨论两个分支之间的合并。如果你是在 octopus 合并上遇到了这样的问题,建议你应该取消这次合并,试着采用逐个解决的思路来解决这个问题。

首先我们需要将重点放在信息收集上,以便了解目标分支上目前所发生的事。在这里, 在 log 命令中使用 .. 这符号可能会很有帮助。例如,a…b 可用来表示来自于分支 b, 但不属于 分支 a 的提交。它可以显示出“我们” (在当前分支上)做了哪些事,而这些事应该不会被提交到其他分支中。

> git log MERGE_HEAD..HEAD

反之亦然,我们也可以用该符号来显示“别人”所做的事情。

> git log HEAD..MERGE_HEAD

另外,分支的图形化表示也会很有用。

> git log --graph --oneline --decorate HEAD MERGE_HEAD

我们也可以在 log 命令中使用 --merge 选项,限制其只输出合并提交。

> git log --merge

除此之外,我们也可以使用一些比对原版本时会用到一些实用的分支技巧。但这需要以
合并操作为基础,即该版本必须在合并操作中是这些分支共同的祖辈提交。

> git merge-base HEAD MERGE_HEAD
ed3b1832c48b359111d00bddb071c42ba6f38324
> git diff --stat ed3b18  HEAD %Our changes
> git diff --stat ed3b18  MERGE_HEAD %Changes by others

如果想用图形化工具代替这种文本输出的话,你也可以使用 difftool 命令。
这样一来,我们就可以看到涉及冲突的是那几个开发者。这时候,我们最好能与他们每
个人都谈一谈,使得每个人都能自行确保他或她被纳入合并的修改是正确的。

如果其他人对此无能为力,那事情就更加难办了,因为我们通常对别人分支上的事情并 不精通。从技术上来说,合并原本应该是一个对称的操作。但我们在意识中往往对此会有一 个不对称的视角。即我们一般会问自己问题:“我应该怎样将别人的代码纳入到自己的代码呢?” 其实,有时将问题反过来看会更有帮助,即我们不妨以别人的版本层次为出发点,去找出可以将自己的修改整合进去的方法。这样的视角转换有时候确实会很有帮助。

或许是由于时间紧迫,我们常常倾向于直接用合并工具选取这份或那份代码变更了事。 这种贪图方便的行为是应该被抵制的。如果经过difflog工具配合“其他”版本的分析之后, 你依然无法确定解决冲突的方式,那么你就应该取消合并,然后再考虑一下以下几种可能的策略。

  • 分支重构:最干净利落的解决方案可能就是以重构的方式其中一个分支进行清理,并 执行交互式变基。但这是一个很大的工作量。
  • 分小步合并:如果两个分支中的一个分支存在细粒度的提交,我们可以采用一次一提 交的方式来处理。这种方法的优势在于,毕竟粒度越小的提交所带来的冲突往往越容易解决。但如果这种提交的数量很大,它也可能会非常耗时,无论是哪一种情况,为此创建一个本地分支都是值得推荐的做法。
  • 丢弃与捡取:在某些情况下,拒绝某个劣质分支上的某些修改是一个不错的做法,我 们可以通过 cherry-pick 命令来对其采取些改进措施。
  • 评级和测试:如果受合并影响的功能可以通过测试,那么我们自然在解决冲突时候据 此来推演,并将其结果改善到能通过所有测试为止。

🌾 总结

  • 合并:所谓合并就是对相关提交图中的分支执行合并操作。
  • 合并提交:执行 merge 命令的结果就是产生一次合并提交。
  • 3路合并:Git会在合并时利用提交图找到合并双方最后的共同祖先。然后, Git 将引自该祖先的一个分支上的修改,连同另一分支上所做的修改放在一起。只要这些修改发生在这份源代码的不同之处, Git 就能自动创建相应的合并提交。
  • 冲突:对于源代码中 Git无法自动合并(或许是由于同一行被人做了不同的修改)的那个点,我们称这里发生了冲突。
  • 内容冲突:虽然修改通常会发生在不同的位置上,但它们在内容是仍然可能会不匹配。 由于Git无法检测到这样的内容冲突。所以项目自身应该设置一些相应的预防措施, 例如自动化测试等,以保护自己免于内容冲突的破坏。
  • 快进合并:在合并过程中, 一个分支是另一个分支的祖先是很常见的。在这种情况下, Git 就只需要将分支指针前移即可,无需去创建合并提交。


温习回顾上一篇(点击跳转)
《【Git教程】(五)分支 —— 并行式开发,分支相关操作(创建、切换、删除)~》

继续阅读下一篇(点击跳转)
《【Git教程】(七)变基与拣取 —— 变基操作的概念、适用场景及其实现方式,拣取操作的实现 ~》

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐