返回文章列表
AI (更新於 2026年5月30日)

兩個 AI 在同一個資料夾裡打架,我的 commit 就這樣人間蒸發

我同時開兩個 Claude Code session 衝刺同一個專案,結果它們共用同一個 Git working tree, 安安靜靜地把對方的 commit 蓋掉。這是一場用 reflog 辦案、用 git worktree 根治、 最後寫了一個 SessionStart hook 自動報警的完整過程——還有那個 hook 隔天真的抓到現行犯的爆笑結局。

先講結論:受害者是一份消失的文件

那天我在做一件現在看來很合理、當下卻很天真的事——同時開兩個 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、物件),但多個獨立的工作目錄,各自有自己的 HEADindex、檔案

# 從主 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,正好拿來做這件事。邏輯很簡單:

  1. 每個 session 啟動時,把自己登記到一個協調資料夾——記下它的 working tree(git rev-parse --show-toplevel)、branch、時間戳。
  2. 啟動時掃描其他登記,只有當另一個 session 的 toplevel 跟我完全相同(= 真的同一個資料夾)才報警。
  3. 不同 worktree?同一個 repo 但不同資料夾?安靜——那本來就是安全的,不該打擾你。
  4. 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 裡寫的。記取教訓這種事,總得從自己做起。)