diff --git a/script/git/git_change_remote_https_to_git.py b/script/git/git_change_remote_https_to_git.py index 2ec7660..a3f28d8 100755 --- a/script/git/git_change_remote_https_to_git.py +++ b/script/git/git_change_remote_https_to_git.py @@ -7,7 +7,9 @@ import logging import os import sys +from colorama import Fore, Style from git import Repo +from git.exc import InvalidGitRepositoryError, NoSuchPathError new_path = os.path.normpath( os.path.join(os.path.dirname(__file__), "..", "..") @@ -59,36 +61,99 @@ def get_config(): return args +def change_remote(git_tool, repo_path, upstream_name, git_to_https): + """Réécrit les remotes de `repo_path`. Rend le nombre de remotes changés. + + Lève ce que GitPython lève : c'est l'appelant qui décide si un dépôt + fautif arrête le lot ou seulement lui-même. + """ + repo_sm = Repo(repo_path) + if upstream_name: + remotes = [a for a in repo_sm.remotes if upstream_name == a.name] + else: + remotes = list(repo_sm.remotes) + for remote in remotes: + url, url_https, url_git = git_tool.get_url(remote.url) + new_url = url_https if git_to_https else url_git + remote.set_url(new_url) + print(f'Remote "{remote.name}" update for {new_url}') + return len(remotes) + + +def change_all_remotes( + git_tool, lst_repo, root_path, upstream_name="", git_to_https=False +): + """Parcourt `lst_repo`. Rend (remotes changés, [(chemin, raison)]). + + Un dépôt fautif n'arrête PAS le lot : un répertoire vidé à la main, un + clone interrompu ou un `.git` effacé sont des états courants d'un + checkout de développement, et ils n'ont rien à voir avec les cent + quarante autres dépôts qui, eux, attendent leur nouveau remote. + + Les ennuis sont RENDUS plutôt qu'affichés au fil de l'eau : noyés dans + la trace d'un lot de cette taille, ils ne se voient plus. + """ + skipped = [] + changed = 0 + total = len(lst_repo) + for i, repo in enumerate(lst_repo, start=1): + print(f"Nb element {i}/{total}") + repo_name = repo.get("name") + repo_path = os.path.join(root_path, repo_name) + if not os.path.isdir(repo_path): + print(f"Ignore repo {repo_path}") + skipped.append((repo_path, "directory is missing")) + continue + try: + changed += change_remote( + git_tool, repo_path, upstream_name, git_to_https + ) + except InvalidGitRepositoryError: + reason = "directory exists but holds no git repository" + except NoSuchPathError: + reason = "path vanished while running" + except Exception as err: + reason = f"{type(err).__name__}: {err}" + else: + continue + print(f"Ignore repo {repo_path}: {reason}") + skipped.append((repo_path, reason)) + return changed, skipped + + +def print_report(total, changed, skipped): + """Le bilan, en fin de course et en couleur. + + Une ligne d'avertissement au moment où elle survient est perdue : ce + que l'humain lit d'un lot long, c'est sa fin. + """ + print(f"\n{'=' * 72}") + print(f"{total} repo, {changed} remote updated, {len(skipped)} skipped") + if not skipped: + return + print(f"{Fore.YELLOW}Skipped{Style.RESET_ALL}:") + for repo_path, reason in skipped: + print(f" · {repo_path} — {reason}") + + def main(): git_tool = GitTool() config = get_config() - upstream_name = config.upstream lst_repo = git_tool.get_repo_info(config.dir, add_root=True) - i = 0 - total = len(lst_repo) - for repo in lst_repo: - i += 1 - print(f"Nb element {i}/{total}") - repo_name = repo.get("name") - relative_path = os.path.join(new_path, repo_name) - if not os.path.isdir(relative_path): - print(f"Ignore repo {relative_path}") - continue - repo_sm = Repo(repo_name) - if upstream_name: - remote_upstream_name = [ - a for a in repo_sm.remotes if upstream_name == a.name - ] - else: - remote_upstream_name = [a for a in repo_sm.remotes] - - for remote in remote_upstream_name: - url, url_https, url_git = git_tool.get_url(remote.url) - new_url = url_https if config.git_to_https else url_git - remote.set_url(new_url) - print(f'Remote "{remote.name}" update for {new_url}') + changed, skipped = change_all_remotes( + git_tool, + lst_repo, + new_path, + upstream_name=config.upstream, + git_to_https=config.git_to_https, + ) + print_report(len(lst_repo), changed, skipped) + # Sortie nulle même avec des dépôts ignorés : tout ce qui POUVAIT être + # changé l'a été, et le bilan porte le reste. C'est déjà le contrat + # tenu pour un répertoire absent, qui n'a jamais fait échouer le lot. + return 0 if __name__ == "__main__": - main() + sys.exit(main()) diff --git a/test/test_git_change_remote.py b/test/test_git_change_remote.py new file mode 100644 index 0000000..6305046 --- /dev/null +++ b/test/test_git_change_remote.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Un dépôt fautif n'arrête pas le lot, et le bilan le nomme à la fin. + +Un checkout de développement porte des répertoires vidés à la main, des +clones interrompus, des `.git` effacés. Ils n'ont rien à voir avec les cent +quarante autres dépôts qui attendent leur nouveau remote, et une trace de +cette longueur noie l'avertissement qui passe au moment où il survient. +""" + +import io +import os +import subprocess +import unittest +from contextlib import redirect_stdout +from tempfile import TemporaryDirectory + +from script.git.git_change_remote_https_to_git import ( + change_all_remotes, + print_report, +) +from script.git.git_tool import GitTool + +HTTPS = "https://github.com/OCA/web.git" +GIT = "git@github.com:OCA/web.git" + + +def _depot(chemin, url=HTTPS): + """Un vrai dépôt git. Rien n'est bouchonné : c'est la lecture et + l'écriture des remotes par git qu'on vérifie.""" + os.makedirs(chemin, exist_ok=True) + subprocess.run(["git", "init", "-q", chemin], check=True) + subprocess.run( + ["git", "-C", chemin, "remote", "add", "origin", url], check=True + ) + return chemin + + +def _url(chemin): + return subprocess.run( + ["git", "-C", chemin, "remote", "get-url", "origin"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +class UnDepotFautifNArretePasLeLot(unittest.TestCase): + def _lot(self, racine, noms): + with redirect_stdout(io.StringIO()) as sortie: + changed, skipped = change_all_remotes( + GitTool(), [{"name": n} for n in noms], racine + ) + return changed, skipped, sortie.getvalue() + + def test_un_repertoire_sans_depot_git_est_ignore(self): + """Le répertoire existe et ne porte plus de dépôt git : + `os.path.isdir` passe, l'ouverture du dépôt lève.""" + with TemporaryDirectory() as racine: + _depot(os.path.join(racine, "addons/Bon")) + os.makedirs(os.path.join(racine, "addons/Vide")) + changed, skipped, _ = self._lot( + racine, ["addons/Bon", "addons/Vide"] + ) + self.assertEqual(_url(os.path.join(racine, "addons/Bon")), GIT) + self.assertEqual(changed, 1) + self.assertEqual(len(skipped), 1) + self.assertIn("addons/Vide", skipped[0][0]) + self.assertIn("no git repository", skipped[0][1]) + + def test_le_lot_continue_apres_le_fautif(self): + """Le fautif est au MILIEU : ce qui le suit doit être servi.""" + with TemporaryDirectory() as racine: + os.makedirs(os.path.join(racine, "addons/Vide")) + _depot(os.path.join(racine, "addons/Apres")) + changed, skipped, _ = self._lot( + racine, ["addons/Vide", "addons/Apres"] + ) + self.assertEqual(_url(os.path.join(racine, "addons/Apres")), GIT) + self.assertEqual(changed, 1) + self.assertEqual(len(skipped), 1) + + def test_un_repertoire_absent_est_ignore_aussi(self): + with TemporaryDirectory() as racine: + changed, skipped, _ = self._lot(racine, ["addons/Absent"]) + self.assertEqual(changed, 0) + self.assertEqual(skipped[0][1], "directory is missing") + + def test_rien_a_signaler_ne_signale_rien(self): + with TemporaryDirectory() as racine: + _depot(os.path.join(racine, "addons/Bon")) + changed, skipped, _ = self._lot(racine, ["addons/Bon"]) + self.assertEqual((changed, skipped), (1, [])) + + +class LeBilanSeLitALaFin(unittest.TestCase): + """Ce que l'humain lit d'un lot long, c'est sa fin.""" + + def _bilan(self, total, changed, skipped): + with redirect_stdout(io.StringIO()) as sortie: + print_report(total, changed, skipped) + return sortie.getvalue() + + def test_il_nomme_chaque_depot_ignore_et_sa_raison(self): + texte = self._bilan( + 3, 1, [("addons/Vide", "no git repository"), ("addons/X", "gone")] + ) + self.assertIn("3 repo, 1 remote updated, 2 skipped", texte) + self.assertIn("addons/Vide — no git repository", texte) + self.assertIn("addons/X — gone", texte) + + def test_sans_incident_il_ne_liste_rien(self): + texte = self._bilan(2, 2, []) + self.assertIn("2 repo, 2 remote updated, 0 skipped", texte) + self.assertNotIn("Skipped", texte) + + +if __name__ == "__main__": + unittest.main()