1. 程式人生 > >Git: merge vs rebase

Git: merge vs rebase

Merge 還是 Rebase,這是一個問題。網上有許多教程說明二者的區別,之前我寫的一個 關於 Git 的 PPT 裡也說過兩者的區別。這篇文章裡,我們從分支圖的角度,看看兩種策略下產生的分支圖有什麼區別。

#理想的分支圖

這裡我們要說明的是, Git 是用來解決多人協作的程式碼管理,儘管也可以“單機”使用,但它的一些優勢或是缺點要在多人使用時才會顯露出來。

現在假設我們獨自開發一個產品,一個個往上加功能,那麼最終的 git 分支圖會像這樣:

M─┐ Merge branch 'feature-2'│ o [feature-2] commit #3│ o [feature-2] commit #2
│ o [feature-2] commit #1M─┤ Merge branch 'feature-1'│ o [feature-1] commit #4│ o [feature-1] commit #3│ o [feature-1] commit #2│ o [feature-1] commit #1I─┘ Initial commit

這裡採用的是 tig 的分支圖符號。可以看到的是每個功能都用了幾個 commit,開發後合併到 master 分支中,再基於最新的程式碼繼續開發下一個功能。清晰明白。

但如果多個人一起開發,或者有多個並行開發的功能,那麼事情就開始變複雜了。

#Merge

現在我們考慮多人開發多個 feature,那麼最幸運的情況是像這樣的:

M─────┐ Merge branch 'feature-2'│     o [Feature-2] commit 2│     o [Feature-2] commit 1M───┐ │ Merge branch 'feature-2'│   o │ [Feature-2] commit 2│   o │ [Feature-2] commit 1M─┐ │ │ Merge branch 'feature-1'│ o │ │ [Feature-1] commit 2│ o │ │ [Feature-1] commit 1o─┴─┴─┘ base commit for all features

可以看到有多列,代表不同的 feature 分支,它們最終都合到 master 裡。這就是採用 merge 策略最常見的分支結構。一般同時開發的分支越多,列數越多。

上面這個影象是理想的並行版本,它要求所有 feature 基於同一個 commit,且每個 commit 的時間是線性的,所以現實中基本不可能滿足這種情況。

如果每個 commit 的時間不同,分支的線就會開始交叉:

M─┐     Merge feature-1M─│───┐ Merge feature-3│ o   │ [1] Commit 2│ │   o [3] Commit 2M─│─┐ │ Merge feature-2│ │ │ o [3] commit 1│ │ o │ [2] commit 2│ │ o │ [2] commit 1│ o │ │ [1] commit 1o─┴─┴─┘ base commit for all features

上面這個圖追蹤起來就比較麻煩了,如果考慮到 commit 數量多的話,情況就更糟糕了。我們這裡所有子分支是從同一個 base commit 開始的,如果各個分支的起始 commit 不同,分支圖就會變得特別亂了。

M─┐     merge feature-1│ o     [1] commit 2M─│───┐ merge feature-3│ │   o [3] commit 2M─│─┐ │ merge feature-2│ │ o │ [2] commit 2│ │ │ o [3] commit 1│ │ o │ [2] commit 1o─│─│─┘ base commit 2│ o │   [1] commit 1o─┴─┘   base commit 1

上面的救命圖可能還相對容易看懂,這是因此 commit 數量少,分支數也少,另外各個 feature 分支上都沒有 merge commit,否則會更復雜。

綜上,在分支圖上,merge 會導致分支圖的列增多,且依據分支的初始 commit 不同及 commit 的時間不同,會使分支圖有更多的交叉,導致歷史難以追蹤。

#rebase

其實大家使用 rebase 的一個重要特點是 rebase 能產生線性的分支歷史。考慮這樣一個分支圖:

(master)│ (feature-1)o │ c│ o b│ o ao─┘

如果我們此時在 master 分支執行 git merge feature-1,則和之前 merge 一樣,結果會變成:

M─┐o │ c│ o b│ o ao─┘

但如果我們在 feature-1 上執行 git rebase master,則會產生下面的圖形。注意的是 rebase 是會產生新的 commit 的,a 變成了 a',如果用 git show 看其中的內容,可以發現雖然 commit message 相同,但 diff 已經是不同了。

