多人开发中的合并冲突是我们使用Git时常常会遇到的情况,小小合并门道大,讲述合并的那些事儿,晴耕 · 白话之“Git合并那些事”系列​持续连载中……

注: 本文采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。知识共享许可协议

关于Merge策略

所谓Merge策略,是指Git在执行git merge命令时所能选择的合并策略。默认情况下,Git在合并分支时会自动选择最合适的Merge策略,我们也可以通过参数-s显式指定策略。不同的策略会对合并的方式与结果产生不同的影响,比如:对参与合并的分支,其“共同祖先(common ancestor)”的选择,以及在合并过程中发生冲突时的处理方式等。

对“共同祖先”这个概念还不了解的同学,不妨看一下《Git合并那些事儿——认识几种Merge方法》一文中有关Three-Way Merge的讨论。下面我们就来介绍一下常见的几种Merge策略。

Recursive策略

Recursive策略是Git在对两个分支进行合并时所采用的默认策略,它只适用于两个分支之间的合并。因此,对于超过两个分支的合并,需要反复地进行两两合并,才能最终完成所有分支的合并(这也是Recursive名字的由来)。本质上,Recursive就是一种Three-Way Merge。它的特点在于,如果Git在寻找共同祖先时,在参与合并的两个分支上找到了不只一个满足条件的共同祖先,它会先对共同祖先进行合并,建立临时快照。然后,把临时产生的“虚拟祖先”作为合并依据,再对分支进行合并。

如下图所示,在对两个分支上的提交A和B进行合并时,我们发现了它们有两个共同祖先,分别是:ancestor0和ancestor1。这个时候,Recursive策略会对ancestor0和ancestor1进行合并,临时创建一个虚拟祖先:ancestor2。用ancestor2与A,B一起进行Three-Way Merge。

Criss-Cross现象

出现多个满足条件的共同祖先的现象被称为Criss-Cross现象。虽然它并不一定经常发生,但是对于那些存在时间很长,拓扑结构很复杂的分支,还是很有可能会出现的。下面我们就用一个例子来模拟一下Criss-Cross现象,并观察Recursive策略是怎么处理这种情况的。

提示:接下来的过程可能会有点“烧脑”,所以建议最好照着下面的步骤实际操作一遍。不过如果你的脑子足够好使的话,也可以只在大脑里做做思维模拟:-)

首先,我们新建一个本地Git库,叫做test-recursive-merge:

$ git init test-recursive-merge
Initialized empty Git repository in /root/test-recursive-merge/.git/
$ cd test-recursive-merge

在master分支上新建README文件:

$ vi README
$ cat README
Merge strategies include:
* Recursive

并建立提交记录c0:

$ git add .
$ git commit -m c0
[master (root-commit) 8937d6a] c0
 1 file changed, 2 insertions(+)
 create mode 100644 README

然后,我们切换到分支feature1开始特性开发:

$ git checkout -b feature1
Switched to a new branch 'feature1'
$ vi README
$ cat README
Merge strategies include:
* Recursive
* ...

并在上面建立提交记录c1:

$ git commit -am c1
[feature1 39b06d9] c1
 1 file changed, 1 insertion(+)

再回到master分支,继续在master分支上进行开发。这期间,假设我们引入了一个拼写错误,把include写成了includes:

$ git checkout master
Switched to branch 'master'
$ vi README
$ cat README
Merge strategies includes:
* Recursive

并建立了提交记录c2:

$ git commit -am c2
[master ff8acc9] c2
 1 file changed, 1 insertion(+), 1 deletion(-)

紧接着,我们又新开了一个分支feature2:

$ git checkout -b feature2
Switched to a new branch 'feature2'

在该分支上把feature1的修改合并了过来,并建立了提交记录c3:

$ git merge -m c3 feature1
Auto-merging README
Merge made by the 'recursive' strategy.
 README | 1 +
 1 file changed, 1 insertion(+)
$ cat README
Merge strategies includes:
1) Recursive
2) ...

这个时候,我们在feature2上的README既包含了在feature1上引入的正常修改,同时也包含了在master上引入的拼写错误。现在,假设我们发现了这个拼写错误。于是切换回master,改正了错误,并建立了提交记录c4:

$ git checkout master
Switched to branch 'master'
$ vi README
$ cat README
Merge strategies include:
* Recursive
$ git commit -am c4
[master c096f34] c4
 1 file changed, 1 insertion(+), 1 deletion(-)

如果我们此时把feature1上做的修改合并到master上:

