從零星的 copy fork 演進為 upstream 持續更新模式

繼上文 “git 分支運作模式之我見” https://6bcf7279.info/2015/07/Ok29jGJM/ 的論述後,下文繼續深入軟體開發面臨的問題與解決模式。

一個軟體有很多客戶使用,客戶A要客製化功能p+x, 客戶B要客製化功能q+y, 客戶C要客戶r+x+y, …。因應不同時期不同的客戶需求,同一套軟體可能因此有多個 fork branches。每個 branch 主體大致上是相同的,但可能約有 5~10% 左右是依客戶需求而特別訂製的。這樣的需求趨動下,常常會伴隨幾個特性狀況如下:

特性: copy fork
假設今天多一個客戶想要客製功能 r+y ,那麼可能就會找跟這個需求最接近的客戶C ( r+x+y ) 的程式,複製一份出來用,再去掉 x 功能。這樣子作最快最方便。

特性: 版本交錯
承 copy fork 的特性,因為是取最接近的而不是最新的,所以常常會有新舊版本交錯的問題。很多時候,新版本還有開發中,不甚穩定,所以此時進來的客戶需求,常常改以較穩定較有把握的舊版本來應付,再額外套上一些新開發的功能。此時,產生的分支常常是新舊版本交錯的狀況。

特性: bug重覆出現
承 copy fork 的特性,當複製了某一個功能時,同時也將該功能的 bug 也複製了。今天很可能客戶 A 的功能 x 出問題了,於是開發人員花了點時間將 A 的 x 功能給修正了。但基於種種原因,並未修正到 C 客戶的 x 功能。結果,過一段時間 C 客戶的 x 功能也出問題了,導致開發人員又花了重複的時間去修正 C 客戶的 x 功能。重複的問題,不但重複耗損開發人員的時間,同時也減損了整體的自信心。

特性: 修正更新狀態不一致
承 bug重覆出現 的特性,因為採取遇一個 bug 修一個的策略,導致多個分支之間的版本跟修正程式碼是不一致的。使得程式裡的特例越來越多,比如說,修正客戶 A 的 x 功能時,是以 A 的使用方式考量下設計。而修正客戶 C 的 x 功能時,則依 C 的情境作調整。那麼同樣的 x 功能,就又產生了 2 個分支。

形成狀態
根據上述種種特性,最終整個專案就難以避免的進入,bug散佈交錯,修正更新狀態紊亂不一致,程式碼交錯充斥 workaround,整體的開發複雜度持續增加,開發人員窮於應付不斷冒出的 bug 跟地雷,對於軟體品質的提升只是個偶然振作,而重複的問題一再複發卻束手無策。

落入這個狀況時,很多人第一直覺的反應常常是,”不夠努力”,或是覺得”程式寫得不夠好”,所以解決的方向常常是”要求要更努力地將程式寫得更好”,而這樣的想法就催生了 “重新開發新版 2.0” 的策略。然後,這樣的策略常常是失敗的。

重新開發新版 2.0 陷入的困境
因為在舊版中,長期累積的 bug & workaround hell,常是開發人員的夢魘。因此,另外成立一個新版本,並宣誓立即擺脫 bug & workaround 陰影,往往讓開發人員立刻感受到解放而有希望的感覺。然而,隨時間演進,卻常常陷入另一種困境,如下。

1. 重新開發的版本,往往不夠成熟,而沒辦法穩定上線。

想想,舊版本在過去不斷的測試跟修正,並調整成使用者能接受的程式,中間花了那麼長一段時間。新版本想要在那麼短的時間內,完成這個過程,談何容易。

2. 因為無法上線,而無法產生收益。

在沒有收益下,開發新版本的這條路,是一條以燒錢來爭取未來轉機的道路。相較於開發新版本的燒錢,改回舊版本提供給客戶,很快就能取得收益,而且時間越長,兩者相差的機會成本越大。在這樣的情境下,開發新版本受到的支持度越來越低,而回守舊版本的支持度越來越高。

3. 回守舊版本

此消彼長之下,最後仍決定回守舊版本。專案全體人員士氣大受打擊,因為努力振作一段時間,但最後一無所獲。更糟的是,開發人員開始意識到,在各項環境條件沒有大改變的情況下,永遠沒有脫離困境的一天。

另一項思路
有兩篇文章給我很大的啟發
* 約耳趣談軟體的 “CH24|你絕對不應該做的事之一”
* 約耳續談軟體的 “CH30|洗刷刷、洗刷刷”

根據上述的問題種種,我想提出另一個想法。

並不是我們不夠努力,也不是我們程式寫得不夠好,而是,我們的方法錯了。

陷入困境的關鍵因子
讓我們陷入 bug & workaround hell 的那個因子,其實是來自於 copy fork 那瞬間所產生的”無法一致更新的分支複本”。

也就是說,分支複本隨時間越來越多,版本跟不一致的狀況越來越嚴重,於是不斷增長的複雜度跟層出不窮的狀況,必然最後就壓垮耗盡了開發人員的時間跟腦力。

只要是 “無法一致更新的分支複本” 的問題存在,即使上述所採行的 “2.0新版本” 開發成功,還是很有可能在未來落入相同的困境。

所以,解決方向應該是針對 “無法一致更新的分支複本” 的這個環節來解決。

這個解決方法是存在並真實可行的,目前我得出的解決方法過程如下:

建立更新共同體

step1. 首先,先要考察所有分支的分支祖譜關係。

step2. 選定其中一個開發能量最集中,且跟各分支相容性佳的那個分支作為標竿,訂為 upstream

step3. 讓所有分支和 upstream 建立第一個 merge 關係。
注意, 是用 merge -s ours 來建立第一個 merge 關係,這一點非常重要。
因為,目標是建立連動關係,而不是一開始就要達成一致。

這三個步驟並不需要什麼大決心、大力氣。但已足夠讓所有分支俱備連動關係了。接下來的,就是一步一步的執行小改善的動作如下:

step4-1. 遇到一個 bug , 先假設這個 bug 是 email 寄送程式相關的問題好了。

step4-2. 研究出這個 bug 的根本原因,還有 dependency 狀況 ( 包括函式庫、影響到的程式有那些、那些分支有相同狀況 )

step4-3. 根據上述研究內容,製作一個可徹底解決問題、且能共通於其他分支的修正程式 patch

step4-4. 將這個 patch 提交進 upstream 分支

step4-5. 讓所有分支從 upstream 分支拉新的 merge update
在各分支拉 upstream 的 update merge 時,有的可能會順利 merge 成功,有的可能會 merge conflict。但預期的難度會在可控制的範圍內。主要原因是:
a) 因為一開始已經建立了 merge 關係,所以此次 merge 會從前一次的 merge base 參考點出發,也就是說,衝突的部份只會在這個 patch 的修改範圍內,而不會是一大群 conflict 列表
b) 一般來說, patch 相容性作的好的話,衝突的比例可降低。例如,20個分支,只有3個分支有嚴重衝突(即 15%),此時再針對這3個分支作針對性處理即可。

step4-6. 完成此次連動更新的修正。
在完成上述的動作後,預期所有分支的這個 bug ,並進行了一次”連動一致性”的修正。這個過程可能比往常多花了一些力氣跟時間(ex: 1.5倍或是 2~3倍),但是俱備下述性質。

a) 雖然多花了點成本,但因修改範圍相對較小,所以可控制在”覺得多作點工,但在可以平常心承受”的範圍內
b) 單次修正的成本雖然較高,但因為修正的較完整而徹底,這個 bug 可能就一次解決後就永遠的被修正而不再複發了
c) 修正的效果,重複了 N 個分支。其 impace 是 N 倍放大。
d) 因為是連動式的全體更新,所以在修正的範圍內,消減了一次複本問題下的複雜度。

繼續重複回到 step4-1.

在上述的流程下,我們進行一回合又一回合的清洗,每次清洗一點,進步一點。慢慢的,就會發現整個專案程式從雜亂無章的複本分支中,漸漸形成出跨越分支的一致性。

標準化、參數設定化、模組化

在上述建立更新共同體之後,已能抑制複雜度的增加,並逐漸收斂出一致性。不過收斂的速度有快有慢,收斂效率的關鍵在於軟體程式本身。

方法之一,參數設定化:

舉例來說,如果一些功能設定是寫死在程式裡面,像是 API Key/URL/… 的值之類的,那麼每次設定值一變更,程式就要修改,改來改去永遠無法固定下來。其實,解決方法很簡單,只要程式增加讀取設定檔的功能 ( ex: Preference/Setting/Configuration/… 功能 ),那麼,設定有變化時,只要改設定檔就好,程式都不用再改。簡單來說,就是將會變動的設定跟參數改由外部讀入,不但程式不需要再頻繁更改,也增加了程式的彈性。

方法之二,標準化

不同分支之間雖然各有差異,但仍有許多共同函式庫跟共用程式。然後,這些共用部份,可能因為函式庫的版本差異或是共用程式的不同小修改,而產生不同的邊際效應,而且這些共用程式常常廣泛的散佈在程式之間。若能將這部份的差異去除掉,那麼就能讓分支間的複雜度快速收斂下來。具體的作法,就是利用上述更新共同體的機制,一次更新某一函式庫至同一版本,一回合一回合地的清洗函式庫與共用程式,直到所有分支的基礎核心都同步一致。此時,各分支間的歧異性,就會因為有同步一致的基礎而有效控制下來了。

方法之三,模組化

當主體核心程式的變動越來越少,不免就會想到,客製化的延伸功能,是否能也像設定檔一樣,由外部載入而不需要更動主體程式。這樣的軟體方法就是模組化,舉例來說,外掛/插件就是模組化的設計,他讓軟體主體在不用更改程式下,能以動態外部載入的方式來增加新的功能。這個軟體方法能大大的降低程式主體的分支複雜度,讓主體核心越來越收斂越,並讓客製的複雜度有效的控制在個別的區域範圍內。

撇除上述三者的不同標題名稱,其實本質都是一樣的,就是如何在不更動程式的前提下,能透過動態設定的方式滿足需求。

Syntax sugar

另外,程式語法的微調跟一致性,也有助於複雜度的收斂。

例如,透過一些 coding style 的 policy 或是 linter ,能有效減少 space/whitespace/quoting/… 之類的歧異。

此外,程式的寫法有也差異,比如說:

CFG=”aaa bbb ccc”

增加一項 “ddd” 的設定時,會變成

CFG=”aaa bbb ccc ddd”

這個變動在 diff 工具的比對下,比較不那麼順手。另一種變形的寫法如下:

CFG=”aaa bbb ccc”
CFG=”$CFG ddd”

或甚至是一開始就是
CFG=””
CFG=”$CFG aaa”
CFG=”$CFG bbb”
CFG=”$CFG ccc”
CFG=”$CFG ddd”

這樣子的寫法,在 diff 工具下,就是呈現一行一行的差異,而且,不易產生 merge conflict 的問題。不管是 mark 註解到,重新排列,或是增加減少,都相對容易處理。

Ask, Do, Commit

受到姿勢跑法(Pose Method)將跑步簡化為 Fall,Pose,Pull 三個動作的啓發,我在想,寫程式是否也能有這樣的簡化?

回想了一下,自己寫程式的過程,似乎也可簡單分成幾個:

1. Ask
先問自己一個問題,或是從 issue tracker 裡挑一個問題。

2. Do
在問題的驅使下,開始去作一些事,如寫程式、調整設定,或是研究資料之類的。

3. Commit
作到某個階段就作個 commit ,可能是版本控制的 commit ,或是筆記的儲存。有點像是玩 RPG 遊戲時”遊戲進度存檔”的功能。

備註:
其中,我覺得第一步是最困難的,很多時候不知那根筋不對,腦筋就是無法進入專注。我發現到,我自己似乎比較常在被問問題,(尤其是有趣的問題),而腦袋開始想東西時,那瞬間好像有什麼開關被打開了一樣。

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 。

View post on imgur.com

以上圖為例,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 變得很好用。其示意圖大致如下:

View post on imgur.com

上圖中,黃色記號的點,是兩個 branch 在 merge 時,所納入計算的 merge base。我們可以注意到,merge base 會依新的 merge 成果,往前累積紀錄點。

另一個值得注意的地方是,雖然是 B 分支 merge A 分支,但 merge base 是在 A 分支上,而上圖的 merge 過程,merge base 都是保持在 A 分支上。

特性: 交叉 merge 的變化

當 git merge 的方向改變時, merge base 也會隨之變化。示例圖如下:

View post on imgur.com

上圖中,黃色記號所註記的 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 的迴路。根據上述的種種特性與觀察,我想出了兩個原則來克服這個問題:

1. 在 git 分支結構中,區別出上游與下游的順序關係
2. 逆流方向必須使用不影響 merge base 的 merge 方式(ex: patches/cherry-pick/merge –squash/…)

重新審視 git workflow

以最常被引用的 nvie 的經典文章 A successful Git branching model 為例:

View post on imgur.com

從上圖的開發流程中,大略可分類為 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呢? 這些問題,從上述提出的觀點下,都有了解釋與答案了。