(master)│ (feature-1)│ o b'│ o a'o─┘ co

可以看到,分支 feature-1 的初始 commit 變成了 c。這時候取決於 merge 的方式,會有不同的效果。一是在 master 上執行 git merge feature-1,這時 git 會判斷可以 fast-forward;二是通過 gitlab 或 github 等提交 Merge/Pull request,它們依舊會建立一個 Merge commit,如下:

local merge                 gitlab/github(master, feature-1)         (master)│                           M─┐ mo b'                        │ o b'o a'                        │ o a'o c                         o─┘ co                           o

但注意到即使 gitlab 會建立新的 merge commit m,在 master 的 cm 之間也不會有任何“插隊”的其它 commit。突出一個清晰明瞭。

這時考慮多個 feature 同時開發,大家在合併前都先 rebase 最新的程式碼,就能做成“線性”的圖形:

(master)           (master)           (master)              (master)│ (feature-1)      │   (feature-2)    │ (feature-2)         ││ │ (feature-2)    │   │              │ │                   M─┐ merge feature 2│ │ │              │   o              │ o                   │ o│ │ o   ====>      │   o    ====>     │ o        ====>      │ o│ │ o              M─┐ │              M─┤                   M─┤ merge feature 1│ o │              │ o │              │ o                   │ o│ o │              │ o │              │ o                   │ oo─┴─┘              o─┴─┘              o─┘                   o─┘   (merge feature-1)   (rebase master)   (merge feature-2)

但在實際的團隊開發中,要達到上面的要求需要“序列”提交程式碼,即上一個人的程式碼合併之後,下一個人再 rebase 最新程式碼並提交新的 Merge/Pull request。這是不現實的。經常的情況是所有人都在 deadline 臨近時一起提交,是一個“並行”提交的過程。並且現在大家一般在 merge 前都會有一些 CI 的檢查,如果序列,這些檢查也得序列地執行,太耗時間了。也因此, rebase 比較合適使用在“內部”分支上。例如一個 feature 有多個 task,那麼 task 分支合併到 feature 分支時,使用 rebase 比較合適。

一些情形下,我們會發現有一些 commit 出現了多次,假設現在我們開發一個 feature,包含兩個子任務,這兩個子任務是在 feature 開發了一定時間後開始的,於是出現這樣的分支圖:

(master)│ (feature)│ │ (task-1)│ │ │ (task-2)o │ │ │ c│ │ │ o t2│ │ o │ t1│ o─┴─┘ b│ o     ao─┘     base commit

現在,兩個 task 前後完成了開發,於是向 master 發起了 Merge/Pull request。在發之前,先 rebase 了 master,於是產生了如下的分支圖:

(master)│ (feature)│ │ (task-1)│ │ │ (task-2)│ │ │ ││ │ │ o t2│ │ │ o b"│ │ │ o a"│ │ o │ t1│ │ o │ b'│ │ o │ a'│ o │ │ b│ o │ │ ao─┴─┴─┘ co       base commit

可以看到,rebase 過後,task-1 task-2 分別生成了自己對應的 commit a b 的複本。那麼當 task-1 task-2 獨立被合併到 master 時,這些複本都會被保留:

(master)M───┐│   o t2│   o b"│   o a"M─┐ ││ o │ t1│ o │ b'│ o │ a'o─┴─┘ co     base commit

所以,此時如果在 master 分支上用 git log 檢視歷史,會看到有兩個 a(a' a") 兩個 b(b' b")。這是用 rebase 容易產生的問題之一,其它需要注意的這裡就不深入了。

綜上,從分支圖的角度上,使用 rebase 能使分支圖更“直”,但如果使用不當,也容易出現一個 commit 被提交了多次的情況。

#一些建議

結合上面我們看到的情況,管理分支時,我的建議是“從哪來,回哪去”。

例如一個 task 分支是從 feature 分支出來的,那麼最好合並回 feature 分支,而不要直接合併到其它的分支(如 master)。這樣能防止 commit 被提交多份。在“回哪去”的過程中,儘量使用 rebase,最大程度保證分支圖的“線性”結構。

當然最重要的還是明白背後的原理,這樣才能靈活使用。