1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

確認してから commit したのに、3ファイルが5ファイルになった — Claude Code を同じ作業ツリーで並走させた3か月の記録

1
Posted at

同じリポジトリの同じ作業ツリーで、Claude Code のセッションを最大5本並走させています。文書の翻訳、定期ジョブの修正、メールの下書きといった別々の作業を別々のセッションに任せ、.git も1つを共有しています。

2026年6月から9月までに、この「共有」が原因の事故とヒヤリハットが8件起き、対策を試す中で git の制約にも1つ当たりました。場所は4つです。git の index、作業ツリーの同じファイル(とフォルダ)、OS のクリップボード、そして「相手のセッションはもう終わったのか」という判断。

この記事はその記録です。公開前に別の AI(Codex)に査読させたところ、私が「効いている」と思っていた防御のいくつかが、思っていたより弱いことも分かり、直しました。直す前と後の実測も含めて書きます。

先に結論です。

  • git add → git diff --cached で確認 → git commit -m の3手は、並走していると原理的にレースします。確認は過去のスナップショットでしかありません
  • 共有 index のまま運用する前提では、git commit -m "…" -- <ファイル> の pathspec 形が、指定外のパスに他人が stage した変更を拾わない、ほぼ唯一の手でした。ただし穴が3つあります。①フォルダを渡すと、そのフォルダ内の他人の未 stage の編集まで入る ②同じファイルを両者が編集していれば、ファイルごと入る ③未追跡のファイルはそのままでは指定できない
  • 根本策は、共有をやめること(セッションごとに別ブランチの worktree)か、書き込みを1本に並べること(全員が編集を始めてから commit・push するまで同じロックに従う)です。私は事情があって常用していません(後述)
  • クリップボードは、同じ GUI ログインの中の全セッションで共通です。経由せずに直接入力し、送信前に全文を照合します
  • 止まって見えるセッションは、終わったとは限りません。transcript の時刻は「直近の活動の参考値」にしかならず、引き取ってよい根拠にはなりませんでした

前提と限定

  • 環境: macOS 26.6.2、git 2.54.0(Apple Git-157)、Claude Code 2.1.282(いずれも2026年9月25日時点)。事故当時の Claude Code の版は記録していません
  • 運用は私1人です。エンジニアではないので、コードは AI に書かせています。複数人のチームで同じ頻度で起きるという主張ではありません
  • 並走は「ターミナルを複数開いて、同じディレクトリでそれぞれ claude を起動する」形です。git worktree による隔離は常用していません
  • 作業の区切り(Claude が正常に応答を終えた各ターンの Stop フック。API エラーや利用上限で止まったときは発火しません)で自動 git push が走ります。主な条件は「今いるブランチが main」「手元の origin/main より先に進んだコミットがある」「機密情報などの検査をすべて通った」で、通ればそのコミットを作者を問わず push しにいきます(認証や通信、先を越された場合は失敗します)。したがって巻き込んだコミットを作ると、別セッションの区切りで外に出ることがあります
  • git の挙動は公式ドキュメントに書かれている仕様で、再現スクリプトを載せます。事故の日付と件数は私の作業記録からの引用です

起きたこと(2026年6月〜9月)

