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
311 lines
11 KiB
Python
311 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
"""Le message 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, 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 ;
|
||
- 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
|
||
le rendra.
|
||
|
||
Compté en CARACTÈRES et non en octets : « préchauffer » pèse 11 caractères et
|
||
12 octets, et une limite en octets refuserait des sujets français conformes.
|
||
"""
|
||
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# APPEND et non insert(0) : ce répertoire est `script/`, qui contient
|
||
# `git/`. En tête de `sys.path`, il fait résoudre `import git` vers
|
||
# `script/git/` au lieu de GitPython, pour tout module importé ensuite —
|
||
# une bibliothèque tierce disparaît alors parce qu'une des nôtres porte le
|
||
# même nom. En queue, `lib_identifiant` se trouve toujours, et les paquets
|
||
# installés gardent la priorité qui leur revient.
|
||
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||
|
||
from lib_identifiant import ( # noqa: E402
|
||
NOMS_INTERDITS,
|
||
identifiants,
|
||
termes_interdits,
|
||
)
|
||
|
||
# Même raison : la racine du dépôt porte un `test/`, qui masquerait le
|
||
# paquet `test` de la bibliothèque standard.
|
||
sys.path.append(str(Path(__file__).resolve().parents[2]))
|
||
|
||
try:
|
||
from script.todo.todo_i18n import t
|
||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||
|
||
def t(key: str) -> str:
|
||
return key
|
||
|
||
|
||
MAX = 72
|
||
|
||
# Lignes non vides par langue. Le corps est bilingue : ce budget est celui
|
||
# d'UNE des deux moitiés, pas du message entier.
|
||
MAX_BODY = 10
|
||
|
||
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 = ("«", '"', "'", "`", "“", "‘")
|
||
|
||
# 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 :
|
||
# tout ce qui suit appartient à git, pas à l'auteur.
|
||
CISEAUX = re.compile(r"^#?\s*-{2,}\s*>8\s*-{2,}")
|
||
|
||
# Un trailer porte légitimement une adresse, et `-x` ajoute sa propre ligne.
|
||
# La liste est fermée : « Checked: … » reste du corps et se fait vérifier.
|
||
TRAILER = re.compile(
|
||
r"^(?:Assisted-by|Co-authored-by|Signed-off-by|Reviewed-by|Acked-by"
|
||
r"|Tested-by|Reported-by|Suggested-by|Cc|Fixes|Closes|Refs|Link):\s"
|
||
r"|^\(cherry picked from commit [0-9a-f]+\)$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
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 _apres_le_sujet(message: str) -> list:
|
||
"""Les lignes du corps : ni commentaire, ni diff de `--verbose`."""
|
||
lines = message.split("\n")
|
||
for index, line in enumerate(lines):
|
||
stripped = line.strip()
|
||
if stripped and not stripped.startswith("#"):
|
||
lines = lines[index + 1 :]
|
||
break
|
||
else:
|
||
return []
|
||
|
||
gardees = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if CISEAUX.match(stripped):
|
||
break
|
||
if stripped.startswith("#"):
|
||
continue
|
||
gardees.append(line)
|
||
return gardees
|
||
|
||
|
||
def body_of(message: str, trailers: bool = False) -> str:
|
||
"""Ce qui suit le sujet, sans les lignes que git ajoute ou qu'il ignore.
|
||
|
||
Sans `trailers`, les lignes `Assisted-by:` et consorts sont retirées : le
|
||
budget de lignes ne les compte pas, et l'adresse d'un `Co-authored-by:` est
|
||
légitime. Avec, elles restent — un nom de client logé dans un `Refs:` est
|
||
tout aussi publié que dans une phrase.
|
||
"""
|
||
gardees = _apres_le_sujet(message)
|
||
if not trailers:
|
||
gardees = [
|
||
ligne for ligne in gardees if not TRAILER.match(ligne.strip())
|
||
]
|
||
return "\n".join(gardees)
|
||
|
||
|
||
def _moities(body: str) -> list:
|
||
"""Le corps découpé par le marqueur de langue. Une seule moitié sans lui."""
|
||
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 = _tag_of(subject)
|
||
if tag is None:
|
||
problems.append(
|
||
t("the subject must start with a tag: %s")
|
||
% ", ".join("[%s]" % tag for tag in TAGS)
|
||
)
|
||
|
||
if len(subject) > MAX:
|
||
problems.append(
|
||
t(
|
||
"the subject is %s characters, %s at most.\n"
|
||
" Do not truncate it: at that length, write KEYWORDS that\n"
|
||
" summarise rather than an amputated sentence. « proxmox: pmxcfs,\n"
|
||
" storage, diagnosis » beats a sentence cut short."
|
||
)
|
||
% (len(subject), MAX)
|
||
)
|
||
|
||
rest = subject.split(":", 1)[1].strip() if ":" in subject else ""
|
||
if rest.startswith(QUOTES):
|
||
problems.append(
|
||
t(
|
||
"the subject opens on a quotation. A screen message is\n"
|
||
" evidence: it belongs in the body. The subject names the cause."
|
||
)
|
||
)
|
||
|
||
return problems
|
||
|
||
|
||
def _check_body(sans_trailers: str, avec_trailers: str) -> list:
|
||
"""Longueur par langue et donnée identifiante. Rien sur le style.
|
||
|
||
Le budget de lignes et le courriel se jugent SANS les trailers, qui sont
|
||
de git et portent légitimement une adresse. L'adresse IP, le chemin de
|
||
compte et le nom privé se jugent AVEC : un `Refs:` publie autant.
|
||
"""
|
||
problems = []
|
||
|
||
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(
|
||
"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"
|
||
" to tasks/, which is not versioned."
|
||
)
|
||
% (len(pleines), MAX_BODY)
|
||
)
|
||
break
|
||
|
||
termes = termes_interdits(NOMS_INTERDITS)
|
||
par_motif = {}
|
||
for motif, extrait, _ in identifiants(avec_trailers, termes):
|
||
if motif != "courriel":
|
||
par_motif.setdefault(motif, []).append(extrait)
|
||
for motif, extrait, _ in identifiants(sans_trailers, ()):
|
||
if motif == "courriel":
|
||
par_motif.setdefault(motif, []).append(extrait)
|
||
|
||
adresses = sorted(set(par_motif.get("adresse", [])))
|
||
if adresses:
|
||
problems.append(
|
||
t(
|
||
"the body carries an IP address: %s.\n"
|
||
" An address designates a machine. Name the CLASS of\n"
|
||
" situation — « on a host behind a NAT » — not the machine."
|
||
)
|
||
% ", ".join(adresses)
|
||
)
|
||
|
||
courriels = sorted(set(par_motif.get("courriel", [])))
|
||
if courriels:
|
||
problems.append(
|
||
t("the body carries an e-mail address: %s.") % ", ".join(courriels)
|
||
)
|
||
|
||
comptes = sorted(set(par_motif.get("compte", [])))
|
||
if comptes:
|
||
problems.append(
|
||
t(
|
||
"the body carries an account path: %s….\n"
|
||
" Write ~/ or /home/<user>/."
|
||
)
|
||
% comptes[0]
|
||
)
|
||
|
||
noms = sorted(set(par_motif.get("nom privé", [])))
|
||
if noms:
|
||
problems.append(
|
||
t(
|
||
"the body carries a refused name: %s.\n"
|
||
" Generalise — « on a production database » — or drop the\n"
|
||
" sentence."
|
||
)
|
||
% ", ".join(noms)
|
||
)
|
||
|
||
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 []
|
||
sans_trailers = body_of(message)
|
||
return (
|
||
_check_subject(subject)
|
||
+ _check_langues(subject, sans_trailers)
|
||
+ _check_body(sans_trailers, body_of(message, trailers=True))
|
||
)
|