我時常透過 Gitlab 提供的 Squash commit 功能,在 Merge Request 合併的當下,將工作分支的所有 Commits 壓縮為一,再併入主線當中,讓主線維持簡潔有序,也方便未來 cherry-pick 的執行。
憑藉一時好奇,探討一下如何透過 Git 指令來重現這樣的結果。
Gitlab 的 Squash commit
那麼,所謂 Gitlab 的 Squash commit 是怎樣的體驗?
整個過程大致是這樣:
- 當工作分支與目標分支之間沒有衝突,就可以執行合併;若有衝突,則先將目標分支合併回工作分支,例如將
main
先合併回feature
,並解完衝突。 - 勾選 Merge Request 頁面上的 Squash 選項,並可以填入新的 Commit message。
- 工作分支上的所有 Commits 會被壓縮為一個 Commit。
- 作者(Author) 會是發起 Merge Request (MR) 的人 (ref.)。
- 提交者(Committer) 會是執行壓縮、完成 MR 的人。 - 主線
main
將可以觀察到被壓縮的一個 Commit 出現在歷史當中,並且長出小耳朵 (因為有開--no-ff
)。
Git 如何實現 Commits 的壓縮
這個主題已經有許多人談過,搜尋一下不難發現幾種套路。
git rebase
再git merge
- 先透過rebase
將多個 Commits 整理為一個,再進行合併。
- squash 僅是 rebase 的部分能力。而 rebase 多半以互動式的方式來執行 (參數-i
),指令介面相對複雜,即便是透過工具執行也需要有通暢的思緒。git merge --squash
- 單純且暴力的用法,一條指令就把工作分支的所有 Commits 壓縮,並完成合併。
- 但實際上新產生的壓縮 Commit 基底(Base) 與原本的工作分支不同,因此 Git 無法正確判斷原工作分支是否已經完成合併,在刪除工作分支時將有警示。git reset --soft BASE
再git commit
再git merge
- 概念上也是先整理,再合併。
- 乍看有些詭異的組合技,但理解上反而簡單,也符合 Gitlab Squash commit 的結果。
於是我們就來聊聊這奇妙又單純(?) 的組合技吧!
看看 git reset 的例子
將git reset
之流的概念展開來可能略為繁瑣,但並不難理解,操作難度也不高。我們以工作分支 featureA
合併至主線分支 main
為例來說明。
首先切換至工作分支 featureA
git checkout featureA
找出所有 Commits 的起點,可以透過以下指令得到 featureA
與 main
共同基底的 Commit hash。當然,從各家工具的分支圖也可以人工判讀,不見得要用指令來找。
git merge-base main featureA
有了基底 (起點),就可以利用 git reset --soft
把分支上的所有 Commits 退回工作目錄。
但在這之前,讓我們先思考:壓縮所有的 Commits 是怎麼一回事,會對接下來的原理比較容易理解。
其實真正關鍵的是最後一個 Commit,無論一條分支上隨著時間出現多少增減與調整,最後一個點自然包含所有變更,它是從第一個點到最新的點所造就的最終成果。而 Git 每一個 Commit 記錄了完整的檔案目錄狀態,並不是記錄差異 (當然在儲存方面另有功夫,不是單純 clone 留存,篇幅考量就不展開了),所以這種壓縮/折疊全部 Commits 的操作,不是去計算每次變動加在一起會是什麼,要做的僅是保留最後一個 Commit 的狀態,並且移除其他所有 Commit,這恰巧可以透過 git reset
來實現。
透過 reset 將目前分支的 HEAD 指向基底,基底之後的所有 Commits 就會從歷史移除,而因為使用了 --soft
所以這些被移除的變更會留存在工作目錄當中,讓我們可以再次 Commit。
git reset --soft main
此時會發現如前述所談,分支 featureA
原有的 Commits 除了起點以外都已消失,而那些變更都留存在工作目錄,等待我們重新 Commit。
產生新 Commit 的方式大家都熟悉,唯一多做的是調整 metadata 當中的作者(Author) 欄位,這樣就與 Gitlab 產生出來的結果一致。也讓這個壓縮後的 Commit 資訊更為清晰,記錄誰是作者、誰是提交者(進行壓縮的人)。
git commit --author "Author <author@example.com>" -m "Squashed commit, #123"
至此,一個包含所有變更的新 Commit 誕生!且它仍然存在於分支 featureA
上,因此將它合併回 main
之後,再移除 featureA
也不會有警告,Git 可以明確判斷它有被合併。
git checkout main
git merge featureA --no-ff
延伸探討:Not fully merged 之謎
寫到這邊,篇幅也夠長了,但還有一個令人好奇的題目「Git: Not fully merged 之謎」另開文章與大家分享。