initial commit

This commit is contained in:
2026-03-24 12:19:08 +01:00
commit 2bcdd327ee
6 changed files with 891 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(python git_updater.py)"
]
}
}

92
CLAUDE.md Normal file
View File

@@ -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

37
build.bat Normal file
View File

@@ -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

7
config.ini Normal file
View File

@@ -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

728
git_updater.py Normal file
View File

@@ -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("<Configure>", 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("<MouseWheel>", 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()

20
log/2026-03-24.log Normal file
View File

@@ -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