commit 2bcdd327ee24a07c361bfeab16f1880b35a683df Author: zogzog Date: Tue Mar 24 12:19:08 2026 +0100 initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..acf6101 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(python git_updater.py)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41eabf7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# Git Update Checker + +## Description + +Outil Windows (.exe) avec interface graphique qui vérifie les mises à jour de **plusieurs dépôts Git distants** et propose de les télécharger. +Conçu pour être placé sur une **clé USB** dont la lettre de lecteur peut changer. +Le programme peut **s'auto-mettre à jour** car il est lui-même dans un dépôt git. + +## Structure du projet + +``` +Lanceur-geco/ +├── git_updater.py # Script principal Python (GUI tkinter) +├── config.ini # Configuration multi-repo +├── build.bat # Script de compilation en .exe via PyInstaller +├── log/ # Dossier de logs (créé automatiquement, 1 fichier par jour) +└── CLAUDE.md +``` + +## Règles importantes + +- **Tous les chemins doivent être relatifs** à l'emplacement de l'exe. Jamais de chemin absolu (pas de `C:\`, `G:\`, etc.). Utiliser `..` et des chemins relatifs pour référencer les dossiers. +- **Accès lecture seule** : le programme ne fait que `git fetch`, `git pull` et `git checkout`. Jamais de `git push`, `git commit`, `git add`, ou toute opération d'écriture vers le remote. +- **Multi-repo** : le programme peut surveiller plusieurs dépôts Git configurés dans `config.ini`. + +## Fonctionnement + +### Auto-update +1. Au démarrage, le programme vérifie si son propre dossier est un dépôt git +2. Si oui, il fait un `git fetch` et compare avec le remote +3. Si une MAJ du programme est dispo, il propose de la télécharger (`git pull`) +4. Après mise à jour, il demande un redémarrage + +### Vérification des dépôts +1. Lit la liste des dépôts depuis `config.ini` (chemins relatifs à l'exe) +2. Pour chaque dépôt : + - `git fetch` pour récupérer l'état distant + - Compare commits locaux vs distants + - Détecte les fichiers supprimés/modifiés localement +3. Affiche le résultat dans une interface graphique (tkinter) +4. Propose pour chaque dépôt : + - `git pull` si nouveaux commits distants + - `git checkout -- .` si fichiers locaux manquants/modifiés + +## Configuration (config.ini) + +Supporte plusieurs sections `[repo:NomDuRepo]` : + +```ini +[repo:Batch] +url = http://192.168.1.235:3125/zogzog/Batch +path = ../Batch + +[repo:Powershell] +url = http://192.168.1.235:3125/zogzog/Powershell +path = ../Powershell +``` + +- `url` : URL du dépôt Git distant +- `path` : Chemin **relatif** vers le dossier local du dépôt (relatif à l'exe) + +## Logging + +- Les logs sont écrits dans `log/` à côté de l'exe (1 fichier par jour, format `YYYY-MM-DD.log`) +- Les vieux logs sont nettoyés automatiquement (30 jours de rétention) +- Chaque action git, erreur, et résultat est loggé avec timestamp +- Bouton "Ouvrir les logs" dans la GUI pour accéder au dossier + +## Build + +```bat +build.bat +``` +Requiert Python + pip. Installe PyInstaller automatiquement si absent. +Produit `dist/GitUpdateChecker.exe`. Copier `config.ini` à côté de l'exe. + +## Contraintes techniques + +- **Chemins relatifs** : Tout est relatif à l'exe, jamais de chemin absolu +- **Encodage** : Force UTF-8 pour les caractères Unicode +- **Clé USB** : Fonctionne sur n'importe quelle lettre de lecteur +- **Git requis** : Git doit être installé et dans le PATH de la machine +- **Serveur Gitea** : Le remote origin pointe vers une instance Gitea locale (192.168.1.235:3125) +- **Lecture seule** : Aucune opération d'écriture vers le remote (pas de push/commit) +- **Interface** : GUI tkinter (inclus dans Python, pas de dépendance externe) +- **Logs** : Dossier `log/` à côté de l'exe, rotation automatique 30 jours + +## Conventions + +- Langage : Python 3, pas de dépendances externes (seulement stdlib + tkinter) +- Interface : GUI tkinter en français +- Langue : Français pour l'interface utilisateur diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..3aeb4d6 --- /dev/null +++ b/build.bat @@ -0,0 +1,37 @@ +@echo off +echo ======================================== +echo Build Git Update Checker (.exe) +echo ======================================== +echo. + +:: Vérifier que Python est installé +python --version >nul 2>&1 +if errorlevel 1 ( + echo [ERREUR] Python n'est pas installe ou pas dans le PATH. + pause + exit /b 1 +) + +:: Installer PyInstaller si nécessaire +pip show pyinstaller >nul 2>&1 +if errorlevel 1 ( + echo [*] Installation de PyInstaller... + pip install pyinstaller +) + +echo [*] Compilation en cours... +echo. + +pyinstaller --onefile --console --name "GitUpdateChecker" --icon=NONE git_updater.py + +echo. +if exist "dist\GitUpdateChecker.exe" ( + echo [OK] Executable cree : dist\GitUpdateChecker.exe + echo. + echo N'oublie pas de copier config.ini a cote de l'exe ! +) else ( + echo [ERREUR] La compilation a echoue. +) + +echo. +pause diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..faa4564 --- /dev/null +++ b/config.ini @@ -0,0 +1,7 @@ +; Configuration des depots Git a surveiller +; Les chemins (path) sont relatifs a l'emplacement de l'exe +; Ajouter autant de sections [repo:NomDuRepo] que necessaire + +[repo:Batch] +url = http://192.168.1.235:3125/zogzog/Batch +path = ../Batch diff --git a/git_updater.py b/git_updater.py new file mode 100644 index 0000000..a09050c --- /dev/null +++ b/git_updater.py @@ -0,0 +1,728 @@ +""" +Git Update Checker - GUI multi-repo. +Vérifie les mises à jour de plusieurs dépôts Git et propose de les télécharger. +Accès lecture seule uniquement (fetch/pull/checkout, jamais de push). +Tous les chemins sont relatifs à l'emplacement de l'exécutable. +""" + +import subprocess +import sys +import os +import configparser +import logging +import threading +import tkinter as tk +from tkinter import ttk, messagebox +from pathlib import Path +from datetime import datetime + +# Forcer UTF-8 sur Windows +if sys.platform == "win32": + os.system("chcp 65001 >nul 2>&1") + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + +# ── Utilitaires ────────────────────────────────────────────────────────────── + +def get_exe_dir(): + if getattr(sys, "frozen", False): + return Path(sys.executable).parent + return Path(__file__).parent + + +def resolve_relative(path_str): + """Résout un chemin relatif par rapport au dossier de l'exe.""" + p = Path(path_str) + if not p.is_absolute(): + p = get_exe_dir() / p + return p.resolve() + + +# ── Logging ────────────────────────────────────────────────────────────────── + +def setup_logging(): + """Configure le logging dans un dossier log/ à côté de l'exe.""" + log_dir = get_exe_dir() / "log" + log_dir.mkdir(exist_ok=True) + + log_file = log_dir / f"{datetime.now().strftime('%Y-%m-%d')}.log" + + logger = logging.getLogger("GitUpdateChecker") + logger.setLevel(logging.DEBUG) + + # Handler fichier + fh = logging.FileHandler(log_file, encoding="utf-8") + fh.setLevel(logging.DEBUG) + fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S")) + logger.addHandler(fh) + + # Nettoyage des vieux logs (garder 30 jours) + for old_log in sorted(log_dir.glob("*.log"))[:-30]: + try: + old_log.unlink() + except OSError: + pass + + return logger + + +log = setup_logging() + + +def run_git(args, cwd=None): + cmd = ["git"] + args + log.debug(f"git {' '.join(args)} (cwd={cwd})") + try: + result = subprocess.run( + cmd, cwd=cwd, capture_output=True, text=True, timeout=30, + creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, + ) + if result.returncode != 0 and result.stderr.strip(): + log.warning(f"git {args[0]} erreur: {result.stderr.strip()}") + return result.returncode, result.stdout.strip(), result.stderr.strip() + except FileNotFoundError: + log.error("Git non trouve dans le PATH") + return -1, "", "Git n'est pas installe ou pas dans le PATH." + except subprocess.TimeoutExpired: + log.error(f"Timeout: git {' '.join(args)}") + return 1, "", "Timeout" + + +# ── Auto-update du programme ───────────────────────────────────────────────── + +def check_self_update(): + """ + Vérifie si le dossier de l'exe est un dépôt git avec des MAJ disponibles. + Retourne (needs_update: bool, info: str). + """ + exe_dir = str(get_exe_dir()) + + if not os.path.isdir(os.path.join(exe_dir, ".git")): + log.info("Auto-update: pas de .git dans le dossier de l'exe, skip") + return False, "" + + log.info("Auto-update: verification...") + + code, branch, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=exe_dir) + if code != 0: + return False, "Impossible de lire la branche" + + code, _, err = run_git(["fetch", "origin"], cwd=exe_dir) + if code != 0: + log.warning(f"Auto-update: fetch echoue: {err}") + return False, f"Fetch echoue: {err}" + + code, local_hash, _ = run_git(["rev-parse", "HEAD"], cwd=exe_dir) + code2, remote_hash, _ = run_git(["rev-parse", f"origin/{branch}"], cwd=exe_dir) + + if code != 0 or code2 != 0: + return False, "Impossible de comparer les commits" + + if local_hash == remote_hash: + log.info("Auto-update: programme a jour") + return False, "" + + # Compter les commits en retard + code, log_out, _ = run_git( + ["log", "--oneline", f"HEAD..origin/{branch}"], + cwd=exe_dir, + ) + count = len(log_out.splitlines()) if code == 0 and log_out else 0 + info = f"{count} commit(s) en retard sur origin/{branch}" + log.info(f"Auto-update: MAJ disponible - {info}") + return True, info + + +def do_self_update(): + """Pull les mises à jour du programme lui-même. Retourne (ok, message).""" + exe_dir = str(get_exe_dir()) + + code, branch, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=exe_dir) + if code != 0: + return False, "Impossible de lire la branche" + + # Restaurer les fichiers locaux modifiés d'abord + run_git(["checkout", "--", "."], cwd=exe_dir) + + code, out, err = run_git(["pull", "origin", branch], cwd=exe_dir) + if code == 0: + log.info(f"Auto-update: pull OK") + return True, "Mise a jour du programme reussie !\nRedemarre le programme pour appliquer." + else: + log.error(f"Auto-update: pull echoue: {err}") + return False, f"Erreur pull: {err}" + + +# ── Configuration ──────────────────────────────────────────────────────────── + +def get_config_path(): + return get_exe_dir() / "config.ini" + + +def load_repos(): + """Charge la liste des dépôts depuis config.ini.""" + config_path = get_config_path() + config = configparser.ConfigParser() + repos = [] + + if not config_path.exists(): + # Créer un config.ini exemple + config["repo:Exemple"] = { + "url": "http://192.168.1.235:3125/user/repo", + "path": "../MonRepo", + } + with open(config_path, "w", encoding="utf-8") as f: + f.write("; Configuration des depots Git a surveiller\n") + f.write("; Les chemins (path) sont relatifs a l'emplacement de l'exe\n") + f.write("; Ajouter autant de sections [repo:NomDuRepo] que necessaire\n\n") + config.write(f) + return repos + + config.read(config_path, encoding="utf-8") + + for section in config.sections(): + if section.startswith("repo:"): + name = section[5:] + url = config.get(section, "url", fallback="").strip() + path = config.get(section, "path", fallback="").strip() + if url and path: + repos.append({"name": name, "url": url, "path": path}) + + return repos + + +# ── Logique Git ────────────────────────────────────────────────────────────── + +def check_repo(repo): + """Vérifie un dépôt et retourne son état.""" + name = repo["name"] + url = repo["url"] + local_path = str(resolve_relative(repo["path"])) + log.info(f"[{name}] Verification - {url} -> {local_path}") + + result = { + "name": name, + "url": url, + "local_path": local_path, + "relative_path": repo["path"], + "exists": False, + "up_to_date": False, + "error": None, + "commits": [], + "files": [], + "local_changes": [], + "local_only": False, + "branch": "", + "local_hash": "", + "remote_hash": "", + "needs_clone": False, + } + + # Vérifier si le dossier existe et est un repo git + if not os.path.isdir(os.path.join(local_path, ".git")): + if os.path.isdir(local_path) and os.listdir(local_path): + result["error"] = "Le dossier existe mais n'est pas un depot git." + return result + result["needs_clone"] = True + return result + + result["exists"] = True + + # Branche courante + code, branch, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=local_path) + if code != 0: + result["error"] = "Impossible de lire la branche courante." + return result + result["branch"] = branch + + # Fetch + code, _, err = run_git(["fetch", "origin"], cwd=local_path) + if code != 0: + result["error"] = f"Erreur fetch : {err}" + return result + + # Comparer HEAD local vs origin + code, local_hash, _ = run_git(["rev-parse", "HEAD"], cwd=local_path) + if code != 0: + result["error"] = "Impossible de lire le commit local." + return result + + code, remote_hash, _ = run_git(["rev-parse", f"origin/{branch}"], cwd=local_path) + if code != 0: + result["error"] = f"Impossible de trouver origin/{branch}." + return result + + result["local_hash"] = local_hash[:8] + result["remote_hash"] = remote_hash[:8] + + # Modifications locales + code, local_diff, _ = run_git(["diff", "--name-status", "HEAD"], cwd=local_path) + if code == 0 and local_diff: + for line in local_diff.splitlines(): + parts = line.split("\t", 1) + if len(parts) == 2: + sc = parts[0].strip() + fn = parts[1].strip() + status_map = {"D": "Supprime localement", "M": "Modifie localement"} + result["local_changes"].append({ + "status": status_map.get(sc[0], sc), + "status_char": sc[0], + "file": fn, + }) + + if local_hash == remote_hash and not result["local_changes"]: + log.info(f"[{name}] A jour ({local_hash[:8]})") + result["up_to_date"] = True + return result + + if local_hash == remote_hash: + log.info(f"[{name}] {len(result['local_changes'])} fichier(s) locaux modifies/supprimes") + result["local_only"] = True + return result + + # Nouveaux commits distants + code, log_out, _ = run_git( + ["log", "--oneline", "--format=%h|%an|%ar|%s", f"HEAD..origin/{branch}"], + cwd=local_path, + ) + if code == 0 and log_out: + for line in log_out.splitlines(): + parts = line.split("|", 3) + if len(parts) == 4: + result["commits"].append({ + "hash": parts[0], "author": parts[1], + "date": parts[2], "message": parts[3], + }) + + # Fichiers distants modifiés + code, diff_out, _ = run_git( + ["diff", "--name-status", f"HEAD..origin/{branch}"], + cwd=local_path, + ) + if code == 0 and diff_out: + for line in diff_out.splitlines(): + parts = line.split("\t", 1) + if len(parts) == 2: + sc = parts[0].strip() + fn = parts[1].strip() + status_map = {"A": "Ajoute", "M": "Modifie", "D": "Supprime", "R": "Renomme"} + result["files"].append({ + "status": status_map.get(sc[0], sc), + "status_char": sc[0], + "file": fn, + }) + + return result + + +def do_clone(repo): + """Clone un dépôt.""" + local_path = str(resolve_relative(repo["path"])) + log.info(f"Clonage: {repo['url']} -> {local_path}") + code, _, err = run_git(["clone", repo["url"], local_path]) + if code == 0: + log.info(f"Clonage reussi: {local_path}") + else: + log.error(f"Clonage echoue: {err}") + return code == 0, err + + +def do_pull(local_path, branch): + """Pull les mises à jour (lecture seule).""" + log.info(f"Pull: {local_path} (branche {branch})") + code, out, err = run_git(["pull", "origin", branch], cwd=local_path) + if code == 0: + log.info(f"Pull reussi: {local_path}") + else: + log.error(f"Pull echoue: {err}") + return code == 0, out, err + + +def do_restore(local_path): + """Restaure les fichiers locaux modifiés/supprimés.""" + log.info(f"Restauration: {local_path}") + code, _, err = run_git(["checkout", "--", "."], cwd=local_path) + if code == 0: + log.info(f"Restauration reussie: {local_path}") + else: + log.error(f"Restauration echouee: {err}") + return code == 0, err + + +# ── Interface graphique ────────────────────────────────────────────────────── + +class App(tk.Tk): + def __init__(self): + super().__init__() + self.title("Git Update Checker") + self.geometry("820x600") + self.minsize(700, 450) + self.configure(bg="#1e1e2e") + + self.repos_config = load_repos() + self.repo_results = [] + + log.info("=== Demarrage Git Update Checker ===") + self._build_ui() + self.after(100, self._check_self_update_then_repos) + + def _build_ui(self): + style = ttk.Style(self) + style.theme_use("clam") + + # Couleurs sombres + bg = "#1e1e2e" + fg = "#cdd6f4" + accent = "#89b4fa" + green = "#a6e3a1" + yellow = "#f9e2af" + red = "#f38ba8" + + style.configure("TFrame", background=bg) + style.configure("TLabel", background=bg, foreground=fg, font=("Segoe UI", 10)) + style.configure("Title.TLabel", background=bg, foreground=fg, font=("Segoe UI", 14, "bold")) + style.configure("Status.TLabel", background=bg, foreground=accent, font=("Segoe UI", 9)) + style.configure("TButton", font=("Segoe UI", 10)) + style.configure("Green.TLabel", background=bg, foreground=green, font=("Segoe UI", 10, "bold")) + style.configure("Yellow.TLabel", background=bg, foreground=yellow, font=("Segoe UI", 10, "bold")) + style.configure("Red.TLabel", background=bg, foreground=red, font=("Segoe UI", 10, "bold")) + + # Header + header = ttk.Frame(self) + header.pack(fill="x", padx=15, pady=(15, 5)) + ttk.Label(header, text="Git Update Checker", style="Title.TLabel").pack(side="left") + self.status_label = ttk.Label(header, text="Verification en cours...", style="Status.TLabel") + self.status_label.pack(side="right") + + # Date + date_label = ttk.Label(self, text=datetime.now().strftime(" %d/%m/%Y %H:%M:%S"), style="Status.TLabel") + date_label.pack(anchor="w", padx=15) + + # Zone scrollable pour les repos + container = ttk.Frame(self) + container.pack(fill="both", expand=True, padx=15, pady=10) + + self.canvas = tk.Canvas(container, bg=bg, highlightthickness=0) + scrollbar = ttk.Scrollbar(container, orient="vertical", command=self.canvas.yview) + self.scroll_frame = ttk.Frame(self.canvas) + + self.scroll_frame.bind("", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) + self.canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw") + self.canvas.configure(yscrollcommand=scrollbar.set) + + self.canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # Scroll avec la molette + self.canvas.bind_all("", lambda e: self.canvas.yview_scroll(int(-1 * (e.delta / 120)), "units")) + + # Boutons en bas + btn_frame = ttk.Frame(self) + btn_frame.pack(fill="x", padx=15, pady=(0, 15)) + + self.btn_refresh = ttk.Button(btn_frame, text="Rafraichir", command=self._start_check) + self.btn_refresh.pack(side="left", padx=(0, 10)) + + self.btn_update_all = ttk.Button(btn_frame, text="Tout mettre a jour", command=self._update_all) + self.btn_update_all.pack(side="left") + self.btn_update_all.state(["disabled"]) + + ttk.Button(btn_frame, text="Ouvrir config.ini", command=self._open_config).pack(side="right") + + self.btn_open_log = ttk.Button(btn_frame, text="Ouvrir les logs", command=self._open_log) + self.btn_open_log.pack(side="right", padx=(0, 10)) + + def _check_self_update_then_repos(self): + """Vérifie d'abord la MAJ du programme, puis les repos.""" + self.status_label.configure(text="Verification auto-update...") + + def work(): + needs, info = check_self_update() + self.after(0, lambda: self._handle_self_update(needs, info)) + + threading.Thread(target=work, daemon=True).start() + + def _handle_self_update(self, needs_update, info): + """Gère le résultat de l'auto-update.""" + if needs_update: + answer = messagebox.askyesno( + "Mise a jour du programme", + f"Une mise a jour du programme est disponible !\n\n{info}\n\nMettre a jour maintenant ?", + ) + if answer: + self.status_label.configure(text="Mise a jour du programme...") + + def work(): + ok, msg = do_self_update() + self.after(0, lambda: self._self_update_done(ok, msg)) + + threading.Thread(target=work, daemon=True).start() + return + + self._start_check() + + def _self_update_done(self, ok, msg): + """Callback après auto-update.""" + if ok: + messagebox.showinfo("Auto-update", msg) + log.info("Auto-update appliquee, fermeture") + self.destroy() + return + else: + messagebox.showwarning("Auto-update", msg) + self._start_check() + + def _start_check(self): + """Lance la vérification dans un thread.""" + self.btn_refresh.state(["disabled"]) + self.btn_update_all.state(["disabled"]) + self.status_label.configure(text="Verification en cours...") + self.repos_config = load_repos() + + if not self.repos_config: + self._clear_cards() + self.status_label.configure(text="Aucun depot configure") + self._show_no_repos() + self.btn_refresh.state(["!disabled"]) + return + + threading.Thread(target=self._check_all, daemon=True).start() + + def _check_all(self): + """Vérifie tous les repos (dans un thread).""" + results = [] + for repo in self.repos_config: + results.append(check_repo(repo)) + self.repo_results = results + self.after(0, self._display_results) + + def _display_results(self): + """Affiche les résultats dans la GUI.""" + self._clear_cards() + + has_any_updates = False + for res in self.repo_results: + card = self._create_card(res) + card.pack(fill="x", pady=5) + if not res["up_to_date"] and not res.get("error"): + has_any_updates = True + + total = len(self.repo_results) + up = sum(1 for r in self.repo_results if r["up_to_date"]) + log.info(f"Resultat: {up}/{total} depots a jour") + self.status_label.configure(text=f"{up}/{total} depots a jour") + self.btn_refresh.state(["!disabled"]) + + if has_any_updates: + self.btn_update_all.state(["!disabled"]) + + def _clear_cards(self): + for w in self.scroll_frame.winfo_children(): + w.destroy() + + def _create_card(self, res): + """Crée une carte pour un dépôt.""" + bg_card = "#313244" + fg = "#cdd6f4" + green = "#a6e3a1" + yellow = "#f9e2af" + red = "#f38ba8" + cyan = "#89dceb" + dim = "#6c7086" + + card = tk.Frame(self.scroll_frame, bg=bg_card, bd=0, highlightthickness=1, highlightbackground="#45475a") + + # Header de la carte + top = tk.Frame(card, bg=bg_card) + top.pack(fill="x", padx=12, pady=(10, 5)) + + tk.Label(top, text=res["name"], bg=bg_card, fg=fg, font=("Segoe UI", 11, "bold")).pack(side="left") + + if res.get("error"): + tk.Label(top, text="ERREUR", bg=bg_card, fg=red, font=("Segoe UI", 9, "bold")).pack(side="right") + elif res.get("needs_clone"): + tk.Label(top, text="A CLONER", bg=bg_card, fg=yellow, font=("Segoe UI", 9, "bold")).pack(side="right") + elif res["up_to_date"]: + tk.Label(top, text="A JOUR", bg=bg_card, fg=green, font=("Segoe UI", 9, "bold")).pack(side="right") + else: + count = len(res["commits"]) + len(res["local_changes"]) + tk.Label(top, text=f"MAJ DISPO ({count})", bg=bg_card, fg=yellow, font=("Segoe UI", 9, "bold")).pack(side="right") + + # Infos + info = tk.Frame(card, bg=bg_card) + info.pack(fill="x", padx=12) + tk.Label(info, text=f"Chemin : {res['relative_path']}", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w") + if res["branch"]: + tk.Label(info, text=f"Branche : {res['branch']} | {res['local_hash']}", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w") + + # Erreur + if res.get("error"): + tk.Label(card, text=f" {res['error']}", bg=bg_card, fg=red, font=("Segoe UI", 9), anchor="w").pack(fill="x", padx=12, pady=(5, 0)) + + # Commits distants + if res["commits"]: + tk.Label(card, text=f"Nouveaux commits ({len(res['commits'])}) :", bg=bg_card, fg=fg, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(8, 2)) + for c in res["commits"][:5]: + line_frame = tk.Frame(card, bg=bg_card) + line_frame.pack(fill="x", padx=20) + tk.Label(line_frame, text=c["hash"], bg=bg_card, fg=cyan, font=("Consolas", 9)).pack(side="left") + tk.Label(line_frame, text=f" {c['message']}", bg=bg_card, fg=fg, font=("Segoe UI", 9), anchor="w").pack(side="left", fill="x") + if len(res["commits"]) > 5: + tk.Label(card, text=f" ... et {len(res['commits']) - 5} autres commits", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w", padx=20) + + # Fichiers distants modifiés + if res["files"]: + tk.Label(card, text=f"Fichiers distants modifies ({len(res['files'])}) :", bg=bg_card, fg=fg, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(5, 2)) + color_map = {"A": green, "M": yellow, "D": red, "R": cyan} + for f in res["files"][:8]: + line_frame = tk.Frame(card, bg=bg_card) + line_frame.pack(fill="x", padx=20) + c = color_map.get(f["status_char"], fg) + tk.Label(line_frame, text=f"[{f['status']:>9}]", bg=bg_card, fg=c, font=("Consolas", 9)).pack(side="left") + tk.Label(line_frame, text=f" {f['file']}", bg=bg_card, fg=fg, font=("Segoe UI", 9)).pack(side="left") + if len(res["files"]) > 8: + tk.Label(card, text=f" ... et {len(res['files']) - 8} autres fichiers", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w", padx=20) + + # Fichiers locaux manquants + if res["local_changes"]: + tk.Label(card, text=f"Fichiers locaux a restaurer ({len(res['local_changes'])}) :", bg=bg_card, fg=fg, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(5, 2)) + for f in res["local_changes"][:8]: + line_frame = tk.Frame(card, bg=bg_card) + line_frame.pack(fill="x", padx=20) + c = red if f["status_char"] == "D" else yellow + tk.Label(line_frame, text=f"[{f['status']:>20}]", bg=bg_card, fg=c, font=("Consolas", 8)).pack(side="left") + tk.Label(line_frame, text=f" {f['file']}", bg=bg_card, fg=fg, font=("Segoe UI", 9)).pack(side="left") + if len(res["local_changes"]) > 8: + tk.Label(card, text=f" ... et {len(res['local_changes']) - 8} autres fichiers", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w", padx=20) + + # Bouton d'action + if not res["up_to_date"] and not res.get("error"): + btn_frame = tk.Frame(card, bg=bg_card) + btn_frame.pack(fill="x", padx=12, pady=(8, 0)) + + if res.get("needs_clone"): + btn = ttk.Button(btn_frame, text="Cloner le depot", command=lambda r=res: self._do_clone(r)) + else: + btn = ttk.Button(btn_frame, text="Mettre a jour", command=lambda r=res: self._do_update(r)) + btn.pack(side="left") + # Stocker ref du bouton pour le désactiver après + res["_btn"] = btn + + # Padding bas + tk.Frame(card, bg=bg_card, height=10).pack() + + return card + + def _do_update(self, res): + """Met à jour un dépôt (pull + restore).""" + log.info(f"[{res['name']}] MAJ unitaire demandee") + if "_btn" in res: + res["_btn"].state(["disabled"]) + + def work(): + messages = [] + success = True + local_path = res["local_path"] + branch = res["branch"] + + if res["commits"]: + log.info(f"[{res['name']}] Pull de {len(res['commits'])} commit(s)...") + ok, out, err = do_pull(local_path, branch) + if ok: + msg = f"Pull OK : {len(res['commits'])} commits telecharges." + log.info(f"[{res['name']}] {msg}") + messages.append(msg) + else: + msg = f"Erreur pull : {err}" + log.error(f"[{res['name']}] {msg}") + messages.append(msg) + success = False + + if res["local_changes"]: + log.info(f"[{res['name']}] Restauration de {len(res['local_changes'])} fichier(s)...") + ok, err = do_restore(local_path) + if ok: + msg = f"Restauration OK : {len(res['local_changes'])} fichiers restaures." + log.info(f"[{res['name']}] {msg}") + messages.append(msg) + else: + msg = f"Erreur restauration : {err}" + log.error(f"[{res['name']}] {msg}") + messages.append(msg) + success = False + + status = "SUCCES" if success else "ECHEC" + log.info(f"[{res['name']}] MAJ unitaire terminee - {status}") + self.after(0, lambda: self._show_update_result(res, messages, success)) + + threading.Thread(target=work, daemon=True).start() + + def _do_clone(self, res): + """Clone un dépôt.""" + log.info(f"[{res['name']}] Clonage demande - {res['url']}") + if "_btn" in res: + res["_btn"].state(["disabled"]) + + repo = {"url": res["url"], "path": res["relative_path"]} + + def work(): + ok, err = do_clone(repo) + if ok: + msg = f"Depot '{res['name']}' clone avec succes !" + log.info(f"[{res['name']}] {msg}") + else: + msg = f"Erreur de clonage : {err}" + log.error(f"[{res['name']}] {msg}") + self.after(0, lambda: messagebox.showinfo("Clonage", msg)) + self.after(100, self._start_check) + + threading.Thread(target=work, daemon=True).start() + + def _show_update_result(self, res, messages, success): + title = "Mise a jour" if success else "Erreur" + messagebox.showinfo(title, f"{res['name']}\n\n" + "\n".join(messages)) + self._start_check() + + def _update_all(self): + """Met à jour tous les dépôts qui ont des MAJ.""" + to_update = [r for r in self.repo_results if not r["up_to_date"] and not r.get("error") and not r.get("needs_clone")] + log.info(f"MAJ globale demandee - {len(to_update)} depot(s) a mettre a jour") + for res in to_update: + self._do_update(res) + + def _show_no_repos(self): + bg_card = "#313244" + fg = "#cdd6f4" + dim = "#6c7086" + + card = tk.Frame(self.scroll_frame, bg=bg_card, bd=0, highlightthickness=1, highlightbackground="#45475a") + card.pack(fill="x", pady=5) + + tk.Label(card, text="Aucun depot configure", bg=bg_card, fg=fg, font=("Segoe UI", 11, "bold")).pack(padx=12, pady=(12, 5)) + tk.Label(card, text="Edite config.ini pour ajouter des depots :", bg=bg_card, fg=dim, font=("Segoe UI", 9)).pack(padx=12) + example = ( + "[repo:MonRepo]\n" + "url = http://192.168.1.235:3125/user/repo\n" + "path = ../MonRepo" + ) + tk.Label(card, text=example, bg="#1e1e2e", fg="#a6e3a1", font=("Consolas", 9), justify="left", padx=10, pady=8).pack(padx=12, pady=8, fill="x") + tk.Frame(card, bg=bg_card, height=10).pack() + + def _open_config(self): + config_path = str(get_config_path()) + if sys.platform == "win32": + os.startfile(config_path) + + def _open_log(self): + log_dir = str(get_exe_dir() / "log") + if sys.platform == "win32": + os.startfile(log_dir) + + +if __name__ == "__main__": + app = App() + app.mainloop() diff --git a/log/2026-03-24.log b/log/2026-03-24.log new file mode 100644 index 0000000..f7121b7 --- /dev/null +++ b/log/2026-03-24.log @@ -0,0 +1,20 @@ +12:14:31 [INFO] === Demarrage Git Update Checker === +12:14:31 [INFO] Auto-update: pas de .git dans le dossier de l'exe, skip +12:14:31 [INFO] [Batch] Verification - http://192.168.1.235:3125/zogzog/Batch -> G:\- SCRIPT -\Script Guigui\Batch +12:14:31 [DEBUG] git rev-parse --abbrev-ref HEAD (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:14:31 [DEBUG] git fetch origin (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:14:32 [DEBUG] git rev-parse HEAD (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:14:32 [DEBUG] git rev-parse origin/master (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:14:32 [DEBUG] git diff --name-status HEAD (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:14:32 [INFO] [Batch] A jour (c825e316) +12:14:32 [INFO] Resultat: 1/1 depots a jour +12:17:04 [INFO] === Demarrage Git Update Checker === +12:17:04 [INFO] Auto-update: pas de .git dans le dossier de l'exe, skip +12:17:04 [INFO] [Batch] Verification - http://192.168.1.235:3125/zogzog/Batch -> G:\- SCRIPT -\Script Guigui\Batch +12:17:04 [DEBUG] git rev-parse --abbrev-ref HEAD (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:17:05 [DEBUG] git fetch origin (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:17:05 [DEBUG] git rev-parse HEAD (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:17:05 [DEBUG] git rev-parse origin/master (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:17:05 [DEBUG] git diff --name-status HEAD (cwd=G:\- SCRIPT -\Script Guigui\Batch) +12:17:05 [INFO] [Batch] 23 fichier(s) locaux modifies/supprimes +12:17:05 [INFO] Resultat: 0/1 depots a jour