initial commit
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python git_updater.py)"
|
||||
]
|
||||
}
|
||||
}
|
||||
92
CLAUDE.md
Normal file
92
CLAUDE.md
Normal 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
37
build.bat
Normal 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
7
config.ini
Normal 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
728
git_updater.py
Normal 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
20
log/2026-03-24.log
Normal 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
|
||||
Reference in New Issue
Block a user