把 custom worktree skill 換成幾行 .worktreeinclude

上一篇我寫了一個 custom skill 來複製 local-only 檔案,本來只想少維護一支 script,卻為了「建立 worktree 後自動 install」一路掛上跨平台 hook,越搞越複雜。這篇記錄我怎麼踩遍 Windows 坑後回頭,退回一個不需要程式碼的宣告式清單。

  • AI coding
  • Claude
  • Git Worktree
  • Workflow

上一篇提到,我寫了一個 custom skill 來把 CLAUDE.local.md.env.local 這些 local-only 檔案複製進新的 worktree。這篇想記錄一件有點反高潮的事:那個 skill 我後來決定不要了。

更精確地說,這是一個「一個想簡化的小改動,怎麼越搞越複雜、最後又退回最簡解」的故事。我原本只想少維護一支 script,中途卻掛上了一整套跨平台 hook,踩遍 Windows 的坑之後才發現繞了一大圈,終點其實近在眼前。

起點:一支把三件事收成一個指令的 skill

先交代一下我原本的作法。前一篇那個 skill 放在 .claude/skills/worktree/,核心是一支 PowerShell,把三件事收成一個指令:git worktree add 建立 worktree、複製 local-only 檔、再跑 pnpm install 把依賴裝好。

要複製的 gitignored 項目一共四個:CLAUDE.local.mdapps/web/.env.localapps/blog/.env.local,還有 .vercel(這個是目錄,不是檔案)。這支 skill 用起來其實很順,一句話就能開好一個隨時能動的 worktree。

會想動它,是因為官方文件裡就有 .worktreeinclude 這個機制,專門用來把 gitignored 檔案複製進新的 worktree。既然官方已經給了現成作法,我就想把複製清單搬過去,少維護一支 custom skill——當時想的就這麼簡單。

第一個轉折:.worktreeinclude 不是 git 原生功能

一查才發現我對它的定位理解錯了。.worktreeinclude 不是 git 原生功能,它是 Claude Code 內建 worktree 功能的機制。它用 .gitignore 的語法,效果是:建立 worktree 時,把「符合 pattern、而且同時被 gitignore」的檔複製過去。

關鍵在於只有 Claude Code 內建的建立路徑才會讀它——也就是 claude -w <name>(等同 claude --worktree),以及對話中的 EnterWorktree。你手動下 git worktree add 這個 git 指令,它完全不認得 .worktreeinclude,什麼都不會複製。

所以「搬清單」這件事的真正代價,不是換個檔案格式而已,而是把「建立 worktree」這個職責,從我自己的 skill 交還給 Claude Code 內建。我得改用 claude -w 來開 worktree,才吃得到 .worktreeinclude

第二個轉折:想保留自動 install,就得自己寫 hook

問題來了。我盤點一下需求,發現有三個願望不能同時滿足:

前兩個 Claude Code 內建就給你,但第三個沒有——內建的建立流程不會幫你裝依賴。要補上這一步,我得自己寫一個 WorktreeCreate hook。

而且這裡有個陷阱:Claude Code 沒有「建立之後」再跑一段的 hook(沒有 PostWorktreeCreate 這種東西)。WorktreeCreate取代型的 hook,官方文件寫得很白,它會 "replaces the default git worktree logic entirely"。這句話當時我沒多想,直接埋下了下一個坑。

第三個轉折:hook 一掛,.worktreeinclude 就失效

「取代型」的意思,在官方文件一個講 SVN/Perforce 的角落才講清楚:

Because the hook replaces the default git behavior, .worktreeinclude is not processed when you use --worktree.

翻成白話:只要你定義了 WorktreeCreate hook,就等於完全接管了建立流程,內建那套讀 .worktreeinclude 複製檔案的邏輯就整個失效了。想複製 local-only 檔?請在你自己的 hook 腳本裡再寫一遍。

這裡就是整件事最諷刺的地方:我掛 hook 的初衷只是想加「自動 install」,結果卻連帶把原本內建會幫我做的「複製 local-only 檔」也一起接了回來,得在 hook 裡重做一次。要維護的 script 不減反增——我本來是想少維護一支 skill 的。

更別說這是在 Windows 上。hook 走 powershell.exe(Windows PowerShell 5.1),腳本得存成 UTF-8 with BOM 才不會亂碼;pnpm 那套 symlink 農場般的 node_modules,真實路徑動輒超過 260 字元的 MAX_PATH。

