這才是真正的 Git——分支合併

騰訊技術工程發表於2020-05-28

本文作者:lzaneli,騰訊 TEG 前端開發工程師

“合併前檔案還在的,合併後就不見了”、“我遇到 Git 合併的 bug 了” 是兩句經常聽到的話,但真的是 Git 的 bug 麼?或許只是你的預期不對。本文透過講解三向合併和 Git 的合併策略,step by step 介紹 Git 是怎麼做一個合併的,讓大家對 Git 的合併結果有一個準確的預期,並且避免發生合併事故。

故事時間

在開始正文之前,先來聽一下這個故事。

如下圖,小明從節點 A 拉了一條 dev 分支出來,在節點 B 中新增了一個檔案 http.js,並且合併到 master 分支,合併節點為 E。這個時候發現會引起線上 bug,趕緊撤回這個合併,新增一個 revert 節點 E'。過了幾天小明繼續在 dev 分支上面開發新增了一個檔案 main.js,並在這個檔案中 import 了 http.js 裡面的邏輯,在 dev 分支上面一切執行正常。可當他將此時的 dev 分支合併到 master 時候卻發現,http.js 檔案不見了,導致 main.js 裡面的邏輯執行報錯了。但這次合併並沒有任何衝突。他又得重新做了一下 revert,並且迷茫的懷疑是 Git 的 bug。

這才是真正的 Git——分支合併

兩句經常聽到的話:

—— ”合併前檔案還在的,合併後就不見了“

—— ”我遇到 Git 的 bug 了“

相信很多同學或多或少在不熟悉 Git 合併策略的時候都會發生過類似上面的事情,明明在合併前檔案還在的,為什麼合併後檔案就不在了麼?一度還懷疑是 Git 的 bug。這篇文章的目的就是想跟大家講清楚 Git 是怎麼去合併分支的,以及一些底層的基礎概念,從而避免發生如故事中的問題,並對 Git 的合併結果有一個準確的預期。

如何合併兩個檔案

在看怎麼合併兩個分支之前,我們先來看一下怎麼合併兩個檔案,因為兩個檔案的合併是兩個分支合併的基礎。

大家應該都聽說過“三向合併”這個詞,不知道大家有沒有思考過為什麼兩個檔案的合併需要三向合併,只有二向是否可以自動完成合並。如下圖

這才是真正的 Git——分支合併

很明顯答案是不能,如上圖的例子,Git 沒法確定這一行程式碼是我修改的,還是對方修改的,或者之前就沒有這行程式碼,是我們倆同時新增的。此時 Git 沒辦法幫我們做自動合併。

所以我們需要三向合併,所謂三向合併,就是找到兩個檔案的一個合併 base,如下圖,這樣子 Git 就可以很清楚的知道說,對方修改了這一行程式碼,而我們沒有修改,自動幫我們合併這兩個檔案為 Print("hello")。

這才是真正的 Git——分支合併

接下來我們瞭解一下什麼是衝突?衝突簡單的來說就是三向合併中的三方都互不相同,即參考合併 base,我們的分支和別人的分支都對同個地方做了修改。

這才是真正的 Git——分支合併

Git 的合併策略

這才是真正的 Git——分支合併

瞭解完怎麼合併兩個檔案之後,我們來看一個使用 git merge 來做分支合併。如上圖,將 master 分支合併到 feature 分支上,會新增一個 commit 節點來記錄這次合併。

Git 會有很多合併策略,其中常見的是 Fast-forward、Recursive 、Ours、Theirs、Octopus。下面分別介紹不同合併策略的原理以及應用場景。預設 Git 會幫你自動挑選合適的合併策略,如果你需要強制指定,使用git merge -s <策略名字>

瞭解 Git 合併策略的原理可以讓你對 Git 的合併結果有一個準確的預期。

Fast-forward

這才是真正的 Git——分支合併

Fast-forward 是最簡單的一種合併策略,如上圖中將 some feature 分支合併進 master 分支,Git 只需要將 master 分支的指向移動到最後一個 commit 節點上。

這才是真正的 Git——分支合併

Fast-forward 是 Git 在合併兩個沒有分叉的分支時的預設行為,如果不想要這種表現,想明確記錄下每次的合併,可以使用git merge --no-ff。

Recursive

