如果您在代码合并时发现 Git 合并结果不符合预期,您是否曾怀疑 Git 出现了 bug ?我希望您把这个选项放在最后。让我们一起来看看 Git-Merge 的那点事儿,一起来查找并解决代码合并的异常...
理解三路合并
Git 在分支合并时,采用三路合并的方法。不管合并的两个分支包含多少提交,Git 合并操作只关心代码的三个版本,即:合并双方的两个版本和一个基线版本。
看下我们的示例程序,使用 Go 语言开发,执行结果如下:
$ git clone https://github.com/jiangxin/git-merge-demo.git
$ cd git-merge-demo
$ git checkout demo-1/user-1 --
$ go build -o demo
$ ./demo
Topics:
♠
♥ ♥
♣
该示例程序输出一些符号,每行输出同一种符号,代表了某一个特性,符号个数代表该特性功能上的异同。
我们可以看到 "demo-1/user-1" 分支上,"demo" 程序有三个特性。特性 "♠"(一颗)、特性 "♥"(两颗)、特性 "♣"(一颗)。
同样我们在 "demo-1/user-2" 分支上会看到不同的特性输出:
♠ ♠ ♠
♥ ♥ ♥
♦ ♦
这两个分支各自包含三个特性。如果要将这两个分支合并在一起,那么 "demo-1/user-1" 分支上独有的 "♣" 特性应该出现在最终的合并结果中么?"demo-1/user-2" 分支上独有的 "♦" 特性应该出现在最终的合并结果中么?对于双方共同拥有的 "♠" 特性在合并结果中是出现一次,还是重复出现三次呢?
仅从上面这两个分支信息,我们很难推导出合理的合并结果。这时必须要考虑一个问题:这两个分支的特性是如何演变来的。我们要寻找这两个分支的共同的祖先提交,即寻找基线。
借助 git merge-base
命令,我们可以看到基线提交编号是 228ec07
:
$ git merge-base --all demo-1/user-1 demo-1/user-2
228ec07e07ac83295b96be1ad55adfbd0c870f74
注意:上面命令中的 --all
参数会显示两个分支所有的基线提交,可能的结果有:
- 0 条基线。即两个分支没有重叠,没有公共的历史提交。
- 1 条基线。大多数合并场景两个分支存在一条基线。
- 多条基线。可能的场景有:1、两条分支都是集成分支,都接受来自各个特性分支的合入。2、特性分支之间存在依赖关系,复杂的特性分支和集成分支之间可能存在多条基线。
本例只有一条集成分支。从下面的 git-log
命令可以看出本例的两个分支提交历史还是很简单的,两个分支各自独有的提交标记为 "+" 和 "x",共有的提交标记为星号。从这两个分支提交历史的重叠部分,我们很容易看出来重叠的顶端提交 228ec07
即是我们要找的基线提交。
$ git log --graph --oneline demo-1/user-1 demo-1/user-2
+ 34b76a2 (demo-1/user-2) Topic 4: ♦ ♦
+ dfdce61 Remove topic 3
+ 68bc440 Topic 2: ♥ ♥ ♥
| x 55e002f (demo-1/user-1) Topic 1: ♠
|/
* 228ec07 (demo-1/base) Topic 3: ♣
* d1f0d8c Topic 2: ♥ ♥
* 8f19971 Topic 1: ♠ ♠ ♠
* bfdeca2 Demo for git 3-way merge
切换到基线提交 228ec07
上执行看看:
$ git checkout 228ec07 --
$ make
Topics:
♠ ♠ ♠
♥ ♥
♣
下图综合了该程序三个版本,那么合并版本是不是呼之欲出了呢?
基线版本 User-1 版本 User-2 版本 合并版本
======== ============= ============= ===========
♠ ♠ ♠ ♠ ♠ ♠ ♠ ?
♥ ♥ ♥ ♥ ♥ ♥ ♥ ?
♣ ♣ ?
♦ ♦ ?
对于特性 ♠ ,相比基线版本,User-1将其修改为一颗,User-2 没有修改;对于特性♥,相比基线,User-1没有修改,User-2将其修改为三颗;对于特性♣,User-1没有修改,User-2将其删除;而对于特性♦,是由 User-2 新引入的特性。
让我们执行命令来看一下真实的合并结果吧:
$ git checkout demo-1/user-1
$ git merge demo-1/user-2
$ make
Topics:
♠
♥ ♥ ♥
♦ ♦
Revert 操作引发的“异常”合并结果
下面我们来看一个“异常”的合并。
在分支 "demo-2/topic-1" 运行,显示如下:
$ git checkout demo-2/topic-1
$ make
◉ ◉ ◉
▲ ▲
△ △ △
其中特性 ◉ 是主干 "demo-2/master" 引入的特性。Topic-1 引入两个子特性: ▲ 和 △ 。
在分支 "demo-2/topic-2" 运行,显示如下:
$ git checkout demo-2/topic-2
$ make
◉ ◉ ◉
♡ ♡ ♡
在主干分支 "demo-2/master" 运行,显示如下:
$ git checkout demo-2/master
$ make
◉ ◉ ◉ ◉
♡ ♡ ♡
我们可以看出主干分支的特性 ◉ 演进了,而且已经包含了 "demo-2/topic-2" 分支的特性。
现在将 "demo-2/topic-1" 分支合并到主干分支 "demo-2/master",看看合并解决是否符合预期:
$ git checkout demo-2/master
$ git merge demo-2/topic-1
$ make
◉ ◉ ◉ ◉
△ △ △
♡ ♡ ♡
看出问题了么?"demo-2/topic-1" 分支的特性没有全部合入,只合入了特性 △ ,而丢失特性 ▲ !
让我们来分析一下:
-
先撤回刚刚的合并提交。
$ git reset --hard HEAD^
-
计算合并基线:
$ git merge-base --all demo-2/topic-1 demo-2/master 5db8ab7c5c1d2ede82096ae890446c290a664060
-
切换到这个基线版本:
$ git checkout 5db8ab7c5c1d2ede82096ae890446c290a664060 -- $ make ◉ ◉ ◉ ▲ ▲
- 基线版本有特性 ▲ ,分支 "demo-2/topic-1" 也有特性 ▲ ,但是主干分支 "demo-2/master" 上并没有特性 ▲。
按照三路合并的原理分析,我们看出合并结果丢失特性 ▲ 是预料中的:
基线 5db8ab7 demo-2/master demo-2/topic-1 合并版本
============ ============= ============== ========
◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉
▲ ▲ ▲ ▲
△ △ △ △ △ △
♡ ♡ ♡ ♡ ♡ ♡
-
下面命令用于查询基线
5db8ab7
之后主干分支的变更,看看哪个提交删除了特性 ▲ :$ git log --stat --oneline 5db8ab7..demo-2/master
.... ....
e50fc0b Topic 1 is not complete, and is not ready for merge
topic/topic-1.1.go | 20 --------------------
1 file changed, 20 deletions(-)
.... ....
从上面的日志输出,我们看到主干分支 "demo-2/master" 上的一个可疑提交 e50fc0b
。这个提交删除了文件 "topic-1.1.go"。提交说明表明“因为当时 topic1 功能尚未完整,故撤回不完整的合并提交”。
如何才能将 "demo-2/topic-1" 特性完整地合入呢?采用如下操作:
-
对提交
e50fc0b
再次执行一次撤回操作。$ git checkout demo-2/master -- $ git revert e50fc0b
-
然后再合并分支 "demo-2/topic-1"。
$ git merge demo-2/topic-1
-
执行程序,查看合并效果。
$ make ◉ ◉ ◉ ◉ ▲ ▲ △ △ △ ♡ ♡ ♡
操作完成,我们看到 "demo-2/topic-1" 分支完整的特性都合入了。
“脏合并”引发的“异常”
下面我们来看另外一个“异常”的合并。
在当前版本的开发分支 "demo-3/master" 运行,显示如下:
$ git checkout demo-3/master
$ make
◉ ◉ ◉ ◉ ◉
♠ ♠ ♠ ♠
♦ ♦ ♦
其中特性 ◉ 是主干 "demo-3/master" 引入的特性。其余两个特性 ♠ 和 ♦ 是从相应的特性分支合入的。
接下来,在下一个版本的开发分支 "demo-3/next" 中运行,显示如下:
$ git checkout demo-3/next
$ make
❍ ❍ ❍ ❍
♠ ♠ ♠ ♠
♥ ♥
♦ ♦ ♦
其中特性 ❍ 是新版本主干 "demo-3/next" 引入的特性。其余三个特性 ♠、♥ 和 ♦ 是从相应的特性分支合入的。
现在要将当前版本 "demo-3/master" 合入到下一个新版本的开发分支 "demo-3/next" 中,运行如下:
$ git checkout demo-3/next
$ git merge demo-3/master
$ make
◉ ◉ ◉ ◉ ◉
❍ ❍ ❍ ❍
♠ ♠ ♠ ♠
♦ ♦ ♦
我们看到合并后,"demo-3/master" 分支的特性 ◉ 被引入了,但是 "demo-3/next" 分支原有的特性 ♥ 被删除了。如果特性 ♥ 是需要的,那么合并后为何会丢失特性 ♥ 呢?
我们按照三路合并的原理来分析一下。
首先查看下分支 "demo-3/master" 以及分支 "demo-3/next" 合并前提交(即 "demo-3/next~1")的基线:
$ git merge-base --all demo-3/master demo-3/next~1
e54a597938d7aaa20c8b3ce79d0b6c5bca8404e7
6b57153213757421f83523d1b03f7743aa654160
b2c844810a095a4e05763ffaff326101e61d444f
惊奇的发现,基线居然有三条!这实际上是三个特性分支分别向 "demo-3/master" 和 "demo-3/next" 分支合入后的结果。
我们使用命令 git log --graph --oneline demo-3/master demo-3/next~1
查看合并双方的提交。如果只显示两个分支重合的部分,如下图所示:
* 6b57153 (demo-3/topic-3) Topic 3: ♦ ♦ ♦
* f1dca57 Topic 3: ♦
/
/
|
| * b2c8448 (demo-3/topic-2) Topic 2: ♥ ♥
| * 60f846d Topic 2: ♥
|/
|
| * e54a597 (demo-3/topic-1) Topic 1: ♠ ♠ ♠ ♠
| * 3ccbd75 Topic 1: ♠ ♠
| * 18ea90f Topic 1: ♠
|/
|
|
* bfdeca2 (tag: v0) Demo for git 3-way merge
会看到有三个端点对应三条基线。
那么对于多基线场景,如何来分析呢?
Git 会将多条基线合并为一个提交,再将这个提交作为唯一的基线,参与到三路合并中。
$ git checkout -b demo-3/base e54a597
$ git merge 6b57153 b2c8448
$ make
♠ ♠ ♠ ♠
♥ ♥
♦ ♦ ♦
列出下列表格分析三路合并结果:
基线 base master 分支 next 分支 合并结果
========= ============ ========== ==========
◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉ ◉
❍ ❍ ❍ ❍ ❍ ❍ ❍ ❍
♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠ ♠
♥ ♥ ♥ ♥
♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦ ♦
从上面表格可以看出来,特性 ♥ 在基线中存在,在 "demo-3/next" 分支中也存在,但是被 "demo-3/master" 中删除了。所以导致合并结果中缺失了特性 ♥ 。
那么 "demo-3/master" 分支是如何在基线 "demo-3/base" 之后删除了特性 ♥ 的呢?
在一段提交历史中找出一个有问题的版本有很多办法,例如使用 git bisect
二分查找命令。不过这里使用 git log
命令就足够了。
使用如下命令查看这段历史中非合并提交:
$ git log --oneline --stat --no-merges demo-3/base..demo-3/master
05f2ec3 (origin/demo-3/master, demo-3/master) Topic master: ◉ ◉ ◉ ◉ ◉
topic/master.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
5daade4 Topic master: ◉ ◉ ◉ ◉
topic/master.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
6325a3f Topic master: ◉ ◉ ◉
topic/master.go | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
看到非合并提交都是在修改特性 ◉ 的,和特性 ♥ 无关。
那么问题一定出在合并提交中。不过使用下面命令看不出来合并提交包含了哪些修改,你知道为什么吗?
$ git log --oneline --stat --merges demo-3/base..demo-3/master
3a69a8a Merge branch 'demo-3/topic-3' into demo-3/master
37544d1 Merge branch 'demo-3/topic-2' into demo-3/master
d872d8f Merge branch 'demo-3/topic-1' into demo-3/master
合并提交是将两个或多个提交合并在一起,因此合并提交有两个以上的父提交。合并的结果要么选择合并的某一方的版本,要么和任何一方版本都不一样,综合了各方版本的修改。
使用 git log -p
或者 git log --stat
显示提交差异,默认是不会显示合并提交差异的。Git 提供以下三个参数改变 git log
或 git diff
的行为,显示合并提交包含的差异。分别是:
- 参数
-m
:分别显示合并提交和每一个父提交的差异。如果合并有两个父提交,则分别显示两个差异比较的结果。 - 参数
-c
:将合并提交和各个父提交的差异合并在一起显示。如果和某个父提交相同,则不显示。 - 参数
--cc
:类似-c
,进一步精简补丁的显示,忽略和某一方相同的补丁的显示。
对于本例,使用 -m
参数,执行结果如下:
$ git log --oneline -m --stat --merges demo-3/base..demo-3/master
3a69a8a (from 37544d1) Merge branch 'demo-3/topic-3' into demo-3/master
topic/3.go | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
3a69a8a (from 6b57153) Merge branch 'demo-3/topic-3' into demo-3/master
topic/1.go | 19 +++++++++++++++++++
topic/master.go | 19 +++++++++++++++++++
2 files changed, 38 insertions(+)
37544d1 (from b2c8448) Merge branch 'demo-3/topic-2' into demo-3/master
topic/1.go | 19 +++++++++++++++++++
topic/2.go | 19 -------------------
topic/master.go | 19 +++++++++++++++++++
3 files changed, 38 insertions(+), 19 deletions(-)
d872d8f (from 6325a3f) Merge branch 'demo-3/topic-1' into demo-3/master
topic/1.go | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
d872d8f (from e54a597) Merge branch 'demo-3/topic-1' into demo-3/master
topic/master.go | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
从输出中我们看到合并提交 37544d1
删除了 topic/2.go
文件。经查这个提交就是导致特性 ♥ 丢失的罪魁祸首。
$ git log -1 37544d1
commit 37544d18c2da92a265acd6a7a8145bed4ba51bd8
Merge: 5daade4 b2c8448
Author: Jiang Xin <zhiyou.jx@alibaba-inc.com>
Date: Sun Mar 15 19:53:56 2020 +0800
Merge branch 'demo-3/topic-2' into demo-3/master
* demo-3/topic-2:
Topic 2: ♥ ♥
Topic 2: ♥
Signed-off-by: Jiang Xin <zhiyou.jx@alibaba-inc.com>
如何解决合并 demo-3/master
分支丢失特性 ♥ 的问题呢?
我们需要重新执行一次和提交 37544d1
类似的合并:
$ git checkout 5daade4 --
$ git merge b2c8448
$ make
◉ ◉ ◉ ◉
♠ ♠ ♠ ♠
♥ ♥
我们看合并的结果中出现了特性 ♥。
然后再和错误的提交 37544d1
做一次合并,并使用当前提交中的内容。
$ git merge -s ours 37544d1
$ make
◉ ◉ ◉ ◉
♠ ♠ ♠ ♠
♥ ♥
记下这个合并提交。
$ git tag -m "fixed merge" fixed-merge
切换到 "demo-3/next" 分支,合并这个刚刚创建好的正确的提交。
$ git checkout demo-3/next --
$ git merge fixed-merge
$ make
◉ ◉ ◉ ◉ ◉
❍ ❍ ❍ ❍
♠ ♠ ♠ ♠
♥ ♥
♦ ♦ ♦
完美的合并结果。
建议
- 创建 pull request 代码评审时,不要在其中包含合并提交。
因为合并提交的差异很难查看和分析,不要因此增加代码评审者的负担。开发者可以使用 git rebase
命令去掉不必要的合并提交。
- 如果做到了第一点,就不会出现多基线的情况。要避免多基线。
多基线一方面增加了三路合并分析的复杂度,另外一方面导致 pull request 展示的代码差异是错的!因为绝大多数代码平台从执行效率考虑,都只会选择多条基线中的一条来和 pull request 的源分支进行比较,显示代码差异。读者可以翻到前面的示例,看看如果选择多条基线中的一条(而不是使用多条基线的合并结果)与要合并的代码进行代码比较,会是什么样的结果?
- 使用
git merge
命令完成分支合并,而不要使用目录比较工具去人为判断合并结果。
因为人工选择合并结果可能造成“脏合并”,“脏合并”像是埋在提交记录中的一枚炸弹,会在未来随时引爆。
If not now, when? if not me, who?
如果你是一个懂代码,爱Git,有技术梦想的工程师,并想要和我们一起打造世界NO.1的代码服务和云产品,请联系我们吧!C/C++/Golang/Java 我们都要 ?