先講結論:受害者是一份消失的文件
那天我在做一件現在看來很合理、當下卻很天真的事——同時開兩個 Claude Code session 衝同一個專案。
一個負責改前端的一個權限 bug,另一個負責整理文件。兩個都在跑、都在 commit、都在開 PR。我心想:反正它們各做各的,互不相干,效率直接翻倍嘛。
半小時後,其中一個 session 回報:「我剛剛 commit 的那份 triage 文件,在 PR 裡找不到了。」
我打開 git log,那份文件確實不在它該在的 branch 上。git status 還在我眼前閃了一下——33 個修改的檔案,下一秒變成 0 個。
那一刻我才意識到:不是我的工作不見了,是我的兩個 AI 助手在同一個房間裡,互相把對方桌上的文件收進了自己的抽屜。
為什麼會這樣:HEAD 是「資料夾」的,不是「session」的
這是整件事的核心,也是 90% 的人(包括當時的我)會搞錯的地方:
HEAD(你現在在哪個 branch)、index(暫存區)、工作目錄裡的檔案——這三樣東西都是「working tree」的屬性,不是「session」的屬性。
換句話說,一個 Git 工作目錄同時只有「一個現在的 branch」。當兩個程序(兩個 Claude session,或一個 session 加一個背景 agent)開在同一個資料夾,它們就是在搶同一個 HEAD。
實際發生的悲劇長這樣:
Session A(文件組) Session B(前端組)
│ │
│ 在 main 上工作 │ git checkout -b fix/the-bug
│ │ (HEAD 現在指向 fix/the-bug)
│ │
│ git commit 文件 ◄────┤ ← 災難點:A 以為自己在 main,
│ │ 但此刻整個資料夾的 HEAD
│ │ 是 B 切過去的 fix/the-bug
▼ ▼
A 的 commit 落在了 B 的 branch 上,
而不是 main。沒有任何錯誤訊息。
最陰險的是:Git 不會報錯。它忠實地把 commit 記在「當下的 branch」上,只是那個 branch 不是 A 以為的那個。後來 B 整理它的 PR、做了一次 rebase,A 那筆 commit 就直接變成了無人認領的 dangling commit——飄在物件資料庫裡,沒有任何 branch 指向它,等著被垃圾回收。
至於那個「git status 33 個檔案瞬間變 0」的靈異現象?也很合理:另一個 session 把那些檔案 commit 進它自己的 branch 了。檔案從「已修改」變成「已提交到別的 branch」,在當前 branch 的視角看起來就像憑空消失。
辦案現場:reflog 是你的監視器錄影
當 git status 開始說謊、檔案開始消失,大多數人的第一反應是「完了,工作沒了」。
錯。第一個該打開的不是悲傷,是 git reflog。
reflog 記錄了 HEAD 的每一次移動——每一次 checkout、commit、reset。它就是 Git 的監視器錄影,能還原「到底是誰、在什麼時候、把 HEAD 移去了哪」:
git reflog -12
4748e2e7 HEAD@{0}: cherry-pick: 把前端修改重新貼到乾淨的 main 上
fcd0ced7 HEAD@{4}: branch: Reset to origin/main
4296b3a6 HEAD@{6}: reset: moving to HEAD
4296b3a6 HEAD@{9}: checkout: moving from docs-branch to fix/the-bug ◄── 真相
71e90f0d HEAD@{10}: cherry-pick: docs(audits): 文件組的工作
fcd0ced7 HEAD@{11}: checkout: moving from fix/the-bug to docs-branch
讀懂這段錄影:文件 session 在 HEAD@{11} 切去了文件 branch,做了它的工作,然後在 HEAD@{9} 切回前端 branch——就在這個時間點,那份 triage 文件被 commit 成了 4296b3a6,落在了前端 branch 上。
辦案的關鍵動作只有三個:
# 1. 確認那筆 commit 還在(物件還沒被回收)
git cat-file -t 4296b3a6 # → commit,還活著
# 2. 立刻釘住它,讓 gc 永遠不敢動它
git tag rescue/lost-doc 4296b3a6
# 3. 從釘住的 commit 把檔案內容撈回來
git show rescue/lost-doc:path/to/the-doc.md > path/to/the-doc.md
「消失」的未提交工作,通常不是被銷毀,而是被另一條工作流 commit 到了別的 branch。 在你開始哀悼之前,先看 reflog 和 git stash list。
根治方法:給每個 session 自己的房間
抓回文件只是止血。真正的問題是:兩個 session 不該共用一個工作目錄。
解法不是「叫它們小心一點」(人類和 AI 都做不到),而是從結構上讓它們不可能互相干擾——git worktree。
一個 .git(共用所有 branch、commit、物件),但多個獨立的工作目錄,各自有自己的 HEAD、index、檔案:
# 從主 repo 開第二個工作目錄,給 session 2 用
git worktree add ../myproject-wt2 -b session2-work main
然後把第二個 Claude session 開在 ../myproject-wt2。
它為什麼是「結構上」的解法、而不是「祈禱式」的解法?因為 Git 直接拒絕讓同一個 branch 在兩個 worktree 同時 checkout:
git worktree add ../wt2 fix/the-bug
# fatal: 'fix/the-bug' is already checked out at '/path/to/main-tree'
這個「拒絕」本身就是防撞護欄。兩個 session 唯一的交會點,從危險的「checkout/commit 時搶 HEAD」,變回了正常的「merge 時」——而那是 Git 本來就擅長處理的場景。
實務地雷(誠實說,免得你踩)
新的 worktree 不會帶走那些被 .gitignore 的東西,要手動補:
cd ../myproject-wt2
cp ../myproject/.env.local . # 環境變數不在 git 裡,deploy 指令會找它
pnpm install # node_modules 是每個 worktree 各一份
還有一個跨 worktree 的隱藏陷阱:stash 是整個 repo 共用的。兩邊 git stash list 看到的是同一份。所以 git stash drop 的時候手要穩,別把另一個 session 的存檔丟了。
加碼:寫一個會自己報警的 hook
根治之後,我還想要一層保險:萬一我哪天又手癢雙開在同一個資料夾,希望有人當場大喊一聲。
Claude Code 有 SessionStart / SessionEnd hook,正好拿來做這件事。邏輯很簡單:
- 每個 session 啟動時,把自己登記到一個協調資料夾——記下它的 working tree(
git rev-parse --show-toplevel)、branch、時間戳。 - 啟動時掃描其他登記,只有當另一個 session 的
toplevel跟我完全相同(= 真的同一個資料夾)才報警。 - 不同 worktree?同一個 repo 但不同資料夾?安靜——那本來就是安全的,不該打擾你。
- session 結束時自我登出;當掉沒登出的,靠一個 8 小時 TTL 自動清掉。
核心判斷大概長這樣(精簡版):
# 用 git rev-parse --show-toplevel 拿到「工作目錄身份」
toplevel = run(["git", "-C", cwd, "rev-parse", "--show-toplevel"])
# 掃描其他還活著的 session,找出「同一個 toplevel」的
others = [e for e in registry
if e["toplevel"] == toplevel and e["session_id"] != me]
if others:
print("⚠️ 另一個 Claude session 正在用同一個工作目錄!")
print(f" → 改用獨立 worktree:git worktree add ../{repo}-wt2 ...")
掛進 ~/.claude/settings.json:
{
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command",
"command": "bash ~/.claude/hooks/parallel-session-guard.sh" }] }
],
"SessionEnd": [
{ "hooks": [{ "type": "command",
"command": "bash ~/.claude/hooks/parallel-session-guard.sh" }] }
]
}
}
關鍵設計原則:同一個資料夾才吼,不同 worktree 完全閉嘴。 一個會在你正確操作時也亂叫的警報器,最後的下場就是被你關掉。好的警報器只在真正危險時出現。
神展開:hook 隔天抓到現行犯
故事如果到這就結束,頂多算「亡羊補牢」。但它有個我寫的時候完全沒料到的結局。
隔天,我 resume 其中一個 session,螢幕開場第一段就跳出來:
⚠️ PARALLEL-SESSION WARNING — 另一個 live session 正共用這個工作目錄:
tree: /Users/michaelwu/ClaudeCode/<project> (本 session 在 branch: fix/the-bug)
• 另一個 session 4b372ab2 — branch fix/the-bug
• 另一個 session 92027fa7 — branch main,50 分鐘前登記
兩個 session 共用一個 tree 會安靜地互相蓋掉 git 狀態。
→ 改用獨立 worktree:git worktree add ../<project>-wt2 ...
我前一天才寫的那個警報器,隔天就當場抓到我自己又開了兩個 session 在同一個資料夾。它甚至列出了現行犯的 session ID 和各自在哪個 branch。
那一刻的感覺很微妙:一半是「靠,又來」,一半是「太好了,這次我在踩雷之前就知道了」。
這就是好工具的價值——它不是讓你不犯錯(你一定會犯),而是讓錯誤在造成損失之前就大聲說出來。
插曲:警報器自己也有個盲點
正當我覺得這套防線很完美的時候,我又盯著那個 hook 的程式碼看了一會兒——然後發現它藏著一個更陰險的 bug。而且方向是「該叫的時候不叫」,比誤報還危險。
問題出在「怎麼判斷一個 session 還活著」。我的第一版很直覺:每個 session 啟動時記一個時間戳,超過 8 小時沒更新的,就當它已經關掉、從名單上清掉。
聽起來很合理對吧?直到你意識到一件事——那個時間戳只在 session「啟動」的瞬間寫一次,整個 session 期間再也不更新。
於是災難場景出現了:
我有一個 session 已經連續工作超過 8 小時(對,我就是會這樣操)。在第三個 session 的掃描視角裡,它的時間戳「過期」了,於是被當成早就關掉的殭屍清除。結果:一個明明還活著、還在同一個資料夾裡 commit 的 session,從警報名單上人間蒸發。下一個開進來的 session,不會收到任何警告。
這就是 false negative(漏報)——警報器在最該響的那一刻,安靜得像沒裝過。對一個專門「防止你踩雷」的工具來說,漏報遠比誤報致命。
解法:別自己造心跳,用系統已經在跳的那顆
最直覺的修法是加一個「心跳」:每隔一段時間就更新一次時間戳。但這代表要在每一次工具呼叫時都插一段程式碼去寫檔,全程都在付這個成本——為了一個只在我手癢雙開時才有用的功能,這代價太貴了。
更好的解法,藏在一個我本來就有、卻沒用到的東西裡:transcript(對話記錄)檔案。
Claude Code 會把每一則訊息、每一次工具執行的結果,即時寫進這個 session 的 transcript 檔。換句話說——這個檔案的「最後修改時間(mtime)」,就是一顆系統免費幫我維護的心跳。 只要 session 還在活動(不管是我在跟它對話、還是它在自主跑一個長任務),這個檔案就一直在被寫入,mtime 永遠是新的。
所以我把「判斷活著」的依據,從「我自己記的時間戳」換成「transcript 檔的 mtime」:
# 舊版:時間戳只在啟動時寫一次 → 長 session 會被誤判成殭屍
if now - entry["started_at"] > TTL:
prune(entry) # ← 把一個還活著的 session 砍了(漏報的根源)
# 新版:用 transcript 的 mtime——系統免費維護的心跳
last_active = os.path.getmtime(entry["transcript_path"])
if now - last_active > TTL:
prune(entry) # 只有「真的沒在動」的 session 才會被清掉
一個跑了 20 小時、但一直在工作的 session,它的 transcript 每幾秒就被寫一次,mtime 永遠新鮮,永遠不會被誤砍。而真正關掉或當掉的 session,transcript 停止更新,時間到了自然被清除。漏報的洞,補起來了——而且一行多餘的心跳程式碼都沒寫。
一個常被忽略的工程槓桿:在你動手造一個新機制(心跳、計數器、狀態追蹤)之前,先看看你的執行環境是不是已經免費幫你維護了同一個訊號。 最好的程式碼,是你根本不用寫的那行。
(這個 bug 怎麼被抓到的?是我請另一個 AI 當「魔鬼代言人」去挑這個 hook 的毛病時,它指出來的。連除錯這件事本身,現在都是人和 AI 一起做的。)
三個帶走的教訓
1. 並行作業的隔離邊界,要劃在「工作目錄」這一層,不是「分支」這一層。
兩個 AI agent 跑不同 branch 聽起來很安全,但只要它們共用一個資料夾,HEAD 就是共用的。git worktree(或乾脆各自 clone)才是真正的隔離。
2. 「消失」的工作,第一步是辦案,不是哀悼。
git reflog + git stash list + git cat-file。未提交的改動很少真的被銷毀,通常只是被另一條流 commit 到了別的地方。撈回來之後,先 git tag 釘住,再慢慢處理。
3. 把教訓變成會自動執行的東西,而不是「下次我會小心」。
我把這類踩坑都編號歸檔(這個是我的第 12 號模式),但筆記只能提醒,不能阻止。真正有效的是那個 SessionStart hook——它把「我要記得別雙開」變成了「系統會在我雙開時當場攔我」。能寫成 hook 的紀律,就別只寫成筆記。
如果你也在用 AI agent 做平行開發——多個 Claude session、背景 agent、或任何會自己 commit 的自動化——這個坑你遲早會踩。希望這篇能讓你在 commit 蒸發之前,就先把每個助手請進它自己的房間。
(順帶一提:這篇文章本身,就是在一個乾淨隔離的 worktree 裡寫的。記取教訓這種事,總得從自己做起。)