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)