`git add -A` を禁止するフックを書いた|AIを2セッション同時に走らせたら互いのファイルを巻き込んでいた

`git add -A` を禁止するフックを書いた|AIを2セッション同時に走らせたら互いのファイルを巻き込んでいた

git add -A(変更したファイルを全部まとめてステージする書き方)は、一人で一つの作業をしているうちは何の問題も起こしません。運営者も長いことこれで済ませていました。

壊れたのは、同じリポジトリをClaude Codeの2セッションで同時に走らせた日でした。片方がコミットしたとき、もう片方が書きかけていたファイルが一緒に入っていたのです。しかも双方向に、2件。

🧭 この記事の前提 運営者は1つのリポジトリに対して、Claude Codeのセッションを2つ同時に開いて別の作業をさせています(片方は記事の書き換え、片方は計測スクリプトの実装、というように)。
困ったのは、両方のAIが git add -A を使っていたため、コミットが「どちらの作業か」を表さなくなったことです。ファイルは消えませんが、レビューも巻き戻しもできなくなります。
持ち帰れるもの:AIに git を触らせている人向けに、禁止事項を文章ではなくフックで止める実物を出します。1リポジトリを1セッションでしか触らない人には、今は不要です。

📌 先に結論 「全部ステージしないでね」とお願いしても直りません。片方が守っても、もう片方が守らなければ同じ事故が起きるからです。
だから PreToolUse フックで、git add -Agit commit -aコマンドが実行される前に機械的に拒否しました。判定は正規表現1行ずつ、全文で40行ほどです。

何が起きたか:実際に混入した2コミット

まず事故のほうから。git show --stat で見ると一目瞭然でした。

1件目 — 8b04e04

コミットメッセージは「GA4に計測設定を入れ、ルーティンを設定済み前提に更新」。計測まわりの作業です。ところが中身はこうなっていました。

agents/analyst-daily.md           |   9 +-
docs/14-analytics-requirements.md |  58 ++++++++++
state/keyword-feasibility.md      | 231 ++++++++++++++++++++++++++++++++++++++
state/metrics/daily/2026-08-19.md |  34 ++++++

3行目の state/keyword-feasibility.md 231行は、別セッションがやっていたキーワードのSERP調査です。計測設定の話とは一切関係ありません。しかもこのコミットで一番行数が多いのがそれです。

2件目 — 4ae0d26

今度は逆向きです。コミットメッセージは「奥行き・高さの2記事を、一般形から『勝てるクエリ』に寄せた」。記事のリライトです。

docs/10-handoff-checklist.md               |  34 ++++
scripts/ga4-daily.js                       | 294 +++++++++++++++++++++++++++++
site/content/posts/desk-hirosa-okuyuki.mdx | 128 ++++++++-----
site/content/posts/desk-takasa-tekisei.mdx | 155 +++++++++------

scripts/ga4-daily.js 294行——1件目で被害者だったセッションが書いていた新規スクリプトが、今度は加害者側のコミットに丸ごと入りました。お互いがお互いを巻き込んでいたわけです。

⚠ この事故の性質 ファイルは1バイトも失われません。だから当日は誰も気づきません。
壊れるのは履歴の意味です。「記事のリライトを取り消したい」と思って 4ae0d26 を revert すると、無関係な294行のスクリプトも一緒に消えます。差分レビューも、294行のノイズの中から記事の変更を探す作業になります。

これは「AIが危険」という話ではない

念のため書いておきます。git add -A で他人の作業を巻き込むのは、複数人が同じ作業ツリーを共有していれば人間でも普通に起きる事故です。AIが特別に雑なわけではありません。

違うのは速度と頻度だけです。人間なら1日に数回しかコミットしませんが、AIは作業を区切るたびにコミットします。しかも2セッションが並行しているので、衝突する窓が常時開いている。人間なら「今ちょっと待って」と声をかけられる場面で、AIには声が届きません。

つまり対策の方向は「AIを信用しない」ではなく、人間が同じ環境でやっても事故る構造を、環境ごと直すです。

なぜ「CLAUDE.mdに書く」で足りなかったか

最初にやったのは、当然ながら指示ファイルへの追記でした。

- `git add -A` / `git add .` / `git commit -a` を使わない。
  自分が変更したファイルだけをパス指定でステージする。

これは効きます。効きますが、確率的にしか効きません。 指示ファイルは毎回読まれる前提の約束事であって、実行を止める仕組みではないからです。約束が2セッションぶん同時に守られ続ける保証はありません。

そして運営者はもともと、これと同じ結論を別の場所に書いていました。

守られない指示が続いたら、それは書き方の問題ではなく量が多すぎるサイン。剪定するか、フックに移す。

指示ファイルが劣化して嘘を引き継ぐ話はCLAUDE.mdは書いた瞬間から劣化するに書きました。文章で守らせるものと、仕組みで止めるものを分ける——今回はその線引きを実行しただけです。

書いたフック(全文)

PreToolUse は、Claudeがツールを実行する直前に呼ばれるフックです。ここで「拒否」を返すと、コマンドは実行されません。

.claude/hooks/block-stage-all.js の実物がこれです。省略していません。

#!/usr/bin/env node
/**
 * `git add -A` のような「全部ステージ」を止める PreToolUse フック。
 *
 * なぜ必要か(2026-08-20):
 * このリポジトリは複数のClaude Codeセッションが同時に触る。両方が `git add -A` を
 * 使っていたため、お互いの作業中ファイルを巻き込んでコミットしていた。
 *   - 8b04e04 に別セッションの state/keyword-feasibility.md が混入
 *   - 4ae0d26(別セッションの記事リライト)に scripts/ga4-daily.js が混入
 * ファイルは失われないが、履歴が「どの変更がどの作業か」を表さなくなり、
 * レビューも巻き戻しもできなくなる。
 *
 * 「気をつける」では直らない(両方のセッションが同じ約束を守る保証がない)ので、
 * 決定的に止める。CLAUDE.md の「守られない指示は hook に移す」に従う。
 *
 * 出力: 該当したら deny のJSONを返す。該当しなければ何も出さない(=許可)。
 */

let input = '';
process.stdin.on('data', (d) => { input += d; });
process.stdin.on('end', () => {
  let cmd = '';
  try {
    cmd = (JSON.parse(input).tool_input || {}).command || '';
  } catch {
    process.exit(0); // 解析できないときは黙って通す。フックで作業を止めない
  }

  // git add -A / --all / .    と    git commit -a / -am / --all
  const stageAll = /git\s+add\s+(-A|--all|\.)(\s|$)/;
  const commitAll = /git\s+commit\s+(?:[^\n;|&]*\s)?(-a|-am|-ma|--all)(\s|$)/;

  if (!stageAll.test(cmd) && !commitAll.test(cmd)) process.exit(0);

  const reason = [
    'このリポジトリでは全部ステージ(git add -A / . / commit -a)を禁止しています。',
    '',
    '理由: 複数のClaude Codeセッションが同時にこのリポジトリを触るため、',
    '全部ステージすると他セッションの作業中ファイルを巻き込んでコミットします。',
    '実際に 8b04e04 と 4ae0d26 で発生しました。',
    '',
    '対処: 自分が変更したファイルだけをパス指定でステージしてください。',
    '  git add <path1> <path2> && git commit -F <tmpfile>',
    '',
    '自分の変更を確認するには git status --short を先に実行してください。',
  ].join('\n');

  process.stdout.write(JSON.stringify({
    hookSpecificOutput: {
      hookEventName: 'PreToolUse',
      permissionDecision: 'deny',
      permissionDecisionReason: reason,
    },
  }));
});

読み方のポイントは4つです。

1. 入力は標準入力にJSONで来る

