I wrote up
a Claude Code hook that blocks the AI agent from committing or pushing to main
. It works, but it only covers one tool. Anything outside Claude Code (my own terminal, another editor, a different AI tool, CI) is untouched by it, by design.
That raised an obvious question: what’s the native, tool-agnostic version of the same guard? Git already has an answer, and it’s older than any of these tools.
Git hooks, briefly
Git runs scripts at specific points in its own lifecycle: before a commit is created, before a push leaves the machine, after a checkout, and so on. These live in .git/hooks/ by default, which is why most people never see them. That directory isn’t tracked by git, so hooks placed there don’t travel with a clone.
The fix is a single config value: core.hooksPath. Point it at a directory inside the repo, commit that directory, and every hook in it becomes shareable, the same way .claude/hooks/ worked for the Claude Code guard.
Why pre-commit, and why pre-push too
My original complaint was “discovering I’ve done all my changes to protected main only when I try to push.” That’s a call for catching the mistake as early as possible, not just before it reaches the remote. One hook alone doesn’t cover both ends.
pre-commit: catches it immediately
pre-commit runs the moment you type git commit, before the commit object even exists. Checked out on main? It rejects right there, before any work piles up on top of the mistake. This is the closest match to what I actually asked for.
Where it has gaps: pre-commit only fires when the commit machinery runs. A few paths update main without ever calling it:
- A fast-forward merge onto
maindoesn’t create a new commit, it just moves the branch pointer, sopre-commitnever sees it. git reset --hard <other-branch>rewritesmainto point elsewhere with no commit involved at all.
Both leave main in a new state that pre-commit was never asked about.
pre-push: the backstop
pre-push runs right before anything leaves the machine, regardless of how main got into its current state, commit, amend, rebase, fast-forward merge, or a hard reset. It’s not “earliest possible” the way pre-commit is, but it’s the one hook nothing slips past on the way out.
Together: pre-commit catches the everyday mistake instantly, and pre-push catches the handful of things pre-commit can’t see, at the last safe moment before they’d actually reach the remote.
The implementation
.githooks/pre-commit
#!/bin/bash
# Blocks `git commit` while checked out on main, for every git client on
# this machine. Catches the mistake immediately, before any work piles up
# on top of it. Install once per clone: git config core.hooksPath .githooks
protected_branch="main"
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
if [ "$branch" = "$protected_branch" ]; then
echo "Commit to '$protected_branch' rejected. Branch off first (e.g. git checkout -b feat/<slug>)." >&2
exit 1
fi
exit 0
Simpler than the push side: check the branch checked out right now, reject if it’s main.
.githooks/pre-push
#!/bin/bash
# Blocks `git push` to main, for every git client on this machine (not just
# Claude Code). Install once per clone: git config core.hooksPath .githooks
protected_branch="main"
while read -r local_ref local_sha remote_ref remote_sha; do
if [ "$remote_ref" = "refs/heads/$protected_branch" ]; then
echo "Push to '$protected_branch' rejected. Branch off and open an MR instead (e.g. git checkout -b feat/<slug>)." >&2
exit 1
fi
done
exit 0
Git invokes pre-push with the remote name and URL as arguments, and feeds it one line per ref being pushed on stdin: local ref, local SHA, remote ref, remote SHA. The loop checks the remote ref, not the branch checked out locally, which matters because git push origin HEAD:main can target main from any local branch. Checking the destination is the only way to catch that.
Make both executable:
chmod +x .githooks/pre-commit .githooks/pre-push
Wiring it up
git config core.hooksPath .githooks
This is local git config, not something committed to the repo. Each clone (each machine, each fresh checkout) needs to run it once. That’s the tradeoff for having the hook live as plain files instead of a package dependency like Husky manages for you.
Testing it
pre-commit just needs a branch to check:
git checkout main
git commit --allow-empty -m "hook test" # rejected, nothing gets created
git checkout <feature-branch>
pre-push’s stdin format is easy to fake directly, without touching the network:
printf 'feat/example abc123 refs/heads/main def456\n' | .githooks/pre-push origin git@gitlab.com:example/example.git
That should exit 1 with the rejection message. Swap refs/heads/main for refs/heads/feat/example and it should exit 0 silently: allowed.
For a live end-to-end check, git push --dry-run still runs the hook. Since pre-commit now blocks committing on main directly, the cleanest way to force a real push attempt at main is to push a feature branch straight at it, from wherever you’re already checked out:
git push --dry-run origin HEAD:main
That’s the same case the pre-push script exists for: the ref being pushed is refs/heads/main, no matter what branch is checked out locally, and it gets rejected.
Comparing it to the Claude Code hook
.githooks/ pair |
Claude Code PreToolUse hook |
|
|---|---|---|
| Coverage | Every git client: terminal, editors, other AI tools, CI | Only Claude Code’s own tool calls |
| Bypassable? | Yes, --no-verify skips either hook, by design |
No escape hatch; the tool call itself is blocked before git runs |
| Setup | git config core.hooksPath .githooks, once per clone |
Loads with the repo; needs one restart the first time the config file appears |
| Feedback | Plain stderr, after git has already started the operation | A structured deny reason, handed back before anything executes |
Neither replaces the other; they run in sequence. If both are wired up, the Claude Code hook fires first, before git is even invoked. The .githooks pair is the backstop for everything else, including my own terminal if I ever forget the rule myself.
The Claude Code hook was explicitly built to leave my own terminal alone. The .githooks pair doesn’t make that exception. It catches me too, which is what I wanted for the tool-agnostic layer.
Caveats
--no-verify is a real escape hatch here, by git design, available to anyone including me. This is a convention I’ve chosen to follow, not a wall. For a harder guarantee, that has to move server-side: GitLab branch rules or GitHub rulesets, covered in the
related approaches section of the Claude Code post
.