Build a git release backporting tool

使用 Bash 製作一個 release 分支自動同步工具

前言

開發日常維護中,我注意到一個反覆出現卻又容易被低估的痛點:當從主幹分支切出 release 分支後,接下來一到兩週內,每個落在主幹上的 hotfix 都必須被 cherry-pick 回到 release 分支。這件事說來簡簡單,執行起來卻麻煩且容易存在人為疏失:忘記揀選、重複揀選、順序錯誤,任何一個都會讓 release 分支處於不一致的狀態。

現有的解決工具通常與特定的平台(GitHub、GitLab)的 API 綁定。這意味著你的自動化流程依賴於某個平台的特定功能,而且遷移到其他平台時就需要重新打造。

這篇文章記錄了我如何從零打造出一個純 Bash 的 git release backport 工具 —— pickle🔗。它只依賴 git,沒有平台依賴,沒有設定檔,沒有狀態檔,只有一個腳本檔案。

了解問題

release 自動化可能會碰上什麼困難?

  1. 選擇哪些 commit? 不是主幹上的所有 commit 都應該回到 release 分支,通常只有 hotfix,但如何判斷一個 commit 是不是 hotfix?
  2. 如何避免重複? 自動排程可能一天跑數次,同一個 commit 不能被揀選兩次。但 cherry-pick 會產生新的 SHA、不同的 commit message,如何識別「這個 patch 已經在 release 分支上了」?
  3. 衝突處理? 兩個分支各自演進後,patch 可能無法乾淨套用。
  4. 順序重要? 如果 fix #4 依賴 fix #2,跳過 #2 直接放 #4 會得到一個會編譯但行為錯誤的 release。
工具問題
GitHub backport bot🔗只在 GitHub 運作;需要 label 觸發;會建立 PR(而非直接 push)
release-please🔗專注於 versioning 和 changelog,不是 hotfix backport
自訂 shell 腳本每個團隊寫一個,品質參差,沒有重複利用

設計決策

核心哲學:只自動化 git 操作

pickle 只依賴 git 命令,不使用任何平台的 API。這代表:

  • 同一條命令在 GitHub Actions、GitLab CI、Jenkins、Drone 或本地筆電上都能運作
  • 遷移 CI 平台不需要改任何程式碼
  • 沒有複雜授權或平台造成的問題
  • 一個檔案就可以部署:curl ... -o /usr/local/bin/pickle && chmod +x

設定方式:命令列 flag + 環境變數

不做設定檔,設定已經存在於版本控制的 CI YAML 中。每個 flag 都有對應支援 PICKLE_ 前綴環境變數,因為 CI matrix 設定環境變數比構造命令列參數更容易。優先順序:flag > 環境變數 > 預設值。

選擇規則:Conventional Commit 優先

基於業界常見標準的 Conventional Commit 類型,只選 fix,開發者不需要任何額外紀律,關於 commit 的描述資料已經在標題裡了。

Terminal window
# 預設只選 fix commits
pickle sync --target release/1.2 --from main
# 也可以自訂
pickle sync --target release/1.2 --types fix,perf
pickle sync --target release/1.2 --include '^\[HOTFIX\]' --exclude 'WIP'
# 或者完全自訂:用任何 git log 指令作為選擇規則
git log --format=%H --grep=URGENT --author=oncall main \
| pickle pick --target release/1.2 -

衝突處理:停下來,人為介入

  • 保留已經成功套用並推送的 commit
  • 中止衝突的 cherry-pick
  • 輸出非零 exit code,讓 CI 失敗
  • 印出可直接複製貼上的解決指令

實際 CI 輸出

以下是一次真實的 pickle sync 遇到衝突時的完整輸出。主幹上有三個 commit,中間那個跟 release 分支的修改衝突:

Terminal window
pickle main → release/1.2
6903689c fix(A): clean one → picked
6d52e8c8 fix(B): conflicting → CONFLICT — needs a human
fc323f83 fix(C): clean two → not attempted (run stopped above)
1 picked, 0 skipped, 1 conflicted, 1 not attempted
pushed release/1.2 to origin
✗ conflict picking 6d52e8c8 fix(B): conflicting
c.txt
the branch was left clean; nothing half-applied was pushed.
resolve it locally:
git fetch origin
git switch release/1.2
git cherry-pick -x 6d52e8c8
# fix the conflict, then:
git add -A && git cherry-pick --continue
git push origin release/1.2
keep the -x flag: it records where the commit came from, which is how the
next run knows not to try again.
  • fix(A) 未衝突同樣推送到 release
  • fix(C) 顯示 “not attempted”,代表前方還有問題要先解決
  • 衝突檔案名稱直接列出
  • exit code 是 1,CI 亮紅燈失敗時提醒人為介入

