Pre-commit hooks catch issues before they reach CI — shifting quality checks as far left as possible.

In an AI-assisted workflow this matters more than ever. LLM-generated commits are faster and more numerous; human review bandwidth is the same. Pre-commit hooks are how you define “done” in a form the machine can check — and the LLM can iterate on.

Why pre-commit? Link to heading

Every CI pipeline has a gate. The question is how early in the cycle you want it to trigger. A failing lint check discovered in CI costs a push, a wait, a context switch back to a problem you thought was done. The same check as a pre-commit hook costs a second and surfaces the issue before it ever leaves your machine.

The shift-left argument has always been true. What’s changed is the volume. AI-assisted development doesn’t just speed up writing code — it speeds up committing code. The pull request queue grows faster; the review window shrinks. Pre-commit hooks become the first automated reviewer that never gets tired, never skips the boring checks, and never waits for a pipeline slot.

They’re also one of the most practical answers to a question that comes up constantly in AI-assisted teams: how do you encode your definition of done in something executable? A .pre-commit-config.yaml in the repo is exactly that — a machine-readable spec for what a commit must satisfy. When an LLM’s change fails a hook, it gets precise, actionable feedback it can loop on without human intervention.

Installing the framework Link to heading

Git’s native hook support (/.git/hooks/) works, but it has two problems: the hooks aren’t committed to the repo, so each developer has to configure them manually, and there’s no standard for managing dependencies.

The pre-commit framework solves both. Hooks are declared in .pre-commit-config.yaml, committed to the repo, and installed once per clone:

# Install the framework (once, per machine)
pip install pre-commit
# or
brew install pre-commit

# Wire it into the repo (once, per clone)
pre-commit install

After pre-commit install, git writes a small wrapper to .git/hooks/pre-commit that delegates to the framework. Every subsequent commit runs the configured hooks automatically.

prek is a drop-in replacement written in Rust. It uses the same .pre-commit-config.yaml format and is meaningfully faster on large repos with many hooks. The switch is a one-line change to your install command; everything else stays the same.

# prek drop-in replacement
cargo install prek
prek install

Either tool reads the same config. This post uses the pre-commit config format, which runs identically under both.

Anatomy of .pre-commit-config.yaml Link to heading

The config is a list of repos — either remote (hooks hosted on GitHub, auto-installed on first run) or local (scripts that live in the repo itself).

Here’s the config from this site’s repo:

repos:
  - repo: 'https://github.com/pre-commit/pre-commit-hooks'
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-merge-conflict

  - repo: local
    hooks:
      - id: gitleaks
        name: gitleaks secret scan
        entry: bash -c 'GL=$(command -v gitleaks || ...) ...'
        language: system
        pass_filenames: false

      - id: shellcheck
        name: ShellCheck Validation
        entry: bash -c 'SC=$(command -v shellcheck || ...) ...'
        language: system
        pass_filenames: false

      - id: hugo_build_draft
        name: hugo build validation
        entry: hugo --buildDrafts
        language: system
        pass_filenames: false

Key fields:

  • repo — a URL for remote hooks, or local for scripts in the working tree
  • rev — version pin for remote hooks; pinning prevents silent upgrades breaking your pipeline
  • id — the hook identifier, used when running or skipping individual hooks
  • entry — the command to run
  • language: system — use tools already on $PATH rather than a managed virtual environment
  • pass_filenames: false — don’t pass staged file paths as arguments; run the command unconditionally

Essential hooks Link to heading

The four hooks from pre-commit/pre-commit-hooks do the quiet, thankless work that is somehow still forgotten in a non-trivial percentage of commits:

HookWhat it catches
trailing-whitespaceTrailing spaces on any line
end-of-file-fixerMissing newline at end of file
check-yamlMalformed YAML — useful for config-heavy repos
check-merge-conflictUnresolved <<<<<< markers left in files

These run in milliseconds and catch issues that are embarrassing in review. The check-added-large-files hook (commented out in this repo’s config) is worth enabling on repos where binary files might accidentally end up staged.

The local hooks in depth Link to heading

The more interesting hooks are the local ones — they’re written for this repo’s specific toolchain and demonstrate patterns worth reusing.

gitleaks: secret scanning Link to heading

- id: gitleaks
  name: gitleaks secret scan
  entry: bash -c 'GL=$(command -v gitleaks || command -v /opt/homebrew/bin/gitleaks || command -v /c/ProgramData/chocolatey/bin/gitleaks 2>/dev/null) || { echo "gitleaks not found, skipping"; exit 0; }; "$GL" git --redact=80 --no-banner --timeout 2 -v --max-target-megabytes=2 --pre-commit --staged'
  language: system
  pass_filenames: false

This does two things worth noting.

