<!--
  canonical-url: https://www.avonture.be/fr/blog/git-precommit
  published:     2025-01-10
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Git - pre-commit-hooks

> Vous détestez les pipelines CI/CD qui échouent ? Découvrez comment utiliser les hooks Git pre-commit, en particulier le framework pre-commit, pour appliquer vos standards de qualité de code en local avant de pusher.

---
![Git - pre-commit-hooks](/img/v2/clean_code.webp)

**TL;DR**

Évitez les pipelines CI/CD en échec en attrapant les erreurs avant de committer. Cet article explique comment utiliser les hooks Git pre-commit pour appliquer vos standards de qualité de code en local. Il propose un guide pratique pour installer et utiliser le framework `pre-commit`, un outil polyvalent capable de gérer des hooks multi-langages. Vous allez voir comment configurer des hooks pour un projet Python afin de détecter automatiquement des problèmes comme un formatage incohérent, et comment les installer et les lancer. L'article explique aussi où trouver d'autres hooks et quand il est légitime de les contourner.

Vous aimez le code propre, non ? Et vous détestez recevoir un email de votre outil de versioning (par exemple Github ou GitLab) qui vous annonce que votre dernier commit n'est pas passé, que le formatage de votre code est mauvais ; et tout ça parce que vous avez laissé un espace de trop en fin de ligne ou utilisé des simples quotes au lieu de doubles (ou l'inverse), par exemple.

Vous avez pushé vos modifications, vous êtes déjà passé à autre chose, peut-être un autre projet, et boum, deux heures après votre dernier commit (*parce que le serveur CI traitait plein de pipelines avant le vôtre*), boum, vous recevez un *Your last commit has failed, #gnagnagna*. Je hais ça autant que j'aime le code propre.

Alors, que faire pour éviter ça ?

