天天看點

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 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 <[email protected]>
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 <[email protected]>
           

如何解決合并

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

指令去掉不必要的合并送出。

  1. 如果做到了第一點,就不會出現多基線的情況。要避免多基線。

多基線一方面增加了三路合并分析的複雜度,另外一方面導緻 pull request 展示的代碼差異是錯的!因為絕大多數代碼平台從執行效率考慮,都隻會選擇多條基線中的一條來和 pull request 的源分支進行比較,顯示代碼差異。讀者可以翻到前面的示例,看看如果選擇多條基線中的一條(而不是使用多條基線的合并結果)與要合并的代碼進行代碼比較,會是什麼樣的結果?

  1. git merge

    指令完成分支合并,而不要使用目錄比較工具去人為判斷合并結果。

因為人工選擇合并結果可能造成“髒合并”,“髒合并”像是埋在送出記錄中的一枚炸彈,會在未來随時引爆。

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

如果你是一個懂代碼,愛Git,有技術夢想的工程師,并想要和我們一起打造世界NO.1的代碼服務和雲産品,請聯系我們吧!C/C++/Golang/Java 我們都要 ?