Skip to content

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.

MethodWhat enforces itWhen it runsOther agents
CLAUDE.md or AGENTS.mdNothing: Claude reads itWhen Claude follows itAGENTS.md only
PreToolUse hookClaude CodeBefore a matching commandNo
Stop hookClaude CodeWhen Claude finishesNo
Git pre-push hookGitOn every git pushYes, and people too

Say it in CLAUDE.md

Start here, since a blocked push wastes a turn:

CLAUDE.md
## 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:

.claude/settings.json
{
  "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"
          }
        ]
      }
    ]
  }
}
.claude/hooks/test-before-push.sh
#!/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
fi
chmod +x .claude/hooks/test-before-push.sh

This 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 if filter is best-effort. A pattern such as Bash(git push *) also runs the hook on commands that use $() or $VAR. In the test, it ran on echo $(date), which would have blocked that command while the tests were failing, and it didn't run on ls.
  • Set onFailure to block. 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 timeout above your suite's time. The default is 600 seconds. With onFailure set to block, 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:

.claude/settings.json
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tests-before-stop.sh",
            "timeout": 1800
          }
        ]
      }
    ]
  }
}
.claude/hooks/tests-before-stop.sh
#!/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
fi
chmod +x .claude/hooks/tests-before-stop.sh

Weigh three things before you use one:

  • It sends Claude back once. The stop_hook_active check 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:

.git/hooks/pre-push
#!/bin/sh
npm test
chmod +x .git/hooks/pre-push

Git 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 init writes a skill Claude Code reads, a block in AGENTS.md and a pre-push hook.
  • 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 run records it for these exact files, and CI skips the check. A Stop hook 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.

Markdown