Claude Code pre-commit hooks git automation workflow CLAUDE.md

Claude Code + Pre-Commit Hooks: Stop Shipping Broken Code

The Prompt Shelf ·

Here is a situation that happens constantly with AI-assisted coding: Claude writes a clean-looking diff, you accept it, the file gets committed, and CI fails three minutes later because the formatter ran and disagreed with Claude’s spacing, or a test that was already skipped is now broken, or a secret from your .env.example got copied verbatim into a config file.

None of these are Claude’s fault specifically. They are the predictable result of merging two workflows — AI code generation and version control — without a seam between them.

Pre-commit hooks are that seam. They run before git commit finalizes, they are language-agnostic, and they are completely outside Claude’s control. This guide covers how to build a setup where Claude Code and pre-commit work together: Claude generates and edits files, hooks validate before they ship.

Two Hook Systems, One Pipeline

Before the config — a clarification that trips people up.

Claude Code hooks (~/.claude/settings.json or .claude/settings.json) run inside the Claude Code session. They fire on events like PostToolUse (after Claude edits a file) and PreToolUse (before Claude runs a shell command). They can inject context into Claude’s conversation, block actions, or trigger side effects.

Git pre-commit hooks (the .git/hooks/pre-commit script, or managed via the pre-commit framework) run when git commit executes. They block the commit if they exit non-zero.

These are entirely separate systems. Neither is a replacement for the other. The useful pattern is to run pre-commit from a Claude Code hook, so issues surface immediately during the session rather than when you run git commit manually later.

Setting Up the pre-commit Framework

The pre-commit Python package manages hook configs as YAML, caches tool environments, and runs hooks in parallel. If you are not using it already:

pip install pre-commit
# or
brew install pre-commit

Then create .pre-commit-config.yaml in your repo root. Examples below.

After creating the config:

pre-commit install          # installs the hook into .git/hooks/pre-commit
pre-commit run --all-files  # run against everything to establish a baseline

Config Templates

Python Project

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests, types-pyyaml]

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: check-added-large-files
        args: [--maxkb=500]
      - id: check-merge-conflict
      - id: check-json
      - id: check-yaml
      - id: detect-private-key
      - id: end-of-file-fixer
      - id: trailing-whitespace

A few notes on these choices:

Ruff instead of flake8 + isort + black: Ruff covers all three tools in a single binary, runs in milliseconds not seconds, and the --fix flag means it self-corrects rather than just complaining. Claude Code tends to produce code that needs minor formatting fixes — Ruff handles those without blocking the commit.

Mypy: Type checking is the one thing Ruff cannot do. Keep it. If your project has no type annotations yet, start with ignore_missing_imports = true in mypy.ini and add strictness gradually.

detect-private-key: This catches patterns like -----BEGIN RSA PRIVATE KEY----- in committed files. It won’t catch every secret format, but it catches the obvious ones. For more coverage, add detect-secrets or gitleaks.

TypeScript / Node Project

repos:
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.57.0
    hooks:
      - id: eslint
        files: \.(js|jsx|ts|tsx)$
        additional_dependencies:
          - [email protected]
          - [email protected]
          - "@typescript-eslint/[email protected]"
          - "@typescript-eslint/[email protected]"

  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.3.0
    hooks:
      - id: prettier
        files: \.(js|jsx|ts|tsx|json|css|md)$

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: check-added-large-files
        args: [--maxkb=1000]
      - id: detect-private-key
      - id: check-merge-conflict
      - id: end-of-file-fixer

Note that mirrors-eslint requires listing your ESLint plugins in additional_dependencies. Pre-commit runs hooks in isolated environments, so it cannot see your node_modules. This is annoying to configure once and then works perfectly.

Alternative: if you want to use your project’s own node_modules installation, use a local hook:

repos:
  - repo: local
    hooks:
      - id: eslint
        name: eslint
        entry: npx eslint
        language: node
        files: \.(js|jsx|ts|tsx)$
        args: [--fix]

Polyglot / Monorepo

For repos with multiple languages (common in full-stack projects Claude Code works on), scope hooks to directories:

