git-repo

Git-Merge 的那点事儿

如果您在代码合并时发现 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” 特性完整地合入呢?采用如下操作:

  1. 对提交 e50fc0b 再次执行一次撤回操作。

     $ git checkout demo-2/master --
     $ git revert e50fc0b
    
  2. 然后再合并分支 “demo-2/topic-1”。

     $ git merge demo-2/topic-1
    
  3. 执行程序,查看合并效果。

     $ 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 loggit 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

  ◉ ◉ ◉ ◉ ◉ 
  ❍ ❍ ❍ ❍ 
  ♠ ♠ ♠ ♠ 
  ♥ ♥ 
  ♦ ♦ ♦ 

完美的合并结果。

建议

  1. 创建 pull request 代码评审时,不要在其中包含合并提交。

    因为合并提交的差异很难查看和分析,不要因此增加代码评审者的负担。开发者可以使用 git rebase 命令去掉不必要的合并提交。

  2. 如果做到了第一点,就不会出现多基线的情况。要避免多基线。

    多基线一方面增加了三路合并分析的复杂度,另外一方面导致 pull request 展示的代码差异是错的!因为绝大多数代码平台从执行效率考虑,都只会选择多条基线中的一条来和 pull request 的源分支进行比较,显示代码差异。读者可以翻到前面的示例,看看如果选择多条基线中的一条(而不是使用多条基线的合并结果)与要合并的代码进行代码比较,会是什么样的结果?

  3. 使用 git merge 命令完成分支合并,而不要使用目录比较工具去人为判断合并结果。

    因为人工选择合并结果可能造成“脏合并”,“脏合并”像是埋在提交记录中的一枚炸弹,会在未来随时引爆。

If not now, when? if not me, who?

如果你是一个懂代码,爱Git,有技术梦想的工程师,并想要和我们一起打造世界NO.1的代码服务和云产品,请联系我们吧!C/C++/Golang/Java 我们都要 💖

简历投递邮箱: