Skip to main content
Pre-Commit Hooks That Saved Our Repo: 7 Real Examples

Pre-Commit Hooks That Saved Our Repo: 7 Real Examples

KU
• 7 min read•42 views

Every hook on this list caught a bug or a security issue in the last twelve months. The configs are short. The savings have been considerable.

Every hook in this list caught a real bug or security issue in our repo in the last twelve months. They run on every commit (locally), again on every PR (in CI), and have collectively saved us from at least three production incidents and one near-miss public credential leak.

We use the pre-commit framework. The full .pre-commit-config.yaml is at the bottom; the seven hooks below are the ones with concrete saves.

#1. detect-secrets — Caught an AWS Key Headed for Public Mirror

We had a scripts/migrate.py with an AWS_ACCESS_KEY_ID = "AKIA..." line that an engineer left in for testing. The hook flagged it on commit; the engineer wiped it before pushing.

yaml.yaml
- repo: https://github.com/Yelp/detect-secrets
  rev: v1.5.0
  hooks:
    - id: detect-secrets
      args: ["--baseline", ".secrets.baseline"]
bash.bash
# One-time setup
detect-secrets scan > .secrets.baseline
detect-secrets audit .secrets.baseline   # mark known false positives

The baseline file lets the hook tolerate already-known patterns (e.g. example values in test fixtures) while still flagging anything new.

Saved: at least one credential. Possibly more — once is enough.

#2. gitleaks — Belt-and-Suspenders for Secrets

detect-secrets is good but heuristic. gitleaks uses regex rules tuned for known cloud providers and has a much lower false-positive rate. We run both because their misses don't overlap.

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

In CI we additionally scan the full PR diff range, not just the staged files:

yaml.yaml
# .github/workflows/ci.yml
- name: gitleaks (full PR)
  uses: gitleaks/gitleaks-action@v2
  with:
    args: detect --source . --log-opts="--all $(git merge-base origin/main HEAD)..HEAD"

This caught a Slack webhook URL that someone had added in commit 3 of a 7-commit branch and "removed" in commit 5 — but the URL was still in the git history.

#3. ruff (or eslint --fix) — Auto-Fix on Commit

Linting in CI is fine. Linting + auto-fix on commit means most lint errors never surface.

yaml.yaml
- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.7.4
  hooks:
    - id: ruff
      args: ["--fix"]
    - id: ruff-format

For a JS/TS repo:

yaml.yaml
- repo: https://github.com/pre-commit/mirrors-eslint
  rev: v9.16.0
  hooks:
    - id: eslint
      args: ["--fix"]
      types: [javascript, ts, tsx]

We don't bother running these in CI as failures — CI runs the same hook to verify, but the local run almost always already fixed everything. CI lint failures dropped to near zero after we added this.

#4. mypy (or tsc --noEmit) on Changed Files Only

Type checking the whole repo on every commit is too slow. mypy on only the files in the commit takes 1–3 seconds and catches type regressions early.

yaml.yaml
- repo: https://github.com/pre-commit/mirrors-mypy
  rev: v1.13.0
  hooks:
    - id: mypy
      additional_dependencies: ["pydantic", "types-requests"]
      args: ["--ignore-missing-imports"]

Caught a real bug last quarter: a function signature changed from Optional[int] to int, and three call sites still passed None. Mypy flagged them; pytest would not have, because the test fixture didn't exercise that path.

#5. shellcheck — Saved Us From a rm -rf That Would Have Hurt

We had a deploy script with:

bash.bash
TARGET_DIR=$1/build
rm -rf $TARGET_DIR/*

If $1 was empty, that would have run rm -rf /build/*. Probably fine on a CI runner. Definitely not fine on the production deploy box where the same script eventually ran.

yaml.yaml
- repo: https://github.com/shellcheck-py/shellcheck-py
  rev: v0.10.0.1
  hooks:
    - id: shellcheck
      args: ["--severity=warning"]
code
SC2086: Double quote to prevent globbing and word splitting.
SC2154: TARGET_DIR is referenced but not assigned.

Two warnings. The fix:

bash.bash
TARGET_DIR="${1:?usage: deploy.sh <root>}/build"
rm -rf -- "${TARGET_DIR:?}"/*

Bash's ${VAR:?message} exits with the message if VAR is empty or unset. Combined with shellcheck this is genuinely defensive.

#6. hadolint — Stopped a Latent Image-Size Bloat

hadolint lints Dockerfiles. It caught a developer using apt-get install without --no-install-recommends and without cleaning the apt cache:

yaml.yaml
- repo: https://github.com/hadolint/hadolint
  rev: v2.12.0
  hooks:
    - id: hadolint-docker
code
DL3015: Avoid additional packages by specifying `--no-install-recommends`.
DL3009: Delete the apt-get lists after installing something.

Fixing those took the image from 1.4 GB to 380 MB. We caught this before the image hit the registry, before downstream services pulled it, before the bigger image became baseline.

#7. check-merge-conflict and check-added-large-files — Boring But Saving

The pre-commit-hooks repo ships a bunch of trivial-but-essential hooks. The two we've actually had save us:

yaml.yaml
- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v5.0.0
  hooks:
    - id: check-merge-conflict
    - id: check-added-large-files
      args: ["--maxkb=500"]
    - id: check-yaml
    - id: check-json
    - id: end-of-file-fixer
    - id: trailing-whitespace
    - id: mixed-line-ending

check-merge-conflict has caught <<<<<<< HEAD markers committed by accident twice.

check-added-large-files once flagged a 47 MB binary that someone had dragged into the repo for "a quick test." Without the hook it would have lived in git history forever.

The Full .pre-commit-config.yaml We Use

yaml.yaml
default_language_version:
  python: python3.12
default_stages: [commit]

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v5.0.0
    hooks:
      - { id: check-merge-conflict }
      - { id: check-added-large-files, args: ["--maxkb=500"] }
      - { id: check-yaml }
      - { id: check-json }
      - { id: end-of-file-fixer }
      - { id: trailing-whitespace }
      - { id: mixed-line-ending }

  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.5.0
    hooks:
      - id: detect-secrets
        args: ["--baseline", ".secrets.baseline"]

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

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.7.4
    hooks:
      - id: ruff
        args: ["--fix"]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.13.0
    hooks:
      - id: mypy
        additional_dependencies: ["pydantic", "types-requests"]
        args: ["--ignore-missing-imports"]

  - repo: https://github.com/shellcheck-py/shellcheck-py
    rev: v0.10.0.1
    hooks:
      - id: shellcheck
        args: ["--severity=warning"]

  - repo: https://github.com/hadolint/hadolint
    rev: v2.12.0
    hooks:
      - id: hadolint-docker

Running The Same Hooks in CI

We also run the hooks in CI to catch anyone who skipped local install or used --no-verify:

yaml.yaml
# .github/workflows/precommit.yml
name: Pre-commit
on: [pull_request]
jobs:
  precommit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - uses: pre-commit/action@v3.0.1

This is the most important hook of all: enforcement. Local hooks can be skipped. CI hooks cannot. Run both.

Best Practices We've Settled On

  1. Run pre-commit in CI as a non-skippable check. Local-only is honor-system; CI is enforcement.
  2. Use --fix for formatters and lints. Friction for the developer is friction for the policy.
  3. Run secret scanners locally AND in CI on the full PR range. Local catches new secrets; CI catches secrets that snuck through earlier commits.
  4. Auto-update hooks monthly. pre-commit autoupdate keeps you on current versions.
  5. Set --maxkb for large files at 500KB. Larger threshold defeats the purpose; smaller one annoys people legitimately committing test fixtures.
  6. Include the .secrets.baseline in the repo. Otherwise every dev sees different false positives.

What's Not Worth Adding

  • Conventional Commits enforcement at the commit level. We tried it; the friction wasn't worth the gains. We enforce on PR title instead.
  • Spell check on prose. Too many false positives (technical terms, brand names). We use a CI-only spell check on docs only.
  • Test suite as a pre-commit hook. Too slow. Run on push or in CI.

Total Setup Time

For a fresh repo: about 30 minutes from pip install pre-commit to a green PR. The first few weeks have noise from existing code that didn't conform; once that's cleaned up, the hooks fade into the background.

The next time one of them catches a credential headed for the public, you'll know the 30 minutes was the best 30 minutes you spent that quarter.

Explore topics:CI/CDDevOps
React
Share this post
KU

Kiril Urbonas

AI Engineer

553 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.