Last active
March 2, 2026 07:33
-
-
Save RebelliousSmile/5327b7b7f3dd22aa0981fdc8dbf4b37b to your computer and use it in GitHub Desktop.
Scanne récursivement le répertoire courant, liste tous les dépôts Git trouvés, puis propose : 1. Pull uniquement — git pull --rebase sur chaque dépôt 2. Sync complet — commit local → pull → push
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @echo off | |
| python "%~dp0git-sync.py" %* |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| git-sync.py — Gestion interactive des dépôts Git | |
| ================================================= | |
| Auteur : François-Xavier Guillois | |
| Licence : MIT | |
| Version : 1.0.0 | |
| Scanne récursivement le répertoire courant, liste tous les dépôts Git | |
| trouvés, puis propose : | |
| 1. Pull uniquement — git pull --rebase sur chaque dépôt | |
| 2. Sync complet — commit local → pull → push | |
| Prérequis : | |
| - Python 3.9+ (stdlib uniquement, aucune dépendance externe) | |
| - Git accessible dans le PATH | |
| Installation (Linux / macOS) : | |
| chmod +x git-sync.py | |
| ln -s /chemin/vers/git-sync.py ~/.local/bin/git-sync | |
| Installation (Windows) : | |
| Placer git-sync.py et git-sync.bat dans un dossier présent dans %PATH%. | |
| Usage : | |
| git-sync scan + menu interactif (sync par défaut) | |
| git-sync --pull pull --rebase sur tous les dépôts | |
| git-sync --sync commit + pull + push sur tous les dépôts | |
| git-sync --list liste les dépôts sans rien faire | |
| git-sync --depth N limite la profondeur de recherche (défaut : 4) | |
| git-sync --help affiche l'aide | |
| """ | |
| import argparse | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| # ─── Couleurs ───────────────────────────────────────────────────────────────── | |
| class C: | |
| GREEN = '\033[92m' | |
| RED = '\033[91m' | |
| CYAN = '\033[96m' | |
| YELLOW = '\033[93m' | |
| MAGENTA = '\033[95m' | |
| BOLD = '\033[1m' | |
| RESET = '\033[0m' | |
| def ok(msg): print(f"{C.GREEN} ✔ {msg}{C.RESET}") | |
| def err(msg): print(f"{C.RED} ✘ {msg}{C.RESET}") | |
| def info(msg): print(f"{C.CYAN} · {msg}{C.RESET}") | |
| def warn(msg): print(f"{C.YELLOW} ⚠ {msg}{C.RESET}") | |
| def header(msg): print(f"\n{C.MAGENTA}{C.BOLD}{msg}{C.RESET}") | |
| # ─── Helpers Git ────────────────────────────────────────────────────────────── | |
| def run(cwd: Path, *args) -> tuple[bool, str]: | |
| try: | |
| r = subprocess.run( | |
| ['git'] + list(args), | |
| cwd=cwd, capture_output=True, text=True | |
| ) | |
| return r.returncode == 0, (r.stdout + r.stderr).strip() | |
| except Exception as e: | |
| return False, str(e) | |
| def is_git_repo(p: Path) -> bool: | |
| return (p / '.git').is_dir() | |
| def has_remote(p: Path) -> bool: | |
| ok_, out = run(p, 'remote') | |
| return ok_ and out.strip() != '' | |
| def current_branch(p: Path) -> str: | |
| ok_, out = run(p, 'branch', '--show-current') | |
| return out if ok_ else '' | |
| def has_changes(p: Path) -> bool: | |
| ok_, out = run(p, 'status', '--porcelain') | |
| return ok_ and out.strip() != '' | |
| # ─── Découverte des dépôts ──────────────────────────────────────────────────── | |
| def find_repos(root: Path, max_depth: int = 4) -> list[Path]: | |
| """ | |
| Cherche récursivement les répertoires .git jusqu'à max_depth niveaux. | |
| Évite de descendre dans un dépôt déjà trouvé (pas de sous-modules inclus). | |
| """ | |
| repos: list[Path] = [] | |
| def _scan(path: Path, depth: int): | |
| if depth > max_depth: | |
| return | |
| try: | |
| children = list(path.iterdir()) | |
| except PermissionError: | |
| return | |
| has_dot_git = any(c.name == '.git' and c.is_dir() for c in children) | |
| if has_dot_git: | |
| repos.append(path) | |
| return # ne pas descendre dans les sous-répertoires du dépôt | |
| for c in children: | |
| if c.is_dir() and not c.name.startswith('.'): | |
| _scan(c, depth + 1) | |
| _scan(root, 0) | |
| return sorted(repos) | |
| # ─── Actions ────────────────────────────────────────────────────────────────── | |
| def pull_repo(path: Path) -> bool: | |
| """Fait un simple git pull --rebase sur la branche courante.""" | |
| if not has_remote(path): | |
| warn("Pas de remote → skip") | |
| return True | |
| branch = current_branch(path) | |
| if not branch: | |
| warn("Branche inconnue → skip") | |
| return True | |
| info(f"Pull {branch}...") | |
| success, out = run(path, 'pull', '--rebase', 'origin', branch) | |
| if success: | |
| ok(out or "Déjà à jour") | |
| else: | |
| err(f"Pull échoué : {out}") | |
| return success | |
| def sync_repo(path: Path) -> bool: | |
| """Commit local (si besoin) + pull + push sur la branche courante.""" | |
| branch = current_branch(path) | |
| if not branch: | |
| warn("Branche inconnue → skip") | |
| return True | |
| # Commit local | |
| if has_changes(path): | |
| info("Changements détectés, commit en cours…") | |
| run(path, 'add', '-A') | |
| ok_, out = run(path, 'commit', '-m', 'Auto-sync commit') | |
| if ok_: | |
| ok("Changements committés") | |
| elif 'nothing to commit' not in out: | |
| err(f"Commit échoué : {out}") | |
| return False | |
| else: | |
| info("Aucun changement local") | |
| if not has_remote(path): | |
| warn("Pas de remote → skip pull/push") | |
| return True | |
| # Pull | |
| info(f"Pull {branch}…") | |
| ok_, out = run(path, 'pull', '--rebase', 'origin', branch) | |
| if not ok_: | |
| warn(f"Pull : {out}") | |
| # Push | |
| info(f"Push {branch}…") | |
| ok_, out = run(path, 'push', '-u', 'origin', branch) | |
| if ok_: | |
| ok(out or "Push réussi") | |
| else: | |
| err(f"Push échoué : {out}") | |
| return False | |
| return True | |
| # ─── Affichage de la liste ──────────────────────────────────────────────────── | |
| def display_repos(repos: list[Path], root: Path): | |
| header(f"Dépôts Git trouvés ({len(repos)})") | |
| for i, r in enumerate(repos, 1): | |
| try: | |
| rel = r.relative_to(root) | |
| except ValueError: | |
| rel = r | |
| branch = current_branch(r) | |
| remote = "✔ remote" if has_remote(r) else "✘ no remote" | |
| changes = " [modifié]" if has_changes(r) else "" | |
| print(f" {C.CYAN}{i:>3}.{C.RESET} {rel} " | |
| f"{C.YELLOW}({branch}){C.RESET} {remote}{C.RED}{changes}{C.RESET}") | |
| # ─── Menu interactif ────────────────────────────────────────────────────────── | |
| def ask_action() -> str: | |
| print() | |
| print(f"{C.BOLD}Que voulez-vous faire ?{C.RESET}") | |
| print(f" {C.GREEN}p{C.RESET} — Pull uniquement (git pull --rebase)") | |
| print(f" {C.GREEN}s{C.RESET} — Sync complet (commit + pull + push)") | |
| print(f" {C.GREEN}q{C.RESET} — Quitter") | |
| print() | |
| while True: | |
| try: | |
| choice = input(" Choix [p/s/q] : ").strip().lower() | |
| except (KeyboardInterrupt, EOFError): | |
| print() | |
| return 'q' | |
| if choice in ('p', 's', 'q'): | |
| return choice | |
| print(" Choix invalide. Entrez p, s ou q.") | |
| def ask_scope(repos: list[Path], root: Path) -> list[Path]: | |
| print() | |
| print(f"{C.BOLD}Sur quels dépôts ?{C.RESET}") | |
| print(f" {C.GREEN}a{C.RESET} — Tous") | |
| print(f" {C.GREEN}n{C.RESET} — Sélection par numéro(s) ex: 1 3 5") | |
| print() | |
| while True: | |
| try: | |
| choice = input(" Choix [a/n] : ").strip().lower() | |
| except (KeyboardInterrupt, EOFError): | |
| print() | |
| return [] | |
| if choice == 'a': | |
| return repos | |
| if choice == 'n': | |
| try: | |
| raw = input(" Numéros : ").strip() | |
| indices = [int(x) - 1 for x in raw.split()] | |
| selected = [repos[i] for i in indices if 0 <= i < len(repos)] | |
| if selected: | |
| return selected | |
| except (ValueError, IndexError): | |
| pass | |
| print(" Sélection invalide, réessayez.") | |
| # ─── Main ───────────────────────────────────────────────────────────────────── | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| prog='git-sync', | |
| description='Gère tous les dépôts Git du répertoire courant.', | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| exemples : | |
| git-sync scan + menu interactif (sync par défaut) | |
| git-sync --pull pull --rebase sur tous les dépôts | |
| git-sync --sync commit + pull + push sur tous les dépôts | |
| git-sync --list liste les dépôts sans rien faire | |
| git-sync --depth 2 limite la profondeur de recherche à 2 niveaux | |
| """ | |
| ) | |
| mode = parser.add_mutually_exclusive_group() | |
| mode.add_argument('--pull', action='store_true', help='pull --rebase sur tous les dépôts') | |
| mode.add_argument('--sync', action='store_true', help='commit + pull + push (défaut interactif)') | |
| mode.add_argument('--list', action='store_true', help='liste les dépôts sans exécuter d\'action') | |
| parser.add_argument('--depth', type=int, default=4, metavar='N', | |
| help='profondeur max de recherche (défaut : 4)') | |
| args = parser.parse_args() | |
| root = Path.cwd() | |
| print(f"\n{C.BOLD}{C.MAGENTA}{'═' * 48}{C.RESET}") | |
| print(f"{C.BOLD}{C.MAGENTA} Git Sync — {root}{C.RESET}") | |
| print(f"{C.BOLD}{C.MAGENTA}{'═' * 48}{C.RESET}") | |
| info("Recherche des dépôts Git…") | |
| repos = find_repos(root, max_depth=args.depth) | |
| if not repos: | |
| warn("Aucun dépôt Git trouvé.") | |
| return 0 | |
| display_repos(repos, root) | |
| if args.list: | |
| return 0 | |
| # Déterminer l'action | |
| if args.pull: | |
| action = 'p' | |
| targets = repos | |
| elif args.sync: | |
| action = 's' | |
| targets = repos | |
| else: | |
| # Mode interactif | |
| action = ask_action() | |
| if action == 'q': | |
| info("Annulé.") | |
| return 0 | |
| targets = ask_scope(repos, root) | |
| if not targets: | |
| info("Aucun dépôt sélectionné.") | |
| return 0 | |
| label = "Pull" if action == 'p' else "Sync" | |
| errors = 0 | |
| for repo in targets: | |
| try: | |
| rel = repo.relative_to(root) | |
| except ValueError: | |
| rel = repo | |
| header(f"── {rel} ──") | |
| fn = pull_repo if action == 'p' else sync_repo | |
| if not fn(repo): | |
| errors += 1 | |
| # Résumé | |
| header("Résumé") | |
| total = len(targets) | |
| print(f" {label} : {total - errors}/{total} réussi(s)" | |
| + (f" {C.RED}({errors} erreur(s)){C.RESET}" if errors else f" {C.GREEN}✔{C.RESET}")) | |
| print() | |
| return 0 if errors == 0 else 1 | |
| if __name__ == '__main__': | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment