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.
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.
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
# 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.
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.
- 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:
# .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.
ruff (or eslint --fix) — Auto-Fix on Commit#Linting in CI is fine. Linting + auto-fix on commit means most lint errors never surface.
- 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:
- 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.
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.
- 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.
shellcheck — Saved Us From a rm -rf That Would Have Hurt#We had a deploy script with:
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.
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.10.0.1
hooks:
- id: shellcheck
args: ["--severity=warning"]
SC2086: Double quote to prevent globbing and word splitting.
SC2154: TARGET_DIR is referenced but not assigned.
Two warnings. The fix:
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.
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:
- repo: https://github.com/hadolint/hadolint
rev: v2.12.0
hooks:
- id: hadolint-docker
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.
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:
- 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.
.pre-commit-config.yaml We Use#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
We also run the hooks in CI to catch anyone who skipped local install or used --no-verify:
# .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.
--fix for formatters and lints. Friction for the developer is friction for the policy.pre-commit autoupdate keeps you on current versions.--maxkb for large files at 500KB. Larger threshold defeats the purpose; smaller one annoys people legitimately committing test fixtures..secrets.baseline in the repo. Otherwise every dev sees different false positives.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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Explore more articles in this category
We've been running the OTel Collector at the edge of every cluster for 18 months. The config patterns that lasted, the ones we ripped out, and a few processors that quietly saved us money.
Blue/green is easy for stateless services. We did it for our primary Postgres cluster with 3.2TB of data and ~8k connections. Here's exactly how — and what almost went wrong.
How to write postmortems that lead to real improvements, not just documentation theater. Includes a template and real examples.