# 日付 共有物 起きたこと 結果
1 6/22 index 2ファイルをコミットしようと git add <自分のフォルダ>/ した直後、git diff --cached --stat に、別セッションが別のフォルダで stage していたノート23件の削除が混ざっていた(ヒヤリハット) commit 前に確認で見つけた。削除が stage されていた相手のフォルダの index と作業ツリーを HEAD に戻した(後述の注意あり)
2 7/7 index セッションAが git rm で削除を stage したまま承認待ちをしている間に、セッションBの git commit がその削除を巻き込んだ 削除自体は意図どおりだったが、コミットメッセージと中身が一致しない
3 8/4 index git add -- <3ファイル> → git diff --cached --stat で3ファイルだけと目視 → 直後の git commit -m が5ファイルをコミット 確認と commit の間に、別セッションが2ファイルを stage していた
4 8/4 index 逆向き。私が git add した2ファイルが、別セッションの pathspec なしの git commit に吸い込まれた 向こうのセッションが git reset HEAD~1 で戻した(今読むと、この既定の reset も共有の index を書き換えるので、その間に他のセッションが入れた stage を消しうる操作だった)
5 8/20 同じファイル pathspec 形でジョブの登録ファイルをコミットしたら、同じファイルに別セッションが入れていた定期ジョブ2本の登録削除が同梱された 内容は正しかったが、「いつそのジョブが消えたか」を追うと無関係なコミットに着地する
6 9/2 (制約) 新規ファイルを add せずに pathspec 形でコミットしようとして error: pathspec … did not match any file(s) known to git コミット自体が中止。共有とは無関係に、1セッションでも起きる git の仕様
7 9/7 クリップボード ブラウザで返信欄に貼り付けたら、別セッションが直前にコピーした別作業の原稿65,855字(内部のパスを含む)が入った(ヒヤリハット) 送信前の照合で気づいて消した。未送信
8 9/15 同じフォルダ 5セッションが同じフォルダで作業中、git add は個別ファイルで指定したのに、commit の pathspec をフォルダにしたため、別セッションが記入中の3ファイルを巻き込んだ 記入途中の版がコミットされた
9 9/15 相手の生死 利用上限で止まった翻訳セッションを「死んだ」と判断して引き取り、訳し直してコミットした。6分後にそのセッションが復帰し、自分の訳で29本を書き戻した ディスク上は2人の訳の混成。機械検査は全部通った。重複した作業は約400万トークン

#3 は、目視で確認した後に起きました。#1 は、その確認で見つけたものです。#5 と #8 は、pathspec 形や個別の add という、当時の防御を使っていても起きました。確認や防御が無かったのではなく、確認と実行の間に他人が動けることと、防御が守る範囲が思っていたより狭かったことが原因でした。

#1 で使った復旧コマンドには注意があります。私は git restore --source=HEAD --staged --worktree -- <相手のフォルダ> で、削除が stage されていたフォルダの index と作業ツリーを HEAD に戻しました。今読むと、これは相手のセッションの未コミットの作業も消す操作です。このときは結果的に問題ありませんでしたが、共有ツリーでは、持ち主に確かめずに --worktree を付けるべきではありませんでした。

再現: 共有 index で git はどう振る舞うか

2つのセッションを、1つのシェルで順に操作して再現します。「セッションB」と書いた行は、Aの2手の間に割り込んだ操作です。一時ディレクトリで動くので、手元のリポジトリには触りません。

#!/bin/bash
# 共有作業ツリー(index は1つ)で2セッションが並走したときの git の挙動を再現する。
# 「セッションB」の操作は、Aの2手の間に割り込んだものとして順に実行する。
set -u
W=$(mktemp -d); cd "$W" || exit 1
git init -q && git config user.email t@example.com && git config user.name t
mkdir docs; for f in a.txt b.txt docs/x.md docs/y.md shared.cfg; do echo base > "$f"; done
git add -A && git commit -qm init
echo "git $(git --version | awk '{print $3}')"
reset() { git reset -q --hard HEAD; git clean -qfd; }
show() { printf '  -> HEAD のファイル: '; git show --name-only --format= HEAD | tr '\n' ' '; echo; }

echo "== 1. add → diff --cached で確認 → (B が別ファイルを add)→ commit -m"
echo A >> a.txt; echo B >> b.txt
git add -- a.txt
printf '  A の確認: '; git diff --cached --name-only | tr '\n' ' '; echo
git add -- b.txt                                   # セッションB
git commit -qm "A: a.txt だけのつもり"; show; reset

echo "== 2. 同じ状況で pathspec 形(git commit -m … -- a.txt)"
echo A >> a.txt; echo B >> b.txt
git add -- b.txt                                   # セッションB が stage 済み
git commit -qm "A: a.txt" -- a.txt; show
printf '  -> B の stage は残る: '; git diff --cached --name-only | tr '\n' ' '; echo; reset

echo "== 3. 逆向き: A が add した直後に B が pathspec なしで commit"
echo A >> a.txt; echo B >> b.txt
git add -- a.txt                                   # A(まだ commit しない)
git add -- b.txt; git commit -qm "B: b.txt だけのつもり"   # セッションB
show; reset