於是問題像滾雪球一樣往下長:session 退出時 Windows 內建清理刪不乾淨那些長路徑,會留下孤兒目錄——我又得補一支 WorktreeRemove hook,用「溫和到暴力」的多段刪除策略收尾。這些細節這裡就不逐一展開,重點是那種「補了一個又冒出下一個」的感覺。

第四個轉折:自動 install 太重,拔掉

真的用 claude -w 跑過幾輪之後,我對「自動 install」這件事的看法變了。每建一個 worktree 就強制等一輪 pnpm install太重了。很多時候我開 worktree 只是想看點東西、對照一下程式碼,根本還不到要跑測試或 commit 的階段,卻每次都得先等它裝完。

所以我把自動 install 拔掉,改成 on-demand:需要的時候自己在 PowerShell 裡跑 pnpm install

這個改動的代價要誠實講,而且得先說清楚這是我這個專案特有的、不是通則:我的 repo 用 husky 掛了 pre-commit hook,每次 commit 前會跑 lint-staged 加 vitest 做驗證,而這些檢查都依賴 node_modules。所以在一個還沒跑過 pnpm install 的 fresh worktree 裡,commit 會被 pre-commit 直接擋下來(連 husky 自己的 .husky/_ 都還沒經由 prepare 重建)。換成沒有這類 pre-commit 檢查的專案,缺依賴照樣能 commit,不會有這個問題。但對我來說這個代價可以接受——因為不是每個 worktree 都要馬上 commit,真的要 commit 時再裝就好。

檢討:把 install 拔掉之後,hook 還剩下什麼

拔掉 install 之後,我回頭看那支 WorktreeCreate hook,愣了一下。

當初撐起「非寫 hook 不可」這個決定的唯一理由,就是自動 install。這根柱子一抽掉,hook 裡剩下的內容幾乎只有一件事——複製那四個 local-only 檔。而複製 local-only 檔,正是 .worktreeinclude 內建就會幫我做的事。

換句話說,我繞了一大圈、踩了一堆 Windows 坑寫出來的 hook,功能上幾乎只是在重造 Claude Code 內建的 .worktreeinclude。既然如此,那就沒有理由留著它。我決定砍掉全部 hook,退回最精簡的 .worktreeinclude

最後 repo root 就一個 .worktreeinclude,四行:

CLAUDE.local.md
apps/web/.env.local
apps/blog/.env.local
.vercel

淨結果:刪掉整個 .claude/skills/worktree/,新增這四行,然後就沒有然後了——再也沒有需要維護的腳本。維護成本從「一支 skill/一整套 hook」降到「一個宣告式檔案」。

收尾:不 delete,而是留下脈絡再 close

程式碼收斂了,但流程上還有一堆殘骸要處理。整個過程我開了一個探索用的 issue、也發了一支實作 hook 方案的 PR。最終真正落地的,是另外開的一個乾淨 PR:加一個 .worktreeinclude、刪掉 skill 的兩個檔,改動小到幾乎只是「加四行、刪兩檔」。

至於那支 hook 方案的 PR,我沒有 merge,而是 close,並附上一則說明留言:移除 pnpm install 之後重新檢討,發現根本不需要這個 hook,只要 .worktreeinclude 配合 claude -w 就滿足需求,這個 PR 不合併回 master。那個探索用的 issue 我也留了 closing comment 交代最終決策,並交叉引用最後落地的那個乾淨 PR。

會這樣處理,是因為過程裡我想通了幾件事,值得記下來:

回頭看:維護成本不會消失,只會搬家

事後最大的體會是:這整件事其實是一個「維護成本沒有真的消失,只是搬了家」的教訓。我把 skill 裡的那支 PowerShell 搬成 hook 裡的 PowerShell,本質上還是同一支 PowerShell,只是換了個觸發者而已,複雜度一分沒少。

真正的簡化,是把它從「一段程式碼」變成「一份不需要程式碼的宣告式清單」。當初如果我先停下來問一句「自動 install 到底值不值得為它掛一個取代型 hook」,也許就能少繞這一圈。下次再想「簡化」某個東西之前,我大概會先分清楚:我是要把複雜度搬到別的地方,還是真的要讓它消失。