iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Claude AI

盡信 Claude,不如無 Code — 心法與全端實戰系列 第 10 篇

Day 10 Git 工作流:哪些讓 AI 做,哪些留給人

  • 分享至 

  • xImage
  •  

昨天那個測試護欄擋的是「它做得對不對」。今天要擋的是另一種東西 —— 它做得對,但你還沒同意。

這兩件事完全不同。測試全綠只能說 code 沒問題;它不知道你 review 了沒,也不知道這個 commit 該不該進 main。


可以,不可以,還有第三種

大部分人把 AI 能做的事分成兩堆:可以的、不可以的。實際上還有第三堆,而且它是最重要的一堆。

等級 操作 為什麼
✅ 放手 git status / diff / log / add / commit;push 到 feature 分支、開 PR 可逆。commit 錯了可以 reset;PR 可以關、分支可以刪,什麼都還沒進 main
🟡 要問 git checkout <branch> / merge / rebase / stash 可逆,但會改變你眼前的工作狀態。你正在看的檔案突然變了,比 code 寫錯還難察覺
❌ 絕不 push main / push --force / gh pr merge / reset --hard / clean -fd 不可逆,或沒經過人 review 就進 main。兩種性質沾到一個就進這一格

怎麼分?一句話:

可逆的放手,改變眼前狀態的要問,不可逆或沒經人 review 就進 main 的絕不。

注意分界線不是「有沒有跨出本機」。push 到 feature 分支也跨出本機,但它可逆 —— PR 本身就是 review 的關卡,AI 開 PR 不等於 AI 決定合併。真正要守的那條線是 main:進去的 commit 會進別人的 clone、進 CI、進 PR 通知。這個系列在講的「不驗過就別收」,在 Git 上的具體形式就是「不經 review 就別進 main」。

練習一:用設定檔來擋,不靠自律

這一段可以直接照抄。在專案的 .claude/settings.json:

{
  "permissions": {
    "deny": [
      "Bash(git push origin main:*)",
      "Bash(git push --force:*)",
      "Bash(git push -f:*)",
      "Bash(gh pr merge:*)",
      "Bash(git reset:*)",
      "Bash(git clean:*)"
    ],
    "ask": [
      "Bash(git checkout:*)",
      "Bash(git merge:*)",
      "Bash(git rebase:*)",
      "Bash(git stash:*)"
    ],
    "allow": [
      "Bash(git status:*)",
      "Bash(git diff:*)",
      "Bash(git log:*)",
      "Bash(git push origin feature/*)",
      "Bash(git push -u origin feature/*)",
      "Bash(gh pr create:*)"
    ]
  }
}

三個欄位的差別:deny 直接拒絕,ask 每次跳出來問你,allow 不問直接放行。**判定順序是 deny → ask → allow,先中的先算,而且「比較具體」不會贏。**一條寬的 deny 會蓋掉一條窄的 allow —— 所以 deny 裡不能留例外。

⚠️ **注意我寫的是 Bash(git reset:*),不是 Bash(git reset --hard:*)。**規則比對的方式是「* 之前的每個字照字面比」,所以 git reset --hard HEAD~1 擋得住,而 git reset HEAD~1 --hard 一條規則都沒中 —— 同一件事換個寫法就繞開了 deny,掉進「沒有規則命中」那一格(下面會驗)。列選項不如列子指令;連 --soft 一起擋的代價,遠小於留一個洞。

