Skip to content

Instantly share code, notes, and snippets.

@RebelliousSmile
Last active March 2, 2026 07:33
Show Gist options
  • Select an option

  • Save RebelliousSmile/5327b7b7f3dd22aa0981fdc8dbf4b37b to your computer and use it in GitHub Desktop.

Select an option

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
@echo off
python "%~dp0git-sync.py" %*
#!/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