何に困ったか
複数の Claude Code セッションを、同じ1つのクローンで同時に動かしています。
CLAUDE.md には「git add -A と reset --hard と stash は使わない」と書いていました。
ただ、文章での「お願い」は、守られないことがあります。他のセッションが書きかけのファイルを git add -A で一緒にコミットしてしまう、といったぶつかりが起きました。
そこで、書くだけでなく、フックで機械的に止めることにしました。
この記事では、実際に使っているフック(止めたときの文章だけ短くしています)と、そのテスト、残っているつまずきを書きます。
環境
- Claude Code(CLI)、Python 3(手元の確認は 3.11)
- フックは
~/.claude/hooks/git-guard.pyに置き、~/.claude/settings.jsonに登録
設定: settings.json
Bash ツールを呼ぶ前(PreToolUse)に、スクリプトを動かします。
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/git-guard.py",
"timeout": 10
}
]
}
]
}
}
フックには、標準入力で JSON が渡されます。コマンドの文字列は tool_input.command に入っています。
終了コード 2 で終わると、そのツール呼び出しは止められ、標準エラーに書いた文章が Claude に返ります。
フック本体: git-guard.py
#!/usr/bin/env python3
import json
import re
import sys
data = json.load(sys.stdin)
cmd = data.get('tool_input', {}).get('command', '') or ''
# コミットメッセージなど、引用符の中の文字は調べない
body = re.sub(r"'[^']*'", "''", cmd)
body = re.sub(r'"(?:\\.|[^"\\])*"', '""', body)
body = re.sub(r'<<\s*[\'"]?(\w+)[\'"]?.*?^\1$', '', body, flags=re.S | re.M)
END = r'(?=\s|$|;|&|\||\))'
RULES = [
(r'\bgit\s+add\s+(?:\S+\s+)*?(?:-A|--all)' + END, 'git add -A / --all'),
(r'\bgit\s+add\s+(?:\S+\s+)*?\.' + END, 'git add .'),
(r'\bgit\s+commit\s+(?:\S+\s+)*?(?:-a\w*|--all)' + END, 'git commit -a'),
(r'\bgit\s+reset\s+(?:\S+\s+)*?--hard' + END, 'git reset --hard'),
(r'\bgit\s+checkout\s+(?:--\s+)?\.' + END, 'git checkout -- .'),
(r'\bgit\s+restore\s+(?:\S+\s+)*?\.' + END, 'git restore .'),
(r'\bgit\s+stash\b', 'git stash'),
(r'\bgit\s+push\s+(?:\S+\s+)*?(?:-f|--force|--force-with-lease)(?:=\S*)?' + END, 'git push --force'),
(r'\bgit\s+clean\s+(?:\S+\s+)*?-\w*f', 'git clean -f'),
]
for pat, name in RULES:
if re.search(pat, body):
sys.stderr.write(
f'止めました: {name}\n'
'共用クローンです。ほかのセッションの書きかけを消したり混ぜたりしないよう、この操作はしません。'
'add は自分が変えたファイルを名指しで、してください。\n'
)
sys.exit(2)
sys.exit(0)
ポイントは、最初の3行の re.sub です。
git commit -m "git add -A は禁止" のように、引用符の中に書いただけの文字では止まらないようにするため、引用符の中身を空にしてから調べています(ヒアドキュメントも同様)。
これを入れる前は、コミットメッセージに git add -A と書いただけで止まるつまずきがありました。
止めたときの文章には、「どうすればよいか」(ファイルを名指しで add する)も入れています。Claude がそれを読んで、回り道をせずにやり直せるようにするためです。
動作の確認
実際に、フックへ JSON を流して確かめました(リポジトリの控えのファイルで実行)。
t() {
printf '{"tool_input":{"command":%s}}' \
"$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$1")" \
| python3 config/claude/hooks/git-guard.py >/dev/null 2>/tmp/e
echo "exit=$? : $1 : $(head -1 /tmp/e)"
}
t 'git add -A'
t 'git add a.md b.md'
t 'git commit -m "git add -A は禁止"'
t 'git reset --hard HEAD'
t 'git push --force origin main'
t 'git push origin HEAD:main'
t 'git stash'
t 'git commit -a -m x'
結果は次のとおりです。
-
git add -A→ exit=2(止まる) -
git add a.md b.md→ exit=0(通る) -
git commit -m "git add -A は禁止"→ exit=0(引用符の中なので通る) -
git reset --hard HEAD→ exit=2 -
git push --force origin main→ exit=2 -
git push origin HEAD:main→ exit=0 -
git stash→ exit=2 -
git commit -a -m x→ exit=2
つまずき: 同じ行の別コマンドを誤検知する
正規表現を (?:\S+\s+)*? で緩くつないでいるため、; や && でつないだ別のコマンドの引数まで拾います。
t 'git push origin main; pkill -f foo' # exit=2 : git push --force と誤検知
t 'git add a.md; ls .' # exit=2 : git add . と誤検知
t 'git add a.md && echo -A' # exit=2 : git add -A と誤検知
pkill -f の -f が、git push の -f(force)に見えてしまいます。
止めすぎる分には安全側なので、私は直さずに受け入れました。止まったらコマンドを分けて打ちます。
-
git push origin mainとpkill -f fooを別々の呼び出しにする -
git add a.mdとls .を別々の呼び出しにする
誤検知を減らしたい場合は、;・&&・| でコマンドを分割してから、それぞれに RULES を当てる形にすると直せるはずです(私はここまでは試していません)。
今回やらなかったこと
- フックは「止める」だけで、書き換えはしません
-
git switchやgit rebaseのような他の危ない操作は、今のルールには入れていません - Windows での動作は確認していません(上の確認は Linux と macOS のシェルです)
まとめ
- ルールを CLAUDE.md に書くだけでは守られないことがある。守らせたいことは
PreToolUseフックで止める - 終了コード 2 と標準エラーの文章で、Claude に「止めた理由」と「代わりの手」を返せる
- 引用符の中を空にしてから調べると、コミットメッセージでの誤検知を避けられる
- 同じ行の別コマンドを誤検知する癖は残る。コマンドを分けて打つ
追記(2026-11-01): git -C などを挟むとすり抜けていた
コメントでご指摘をいただき、手元で確かめました。上の RULES は git の直後にサブコマンドが来る前提なので、次の3つは止まらずに通っていました。
git -C sub add -Agit -c core.x=1 add -Agit -C . push --force
git とサブコマンドの間の全体オプションを飛ばして見る部品を足し、各ルールの先頭の \bgit\s+ をこれに置き換えました。
# git と サブコマンドの間の全体オプション(-C <dir>、-c <k=v>、--git-dir=… など)も飛ばして見る
GIT = r'\bgit(?:\s+(?:-[Cc]\s+\S+|--(?:git-dir|work-tree|namespace|exec-path|config-env)(?:=\S+|\s+\S+)|--[\w-]+(?:=\S+)?|-[pP]))*\s+'
RULES = [
(GIT + r'add\s+(?:\S+\s+)*?(?:-A|--all)' + END, 'git add -A / --all'),
# ほかの行も同じように GIT + r'サブコマンド…' にする
]
直したあとの確認です(2 = 止めた、0 = 通した)。
2 git -C sub add -A
2 git -c core.x=1 add -A
2 git -C . push --force
2 git --git-dir=.git --work-tree=. reset --hard
2 git -C ~/workspace stash
0 git -C ~/workspace add projects/a.md
0 git -C x commit -q -m "add -A"
0 git -C ~/workspace log --all --oneline
もう1つ、「;・&&・| で分割してから当てる」と書いた部分です。Claude Code の権限ルール(permissions.deny)は、すでにこの分割をしていました。公式ドキュメント(Configure permissions の Bash rules の節)によると、deny と ask のルールは、&&・;・| などで連結した各サブコマンドにも照合されます。サブシェルやコマンド置換 $() の中も対象です。
一方で、同じ節には「Bash(git push *) は git -C . push origin main を止めない」とも書かれています。連結は権限ルールで、書き方の揺れはフックで止める、という分け方になります。