[ADD] script todo : installer les hooks git depuis le menu Git

Un commit-msg non installé, c'est le garde-fou de convention qui ne tourne
jamais, et la pose restait une commande à recopier. git saute sans rien
dire un hook privé du bit d'exécution : l'installation le remet.

exec_command_live ne passe aucun cwd et ce dépôt porte 128 dépôts
imbriqués : sans « git -C racine », un lancement depuis un addon y écrivait
core.hooksPath et laissait la racine sans garde-fou. Et comme cette
fonction retourne le code sans jamais lever, un « fatal: not in a git
directory » annonçait « hooks installés ».

Vérifié : 4114 tests verts ; le nouveau garde tombe sur un dispatch
échangé comme sur une entrée sans branche.

--- EN ---

An uninstalled commit-msg is the convention guard never running, and
installing it stayed a command to copy by hand. git skips a hook without
the execution bit and says nothing: installing restores it.

exec_command_live passes no cwd and this checkout holds 128 nested
repositories: without the "git -C root", a launch from an addon wrote
core.hooksPath there and left the root unguarded. And since that function
returns the exit code without ever raising, a "fatal: not in a git
directory" still announced "hooks installed".

Checked: 4114 tests green; the new guard fails on a swapped dispatch as
well as on an entry with no branch.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-31 06:17:48 -04:00
parent dfd6cd1fb6
commit 0b8f25fb4b
3 changed files with 110 additions and 1 deletions

View file

@ -2524,11 +2524,21 @@ class TODO(
if cmd_no_found:
print(t("Command not found !"))
# Les hooks que le dépôt fournit. git saute silencieusement un hook qui
# ne porte pas le bit d'exécution, d'où la vérification à l'installation.
_GIT_HOOKS = ("commit-msg", "pre-commit")
_GIT_HOOKS_PATH = os.path.join("script", "git", "hooks")
def prompt_execute_git(self):
print(f"🤖 {t('Git management tools!')}")
choices = [
{"prompt_description": t("Local git server")},
{"prompt_description": t("Add a remote to a local repository")},
{
"prompt_description": t(
"Install git hooks (commit-msg, pre-commit)"
)
},
]
# Append config-driven entries
@ -2547,6 +2557,8 @@ class TODO(
self.prompt_execute_git_local_server()
elif status == "2":
self._git_add_remote()
elif status == "3":
self._git_install_hooks()
else:
cmd_no_found = True
try:
@ -2579,6 +2591,51 @@ class TODO(
except Exception as e:
print(f"{t('Error adding remote: ')}{e}")
def _git_install_hooks(self):
"""Pointer core.hooksPath sur les hooks du dépôt.
Le bit d'exécution fait partie de l'installation : sans lui git
ignore le hook sans rien dire, et le garde-fou du message de commit
passe inaperçu.
"""
racine = self._claude_context_root()
absolu = os.path.join(racine, self._GIT_HOOKS_PATH)
if not os.path.isdir(absolu):
print(f"{t('Hooks directory is missing: ')}{absolu}")
return
actuel = self._git_hooks_path(racine)
if actuel and actuel != self._GIT_HOOKS_PATH:
print(f"{t('Another hooks path is already set: ')}{actuel}")
if not self._is_yes(input(t("Replace it? (y/Y): "))):
print(t("Nothing to do."))
return
for hook in self._GIT_HOOKS:
chemin = os.path.join(absolu, hook)
if os.path.isfile(chemin) and not os.access(chemin, os.X_OK):
os.chmod(chemin, os.stat(chemin).st_mode | 0o111)
print(f"{t('Execution bit added: ')}{hook}")
# « -C racine » et non le cwd : lancé depuis un dépôt imbriqué
# (odoo18.0/addons/…), git écrirait core.hooksPath là-bas et la
# racine resterait sans garde-fou, sans le moindre message.
cmd = (
f"git -C {shlex.quote(racine)} config"
f" core.hooksPath {self._GIT_HOOKS_PATH}"
)
print(f"{t('Will execute:')} {cmd}")
# exec_command_live RETOURNE le code de sortie, il ne lève rien : sans
# ce test, un « fatal: not in a git directory » annonçait quand même
# « Hooks git installés! ». Le rapport qui suit ne rattrape pas, il
# relit le bit d'exécution et non core.hooksPath.
status = self.execute.exec_command_live(cmd, source_erplibre=False)
if status:
print(f"{t('Error installing hooks: ')}{status}")
return
print(t("Git hooks installed!"))
for hook in self._GIT_HOOKS:
pose = os.access(os.path.join(absolu, hook), os.X_OK)
marque = t("hook installed") if pose else t("hook not installed")
print(f" {hook:<26} {marque}")
def prompt_execute_git_local_server(self):
print(f"🤖 {t('Manage local git repository server!')}")
choices = [
@ -2879,7 +2936,7 @@ class TODO(
)
if chemin_hooks:
absolu = os.path.join(racine, chemin_hooks)
for hook in ("commit-msg", "pre-commit"):
for hook in self._GIT_HOOKS:
pose = os.access(os.path.join(absolu, hook), os.X_OK)
marque = (
t("hook installed") if pose else t("hook not installed")

View file

@ -883,6 +883,35 @@ TRANSLATIONS = {
"fr": "Erreur lors de l'ajout du remote : ",
"en": "Error adding remote: ",
},
# Git hooks
"Install git hooks (commit-msg, pre-commit)": {
"fr": "Installer les hooks git (commit-msg, pre-commit)",
"en": "Install git hooks (commit-msg, pre-commit)",
},
"Hooks directory is missing: ": {
"fr": "Le répertoire des hooks est absent : ",
"en": "Hooks directory is missing: ",
},
"Another hooks path is already set: ": {
"fr": "Un autre chemin de hooks est déjà configuré : ",
"en": "Another hooks path is already set: ",
},
"Replace it? (y/Y): ": {
"fr": "Le remplacer? (y/Y) : ",
"en": "Replace it? (y/Y): ",
},
"Execution bit added: ": {
"fr": "Bit d'exécution ajouté : ",
"en": "Execution bit added: ",
},
"Git hooks installed!": {
"fr": "Hooks git installés!",
"en": "Git hooks installed!",
},
"Error installing hooks: ": {
"fr": "Erreur lors de l'installation des hooks : ",
"en": "Error installing hooks: ",
},
# Git config vim
"Configure git local editor to vim": {
"fr": "Configuration git local par vim",

View file

@ -448,6 +448,29 @@ class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase):
}
class TestGitMenuNumbering(MenuCoherence, unittest.TestCase):
"""Le menu Git, le seul dont todo.json suit des entrées codées en dur.
Ses trois premières entrées sont écrites à la main, les suivantes viennent
de `git_from_makefile` et le repli générique les renumérote tout seul :
ajouter « Installer les hooks git » en [3] a poussé les deux entrées de
todo.json en [4] et [5] sans que rien ne le dise. Une quatrième entrée
codée en dur, oubliée dans le dispatch, ferait lancer à [4] la commande
du voisin sous le libellé attendu.
"""
SOURCE = TODO_DIR / "todo.py"
ENTRY = "def prompt_execute_git(self):"
END = "def _git_install_hooks(self):"
MINIMUM = 2
EXPECTED = {
"Local git server": "prompt_execute_git_local_server",
"Add a remote to a local repository": "_git_add_remote",
"Install git hooks": "_git_install_hooks",
}
class TestMenuLabels(unittest.TestCase):
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.