Skip to content
Chinh Hoang
Go back

How to make Claude Code follow a rule: a PreToolUse hook that refused git commit -a 40 times

Markdown
Open in ChatGPTOpen in Claude
Contents
  1. Does this guide apply to you?
  2. What broke
  3. Why a text rule did not stop it
  4. The hook
  5. How to check that it blocks
  6. What happened after
  7. What it misses

From 15 to 26 October 2026, my coding agents tried git commit -a 40 times, and a 30-line hook refused all 40. In 36 of those cases the agent’s next command was git add with only its own files.

Does this guide apply to you?

What broke

I run several agent sessions in one checkout. On 14 October at 22:12, one of them committed a test fix with git commit -qam. Twenty of the commit’s 21 files were another session’s unfinished work. The revert went in at 22:14.

Why a text rule did not stop it

At 22:16 a memory note told agents to stage only their own files. After the note, agents still ran git commit -a twice before the hook existed. From 1 October to the hook, 58 such commands ran; none was refused.

Claude Code’s docs say why: Claude treats CLAUDE.md “as context, not enforced configuration”. A hook runs before the command and can refuse it. Make a rule a hook when a script can decide it from the command alone.

The hook

It reads the tool call as JSON on stdin and prints a deny with a reason. The agent reads the reason, so the reason names the fix. shlex splitting lets a commit message that mentions -a pass.

#!/usr/bin/env python3
import json, re, shlex, sys
def stages_all(cmd):
    cmd = re.sub(r"<<-?\s*['\"]?(\w+)['\"]?[^\n]*\n.*?\n\s*\1\s*(?=\n|$)", "", cmd, flags=re.S)
    lex = shlex.shlex(cmd.replace("\n", ";"), posix=True, punctuation_chars=True)
    lex.whitespace_split = True
    try:
        words = list(lex)
    except ValueError:
        return False
    seg = []
    for w in words + [";"]:
        if re.fullmatch(r"[;&|()]+", w):
            if "git" in seg and "commit" in seg[seg.index("git"):]:
                for opt in seg[seg.index("commit") + 1:]:
                    if opt == "--": break
                    if opt == "--all" or re.fullmatch(r"-[^-a]*a\w*", opt) and not re.match(r"-[^a]*[mFCctS]", opt):
                        return True
            seg = []
        else:
            seg.append(w)
    return False
cmd = json.load(sys.stdin).get("tool_input", {}).get("command", "")
if stages_all(cmd):
    print(json.dumps({"hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": "git commit -a stages other sessions' edits. "
            "Run git status --short, git add <your files>, git commit -m.",
    }}))

The first re.sub drops heredoc bodies, so text inside a heredoc is never read as a command.

Register it in ~/.claude/settings.json:

"hooks": {
  "PreToolUse": [
    {
      "matcher": "Bash",
      "hooks": [
        { "type": "command", "command": "python3 ~/hooks/block-commit-all.py", "timeout": 5 }
      ]
    }
  ]
}

How to check that it blocks

Feed it fake tool calls (needs jq):

$ jq -nc '{tool_name:"Bash",tool_input:{command:"git commit -qam \"fix: typo\""}}' | python3 block-commit-all.py
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "git commit -a stages other sessions' edits. Run git status --short, git add <your files>, git commit -m."}}
$ jq -nc '{tool_name:"Bash",tool_input:{command:"git commit -m \"docs: explain the -a flag\""}}' | python3 block-commit-all.py
$

An allowed command prints nothing. Then ask a new session to run git commit -am test; it must fail with your reason. Mine went in on 15 October at 09:34; a test call was refused at 09:42, the first real one at 09:51.

What happened after

Replayed over 59,263 Bash commands from my transcripts (1 to 26 October), the version above blocks the same 99 as my 165-line production hook, plus 2 where the commit follows a ).

What it misses

My production hook’s message ends with “For longer work use a separate git worktree.” A worktree has its own index, so -a there sweeps only its own edits.

My order for any rule: make it the default, else block it with a hook, else fail it in CI, and only then write it down.


Share this post:

Previous Post
How to move a static site from Netlify to Cloudflare Workers without breaking old URLs
Next Post
How to check that PHP is really patched for PHP2Shell when the version number does not change