
GitHub PR 合併:Merge、Squash、Rebase 的差異與抉擇
前言
最近遇到團隊轉型,除了選定工具和規劃專案架構,開發流程也是相當重要的一環。
其中我發現在討論 PR Merge 時應該用什麼模式 的時候,討論上比較卡。
大家對於 Merge 模式中,有共識的理解是:
有 Merge 或 Squash 兩種模式,如果是 Merge 的話 commit 點會連起來,而 Squash 不會。
其實更像是個人開發的習慣問題,大家也認同看偏好跟習慣選一種配合就行。
不過我個人習慣會去研究差異,而不是單純依靠習慣選擇。
所以就到了該寫文章的時候,分享一下我自己的看法。
GitHub 提供的合併策略
為了講解方便,我們先建立 main
跟 feat/home
分支,main
是主要分支, feat/home
則是開發使用的工作分支。
我們的目標是將工作分支 feat/home
的變更,合併回主分支 main
。
合併分支的方法大致分為兩類,Merge 跟 Rebase,而根據結果細分的話可以參考 GitHub merge 時提供的 merge methods,可以選擇三種方式:
Create a merge commit
GitHub 預設,也是使用上最常見的模式,後面會統稱 Merge Commit。
指令等同於執行 git merge --no-ff
。
分支合併後會自動創立一個新的點,該點會保留合併的所有檔案變動紀錄。
因此就算合併後刪除 feat/home
分支,在 graph 上也還是可以看得到紀錄。
合併後的紀錄看起來像一個樹狀結構:
Squash and merge
指令等同於執行 git merge --squash
。
會將 feat/home
上的 commit 記錄集合成一個點新增到 main
上,並且不會紀錄 feat/home
上的 commit 內容與分支關聯。
雖然兩個點的內容會是一樣的,但線不會連起來:
Rebase and merge
這個模式就比較複雜了一點。
不會額外產生新的合併 commit,看起來就像是直接在 main
分支上提交一樣,commit 的數量會取決於 feat/home
的 commit 數。
透過 Git 指令實現的話,大致上會執行下面的操作:
-
切換到 Pull Request 分支:
git checkout feat/home
-
重置基底到
main
分支:git rebase main
這一步會將
feat/home
上的 commit 放到main
分支的最新提交之上。這樣
feat/home
上的每個 commit 都會變成基於main
分支延伸的最新狀態。 -
切換回
main
分支:git checkout main
-
合併重置基底後的分支:
git merge feat/home
由於
feat/home
已經基於main
分支的最新狀態進行了 rebase,不會產生衝突。所以這一步 Git 預設會使用 fast-forward merge,也就不像 Merge Commit 會產生一個新的合併 commit。
fast-forward 是什麼?
fast-forward 是 git merge
時的一種策略,直接翻譯叫做「快轉」。
Git 的行為會把 HEAD 指針往前移動到最新的點,就像把播放器快轉到最新一幀,過程中不會產生多餘的節點。
如果當前分支到目標分支之間沒有分岔,例如下面這張圖,feat/home
的內容剛好是基於 main
最新的 commit 開出來的:
如果執行 git merge
的話,預設就會使用 fast-forward(快轉模式)模式,這時 Git 會直接移動 HEAD 指針到合併分支:
反之,如果當前分支到目標分支之間有分岔,例如下面這張圖, main
跟 feat/home
都個別有新的 commit,但其實兩個 commit 的內容都相同:
這時如果下 git merge
指令,即使 merge 的內容沒有衝突,也會使用 no fast-forward 模式:
那如果希望都統一不要 fast-forward,無論如何都要有 merge 後的 commit 時,可以使用 git merge --no-ff
指令:
我如何選擇合併模式
在三種模式中,通常我只會使用 Merge Commit 與 Squash,不會選擇 Rebase and merge,因為缺點非常明顯,到目前我也沒聽過有團隊在使用。
為何不選 Rebase and merge
一來是因為 Rebase and merge 的做法會讓所有的分支 commit 都會回到主分支上,所以這會非常考驗工程師切 commit 的能力與團隊規範的嚴謹程度,否則一個沒控制好,主分支很容易被各種無意義的 commit 充斥而變得雜亂。
其次,如果今天功能出了問題需要 rollback,最快又不破壞分支的方法通常是選擇 revert,而 Rebase and merge 由於會把所有 commit 點都帶到主分支的關係,要 revert 時會變得相當困難。
當然解法也不是沒有,就是確保每次開發都只打一個 commit,這樣 revert 就不會遇到問題。
不過這在真實的開發情境下是不切實際的,只有一個 commit 點對於工程師或是 PR Reviewer 都會是很大的負擔。
Merge Commit 與 Squash 的取捨
這樣比較下來,Merge Commit 跟 Squash and merge 其實都是比較合適的,這兩者我選擇方法也很簡單:
只要分支之間有合併順序的關係,除了順序中的第一個分支外,其他一律選 Merge Commit。
基本上就是團隊使用 Git flow 時的情境,例如合併順序是:
feature -> develop -> staging -> production
那 develop
、staging
、production
分支一率使用 Merge Commit,feature
分支則是兩種都可以。
因為以上面例子來說,如果 production
一定會由 staging
合併進去,使用 Squash and merge 除了在 graph 上會看不出合併的關聯及兩者的進度落差外,也可能出現明明變更內容相同,卻會出現 merge conflict 的狀況。
這都是因為使用 Squash and merge 後,兩者的 commit ID 不同,所以只能依靠 commit 的時間碰撞。
導致 Git 無法順利推斷應該使用哪一個 commit 內容,而 Merge Commit 則是因為有明確的合併順序關係,所以並不會出現類似問題。
那是否我可以一律選 Merge Commit 就好?
我個人認為這取決於你認為「開發時的 commit 點是否重要」。
如果你的答案是「Yes」,那選 Merge Commit 一定不會錯,否則適時的選擇 Squash and merge 會讓分支看起來乾淨一點。
以上面的例子來說,想像每個人都在 feature
打了很多 commit,然後每個人的 commit 習慣又不一樣,那使用 Merge Commit 反而會讓 develop
變得很亂。
這時候使用 Squash and merge 整理就會是很好的方式,而且也因為 feature 通常開發完就能被捨棄,所以就算使用 Squash,日後也不會遇到衝突的問題。
結語
最後簡單總結一下差異:
模式 | 特點 | 適合場景 | 缺點 |
---|---|---|---|
Merge Commit | 保留完整紀錄,分支關聯清晰 | 有合併順序的專案(Git flow) | graph 可能凌亂 |
Squash | 單一提交,乾淨整潔 | feature 分支、短期開發 | commit 歷史不保留,可能導致後續 conflict |
Rebase | 線性歷史 | 單人開發、極度重視乾淨紀錄 | 團隊協作困難,rollback 困難 |
在我團隊中以往的主力專案,開發時會以一個專案為模板基底,開分支後開發單一客戶的一次性需求,一年累積下來可能可以有幾百個分支。
因為沒有所謂環境的問題,所以不用考慮分支之間互相合併,所以通常都會選擇直接 Squash,檢視 git graph 時也會看起來比較整潔,
但這個邏輯換到功能比較複雜的系統專案時,因為多了環境問題,所以選擇 Merge Commit 會更安全。
但所有 Commit 全部 merge 保留,看起來也會很混亂,然而開發的功能程式碼一多,就算用 Squash 可能會變成一大包,遇到 bug 時也比較難快速定位問題的範圍。
所以我偏好依靠拆工作項目的方式,確保每一個 Squash 的功能都不會太多,然後採用開發時 Squash,後續一律 Merge Commit 的方式,這樣的做法實際上兼顧了歷史可讀性和可追溯性,是我在團隊裡蠻推薦的折衷解法。
當然如果你的團隊使用的像是 GitHub flow 或 TBD,那可能哪一種合併策略都不會是太大問題,依照團隊的需求去調整策略才是最重要的。
不知道大家都習慣哪種策略?歡迎留言分享給我~