From 0b8f25fb4bb2586e6095e5ab71e9f1e56d651eee Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 31 Aug 2026 06:17:48 -0400 Subject: [PATCH] [ADD] script todo : installer les hooks git depuis le menu Git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/todo/todo.py | 59 +++++++++++++++++++++++++++++++++++++++- script/todo/todo_i18n.py | 29 ++++++++++++++++++++ test/test_todo_menu.py | 23 ++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index aa1dd48..0755c1f 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 21fdc67..1790373 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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", diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 5c38677..f6a5aa5 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -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.