[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:
parent
dfd6cd1fb6
commit
0b8f25fb4b
3 changed files with 110 additions and 1 deletions
|
|
@ -2524,11 +2524,21 @@ class TODO(
|
||||||
if cmd_no_found:
|
if cmd_no_found:
|
||||||
print(t("Command not 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):
|
def prompt_execute_git(self):
|
||||||
print(f"🤖 {t('Git management tools!')}")
|
print(f"🤖 {t('Git management tools!')}")
|
||||||
choices = [
|
choices = [
|
||||||
{"prompt_description": t("Local git server")},
|
{"prompt_description": t("Local git server")},
|
||||||
{"prompt_description": t("Add a remote to a local repository")},
|
{"prompt_description": t("Add a remote to a local repository")},
|
||||||
|
{
|
||||||
|
"prompt_description": t(
|
||||||
|
"Install git hooks (commit-msg, pre-commit)"
|
||||||
|
)
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Append config-driven entries
|
# Append config-driven entries
|
||||||
|
|
@ -2547,6 +2557,8 @@ class TODO(
|
||||||
self.prompt_execute_git_local_server()
|
self.prompt_execute_git_local_server()
|
||||||
elif status == "2":
|
elif status == "2":
|
||||||
self._git_add_remote()
|
self._git_add_remote()
|
||||||
|
elif status == "3":
|
||||||
|
self._git_install_hooks()
|
||||||
else:
|
else:
|
||||||
cmd_no_found = True
|
cmd_no_found = True
|
||||||
try:
|
try:
|
||||||
|
|
@ -2579,6 +2591,51 @@ class TODO(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"{t('Error adding remote: ')}{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):
|
def prompt_execute_git_local_server(self):
|
||||||
print(f"🤖 {t('Manage local git repository server!')}")
|
print(f"🤖 {t('Manage local git repository server!')}")
|
||||||
choices = [
|
choices = [
|
||||||
|
|
@ -2879,7 +2936,7 @@ class TODO(
|
||||||
)
|
)
|
||||||
if chemin_hooks:
|
if chemin_hooks:
|
||||||
absolu = os.path.join(racine, 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)
|
pose = os.access(os.path.join(absolu, hook), os.X_OK)
|
||||||
marque = (
|
marque = (
|
||||||
t("hook installed") if pose else t("hook not installed")
|
t("hook installed") if pose else t("hook not installed")
|
||||||
|
|
|
||||||
|
|
@ -883,6 +883,35 @@ TRANSLATIONS = {
|
||||||
"fr": "Erreur lors de l'ajout du remote : ",
|
"fr": "Erreur lors de l'ajout du remote : ",
|
||||||
"en": "Error adding 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
|
# Git config vim
|
||||||
"Configure git local editor to vim": {
|
"Configure git local editor to vim": {
|
||||||
"fr": "Configuration git local par vim",
|
"fr": "Configuration git local par vim",
|
||||||
|
|
|
||||||
|
|
@ -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):
|
class TestMenuLabels(unittest.TestCase):
|
||||||
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.
|
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue