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 |
這裡採用的是 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 的 c
與 m
之間也不會有任何“插隊”的其它 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
,最大程度保證分支圖的“線性”結構。
當然最重要的還是明白背後的原理,這樣才能靈活使用。