La réponse est simple ! Avant chaque push, on devrait lancer les mêmes outils d'analyse de code que ceux exécutés dans la CI, c'est-à-dire `phplint`, `php-cs-fixer`, `phpcbf`, `phan`, `phpstan`, ... (pour PHP, tous regroupés dans l'[jakzal/phpqa Docker image](/blog/php-jakzal-phpqa)) ou `pylint`, `mypy`, `prospector`, `black`, `ruff`, ... (pour Python, voir [Python - Code Quality tools](/blog/python-qa)) ou `shellcheck` et `shellformat` (pour Bash sous Linux) ou ...

On devrait, mais y pense-t-on chaque fois ? Malheureusement non.

Voyons comment corriger ça.

## Le résultat : un commit reformaté avant même d'exister {#the-result-a-commit-reformatted-before-it-even-happens}

Voici `pre-commit` en train d'attraper (et de corriger) une violation de formatage, sur un projet de démo que vous allez construire plus bas :

```bash
$ pre-commit run --all-files
```

```bash
[INFO] Initializing environment for https://github.com/pre-commit/pre-commit-hooks.
[WARNING] repo `https://github.com/pre-commit/pre-commit-hooks` uses deprecated stage names (commit, push) which will be removed in a future version.  Hint: often `pre-commit autoupdate --repo https://github.com/pre-commit/pre-commit-hooks` will fix this.  if it does not -- consider reporting an issue to that repo.
[INFO] Installing environment for https://github.com/pre-commit/pre-commit-hooks.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
Check Yaml...........................................(no files to check)Skipped
Fix End of Files.....................................(no files to check)Skipped
Trim Trailing Whitespace.............................(no files to check)Skipped
black................................................(no files to check)Skipped
# git add .
# pre-commit run --all-files
Check Yaml...............................................................Passed
Fix End of Files.........................................................Passed
Trim Trailing Whitespace.................................................Passed
black....................................................................Failed
- hook id: black
- files were modified by this hook

reformatted main.py

All done! ✨ 🍰 ✨
1 file reformatted.
```

Vous avez vu la ligne **reformatted main.py** ? Une incohérence de quotes (`"double"` vs `'single'`) a été corrigée silencieusement par `black`, directement dans le stage local :

![Black a reformaté notre script](./images/black.webp)

Pas de run CI, pas d'email deux heures plus tard — la violation ne quitte jamais votre machine.

## Pourquoi ça fonctionne {#why-it-works}

Git propose deux étapes de hooks, `pre-` et `post-` ; un hook `pre-commit` s'exécute *avant* la création du commit et peut l'annuler complètement si quelque chose échoue.

Parmi les outils bâtis sur ce principe ([husky](https://github.com/typicode/husky), [pre-commit](https://github.com/pre-commit/pre-commit), [grumphp](https://github.com/phpro/grumphp), [CaptainHook](https://github.com/captainhookphp/captainhook)), cet article utilise **pre-commit** :

* Il est multi-langages — PHP, Python, Bash, tout ce dont le projet a besoin — un seul fichier de configuration.
* Il lance les mêmes outils que votre CI (`black`, `pylint`, `phpstan`, ...), juste plus tôt et gratuitement, sans runner à attendre.
* Une fois installé, il revérifie automatiquement à chaque `git commit` — rien à retenir, c'est imposé.

## Installation {#installation}

On va créer un nouveau dossier temporaire, lancer `git init` pour initialiser un projet, puis créer une image Docker et lancer un container pour notre démo.

D'abord, créez un dossier temporaire et placez-vous dedans, puis initialisez-le comme repository git (on va travailler hors ligne mais, oui, pour utiliser les hooks git pre-commit, il faut un projet git).

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

Il nous faut trois fichiers : un `Dockerfile` pour créer notre image Docker Python, un `compose.yaml` pour définir quelques paramètres et `main.py` comme exemple de script Python.

**Dockerfile**

```docker
# 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"]
```

**compose.yaml**

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

**main.py**

```python
print("I'm your Python code")

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

On va créer notre image Docker et un container avec cette seule commande : `docker compose up --detach --build`.

Et, maintenant, on entre dans le container en lançant : `docker compose exec app_python /bin/sh`.

Et on peut lancer notre script :

```bash
$ python main.py
I'm your Python code
Who you, who are you?
```

### Installer pre-commit {#installing-pre-commit}

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

Pour un projet Python, c'est vraiment facile, il suffit de lancer `pip install pre-commit`.

### Ajouter un fichier de configuration {#adding-a-configuration-file}

Simple aussi, créez un fichier appelé `.pre-commit-config.yaml` avec ce contenu :

**.pre-commit-config.yaml**

```yaml
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
```

### Déclencher le hook manuellement {#manually-fire-the-hook}

`pre-commit` peut être déclenché manuellement mais il faut avoir des fichiers dans le stage local git. Dans cet article, on a créé quelques fichiers, lancez `git add .` simplement pour les mettre dans le stage local git.

Maintenant, pour démarrer manuellement tous les contrôles définis dans le fichier yaml, lancez simplement `pre-commit run --all-files` — c'est exactement la commande et la sortie montrées en haut de cet article.

Vous avez remarqué ma coquille ?

```python
print("I'm your Python code")

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

La première fois, j'ai utilisé des doubles quotes (dans le premier `print`) alors que j'ai utilisé des simples dans le second. J'ai donc (volontairement) créé une violation de code et l'outil `black` l'a vue et a reformaté `main.py`, exactement comme montré plus haut.

## Autres démos {#more-demos}

### Installer les hooks {#install-hooks}

Ok, l'idée n'était pas de déclencher les hooks pre-commit à la main, non ? Lancez simplement `pre-commit install` et, à partir de maintenant, chaque fois que vous lancerez `git commit`, les contrôles `pre-commit` seront d'abord effectués et c'est seulement quand tous les contrôles réussissent (c'est-à-dire qu'ils renvoient tous un code de sortie `0`) que votre commit sera autorisé.

### Quelques hooks en plus {#a-few-more-hooks}

Il existe énormément de hooks et vous pouvez même créer les vôtres :

* [Code spell](https://github.com/codespell-project/codespell/blob/main/.pre-commit-config.yaml#L70), *Check code for common misspellings*
* [Git leaks](https://github.com/gitleaks/gitleaks/blob/master/.pre-commit-hooks.yaml), *Find secrets with Gitleaks*
* [Git lint](https://github.com/jorisroovers/gitlint), *Linting for your git commit messages*
* [Markdown format](https://github.com/hukkin/mdformat/blob/master/.pre-commit-config.yaml), *CommonMark compliant Markdown formatter*
* [Markdownlint](https://github.com/markdownlint/markdownlint/blob/main/.pre-commit-hooks.yaml), *Markdown lint tool*
* [Ruff](https://github.com/astral-sh/ruff-pre-commit/blob/main/.pre-commit-hooks.yaml), *A pre-commit hook for Ruff.*
* [Shell check](https://github.com/shellcheck-py/shellcheck-py?tab=readme-ov-file#as-a-pre-commit-hook), *python3/pip3 wrapper for installing shellcheck*
* [Trufflehog](https://github.com/trufflesecurity/trufflehog/blob/main/.pre-commit-config.yaml), *Find, verify, and analyze leaked credentials*

### Une configuration Python 3.13 prête à l'emploi {#a-ready-made-python-313-config}

Pour un projet Python 3.13, voici mon fichier `.pre-commit-config.yaml` :

**.pre-commit-config.yaml**

```yaml
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"]
```

## Sous le capot (passez ce point si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}

> **Note:**
>
> Si la mécanique vous intéresse, affichez simplement le fichier `.git/hooks/pre-commit`. L'instruction `pre-commit install` a configuré git pour exécuter un petit script Bash appelé `.git/hooks/pre-commit`.

### Chercher des hooks et écrire les vôtres {#search-for-hooks-and-write-your-own}

Prenez le temps de parcourir <https://github.com/pre-commit/pre-commit-hooks> pour en découvrir quelques-uns, ou cherchez sur <https://sourcegraph.com/search> avec des queries comme `context:global file:^\.pre-commit-hooks\.yaml$ "types: [python]"` par exemple ([lien direct](https://sourcegraph.com/search?q=context:global+file:%5E%5C.pre-commit-hooks%5C.yaml%24+%22types:+%5Bpython%5D%22\&patternType=keyword\&sm=0)).

Comme illustré sur <https://pre-commit.com/#repository-local-hooks>, vous pouvez ajouter des hooks locaux.

Imaginez : vous avez déjà installé un outil comme `prospector` (pour Python) ou `phpstan` (pour PHP). Ces outils sont installés sur votre machine (vous pouvez donc les appeler en ligne de commande). Il suffit alors d'ajouter un nouveau hook comme ceci :

**.pre-commit-config.yaml**

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

Vous pouvez aussi passer des arguments :

**.pre-commit-config.yaml**

```yaml
- 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"]
```

## Astuce : --no-verify {#tip---no-verify}

Dans certaines situations, vous devez pusher vos modifications même s'il reste des violations de code. Disons que c'est votre dernière heure avant trois semaines de vacances et que vous travaillez seul sur une branch comme `feat-user-profile`. Vous voulez pusher vos modifications et profiter de la pause.

Dans ce cas, vous pouvez ajouter le flag `--no-verify`, par exemple `git commit -m "wip: not yet finished" --no-verify`. Et là, les hooks pre-commit ne seront pas exécutés, donc vos modifications seront committées.

> **Le flag --no-verify:**
>
> N'utilisez ce flag que si vous savez exactement ce que vous faites. Ce serait une très mauvaise idée de faire ça, par exemple, sur la branch `dev` si vous travaillez en équipe.

## Conclusion {#conclusion}

Les emails de CI en échec de l'introduction disparaissent dès que les mêmes contrôles tournent en local, avant que le commit n'existe — `pre-commit` transforme « attendre que le serveur vous le dise » en « le savoir immédiatement ». Installez-le une fois (`pre-commit install`) et tous vos futurs commits sont couverts, dans tous les langages que votre projet mélange.

Gardez `--no-verify` sous le coude pour la rare exception assumée, et consultez [Python - Code Quality tools](/blog/python-qa) pour les outils sur lesquels reposent les hooks de la démo de cet article.
