It happened the week I was moving Firebase in my wallpaper app from CocoaPods to Swift Package Manager. I'd handed Claude Code the whole stretch — drop the Podfile, rewrite the package dependencies, get the build green — and the first line of my CLAUDE.md said, in plain words, that commits and pushes were mine to make and that changes should stay in the working tree. The next morning, before even checking the build, I ran git log out of habit. There was a commit I hadn't made.
The message was tidy. The diff wasn't broken. But the one step I'd reserved for myself — reading the diff with my own eyes before it enters history — had been completed without me. My stomach sank a little.
I'm clearly not alone. The Claude Code repository has several issues titled some version of "committed without confirmation despite being explicitly told not to," closed as duplicates and reopened again. My first reaction was the same as most people's: I made the instruction bold, added "never," and explained why. A few days later it happened again, and that's when I changed my approach.
What I'd most like to pass on is this distinction: an instruction is something that gets read, not something that takes effect. If I want commits stopped, the fast, reliable, and frankly calming route is to put a mechanism in place that stops them. Below is the order I now follow — undo, diagnose, deny rule, hook, and what to try when none of that bites.
Recognizing the symptom: undo first, then confirm who committed
A commit isn't lost work, so there's no rush. If it's the most recent one, a single line removes it from history while leaving the changes staged in your working tree.
# Undo the last commit, keep the changes staged
git reset --soft HEAD~1
git status --shortNext, confirm that Claude Code actually made it. On my machine Claude Code runs with my own git identity, so the author field tells me nothing. What helps is lining up the commit timestamp against when the session was running, plus the reflog.
git log -3 --format='%h %ad %an | %s' --date=iso
git reflog -5If the reflog shows a commit: entry at a time the session was active, that's your answer. Automatic rebases and stashes can also make history look like it moved, so I'm careful not to confuse those rebase or stash lines with a real commit.
Narrowing the cause: which layer was supposed to stop it
At this point I did an inventory of what stops what. Claude Code gives you three layers — the instructions you write, the permissions you configure, and hooks that run before a tool executes — and each one stops a different kind of thing.
| Layer | What it stops | What it can't stop |
|---|---|---|
| CLAUDE.md instruction | The model's judgment (reads and complies) | Interpretation drift, oversights in long sessions |
| permissions.deny | Command text matching the rule's shape | The same operation written another way |
| PreToolUse hook | Command text checked by your own logic | Operations that never appear in command text |
| Sandbox | Reaching the filesystem or network at all | — |
I had been leaning on the top layer alone. The CLAUDE.md text is read, reliably — but when a judgment like "the build passed, so let me record this as a checkpoint" slips in, the text can lose to that judgment. I've come to see this less as a model defect and more as the nature of a promise delivered in prose.
The two lower layers don't involve judgment. If the command text meets the condition, it stops, no matter the reasoning. Operations I want stopped should be blocked, not read about. That sentence replaced the paragraph in my CLAUDE.md.
Fix 1: add one line to permissions.deny
The first thing I added was a deny rule, in the project's .claude/settings.json (or .claude/settings.local.json if you don't want to share it with a team).
{
"permissions": {
"deny": [
"Bash(git commit *)",
"Bash(git push *)"
]
}
}With that, git commit -m "..." is refused before it runs and Claude Code is told it was denied. According to the official docs, deny and ask rules apply to any subcommand of a compound command, so cd ios && git commit -am wip is still stopped even with the cd in front. The same goes for commands nested in a subshell or $().
In my case, though, this wasn't the end of it — and the reason was a habit of my own.
For my website work I write commits as git -c user.email=... -c user.name=... commit -m "..." so that no git identity is left behind in the environment, and I'd put that exact form in CLAUDE.md as the procedure. Claude Code imitated it faithfully, with the -c options in between. Bash(git commit *) only matches text where commit comes right after git, so the -c form passed straight through.
The docs don't hide this. Their table shows that Bash(git push *) stops git push origin main but not git -C . push origin main or git -c push.default=current push origin main. A deny rule covers the invocation Claude usually produces, they say, and isn't a security boundary around the program.
One more detail: writing the rule against the argument name, as in Bash(command:git commit *), is ignored because it can be bypassed with a compound command — Claude Code emits a startup warning instead. I wrote it that way once and saw the warning.
Fix 2: read the whole command text in a PreToolUse hook
To cover the other spellings too, you put a hook in front that inspects the full command text with your own logic. PreToolUse runs right before a tool executes and receives the command as JSON on stdin.
What this solves, stated up front: "no matter how many options sit between git and the verb, if the verb is commit or push, deny it."
#!/bin/bash
# .claude/hooks/no-commit.sh
# Find git <options…> commit / push anywhere in the command text and stop it
COMMAND=$(jq -r '.tool_input.command // empty')
[ -z "$COMMAND" ] && exit 0
if echo "$COMMAND" | grep -Eq '(^|[^[:alnum:]_./-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+(commit|push)([[:space:]]|$)'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Commits and pushes are done by a human here. Leave the diff in the working tree and do not run this command."
}
}'
fi
exit 0Don't forget chmod +x .claude/hooks/no-commit.sh. On the settings side, bind it with a Bash matcher.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/no-commit.sh" }
]
}
]
}
}Why it's written this way: the middle part of the regex, ([[:space:]]+-[^[:space:]]+(...)?)*, swallows any run of options like -c key=value or -C path. That's what lets one pattern catch git commit, git -c user.email=a@b.c commit, and git -C . push alike. The permissionDecisionReason text is handed to Claude Code verbatim, so stating the reason and the expected next behavior in one sentence made repeat attempts noticeably rarer for me.
You can test the hook without starting Claude Code — just feed JSON to stdin.
for c in 'git commit -m "fix"' \
'git -c user.email=a@b.c -c user.name=x commit -m "x"' \
'cd ios && git commit -am wip' \
'git -C . push origin main' \
'git status && git diff --stat' \
'git log --oneline -3'; do
printf '%-55s -> ' "$c"
jq -n --arg c "$c" '{tool_name:"Bash",tool_input:{command:$c}}' \
| .claude/hooks/no-commit.sh \
| jq -r '.hookSpecificOutput.permissionDecision // "pass"'
doneOn my machine the first four come back deny and the last two pass. In fairness I should add that echo "git commit is mine" also comes back deny, because the string appears inside quotes. The hook reads the command text; it doesn't understand meaning. I've chosen to treat that as an error on the safe side rather than a flaw.
That's layers two and three. A command wrapped as bash -c '...' is still read as text, so it's stopped too. What the hook can't see is an operation that never appears in the command text — writing a script to a file and then running it, for example. If I ever need to fence that in, the bottom row of the table, sandboxing, is the next step.
When it still isn't stopped
If the deny rule and the hook are in place and a commit still goes through, I check these in order.
- Does
/hooksshow the registration? A settings file in the wrong location, or one extra brace in the JSON, has been the most common cause for me. - Execute permission and jq. Is
chmod +xmissing? Doeswhich jqresolve? Without jq the hook produces no output and ends up letting the command through. - Try in a fresh session. Right after changing settings, I close the session and reopen it before testing — that habit has saved me from chasing ghosts.
- Is it written in yet another form?
/usr/bin/gitorsh -cwould call for revisiting the leading character class in the regex, or moving on to sandboxing.
When the hook itself needs debugging, I've written up the procedure in Debugging Claude Code Hooks in Production — Where to Start When Logs Are Missing.
Diffs to Claude, history to me
Looking back, what I wanted wasn't "a Claude that doesn't commit" but "a working environment where committing isn't possible." The first depends on luck every time; the second on one configuration.
Claude makes the diff; I write it into history. Since drawing that line, the small dread of opening git log in the morning has gone. The few minutes it takes to read a diff and commit by hand feel, if anything, like a cheap price for being able to hand over more.
I'd suggest starting with just the one line — Bash(git commit *) in .claude/settings.json — and checking git log the next morning. If a form slips through, that's the moment to add the hook. For deciding which event a hook belongs on and what it should return, I've gathered my reasoning as an indie developer in Claude Code Hooks: A Field Guide to the 8 Core Hooks — and the 33-Event Catalog They Now Live In.