Skip to main content

Git - pre-commit-hooks

Hate failed CI/CD pipelines? Discover how to use Git pre-commit hooks, focusing on the pre-commit framework, to enforce code quality standards locally before you push.

· 8 min read
View raw
Christophe
Markdown, WSL and Docker lover ~ Insatiable curious.
Cet article existe aussi en français — lire la version française.

Changelog

Latest
Restructured for time-to-value: the reformatting proof now comes before the setup steps; added a Conclusion.

Git - pre-commit-hooks

⚡
TL;DR

Avoid failing CI/CD pipelines by catching errors before you commit. This article explains how to use Git pre-commit hooks to enforce code quality standards locally. It provides a practical guide to setting up and using the pre-commit framework, a versatile tool for managing multi-language hooks. You'll learn how to configure hooks for a Python project to automatically check for issues like inconsistent formatting, and how to install and run them. The article also covers how to find more hooks and when it's appropriate to bypass them.

You like clean code, don't you? And you hate getting an email from your versioning tool (e.g. Github or GitLab) telling you that your last commit didn't go through, that the formatting of your code is bad; this is because you've left one space too many at the end of a line or you've used single quote instead of double (or the opposite), for example.

You've pushed your changes, already started to work on another activity, perhaps another project and boom, two hours after your last commit (because the CI server was working on a lot of pipelines before yours), boom, you get a Your last commit has failed, #gnagnagna. I hate it as much as I love clean code.

So, what should we do to avoid this?

The answer is simple! Before each push, we should run the same code analysis tools that are executed in the CI, i.e. phplint, php-cs-fixer, phpcbf, phan, phpstan, ... (for PHP, all bundled in the jakzal/phpqa Docker image) or pylint, mypy, prospector, black, ruff, ... (for Python, see Python - Code Quality tools) or shellcheck and shellformat (for Linux Bash) or ...

We should, but do we think about it every time? Unfortunately not.

Let's see how to correct this.

The Result: A Commit, Reformatted Before It Even Happens​

Here is pre-commit catching (and fixing) a formatting violation, on a demo project you'll build below:

>user@machine: ~/yourproject

$ pre-commit run --all-files

>user@machine: ~/yourproject

Did you see the reformatted main.py line? A quoting inconsistency ("double" vs 'single' quotes) got silently fixed by black, right there in the local stage:

Black has reformatted our script

No CI run, no email two hours later — the violation never leaves your machine.

Why It Works​

Git ships two hook stages, pre- and post-; a pre-commit hook runs before the commit is created and can abandon it entirely if something fails.

Among the tools that build on this (husky, pre-commit, grumphp, CaptainHook), this article uses pre-commit:

  • It's multi-language — PHP, Python, Bash, whatever the project needs — one config file.
  • It runs the same tools your CI already runs (black, pylint, phpstan, ...), just earlier and for free, with no runner to wait for.
  • Once installed, it re-checks automatically on every git commit — nothing to remember, it's enforced.

Installation​

We'll create a new temporary folder, run git init to initialise a project and create a Docker image and run a container for our demo.

