git rm et savoir dans quel ordre réagir~/projets/quizops et jq installé (voir Exercices)gh, le client GitHub, et un compte GitHub50 minutes : 10 pour l'exercice 1, 15 pour le 2, 13 pour le 3, 12 pour le 4.
Le tag tp7-depart reconstruit l'état de sortie du TP 6 : la validation des entrées, les tests du tirage et les deux skills /revue et nouvelle-question. Le dépôt n'a encore ni chaîne d'intégration, ni hook.
cd ~/projets/quizops
outils/depart.sh 7
............................. [100%]
29 passed in 0.20s
$ ls .github
ls: .github: No such file or directory
$ jq .hooks .claude/settings.json
null
Provoquer la fuite classique, mesurer ce que le réflexe habituel efface vraiment, et fixer l'ordre des opérations.
Le script du formateur écrit un .env avec une fausse clé et le committe.
$ outils/fuite.sh
Le fichier .env est committé avec une fausse clé. À vous de jouer.
$ git log --oneline -1
<hash> Ajoute la configuration locale
$ git log -p --all | grep -c sk-ant-fausse
1
Le fichier était pourtant déjà ignoré par Git : outils/fuite.sh a forcé l'ajout avec git add -f.
$ grep -n '^\.env$' .gitignore
7:.env
$ git rm --cached .env
$ git commit -m "Retire le fichier .env du dépôt"
$ git log -p --all | grep -c sk-ant-fausse
2
❌ Le compteur monte au lieu de tomber. Le commit de suppression ajoute un diff qui réaffiche la ligne retirée, et le commit d'origine reste intact.
Les deux commits sont locaux, la réécriture est donc encore indolore.
$ git reset --hard HEAD~2
$ rm -f .env
$ git log -p --all | grep -c sk-ant-fausse
0
Note : dans la vraie vie, la première commande n'est pas une commande Git. Une clé committée est compromise dès que le commit a été poussé ou cloné : on la révoque chez le fournisseur, on en génère une nouvelle, et on nettoie l'historique ensuite. Sur une branche partagée,
git reset --hardne suffit plus.
Les trois relevés du même compteur : 1 après la fuite, 2 après le git rm --cached et son commit, 0 après la réécriture. Le dépôt est revenu à l'état du point de départ, git log --oneline -1 affichant de nouveau Ajoute une question sur le port du kubelet et git status --short restant muet.
2 après le git rm --cached ?.env était déjà dans .gitignore. Pourquoi le dépôt a-t-il quand même été pollué ?Q: Pourquoi le compteur monte-t-il à 2 après le git rm --cached ?
R: Le commit d'origine contient toujours le fichier, et le commit de suppression contient un diff qui réaffiche la ligne supprimée. git log -p --all parcourt tous les diffs, il en voit donc deux.
Q: .env était déjà dans .gitignore. Pourquoi le dépôt a-t-il quand même été pollué ?
R: .gitignore ne concerne que les fichiers non suivis, et se contourne avec git add -f. Un fichier déjà suivi continue d'être committé quoi qu'il arrive.
statistiques, du Commit à la CI (Libre)Livrer GET /api/statistiques sur une branche dédiée, en trois commits lisibles, avec une chaîne d'intégration qui rejoue la vérification.
Le README annonce une route de statistiques qui n'existe pas encore. Ses tests d'acceptation attendent dans tests/a-venir/ et fixent le seul contrat : deux champs, nb_parties et moyenne.
statistiques depuis la branche du TP.tests/a-venir/test_statistiques.py vers tests/ et lancez la suite : elle doit afficher 2 failed, 29 passed..github/workflows/ci.yml en troisième commit : uv sync, formatage, analyse, tests.gh pr create --fill, ou listez les commits de la branche si vous n'avez ni gh ni compte GitHub./code-review sur la branche, puis fusionnez avec git merge --no-ff statistiques.Contraintes à imposer dans votre prompt
nb_parties et moyenne.app/database.py, jamais dans app/main.py.push et sur pull_request.💡 Indice : demandez le message de commit à partir de git diff --staged, la seule source qui ne se trompe pas sur ce qui a changé.
$ uv run pytest -q
............................... [100%]
31 passed in 0.20s
$ git log --oneline main..statistiques | wc -l
3
$ grep -c 'uv run pytest' .github/workflows/ci.yml
1
1 et 2. La branche et les tests en premier
$ git checkout -b statistiques
$ mv tests/a-venir/test_statistiques.py tests/
$ uv run pytest -q
FAILED tests/test_statistiques.py::test_les_statistiques_ont_un_nombre_de_parties_et_une_moyenne
FAILED tests/test_statistiques.py::test_deux_parties_font_monter_le_compteur_de_deux
2 failed, 29 passed in 0.21s
3. Un prompt possible, puis la demande du message
Ajoute la route GET /api/statistiques. Elle renvoie deux champs :
nb_parties (le nombre de parties enregistrées) et moyenne (le score
moyen, arrondi à l'entier). La requête SQL va dans app/database.py,
la route dans app/main.py. Aucune dépendance nouvelle. Les tests de
tests/test_statistiques.py fixent le contrat, ne les modifie pas.
Lance uv run pytest -q à la fin et montre-moi le résultat.
Voici le diff indexé : git diff --staged. Propose un message de commit
en français, une ligne de sujet sous 72 caractères et un corps qui dit
pourquoi, pas quoi.
L'agent lit le diff avant de répondre, ici sur une autre modification du projet :
⏺ Bash(git diff README.md)
⎿ diff --git a/README.md b/README.md (+10 lignes)
⏺ Voici le message de commit proposé :
Précise que le quiz est en français
Le README décrivait le quiz sans mentionner sa langue.
4. Le fichier .github/workflows/ci.yml
Le fichier complet du dépôt de référence est celui du module 06 ; ses étapes sont la séquence de vérification locale, rejouée par la forge.
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
with:
python-version: "3.13"
- run: uv sync
- run: uvx ruff format --check .
- run: uvx ruff check .
- run: uv run pytest -q
Le --check du formatage échoue au lieu de corriger : la chaîne constate, elle ne modifie pas le dépôt.
5 et 6. La pull request, la revue, la fusion
git push -u origin statistiques
gh pr create --fill
gh pr create --fill reprend le titre et le corps depuis les commits de la branche, puis affiche l'URL de la pull request créée. Sans gh ni compte GitHub, la preuve reste git log --oneline main..statistiques, et le TP se poursuit à l'identique.
/code-review relit ensuite la branche et rend ses constats ; ce skill livré s'exprime en anglais par défaut. Après la fusion en --no-ff, l'historique garde la trace de la branche :
* <hash> Fusionne la branche statistiques
|\
| * <hash> Ajoute la chaîne d'intégration continue
| * <hash> Ajoute les tests des statistiques
| * <hash> Ajoute la route /api/statistiques
|/
* <hash> Ajoute une question sur le port du kubelet
✅ Trois commits, trois sujets. Un commit fourre-tout aurait été plus rapide à écrire, et impossible à relire ou à annuler séparément.
Installer un hook PostToolUse qui formate tout fichier Python écrit par l'agent, et vérifier qu'il s'exécute même quand la consigne demande le contraire.
Ajoutez la section hooks à .claude/settings.json, à côté de la section permissions qui existe déjà.
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | grep '\\.py$' | xargs -r uvx ruff format -q"
}
]
}
]
}
La commande reçoit le JSON de l'appel d'outil sur son entrée standard, en extrait le chemin, ne garde que les fichiers .py et appelle le formateur. Le -r de xargs évite de lancer ruff quand grep ne renvoie rien.
Voici les quatre lignes de ce JSON qui intéressent le hook, extraites d'un appel réel :
"hook_event_name": "PostToolUse",
"tool_name": "Edit",
"tool_input": {
"file_path": "~/projets/quizops/README.md",
Note : le hook va dans
.claude/settings.json, qui est versionné, donc il protège toute l'équipe..claude/settings.local.jsonest ignoré par Git et ne sert qu'aux réglages personnels.
Les hooks sont lus au démarrage : quittez avec Ctrl+D, relancez claude, puis demandez un fichier volontairement mal écrit.
Crée app/exemple.py avec une fonction addition(a, b) qui renvoie a + b.
Écris-le mal exprès : imports sur une seule ligne, indentation de six
espaces, deux lignes vides au milieu. Ne le formate pas.
Voici ce que vous devez voir : l'agent écrit, relit, et constate le reformatage.
⏺ Write(~/projets/quizops/app/exemple.py)
⎿ File created successfully at: ~/projets/quizops/app/exemple.py
⏺ Read(~/projets/quizops/app/exemple.py)
⎿ 1 import os, sys (+5 lignes)
⏺ A PostToolUse hook in this project auto-formats files after every write
(likely running `ruff format`), so it reformatted `app/exemple.py` back to
clean style right after I wrote it. I can't bypass that hook myself.
$ cat app/exemple.py
import os, sys
def addition(a, b):
return a + b
L'indentation et les lignes vides sont normalisées. La ligne import os, sys reste en place : ruff format met en forme sans réécrire les imports, ce que fait ruff check --fix.
✅ La consigne « ne le formate pas » a été suivie par le modèle et annulée par le hook, exécuté par Claude Code après chaque Write ou Edit réussi.
$ uvx ruff format --check app/exemple.py
1 file already formatted
$ jq '.hooks.PostToolUse[0].matcher' .claude/settings.json
"Write|Edit"
Supprimez le fichier d'essai (rm app/exemple.py). Le menu /hooks de la session liste les hooks actifs par événement ; la source de vérité reste le fichier JSON.
Écrire un hook PreToolUse qui refuse les commandes destructrices, et le vérifier sans jamais lancer Claude Code.
Un hook qui ne se vérifie qu'en conversation reste un pari. Celui-ci est un script shell ordinaire, testable dans un terminal.
.claude/hooks/garde-fou.sh : il lit la commande avec jq -r '.tool_input.command', écrit un message sur la sortie d'erreur et sort en 2 quand la commande est interdite, sort en 0 sinon. Rendez-le exécutable.rm récursif dont la cible n'est pas sous /tmp, et un git push forcé (--force ou -f).PreToolUse avec le matcher Bash dans .claude/settings.json, en désignant le script par "$CLAUDE_PROJECT_DIR"./tmp et une commande banale.data/, puis committez les deux hooks ensemble.Contraintes à imposer dans votre prompt
echo et un |, et son message d'erreur cite la commande refusée.exit 2 bloque, exit 0 laisse passer, aucun autre code."$CLAUDE_PROJECT_DIR", jamais par un chemin absolu du poste.💡 Indice : jq -r '.tool_input.command // empty' renvoie une chaîne vide quand le champ est absent, ce qui évite un null littéral dans le test.
$ echo '{"tool_input":{"command":"rm -rf data"}}' | .claude/hooks/garde-fou.sh; echo $?
Commande interdite par la politique du projet : rm -rf data
2
$ echo '{"tool_input":{"command":"rm -rf /tmp/cache"}}' | .claude/hooks/garde-fou.sh; echo $?
0
$ echo '{"tool_input":{"command":"ls"}}' | .claude/hooks/garde-fou.sh; echo $?
0
$ echo '{"tool_input":{"command":"git push --force origin main"}}' | .claude/hooks/garde-fou.sh; echo $?
Commande interdite par la politique du projet : git push --force origin main
2
1 et 2. Le script, ici sans son en-tête de commentaires, suivi de chmod +x .claude/hooks/garde-fou.sh :
#!/usr/bin/env bash
commande="$(jq -r '.tool_input.command // empty')"
bloquer() {
echo "Commande interdite par la politique du projet : $commande" >&2
exit 2
}
# rm récursif, sauf sous /tmp
if [[ "$commande" =~ rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+([^[:space:]]+) ]]; then
[[ "${BASH_REMATCH[1]}" == /tmp/* ]] || bloquer
fi
# push forcé
if [[ "$commande" =~ git[[:space:]]+push.*(--force|[[:space:]]-f([[:space:]]|$)) ]]; then
bloquer
fi
exit 0
3. La déclaration
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/garde-fou.sh"
}
]
}
]
5. Le passage par l'agent
La demande peut s'arrêter avant le hook : l'agent inspecte le dossier et refuse de lui-même.
⏺ Bash(ls -la ~/projets/quizops/data)
⎿ total 8 (+3 lignes)
⏺ Je n'ai pas exécuté la commande : `data/` contient `questions.json`, le
fichier source des questions du quiz. Confirmes-tu que tu veux le supprimer ?
✅ Ce refus dépend du prompt, du modèle et du contexte. La garantie, elle, est le code de retour 2 du script, qui se rejoue à l'identique dans un terminal.
Sans capture ici : un hook Stop qui lance uv run pytest -q et sort en 2 quand la suite est rouge oblige la session à corriger avant de rendre la main.
git rm : on révoque la clé d'abord, on réécrit l'historique ensuite.--check du formatage.exit 2 bloque, et .claude/settings.json le partage avec l'équipe.Rendez-vous au TP 8 pour sortir du dépôt : un navigateur piloté par l'agent, une documentation à jour interrogée en direct, et tout l'outillage du projet empaqueté en plugin.
➡️ Module associé : Git, CI/CD et Hooks Déterministes · TP suivant : TP 8 : MCP et Plugins