echo "== 4. pathspec にディレクトリを渡す(B が同じフォルダで未 stage の編集中)"
echo A >> docs/x.md; echo B >> docs/y.md           # y は B の編集(add していない)
git add -- docs/x.md
git commit -qm "A: docs/x.md" -- docs/; show; reset

echo "== 5. 同じファイルを両者が編集(pathspec でも防げない)"
printf 'A-line\n' >> shared.cfg; printf 'B-line\n' >> shared.cfg
git commit -qm "A: shared.cfg" -- shared.cfg
printf '  -> コミットされた追加行: '; git show --format= -U0 HEAD -- shared.cfg | grep '^+[^+]' | tr '\n' ' '; echo; reset

echo "== 6. 新規(未追跡)ファイルに pathspec commit"
echo new > new.txt
git commit -qm "A: new" -- new.txt 2>&1 | sed 's/^/  -> /'
printf '  -> 直前の HEAD: '; git log -1 --format=%s; reset

echo "== 7. 新規ファイルは add と commit を && で連結"
echo new > new.txt
git add -- new.txt && git commit -qm "A: new" -- new.txt; show
rm -rf "$W"

出力(2026年9月25日・git 2.54.0・macOS 26.6.2):

git 2.54.0
== 1. add → diff --cached で確認 → (B が別ファイルを add)→ commit -m
  A の確認: a.txt
  -> HEAD のファイル: a.txt b.txt
== 2. 同じ状況で pathspec 形(git commit -m … -- a.txt)
  -> HEAD のファイル: a.txt
  -> B の stage は残る: b.txt
== 3. 逆向き: A が add した直後に B が pathspec なしで commit
  -> HEAD のファイル: a.txt b.txt
== 4. pathspec にディレクトリを渡す(B が同じフォルダで未 stage の編集中)
  -> HEAD のファイル: docs/x.md docs/y.md
== 5. 同じファイルを両者が編集(pathspec でも防げない)
  -> コミットされた追加行: +A-line +B-line
== 6. 新規(未追跡)ファイルに pathspec commit
  -> error: pathspec 'new.txt' did not match any file(s) known to git
  -> 直前の HEAD: A: shared.cfg
== 7. 新規ファイルは add と commit を && で連結
  -> HEAD のファイル: new.txt

ケース6の「直前の HEAD」がケース5のコミットのままなのは、新しいコミットが作られなかったことを示しています。

git の公式ドキュメント(git help commit・2.54.0)には、こう書いてあります。

by listing files as arguments to the commit command (without --interactive or --patch switch), in which case the commit will ignore changes staged in the index, and instead record the current content of the listed files (which must already be known to Git);

-o, --only Make a commit by taking the updated working tree contents of the paths specified on the command line, disregarding any contents that have been staged for other paths. This is the default mode of operation of git commit if any paths are given on the command line, in which case this option can be omitted.

ここから読めることは3つです。

  • pathspec を付けた commit は、指定したパス以外に stage された変更を無視する(ケース2)。共有 index のままで、他セッションが別のファイルに入れた stage を拾わないのは、この形です
  • ただし記録するのは作業ツリーの現在の中身です。自分が stage した中身ではありません。だからフォルダを渡すと配下の追跡済みファイルの編集が全部入り(ケース4)、同じファイルに他人の編集があれば、stage 済みかどうかに関係なく一緒に入ります(ケース5)
  • 「which must already be known to Git」とあるとおり、未追跡のままのファイルは対象外で、コミット自体が中止されます(ケース6)。add したあとなら通ります(ケース7)

pathspec 形が守るのは「指定外のパスに入った stage」だけです。同じファイルの中身、作業ツリーそのもの、ブランチの先端(HEAD)は、相変わらず全セッションで共有されています。

防御1: 共有 index のまま運用するなら

いまの手順です。

# 追跡済みファイルの変更: add を挟まず、pathspec でファイルを列挙する
git commit -m "…" -- path/a.md path/b.md
#   → 出力の [main 1a2b3c4] の OID を控える