実行しようとしているコマンドは tool_input.command に入っています。ここだけ取り出せば判定できます。

2. パースに失敗したら黙って通す

} catch {
  process.exit(0); // 解析できないときは黙って通す
}

ここは意図的です。フックが壊れたときに作業全体が止まるほうが害が大きいと判断しました。止めたいのは特定の1コマンドであって、開発そのものではありません。

3. 何も出力しなければ「許可」

該当しなければ process.exit(0) で終わります。JSONを出すのは deny するときだけです。

4. 拒否は permissionDecision: 'deny' で返す

permissionDecisionReason に書いた文字列が、そのままClaudeに読まれます。だからここはClaudeへの指示書として書きます。後述します。

正規表現が何を止めて、何を通すか

肝はこの2行です。

const stageAll  = /git\s+add\s+(-A|--all|\.)(\s|$)/;
const commitAll = /git\s+commit\s+(?:[^\n;|&]*\s)?(-a|-am|-ma|--all)(\s|$)/;

commitAll のほうにある (?:[^\n;|&]*\s)? は、git commit-a の間に他のオプションが挟まっていても捕まえるためのものです。git commit -m "msg" -a のような並びを逃さない一方で、; | & を含まないことで別のコマンドまで巻き込んで誤判定しないようにしています。

そして重要なのは通すほうです。

コマンド判定理由
git add -A⛔ deny全部ステージ
git add .⛔ deny同上
git add --all⛔ deny同上
git commit -a⛔ denyステージを飛ばして全部コミット
git commit -am "msg"⛔ deny同上
git add site/content/posts/foo.mdx✅ 許可パス指定。これが正しい形
git add ./scripts/a.js ./scripts/b.js✅ 許可複数でもパス指定なら通る
git commit --amend✅ 許可-a ではない。ステージ範囲を広げない
git commit -m "msg"✅ 許可ステージ済みのものだけ
git status --short✅ 許可読み取り
git add -p✅ 許可むしろ推奨したい形
git diff --staged✅ 許可読み取り

この12ケースを実際に流して、deny が5件・許可が7件になることを確認してから運用に入れました。特に神経を使ったのが --amend です。文字列として -a を含むので、雑な正規表現だと巻き込みます。末尾の (\s|$) が効いていて、--amend-a の直後が m なのでマッチしません。

📌 フックを書いたら必ず「通るはずのもの」を試す 止めたいものが止まるかより、止めてはいけないものが誤爆しないかのほうが事故ります。誤爆したフックは、毎回の作業を静かに邪魔し続けます。

検証を「やったつもり」で終わらせない考え方はAIの作業を検証で閉じるにまとめています。

登録の仕方(settings.json)

.claude/settings.json にこう書きます。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "if": "Bash(git *)",
            "shell": "bash",
            "command": "node \"C:/Users/<ユーザー名>/<リポジトリ名>/.claude/hooks/block-stage-all.js\"",
            "timeout": 10,
            "statusMessage": "ステージ範囲を確認中"
          }
        ]
      }
    ]
  }
}
  • matcher: "Bash" で、Bashツールの実行前だけに絞る
  • if: "Bash(git *)" で、さらに git から始まるコマンドだけに絞る。無関係な npm run build のたびにNodeを起動しないため
  • timeout: 10 は保険。フックが固まっても10秒で諦める
  • statusMessage は実行中に画面へ出る文字。何が起きているか分かるようにしておく

Windowsですが shell: "bash" を指定しています。パスは絶対パスで、フォワードスラッシュです。

⚠ 落とし穴:書いた直後は効かない

ここが一番ハマったところです。

settings.json を編集しても、すぐには反映されません。 Claude Codeが設定ファイルの変更を検知して読み直すまで、フックは存在しないのと同じ状態です。

つまり、書いた直後に git add -A を試すと普通に通ってしまう。ここで「正規表現が間違っているのか」と疑ってフックを直し始めると、正しいコードを壊す方向に修正してしまいます。実際、運営者は一度それをやりかけました。