$ git merge -m c5 feature1
Merge made by the 'recursive' strategy.
 README | 1 +
 1 file changed, 1 insertion(+)
$ cat README
Merge strategies include:
* Recursive
* ...

然后用git log查看提交历史,就会发现,这个时候传说中的“Criss-Cross现象”出现了:

$ git log --graph --oneline --all
*   1725ff2 (HEAD -> master) c5
|\  
* | c096f34 c4
| | *   26dfc71 (feature2) c3
| | |\  
| |/ /  
|/| /   
| |/    
| * 39b06d9 (feature1) c1
* | ff8acc9 c2
|/  
* 8937d6a c0

如图所示,c3和c5的共同祖先有两个,它们分别是c1和c2。

这个时候,如果我们执行git merge-base命令,并传入master和feature2,即:寻找分支master和feature2的最佳共同祖先,则会得到两个结果,分别对应c1(39b06d9)和c2(ff8acc9):

$ git merge-base --all master feature2
39b06d96f6d584d2a7d5b2caff21dc75d5d5075f
ff8acc923e2a4d7d09a8d42236a190d47ff38286

如果我们查看master和feature1,或者feature1和feature2的最佳共同祖先,则只会得到一个结果,对应c1(39b06d9):

$ git merge-base --all master feature1
39b06d96f6d584d2a7d5b2caff21dc75d5d5075f
$ git merge-base --all feature1 feature2
39b06d96f6d584d2a7d5b2caff21dc75d5d5075f

当遇到这种情况时,如果我们想把feature2上的修改合并到master,Git会自动先合并c1和c2,为它们创建虚拟祖先,然后再对两个分支进行合并:

$ git merge -m c6 feature2
Already up to date!
Merge made by the 'recursive' strategy.

执行git log查看提交历史:

$ git log --graph --oneline --all
*   0935c24 (HEAD -> master) c6
|\  
| *   26dfc71 (feature2) c3
| |\  
* | \   1725ff2 c5
|\ \ \  
| | |/  
| |/|   
| * | 39b06d9 (feature1) c1
* | | c096f34 c4
| |/  
|/|   
* | ff8acc9 c2
|/  
* 8937d6a c0

如图所示,会看得更清楚些,c3和c5最终合并后生成了c6:

Ours和Theirs参数

在处理合并时,和其他某些Merge策略一样,Recursive策略通常会尽量自动完成合并。如果在合并过程中发现冲突,Git会在被合并的文件里插入冲突标记(merge conflict markers),并标记当前文件存在冲突,然后交由人工来处理。

不过,我们也可以通过指定参数告诉Git,当发生冲突时自动选择或丢弃其中一个分支上的修改。比如,假设我们要把分支B合并到分支A。如果指定参数-Xours,则表明丢弃分支B上的修改,保留当前分支A上的内容;指定参数-Xtheirs则刚好相反。

这里要注意的是,这两个参数只在发生冲突时起作用。而正常情况下,即没有发生冲突时,Git还是会帮我们自动完成合并的。关于这一点,在后面谈到Ours策略时可以做一个对比,两者在概念上比较容易混淆。

下面我们来做个实验加深理解。接着前面的例子,我们切换到feature1分支:

$ git checkout feature1
Switched to branch 'feature1'

对README文件做些修改:

$ vi README
$ git diff README 
diff --git a/README b/README
index f725440..1115275 100644
--- a/README
+++ b/README
@@ -1,3 +1,3 @@
-Merge strategies include:
+Git merge strategies include:
 * Recursive
-* ...
+* and so on...

并建立提交记录c7:

$ git commit -am c7
[feature1 9232cf1] c7
 1 file changed, 2 insertions(+), 2 deletions(-)

然后再切换回master分支:

$ git checkout master
Switched to branch 'master'

继续对README做修改:

$ vi README 
$ git diff README 
diff --git a/README b/README
index f725440..9855509 100644
--- a/README
+++ b/README
@@ -1,3 +1,3 @@
 Merge strategies include:
 * Recursive
-* ...
+* etc.

并建立提交记录c8:

$ git commit -am c8
[master 306f03a] c8
 1 file changed, 1 insertion(+), 1 deletion(-)

最后,我们把feature1上的修改合并到master:

$ git merge feature1
Auto-merging README
CONFLICT (content): Merge conflict in README
Automatic merge failed; fix conflicts and then commit the result.
$ cat README 
Git merge strategies include:
* Recursive
<<<<<<< HEAD
* etc.
=======
* and so on...
>>>>>>> feature1

