From f9edaa4e59e4cef91c519930642cd131e5c80b90 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 05:48:54 -0400 Subject: [PATCH] [ADD] git : un garde-fou commit-msg pour le sujet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rien ne tenait la convention sur le sujet : le tag est respecté partout, c'est la longueur qui glisse. Le hook refuse le mécanique et rien de plus — tag absent, plus de 72 caractères, sujet ouvrant sur une citation ; dire sur quoi porte le code reste un jugement qu'aucun hook ne rendra. Il compte des caractères et non des octets, sans quoi un sujet français de 72 caractères tomberait sur ses accents. Le refus enseigne le repli vers des mots-clés plutôt que la troncature, et nomme `--no-verify` : un garde-fou qui refuse trop est désinstallé. Les tests pèsent donc autant les acceptations, « Merge branch » ou fixup de rebase. Le lanceur balaie le préfixe test_git_ : 32 tests jamais exécutés, le total va de 1021 à 1071. --- EN --- Nothing held the subject convention: the tag is respected everywhere, it is the length that slips. The hook refuses the mechanical and nothing more — no tag, over 72 characters, a subject opening on a quotation; whether it says what the code is about stays a judgement no hook will make. It counts characters, not bytes, or a 72-character French subject would fall on its accents. The refusal teaches the fallback to keywords rather than truncation, and names `--no-verify`: a guard rail that refuses too much gets uninstalled. So the tests weigh the acceptances as much, a "Merge branch" or a rebase fixup. The runner sweeps the test_git_ prefix: 32 tests never ran, and the total goes from 1021 to 1071. Assisted-by: Claude Opus 5 (cherry picked from commit c9abf4d1b723ae8ff3762d39bfe1d6982400d12d) --- .claude/rules/04-code-conventions.md | 14 +++ conf/template_claude_commands_commit.md | 18 +++ script/git/commit_msg_lib.py | 71 +++++++++++ script/git/hooks/commit-msg | 48 ++++++++ script/test/run_unit_test.sh | 2 +- test/test_git_commit_msg.py | 152 ++++++++++++++++++++++++ 6 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 script/git/commit_msg_lib.py create mode 100755 script/git/hooks/commit-msg create mode 100644 test/test_git_commit_msg.py diff --git a/.claude/rules/04-code-conventions.md b/.claude/rules/04-code-conventions.md index 75e1616..b692e75 100644 --- a/.claude/rules/04-code-conventions.md +++ b/.claude/rules/04-code-conventions.md @@ -33,6 +33,20 @@ après le deux-points doivent dire QUOI. Le sujet résume le commit ENTIER, pas sa plus grosse pièce. S'il lui faut un « et » entre deux choses sans rapport, c'étaient deux commits. +Si le travail n'entre décidément pas dans une phrase de 72 caractères, ne pas +en écrire une amputée : des **mots-clés qui résument**, séparés par des +virgules, en disent plus dans la même place — `[FIX] proxmox : pmxcfs à terre, +pvesm muet, diagnostic à la source`. C'est un repli, pas un défaut : la phrase +reste préférable quand elle tient. + +Un garde-fou refuse le mécanique — tag absent, plus de 72 caractères, sujet +qui s'ouvre sur une citation : + +```bash +git config core.hooksPath script/git/hooks # une fois par clone +git commit --no-verify # exception légitime +``` + Le mode d'emploi complet, avec des exemples avant/après pris dans l'historique de ce dépôt, est dans `conf/template_claude_commands_commit.md`. diff --git a/conf/template_claude_commands_commit.md b/conf/template_claude_commands_commit.md index ad00dc4..01fd745 100644 --- a/conf/template_claude_commands_commit.md +++ b/conf/template_claude_commands_commit.md @@ -123,6 +123,24 @@ only honest subject needs an `and` joining two unrelated things, split it. `--oneline` has failed at the one place it is read most. Write it to fit rather than trimming it afterwards: drop the adjectives, keep the nouns. +When the work genuinely will not fit in a sentence, do not write an amputated +one — write **keywords that summarise**. A comma-separated list of the nouns +that matter says more in the space than half a sentence does: + +``` +[FIX] proxmox : pmxcfs à terre, pvesm muet, diagnostic à la source +[ADD] migration : copies de site, index doublés, réglages perdus +``` + +That form is a fallback, not a default. Prefer the sentence when it fits. + +**The guard rail.** `script/git/hooks/commit-msg` refuses a subject with no +tag, one over 72 characters, and one opening on a quotation. Install it with +`git config core.hooksPath script/git/hooks`; `git commit --no-verify` passes +a legitimate exception. It checks only what is mechanical — whether the +subject says what the code is about stays a judgement, and the test above is +how you make it. + ### Keep it short The body answers one question: why was this necessary. Stop once it is diff --git a/script/git/commit_msg_lib.py b/script/git/commit_msg_lib.py new file mode 100644 index 0000000..2c9366d --- /dev/null +++ b/script/git/commit_msg_lib.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le sujet d'un commit tient-il la convention ? + +La convention est dans `.claude/rules/04-code-conventions.md`, son mode +d'emploi dans `conf/template_claude_commands_commit.md`. Ce module en vérifie +la part MÉCANIQUE : le tag, la longueur, et le sujet qui s'ouvre sur une +citation. Le reste — « ce sujet dit-il sur quoi porte le code » — est un +jugement, et aucun hook ne le rendra. + +Compté en CARACTÈRES et non en octets : « préchauffer » pèse 12 caractères et +14 octets, et une limite en octets refuserait des sujets français conformes. +""" + +MAX = 72 + +TAGS = ("ADD", "FIX", "UPD", "IMP", "REF", "REM", "MOV", "I18N") + +# Ce que git écrit lui-même, ou ce qu'un rebase consomme : hors convention. +GENERATED = ("Merge ", "Revert ", "fixup!", "squash!", "amend!") + +# Un sujet qui s'ouvre sur une citation cite un écran. C'est une PREUVE, et une +# preuve va dans le corps — le sujet doit nommer la cause. +QUOTES = ("«", '"', "'", "`", "“", "‘") + + +def subject_of(message: str) -> str: + """La première ligne utile : ni commentaire, ni ligne vide.""" + for line in message.split("\n"): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + return stripped + return "" + + +def check(message: str) -> list: + """Rend la liste des problèmes. Vide si le sujet passe.""" + subject = subject_of(message) + if not subject or subject.startswith(GENERATED): + return [] + + problems = [] + + tag = None + for candidate in TAGS: + if subject.startswith(f"[{candidate}]"): + tag = candidate + break + if tag is None: + problems.append( + f"le sujet doit commencer par un tag : {', '.join('[%s]' % t for t in TAGS)}" + ) + + if len(subject) > MAX: + problems.append( + f"le sujet fait {len(subject)} caractères, {MAX} au plus.\n" + " Ne le tronquez pas : à cette longueur, écrivez des MOTS-CLÉS\n" + " qui résument plutôt qu'une phrase amputée. « proxmox : pmxcfs,\n" + " stockage, diagnostic » vaut mieux qu'une phrase coupée net." + ) + + rest = subject.split(":", 1)[1].strip() if ":" in subject else "" + if rest.startswith(QUOTES): + problems.append( + "le sujet s'ouvre sur une citation. Un message d'écran est une\n" + " preuve : elle va dans le corps. Le sujet nomme la cause." + ) + + return problems diff --git a/script/git/hooks/commit-msg b/script/git/hooks/commit-msg new file mode 100755 index 0000000..7674ef7 --- /dev/null +++ b/script/git/hooks/commit-msg @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Hook commit-msg : refuse un sujet hors convention. + +Installation : + + git config core.hooksPath script/git/hooks + +Contournement pour un cas légitime : `git commit --no-verify`. +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from commit_msg_lib import check # noqa: E402 + + +def main() -> int: + if len(sys.argv) < 2: + print("commit-msg : aucun fichier de message reçu", file=sys.stderr) + return 1 + path = Path(sys.argv[1]) + try: + message = path.read_text(encoding="utf-8") + except OSError as exc: + print(f"commit-msg : {exc}", file=sys.stderr) + return 1 + + problems = check(message) + if not problems: + return 0 + + print("\n ✗ sujet de commit hors convention\n", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + print( + "\n La règle : .claude/rules/04-code-conventions.md" + "\n Le mode d'emploi : conf/template_claude_commands_commit.md" + "\n Passer outre pour un cas légitime : git commit --no-verify\n", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index 6aadf94..9352e64 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -50,7 +50,7 @@ if [[ ${#FILES[@]} -eq 0 ]]; then # Aucun argument : tout ce que le dépôt sait tester sans base de données. mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \ test/test_todo_*.py test/test_install_*.py test/test_run_*.py \ - test/test_proxmox_*.py \ + test/test_proxmox_*.py test/test_git_*.py \ 2>/dev/null) fi diff --git a/test/test_git_commit_msg.py b/test/test_git_commit_msg.py new file mode 100644 index 0000000..0cd7d62 --- /dev/null +++ b/test/test_git_commit_msg.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le garde-fou du sujet de commit refuse-t-il ce qu'il doit, et rien d'autre ? + +Un hook qui refuse trop est désinstallé dans la semaine. Ces tests pèsent donc +autant les refus que les acceptations : un « Merge branch » que git écrit +lui-même, un sujet français de 70 caractères dont les accents pèsent 2 octets, +un fixup de rebase — tout cela doit passer. + +La part de la convention qu'aucun hook ne juge — « ce sujet dit-il sur quoi +porte le code » — n'est pas testée ici parce qu'elle n'est pas vérifiable. +""" +import os +import subprocess +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "script", "git")) + +from commit_msg_lib import MAX, check, subject_of # noqa: E402 + +HOOK = os.path.join( + os.path.dirname(__file__), "..", "script", "git", "hooks", "commit-msg" +) + + +class TestCeQuiPasse(unittest.TestCase): + def test_un_sujet_conforme(self): + self.assertEqual([], check("[FIX] proxmox : signaler pmxcfs à terre")) + + def test_les_huit_tags_de_la_convention(self): + for tag in ("ADD", "FIX", "UPD", "IMP", "REF", "REM", "MOV", "I18N"): + self.assertEqual([], check(f"[{tag}] portée : quelque chose"), tag) + + def test_un_sujet_de_72_caracteres_pile(self): + sujet = "[FIX] portée : " + "a" * (MAX - len("[FIX] portée : ")) + self.assertEqual(MAX, len(sujet)) + self.assertEqual([], check(sujet)) + + def test_les_accents_comptent_pour_un_caractere(self): + """« é » pèse 2 octets : une limite en octets refuserait ce sujet.""" + sujet = "[FIX] déploiement : " + "é" * (MAX - len("[FIX] déploiement : ")) + self.assertEqual(MAX, len(sujet)) + self.assertGreater(len(sujet.encode("utf-8")), MAX) + self.assertEqual([], check(sujet)) + + def test_ce_que_git_ecrit_lui_meme(self): + for genere in ( + "Merge branch 'develop' into master", + "Merge remote-tracking branch 'origin/develop'", + "Revert \"[FIX] portée : quelque chose\"", + "fixup! [FIX] portée : quelque chose", + "squash! [FIX] portée : quelque chose", + ): + self.assertEqual([], check(genere), genere) + + def test_un_message_vide_ou_commente(self): + """Un commit abandonné : git s'en occupe, le hook ne s'en mêle pas.""" + self.assertEqual([], check("")) + self.assertEqual([], check("# Please enter the commit message\n#\n")) + + def test_le_sujet_est_la_premiere_ligne_utile(self): + self.assertEqual( + "[FIX] portée : sujet", + subject_of("#\n# commentaire\n\n[FIX] portée : sujet\n\ncorps\n"), + ) + + +class TestCeQuiEstRefuse(unittest.TestCase): + def test_un_sujet_sans_tag(self): + problemes = check("corriger le pont NAT") + self.assertEqual(1, len(problemes)) + self.assertIn("tag", problemes[0]) + + def test_un_tag_inconnu(self): + self.assertTrue(check("[WIP] portée : quelque chose")) + + def test_un_sujet_de_73_caracteres(self): + sujet = "[FIX] portée : " + "a" * (MAX + 1 - len("[FIX] portée : ")) + self.assertEqual(MAX + 1, len(sujet)) + problemes = check(sujet) + self.assertEqual(1, len(problemes)) + self.assertIn("73 caractères", problemes[0]) + + def test_le_message_de_longueur_enseigne_les_mots_cles(self): + """Tronquer est le réflexe et c'est le mauvais : le hook doit le dire.""" + probleme = check("[FIX] portée : " + "a" * 80)[0] + self.assertIn("MOTS-CLÉS", probleme) + self.assertIn("Ne le tronquez pas", probleme) + + def test_un_sujet_qui_ouvre_sur_une_citation(self): + for ouvrant in ("«", '"', "'", "`", "“"): + problemes = check(f"[FIX] proxmox : {ouvrant}il manque le stockage{ouvrant}") + self.assertEqual(1, len(problemes), ouvrant) + self.assertIn("citation", problemes[0]) + + def test_une_citation_ailleurs_dans_le_sujet_passe(self): + """Seule l'OUVERTURE est refusée : citer en fin de sujet reste permis.""" + self.assertEqual( + [], check("[FIX] proxmox : pmxcfs à terre, dit « aucun stockage »") + ) + + def test_deux_problemes_sont_rapportes_ensemble(self): + problemes = check("proxmox : « un sujet sans tag et beaucoup trop long » " + "a" * 40) + self.assertEqual(3, len(problemes)) + + +class TestLeHookLuiMeme(unittest.TestCase): + """Le module peut être juste et le hook faux : on l'exécute vraiment.""" + + def _lancer(self, message): + with tempfile.NamedTemporaryFile( + "w", suffix=".txt", delete=False, encoding="utf-8" + ) as fh: + fh.write(message) + chemin = fh.name + try: + return subprocess.run( + [sys.executable, HOOK, chemin], + capture_output=True, + text=True, + ) + finally: + os.unlink(chemin) + + def test_il_sort_en_zero_sur_un_sujet_conforme(self): + r = self._lancer("[FIX] portée : quelque chose\n") + self.assertEqual(0, r.returncode, r.stderr) + + def test_il_sort_en_un_et_dit_pourquoi(self): + r = self._lancer("[FIX] portée : " + "a" * 80 + "\n") + self.assertEqual(1, r.returncode) + self.assertIn("hors convention", r.stderr) + self.assertIn("MOTS-CLÉS", r.stderr) + + def test_il_nomme_le_contournement(self): + """Sans issue annoncée, un hook se contourne en le supprimant.""" + r = self._lancer("pas de tag\n") + self.assertIn("--no-verify", r.stderr) + + def test_sans_argument_il_echoue_proprement(self): + r = subprocess.run( + [sys.executable, HOOK], capture_output=True, text=True + ) + self.assertEqual(1, r.returncode) + self.assertIn("aucun fichier", r.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=2)