Recursive 是 Git 分支合併策略中最重要也是最常用的策略,是 Git 在合併兩個有分叉的分支時的預設行為。其演算法可以簡單描述為:遞迴尋找路徑最短的唯一共同祖先節點,然後以其為 base 節點進行遞迴三向合併。說起來有點繞,下面透過例子來解釋。

如下圖這種簡單的情況,圓圈裡面的英文字母為當前 commit 的檔案內容,當我們要合併中間兩個節點的時候,找到他們的共同祖先節點(左邊第一個),接著進行三向合併得到結果為 B。(因為合併的 base 是“A”,下圖靠下的分支沒有修改內容仍為“A”,下圖靠上的分支修改成了“B”,所以合併結果為“B”)。

這才是真正的 Git——分支合併

但現實情況總是複雜得多,會出現歷史記錄鏈互相交叉等情況,如下圖:

這才是真正的 Git——分支合併

當 Git 在尋找路徑最短的共同祖先節點的時候,可以找到兩個節點的,如果 Git 選用下圖這一個節點,那麼 Git 將無法自動的合併。因為根據三向合併,這裡是是有衝突的,需要手動解決。(base 為“A“,合併的兩個分支內容為”C“和”B“)

這才是真正的 Git——分支合併

而如果 Git 選用的是下圖這個節點作為合併的 base 時,根據三向合併,Git 就可以直接自動合併得出結果“C”。(base 為“B“,合併的兩個分支內容為”C“和”B“)

這才是真正的 Git——分支合併

作為人類,在這個例子裡面我們很自然的就可以看出來合併的結果應該是“C”(如下圖,節點 4、5 都已經是“B”了,節點 6 修改成“C”,所以合併的預期為“C”)

這才是真正的 Git——分支合併

那怎麼保證 Git 能夠找到正確的合併 base 節點,儘可能的減少衝突呢?答案就是,Git 在尋找路徑最短的共同祖先節點時,如果滿足條件的祖先節點不唯一,那麼 Git 會繼續遞迴往下尋找直至唯一。還是以剛剛這個例子圖解。

如下圖所示,我們想要合併節點 5 和節點 6,Git 找到路徑最短的祖先節點 2 和 3。

這才是真正的 Git——分支合併

因為共同祖先節點不唯一,所以 Git 遞迴以節點 2 和節點 3 為我們要合併的節點,尋找他們的路徑最短的共同祖先,找到唯一的節點 1。

這才是真正的 Git——分支合併

接著 Git 以節點 1 為 base,對節點 2 和節點 3 做三向合併,得到一個臨時節點,根據三向合併的結果,這個節點的內容為“B”。

這才是真正的 Git——分支合併

再以這個臨時節點為 base,對節點 5 和節點 6 做三向合併,得到合併節點 7,根據三向合併的結果,節點 7 的內容為“C”

這才是真正的 Git——分支合併

至此 Git 完成遞迴合併,自動合併節點 5 和節點 6,結果為“C”,沒有衝突。

這才是真正的 Git——分支合併

Recursive 策略已經被大量的場景證明它是一個儘量減少衝突的合併策略,我們可以看到有趣的一點是,對於兩個合併分支的中間節點(如上圖節點 4,5),只參與了 base 的計算,而最終真正被三向合併拿來做合併的節點,只包括末端以及 base 節點。

需要注意 Git 只是使用這些策略儘量的去幫你減少衝突,如果衝突不可避免,那 Git 就會提示衝突,需要手工解決。(也就是真正意義上的衝突)。

Ours & Theirs

Ours 和 Theirs 這兩種合併策略也是比較簡單的,簡單來說就是保留雙方的歷史記錄,但完全忽略掉這一方的檔案變更。如下圖在 master 分支裡面執行git merge -s ours dev,會產生藍色的這一個合併節點,其內容跟其上一個節點(master 分支方向上的)完全一樣,即 master 分支合併前後專案檔案沒有任何變動。

這才是真正的 Git——分支合併

而如果使用 theirs 則完全相反,完全拋棄掉當前分支的檔案內容,直接採用對方分支的檔案內容。

這兩種策略的一個使用場景是比如現在要實現同一功能,你同時嘗試了兩個方案,分別在分支是 dev1 和 dev2 上,最後經過測試你選用了 dev2 這個方案。但你不想丟棄 dev1 的這樣一個嘗試,希望把它合入主幹方便後期檢視,這個時候你就可以在 dev2 分支中執行git merge -s ours dev1。