可以看到,对于第一行,我们在feature1上的修改已经成功的被自动合并到master;而对于最后一行,由于我们在feature1和master上都做了修改。所以,Git无法进行自动合并,于是插入合并冲突标记,交由人工来处理。

我们可以看一下,假如指定-Xours参数会有什么效果。再次执行git merge之前,先使用--abort参数撤销当前合并:

$ git merge --abort
$ cat README 
Merge strategies include:
* Recursive
* etc.

然后执行git merge并指定-Xours参数:

$ git merge -Xours -m c9 feature1
Auto-merging README
Merge made by the 'recursive' strategy.
 README | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

利用git diff对比合并后的提交记录c9和之前的c8可以看到:对于第一行,因为不存在冲突,Git还是会帮我们自动完成合并;对于最后一行,由于存在合并冲突,而且我们选择的是保留当前分支(即:master)的版本,所以合并后的内容和上一个版本是一摸一样的:

$ git diff HEAD^ HEAD
diff --git a/README b/README
index 9855509..c090928 100644
--- a/README
+++ b/README
@@ -1,3 +1,3 @@
-Merge strategies include:
+Git merge strategies include:
 * Recursive
 * etc.

假如指定-Xtheirs参数呢?让我们先回退到上次合并前的提交记录c8:

$ git reset --hard HEAD^
HEAD is now at 306f03a c8

然后再执行合并,并指定-Xtheirs参数:

$ git merge -Xtheirs -m c9 feature1
Auto-merging README
Merge made by the 'recursive' strategy.
 README | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

这时的结果则刚好相反,合并产生冲突的部分保留了feature1分支的版本:

$ git diff HEAD^ HEAD 
diff --git a/README b/README
index 9855509..1115275 100644
--- a/README
+++ b/README
@@ -1,3 +1,3 @@
-Merge strategies include:
+Git merge strategies include:
 * Recursive
-* etc.
+* and so on...

Resolve策略

和Recursive策略类似,Resolve策略是另一种解决两个分支间合并问题的策略,同样也是采用的Three-Way Merge算法。关于它的介绍,网络上资料不多。只知道它是在Recursive策略出现之前,Git合并时所采用的默认策略。和Recursive策略不同的是,它在处理Criss-Cross情况时,会在多个满足条件的共同祖先里选取其中一个作为合并的基础(称为Merge Base)。在某些情况下,如果使用Recursive策略作为默认策略进行合并时遇到了问题,也可以尝试选择Resolve策略。

Ours策略

前面提到了,如果在使用Recursive策略时指定-Xours参数,那么当发生冲突时,Git会选择丢弃来自被合并分支的修改,而保留被当前分支上的原有修改。这种情况只在有冲突时才会发生,如果没有冲突,Git还是会帮我们自动完成合并的。

与之不同的是,Ours策略无论有没有冲突发生,都会毫不犹豫的丢弃来自被合并分支的修改,完整保留当前分支上的修改。所以,对于Ours策略而言,实质上根本就没有做任何真正意义上的合并,或者说做的是假合并(fake merge)。不过,从提交历史上看,Git依然会创建一个新的合并提交(merge commit),并让它的parent分别指向参与合并的两个分支上的提交记录。

接着前面的例子,我们继续通过实验来加深理解。先回退到上次合并前的提交记录c8:

$ git reset --hard HEAD^
HEAD is now at 306f03a c8

在当前分支master上执行git merge,并指定Ours作为合并策略:

$ git merge -s ours -m c9 feature1
Merge made by the 'ours' strategy.

查看提交历史会发现,master分支上多了一个提交记录c9:

$ git log --oneline --graph --all
*   e9d35fd (HEAD -> master) c9
|\  
| * 9232cf1 (feature1) c7
* | 306f03a c8
* |   0935c24 c6
|\ \  
| |/  
|/|   
| *   26dfc71 (feature2) c3
| |\  
* | \   1725ff2 c5
|\ \ \  
| | |/  
| |/|   
| * | 39b06d9 c1
* | | c096f34 c4
| |/  
|/|   
* | ff8acc9 c2
|/  
* 8937d6a c0

但对比合并前后的提交记录:c8和c9,却没有发现任何差异,两者内容是完全一样的:

$ git diff HEAD^ HEAD
$

OK,今天就到这里了。在接下来的一篇文章里,我们将继续学习Git的另外几种合并策略,比如Octopus策略,还有Subtree策略。我们下次再见!

留下评论

您的电子邮箱地址并不会被展示。请填写标记为必须的字段。 *

正在加载...