Claude Code pre-commit hook

Block Claude from committing if lint or typecheck fails — a zero-config quality gate via settings.json.

Claude Code pre-commit hook

Hooks are shell commands the harness runs in response to Claude Code events. They fire deterministically — Claude can't forget to run them. Below is the gate I put on every project.

settings.json

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash -lc 'case \"$CLAUDE_TOOL_INPUT\" in *git\\ commit*) npm run lint && npm run type-check ;; esac'"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "say -v Samantha 'Done.'"
          }
        ]
      }
    ]
  }
}

How it works

Claude Code emits a named event at each step of its lifecycle, and the harness — not the model — decides what to run in response. Each entry under hooks pairs an event name with a matcher (a tool name like Bash, Write, or Edit) and a list of shell commands. When the event fires and the matcher matches, the harness runs the commands in a real shell, passing the tool's payload through the CLAUDE_TOOL_INPUT environment variable so the command can inspect what Claude is about to do.

The control flow comes from exit codes. A PreToolUse hook runs before the tool executes, so a non-zero exit aborts the call entirely; Claude receives the failure output and has to react to it rather than barrelling ahead. In the commit gate above, the case statement only triggers lint and typecheck when the command being run contains git commit, so ordinary git status or git log calls pass straight through. A PostToolUse hook runs after the tool returns, which is why it suits formatting: by the time the autoformatter runs, the file Claude wrote already exists on disk. The Stop event has no matcher because it isn't tied to a tool — it fires once when the model ends its turn.

What's happening

  • PreToolUse on Bash + git commit — before any git commit runs, lint and typecheck must pass. A non-zero exit blocks the commit; Claude sees the failure and fixes it.
  • Stop — when Claude finishes a turn, a TTS voice says "Done." so I can switch tabs without watching the spinner. macOS only; swap for notify-send on Linux.

Useful events

| Event | Fires when | |---|---| | PreToolUse | Before any tool call (matcher narrows by tool name) | | PostToolUse | After a tool call returns — perfect for autoformatting after Write | | UserPromptSubmit | When you hit enter — useful for redacting secrets from outgoing prompts | | Stop | When the model returns end_turn | | SessionStart | When a new Claude Code session begins — load env, warm caches |

A real PostToolUse autoformat

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -lc 'echo \"$CLAUDE_TOOL_INPUT\" | jq -r .file_path | xargs -r prettier --write'"
          }
        ]
      }
    ]
  }
}

Every Write or Edit call now ends with the file getting prettier'd. Claude never has to remember.

Two rules I follow

  1. Hooks are not Claude. Anything you express as "Claude should always do X" is actually a hook on the harness — Claude can drift, the runtime can't.
  2. Hooks must be idempotent and fast. A flaky 10s hook turns every tool call into a 10s wait.

Notes & gotchas

A few things worth knowing before you lean on this pattern. The matcher is matched against the tool name, not the command string, so a Bash matcher fires on every shell call — that's why the commit gate filters on the payload inside the command itself rather than trusting the matcher to be selective. If you want to gate only a subset of commands, do the narrowing in the script, as the case statement above does.

These commands are platform-specific. The say command in the Stop hook is macOS-only; on Linux you'd swap it for notify-send or another notifier, and the autoformat hook assumes jq and prettier are on PATH. Because a PreToolUse non-zero exit blocks the action, a buggy or overly strict hook can wedge the agent — if Claude suddenly can't commit or write files, suspect a hook before suspecting the model. Keep the commands cheap and deterministic: anything that touches the network or runs a full test suite on every tool call will make the whole session feel sluggish, which is the practical reason behind the "idempotent and fast" rule above.