Make Claude Code run tests before it pushes
Updated
To make Claude Code run your tests before every push, block the push with a hook. Instructions in CLAUDE.md help, but Anthropic's docs say Claude treats them as context, not enforced configuration. A hook runs whether or not Claude remembers.
| Method | What enforces it | When it runs | Other agents |
|---|---|---|---|
CLAUDE. or AGENTS. | Nothing: Claude reads it | When Claude follows it | AGENTS. only |
PreToolUse hook | Claude Code | Before a matching command | No |
Stop hook | Claude Code | When Claude finishes | No |
Git pre- hook | Git | On every git push | Yes, and people too |
Say it in CLAUDE.md
Start here, since a blocked push wastes a turn:
## Before you push
Run `npm test` and fix what fails before `git push`.Block the push with a hook
A PreToolUse hook runs before a tool call, and exit code 2 blocks the call. Commit this to the repository, so everyone who works in it gets it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(git push *)",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/test-before-push.sh",
"timeout": 1800,
"onFailure": "block"
}
]
}
]
}
}#!/bin/sh
# Without jq the check below can't read the command, so refuse.
command -v jq >/dev/null || { echo "This hook needs jq." >&2; exit 2; }
# The hook's `if` filter is best-effort, so check the command here too.
case "$(jq -r .tool_input.command)" in
*"git push"*) ;;
*) exit 0 ;;
esac
# Exit code 2 blocks the push, and Claude reads what's on stderr.
if ! npm test >&2; then
echo "Tests failed: fix them, then push again." >&2
exit 2
fichmod +x .claude/hooks/test-before-push.shThis setup was tested on Claude Code 2.1.316 on November 1, 2026. With a failing test, it blocked git push, git push origin main, npm test && git push and FOO=bar git push, and let other commands through. With a passing test, the push went ahead. With jq missing, it refused.
Three details decide whether it holds:
- Check the command in the script. The
iffilter is best-effort. A pattern such asBash(git push *)also runs the hook on commands that use$()or$VAR. In the test, it ran onecho $(date), which would have blocked that command while the tests were failing, and it didn't run onls. - Set
onFailuretoblock. Without it, a hook that crashes, times out or has a wrong path lets every push through. It needs Claude Code 2.1.295 or later. - Set
timeoutabove your suite's time. The default is 600 seconds. WithonFailureset toblock, a timeout blocks the push, so a suite that takes longer can never push.
Anthropic's docs point to the permission system for a hard deny, because if is a filter and not a boundary. That's a reason to add a Git hook as well.
Send Claude back if tests fail
A Stop hook runs when Claude finishes, and exit code 2 sends it back to work:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-before-stop.sh",
"timeout": 1800
}
]
}
]
}
}#!/bin/sh
# stop_hook_active is true when Claude is already continuing because of this hook.
if [ "$(jq -r .stop_hook_active)" = "true" ]; then
exit 0
fi
if ! npm test >&2; then
echo "Tests are failing. Fix them before you finish." >&2
exit 2
fichmod +x .claude/hooks/tests-before-stop.shWeigh three things before you use one:
- It sends Claude back once. The
stop_hook_activecheck lets the second stop through, whether or not the tests pass. Without that check, the loop goes on until they do, or until Claude Code ends it after eight continuations in a row with no tool call between them. - It runs on every stop, even when Claude only answered a question.
- It can reward the wrong fix. In the same test, with a check that could only fail, Claude Code on its Haiku model rewrote the test so that it always passed, and said so in its report. Read changes to test files in review.
Add a Git hook as the backstop
Claude Code's hooks bind only Claude Code. A pre-push hook is Git's own, so it also covers Codex, Cursor and people:
#!/bin/sh
npm testchmod +x .git/hooks/pre-pushGit doesn't clone hooks, and git push --no-verify skips them. Run CI locally before you push covers both.
Make the hook cheap with hushrun
Every method above runs the whole suite, and then CI runs it again. hushrun changes both:
npx hushrun initwrites a skill Claude Code reads, a block inAGENTS.mdand apre-pushhook.- The hook runs no checks. It asks what CI would still run on this exact code, so a long suite can't time out a push. It refuses a push from Claude Code, Codex, Cursor or OpenCode until those checks have passed, and warns people instead of refusing.
- A pass counts everywhere.
hushrun runrecords it for these exact files, and CI skips the check. AStophook that runs a wrapped check costs nothing on code that already passed.
$ git push hushrun: push refused: CI would run test again on a0b1c2d test: no pass yet for this exact code Run these here, then push again: npm run test
Claude reads those lines, runs the command and pushes again. --no-verify still gets past the hook, and nothing is lost when it does: CI runs whatever has no pass.