解決流程

人類複製貼上指令後發生什麼事:

Terminal window
# 1. 切換到 release 分支並揀選衝突的 commit
git switch release/1.2
git cherry-pick -x 6d52e8c8
# → CONFLICT (content): Merge conflict in c.txt
# 2. 手動解決衝突(編輯 c.txt,選擇要保留的內容)
vim c.txt
# 3. 標記解決並繼續
git add -A && git cherry-pick --continue
# → 完成,-x trailer 自動保留在 message 中
# 4. 推送
git push origin release/1.2

推送後,commit message 中會有這行結尾:

fix(B): conflicting
(cherry picked from commit 6d52e8c8)

下次 pickle sync 跑的時候,第二層的去重機制就會認出這個 commit 已經在 release 分支上,直接跳過:

Terminal window
$ pickle sync --target release/1.2 --from main
fc323f83 fix(C): clean two → picked
6d52e8c8 fix(B): conflicting → skip (backported by hand, -x trailer)
1 picked, 1 skipped
pushed release/1.2 to origin

不需要記住任何規則——需要的指令在衝突時就印在眼前,而 -x 結尾確保解決後的 commit 不會被重複揀選。

衝突處理:如何避免重複揀選

這是整個工具最關鍵的部分,因為 sync 可能對同一個分支跑數百次。pickle 使用三層去重機制:

第一層:patch-id

git patch-id 會根據 commit 的 diff 計算識別碼,並正規化空白與行號。因此,即使 cherry-pick 後產生了新的 commit SHA,或修改了 commit message、author、date,只要實際變更相同,通常仍會得到相同的 patch-id。

pickle 會自行建立 patch-id 集合,判斷哪些變更已經存在於目標分支,而不是直接使用 git cherry。這是因為除了要比對一般 commit,還需要處理 merge commit:以第一個 parent 為基準,取出 git cherry-pick -m 1 對應的變更。同時,自行計算也能讓 pickle 明確控制 commit 的走訪順序。

首先,取得目標分支自共同祖先以來的 patch-id:

Terminal window
base=$(git merge-base "$FROM" "$TARGET")
git log -p --no-merges --no-renames --format='commit %H' "$base..$TARGET" \
| git patch-id --stable

接著,計算單一候選 commit 的 patch-id:

Terminal window
git diff-tree -p -m --first-parent --no-commit-id --no-renames --root "$sha" \
| git patch-id --stable

有了目標分支的 patch-id 集合後,pickle 會依照拓撲順序,從舊到新檢查候選 commit:

Terminal window
git rev-list --reverse --topo-order --no-merges "$TARGET..$FROM"

每個候選 commit 都會與集合比對。如果 patch-id 已存在,代表相同的變更已經出現在目標分支,可以直接跳過;否則才需要進入後續處理。

第二層:-x 結尾

Patch-id 唯一遺漏的情況:有人解決了衝突,所以套用後的 diff 跟原始的不同。

git cherry-pick -x 會在 message 中記錄 (cherry picked from commit ...)。pickle 掃描 release 分支上的這個結尾來識別:

Terminal window
base=$(git merge-base "$FROM" "$TARGET")
git log --format=%B "$base..$TARGET" \
| sed -n 's/^[[:space:]]*(cherry picked from commit \([0-9a-f]\{7,40\}\)).*/\1/p'

第三層:pickle skip

如果某個 hotfix 決定不發布到 release(例如依賴 release 中沒有的功能),上面兩個信號都不會觸發。pickle skip 寫一個帶結尾的 empty commit:

Terminal window
pickle skip --target release/1.2 abc1234 --reason "depends on a feature not in 1.2"

這在歷史中留下可審計的紀錄,記錄「一個人類故意做了這個決定」。

Agent Skill

可以透過 skills/pickle/ 使用 pickle。這個 skill 教會 agent:

  • 指令和 exit code 的意義
  • --dry-run 優先的習慣
  • 如何解決衝突而不丟失 -x 結尾
Terminal window
npx skills add riceball-tw/pickle # 任何 agent:Claude Code、Cursor…
cp -r skills/pickle ~/.claude/skills/ # 或者直接複製過去

Claude Code 也可以 plugin 形式安裝:/plugin marketplace add riceball-tw/pickle。

延伸閱讀