如果您在代碼合并時發現 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 <[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
◉ ◉ ◉ ◉ ◉
❍ ❍ ❍ ❍
♠ ♠ ♠ ♠
♥ ♥
♦ ♦ ♦
完美的合并結果。
建議
- 建立 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 我們都要 ?