背景
小菜:“大鸟我最近碰到一件怪事”
大鸟:“哦?说来听听。”
小菜:“事情是这样的…”
旁白:
如下图,小菜基于 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 文件消失了!
大鸟:“我知道了,要解决这个问题,我们得先了解一下分支合并的一些相关知识…”
问题
- 为什么小菜的 feature.js 会丢失?
- 怎样操作才能正常合并 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>复制多个提交:
注:
- 注意顺序,越前面的提交越早
- git cherry-pick commitHash1..commitHashN,这种写法,提交必须是连续的
git cherry-pick <commitHash1> <commitHash2>
git cherry-pick commitHash1..commitHashN--continue
同 rebase
--abort
同 rebase
--quit
同 rebase