Pre-commit Hooks That Break CI: 5 Fixes from 50+ Repos

Updated Feb 14, 2026
Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.

⚡ Key Takeaways
  • Pre-commit hooks can pass locally but fail in CI due to line ending differences, path mismatches, environment variables, or version skew between local and CI tool installations.
  • The most common issue is Windows CRLF vs Linux LF line endings — fix it permanently with .gitattributes forcing eol=lf for all text files.
  • To debug hook vs CI mismatches, reproduce the exact CI command locally, compare tool versions, check working directories, and verify git state before running checks.
  • Auto-fix in hooks (e.g., black .) but validate in CI (black –check .) to catch cases where hooks didn't run, and use git diff –exit-code to detect any uncommitted changes after running formatters.
  • Let pre-commit manage tool versions instead of installing them separately in CI — this eliminates version skew and keeps configs in sync across environments.

The Hook Runs Fine Locally, CI Fails Anyway

Your pre-commit hook passes locally. You push. CI fails with the exact same check.

This happened to me on three different projects last month. The hook would run black --check . locally, pass, and then CI would fail with “Files would be reformatted.” Same version, same config, different result.

The problem isn’t the tools. It’s the environment mismatch between where hooks run and where CI runs. After debugging this across 50+ repos (client work + open source audits), I’ve seen five patterns that cause 90% of these failures.

Hand holding a Jenkins sticker outdoors, blurred background for focus effect.
Photo by RealToughCandy.com on Pexels

After spending an afternoon reproducing CI failures locally and tracking down environment mismatches across 50+ repos, you deserve a Japanese snack box to reward your debugging persistence.

Pattern 1: Line Ending Hell on Windows

The most common culprit. Your hook runs prettier or black on Windows with CRLF line endings. It passes. CI runs on Linux with LF endings and reformats everything.

Here’s what actually happens:

# pre-commit config
repos:
  - repo: https://github.com/psf/black
    rev: 23.12.1
    hooks:
      - id: black
        args: [--check]

Locally (Windows):

$ git config core.autocrlf
true
$ black --check .
All done!  🍰 5 files would be left unchanged.

CI (Linux):

Oh no! 💥 💔 💥
5 files would be reformatted.

The files are byte-identical except for rn vs n. Black sees that as a difference.

Fix: Force LF in .gitattributes for all text files:

* text=auto eol=lf
*.py text eol=lf
*.js text eol=lf
*.md text eol=lf

Then refresh your working copy:

git rm --cached -r .
git reset --hard

This bit me on a React + Python monorepo where half the team was on Windows. We had 47 files flip-flopping between CRLF and LF on every commit until we added .gitattributes.

When you’re spending hours debugging CI pipelines and tweaking pre-commit configs, a mechanical keyboard makes those repetitive config edits feel less like punishment.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Pattern 2: The Hook Modifies Files CI Only Checks

Your local hook auto-fixes issues. CI only validates. When the hook misses something (or runs in a subtly different mode), CI catches it.

Example from a Django project:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pycqa/isort
    rev: 5.13.2
    hooks:
      - id: isort
        args: [--profile, black]

Locally, isort reformats imports. In CI:

# .github/workflows/ci.yml
- name: Check imports
  run: isort --check-only --profile black .

The hook runs isort (fix mode). CI runs isort --check-only (validation mode). If the hook skips a file (wrong extension, excluded path), CI catches it.

I’ve seen this happen when:
– The hook uses types: [python] but CI runs on all .py files including generated code
– The hook excludes migrations/ but CI doesn’t
– The hook runs on staged files only, CI runs on the full tree

Fix: Make CI run the exact same fix command, then check for dirty git state:

- name: Check formatting
  run: |
    isort --profile black .
    black .
    git diff --exit-code

If any file changed, git diff --exit-code returns non-zero. This catches tools that “fixed” something the hook missed.

Pattern 3: Path Differences Break Exclusions

Pre-commit runs from repo root. Your CI job might run from a subdirectory, or use absolute paths in ways that break exclude patterns.

Real case from a microservices monorepo:

repos:
  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
        exclude: ^(migrations|generated)/

Locally, this excludes app/migrations/ correctly. In CI:

- name: Lint
  working-directory: ./services/api
  run: pre-commit run --all-files

The working-directory change breaks the regex. Pre-commit still thinks it’s at repo root, but the paths are now relative to services/api/. The exclude pattern ^migrations/ doesn’t match ../../migrations/ or however the path resolves.

Fix: Use absolute exclude patterns or avoid changing working directories in CI:

repos:
  - repo: https://github.com/pycqa/flake8
    rev: 7.0.0
    hooks:
      - id: flake8
        exclude: (^|/)migrations/

The (^|/) prefix matches both migrations/ at root and foo/bar/migrations/ nested.

Alternatively, always run pre-commit from repo root in CI:

- name: Lint
  run: pre-commit run --all-files
  # No working-directory directive

Pattern 4: Environment Variables Change Tool Behavior

Some tools read environment variables that differ between your shell and CI. I’ve seen this with mypy, eslint, and ruff.

Example:

repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.8.0
    hooks:
      - id: mypy
        args: [--strict]

Locally, I had MYPYPATH=./stubs in my .bashrc to point to custom type stubs. CI didn’t have this. Result: mypy passed locally (found custom stubs), failed in CI (stub files missing, type errors on third-party imports).

Another variant: NODE_ENV. ESLint plugins behave differently in development vs production. If your hook runs in development mode but CI runs in production, rule sets can diverge.

Fix: Explicitly set required environment variables in the hook config:

repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.8.0
    hooks:
      - id: mypy
        args: [--strict]
        language: system
        entry: env MYPYPATH=./stubs mypy

Or better, don’t rely on environment variables. Put config in mypy.ini:

[mypy]
mypy_path = ./stubs
strict = True

Then both local and CI read the same file.

Pattern 5: The Hook Uses a Different Tool Version

Pre-commit installs tools into isolated virtualenvs. Your CI might install them via requirements-dev.txt or a global package manager. Version skew causes different behavior.

Classic case:

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

CI (using uv):

- name: Install tools
  run: uv pip install ruff
- name: Lint
  run: ruff check --fix .

The hook pins ruff to v0.1.9. CI installs whatever’s latest (say, v0.2.1). Rule implementations change between versions. What passed in 0.1.9 might fail in 0.2.1.

I saw this exact issue when Ruff added the E721 rule in 0.2.0. Old code using type(x) == SomeClass passed pre-commit (0.1.9) but failed CI (0.2.0) with “use isinstance() instead.”

Fix: Pin the same version everywhere. In CI, install from the pre-commit config:

- name: Install pre-commit
  run: pip install pre-commit
- name: Run hooks
  run: pre-commit run --all-files

This forces CI to use the exact versions from .pre-commit-config.yaml. No skew.

Alternatively, if you must install tools separately, extract versions into a shared file:

# pyproject.toml
[tool.ruff]
required-version = "0.1.9"

Then reference it:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.1.9  # Must match pyproject.toml
    hooks:
      - id: ruff

And in CI:

- name: Install ruff
  run: pip install ruff==0.1.9

But honestly, just let pre-commit manage it. One source of truth.

Detailed view of a stack of compact discs on a spindle, highlighting their reflective surfaces.
Photo by BOOM 💥 Photography on Pexels

The Debug Process That Actually Works

When you hit this (hook passes, CI fails), here’s the checklist:

  1. Reproduce CI locally. Don’t guess. Run the exact CI command on your machine:
    bash
    # If CI does this:
    black --check .
    # You do this:
    black --check .

    If it passes locally but fails in CI, it’s environment. If it fails locally too, your hook isn’t running.

  2. Check tool versions. Print them in both places:
    bash
    black --version
    pre-commit run black --verbose # shows installed version

    Mismatch? That’s your culprit.

  3. Check working directory. Where is CI running the command?
    yaml
    - name: Debug paths
    run: pwd && ls -la

    If it’s not repo root, your exclude patterns are probably wrong.

  4. Check git state. What files is CI actually seeing?
    yaml
    - name: Debug git
    run: git status && git diff

    If there are unstaged changes (from checkout or previous steps), tools might run on the wrong content.

  5. Run the hook in CI. Instead of running the tool directly, run the hook:
    yaml
    - name: Pre-commit
    run: pre-commit run --all-files

    If this passes but your manual tool invocation fails, the hook config differs from your CI command.

I’ve debugged 30+ cases using this exact sequence. It’s boring, but it works.

What About Skip Conditions?

Some hooks use files: or exclude: regex that’s just subtly wrong. The hook skips a file. CI doesn’t.

Example:

repos:
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.56.0
    hooks:
      - id: eslint
        files: .(js|jsx)$

This runs ESLint only on .js and .jsx files. But CI does:

- name: Lint JS
  run: eslint .

ESLint’s default config might include .ts, .tsx, .mjs files. The hook skips them. CI lints them. Mismatch.

Fix: Either expand the hook’s file pattern:

files: .(js|jsx|ts|tsx|mjs)$

Or narrow CI to match the hook:

- name: Lint JS
  run: eslint "**/*.{js,jsx}"

I prefer the first approach (expand the hook). If you have .ts files, you probably want to lint them.

The Trap of --all-files in CI

Running pre-commit run --all-files in CI sounds safe. It’s not.

If your hook uses stages:, it might only run on commit stage locally but CI forces all stages:

repos:
  - repo: local
    hooks:
      - id: check-secrets
        name: Check for secrets
        entry: detect-secrets scan
        language: system
        stages: [commit]

Locally, this runs only on commit (not push, not manual). In CI with --all-files, pre-commit ignores stages: and runs it anyway.

Usually that’s fine. But if the hook has side effects (writes to a file, calls an API), running it in CI might break things.

Fix: Be explicit about stages in CI:

- name: Run commit hooks
  run: pre-commit run --all-files --hook-stage commit

Or just don’t use stages: unless you really need them. Most hooks should run everywhere.

Math Checks in Hooks: The Floating Point Trap

If your hook validates scientific code (checksums, numerical tests), floating point precision can differ between architectures.

Example hook that validates a physics simulation:

# scripts/validate_sim.py
import numpy as np

def check_energy_conservation():
    result = run_simulation()
    expected = 1.0
    assert np.isclose(result.energy, expected, atol=1e-9)

Locally (x86_64 with AVX2):

$ python scripts/validate_sim.py
Passed

CI (ARM64 or older x86):

AssertionError: energy 1.0000000012 not close to 1.0

The simulation uses different BLAS libraries or CPU instructions. The result differs at the 10th decimal place. Your tolerance atol=109text{atol} = 10^{-9} is too strict.

Fix: Relax tolerances for cross-platform checks:

assert np.isclose(result.energy, expected, atol=1e-6)

Or pin BLAS libraries:

- name: Install NumPy with OpenBLAS
  run: pip install numpy==1.24.0  # Known deterministic version

But honestly, if your hook is running numerical code, maybe it shouldn’t be a pre-commit hook. That’s what CI is for.

FAQ

Q: Should pre-commit hooks auto-fix or just validate?

Auto-fix locally, validate in CI. Let the hook run black . (fix mode) so developers don’t have to think about it. In CI, run black --check . to catch cases where the hook didn’t run (force push, manual edits). This gives you safety without annoying developers.

Q: How do I debug a hook that only fails in CI?

Run the exact CI command locally. If it still passes, SSH into the CI runner (GitHub Actions supports tmate for debugging) and run commands interactively. Check git status, env, and pwd. 90% of the time it’s line endings, working directory, or environment variables.

Q: Can I skip pre-commit hooks in CI and just run the tools directly?

Yes, but you lose the version pinning and config consistency pre-commit provides. If you do this, you must manually keep tool versions in sync between .pre-commit-config.yaml and CI. I’ve seen teams do it successfully, but it’s more maintenance. I’d only skip pre-commit in CI if you have a very custom setup (Bazel, Nix) that conflicts with pre-commit’s virtualenv isolation.

When to Give Up on Pre-commit Hooks

Some checks just don’t belong in hooks.

If the check:
– Takes >5 seconds (kills developer flow)
– Requires network access (flaky, slow)
– Depends on external services (database, API)
– Has platform-specific behavior you can’t normalize

Move it to CI. Hooks are for fast, deterministic, local-first checks. Type checking, linting, formatting. Not integration tests.

I removed a pytest --doctest hook from a data science repo because doctests were flaky (floating point output, random seeds). Developers would commit, hook would randomly fail, they’d commit again without changes, it would pass. That’s not useful. Moved it to CI where we could control the seed and environment.

The Setup I’d Use Today

If I were starting a new Python project:

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

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

CI:

name: CI
on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - name: Install pre-commit
        run: pip install pre-commit
      - name: Run hooks
        run: pre-commit run --all-files

That’s it. No separate tool installations. No version skew. CI runs the exact same hooks as local.

.gitattributes:

* text=auto eol=lf

No line ending issues.

This setup would have saved me hours across those 50 repos. The complexity comes from trying to run tools in CI separately from pre-commit, or from not pinning versions, or from letting Windows developers use CRLF.

Keep it simple. Let pre-commit manage tools. Force LF. Run the same thing everywhere.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 285 | TOTAL 113,561