repos:
  # Python backend
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        files: ^backend/
        args: [--fix]
      - id: ruff-format
        files: ^backend/

  # TypeScript frontend
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.3.0
    hooks:
      - id: prettier
        files: ^frontend/.*\.(ts|tsx|js|css)$

  # Global checks
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: detect-private-key
      - id: check-merge-conflict
      - id: check-added-large-files
        args: [--maxkb=1000]

  # Secret scanning (recommended for any production project)
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks:
      - id: detect-secrets
        args: [--baseline, .secrets.baseline]

Wiring pre-commit into Claude Code Hooks

This is the part that makes the setup active during your session rather than passive at commit time.

Add a PostToolUse hook to your .claude/settings.json that runs pre-commit after every file edit:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "pre-commit run --files $CLAUDE_TOOL_OUTPUT_PATH 2>&1 | head -50"
          }
        ]
      }
    ]
  }
}

This runs only the hooks relevant to the changed file (pre-commit is smart about this). The head -50 prevents a wall of output from flooding Claude’s context.

The output goes back to Claude. If Ruff auto-fixed something, Claude sees the diff. If a type error was found, Claude sees the mypy output and can address it immediately.

For a harder enforcement that blocks Claude from continuing until the file is clean:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'output=$(pre-commit run --files $CLAUDE_TOOL_OUTPUT_PATH 2>&1); status=$?; echo \"$output\"; exit $status'"
          }
        ]
      }
    ]
  }
}

Exit code non-zero from pre-commit causes the Claude Code hook to exit non-zero, which surfaces the issue to Claude as a tool failure. Claude sees the hook output and typically self-corrects.

Be careful with this pattern for formatters that auto-fix (like Ruff with --fix or Prettier). They exit non-zero on first run (they made changes), then exit 0 on the second run. Claude will see a failure, run the file again, and the issue will be gone — but it creates noise. A cleaner approach for auto-fix tools: let them fix silently, only block on errors that cannot self-correct.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'pre-commit run ruff --files $CLAUDE_TOOL_OUTPUT_PATH 2>/dev/null; pre-commit run mypy --files $CLAUDE_TOOL_OUTPUT_PATH 2>&1; exit 0'"
          }
        ]
      }
    ]
  }
}

Here Ruff runs silently (auto-fixes, output discarded), mypy output goes to Claude, and the exit is always 0 — we are informing Claude, not blocking it.

Running Tests Before Commit

Pre-commit is not limited to linting. You can run a targeted test suite as a hook:

repos:
  - repo: local
    hooks:
      - id: pytest-fast
        name: Run fast tests
        entry: pytest tests/unit -x -q --tb=short
        language: python
        pass_filenames: false
        stages: [commit]
        always_run: false

The stages: [commit] scoping means this only runs at git commit time, not when you run pre-commit run --all-files for linting. The -x flag stops on first failure. pass_filenames: false is required for test runners — they do not take file paths as arguments.

For a slow test suite, consider running a subset before commit and the full suite in CI:

- id: pytest-fast
  name: Unit tests (pre-commit)
  entry: pytest tests/unit -x -q --timeout=30
  language: python
  pass_filenames: false
  stages: [commit]

The --timeout=30 flag (requires pytest-timeout) prevents any individual test from hanging the commit process.

The CLAUDE.md Addition

Tell Claude about your pre-commit setup so it understands the workflow:

## Code quality enforcement

This project uses pre-commit hooks. When you edit Python files:
- Ruff runs automatically and fixes formatting issues
- Mypy checks type correctness — fix any errors it reports
- Secrets detection runs before every commit — never put credentials in source files

To check the current state manually: `pre-commit run --all-files`
To run a specific hook: `pre-commit run ruff --all-files`

If a hook fails after your edit, fix the reported issue before moving on.

The last line matters. Without it, Claude sometimes acknowledges the hook failure and moves to the next task anyway. Explicit instruction keeps it in the fix-the-error loop.

Handling the pre-commit Cache Problem

Pre-commit creates isolated virtual environments for each hook on first run. This takes 30-60 seconds. Every subsequent run is fast (cached environments).

The problem: if Claude Code is running a PostToolUse hook that calls pre-commit and the cache has not been built yet, the first run takes a minute. Claude may time out waiting.

