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, orlocalfor scripts in the working treerev— version pin for remote hooks; pinning prevents silent upgrades breaking your pipelineid— the hook identifier, used when running or skipping individual hooksentry— the command to runlanguage: system— use tools already on$PATHrather than a managed virtual environmentpass_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:
| Hook | What it catches |
|---|---|
trailing-whitespace | Trailing spaces on any line |
end-of-file-fixer | Missing newline at end of file |
check-yaml | Malformed YAML — useful for config-heavy repos |
check-merge-conflict | Unresolved <<<<<< 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
- pre-commit.com — framework documentation and hook registry
- prek — Rust drop-in replacement
- gitleaks — secret scanning rules and configuration
- ShellCheck — shell script static analysis
- pre-commit/pre-commit-hooks — the standard hook collection used in this config