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

URL: https://www.devopsness.com/blog/pre-commit-hooks-that-saved-our-repo-7-real-examples-2026-04-13
Author: Kiril Urbonas
Published: 2026-04-13
Updated: 2026-04-13
Categories: DevOps
Tags: CI/CD, Security, GitHub Actions

Summary: 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.

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

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`](https://pre-commit.com/) 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
- repo: https://github.com/Yelp/detect-secrets
  rev: v1.5.0
  hooks:
    - id: detect-secrets
      args: ["--baseline", ".secrets.baseline"]
```

```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
- 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
# .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
- 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
- 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
- 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
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
- 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:

```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
- 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.

## #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
- 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
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
# .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.