Fix: run pre-commit run --all-files once manually before starting a Claude Code session. The cache builds, subsequent runs are instant.

Alternatively, add a SessionStart hook that pre-warms the cache:

{
  "hooks": {
    "SessionStart": [
      {
        "type": "command",
        "command": "pre-commit run --all-files 2>/dev/null || true"
      }
    ]
  }
}

The || true ensures this always exits 0 (we do not want a lint failure to block the session from starting). Output goes to /dev/null because we do not need Claude to see the baseline lint state — just need the cache warm.

Skipping Hooks When Needed

Sometimes you need to commit a work-in-progress without running the full hook suite. Git provides an escape hatch:

git commit --no-verify -m "WIP: rough draft"

--no-verify skips all pre-commit hooks. Use sparingly. A better pattern for WIP commits is to use a draft branch and squash-merge when the feature is done — your pre-commit hooks run once on the final clean commit.

If you want to skip only a specific hook:

SKIP=mypy git commit -m "chore: update deps (type stubs pending)"

The SKIP environment variable accepts a comma-separated list of hook IDs.

Secret Scanning: Going Beyond detect-private-key

The built-in detect-private-key hook catches PEM-formatted keys. For a more thorough setup, add Gitleaks:

- repo: https://github.com/gitleaks/gitleaks
  rev: v8.18.2
  hooks:
    - id: gitleaks

Gitleaks uses pattern matching against hundreds of known secret formats: AWS keys, GitHub tokens, Stripe keys, Twilio SIDs, JWT secrets, etc. It also supports a .gitleaks.toml config for allowlisting false positives:

# .gitleaks.toml
[allowlist]
  description = "Global allowlist"
  regexes = ['''EXAMPLE_KEY''', '''test-only-placeholder''']
  paths = ['''tests/fixtures/''']

False positives are common in test fixture files. Allowlist them rather than disabling the rule.

CI Parity

Your pre-commit config should run identically in CI. Add this to your GitHub Actions workflow:

- name: Run pre-commit
  uses: pre-commit/[email protected]

Or with caching for faster CI runs:

- name: Set up pre-commit cache
  uses: actions/cache@v4
  with:
    path: ~/.cache/pre-commit
    key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}

- name: Run pre-commit
  run: pre-commit run --all-files

This guarantees the same checks run locally (via Claude Code session hooks or manual git commit) and in CI. No more “passes locally, fails in CI” issues caused by mismatched tooling.

Debugging Failed Hooks

When a hook fails and the output is unclear:

# Run the specific hook verbosely
pre-commit run mypy --all-files --verbose

# Run hooks on a specific file
pre-commit run --files src/module.py

# Show the environment pre-commit built for a hook
pre-commit run mypy --show-diff-on-failure

For hooks that run in isolated environments (most of them), you can inspect that environment:

# Find the pre-commit cache location
python -m pre_commit.main info --path

# Activate the environment for a specific hook to debug
source ~/.cache/pre-commit/repo<hash>/py_env-python3.11/bin/activate
mypy --version

What This Actually Prevents

To make this concrete: here are three categories of issues this setup catches that Claude Code without pre-commit does not.

Style drift over a session: Claude Code’s formatting consistency degrades over long sessions, especially after /compact. A Ruff/Prettier hook corrects every file before it reaches git history. You never get a commit that is “Claude’s style” instead of your project’s style.

Type regressions: Mypy and TypeScript type checking catch when Claude adds a function call with the wrong argument type, or uses Optional where the caller expects a concrete value. These are subtle bugs — the code runs, but breaks at runtime under specific conditions. Static analysis finds them at commit time.

Accidental secret leakage: This one is not Claude’s fault, but AI coding sessions involve more copy-paste from examples, docs, and .env.example files. Secret scanning catches credentials before they enter version control history. Once a secret is in git history, it is very difficult to remove completely.

The setup requires about 15 minutes to configure and then runs silently. The return on time is high.

Browse the gallery for Claude Code hook configurations from real projects, including setups that combine pre-commit with Claude Code’s PreToolUse hooks for shell command blocking, and PostToolUse patterns for automatic test execution after writes.

More from the blog

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules