[UPD] commit rule: English subject first, French title under --- FR ---

git log --oneline shows only the subject, and it read in whichever language
the author was thinking in. The subject and the body under it are now in
English, then --- FR --- opens the French section, which starts with the
subject translated under the same tag. The hook refuses a --- EN --- marker
and a French section without that title, and checks the title like a
subject; it does not count against the body budget. Whether the subject is
really English is not checked. /commit and /git_prepare_merge follow.
Checked: 55 hook tests, 7 of them new; i18n tests pass.

--- FR ---

[UPD] règle de commit : sujet anglais d'abord, titre FR sous --- FR ---

git log --oneline ne montre que le sujet, et il se lisait dans la langue où
l'auteur pensait. Le sujet et le corps qui le suit sont désormais en anglais,
puis --- FR --- ouvre la section française, qui commence par le sujet traduit
sous le même tag. Le hook refuse un marqueur --- EN --- et une section
française sans ce titre, et juge ce titre comme un sujet ; il ne compte pas
dans le budget du corps. Que le sujet soit vraiment en anglais ne se vérifie
pas. /commit et /git_prepare_merge suivent.
Vérifié : 55 tests du hook, dont 7 nouveaux ; tests i18n au vert.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-09-25 01:31:27 -04:00
parent aa3f99e746
commit 76c548a781
6 changed files with 186 additions and 40 deletions

View file

@ -90,9 +90,9 @@ liste, et elle est vraie demain.
- Branches : `develop` (développement), `master` (production)
- Pas de submodules Git — utilise **Google Repo** pour les addons
- Manifests XML dans `manifest/` pour chaque version Odoo
- Format de commit : `[TYPE] portée : sujet`, sujet à l'impératif, 72
caractères au plus. Tags réellement utilisés : `[UPD]`, `[FIX]`, `[ADD]`,
`[IMP]`, `[REF]`.
- Format de commit : `[TYPE] scope: subject`, sujet **en anglais**, à
l'impératif, 72 caractères au plus. Tags réellement utilisés : `[UPD]`,
`[FIX]`, `[ADD]`, `[IMP]`, `[REF]`.
- **Nommer les fichiers à l'indexation, jamais `git add -A`** : `private/` et
`tasks/` ne sont pas suivis EXPRÈS, et un ratissage les commit. Il emporte
aussi ce qui est en cours ailleurs dans le checkout, sous un sujet qui ne le
@ -117,14 +117,17 @@ Le sujet résume le commit ENTIER, pas sa plus grosse pièce. S'il lui faut un
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.
virgules, en disent plus dans la même place — `[FIX] proxmox: pmxcfs down,
pvesm silent, diagnosis at the 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. Sur le sujet : tag absent, plus de 72
caractères, ouverture sur une citation. Sur le corps : plus de 10 lignes pour
une langue, une adresse IP, un courriel, un chemin de compte. Ce qui reste un
jugement — « ce corps raconte-t-il l'enquête » — n'est vérifié par personne.
une langue, une adresse IP, un courriel, un chemin de compte. Sur l'ordre des
langues : un marqueur `--- EN ---`, une section française qui ne s'ouvre pas
sur le sujet traduit sous le même tag. Que le sujet soit bien en anglais
reste à l'auteur. Ce qui reste un jugement — « ce corps raconte-t-il
l'enquête » — n'est vérifié par personne.
```bash
git config core.hooksPath script/git/hooks # une fois par clone
@ -141,8 +144,9 @@ Trois exigences, sans exception — `AI_POLICY.md` en donne la raison :
- Trailer `Assisted-by: <modèle>`, une ligne par modèle. C'est **binaire** :
il y a eu IA ou non, aucun seuil à apprécier.
- **Jamais** d'IA dans `Co-authored-by:` — ce champ est réservé aux humains.
- Corps **bilingue** : le corps, puis `--- FR ---` (ou `--- EN ---`, le
marqueur nomme la langue de ce qui SUIT), puis la traduction.
- Message **bilingue, l'anglais d'abord** : le sujet et le corps en anglais,
puis `--- FR ---`, puis le sujet traduit en français sous le même tag, puis
le corps traduit. `--- EN ---` n'existe plus : l'ordre ne varie jamais.
Court et direct : **8 lignes par langue**, 10 est un plafond. Le corps dit
pourquoi c'était nécessaire, puis s'arrête. Rien de ce que le diff montre

View file

@ -77,7 +77,7 @@ The first five cover every one of the last 400 commits. Reach for `[REM]`,
### Format
```
[TAG] scope: short description in imperative mood
[TAG] scope: short description in imperative mood, in English
Explain WHY the change was made — the diff already shows what. Name the
failure mode, and what was measured rather than assumed, in words a
@ -87,11 +87,18 @@ Wrap at 80 characters.
--- FR ---
[TAG] portée : le sujet, traduit en français
The same body, translated.
Assisted-by: {MODEL}
```
The subject line is in ENGLISH, and the English body follows it: together
they are the English section. `--- FR ---` then opens the French section,
which starts with the subject translated, under the same tag, and goes on
with the translated body.
### The subject line
The subject is read a hundred times for every time the body is read: in
@ -108,9 +115,9 @@ built on them reads well and tells the next reader nothing:
| Instead of | Write |
|-----------|-------|
| `[FIX] nettoyage : les enfants s'en vont avec leur rebond` | `[FIX] nettoyage : les entrées ssh qui rebondissent par une VM effacée` |
| `[FIX] proxmox : « il manque le stockage » était le symptôme, pas la cause` | `[FIX] proxmox : signaler pmxcfs à terre, et non « aucun stockage »` |
| `[FIX] migration: un module fautif n'emporte plus tout le lot` | `[FIX] migration: isoler l'échec d'un module dans la désinstallation` |
| `[FIX] cleanup: the children leave with their bounce` | `[FIX] cleanup: ssh entries that bounce through a deleted VM` |
| `[FIX] proxmox: "storage is missing" was the symptom, not the cause` | `[FIX] proxmox: report pmxcfs down, not "no storage"` |
| `[FIX] migration: a faulty module no longer takes the whole batch` | `[FIX] migration: isolate a module's failure during uninstall` |
The scope is not the subject. `proxmox` says WHERE; the words after the colon
must say WHAT. A subject that works with its scope removed is usually the
@ -130,8 +137,8 @@ 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
[FIX] proxmox: pmxcfs down, pvesm silent, diagnosis at the source
[ADD] migration: site copies, doubled indexes, lost settings
```
That form is a fallback, not a default. Prefer the sentence when it fits.
@ -139,7 +146,10 @@ 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. It reads the body
too: over ten lines for one language, an IP address, an e-mail, and a
`/home/<account>/` path. Install the hook with `git config core.hooksPath script/git/hooks`; `git commit
`/home/<account>/` path. And the order of the languages: a `--- EN ---`
marker, or a French section that does not open on the translated subject
under the same tag. Whether the subject is really in English stays yours to
check. Install the hook 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, and whether the body tells
the story instead of the mechanism, stay judgements, and the tests above are
@ -187,12 +197,15 @@ goes.
### Bilingual body
Every AI-assisted commit carries its body twice. Write it first in whichever
language you were thinking in, then the marker, then the translation.
Every AI-assisted commit carries its subject and its body twice, English
first: the subject and the body under it are in English, then `--- FR ---`,
then the subject translated into French under the same tag, then the body
translated. The order never varies — `--- EN ---` is refused — so a reader of
`git log --oneline` always gets English, and a French reader finds the French
title where the French section starts.
The marker names the language of what FOLLOWS it: `--- FR ---` after an
English body, `--- EN ---` after a French one. One marker per commit, never
both.
The translated subject obeys the same rules as the subject: 72 characters,
no opening quotation. It does not count against the body's line budget.
Translate, do not re-summarise: a reader of either language must get the same
reasoning, the same measured figures and the same caveats.
@ -259,12 +272,14 @@ the subject you just wrote, and no others. When one file carries two subjects,
git status --porcelain
git add script/module/thing.py test/test_thing.py
git -c user.name="Your Name" -c user.email="your@email.com" commit -F - <<'MSG'
[TAG] scope: description
[TAG] scope: description in English
Explain WHY here.
--- FR ---
[TAG] portée : la description en français
The same body, translated.
Assisted-by: {MODEL}

View file

