devops Git-Merge 的那点事

Yvonne · 2020年03月16日 · 1483 次阅读

程序员在软件开发过程中会经常会遇到代码合并的场景,一旦发现 git 合并的结果不符合预期,你会怀疑是 git 出现了 bug 么?我希望你把这个选项放在最后,首先要做的是按照本文的思路,查找引发合并异常的提交。

理解三路合并
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 我们都要 💖

简历投递邮箱:

zhiyou.jx@alibaba-inc.com
worldhello.net@gmail.com
AGit-Flow 阿里巴巴集中式 Git 工作流

了解云效更多信息,可以加入群號 : 23362009
原创作者:蒋鑫

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册