Aller au contenu principal
Nicolas Cousin Tech SolutionsNicolas Cousin Tech Solutions
Module 3 of 5

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 like local)
    • 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 its id

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.