Graceful fallback. The entry command tries to find gitleaks in three places — $PATH, the Homebrew prefix on macOS, and the Chocolatey prefix on Windows — before giving up. If it’s not found, it prints a message and exits 0 (success), so the commit proceeds. This is the right trade-off for a security tool: you want it to run when present, but you don’t want a missing binary to block every engineer who hasn’t installed it yet.

The downside: CI environments that haven’t installed gitleaks silently skip the scan. The mitigation is to explicitly install gitleaks in CI and fail the pipeline if the binary isn’t present there — the fallback is for local developer machines, not the audit trail.

Staged-only scan. --pre-commit --staged tells gitleaks to examine only the files in the staging area, not the full repository history. This keeps the hook fast and focused. --redact=80 shows 80% of any detected secret, enough to identify what leaked without writing the full value to terminal output.

--timeout 2 and --max-target-megabytes=2 bound the worst-case runtime. Without these, a hook scanning a large binary accidentally staged could stall the commit indefinitely.

shellcheck: shell script linting Link to heading

- id: shellcheck
  name: ShellCheck Validation
  entry: bash -c 'SC=$(command -v shellcheck || command -v /opt/homebrew/bin/shellcheck || command -v /c/ProgramData/chocolatey/bin/shellcheck 2>/dev/null) || { echo "shellcheck not found, skipping"; exit 0; }; find . -type f -name "*.sh" -not -path "*/\.*" -exec "$SC" -S warning {} +'
  language: system
  pass_filenames: false

Same cross-platform resolution pattern as gitleaks. The scan itself uses find to locate all .sh files in the working tree (excluding hidden directories), then runs shellcheck -S warning on each. The -S warning flag sets the minimum severity — warnings and above are reported, informational notes are suppressed.

Note pass_filenames: false here — rather than letting pre-commit pass only staged .sh files, the hook scans everything. This is intentional: shell scripts rarely change in isolation, and a script dependency chain means a change in one file can affect the correctness of another.

hugo build validation Link to heading

- id: hugo_build_draft
  name: hugo build validation
  entry: hugo --buildDrafts
  language: system
  pass_filenames: false

The simplest of the three, and arguably the most opinionated. Running hugo --buildDrafts as a pre-commit hook means every commit must produce a clean build — including draft posts. A broken template, a malformed frontmatter field, or a missing partial will fail the hook and block the commit.

This is the kind of hook that looks like overhead until the day it catches a template regression in a layout override that would have deployed a broken site to production. For a content-heavy repo with CI/CD wired directly to main, this is cheap insurance.

Writing your own local hook Link to heading

The pattern from the local hooks above generalises. A local hook is any command the repo can run:

- repo: local
  hooks:
    - id: my-check
      name: My custom check
      entry: ./scripts/check.sh
      language: system
      pass_filenames: false
      stages: [pre-commit]

stages controls when the hook runs — pre-commit, pre-push, commit-msg, or manual. The default is pre-commit. A hook that’s too slow for every commit (an integration test, a full build) belongs on pre-push or manual.

For entry, prefer a script over an inline bash -c '...' for anything longer than one expression. Inline commands are hard to read, impossible to test in isolation, and tend to acquire escaping bugs as they grow.

Skipping hooks Link to heading

SKIP=hook-id git commit skips a named hook for a single commit without disabling it globally:

SKIP=hugo_build_draft git commit -m "chore: add draft post"

git commit --no-verify skips all hooks. Use it sparingly and deliberately — it’s the right tool when you’re committing a work-in-progress mid-session and know the build is intentionally broken. It’s the wrong tool when a hook is failing and you’d rather not deal with it.

A useful indicator: if --no-verify appears in more than a small percentage of commits in your git log, the hooks are too aggressive, too slow, or not well calibrated. Fix the hooks; don’t normalise bypassing them.

CI integration Link to heading

Pre-commit hooks only run for engineers who have run pre-commit install. Someone cloning the repo on a new machine without that step will commit without any checks. CI is the backstop.

Run the full hook suite against all files on every pull request:

# .github/workflows/quality.yml
name: Quality
on: [pull_request]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.x'
      - run: pip install pre-commit
      - run: pre-commit run --all-files

For repos using the gitleaks or shellcheck local hooks, install the binaries before the pre-commit step:

      - run: brew install gitleaks shellcheck
        if: runner.os == 'macOS'
      - run: |
          wget -O gitleaks.tar.gz https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_x64.tar.gz
          tar -xzf gitleaks.tar.gz && mv gitleaks /usr/local/bin/
        if: runner.os == 'Linux'

This is also where you remove the graceful fallback trade-off: CI knows the binary is present, so a scan failure is a genuine failure.

Further reading Link to heading