git 分支運作模式之我見
tag: merge / patch / 上游 / 下游 / 順流 / 逆流
特性: merge strategy
git 的 merge 有多種不同的 strategy 模式,可透過
git merge [-s ] [-X ]
比較常用的有
git merge -s ours # 以我方 branch 為主
git merge -s recursive -X ours # 如遇衝突以我方 branch 為主
git merge -s recursive -X theirs # 如遇衝突以對方 branch 為主
其中, 最重要的是預設模式為
git merge -s recursive
特性: merge base
git 在作 merge 時,除了參考兩方 branch 的 HEAD ,還會參考兩個 branch 的共同 ancestor 。
以上圖為例,R 是 A,B 分支共同的 ancestor ,也是 A3, B2 的 merge base。
要如何找出 merge base 呢? 除了手算外,也可以下指令找出 merge base:
git merge-base [branch A] [branch B]
特性: git 下不會影響 merge base 的 merge 方式
git 還有其他不會影響 merge base 的 merge 方式,如下:
git cherry-pick
git merge --squash
- 以 patches 形式匯入
特性: merge 的累積性
當我們持續跟隨 merge 某個分支時,會發現新一次的 merge 會參考前一次 merge 的資訊,而只處理前一次 merge 完之後的新變更。 也就是說,之前已經處理的 merge conflict ,不會再重複出現,也不需要重複處理。 這個聰明特性讓 git merge 變得很好用。其示意圖大致如下:
上圖中,黃色記號的點,是兩個 branch 在 merge 時,所納入計算的 merge base。 我們可以注意到,merge base 會依新的 merge 成果,往前累積紀錄點。
另一個值得注意的地方是,雖然是 B 分支 merge A 分支,但 merge base 是在 A 分支上,而上圖的 merge 過程,merge base 都是保持在 A 分支上。
特性: 交叉 merge 的變化
當 git merge 的方向改變時, merge base 也會隨之變化。示例圖如下:
上圖中,黃色記號所註記的 merge base 點,隨著 B merge A 切換成 A merge B 時,merge base 也跟著從 A 分支切換到 B 分支上。 而這個變化,正是藏在細節裡的魔鬼,因為這個切換,使先前累積一致的紀錄點中斷了。 在中斷之後,再一次回到 B merge A 時,會發現先前已經處理過的舊 conflict 又再一次重複出現。
也就是說,交叉 merge 會破壞掉原先 merge 的累積紀錄,不管是 A 分支或是 B 分支,都會失去 merge 的累積性。
在交叉 merge 的特性下,只會有兩個結果,一個是兩分支漸趨一致,最後合而為一,或是保持獨立分支,但一直不斷的處理先前舊的 merge conflict。
特性: merge base 俱有傳遞性
舉例來說,當 B merge A ,而 C 又 merge B 時,C 的 merge base 會分別紀錄在 A, B 分支上。 接著,當 A merge C 時,merge base 切換紀錄在 C 分支上。 則當下一次 C 再次 merge B 之時,會發現之前累積在 A, B 分支上的 merge base 紀錄中斷了,而切換到 C 分支上。
觀察: 順流發佈與逆流整合
在分支的使用過程中,我觀察到兩種類型的使用情境。
其一,順流發佈。
以 Linux Kernel 為例,主要的版本會在最上游的 kernel mainline 發佈,然後各個 Linux distribution 主分支再 merge mainline 新發佈的程式碼。而再更下游的 distribution 分支,會再 merge 上述的主分支的程式。
這個過程主要是從功能開發的上游往下游遞延,跟產品製造從生產、經銷、商店的發佈流程類似。
這個模式在 git branch 模式中,很容易實現,只要以 fork 的方式從上游衍生下游分支,並持續單向 merge 即可。
其二,逆流整合
商品出現問題時,回饋的方向則跟上述的流程是相反的。舉例來說,消費者可能先反應給商店,商店反應給經銷,經銷再回饋給製造的上游廠商,或是消費者也有管道可直接反應給經銷商、製造商。不論經過幾層,這個方向跟順流發佈是相反的。
程式也是一樣,可能有 bug 或是新功能,是散佈在下游的分支們之間。為了讓這些成果能夠發佈到更多地方使用,我們會嘗試將這些成果整合到上游的分支,以利下一波發佈時能一併分佈到其他更多下游分支。
一般來說,我們在 git 下實行的方式,大概是從主要版本中成立一個獨立開發分支,然後用這個分支去一一 merge 那些想要整合的下游分支。
順流發佈與逆流整合的衝突
我在嘗試建立分支之間的發佈與整合流程時,遭遇了大挫敗。 情況大概是,我在處理完近幾個 merge conflict ,建立順流發佈的分支架構後,卻在一次逆流整合的小小 merge 之後,先前已經處理過的舊 conflict 又重新冒了出來。
為什麼?
這問題我卡了很久,後來研究發現,其實原因就是上述提及的兩個:
- 交叉 merge 的變化
- merge base 俱有傳遞性
也就是說,我在順流發佈與逆流整合的分支流程中,構成了迴路,使得整個 merge base 的累積性紀錄被破壞了。
重構 git 分支結構的兩個原則
要解決上述的問題,唯一的方法就是避免產生 merge base 的迴路。根據上述的種種特性與觀察,我想出了兩個原則來克服這個問題:
- 在 git 分支結構中,區別出上游與下游的順序關係
- 逆流方向必須使用不影響 merge base 的 merge 方式(ex: patches/cherry-pick/merge –squash/…)
重新審視 git workflow
以最常被引用的 nvie 的經典文章 A successful Git branching model 為例:
從上圖的開發流程中,大略可分類為 develop + feature branches => release branches => master + hotfixes,可劃分為 upstream , midstream, downstream。 其中,標上星號的是我認為是逆流方向的 merge的部份。
以 tag=1.0 這個節點為例,在 merge 產生 tag=1.0 時,參與 merge 計算的有,tag=0.2 節點跟綠色的 release branch 節點,還有作為 merge base 參考點的 hotfixed 紅色節點。 但如果 hotfixes 往 develop 分支的 merge 是斷開的話,那麼 merge base 參考點,就會是 tag=0.1 節點了。 也就是說圖中的 hotfixes 分支往 develop 分支的 merge ,破壞了原先累積性的 workflow 設計。 雖然此示例圖比較簡單,看起來影響不是很明顯,但在更複雜的案例中,很可能就因某次的逆流 merge 而導致整體 workflow 衝突中斷。
這也是為什麼,這麼多人依這一篇文章建立 git workflow 運作一段時間之後,卻遇到一些難纏的 merge conflict 的問題的原因了。 會出現奇怪的 conflict 其實不是你的錯,而這個 git workflow 本身就漏了這個細節中的魔鬼。
延伸思考
仔細觀察 Linux Kernel 的 workflow,你會發現 upstream 沒有在收 Pull Request 的,而是以 patch / squash merge 的方式在整合的。
為什麼 upstream 不收 Pull Request 呢? 又 Pull Request 適用在那些 workflow ,又不適合那些 workflow呢? 這些問題,從上述提出的觀點下,都有了解釋與答案了。