⚠ フックが効かないときに最初に疑うこと コードではなく読み込みのタイミングを先に疑ってください。設定を書き換えたら、いったん置いてから試す。それでも駄目ならセッションを開き直す。「反映されていないだけ」を「動かない」と誤診しない。

これは以前、計測イベントが届かない原因を「サイトのバグ」と断定して外した誤診と同じ構造です(実際はブラウザ拡張が原因でした)。観測できないことと、存在しないことは違います。

deny のときにAIが読むメッセージ

最後にここです。フックが拒否したとき、Claudeが実際に受け取る文面がこれです。

このリポジトリでは全部ステージ(git add -A / . / commit -a)を禁止しています。

理由: 複数のClaude Codeセッションが同時にこのリポジトリを触るため、
全部ステージすると他セッションの作業中ファイルを巻き込んでコミットします。
実際に 8b04e04 と 4ae0d26 で発生しました。

対処: 自分が変更したファイルだけをパス指定でステージしてください。
  git add <path1> <path2> && git commit -F <tmpfile>

自分の変更を確認するには git status --short を先に実行してください。

「拒否しました」だけを返すのはもったいないです。フックのメッセージは、AIが必ず読む数少ないタイミングだからです。

3つを必ず入れています。

入れるものなぜ
禁止の内容何が引っかかったのかを一意に伝える
理由(実例つき)理由が無いと、AIは別の抜け道を探して同じことをやる
代わりにやるべきコマンドこれが無いと止まるだけで前に進まない

特に効いたのが3行目の代替コマンドです。git commit -F <tmpfile> を明示しているのは、日本語のコミットメッセージを -m でシェルに渡すと環境によって文字化けするためで、ここも過去に一度やらかしています。止めるついでに、正しい型を毎回教え直している格好です。

理由を書かないと抜け道を探される、というのはAIの性質というより、指示の書き方の問題です。この感覚はAIへの指示の実務でも同じでした。

どこまで止めて、どこから止めないか

同じ発想で、ステージ以外にも危ないものがあります。運営者は次を「文章での禁止」に留めています。

  • git reset --hard
  • git checkout .(パス指定なし)
  • git stash(パス指定なし)

これらは他セッションの未コミット作業を消します。事故の重さでいえば git add -A より上です。それでもフックにしていないのは、まだ一度も踏んでいないからです。

判断の基準は一つにしています。

一度実際に起きた事故だけを、フックに昇格させる。

想像で禁止事項を増やすと、通したいコマンドまで塞いで、結局フックごと外すことになります。増やすタイミングは「2回目が起きたとき」で十分間に合います。

止める対象の考え方そのものはClaude Codeのスキルとセキュリティにも通じます。許可を渋るのではなく、戻せないものだけを確実に止めるという方針です。

まとめ

  • 2セッションが同じリポジトリで git add -A を使い、双方向に他方の作業ファイルを巻き込んだ8b04e04 に231行の調査ファイル、4ae0d26 に294行のスクリプト)
  • ファイルは失われないが、履歴が「どの変更がどの作業か」を表さなくなる。revert もレビューもできない
  • これはAI固有の事故ではなく、同じ作業ツリーを共有すれば人間でも起きる。違うのは頻度だけ
  • 「気をつける」は片方しか守らない可能性があるので、PreToolUseフックで決定的に止めた
  • 通したいもの(--amend・パス指定・git add -p)を12ケースで先に確認してから入れる
  • 設定は即時反映されない。 効かないときはコードより先に読み込みのタイミングを疑う
  • deny のメッセージには理由と代替コマンドまで書く。止めるだけでは前に進まない

複数セッションを同時に走らせること自体のメリットと、セッションをまたいで文脈を渡す方法はAIにAIを引き継ぐ設計に書きました。Claude CodeがそもそもどんなツールかはClaude Codeとは何かからどうぞ。