First, create a temporary folder and jump into it, then initialise it as a git repository (we'll work offline but, yes, to use git pre-commit hooks, we need a git project).

>user@machine: ~/yourproject

$ mkdir /tmp/hooks && cd $_ $ git init

We'll need three files, a Dockerfile to create our Python Docker image, a compose.yaml to set some settings and main.py as a Python example script.

# syntax=docker/dockerfile:1
FROM python:3.13-slim AS base
RUN apt-get -y update \
&& apt-get install --yes --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR "/app/src"
# Keep the container running
ENTRYPOINT ["tail", "-f", "/dev/null"]

This Dockerfile creates a Python 3.13 container with git installed, sets the working directory to /app/src, and keeps the container running indefinitely so you can shell into it for debugging or development. The 'slim' variant is used to keep the image small.

services:
app_python:
build: .
volumes:
- .:/app/src

This is a Docker Compose configuration that defines a single service called app_python. It builds a Docker image from the current directory and mounts the current directory into the container so that code changes on your machine are immediately reflected inside the running container.

print("I'm your Python code")

print('Who you, who are you?')

This script simply prints two string messages to the console using Python's print function. It demonstrates basic output in Python.

We'll create our Docker image and create a container with this single command: docker compose up --detach --build.

And, now, we'll jump in the container by running: docker compose exec app_python /bin/sh.

And we can run our script:

>user@machine: ~/yourproject

Installing pre-commit​

https://pre-commit.com/#install

For a Python project, it's really easy, you just need to run pip install pre-commit.

Adding a configuration file​

Simple too, please create a file called .pre-commit-config.yaml with this content:

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.3.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black

This is a pre-commit configuration file that sets up automated code checks to run before each git commit. It installs two tools: pre-commit-hooks (which does basic file validation) and Black (a code formatter), along with specific checks like validating YAML syntax and removing trailing whitespace.

Manually fire the hook​

pre-commit can be manually fired but you should have some files in your git local stage. In this article, we've created a few files, please run git add . just to put them in the git local stage.

Now, to manually start all controls defined in the yaml file simply run pre-commit run --all-files — that's the exact command and output shown at the top of this article.

Did you notice my typo?

print("I'm your Python code")

print('Who you, who are you?')

The first time I've used double quotes (in the first print statement) while I've used single ones in the second. So, I've (voluntary) created a code violation and the black tool has see it and reformatted main.py, exactly as shown above.

More Demos​

Install hooks​

Ok, the idea wasn't to fire pre-commit hooks manually, right? Just run pre-commit install and, from now, every single time you'll run git commit, first, pre-commit controls will be made and only when all controls are successful (i.e. all will return an exit code of 0), then your commit will be allowed.

A few more hooks​

There are a lot of existing hooks and you can even create yours:

A ready-made Python 3.13 config​

For a Python 3.13 project, here is my .pre-commit-config.yaml file:

default_language_version:
python: python3.13
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-ast # Python linter - Simply check whether files parse as valid python.
- id: check-docstring-first # No code before the initial docstring
- id: check-json # JSON linter - Attempts to load all json files to verify syntax.
- id: check-toml # TOML linter - Attempts to load all TOML files to verify syntax.
- id: check-xml # XML linter - Attempts to load all XML files to verify syntax.
- id: check-yaml # YAML linter - Attempts to load all yaml files to verify syntax.
- id: check-merge-conflict # Check for files that contain merge conflict strings.
- id: end-of-file-fixer # Makes sure files end in a newline and only a newline.
- id: file-contents-sorter
files: \.config/*.txt$ # Sort all lines in any .config/*.txt file (like cspell.txt f.i.)
args: [--ignore-case]
- id: no-commit-to-branch
args: [--branch, main] # Don't allow to push directly on the main branch
- id: requirements-txt-fixer # Sorts entries in requirements.txt
- id: trailing-whitespace # Trims trailing whitespace.
- repo: https://github.com/psf/black
rev: 24.10.0
hooks:
- id: black # Python black formatter
- repo: local
hooks:
- id: isort # isort (should be installed using `pip install isort` or mentioned in the `requirements.txt` file)
name: isort
entry: isort
language: system
types: [python]
require_serial: true
args: ["--line-length 120"]
- id: mypy # Mypy (should be installed using `pip install mypy` or mentioned in the `requirements.txt` file)
name: mypy
entry: mypy
language: system
types: [python]
require_serial: true
args: ["--config-file=.config/mypy.ini"]
- id: pylint # Pylint (should be installed using `pip install pylint` or mentioned in the `requirements.txt` file)
name: pylint
entry: pylint
language: system
types: [python]
require_serial: true
args: ["--rcfile=.config/.pylintrc"]

This is a pre-commit configuration file that sets up automated code quality checks to run before commits. It specifies Python 3.13 as the default version, then lists various linters and formatters (like black, mypy, and pylint) that will automatically validate code syntax, style, and type safety whenever someone tries to commit changes.

Under the Hood (skip this if you just want to use it)​

Note

If you're curious about how it works, simply show the .git/hooks/pre-commit file. The pre-commit install instruction has configured git to execute a small Bash script called .git/hooks/pre-commit.

Search for hooks and write your own​

Take time to surf on https://github.com/pre-commit/pre-commit-hooks to see a few of them, or search on https://sourcegraph.com/search with queries like context:global file:^\.pre-commit-hooks\.yaml$ "types: [python]" f.i. (direct link).

As illustrated on https://pre-commit.com/#repository-local-hooks, you can add local hooks.

Imagine, you've already installed a tool like prospector (for Python) or phpstan (for PHP). These tools are installed on your machine (so you can call them on the command line). So, simply add a new hook like this:

- repo: local
hooks:
- id: prospector
name: prospector
entry: prospector
language: system

This is a configuration for a pre-commit hook that runs 'prospector', a Python code analysis tool. When developers commit code, this hook will automatically execute prospector to check for code quality issues before the commit is allowed.

You can also pass arguments:

- repo: local
hooks:
- id: mypy # Mypy (should be installed using `pip install mypy`)
name: mypy
entry: mypy
language: system
types: [python]
require_serial: true
args: ["--config-file=.config/mypy.ini"]

This is a pre-commit hook configuration that runs mypy, a Python type checker, on Python files before commits. It tells pre-commit to execute mypy with a custom configuration file located at .config/mypy.ini, and to process files serially (one at a time) rather than in parallel.

Tip: --no-verify​

In some situation, you've to push your changes even if there are some code violation. Let's say, it's your last hour before three weeks holiday and you're working alone on a branch like feat-user-profile. You wish to push your changes and enjoy a break.

In that situation, you can add the --no-verify flag f.i. git commit -m "wip: not yet finished" --no-verify. And, then, pre-commit hooks won't be executed so your changes will be committed.

The --no-verify flag

Only use this flag if you know exactly what you're doing. It would be a very bad idea to do this f.i. to the dev branch if you're working in a team.

Conclusion​

Those failed-CI emails from the introduction stop happening once the same checks run locally, before the commit exists — pre-commit turns "wait for the server to tell you" into "know immediately." Install it once (pre-commit install) and every future commit is covered, in any language your project mixes.

Keep --no-verify in your back pocket for the rare deliberate exception, and see Python - Code Quality tools for the tools this article's demo hooks are built on.


Was this article helpful?
Did you try the steps in this article?