前言
開發日常維護中,我注意到一個反覆出現卻又容易被低估的痛點:當從主幹分支切出 release 分支後,接下來一到兩週內,每個落在主幹上的 hotfix 都必須被 cherry-pick 回到 release 分支。這件事說來簡簡單,執行起來卻麻煩且容易存在人為疏失:忘記揀選、重複揀選、順序錯誤,任何一個都會讓 release 分支處於不一致的狀態。
現有的解決工具通常與特定的平台(GitHub、GitLab)的 API 綁定。這意味著你的自動化流程依賴於某個平台的特定功能,而且遷移到其他平台時就需要重新打造。
這篇文章記錄了我如何從零打造出一個純 Bash 的 git release backport 工具 —— pickle。它只依賴 git,沒有平台依賴,沒有設定檔,沒有狀態檔,只有一個腳本檔案。
了解問題
release 自動化可能會碰上什麼困難?
- 選擇哪些 commit? 不是主幹上的所有 commit 都應該回到 release 分支,通常只有 hotfix,但如何判斷一個 commit 是不是 hotfix?
- 如何避免重複? 自動排程可能一天跑數次,同一個 commit 不能被揀選兩次。但 cherry-pick 會產生新的 SHA、不同的 commit message,如何識別「這個 patch 已經在 release 分支上了」?
- 衝突處理? 兩個分支各自演進後,patch 可能無法乾淨套用。
- 順序重要? 如果 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 的描述資料已經在標題裡了。
# 預設只選 fix commitspickle sync --target release/1.2 --from main
# 也可以自訂pickle sync --target release/1.2 --types fix,perfpickle 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 分支的修改衝突:
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)未衝突同樣推送到 releasefix(C)顯示 “not attempted”,代表前方還有問題要先解決- 衝突檔案名稱直接列出
- exit code 是 1,CI 亮紅燈失敗時提醒人為介入
解決流程
人類複製貼上指令後發生什麼事:
# 1. 切換到 release 分支並揀選衝突的 commitgit switch release/1.2git 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 分支上,直接跳過:
$ 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:
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:
git diff-tree -p -m --first-parent --no-commit-id --no-renames --root "$sha" \ | git patch-id --stable有了目標分支的 patch-id 集合後,pickle 會依照拓撲順序,從舊到新檢查候選 commit:
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 分支上的這個結尾來識別:
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:
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結尾
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。