@ -117,12 +117,14 @@ The shape, as this repository writes it:
```
Merge branch '<branch>'
[TAG] scope: what the branch delivers, imperative, 72 characters maximum
[TAG] scope: what the branch delivers, in English, 72 characters maximum
<N> commits. Why the branch existed: the failure mode it removes, the
figure that bounds it, what was verified and how. Wrap at 80 characters.
--- EN ---
--- FR ---
[TAG] portée : ce que livre la branche, traduit en français
The same body, translated.
@ -143,14 +145,15 @@ commits the branch carries, then say what they add up to. No bullet list, no
per-commit rundown: `git log <base>..HEAD` already gives that, and a body
repeating it teaches nothing.
The bilingual rule holds — body, then the marker naming the language of what
FOLLOWS, then the translation — as does the ban on naming an AI in
The bilingual rule holds as for a commit — the tagged line and the body in
English, then `--- FR ---`, then the tagged line translated under the same
tag, then the translated body — as does the ban on naming an AI in
`Co-authored-by:`.
**The hook does not check this one.** `script/git/hooks/commit-msg` skips any
message beginning with `Merge `, along with `Revert `, `fixup!` and `squash!`.
Length, addresses and account paths pass unchallenged here, so the discipline
is entirely yours.
Length, addresses, account paths and the order of the languages pass
unchallenged here, so the discipline is entirely yours.
### 3. Hand it over

View file

@ -8,7 +8,13 @@ d'emploi dans `conf/template_claude_commands_commit.md`. Ce module en vérifie
la part MÉCANIQUE, sur deux plans :
- le sujet : le tag, la longueur, l'ouverture sur une citation ;
- le corps : sa longueur par langue, et la donnée identifiante.
- le corps : sa longueur par langue, et la donnée identifiante ;
- l'ordre des langues : le sujet et le corps qui le suit sont en anglais,
« --- FR --- » ouvre la traduction, et celle-ci commence par le sujet
traduit, sous le même tag.
Que le sujet soit VRAIMENT en anglais ne se vérifie pas : seul l'ordre des
moitiés et la présence du titre traduit le sont.
Le reste — « ce sujet dit-il sur quoi porte le code », « ce corps raconte-t-il
l'enquête plutôt que le fonctionnement » — est un jugement, et aucun hook ne
@ -64,6 +70,8 @@ GENERATED = ("Merge ", "Revert ", "fixup!", "squash!", "amend!")
QUOTES = ("«", '"', "'", "`", "“", "‘")
# Le marqueur nomme la langue de ce qui SUIT : il sépare les deux moitiés.
# Seul « --- FR --- » est admis, l'anglais venant toujours en premier ; « EN »
# est reconnu pour être refusé avec un message, et non ignoré.
MARKER = re.compile(r"^---\s*(FR|EN)\s*---\s*$", re.MULTILINE)
# `git commit --cleanup=scissors` laisse le diff en clair sous cette ligne :
@ -133,14 +141,26 @@ def _moities(body: str) -> list:
return [part for part in MARKER.split(body) if part not in ("FR", "EN")]
def _tag_of(subject: str):
"""Le tag qui ouvre le sujet, sans crochets ; None s'il n'y en a pas."""
for candidate in TAGS:
if subject.startswith(f"[{candidate}]"):
return candidate
return None
def _titre_traduit(moitie: str) -> str:
"""La première ligne non vide d'une moitié traduite : son titre."""
for ligne in moitie.split("\n"):
if ligne.strip():
return ligne.strip()
return ""
def _check_subject(subject: str) -> list:
problems = []
tag = None
for candidate in TAGS:
if subject.startswith(f"[{candidate}]"):
tag = candidate
break
tag = _tag_of(subject)
if tag is None:
problems.append(
t("the subject must start with a tag: %s")
@ -179,8 +199,12 @@ def _check_body(sans_trailers: str, avec_trailers: str) -> list:
"""
problems = []
for moitie in _moities(sans_trailers):
for rang, moitie in enumerate(_moities(sans_trailers)):
pleines = [ligne for ligne in moitie.split("\n") if ligne.strip()]
# Le titre traduit est le sujet de sa langue : il ne pèse pas sur le
# budget du corps, pas plus que le sujet anglais.
if rang and pleines and _tag_of(pleines[0].strip()):
pleines = pleines[1:]
if len(pleines) > MAX_BODY:
problems.append(
t(
@ -243,11 +267,45 @@ def _check_body(sans_trailers: str, avec_trailers: str) -> list:
return problems
def _check_langues(subject: str, sans_trailers: str) -> list:
"""L'anglais d'abord, puis « --- FR --- » et le sujet traduit.
Un message sans marqueur n'est pas bilingue et n'est pas jugé ici.
"""
marqueurs = MARKER.findall(sans_trailers)
if not marqueurs:
return []
if "EN" in marqueurs:
return [
t(
"the marker is « --- EN --- ». The subject and the body under it\n"
" are in English; « --- FR --- » opens the French translation."
)
]
tag = _tag_of(subject)
titre = _titre_traduit(_moities(sans_trailers)[-1])
if tag is None or not titre.startswith(f"[{tag}]"):
return [
t(
"the French section must open on the translated subject,\n"
" under the same tag: %s"
)
% ("[%s] …" % (tag or "TAG"))
]
return [
t("French subject: %s") % probleme
for probleme in _check_subject(titre)
]
def check(message: str) -> list:
"""Rend la liste des problèmes. Vide si le message passe."""
subject = subject_of(message)
if not subject or subject.startswith(GENERATED):
return []
return _check_subject(subject) + _check_body(
body_of(message), body_of(message, trailers=True)
sans_trailers = body_of(message)
return (
_check_subject(subject)
+ _check_langues(subject, sans_trailers)
+ _check_body(sans_trailers, body_of(message, trailers=True))
)

View file

@ -9446,6 +9446,24 @@ TRANSLATIONS = {
"en": "the subject opens on a quotation. A screen message is\n"
" evidence: it belongs in the body. The subject names the cause.",
},
"the marker is « --- EN --- ». The subject and the body under it\n"
" are in English; « --- FR --- » opens the French translation.": {
"fr": "le marqueur est « --- EN --- ». Le sujet et le corps qui le suit\n"
" sont en anglais ; « --- FR --- » ouvre la traduction française.",
"en": "the marker is « --- EN --- ». The subject and the body under it\n"
" are in English; « --- FR --- » opens the French translation.",
},
"the French section must open on the translated subject,\n"
" under the same tag: %s": {
"fr": "la section française doit s'ouvrir sur le sujet traduit,\n"
" sous le même tag : %s",
"en": "the French section must open on the translated subject,\n"
" under the same tag: %s",
},
"French subject: %s": {
"fr": "sujet français : %s",
"en": "French subject: %s",
},
"the body is %s lines for one language, %s at most.\n"
" The body says why it was necessary, then stops.\n"
" The investigation, the dated measurements and the dead ends go\n"

View file

@ -151,7 +151,55 @@ class TestCeQuiEstRefuse(unittest.TestCase):
def _message(corps):
return "[FIX] portée : quelque chose\n\n" + corps + "\n"
return "[FIX] scope: something\n\n" + corps + "\n"
# Le sujet de _message, traduit : ce qui ouvre la section française.
TITRE_FR = "[FIX] portée : quelque chose"
class TestLesLangues(unittest.TestCase):
"""L'anglais d'abord, puis « --- FR --- » et le sujet traduit."""
def test_anglais_puis_francais_avec_titre_traduit(self):
corps = f"Why, in English.\n\n--- FR ---\n\n{TITRE_FR}\n\nPourquoi."
self.assertEqual([], check(_message(corps)))
def test_un_message_sans_marqueur_nest_pas_juge(self):
self.assertEqual([], check(_message("Why, in one language.")))
@EN_FRANCAIS
def test_le_marqueur_en_est_refuse(self):
corps = "Pourquoi, en français.\n\n--- EN ---\n\nWhy, in English."
problemes = check(_message(corps))
self.assertEqual(1, len(problemes))
self.assertIn("--- EN ---", problemes[0])
@EN_FRANCAIS
def test_la_section_francaise_sans_titre(self):
problemes = check(_message("Why.\n\n--- FR ---\n\nPourquoi."))
self.assertEqual(1, len(problemes))
self.assertIn("sujet traduit", problemes[0])
self.assertIn("[FIX] …", problemes[0])
def test_le_titre_traduit_porte_le_meme_tag(self):
corps = (
"Why.\n\n--- FR ---\n\n[ADD] portée : autre chose\n\nPourquoi."
)
self.assertEqual(1, len(check(_message(corps))))
@EN_FRANCAIS
def test_le_titre_traduit_suit_les_regles_du_sujet(self):
titre = "[FIX] portée : " + "é" * MAX
corps = f"Why.\n\n--- FR ---\n\n{titre}\n\nPourquoi."
problemes = check(_message(corps))
self.assertEqual(1, len(problemes))
self.assertTrue(problemes[0].startswith("sujet français :"), problemes[0])
def test_le_titre_traduit_ne_compte_pas_dans_le_corps(self):
moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY))
corps = f"{moitie}\n\n--- FR ---\n\n{TITRE_FR}\n{moitie}"
self.assertEqual([], check(_message(corps)))
class TestLeCorps(unittest.TestCase):
@ -166,7 +214,7 @@ class TestLeCorps(unittest.TestCase):
def test_dix_lignes_par_langue_passent(self):
moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY))
corps = f"{moitie}\n\n--- FR ---\n\n{moitie}"
corps = f"{moitie}\n\n--- FR ---\n\n{TITRE_FR}\n\n{moitie}"
self.assertEqual([], check(_message(corps)))
def test_onze_lignes_pour_une_langue_sont_refusees(self):