9.6 Pre-commit hook 與 CI 整合
工具落地的核心責任是讓檢查在對的時機自動執行,把紀律從「勤勞的人手動跑」轉移到「每次 commit / push 都跑」的基礎設施。Pre-commit hook 守本機開發、CI 守共享 branch;兩者互補、一起把規則失敗成本壓到秒級可回饋,避免 bug 漏到 production。這個模式對 idempotent 工具特別重要 — hook 每次 commit 都會跑,非冪等的工具會累積漂移、讓作者反覆看到「為什麼這個檔案又被改了」的困惑。
工具一旦從 CLI 進入 hook / CI,就有幾個容易出狀況的邊界:哪些 check 該放 hook(快、本地可執行)、哪些該放 CI(慢、需要乾淨環境)、hook 改了檔怎麼 re-stage、–no-verify 的邊界怎麼約定、CI strict mode 跟 local dev 的差異怎麼處理。本章展開這些問題,並以 .githooks/pre-commit + .github/workflows/md-check.yml 作為 concrete instance。
Pre-commit hook 能做什麼、不該做什麼
能做:
- 讀 staged 檔案,跑 lint / fmt
- 自動修正格式違規、
git addre-stage - 擋下 lint error 的 commit
- 跑跨檔分析(cards)
- 執行 build(確保程式碼能編譯)
不該做:
- 執行完整 test suite(太慢,交給 CI)
- 執行 e2e 或需要網路的操作(脆弱,commit 不該依賴外部)
- 修改未 stage 的檔案(會造成 working tree 混亂)
- 執行超過幾秒的任務(心流殺手)
原則:pre-commit 是快速守門員,不是完整驗證器。該做的 checks 要在秒級完成;更慢的驗證交給 CI。
Makefile 作為 hook 與 CI 的共同介面
有個常被忽略的 pattern:hook 跟 CI 都透過 Makefile 呼叫工具,不直接呼叫 binary。這讓三方共用同一套指令。
1# Makefile
2MDTOOLS_SRC := $(shell find scripts/mdtools -type f -name '*.go' 2>/dev/null)
3MDTOOLS_MOD := scripts/mdtools/go.mod scripts/mdtools/go.sum
4MDTOOLS_BIN := bin/mdtools
5
6.PHONY: build check fix lint cards install-hooks
7
8build: $(MDTOOLS_BIN)
9
10$(MDTOOLS_BIN): $(MDTOOLS_SRC) $(MDTOOLS_MOD)
11 @mkdir -p bin
12 @cd scripts/mdtools && go build -o ../../$(MDTOOLS_BIN) .
13
14check: build
15 @./$(MDTOOLS_BIN) fmt --check content/
16 @./$(MDTOOLS_BIN) lint content/
17 @./$(MDTOOLS_BIN) cards content/
18
19install-hooks:
20 @git config core.hooksPath .githooks
這樣:
- 開發者本機:
make check手動驗一次 - Pre-commit:hook 呼叫
./bin/mdtools ...(或透過 Makefile target) - CI:workflow 跑
make check - 所有人看到的失敗訊息格式一致
Make 的依賴 timestamp 機制也剛好解決「binary 什麼時候重 build」— MDTOOLS_BIN 依賴 MDTOOLS_SRC,source 新於 binary 才重 build。
Pre-commit hook 實作
1#!/usr/bin/env bash
2# .githooks/pre-commit
3set -euo pipefail
4
5MDTOOLS_BIN="bin/mdtools"
6REPO_ROOT="$(git rev-parse --show-toplevel)"
7cd "$REPO_ROOT"
8
9# 沒 staged .md 快速退出
10staged_md=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.md$' || true)
11if [[ -z "$staged_md" ]]; then
12 exit 0
13fi
14
15# Rebuild if source newer than binary
16if [[ ! -x "$MDTOOLS_BIN" ]] || [[ -n "$(find scripts/mdtools -type f -name '*.go' -newer "$MDTOOLS_BIN" 2>/dev/null || true)" ]]; then
17 echo "[pre-commit] rebuilding mdtools..."
18 (cd scripts/mdtools && go build -o "$REPO_ROOT/$MDTOOLS_BIN" .) || {
19 echo "[pre-commit] mdtools build failed" >&2
20 exit 1
21 }
22fi
23
24# fmt --fix on staged,re-stage 變動的檔案
25echo "[pre-commit] mdtools fmt --fix"
26while IFS= read -r f; do
27 [[ -z "$f" ]] && continue
28 before=$(git hash-object "$f")
29 "$MDTOOLS_BIN" fmt --fix "$f" >/dev/null
30 after=$(git hash-object "$f")
31 if [[ "$before" != "$after" ]]; then
32 git add "$f"
33 fi
34done <<< "$staged_md"
35
36# lint on staged (擋錯)
37echo "[pre-commit] mdtools lint"
38"$MDTOOLS_BIN" lint $staged_md || exit 1
39
40# cards on full content (擋錯)
41echo "[pre-commit] mdtools cards"
42"$MDTOOLS_BIN" cards content/ || exit 1
43
44exit 0幾個關鍵 pattern:
Fast exit when no markdown staged
沒 md 改動時 hook 在 10ms 內退出。Go 工程師改 .go 檔時不會被 markdown hook 擋。這是使用者體驗的生死線。
git diff --cached --diff-filter=ACMR
A(added),C(copied),M(modified),R(renamed) — 該檢查的變動類型- 排除
D(deleted) — 刪除的檔案不用 lint
git hash-object 偵測實際變動
1before=$(git hash-object "$f")
2./bin/mdtools fmt --fix "$f"
3after=$(git hash-object "$f")
4[[ "$before" != "$after" ]] && git add "$f"只在檔案內容實際改變時 re-stage。如果 fmt --fix 跑完沒改東西(檔案已經 compliant),不觸發多餘 git add。
避免用 stat 或 mtime — 那些會誤判(file touched 但內容相同)。
分層 exit code
- Fast exit (
exit 0):沒事要做 - Lint error (
exit 1):違規,擋 commit - Build failure (
exit 1):工具壞了,擋 commit(比讓人用壞工具好)
Git 看 non-zero 就會阻止 commit,訊息會印到 terminal,作者能看到原因。
CI workflow
CI 跑得比 hook 更嚴格:
1# .github/workflows/md-check.yml
2name: md-check
3
4on:
5 push:
6 branches: [main]
7 pull_request:
8 branches: [main]
9
10jobs:
11 md-check:
12 runs-on: ubuntu-latest
13 steps:
14 - uses: actions/checkout@v4
15 - uses: actions/setup-go@v5
16 with:
17 go-version-file: scripts/mdtools/go.mod
18 cache-dependency-path: scripts/mdtools/go.sum
19 - name: Build mdtools
20 run: |
21 mkdir -p bin
22 (cd scripts/mdtools && go build -o ../../bin/mdtools .)
23 - name: fmt --check
24 run: ./bin/mdtools fmt --check content/
25 - name: lint
26 run: ./bin/mdtools lint content/
27 - name: cards
28 run: ./bin/mdtools cards content/設計決策:
go-version-file: scripts/mdtools/go.mod
讓 CI 從 go.mod 讀取 Go 版本。go.mod 裡寫 go 1.25.1,CI 自動用匹配的版本;本機升 Go 時 workflow 跟著同步。
CI 用 --check 而非 --fix
CI 的角色是偵測,修復留給本機。--check 發現問題就 fail,讓作者在本機修完再 push。若 CI 自動 --fix 然後 commit,會造成「CI 偷改作者 PR」的混亂。
不寫 try-catch 吞錯
CI 步驟失敗就 fail — 不要寫 continue-on-error: true 藏錯誤。早期接工具時覺得「讓 CI 通過先」很誘人,但藏錯誤等於工具沒生效。寧可 CI 紅,也要誠實。
安裝 hook 的 UX
.githooks/pre-commit 放進 repo,但 git 預設看 .git/hooks/,不會自動啟用。要跑:
1git config core.hooksPath .githooks包成 Makefile target:
1install-hooks:
2 @git config core.hooksPath .githooks
3 @echo "hooks installed"
README 或 CONTRIBUTING.md 要寫「新 clone 時執行 make install-hooks」。這是一次性動作,但必要。
考慮過的替代方案:
- 放
.git/hooks/pre-commit— 不能 commit 進 repo,每個 clone 都要重設 - 用
husky/pre-commit等工具 — 增加依賴,值得與否看團隊 - 用 direnv
.envrc自動設定 — 依賴 direnv,非標準
最乾淨是 Makefile target + 明確的 onboarding 步驟。
不能繞過的邊界
Pre-commit hook 可以用 --no-verify 跳過。規範要明寫:
寫作時遇到 pre-commit 報錯:讀錯誤訊息並修正,不可用
--no-verify繞過 hook。
這是社會規範而非技術強制 — 技術上 git 一定允許 --no-verify。但只要規範明列、有人做 code review 抓到違規,就足夠維持紀律。
有個 nuance:緊急情況真的需要 --no-verify 怎麼辦?例如在服務中斷時要緊急 commit 修復。規範要留這個緊急閥門,但搭配:
- 事後必須補 commit 把違規修掉
--no-verify的使用要 log 或在 PR 描述標註
大多數 repo 一年可能用不到兩次。關鍵是預設是不繞過,而不是「看情況」。
常見陷阱
Hook 執行時間爆炸
常見在 cards 這類需要 parse 全 repo 的 check。對 400 檔 < 1 秒可接受;對 10000 檔就要評估。降級手段:
cards只跑受影響的子圖(根據 staged 檔案 inferrence)- 複雜 check 搬到 CI
- 本機加 cache(invalidate on file mtime)
Binary 不 commit 進 repo,但 CI 失敗
.gitignore 排除 bin/,所以 CI checkout 時沒有 binary。要記得在 CI 加 build step(上面 workflow 的 Build mdtools step)。
fmt –fix 後 commit 有兩個版本
若 hook 的 fmt --fix 改了檔但 re-stage 失敗(例如 permission 問題),作者以為 commit 成功但實際 commit 的是舊版本。每次 staged 版本跟 working tree 都要同步 — git hash-object 比對能早期發現不一致。
Hook 不能跨平台
macOS / Linux 的 bash hook 在 Windows(未裝 WSL 或 Git Bash)可能不執行。如果 contributor 有 Windows,把 hook 寫成 Go 程式(例如 bin/mdtools hook pre-commit),讓 Go 本身處理跨平台。
擴充路徑
- Hook 只跑 staged 子圖:根據 staged files 推算需要 parse 的 repo subset,降低 hook 延遲。
- CI artifact 留 report:把 lint / cards 的報告 upload 成 GitHub Actions artifact,讓 PR 評論能連結到完整報告。
- Pre-push hook 做更重檢查:把 full test suite 放 pre-push(本機 push 前跑),更頻繁的 pre-commit 只做格式與 lint。
模組總結
走完九個章節,回到出發點:Go 除了寫後端服務,還能寫內部工具 / 靜態分析 / CLI / 程式碼生成。跟後端服務的差異在於生命週期、I/O 模式、錯誤處理慣例,但共用的 Go 技能(型別、interface、package、error)完全可遷移。
本模組介紹的技術 — stdlib flag、goldmark AST、idempotent rewriting、graph analysis、tripwire 決策、pre-commit 整合 — 適用範圍不只 markdown 工具。寫 linter、codegen、migration tool、build tool、dev helper 都是這些技術的組合。
下一步:動手把 scripts/mdtools clone 出來,加一條自己的 rule 進去。真正讀懂一個工具的方式是改它一次。