Writing a multi-hook .pre-commit-config.yaml
An independent thread, not a continuation
Important before you start: this module does not depend on module 2. It's a second skill thread running in parallel with the first one — it's simply placed after it in reading order for this track. It only assumes you've finished modules 5 ("Installing pre-commit") and 6 ("Native hooks vs. pre-commit") of the Beginner track. You'll be working on a project entirely separate from my-node-api.
The project
A tiny Python script, deliberately badly formatted to give the hooks something to do:
mkdir my-python-script
cd my-python-script
git init
Create app.py with exactly this content (the odd spacing is intentional):
import os
def greet(name):
x = 1
print( "hello, "+name )
greet('world')
The .pre-commit-config.yaml schema
A .pre-commit-config.yaml file has the following structure:
repos: a list of hook repositories- for each repo:
repo: the URL of the hooks repository (or a sentinel value likelocal)rev: an immutable reference — a tag (v5.0.0) or a full commit SHA. Never a branch name.hooks: a list of hooks to enable from among those this repo offers, each identified by itsid
Why rev must be immutable
pre-commit caches a hook's installation environment (dependencies, virtualenv) keyed on the value of rev, assuming that reference never changes. A tag or a SHA satisfies that assumption. A branch like main doesn't: its content moves, but pre-commit would keep using the environment cached at first install, never refreshing it — which makes behavior unpredictable and non-reproducible across machines.
If you try rev: main anyway, pre-commit doesn't fail outright, but it warns you very explicitly:
[WARNING] The 'rev' field of repo 'https://github.com/pre-commit/pre-commit-hooks' appears to be a mutable reference (moving tag / branch). Mutable references are never updated after first install and are not supported. See https://pre-commit.com/#using-the-latest-version-for-a-repository for more details. Hint: `pre-commit autoupdate` often fixes this.
It's a warning, not a blocking error — the hooks still run on that pass. But it's a trap: anyone else running the same .pre-commit-config.yaml later, with a different cache, can get a different result than you did. Always use a tag or a SHA.
Step 1 — the generic checks
Start with the simplest, language-independent hooks, provided by pre-commit/pre-commit-hooks:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
- id: detect-private-key
check-yaml validates that every YAML file in the repo is syntactically correct. detect-private-key refuses the commit if a file looks like a private key (RSA, SSH...) — a typical generic check, independent of the project's language.
Step 2 — the formatter
Add black, the standard Python formatter, which automatically fixes style rather than just flagging it:
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
Step 3 — the linter
Add flake8, which catches problems black doesn't fix — unused imports, variables that are never used:
- repo: https://github.com/pycqa/flake8
rev: 7.1.1
hooks:
- id: flake8
The full file, .pre-commit-config.yaml:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
- id: detect-private-key
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black
- repo: https://github.com/pycqa/flake8
rev: 7.1.1
hooks:
- id: flake8
Step 4 — first run, and proof of the formatter
Stage the files and run pre-commit against the whole repo:
git add .
pre-commit run --all-files
Real output obtained on this file:
check yaml...............................................................Passed
detect private key.......................................................Passed
black....................................................................Failed
- hook id: black
- files were modified by this hook
reformatted app.py
All done! ✨ 🍰 ✨
1 file reformatted.
flake8...................................................................Failed
- hook id: flake8
- exit code: 1
app.py:1:1: F401 'os' imported but unused
app.py:5:5: F841 local variable 'x' is assigned to but never used
Notice that flake8 still ran even though black failed right before it: by default, pre-commit runs every configured hook in one pass, even if one of them fails, to give you the full list of problems at once.
app.py was automatically rewritten by black. Before/after:
Before (your original file):
import os
def greet(name):
x = 1
print( "hello, "+name )
greet('world')
After (automatically rewritten by black):
import os
def greet(name):
x = 1
print("hello, " + name)
greet("world")
black fixed spacing, quotes, and blank lines — pure style. It did not touch the unused os import or the never-used x variable: that's not its job, that's the linter's.
Step 5 — fixing what flake8 flags
black can't decide for you whether an import or a variable is genuinely unnecessary — it leaves that to you. Edit app.py:
def greet(name):
print("hello, " + name)
greet("world")
Step 6 — the final proof
git add .
pre-commit run --all-files
Expected output, all green:
check yaml...............................................................Passed
detect private key.......................................................Passed
black....................................................................Passed
flake8...................................................................Passed
What you just did
You wrote a multi-hook pre-commit configuration from scratch: a repos/rev/hooks schema followed to the letter, an immutable rev on every repo, and three distinct roles (generic check, formatter, linter) cooperating in a single pass. In the next module, you deliberately break this configuration to learn how to diagnose a real pre-commit error — not a made-up one, the one the tool actually produces.
Check your understanding
Does this module depend on module 2 ("Adding a Feature")?
In .pre-commit-config.yaml, what exactly does a repo's `rev` field represent?
In this module's example, what happens when black fails but flake8 is also configured?
What are the three mandatory roles in this module's multi-hook configuration?
Want to hear about the next modules?
The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.