# 新規ファイルを含むとき: 明示パスの add と commit を同じ行で && 連結する(commit が失敗すると add した分は stage に残る=下の注意)
git add -- path/new.md && git commit -m "…" -- path/new.md path/a.md
  • add を挟まない。追跡済みのファイルなら、pathspec 形は作業ツリーから直接コミットします。add した瞬間から commit までが、自分のファイルが他セッションの commit に吸い込まれる窓になります(#4)
  • 新規ファイルの add && commit は、窓を短くするだけです。2つの git プロセスの間に他セッションの commit が割り込む余地は残ります
  • フォルダを渡さない(#8)。-- docs/ は「docs/ に限定した commit -a」とほぼ同じ働きになります
  • 同じファイルは、commit の直前に git diff HEAD -- <ファイル> で中身を読む(#5)。git diff -- <ファイル> だと index と作業ツリーの差しか出ず、同じファイルに他人が stage 済みの変更が見えません。そして pathspec 形は、その見えなかった変更も含めてコミットします(私は当初 git diff -- <ファイル> と書いていて、査読でこの穴を指摘されました)。これも確認なので、読んだ後に書き換えられれば防げません
  • コミットの後は、HEAD ではなく控えた OID を git show --name-only --format= <OID> で全部読む。HEAD は他セッションの commit で動くので、HEAD を見ると他人のコミットを自分のものとして確認しかねません
  • && は commit の成功を保証しません。フックや署名で commit が失敗すると、add した新規ファイルは共有の index に stage されたまま残り、隙は短時間ではなく無期限になります。失敗したら git diff --cached --name-only で自分の stage を確かめ、git restore --staged -- <ファイル> で戻します
  • 巻き込んでいたら、まず控えた短縮 OID を git rev-parse <短縮OID> で完全な OID にし、それが今も先端で(git rev-parse HEAD と一致)、まだ push されていないときだけ git reset --soft HEAD~1 で戻します。確認と reset は原子的ではなく、その間にも他セッションは動けるので、並走中なら先に相手を止めます(git update-ref refs/heads/main <戻し先> <確認した現在の OID> の形なら、main が動いていれば失敗するので安全です)
  • 先端でない、または push 済みなら、git revert はコミット全体を反転するので、自分が意図した変更まで戻ります。巻き込まれた相手と相談して、「全体を revert してから自分の分を入れ直す」か「巻き込んだファイルだけを戻す修正コミットを作る」かを決めます
  • 機械的な歯止めとして、一括の add をツール実行前のフックで拒否しています。ただ、査読でこのフックが思っていたよりずっと狭いことが分かりました。旧版はコマンドを && や ; で先に区切ってから字句解析していたので、git commit -am "a && b" のように引用の中に区切り記号があると素通りし、git -C <dir> add -A、git add -u、git add ':/*'(全ファイルに一致するマジック pathspec)、git commit -a も通していました。いまは、コマンド全体を引用符と heredoc を考慮して字句解析してから引用の外の区切りで分け、git のグローバルオプション・-u・マジック pathspec・ワイルドカード・--pathspec-from-file・commit -a も拒否します。字句解析できないときは、git を含むコマンドなら止めます。用意した160通りのコマンドで試すと、旧版は91通りで意図と外れ、新版は160通りすべて意図どおりでした。ここでの「意図」には、旧版の誤検知3通り(dry-run の一括 add を止める)を後述の理由でそのまま残す判断が含まれます。それでも、文字列から読む以上、スクリプトの中で呼ぶ git や eval は見えません。フォルダの add も通します
  • pathspec なしの git commit -m は、無人ジョブが「add してから commit -m」の形で使っているので一律には止めていません。代わりに、コマンドを実行する前の stage が空か、stage 済みのものが全部「同じコマンドの中で、commit まで && だけでつないで先に add するファイル」なら通し、それ以外は止めます。これはコマンドを実行する前に存在する他人の stage を見つけるだけで、検査の後から commit までの間に別セッションが stage したものは防げません(この記事の主題である「確認と実行の間」の隙は、この防御にもそのまま残ります)。; や || の後ろ、if や関数の中の add は、実行されたか・成功したか分からないので数えません(if false; then git add …; fi; git commit -m … で他人の stage を通していたのを、査読で指摘されました)。別のコマンドで add しておいた自分の stage でも止まるので、その場合は pathspec 形を使います。途中で「セッションごとに add したファイルを記録しておき、別のコマンドの add も自分の stage と認める」方式も作りましたが、実行前に記録するので失敗や試行(-n)まで記録してしまうなど、査読のたびに穴が見つかり、単純な規則に戻しました

git diff --cached での確認も、git diff HEAD での確認も、補助でしかありません(#3)。見ているのは確認した時点の状態で、commit する時点の状態ではないからです。

自動 push にも同じ形の隙がありました。私の Stop フックは「origin/main..HEAD のコミットを1つずつ機密検査し、通れば main を push する」作りで、検査した範囲と push する範囲が同じ OID に固定されていませんでした。検査の途中でコミットを1つ差し込む試験をすると、旧版はそのコミットを検査せずに push しました。いまは、最初に main と origin/main の OID を1回だけ読み、その範囲だけを検査して、検査した OID をそのまま push します(git push origin <OID>:refs/heads/main)。ただ、OID を固定しただけでは逆向きの隙が残ると査読で指摘されました。検査の後に別セッションが main を巻き戻すと、取り消されたコミットを origin に押し戻してしまいます。検査の後に修正のコミットが積まれた場合も、修正前の状態だけを先に出すことになります。そこで push の直前に「main が検査した OID のままか」を確かめ直し、一致したときだけ push します。進んでいれば今回は出さずに次の区切りで全体を検査し直し、検査した OID が今の main の祖先でなければ(巻き戻し・rebase・履歴の分岐)中止します。それでも、確かめてから push が送り終わるまでの短い間は防げません。その間に巻き戻されると取り消されたコミットが origin に出るので、push の後にもう一度確かめ、外れていれば警告を出します。その間に main が進んだ場合は、検査済みの OID だけが出て、後ろに積まれた修正は次の区切りまで出ません(push の後の確認は祖先かどうかしか見ないので、これは警告されません。修正前の状態が一時的に origin に出ることは受け入れています)。中間状態を外に出さないようにするには、全セッションが従う外部のロックか、push する主体を1つに絞るしかありません。

この隙を閉じようと、git 自身が commit や reset のたびに取るロックファイル(このリポジトリの files 形式の ref 保存では .git/refs/heads/main.lock。git の公開されたロック API ではなく、reftable 形式では使えません)を push の間だけフックが持つ版も作りました。ほかのセッションの commit はロックで失敗し、やり直せば通ります。ところが次の査読で、git reset --hard は作業ツリーと index を書き換えてから ref のロックで失敗するので、半端な状態を作ると指摘されました(git の実装もその順序でした)。小さな隙を閉じるために、ほかのセッションの操作を壊しうる仕組みを入れては本末転倒なので、この版は取り下げ、隙は既知の限界として残しています。中止したときの案内も、HEAD~N への reset ではなく、main が検査時から動いていないときだけ検査時の基点への reset を案内するように変えました(案内であって実行ではないので、案内と実行の間に main が動けば古い案内になります。実行前にもう一度 git rev-parse main で確かめます)。

防御2: クリップボードを経由しない

macOS のクリップボード(一般ペーストボード)は、同じ GUI ログインの中で共通です。ターミナルの pbcopy も、ブラウザでのコピーも、同じものを上書きします。私の環境では複数のセッションがそれぞれ pbcopy してから貼り付ける手順を使っていたため、コピーから貼り付けまでの数十秒に、他セッションのコピーが割り込みました(#7)。

再現は簡単ですが、実行すると手元のクリップボードを上書きするので、載せるだけにします。

printf 'A の返信文' | pbcopy
( sleep 1; printf 'B の原稿' | pbcopy ) &   # 別セッションのコピー
sleep 2; pbpaste                              # → B の原稿

いまの手順です。

  • ブラウザへの入力は、クリップボードを経由せず、ページ内の JavaScript で直接入れます。対象の入力欄に focus() し、document.activeElement がその欄であることを確かめてから、document.execCommand('insertText', false, 文字列) で入れます。文面は評価するコードに生のまま連結せず、JSON 文字列として埋め込みます(引用符や改行でコードが壊れたり、文面がコードとして実行されたりしないように)。当時の Chrome と対象のサイトでは、入力欄のフレームワークが通常の入力として受け取りました。execCommand は非推奨の API で、どのサイトでも同じように効く保証はありません
  • 送信前に、入力欄の値が、承認した文面と全文一致するかを照合します。#7 は照合で見つかりました(当時は文字数と先頭50字の照合でしたが、全文一致に上げました)。「貼れた」という手応えは何の保証にもなりませんでした
  • どうしても pbcopy を使うときは、貼った直後に同じ照合をします

防御3: 止まって見えるセッションを「終わった」と決めない

#9 がいちばん高くつきました。翻訳セッションが16時過ぎに利用上限で止まり、5時間たっても進捗が 1/34 のまま動かなかったので、引き取って残りを訳し、21:58 にコミットしました。22:04 にそのセッションが復帰し、自分の訳で29本を書き戻しました。

利用上限は時間で解けます。止まっていることは、終わったことの証拠になりません。

さらに厄介だったのは、混成になった成果物が機械検査を全部通ったことです。2つのセッションが同じ指示書に従っていたので、見出しの形式も用語のチェックも揃っていて、訳語だけが静かに食い違っていました。検査を通ったことは、混ざっていないことの証拠になりませんでした。

transcript の時刻は「直近の活動の参考値」

Claude Code は既定の構成では会話の記録を ~/.claude/projects/<プロジェクト>/<セッションID>.jsonl に1行1イベントで書きます(2.1.282・既定の構成での手元の実測。保存先は CLAUDE_CONFIG_DIR で変わり、行の形式は公開された仕様ではありません。フックから読むならパスを組み立てず、フックの入力にある transcript_path を使います)。9/16 に、別セッションが動いているかをこれで3通りに測りました。

測り方 そのときの判定 実際
transcript ファイルの更新時刻 40分前に更新された=動いている ❌ 他セッションからのメッセージがキューに積まれただけでも更新される
lsof <transcript> で開いているか 開いていない=止まっている ❌ 対照として自分の動いているセッションも出なかった(書くたびに開いて閉じているのだと推測しています)
1行ずつ JSON として読み、type ごとの最新の timestamp を取る 最新の assistant は4秒前 動いていた

3つ目がいちばん当てになりましたが、それでも生死の判定にはなりません。公式ドキュメントにも「The transcript file is written asynchronously and may lag the in-memory conversation」(Hooks リファレンス、transcript_path の説明)とあります。最新の assistant が古くても、長いツールの実行中や利用上限の待ちなのかもしれず、新しくても、その直後に終わったのかもしれません。

import json, sys
from datetime import datetime

latest = {}
with open(sys.argv[1], encoding='utf-8', errors='replace') as f:
    for line in f:
        try:
            d = json.loads(line)
        except json.JSONDecodeError:
            continue  # 書き込み途中の行や壊れた行は飛ばす
        t, ts = d.get('type'), d.get('timestamp')
        if not ts:
            continue
        at = datetime.fromisoformat(ts.replace('Z', '+00:00'))
        if t not in latest or at > latest[t]:
            latest[t] = at
for t, at in sorted(latest.items(), key=lambda kv: kv[1]):
    print(f'{t:20} {at.isoformat()}')

手元の transcript を集計すると、type には user と assistant のほかに queue-operation(積まれたメッセージ)や attachment などがあり、ファイルの更新時刻はそのどれでも動きます。このファイル形式は公開仕様ではないので、版が変われば変わりうる前提で使っています。

測るタイミングでも誤りました。呼びかけた直後に測ると、相手が起き上がる前の値を取ります。9/16 には「02:18 以降 assistant なし」と測ったセッションが、その2分後には応答を書いていました。

いまは、引き取ってよい条件を「相手が明示的に手放した」「相手が呼びかけに応答して了承した」「私自身が相手の画面を見て終わっていると確かめた」の3つに限っています。transcript の時刻は、呼びかけるかどうかを決める材料にだけ使います。

横取りを止める仕組みと、その限界

9/15 のあと、作業を「レーン」(このときは訳す言語)に分け、占有を lock ファイルで持つようにしました。

  • 占有を扱う小さな CLI(claim / beat / release / status)。古い lock を自動で失効させない(古さは死の証拠にならない、がこの事故の教訓なので)
  • ツール実行前のフック(PreToolUse)で、他セッションが持つレーンへの書き込み(Write / Edit と、Bash の cp・mv・tee・リダイレクト)と、そのレーン向けのサブエージェント起動を拒否します。本命は後者です。翻訳の費用はエージェントを起動した時点でほぼ決まるので、書き込みの段階で止めても遅い
  • フック自体が壊れたとき(例外・JSON 不正・lock の破損)は通します(fail-open)。検査が原因で全部の作業が止まるほうが害が大きいと判断しました

ただ、査読でこの仕組みの穴がはっきりしました。コードを読んで指摘どおりだと確かめ、直しました。

穴 直す前 いま
lock の取得が原子的でない。「lock が無いことを読む」と「lock を書く」が別の操作 2つのセッションに同時に書かせる試験で、50回中49回、両方が通った 読む→判断→書くを、Python の fcntl.flock で取る排他ロックの中で行う。lock は一時ファイルに書いてから置き換える。同じ試験で50回中0回(排他が効くのは通常経路だけ。ロックを5秒で取れないときは lock を読んで他人のものなら止め、空きなら登録せずに通す。例外や lock の破損も通す。50回の値はこの条件での一実測で、競合の確率ではない)
強制解放に理由が要らない。他人の lock を release --force だけで消せ、その後は普通に claim できた 理由の記録なしに奪えた release --force にも理由を必須にし、強制操作は lock 本体ではなく追記専用の監査ログに残す。ログを先に書き、書けなければ操作しない
Bash の書き込み判定が文字列の部分一致で、コマンドに出てくるパスを全部「書き込み先」扱い confirm を rm と読み、perl -pi を拾わず、cp <訳文> /tmp/ のコピー元(読むだけ)でも止めた 字句解析して書き込み先だけを見る(リダイレクト先・cp の宛先・rm の対象・sed -i の対象など)。heredoc の本文はデータなので除く。ただし終端語が引用されていない heredoc の本文ではコマンド置換が実行されるので、$( があれば解析失敗として粗い判定に戻す
lock の置き場所が作業ツリーの中(git の管理外のフォルダ) git clean -X で監査ログごと消えうる 据え置き。作業ツリーの外へ移す版を作ったが、古いコミットのまま残っている worktree の旧フックは新しい場所を見ないので、同じレーンを二重に取れてしまう(査読で指摘)。移すのは全部が新版になってから

それでも、これは「うっかり」を止める best-effort の仕組みです。Python や Node の中からの書き込み、スクリプトの中の操作は見えません。サブエージェントの起動を止める判定も、プロンプトの中の絶対パスと「出力は〜」という言い回しを見ているだけです。対象も決まった命名規則の翻訳ファイルだけで、#8 で巻き込んだような周辺のファイルは守っていません。フックが壊れたときは通します。同時に走る2つのセッションを確実に分けたいなら、worktree で分けることになります。

なぜ git worktree で隔離しないのか

素直な答えは「セッションごとに git worktree を切る」です。worktree は作業ツリーと index と HEAD をセッションごとに分けるので、上の git の事故はほぼ起きなくなります。Claude Code にも、サブエージェントを worktree で隔離して動かす指定があります。

それでも常用していないのは、スキル(作業手順書)がメインの作業ツリーを指す固定のパス(~/リポジトリ名 や $HOME/リポジトリ名 の形)を持っているからです。2026年9月25日時点で、スキル19ファイルに77箇所あります(grep -rhoE '(~|\$HOME|\$\{HOME\})/<リポジトリ名>' <スキルのフォルダ> | wc -l で数えた値)。worktree で作業しても、スキルが呼ぶスクリプトはメインの作業ツリーのものが走ります。「worktree で直したのに挙動が変わらない」という別の混乱を生むので、パスを相対化するまでは常用しない、と判断しました。

使っている場面はあります。並列で書き込むサブエージェントを起動するときは worktree で隔離し、読み取りだけなら共有ツリーのまま動かしています。

別の index ファイルを使う方法(GIT_INDEX_FILE)は、根本策ではありません。分かれるのは index だけで、作業ツリーとブランチの先端は共有のままです。しかも、古い HEAD を基準にした別 index のまま、他のセッションが先にコミットした後で普通に commit すると、先に入った変更を打ち消すコミットを作りうる、と査読で指摘されました(私は試していません)。

一般化: 並走させる前に、共有物を数える

共有物 起きうること いまの防御と、その限界
git の index 他人の stage を自分の commit が拾う/自分の stage を他人の commit が拾う pathspec 形・add を挟まない。一括 add と commit -a はフックで拒否。pathspec なしの commit -m は「実行前の stage が、同じコマンドで add するものだけ」のときだけ通す(文字列から読む best-effort)
作業ツリーの同じファイル・同じフォルダ pathspec の中に他人の編集が入る ファイルを列挙する・直前に git diff HEAD -- <ファイル>。確認は補助でしかない
ブランチの先端(HEAD) 他人のコミットを自分のものとして確認・巻き戻す 控えた OID で確認する。巻き戻しは先端かつ未 push のときだけ
自動 push 巻き込んだコミットが外に出る。検査と push の間のコミットが検査を受けない 検査した OID だけを、main がその OID のままのときだけ push する。直前の巻き戻しは push の後に検出するだけ。巻き込みそのものの防御は commit まで
クリップボード コピーから貼り付けの間に上書きされる 経由しない・送信前に全文照合
「相手は終わったか」という判断 止まっているだけの相手の作業を上書きする 引き取りは明示の手放し・了承・目視だけ。lock は排他ロックで取るが、フック全体は best-effort

#6 を除けば、どれも1セッションなら起きない事故です。セッションを増やすと、増えるのは作業量だけではありません。共有物の数と、同時にそれを触る相手の数も増えます。並走を始める前に、この表の自分の版を書いておくことを勧めます。そして、防御を入れたら、それが何を守らないかも一度誰かに読んでもらうことを勧めます。私の場合、この記事の初回の査読で、手順の誤り2つ(HEAD を付けない git diff -- を使っていたことと、HEAD で確認していたこと)と、仕組みの穴4つ(一括 add のフック・自動 push・lock の原子性・強制解放)が見つかり、その後の査読で Bash の書き込み判定と lock の置き場所の穴も見つかりました。仕組みの穴は、どれも「動いている」ことは確かめていて、「何を通してしまうか」は試していなかったものです。直すほうも一度では済まず、フックとロックの修正は別の AI の査読を16回通しました。それでも終わりませんでした。8回目からは「直したつもりの新版が、旧版なら止めた操作を通す」後退が毎回1〜10件出て、件数は減りませんでした(time -p cp のように命令の前に付くオプション、失敗した cd の後、行末の \ で分かれた heredoc の終端語、env -iC DIR のように束ねたオプション、--pathspec-from-fi のような省略形など)。原因は設計にありました。旧版は「コマンドの文字列に cp や絶対パスが含まれるか」という粗い判定で、私はそれを「シェルを字句解析して書き込み先だけを見る」精密な判定に置き換えていました。精密にした分だけ、シェル文法の隅を突かれる面積が増え、査読は毎回新しい隅を見つけます。ガードは209行から891行に膨らみ、それ自体がバグの温床になりました。

最後に取った手は、精密な判定を旧版の置き換えではなく追加にすることです。旧版の粗い判定を毎回そのまま走らせ、その上で新しい解析も走らせ、どちらかが止めれば止める。「旧版より弱くならない」をテストで確かめるのではなく、構造で保証します。代償は旧版の誤検知(cp <訳文> /tmp/ のような読むだけの操作を止める)が戻ることだけで、それは10日間そのまま運用していたものでした。最初からこの形にしていれば、査読は8回で済んでいたはずです。シェルのコマンドを文字列から読む以上、例外は尽きません。途中からは「コミット前に直すもの」と「既知の限界として書いておくもの」を分けてもらい、後者はコードのコメントとこの記事に残しています。

出典(2026年9月25日確認)

  • git: git help commit(git 2.54.0・Apple Git-157)の DESCRIPTION(コミットする内容の指定方法の3番目)、-o, --only、<pathspec>...。git diff の HEAD 指定の有無による比較対象の違いは git help diff
  • Claude Code: Hooks リファレンス(https://code.claude.com/docs/en/hooks)の transcript_path の説明。手元は 2.1.282。transcript の type は手元のファイルを集計した値で、公開仕様ではありません

※ この記事は Zenn に投稿した同名記事(https://zenn.dev/masamitsu_sera/articles/f60337c10c43db )の転載です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?