Octopus

這種合併策略比較神奇,一般來說我們的合併節點都只有兩個 parent(即合併兩條分支),而這種合併策略可以做兩個以上分支的合併,這也是 git merge 兩個以上分支時的預設行為。比如在 dev1 分支上執行git merge dev2 dev3。

這才是真正的 Git——分支合併

他的一個使用場景是在測試環境或預釋出環境,你需要將多個開發分支修改的內容合併在一起,如果不用這個策略,你每次只能合併一個分支,這樣就會導致大量的合併節點產生。而使用 Octopus 這種合併策略就可以用一個合併節點將他們全部合併進來。

Git rebase

git rebase 也是一種經常被用來做合併的方法,其與 git merge 的最大區別是,他會更改變更歷史對應的 commit 節點。

如下圖,當在 feature 分支中執行 rebase master 時,Git 會以 master 分支對應的 commit 節點為起點,新增兩個全新的 commit 代替 feature 分支中的 commit 節點。其原因是新的 commit 指向的 parent 變了,所以對應的 SHA1 值也會改變,所以沒辦法複用原 feature 分支中的 commit。(這句話的理解需要這篇文章的基礎知識)

這才是真正的 Git——分支合併

對於合併時候要使用 git merge 還是 git rebase 的爭論,我個人的看法是沒有銀彈,根據團隊和專案習慣選擇就可以。git rebase 可以給我們帶來清晰的歷史記錄,git merge 可以保留真實的提交時間等資訊,並且不容易出問題,處理衝突也比較方便。唯一有一點需要注意的是,不要對已經處於遠端的多人共用分支做 rebase 操作。

我個人的一個習慣是:對於本地的分支或者確定只有一個人使用的遠端分支用 rebase,其餘情況用 merge。

rebase 還有一個非常好用的東西叫 interactive 模式,使用方法是git rebase -i。可以實現壓縮幾個 commit,修改 commit 資訊,拋棄某個 commit 等功能。比如說我要壓縮下圖 260a12a5、956e1d18,將他們與 9dae0027 合併為一個 commit,我只需將 260a12a5、956e1d18 前面的 pick 改成“s”,然後儲存就可以了。

這才是真正的 Git——分支合併

限於篇幅,git rebase -i 還有很多實用的功能暫不展開,感興趣的同學可以自己研究一下。

總結

這才是真正的 Git——分支合併

現在我們再來看一下文章開頭的例子,我們就可以理解為什麼最後一次 merge 會導致 http.js 檔案不見了。根據 Git 的合併策略,在合併兩個有分叉的分支(上圖中的 D、E‘)時,Git 預設會選擇 Recursive 策略。找到 D 和 E’的最短路徑共同祖先節點 B,以 B 為 base,對 D,E‘做三向合併。B 中有 http.js,D 中有 http.js 和 main.js,E’中什麼都沒有。根據三向合併,B、D 中都有 http.js 且沒有變更,E‘刪除了 http.js,所以合併結果就是沒有 http.js,沒有衝突,所以 http.js 檔案不見了。

這個例子理解原理之後解決方法有很多,這裡簡單帶過兩個方法:1. revert 節點 E'之後,此時的 dev 分支要拋棄刪除掉,重新從 E'節點拉出分支繼續工作,而不是在原 dev 分支上繼續開發節點 D;2. 在節點 D 合併回 E’節點時,先 revert 一下 E‘節點生成 E’‘(即 revert 的 revert),再將節點 D 合併進來。

Git 有很多種分支合併策略,本文介紹了 Fast-forward、Recursive、Ours/Theirs、Octopus 合併策略以及三向合併。掌握這些合併策略以及他們的使用場景可以讓你避免發生一些合併問題,並對合並結果有一個準確的預期。

希望這篇文章對大家有用,感興趣的同學可以逛一逛我的部落格www.lzane.com 或看看我的其他文章。

參考

  • 三向合併 http://blog.plasticscm.com/2016/02/three-way-merging-look-under-hood.html
  • Recursive 合併【影片】https://www.youtube.com/watch?v=Lg1igaCtAck
  • 書籍 Scott Chacon, Ben Straub - Pro Git-Apress (2014)
  • 書籍 Jon Loeliger, Matthew McCullough - Version Control with Git, 2nd Edition - O’Reilly Media (2012)


相關文章