還有兩個更細的::* 和結尾的 * 不一樣。Bash(git push origin feature/:*) 的意思是「前綴到此為止,後面接空格再任意」,所以它對不到 feature/due-at;要對到分支名,得寫 Bash(git push origin feature/*)。官方文件的例子講的就是這件事:Bash(ls *) 對不到 lsof,Bash(ls*) 才對。旗標也會斷前綴:它習慣下 git push -u origin …,-u 一插進來,git push origin feature/* 就對不到了 —— 上面 allow 列兩條就是這麼來的。

為什麼要寫進檔案,而不是「我記得不要讓它推」?

因為自律是有狀態的。你在第三天記得,第十七天趕著發文的時候不會記得。而 settings.json 沒有狀態 —— 它在第十七天跟第三天一樣有效。

而且它不是只做字串比對 —— 它懂 shell:

deny 和 ask 規則只要任何一個子指令中了就會生效,包含藏在子 shell、命令替換、甚至 for 迴圈裡的。

也就是說 cd /tmp && git push origin main、echo "$(git push origin main)" 這種寫法一樣擋得住。這是「寫進設定」跟「寫進 CLAUDE.md」最實際的差別 —— 前者是執行環境在比對整棵指令樹,後者只是一句它讀過的話。

這跟 Day 6 那份 CLAUDE.md 是同一個思路,但有一個關鍵差別值得說清楚:

**CLAUDE.md 是講給 AI 聽的,settings.json 是講給執行環境聽的。**前者它可以「不小心忘記」,後者它做不到。

規則寫在哪一邊,決定了它是建議還是規則。

這份檔案值得 commit 進 repo,而且有一個剛好對我們有利的性質:

什麼時候生效
deny / ask clone 下來就生效
allow 要等每個人信任這個資料夾之後

換句話說,擋人的規則是立即的,放行的規則才要等信任。對 ❌ 那一格來說,這個順序剛剛好。這一格我後面撞到了證據:乾淨 clone 從沒開過互動 session,deny 照擋,allow 全部不認 —— 要在指令列用 --allowedTools 再放行一次才推得出去。

故意做一件該被擋的事

設定檔寫完不算完,要驗。我用乾淨 clone 放上這份 settings.json,claude -p 一次叫它跑一條指令,看它拿到什麼回覆:

我叫它跑 中哪條 它拿到的回覆
git push origin main deny Permission to use Bash with command … has been denied.
git push --force origin main deny 同上
gh pr merge 1 --squash deny 同上
cd /tmp && git push origin main deny(子指令) 同上
echo "$(git push origin main)" deny(命令替換) 同上
git checkout -b probe-branch ask Claude requested permissions to use Bash, but you haven't granted it yet.
git stash ask 同上
git push origin HEAD:main 一條都沒中 This command requires approval
git status allow 直接跑,回 On branch main …

三種回覆的句子不一樣,那才是要看的東西:「has been denied」是 deny 中了;「haven't granted」是 ask 中了;「requires approval」是什麼規則都沒中,掉進預設流程。在 -p 裡三種都跑不了,所以光看「它沒跑」分不出來 —— 要看句子。

reset 那條也驗了:把 deny 裡的 Bash(git reset:*) 換成窄的 Bash(git reset --hard:*),再跑兩條 ——

我叫它跑 中哪條 它拿到的回覆
git reset --hard HEAD~1 deny … has been denied.
git reset HEAD~1 --hard 一條都沒中 This command requires approval

窄規則放走了換順序的寫法;它不是被規則擋下,是沒有規則命中、又剛好沒人可問。

上面那張表倒數第二列是同一件事的另一個樣子:git push origin HEAD:main 進的還是 main,但字面上沒有 git push origin main,deny 對不到。規則永遠有寫不全的一天 —— 所以下一節要把裁判放到規則外面。

而「掉進預設流程」會發生什麼,看模式,而且現在的預設模式變了:Pro / Max / Team 的起始模式已經是 auto mode —— 沒中規則的指令不是問你,是交給一個分類器模型審。官方文件把三件事寫死:deny 在所有模式都擋,連 bypassPermissions 也擋;ask 在所有模式都一定停下來問;沒中規則的,Manual 問你、auto 交給分類器、-p 視同拒絕。所以 deny 清單要寫全 —— 它是三格裡唯一不看模式的那一格。

裁判不在你電腦上

settings.json 擋的是「這台機器上的 Claude Code」。你自己手滑、別的工具、換一台電腦,它都管不到;而且上面才證明過規則有洞。「不經 review 就別進 main」這條,真正的裁判在 GitHub 那一端。

Settings → Branches → Add classic branch protection rule,pattern 填 main,三個勾:

  1. ✅ Require a pull request before merging —— 底下的「Require approvals」取消(GitHub 不准自己 approve 自己的 PR,單人 repo 勾了會把自己鎖死)
  2. ✅ Do not allow bypassing the above settings —— 不勾的話 owner 直接 push 照樣進去,裁判只管別人不管你
  3. ✅ Require linear history;Allow force pushes、Allow deletions 都不勾

設完我從乾淨 clone 直接 git push origin main 一次:

remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: - Changes must be made through a pull request.
 ! [remote rejected] main -> main (protected branch hook declined)

這行拒絕跟 settings.json 的拒絕來源不同:前者是 server 端,誰來推都一樣;後者是本機的規則。前一節那條 HEAD:main 穿過了 deny,到這裡還是會被擋。

順手兩個設定,在 Settings → General → Pull Requests:合併方式只留 Squash and merge(AI 開的 PR 常是一串小 commit,squash 後進 main 就是一個);勾 Always suggest updating pull request branches(分支落後 main 時 PR 頁直接給按鈕)。這兩個是方便,不是裁判。

練習二:worktree 開分支,讓它做到 PR 為止

有了上面那兩層,就可以放手讓它做一整件事 —— 從空分支到開出 PR,人只在最後一步出現。

# 開一個獨立的工作目錄 + 分支,原本的目錄完全不動
git worktree add ../library-loan-due-at -b feature/due-at
cd ../library-loan-due-at

# 讓 AI 在那邊工作:寫測試、實作、commit、push、開 PR(完整 prompt 在 raw 的 flow-prompt.txt)
claude -p "實作 dueAt(now, loanDays)…先寫紅測試…commit…push 到 feature/due-at…gh pr create…不要碰 main,不要 merge"

# 它開完 PR,你回 GitHub review;不要了就整個丟掉
git worktree remove ../library-loan-due-at

worktree 跟開新分支的差別在於:它多了一個實體工作目錄。git checkout 是把同一個目錄的內容換掉,你正在編輯的檔案會在腳下變形;worktree 是另一個資料夾,你這邊完全不受影響。失敗的成本變成一行 git worktree remove。

跟 Day 4 那三級擺在一起,worktree 比第一級還輕 —— 它跟主目錄共用同一個 .git 和所有設定,隔的只有工作目錄。所以它擋的不是 AI,是你自己正在編輯的檔案不被弄亂;要擋 AI,還是上面那兩層。

第一趟:一路做到 PR

題目是圖書館練習的下一支 domain 函式 dueAt(now, loanDays)。它讀了規格,先寫 9 題紅測試,再實作,15 題全綠後 commit —— 然後在 push 那一步停下來:它下的是 git push -u origin feature/due-at,我的 allow 規則沒料到 -u,前綴一斷就落到 ask,它拿到「haven't granted」,老實回報「非互動模式沒人可問,我停在這裡」,沒有繞。接著又撞到 feature/:* 對不到分支名、allow 在沒信任過的資料夾不生效 —— 練習一那幾個坑全是這一趟踩出來的。規則改對、--resume 接回同一個 session,它推上去、開了 PR:

https://github.com/n913239/library-loan-lab/pull/1

我看 diff:2 個新檔、domain 沒有 Date.now()、測試涵蓋 14 天/帶毫秒/不讀時鐘。留一句 LGTM。本機看過 diff…npx vitest run 15/15,Squash and merge。叫它 gh pr merge 試一次 —— has been denied。最後一步只能是人,這不是靠它自律,是 deny + branch protection 兩層。

第二趟:review 不是按 approve

第二支函式 book-state.js(書的狀態機),同一條流程,PR #2。這次我照規格逐行對,有兩處該退:

  • blocking:它的測試是在 vitest 5.0.1 跑的 —— worktree 沒 node_modules,npx 抓了快取裡的版本,repo 要的是 ^3.2.4。它自己在 PR 內文老實寫了,但接著說「CI 用 lock 版本跑一次就能確認」—— repo 沒有 lock 檔、也沒有 CI,那是不存在的裁判。
  • suggestion:非法轉移丟什麼錯,規格沒表態,它自己加了 IllegalTransitionError。理由合理,但照 Day 9 的做法,這個決定要補進 spec.md,不然是 AI 的行為不是規格的行為。

行內留 comment、總評寫 blocking:。一件事要先知道:AI 用你的身分開 PR,GitHub 就把你當作者,Request changes 和 Approve 兩個鈕都按不了,只能用 Comment —— 要讓「駁回」在系統層成立,AI 得用自己的身分(GitHub App 或 bot 帳號),那是團隊版的事。

然後讓它回同一個 worktree 處理:npm install 後 vitest 3.2.7 重跑 28/28、版本與輸出貼回 PR、spec.md 補一列、同一分支再 commit 一次(不 force push、不開新 PR)、逐條回覆 reviewer 的原話、把 PR 內文裡已經不對的句子改掉。

https://ithelp.ithome.com.tw/upload/images/20260924/20103790t5NViSN2sK.png

它還多回報一件我沒問的:npm install 生出的 package-lock.json 它沒 commit,因為 CLAUDE.md 說不主動加東西,「lock 檔要不要進 repo 是另一個決定,你說了算」。這是對的。

我 resolve 那條 thread、留 LGTM、Squash and merge。時間軸長這樣:

https://ithelp.ithome.com.tw/upload/images/20260924/20103790lsnSvN36c2.png

一個 PR、兩個 commit,squash 後 main 上只留一個。討論留在同一頁,歷史乾淨。這一趟的重點不在它修得對不對,在它被退的兩件事都是「綠燈以外的東西」 —— 版本不對的綠燈、規格沒寫的決定 —— 測試抓不到,只有人拿規格對著 diff 看才抓得到。

merge 之後畫面上有一顆 Revert:按下去 GitHub 會開一個反向的新 PR,再 review、再 merge,main 就退回去 —— 歷史留著、不用 reset --hard、不用 force push。這就是「PR 可逆」的可逆之處。

一個我踩過的坑:commit message 的署名

讓 AI 寫 commit message 很方便,但預設會在尾巴加一行 Co-Authored-By: Claude …。我的文章 repo 有固定格式,這行會破壞它,而且它是一行一行累積的 —— 發現時已經一串了。當時我不知道有設定可以關,就寫進 CLAUDE.md。回頭數兩個 repo、66 個 commit,0 個帶署名 —— 它有效。但它放錯層了:每開一個新 repo 都要再寫一次,而且那個 0 沒有裁判,是「還沒破」不是「不會破」。

它有專屬設定,而我找了很久才知道

{
  "attribution": {
    "commit": "",
    "pr": ""
  }
}

放進 ~/.claude/settings.json,所有專案一次解決。但這裡有個「你以為設好了」的坑,我實測了三種寫法:

你寫的值 commit message 尾巴
不設定 Co-Authored-By: Claude Opus 5 …
false(布林) 原樣簽名
"false"(字串) 多一行 false
""(空字串) 什麼都沒有 ✅

那個欄位不是開關,是內容。 "false" 的意思是「把簽名改成 false」;false 是型別不符,被靜默忽略 —— 設定檔看起來很對,行為完全沒變,你要去翻 commit 才會發現。官方文件頁當時寫的是 false,照著寫兩次都沒生效;去翻 JSON schema 才看到 "type": "string"。文件跟 schema 不一致的時候,schema 才是執行的那一份。(9/23 回頭再查,那一頁已經改成 Type: string 了 —— 不一致修掉了,但當時踩到的是真的。)

還想要一道保險:commit-msg hook

設定管得住 Claude Code,管不住你自己貼上去的、或別的工具加的。所以我還寫了一支 commit-msg hook:用 grep 攔 Co-Authored-By / Generated with 這類 trailer,中了就 exit 1,四筆測資都對。但它擋得住手滑,擋不住 --no-verify —— 防的是手滑,不是惡意。最後沒裝:我這幾個 repo 只有我一個人 commit,設定那層已經驗過有效,hook 的維護成本大於它擋的殘餘風險。

層 我怎麼做的 結果
CLAUDE.md 兩個 repo 各寫一次 有效,但每個新專案都要重寫
settings.json attribution.commit: "" 一次設定,全部專案生效
commit-msg hook 寫了、驗了、沒裝 擋得住手滑,擋不住 --no-verify;設定夠用時不需要

**先找設定,找不到才寫規則,寫了規則還擋不住才上 hook。**順序搞反的代價不是做白工,是你以為擋住了。


一句話總結今天

Day 6 到今天,護欄已經有三層了:

層 檔案 擋什麼
意圖 CLAUDE.md 它該怎麼做
正確性 測試 + CI 它做得對不對
權限 settings.json + branch protection 它做得到什麼

前兩層可以被「它忘了」或「它繞過去了」破掉。第三層不行 —— 它不靠 AI 配合,所以真正不能出事的東西要放在那裡。而第三層自己又分兩邊:settings.json 是本機的規則,有寫不全的一天(HEAD:main 那條);branch protection 在 server 端,誰來推都一樣。今天的 settings.json 管跑什麼指令,Day 4 的 /sandbox 管寫到哪裡 —— 都不靠 AI 配合。

有了這三層,工作流就能反過來寫:開 PR 之前的事全交給它,進 main 那一步留給人。 worktree 開分支 → 它寫測試、實作、commit、push、開 PR → 你拿規格對著 diff 看 → 該退就退,它在同一分支修 → 你 merge。兩趟跑下來,它被退的都是綠燈以外的東西,這正是人該站的位置。

但第三層有它自己的失敗方式,而且更安靜:你以為你設好了。

attribution 那個 false 是一種 —— 型別不符、靜默忽略、設定檔看起來完全正確。feature/:* 對不到分支名是一種。allow 在沒信任過的資料夾裡不生效又是一種。所以三層護欄後面還有一句:

每一層都要驗過。 前兩層驗「它有沒有照做」,第三層驗「這條規則有沒有真的生效」—— 故意做一件該被擋的事,看它擋不擋。

我這一篇裡的每一格都是這樣驗出來的,包括我自己寫錯的那幾格。

這一篇留下的心法:

哪些讓 AI 做,不是靠交代,是靠設定檔來擋;擋了還要故意做一件該被擋的事,看它擋不擋。分界線是 main,不是本機與網路:它一路做到開 PR,合併那一步留給人;這條線要靠 server 端的裁判,不是靠它自律。

明天:把每天要講三次的話寫成一個檔案 —— 哪些話值得做成指令、一條可以直接抄走的 /commit,和一條給健忘的人的 /mike。


參考資料


上一篇
Day 9 測試是給 AI 的護欄,也是驗收標準
下一篇
Day 11 Slash Commands 與 Skills:把每天講三次的話變成一條指令
系列文
盡信 Claude,不如無 Code — 心法與全端實戰 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言