git必知必会-分支合并那些事

背景

小菜:“大鸟我最近碰到一件怪事”

大鸟:“哦?说来听听。”

小菜:“事情是这样的…”

旁白
如下图,小菜基于 master 分支的提交 C1 迁出了一条新分支 dev,并添加了 feature.js(D1),这是一个新功能;

然后他切换到 master 分支,发现此时同事有一个新提交 C2。小菜很自信,他直接把自己的 dev 分支合了过来(E1),然后发布到了测试环境。

第二天,QA 提了 bug,小菜排查后发现是由于昨天添加了 feature.js 的原因,所以小菜只好把昨天的提交(E1) revert 了(E2);

之后小菜切换到自己的 dev 分支修复 fuature.js 的问题。他添加了一个 fix.js 并 import 了 feature.js。

这次小菜长了心眼,在合并 dev 分支后(F),他在本地自测了一下。但奇怪的是,他发现合并后自己的 feature.js 文件消失了!

大鸟:“我知道了,要解决这个问题,我们得先了解一下分支合并的一些相关知识…”

问题

  1. 为什么小菜的 feature.js 会丢失?
  2. 怎样操作才能正常合并 dev 分支到 master?

三路合并(Three-way merge)

首先我们先来了解一下三路合并算法,这是文件合并的常用算法。

以下内容引用自维基百科

三路合并(three-way merge),首先考虑对文件 A、文件 B 以及它们的共同祖先文件 C 做差异分析。

对于文件中的每节(sector),如果上述三个文件中有两个文件该节的内容一致,那么抛弃文件 C 中该节的内容,保留与文件 C 中不同的内容放到结果文件中。

如果该节在三个文件中都不同,那么这个冲突需要手工合并。

举个例子:

如图所示,因为 base 版本和 ours 版本的内容一致,所以我们最终选择 theirs 版本做为合并结果。

Git 合并策略

接下来我们了解一下 Git 的合并策略,包括:Fast-forward、Recursive、Ours、Theirs、Octopus

Fast-forward

Fast-forward 是一个比较简单的合并策略。它的核心内容是:
在分支合并时,如果发现最近公共祖先就是它们其中之一,就会直接移动文件指针,不产生新的提交

Recursive

Recursive 也被称为递归三路合并,是 Git 分支合并中最常用的策略。
核心内容是:递归寻找最近公共祖先,并以其为 base 节点,进行递归三路合并。
下面我们来看几个例子:

注:以下所有 Git 提交树,都以其节点名称作为文件内容

一个简单的例子

当我们要合并中间两个提交 A、B 时,会去寻找他们的最近公共祖先,所以就找到了左边的 A 节点;

以它为 base 节点,根据三路合并算法,最后的合并结果为 B。

小菜:“不是说叫做递归三路合并吗,这里完全没有递归操作啊?”

大鸟:“别急,下面我们来看一个更加复杂的例子。”

一个复杂的例子

但实际情况往往没那么简单,可能出现层级较深、复杂交叉等情况。如下图:

我们发现,在合并 BC 节点的时候,出现了两个最近公共祖先,分别为 A、B,如下图:

所以我们需要再递归,直至寻找到唯一的最近公共祖先,为最左边的 A 节点。

那么我们的合并节点和顺序就可以确定了,如下图:

首先我们以 A 为 base,合并 AB 节点,得到一个临时节点,根据三路合并算法,它的结果是 B。

然后以这个临时节点 B 为 base,合并 BC 节点,得到最终结果为 C。

Ours

Ours 的合并策略比较简单。它的核心内容是:丢弃目标分支的内容,采用当前分支的内容

所以在这种合并策略下,最后的内容和没有合并时的当前分支的内容是一样的

注:Ours 策略和 -Xours 参数不一样, Ours 策略无论有没有冲突发生,都会丢弃目标分支的修改。
-Xours 参数则是仅在出现冲突时这么处理。

Theirs

该策略目前已不可用,仅在旧版本的 Git 中存在!
Theirs 和 Ours 类似,只是保留内容的目标变了。它的核心内容是:丢弃当前分支的内容,采用目标分支的内容

所以在这种合并策略下,最后的内容和没有合并时的目标分支的内容是一样的

Octopus

这个策略的意思是章鱼。顾名思义,这种策略可以用于合并多条分支。不过,如果出现需要手工解决的冲突,操作将执行失败。

解答

那么回到我们最开始的例子以及问题

大鸟:“小菜,现在知道为什么你的合并操作结果不对了吗?”

小菜:知道了,我们首先寻找 D2 和 E2 的最近公共祖先,为 D1,如下图

D1 不符合使用 Fast-forward 策略的条件, 所以根据 recursive 策略, 以 D1 为 base 节点,对 D1、D2、E2 进行差异分析:

  • D2 的变更是:添加了 fix.js;

  • E2 的变更是:删除了 feature.js(这里 revert 等同与删除)。

所以根据三路合并算法,两个文件的变更都被自动合并了,所以 feature.js 被删除了

大鸟:“说得没错,但这只是对错误进行解释,并不是根本原因”

小菜:“那根本原因是什么呢?”

大鸟:“根本原因就是你在 dev 分支上开发的时候,内容并不是最新的”

小菜:“我知道了!所以我的 dev 分支应该先拉取 master 的最新代码,再进行开发;
这样我在合并的时候就根本不会使用 Recursive 策略,而是根据 Fast-forward 策略合并就好了!”

大鸟:“不错不错,孺子可教也…”

参考

附1:merge 和 rebase 的区别

merge

merge 操作会生成新节点,并保留每个提交历史的祖先。

优点

  • 使用简单,易于理解。
  • 能够记录 commit 、分支历史情况。

缺点

  • 历史记录会比较杂乱。

rebase

rebase 是将一个分支的修改重写到另一个分支上,不需要创建新的提交。

优点

  • 能够合并 commit 历史
  • 易于制造更加干净、线性的提交树

缺点

  • 会改写历史记录,可能会丢失上下文。

附2:cherry-pick 详解

作用

将某些提交复制到当前分支上。

使用场景

小菜最近接到了一个新需求,需要引用一个外部库 demo.js。他开了一条新分支 demo,在该分支上进行新需求的开发。

由于他不熟悉这个库,所以在开发过程中,他添加了一些测试代码,这些测试代码分散在不同的提交记录中

某一天,这个新需求开发好了,小菜准备将分支合并到 develop 上,然后提交代码。

但是总不能将测试代码也一起提交吧?或者手动删除?不行,效率太低,小菜犯愁了。

这时,小菜的同事建议可以使用 cherry-pick,小菜赶紧了解了一下,发现还真的能够快速解决问题。

如上图所示,提交记录中 M 开头的表示 demo 分支的主要功能,是需要被合并到 develop 分支的;

而 T 开头的则是测试代码,这些提交是不想合并到 develop 分支的。

所以小菜切换到 develop 分支并运行了如下命令,解决了这个问题:

git cherry-pick M1 M2

优缺点

现在我们来看一下 cherry-pick 这个命令的优缺点。

优点:能够快速进行提交的复制、移动

缺点:由于 cherry-pick 是 复制 提交,而不是合并。
所以如果分支并行地进行开发,在合并时可能会出现问题,而且不会出现冲突提示。我们来看一个简单的例子:

注:这里提交节点附近的文字代表文件内容

如上图,在节点 C 我们使用了 cherry-pick 从 dev 分支复制了 E 节点到 master 分支(这里虚线代表进行 cherry-pick 操作,不代表 merge)。

然后我们在 dev 分支修改了文件内容,变为 cherry,此时将 dev 分支合并到 master。

我们很容易知道 CF 的最近公共祖先为 A,根据三路合并算法,所以最终的合并结果为 pick,这并不是我们所期望的。

而如果是合并操作,那么 CF 的最近公共祖先则是 E 节点,此时根据三路合并算法,结果就变成 cherry 了,这是正确的结果。

所以我们在使用 cherry-pick 时,需要注意使用场景,不能盲目使用。

附3:merge 和 rebase 相关参数

merge

  • --stat-n 以及 --no-stat

    • --stat 参数会在合并的提交信息后附加文件差异信息(默认就是会附加的,信息如下图)

    • -n--no-stat--stat 相反,不附加文件差异信息

  • --squash--no-squash

    • --squash 参数表示会将被合并的分支提交压缩在一起成为一个新节点,并需要重新确认提交信息。

    • --no-squash 则相反。

    注: 该操作会改变 author

  • --commit--no-commit

    • --commit 参数表示在合并后会自动进行提交。

    • --no-commit 参数则相反,不进行自动提交。

  • --e, --edit--no-edit

    • --e--edit 表示在成功合并前进行合并信息的编辑。

    • --no-edit 则相反,即使用自动合并的信息。

  • --ff--no-ff 以及 --ff-only

    • --ff 指 fast-forward 模式,使用该模式进行合并时将不会创造一个新的提交节点。

    • --no-ff 则相反,在合并时会创建一个新的提交节点。

    • --ff-only 表示如果合并过程出现冲突, Git 会自动 abort 此次合并。

  • --Xours--Xtheirs

    用于指定在出现冲突时,完全采用一方的变动而忽略另一方。

  • --s, --strategy

    用于指定合并的策略,合并策略详见上方正文。

  • -X, --strategy-option

    用于指定合并策略的具体参数。

  • -m, --message

    用于指定合并的提交信息(仅在非 fast-forward 模式下可用)

  • -abort

    退出合并(一般在出现冲突时使用)。

rebase

  • --i, --interactive

    打开互动模式,以 GUI 的形式让使用者进行操作。

  • --stat-n 以及 --no-stat

    同上方 merge

  • --continue

    表示继续 rebase 流程,通常用于 rebase 处理冲突后。

  • --skip

    表示直接跳过当前分支的提交,采用目标分支的提交。

  • --abort

    表示放弃 rebase,并将 HEAD 重置为原始分支。

  • --quit

    表示放弃 rebase。

    --abort 的区别: --quit 不会重置工作区和索引

附4:cherry-pick 用法

  • 最常见的用法:

    注:cherry-pick 后如果使用分支名称,则复制的是分支的最新提交。

    git cherry-pick <commitHash>
    git cherry-pick <branch>
  • 复制多个提交:

    注:

    1. 注意顺序,越前面的提交越早
    2. git cherry-pick commitHash1..commitHashN,这种写法,提交必须是连续的
    git cherry-pick <commitHash1> <commitHash2>
    git cherry-pick commitHash1..commitHashN
  • --continue

    同 rebase

  • --abort

    同 rebase

  • --quit

    同 rebase

文章作者: actake
文章链接: https://actake.github.io/2021/03/21/git必知必会-分支合并那些事/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 actake的博客