[FIX] git remote : ignorer un dépôt fautif, lister les écarts à la fin
Un répertoire vidé à la main passe `os.path.isdir` puis fait lever l'ouverture du dépôt, ce qui emportait tout le reste du lot. Ces états — clone interrompu, `.git` effacé — sont courants sur un checkout de développement, et sans rapport avec les dépôts qui attendent leur remote. Les écarts sont rendus plutôt qu'affichés au fil de l'eau : noyés dans la trace d'un lot de plus de cent dépôts, ils ne se voient plus. La sortie reste nulle, comme déjà pour un répertoire absent. Vérifié dans des dépôts jetables ; 6 tests neufs, dont 2 échouent si le garde-fou est retiré. --- EN --- A directory emptied by hand passes `os.path.isdir` then makes opening the repository raise, which took down the whole rest of the batch. Such states — an interrupted clone, a deleted `.git` — are common on a development checkout, and unrelated to the repositories awaiting their remote. Deviations are returned rather than printed as they happen: drowned in the trace of a batch of over a hundred repositories, they can no longer be seen. The exit code stays zero, as it already was for a missing directory. Checked in throwaway repositories; 6 new tests, 2 of which fail if the guard is removed. Assisted-by: Claude Opus 5
This commit is contained in:
parent
a60baf743e
commit
b8361c30e6
2 changed files with 210 additions and 25 deletions
|
|
@ -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())
|
||||
|
|
|
|||
120
test/test_git_change_remote.py
Normal file
120
test/test_git_change_remote.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue