Compare commits

..

33 Commits

Author SHA1 Message Date
9bf66f6d90 Nettoyage: suppression fichiers inutiles
Changements :
- Suppression icon_small.png (non utilise)
- Suppression rsrc.syso du tracking (fichier genere par build.bat)
- Suppression dossier Scripts vide
- Nettoyage .gitignore (ajout rsrc.syso, retrait entrees Python)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:58:54 +01:00
3f0f13147b Suppression gitchecker.exe (ancien build inutilise)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:57:35 +01:00
2b2eb87f45 Fix layout icone en-tete: redimensionnement 24x24 avant affichage
Changements :
- Redimensionnement de l'icone PNG a 24x24 via CatmullRom (golang.org/x/image/draw)
- Corrige le decalage de l'interface cause par l'image 912x1164

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:51:04 +01:00
3ffbb550ec Fix png dans le titre 2026-03-25 12:46:53 +01:00
98b5187bfc v0.7.7 - Icone PNG embarquee dans l'en-tete GUI
Changements :
- Icone icon.png embarquee dans l'exe via go:embed
- Affichage de l'icone a gauche du titre dans l'interface
- Mise a jour CLAUDE.md pour refleter la migration Go

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 12:42:33 +01:00
18fe9d7186 maj 2026-03-25 11:41:51 +01:00
d8f3a29f8e v0.7.6 - Clone dossier non-vide et verification rapide
Changements :
- Clone dans dossier non-vide (git init + remote add + fetch + checkout)
- Verification rapide via git ls-remote au lieu de git fetch (timeout 15s)
- Support branche par repo dans config.ini (champ branch)
- Suppression fichiers Python et artefacts PyInstaller (_internal/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 11:41:11 +01:00
55663e3a19 v0.7.5 - Miroir depot git et detection fichiers non suivis
Changements :
- Detection des fichiers non suivis (untracked) dans chaque depot
- Affichage "X fichier(s) en trop" dans le statut
- Popup de confirmation listant les fichiers avant suppression (git clean -fd)
- Suppression auto des fichiers en trop via "Tout mettre a jour"
- Verification du depot distant via git ls-remote avant de proposer le clone
- Affichage "Depot introuvable" si l'URL pointe vers un repo inexistant

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 10:34:40 +01:00
db57cfacaf v0.7.4 - Verification depot distant et suppression popups erreur
Changements :
- Verification du depot distant via git ls-remote avant de proposer le clone
- Affichage "Depot introuvable" si l'URL pointe vers un repo inexistant
- Remplacement des popups d'erreur par des messages dans le journal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 10:26:33 +01:00
037f211d9d v0.7.3 - Fix re-check unique apres action depot
Changements :
- Ajout barre de progression Unicode dans la colonne Progression
- Capture temps reel de la sortie git (clone/pull --progress)
- Timeouts augmentes (2h clone/pull, 5min fetch) pour gros depots 10+ Go
- Apres mise a jour d'un depot, seul ce depot est re-verifie (plus de re-fetch global)
- Config self-update : ajout branch = feature/go-rewrite

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:59:28 +01:00
da8fc74a68 maj exe 2026-03-25 09:21:41 +01:00
30ece54758 Maj branch 2026-03-25 09:18:49 +01:00
d03ff595ed v0.7.1 - Test auto-update Go
Changements :
- Version 0.7.1 pour tester le mecanisme d auto-update
- GitUpdateChecker.exe compile en Go (exe unique, sans _internal/)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:50:40 +01:00
8c5257e96f Fix TTM_ADDTOOL : manifeste Windows Common Controls 6.0
- app.manifest : active comctl32 v6 (requis par walk) + DPI awareness
- rsrc.syso : manifeste + icone exe embarques dans le binaire via rsrc
- build.bat : genere rsrc.syso automatiquement avant go build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:47:50 +01:00
af439a8e69 Migration vers go 2026-03-25 07:34:53 +01:00
50c8ad9823 feature/go-rewrite : base Go avec walk GUI
- Rewrite complet en Go : exe unique sans _internal/, sans extraction temp
- GUI Windows-native via github.com/lxn/walk (TableView, TextEdit, PushButton)
- Meme fonctionnalites : check repos, pull, checkout, auto-update, logs
- build.bat : go build -ldflags "-H windowsgui -s -w" -> 9.6 Mo, zero dependance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 07:33:26 +01:00
959298fc2d v0.6.6 - Fix icone header : icon_small.png pre-generee
Changements :
- icon_small.png (25x32) generee au build via Pillow LANCZOS, plus de subsample au runtime
- Chargement direct de icon_small.png dans le header, sans calcul de redimensionnement
- build.bat genere automatiquement icon_small.png avant la compilation
- _find_icon() generalisee pour chercher n'importe quel fichier icone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:32:49 +01:00
fa17301842 Bug icon fix 2026-03-24 22:24:59 +01:00
8718b04a7d v0.6.5 - Fix affichage icone dans le header
Changements :
- Methode _find_icon() : cherche icon.png a cote de l'exe puis dans _internal comme fallback
- Redimensionnement corrige : diviseur commun pour conserver le ratio (image 912x1164)
- tk.Label au lieu de ttk.Label pour l'image (meilleur rendu sur fond sombre)
- icon.png bundle dans l'exe via --add-data pour fonctionner sans le fichier externe
- Logs d'erreur si l'icone ne charge pas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:24:24 +01:00
1a51d6b5fa Fix _internal imbriquee
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:07:35 +01:00
997d82bb96 v0.6.4 - Retour onedir (configuration stable)
Changements :
- Retour a --onedir : seule configuration sans erreur DLL confirmee
- _internal/ recommite dans le repo
- build.bat restaure avec copie automatique de _internal/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 22:06:46 +01:00
7d77f4d901 Bug fix 2026-03-24 21:12:05 +01:00
3c9a6e70eb v0.6.3 - Fix _PYI_APPLICATION_HOME_DIR avec --runtime-tmpdir
Changements :
- Ajout de --runtime-tmpdir . dans PyInstaller : extraction a cote de l'exe au lieu de %TEMP%
- Resout l'erreur "_PYI_APPLICATION_HOME_DIR is not defined" de PyInstaller 6.x en onefile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:11:20 +01:00
62d6c7f89f bug fix 2026-03-24 21:09:00 +01:00
415a09a12d v0.6.2 - Retour en onefile, suppression _internal
Changements :
- Retour a --onefile : exe unique, plus besoin de _internal/
- Le fix Zone.Identifier (v0.5.9) resout le probleme de DLL pour les exe telecharges
- Suppression de _internal/ du repo et ajout au .gitignore
- build.bat simplifie

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:08:26 +01:00
b460502626 v0.6.1 - Bump version
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:04:47 +01:00
ead08293fa v0.6.0 - Ajout _internal pour fix erreur DLL
Changements :
- Ajout du dossier _internal/ (DLL Python generees par PyInstaller --onedir)
- Resout l'erreur "Failed to load Python DLL" : plus d'extraction dans %TEMP%
- build.bat copie automatiquement _internal/ a la racine apres chaque build
- _internal/ a re-committer uniquement si la version de Python change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:00:22 +01:00
6954d7cc06 bug fix 2026-03-24 20:54:26 +01:00
a2cefb2c4c v0.6.0 - Fix erreur DLL : passage en mode onedir
Changements :
- Remplacement de --onefile par --onedir dans PyInstaller
- --onefile extrayait les DLL dans %TEMP% a chaque lancement, ce que Windows bloquait (securite, antivirus)
- --onedir place les DLL dans _internal/ a cote de l'exe : pas d'extraction, pas de blocage
- L'auto-update continue de ne remplacer que GitUpdateChecker.exe (_internal/ reste en place)
- build.bat mis a jour avec les instructions de deploiement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:52:43 +01:00
dc3ac499d5 v0.5.9 - Fix erreur DLL apres mise a jour
Changements :
- Suppression du flux Zone.Identifier (Mark of the Web) apres le telechargement du nouvel exe
- Windows bloquait le chargement de python313.dll car le fichier etait marque comme telecharge depuis internet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:44:28 +01:00
345171f035 Améliorations 2026-03-24 20:40:28 +01:00
6863fbad98 v0.5.8 - Ajout icone
Changements :
- Icone affichee dans le coin haut gauche du header de la GUI
- Icone de fenetre et taskbar via iconphoto (icon.png)
- Icone de l'exe compilee depuis icon.png -> icon.ico (Pillow, multi-tailles)
- build.bat mis a jour avec la conversion PNG -> ICO automatique

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:39:52 +01:00
5f2cd13072 v0.5.7 - Suppression de la fenetre console
Changements :
- Remplacement du flag --console par --noconsole dans PyInstaller (app GUI, pas besoin de console)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:34:33 +01:00
21 changed files with 1593 additions and 1053 deletions

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(pyinstaller --onedir --noconsole --name \"GitUpdateChecker\" --icon=icon.ico -y git_updater.py)"
]
}
}

View File

@@ -8,7 +8,13 @@
"Bash(pyinstaller --onefile --console --name \"GitUpdateChecker\" --icon=NONE git_updater.py)", "Bash(pyinstaller --onefile --console --name \"GitUpdateChecker\" --icon=NONE git_updater.py)",
"Bash(python -m PyInstaller --onefile --console --name \"GitUpdateChecker\" --icon=NONE git_updater.py)", "Bash(python -m PyInstaller --onefile --console --name \"GitUpdateChecker\" --icon=NONE git_updater.py)",
"Bash(cmd /c build.bat)", "Bash(cmd /c build.bat)",
"Bash(pyinstaller --onefile --noconsole --name \"GitUpdateChecker\" --icon=NONE git_updater.py)" "Bash(pyinstaller --onefile --noconsole --name \"GitUpdateChecker\" --icon=NONE git_updater.py)",
"Bash(python -c \"from PIL import Image; img = Image.open\\(''icon.png''\\); img.save\\(''icon.ico'', format=''ICO'', sizes=[\\(256,256\\),\\(128,128\\),\\(64,64\\),\\(32,32\\),\\(16,16\\)]\\)\")",
"Bash(pyinstaller --onefile --noconsole --name \"GitUpdateChecker\" --icon=icon.ico git_updater.py)",
"Bash(pyinstaller --onedir --noconsole --name \"GitUpdateChecker\" --icon=icon.ico git_updater.py)",
"Bash(\"j:/Documents/- PROJET -/Code/Lanceur-Geco/Lanceur-Geco/.gitignore\")",
"Bash(pyinstaller --onefile --noconsole --runtime-tmpdir . --name \"GitUpdateChecker\" --icon=icon.ico git_updater.py)",
"Bash(python -c \"from PIL import Image; img = Image.open\\(''j:/Documents/- PROJET -/Code/Lanceur-Geco/Lanceur-Geco/icon.png''\\); print\\(f''{img.width}x{img.height}''\\)\")"
] ]
} }
} }

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
log/ log/
dist/ dist/
build/ build/
__pycache__/ rsrc.syso
*.spec *.spec
*.exe.old *.exe.old
_update.bat _update.bat

105
CLAUDE.md
View File

@@ -6,37 +6,53 @@ Outil Windows (.exe) avec interface graphique qui vérifie les mises à jour de
Conçu pour être placé sur une **clé USB** dont la lettre de lecteur peut changer. 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. Le programme peut **s'auto-mettre à jour** car il est lui-même dans un dépôt git.
## Langage
**Go** (anciennement Python, migré en Go depuis v0.7.x).
- GUI : `github.com/lxn/walk` (contrôles natifs Windows)
- Exe unique, aucune dépendance externe à l'exécution
- Build : `go build` via `build.bat`
## Structure du projet ## Structure du projet
``` ```
Lanceur-geco/ Lanceur-geco/
├── git_updater.py # Script principal Python (GUI tkinter) ├── main.go # Point d'entrée, constante VERSION
├── version.txt # Fichier contenant le numéro de version (ex: "0.5.1") ├── config.go # Chargement config.ini (repos + self-update)
├── git.go # Opérations git (check, clone, pull, checkout, clean)
├── gui.go # Interface graphique (walk/TableView)
├── logger.go # Logging fichier (1 fichier/jour, rotation 30j)
├── selfupdate.go # Auto-mise à jour du programme
├── platform_windows.go # Code spécifique Windows (création processus)
├── version.txt # Numéro de version (utilisé par l'auto-update distant)
├── config.ini # Configuration multi-repo ├── config.ini # Configuration multi-repo
├── build.bat # Script de compilation en .exe via PyInstaller ├── build.bat # Script de compilation en .exe
├── log/ # Dossier de logs (créé automatiquement, 1 fichier par jour) ├── app.manifest # Manifeste Windows (DPI, elevation)
├── icon.ico # Icône application
├── go.mod / go.sum # Dépendances Go
├── log/ # Dossier de logs (créé automatiquement)
└── CLAUDE.md └── CLAUDE.md
``` ```
## Règles importantes ## 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. - **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. - **Accès lecture seule** : le programme ne fait que `git ls-remote`, `git fetch`, `git pull`, `git checkout` et `git clean`. 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`. - **Multi-repo** : le programme peut surveiller plusieurs dépôts Git configurés dans `config.ini`.
## Versioning ## Versioning
- La version est définie en dur dans `git_updater.py` via la constante `VERSION` (ex: `VERSION = "0.5.1"`) - La version est définie en dur dans `main.go` via la constante `VERSION` (ex: `const VERSION = "0.7.6"`)
- Le fichier `version.txt` à la racine du projet contient le même numéro de version (utilisé par le mécanisme d'auto-update distant) - Le fichier `version.txt` à la racine du projet contient le même numéro de version (utilisé par le mécanisme d'auto-update distant)
- Format : **semver simplifié** `MAJEUR.MINEUR.PATCH` (ex: `0.5.1`) - Format : **semver simplifié** `MAJEUR.MINEUR.PATCH` (ex: `0.7.6`)
- **Les deux doivent toujours être synchronisés** : quand on change la version, mettre à jour `VERSION` dans `git_updater.py` ET `version.txt` - **Les deux doivent toujours être synchronisés** : quand on change la version, mettre à jour `VERSION` dans `main.go` ET `version.txt`
### Mise à jour de la version ### Mise à jour de la version
A chaque changement de version, il faut mettre à jour **4 éléments** : A chaque changement de version, il faut mettre à jour **4 éléments** :
1. `VERSION` dans `git_updater.py` (constante en haut du fichier) 1. `VERSION` dans `main.go` (constante en haut du fichier)
2. `version.txt` à la racine du projet 2. `version.txt` à la racine du projet
3. **Recompiler l'exe** via `build.bat` et copier `dist/GitUpdateChecker.exe` à la racine du projet 3. **Recompiler l'exe** via `build.bat` (produit `GitUpdateChecker.exe` à la racine)
4. **Créer un commit** avec le message suivant : 4. **Créer un commit** avec le message suivant :
``` ```
@@ -50,18 +66,18 @@ Changements :
Exemple : Exemple :
``` ```
v0.5.2 - Detection depot hors ligne v0.7.6 - Clone dossier non-vide et verification rapide
Changements : Changements :
- Ajout verification connectivite remote avant fetch (git ls-remote) - Clone dans dossier non-vide (git init + remote add + fetch + checkout)
- Affichage "HORS LIGNE" si le serveur est inaccessible - Verification rapide via git ls-remote au lieu de git fetch
- Synchronisation auto de l'URL origin depuis config.ini - Support branche par repo dans config.ini
``` ```
### Mécanisme de comparaison ### Mécanisme de comparaison
- La fonction `_version_tuple(v)` convertit la chaîne version en tuple d'entiers (ex: `"0.5.1"` -> `(0, 5, 1)`) pour permettre la comparaison numérique - La fonction `parseVersion(v)` convertit la chaîne version en `[3]int` (ex: `"0.7.6"` -> `[0, 7, 6]`) pour permettre la comparaison numérique
- L'auto-update télécharge `version.txt` depuis le serveur Gitea via HTTP (`{repo_url}/raw/branch/master/version.txt`) et compare avec la `VERSION` locale - L'auto-update télécharge `version.txt` depuis le serveur Gitea via HTTP (`{repo_url}/raw/branch/{branch}/version.txt`) et compare avec la `VERSION` locale
- Si la version distante est supérieure, une mise à jour est proposée - Si la version distante est supérieure, une mise à jour est proposée
## Fonctionnement ## Fonctionnement
@@ -71,64 +87,77 @@ Changements :
2. Compare la version distante avec la constante `VERSION` locale (comparaison par tuple numérique) 2. Compare la version distante avec la constante `VERSION` locale (comparaison par tuple numérique)
3. Si la version distante est supérieure, propose de télécharger le nouvel exe 3. Si la version distante est supérieure, propose de télécharger le nouvel exe
4. Stratégie de remplacement : télécharge dans `.new`, renomme l'exe actuel en `.old`, place le nouveau 4. Stratégie de remplacement : télécharge dans `.new`, renomme l'exe actuel en `.old`, place le nouveau
5. Après mise à jour, demande un redémarrage 5. Après mise à jour, lance un script batch de redémarrage
### Vérification des dépôts ### Vérification des dépôts
1. Lit la liste des dépôts depuis `config.ini` (chemins relatifs à l'exe) 1. Lit la liste des dépôts depuis `config.ini` (chemins relatifs à l'exe)
2. Pour chaque dépôt : 2. Pour chaque dépôt (en parallèle via goroutines) :
- `git fetch` pour récupérer l'état distant - `git ls-remote` pour vérifier la disponibilité et comparer les hashs (rapide, timeout 15s)
- Compare commits locaux vs distants - `git status --porcelain` pour détecter les fichiers modifiés/non suivis localement
- Détecte les fichiers supprimés/modifiés localement 3. Affiche le résultat dans une interface graphique (walk/TableView)
3. Affiche le résultat dans une interface graphique (tkinter)
4. Propose pour chaque dépôt : 4. Propose pour chaque dépôt :
- `git pull` si nouveaux commits distants - `git pull` si MAJ disponible (hash distant différent)
- `git checkout -- .` si fichiers locaux manquants/modifiés - `git checkout -- .` si fichiers locaux modifiés
- `git clean -fd` si fichiers non suivis en trop
### Clone dans dossier non-vide
Si le dossier cible existe déjà mais n'a pas de `.git` (ex: repos imbriqués), le programme fait un clone "in-place" :
`git init` + `git remote add` + `git fetch` + `git checkout -b <branch>`
## Configuration (config.ini) ## Configuration (config.ini)
Supporte plusieurs sections `[repo:NomDuRepo]` : Supporte plusieurs sections `[repo:NomDuRepo]` :
```ini ```ini
[repo:Batch] [self-update]
url = http://192.168.1.235:3125/zogzog/Batch url = http://192.168.1.235:3125/zogzog/Lanceur-geco
path = ../Batch exe_name = GitUpdateChecker.exe
branch = master
[repo:Powershell] [repo:Scripts]
url = http://192.168.1.235:3125/zogzog/Powershell url = http://192.168.1.235:3125/zogzog/Scripts
path = ../Powershell path = ../SOFT/Batch/Scripts
[repo:Soft]
url = http://192.168.1.235:3125/zogzog/Soft.git
path = ../SOFT/
branch = master
``` ```
- `url` : URL du dépôt Git distant - `url` : URL du dépôt Git distant
- `path` : Chemin **relatif** vers le dossier local du dépôt (relatif à l'exe) - `path` : Chemin **relatif** vers le dossier local du dépôt (relatif à l'exe)
- `branch` : Branche à suivre (optionnel, défaut: `master`)
## Logging ## Logging
- Les logs sont écrits dans `log/` à côté de l'exe (1 fichier par jour, format `YYYY-MM-DD.log`) - 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) - Les vieux logs sont nettoyés automatiquement (30 jours de rétention)
- Chaque action git, erreur, et résultat est loggé avec timestamp - Chaque action git, erreur, et résultat est loggé avec timestamp
- Bouton "Ouvrir les logs" dans la GUI pour accéder au dossier - Bouton "Logs" dans la GUI pour ouvrir le dossier
## Build ## Build
```bat ```bat
build.bat build.bat
``` ```
Requiert Python + pip. Installe PyInstaller automatiquement si absent. Requiert Go installé et dans le PATH. Installe `rsrc` automatiquement si absent.
Produit `dist/GitUpdateChecker.exe`. Copier `config.ini` à côté de l'exe. Produit `GitUpdateChecker.exe` à la racine (exe unique, pas de dépendances).
Flags de build : `-H windowsgui -s -w` (pas de console, symboles strippés).
## Contraintes techniques ## Contraintes techniques
- **Chemins relatifs** : Tout est relatif à l'exe, jamais de chemin absolu - **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 - **Clé USB** : Fonctionne sur n'importe quelle lettre de lecteur
- **Git requis** : Git doit être installé et dans le PATH de la machine - **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) - **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) - **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) - **Interface** : GUI native Windows via walk (pas de console)
- **Logs** : Dossier `log/` à côté de l'exe, rotation automatique 30 jours - **Logs** : Dossier `log/` à côté de l'exe, rotation automatique 30 jours
- **Repos imbriqués** : Supporte les dépôts git imbriqués (ex: parent/enfant) via clone in-place
## Conventions ## Conventions
- Langage : Python 3, pas de dépendances externes (seulement stdlib + tkinter) - Langage : Go 1.22+
- Interface : GUI tkinter en français - GUI : github.com/lxn/walk (contrôles natifs Windows)
- Langue : Français pour l'interface utilisateur - Interface en français
- Pas de console (flag `-H windowsgui`)

Binary file not shown.

32
app.manifest Normal file
View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity
version="1.0.0.0"
processorArchitecture="*"
name="GitUpdateChecker"
type="win32"/>
<description>Git Update Checker</description>
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>

View File

@@ -1,34 +1,38 @@
@echo off @echo off
echo ======================================== echo ========================================
echo Build Git Update Checker (.exe) echo Build Git Update Checker (.exe) - Go
echo ======================================== echo ========================================
echo. echo.
:: Vérifier que Python est installé where go >nul 2>&1
python --version >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
echo [ERREUR] Python n'est pas installe ou pas dans le PATH. echo [ERREUR] Go n'est pas installe ou pas dans le PATH.
pause pause
exit /b 1 exit /b 1
) )
:: Installer PyInstaller si nécessaire echo [*] Telechargement des dependances...
pip show pyinstaller >nul 2>&1 go mod tidy
if errorlevel 1 ( if errorlevel 1 (
echo [*] Installation de PyInstaller... echo [ERREUR] go mod tidy a echoue.
pip install pyinstaller pause
exit /b 1
) )
echo [*] Generation du manifeste Windows (rsrc.syso)...
where rsrc >nul 2>&1
if errorlevel 1 (
echo [*] Installation de rsrc...
go install github.com/akavel/rsrc@latest
)
rsrc -manifest app.manifest -ico icon.ico -o rsrc.syso
echo [*] Compilation en cours... echo [*] Compilation en cours...
echo. go build -ldflags "-H windowsgui -s -w" -o GitUpdateChecker.exe .
pyinstaller --onefile --noconsole --name "GitUpdateChecker" --icon=NONE git_updater.py
echo. echo.
if exist "dist\GitUpdateChecker.exe" ( if exist "GitUpdateChecker.exe" (
echo [OK] Executable cree : dist\GitUpdateChecker.exe echo [OK] GitUpdateChecker.exe cree - exe unique, aucune dependance.
echo.
echo N'oublie pas de copier config.ini a cote de l'exe !
) else ( ) else (
echo [ERREUR] La compilation a echoue. echo [ERREUR] La compilation a echoue.
) )

86
config.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"bufio"
"os"
"path/filepath"
"strings"
)
type RepoConfig struct {
Name string
URL string
Path string
Branch string // branche à suivre (défaut: "master")
}
type SelfUpdateConfig struct {
URL string
ExeName string
Branch string
}
func loadConfig() ([]RepoConfig, SelfUpdateConfig, error) {
cfgPath := filepath.Join(exeDir(), "config.ini")
f, err := os.Open(cfgPath)
if err != nil {
return nil, SelfUpdateConfig{}, err
}
defer f.Close()
var repos []RepoConfig
var su SelfUpdateConfig
section := ""
kv := map[string]string{}
flush := func() {
switch {
case strings.HasPrefix(section, "repo:"):
name := strings.TrimPrefix(section, "repo:")
if kv["url"] != "" && kv["path"] != "" {
branch := kv["branch"]
if branch == "" {
branch = "master"
}
repos = append(repos, RepoConfig{
Name: name,
URL: kv["url"],
Path: kv["path"],
Branch: branch,
})
}
case section == "self-update":
su.URL = strings.TrimRight(kv["url"], "/")
su.ExeName = kv["exe_name"]
if su.ExeName == "" {
su.ExeName = "GitUpdateChecker.exe"
}
su.Branch = kv["branch"]
if su.Branch == "" {
su.Branch = "master"
}
}
kv = map[string]string{}
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
flush()
section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1]))
continue
}
if idx := strings.IndexByte(line, '='); idx > 0 {
k := strings.TrimSpace(strings.ToLower(line[:idx]))
v := strings.TrimSpace(line[idx+1:])
kv[k] = v
}
}
flush()
return repos, su, scanner.Err()
}

View File

@@ -5,6 +5,7 @@
[self-update] [self-update]
url = http://192.168.1.235:3125/zogzog/Lanceur-geco url = http://192.168.1.235:3125/zogzog/Lanceur-geco
exe_name = GitUpdateChecker.exe exe_name = GitUpdateChecker.exe
branch = feature/go-rewrite
[repo:Scripts] [repo:Scripts]
url = http://192.168.1.235:3125/zogzog/Scripts url = http://192.168.1.235:3125/zogzog/Scripts

421
git.go Normal file
View File

@@ -0,0 +1,421 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
type RepoResult struct {
Name string
Path string
URL string
Branch string // branche configurée
Pending bool
UpToDate bool
Offline bool
NeedsClone bool
HasUpdate bool // MAJ disponible (hash local != distant)
Error string
LocalChanges int
UntrackedFiles int
UntrackedList []string // liste des fichiers non suivis
}
func runGit(args []string, cwd string, timeout time.Duration) (code int, stdout string, stderr string) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fullArgs := append([]string{"-c", "safe.directory=*"}, args...)
cmd := newGitCmd(ctx, fullArgs, cwd)
var outBuf, errBuf strings.Builder
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
stdout = strings.TrimSpace(outBuf.String())
stderr = strings.TrimSpace(errBuf.String())
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 1, stdout, "Timeout"
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), stdout, stderr
}
return -1, stdout, err.Error()
}
return 0, stdout, stderr
}
func absRepoPath(rel string) string {
if filepath.IsAbs(rel) {
return rel
}
return filepath.Join(exeDir(), rel)
}
func checkRemoteOffline(stderr string) bool {
for _, kw := range []string{"could not resolve", "connection refused", "unable to connect", "timed out", "the remote end hung up", "timeout"} {
if strings.Contains(strings.ToLower(stderr), kw) {
return true
}
}
return false
}
func checkRepo(cfg RepoConfig) RepoResult {
res := RepoResult{Name: cfg.Name, URL: cfg.URL, Branch: cfg.Branch}
local := absRepoPath(cfg.Path)
res.Path = local
if _, err := os.Stat(filepath.Join(local, ".git")); os.IsNotExist(err) {
// Vérifier que le dépôt distant existe avant de proposer le clone
code, _, stderr := runGit([]string{"ls-remote", "--exit-code", cfg.URL}, "", 15*time.Second)
if code != 0 {
if checkRemoteOffline(stderr) {
res.Offline = true
res.Error = "Hors ligne"
return res
}
if strings.Contains(strings.ToLower(stderr), "not found") || strings.Contains(strings.ToLower(stderr), "repository not found") {
res.Error = "Dépôt introuvable : " + cfg.URL
return res
}
res.Error = "Erreur remote : " + stderr
return res
}
res.NeedsClone = true
return res
}
runGit([]string{"remote", "set-url", "origin", cfg.URL}, local, 10*time.Second)
// Hash local
_, localHash, _ := runGit([]string{"rev-parse", "HEAD"}, local, 5*time.Second)
if localHash == "" {
res.Error = "Impossible de lire le commit local"
return res
}
// Vérification rapide du remote via ls-remote (timeout court)
branch := cfg.Branch
code, lsOut, stderr := runGit([]string{"ls-remote", "origin", "refs/heads/" + branch}, local, 15*time.Second)
if code != 0 {
if checkRemoteOffline(stderr) {
res.Offline = true
res.Error = "Hors ligne"
return res
}
res.Error = "ls-remote: " + stderr
return res
}
// Extraire le hash distant
remoteHash := ""
if lsOut != "" {
parts := strings.Fields(lsOut)
if len(parts) > 0 {
remoteHash = parts[0]
}
}
if remoteHash == "" {
res.Error = fmt.Sprintf("Branche '%s' introuvable sur le remote", branch)
return res
}
// Comparer les hashs
if localHash != remoteHash {
res.HasUpdate = true
}
// Modifications locales
_, status, _ := runGit([]string{"status", "--porcelain"}, local, 5*time.Second)
if status != "" {
for _, line := range strings.Split(strings.TrimSpace(status), "\n") {
if strings.HasPrefix(line, "?? ") {
res.UntrackedFiles++
res.UntrackedList = append(res.UntrackedList, strings.TrimPrefix(line, "?? "))
} else {
res.LocalChanges++
}
}
}
res.UpToDate = !res.HasUpdate && res.LocalChanges == 0 && res.UntrackedFiles == 0
return res
}
func doClone(cfg RepoConfig) error {
local := absRepoPath(cfg.Path)
if err := os.MkdirAll(filepath.Dir(local), 0755); err != nil {
return err
}
// Si le dossier n'existe pas ou est vide, clone classique
entries, _ := os.ReadDir(local)
if len(entries) == 0 {
code, _, stderr := runGit([]string{"clone", cfg.URL, local}, "", 300*time.Second)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
// Dossier non-vide sans .git : init + remote + fetch + checkout
return doCloneInPlace(cfg, local)
}
func doCloneInPlace(cfg RepoConfig, local string) error {
code, _, stderr := runGit([]string{"init"}, local, 30*time.Second)
if code != 0 {
return fmt.Errorf("git init: %s", stderr)
}
code, _, stderr = runGit([]string{"remote", "add", "origin", cfg.URL}, local, 10*time.Second)
if code != 0 {
// remote existe déjà, mettre à jour l'URL
runGit([]string{"remote", "set-url", "origin", cfg.URL}, local, 10*time.Second)
}
code, _, stderr = runGit([]string{"fetch", "origin"}, local, 5*time.Minute)
if code != 0 {
return fmt.Errorf("fetch: %s", stderr)
}
branch := cfg.Branch
code, _, stderr = runGit([]string{"checkout", "origin/" + branch, "-b", branch}, local, 30*time.Second)
if code != 0 {
// Branche locale existe déjà
code, _, stderr = runGit([]string{"checkout", branch}, local, 30*time.Second)
if code == 0 {
code, _, stderr = runGit([]string{"reset", "--hard", "origin/" + branch}, local, 30*time.Second)
}
}
if code != 0 {
return fmt.Errorf("checkout: %s", stderr)
}
return nil
}
func doPull(res RepoResult) error {
_, branch, _ := runGit([]string{"rev-parse", "--abbrev-ref", "HEAD"}, res.Path, 5*time.Second)
if branch == "" {
branch = "master"
}
code, _, stderr := runGit([]string{"pull", "origin", branch}, res.Path, 120*time.Second)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
func doCheckout(res RepoResult) error {
code, _, stderr := runGit([]string{"checkout", "--", "."}, res.Path, 30*time.Second)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
func doClean(res RepoResult) error {
code, _, stderr := runGit([]string{"clean", "-fd"}, res.Path, 60*time.Second)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
// ── Progression Git ───────────────────────────────────────────────────────────
// ProgressInfo contient l'état de progression d'une opération git.
type ProgressInfo struct {
Phase string // ex: "Receiving objects", "Resolving deltas"
Percent float64 // 0.0 à 1.0
Current int64
Total int64
Speed string // ex: "1.2 MiB/s"
}
// ProgressCallback est appelé à chaque mise à jour de la progression.
type ProgressCallback func(ProgressInfo)
// reGitProgress capture les lignes de progression git :
//
// "Receiving objects: 45% (123/456), 1.20 MiB | 500.00 KiB/s"
// "Resolving deltas: 100% (89/89), done."
var reGitProgress = regexp.MustCompile(
`(?i)([\w\s]+):\s+(\d+)%\s+\((\d+)/(\d+)\)(?:.*\|\s*(.+/s))?`,
)
// parseGitProgress analyse une ligne de sortie git et renvoie un ProgressInfo.
func parseGitProgress(line string) (ProgressInfo, bool) {
m := reGitProgress.FindStringSubmatch(line)
if m == nil {
return ProgressInfo{}, false
}
pct, _ := strconv.Atoi(m[2])
cur, _ := strconv.ParseInt(m[3], 10, 64)
tot, _ := strconv.ParseInt(m[4], 10, 64)
speed := strings.TrimSpace(m[5])
return ProgressInfo{
Phase: strings.TrimSpace(m[1]),
Percent: float64(pct) / 100.0,
Current: cur,
Total: tot,
Speed: speed,
}, true
}
// runGitWithProgress exécute une commande git et capture la progression en temps réel.
// Le timeout est désactivé (0) ou très long pour les gros dépôts.
func runGitWithProgress(args []string, cwd string, timeout time.Duration, cb ProgressCallback) (int, string, string) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fullArgs := append([]string{"-c", "safe.directory=*"}, args...)
cmd := newGitCmd(ctx, fullArgs, cwd)
var outBuf strings.Builder
cmd.Stdout = &outBuf
// Pipe stderr pour lire la progression en temps réel
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return -1, "", err.Error()
}
if err := cmd.Start(); err != nil {
return -1, "", err.Error()
}
// Lire stderr byte par byte pour détecter les \r (git écrase la ligne)
var stderrBuf strings.Builder
reader := bufio.NewReader(stderrPipe)
var lineBuf strings.Builder
for {
b, err := reader.ReadByte()
if err != nil {
if err != io.EOF {
stderrBuf.WriteString(err.Error())
}
break
}
stderrBuf.WriteByte(b)
if b == '\r' || b == '\n' {
line := lineBuf.String()
lineBuf.Reset()
if cb != nil && line != "" {
if info, ok := parseGitProgress(line); ok {
cb(info)
}
}
} else {
lineBuf.WriteByte(b)
}
}
// Dernière ligne sans \r\n
if lineBuf.Len() > 0 && cb != nil {
if info, ok := parseGitProgress(lineBuf.String()); ok {
cb(info)
}
}
err = cmd.Wait()
stdout := strings.TrimSpace(outBuf.String())
stderr := strings.TrimSpace(stderrBuf.String())
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return 1, stdout, "Timeout"
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode(), stdout, stderr
}
return -1, stdout, err.Error()
}
return 0, stdout, stderr
}
// doCloneWithProgress clone un dépôt avec suivi de progression.
func doCloneWithProgress(cfg RepoConfig, cb ProgressCallback) error {
local := absRepoPath(cfg.Path)
if err := os.MkdirAll(filepath.Dir(local), 0755); err != nil {
return err
}
// Si le dossier n'existe pas ou est vide, clone classique avec progression
entries, _ := os.ReadDir(local)
if len(entries) == 0 {
code, _, stderr := runGitWithProgress(
[]string{"clone", "--progress", cfg.URL, local},
"", 2*time.Hour, cb,
)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}
// Dossier non-vide sans .git : init + remote + fetch avec progression + checkout
code, _, stderr := runGit([]string{"init"}, local, 30*time.Second)
if code != 0 {
return fmt.Errorf("git init: %s", stderr)
}
code, _, stderr = runGit([]string{"remote", "add", "origin", cfg.URL}, local, 10*time.Second)
if code != 0 {
runGit([]string{"remote", "set-url", "origin", cfg.URL}, local, 10*time.Second)
}
code, _, stderr = runGitWithProgress(
[]string{"fetch", "--progress", "origin"},
local, 2*time.Hour, cb,
)
if code != 0 {
return fmt.Errorf("fetch: %s", stderr)
}
branch := cfg.Branch
code, _, stderr = runGit([]string{"checkout", "origin/" + branch, "-b", branch}, local, 30*time.Second)
if code != 0 {
code, _, stderr = runGit([]string{"checkout", branch}, local, 30*time.Second)
if code == 0 {
code, _, stderr = runGit([]string{"reset", "--hard", "origin/" + branch}, local, 30*time.Second)
}
}
if code != 0 {
return fmt.Errorf("checkout: %s", stderr)
}
return nil
}
// doPullWithProgress fait un pull avec suivi de progression.
func doPullWithProgress(res RepoResult, cb ProgressCallback) error {
_, branch, _ := runGit([]string{"rev-parse", "--abbrev-ref", "HEAD"}, res.Path, 5*time.Second)
if branch == "" {
branch = "master"
}
code, _, stderr := runGitWithProgress(
[]string{"pull", "--progress", "origin", branch},
res.Path, 2*time.Hour, cb,
)
if code != 0 {
return fmt.Errorf("%s", stderr)
}
return nil
}

View File

@@ -1,996 +0,0 @@
"""
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.
"""
VERSION = "0.5.6"
import subprocess
import sys
import os
import configparser
import logging
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import tkinter as tk
from tkinter import ttk, messagebox
from pathlib import Path
from datetime import datetime
import urllib.request
import urllib.error
# 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, timeout=30):
# -c safe.directory=* : évite l'erreur "dubious ownership" sur clé USB
cmd = ["git", "-c", "safe.directory=*"] + args
log.debug(f"git {' '.join(args)} (cwd={cwd})")
try:
result = subprocess.run(
cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout,
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 _version_tuple(v):
"""Convertit '0.4' en (0, 4) pour comparaison."""
try:
return tuple(int(x) for x in v.strip().split("."))
except (ValueError, AttributeError):
return (0,)
def _get_self_update_config():
"""Lit la config [self-update] depuis config.ini. Retourne (url, exe_name, branch) ou (None, None, None)."""
config_path = get_config_path()
if not config_path.exists():
return None, None, None
config = configparser.ConfigParser()
config.read(config_path, encoding="utf-8")
if not config.has_section("self-update"):
return None, None, None
url = config.get("self-update", "url", fallback="").strip().rstrip("/")
exe_name = config.get("self-update", "exe_name", fallback="GitUpdateChecker.exe").strip()
branch = config.get("self-update", "branch", fallback="master").strip()
if not url:
return None, None, None
return url, exe_name, branch
def check_self_update():
"""
Vérifie si une nouvelle version est disponible sur le serveur Gitea.
Télécharge version.txt via HTTP et compare avec VERSION locale.
Retourne (needs_update: bool, info: str).
"""
repo_url, _, branch = _get_self_update_config()
if not repo_url:
log.info("Auto-update: pas de section [self-update] dans config.ini, skip")
return False, ""
log.info("Auto-update: verification via HTTP...")
version_url = f"{repo_url}/raw/branch/{branch}/version.txt"
try:
req = urllib.request.Request(version_url, headers={"User-Agent": "GitUpdateChecker"})
with urllib.request.urlopen(req, timeout=10) as resp:
remote_version = resp.read().decode("utf-8").strip()
except (urllib.error.URLError, OSError) as e:
log.warning(f"Auto-update: impossible de verifier la version distante: {e}")
return False, f"Impossible de contacter le serveur: {e}"
log.info(f"Auto-update: version locale={VERSION} distante={remote_version}")
if _version_tuple(remote_version) <= _version_tuple(VERSION):
log.info("Auto-update: programme a jour")
return False, ""
info = f"Version actuelle : {VERSION}\nVersion disponible : {remote_version}"
log.info(f"Auto-update: MAJ disponible - {remote_version}")
return True, info
def do_self_update():
"""
Télécharge le nouvel exe depuis le serveur Gitea.
Stratégie : télécharger dans .new, renommer l'exe actuel en .old, placer le nouveau.
Retourne (ok, message).
"""
repo_url, exe_name, branch = _get_self_update_config()
if not repo_url:
return False, "Configuration [self-update] manquante"
is_frozen = getattr(sys, "frozen", False)
if not is_frozen:
log.warning("Auto-update: mode script, telechargement non supporte")
return False, "Auto-update uniquement supporte en mode .exe"
exe_path = Path(sys.executable)
exe_old_path = exe_path.with_suffix(".exe.old")
exe_new_path = exe_path.with_suffix(".exe.new")
# Telecharger le nouvel exe
download_url = f"{repo_url}/raw/branch/{branch}/{exe_name}"
log.info(f"Auto-update: telechargement de {download_url}")
try:
req = urllib.request.Request(download_url, headers={"User-Agent": "GitUpdateChecker"})
total_bytes = 0
with urllib.request.urlopen(req, timeout=60) as resp, open(exe_new_path, "wb") as f:
while True:
chunk = resp.read(65536) # 64 Ko par bloc
if not chunk:
break
f.write(chunk)
total_bytes += len(chunk)
if total_bytes < 1000:
log.error(f"Auto-update: fichier telecharge trop petit ({total_bytes} octets)")
exe_new_path.unlink(missing_ok=True)
return False, "Le fichier telecharge semble invalide"
log.info(f"Auto-update: telecharge {total_bytes} octets -> {exe_new_path.name}")
except (urllib.error.URLError, OSError) as e:
log.error(f"Auto-update: echec telechargement: {e}")
if exe_new_path.exists():
try:
exe_new_path.unlink()
except OSError:
pass
return False, f"Erreur telechargement: {e}"
# Renommer : exe actuel -> .old
try:
if exe_old_path.exists():
exe_old_path.unlink()
exe_path.rename(exe_old_path)
log.info(f"Auto-update: {exe_path.name} -> {exe_old_path.name}")
except OSError as e:
log.error(f"Auto-update: impossible de renommer l'exe: {e}")
if exe_new_path.exists():
try:
exe_new_path.unlink()
except OSError:
pass
return False, f"Impossible de renommer l'exe: {e}"
# Renommer : .new -> exe
try:
exe_new_path.rename(exe_path)
log.info(f"Auto-update: {exe_new_path.name} -> {exe_path.name}")
except OSError as e:
log.error(f"Auto-update: impossible de placer le nouvel exe: {e}")
try:
exe_old_path.rename(exe_path)
log.info("Auto-update: ancien exe restaure")
except OSError:
pass
return False, f"Impossible de placer le nouvel exe: {e}"
return True, "Mise a jour reussie !\nLe programme va redemarrer."
def relaunch_program():
"""Relance le programme (nouvel exe) et quitte le processus actuel."""
if getattr(sys, "frozen", False):
exe_path = str(Path(sys.executable))
log.info(f"Auto-update: relance de {exe_path}")
# Lancer un batch qui attend 1s puis lance le nouvel exe et supprime l'ancien .old
bat_path = str(get_exe_dir() / "_update.bat")
bat_content = (
f'@echo off\n'
f'timeout /t 1 /nobreak >nul\n'
f'start "" "{exe_path}"\n'
f'del "{exe_path}.old" 2>nul\n'
f'del "%~f0"\n'
)
with open(bat_path, "w", encoding="mbcs") as f:
f.write(bat_content)
subprocess.Popen(
["cmd", "/c", bat_path],
creationflags=subprocess.CREATE_NO_WINDOW,
)
else:
# Mode script : relancer python
log.info("Auto-update: relance du script")
subprocess.Popen([sys.executable, __file__])
# ── 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,
"offline": 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
# Mettre à jour l'URL origin si elle a changé dans config.ini
run_git(["remote", "set-url", "origin", url], cwd=local_path)
# Fetch (détecte aussi si le remote est inaccessible)
code, _, err = run_git(["fetch", "origin"], cwd=local_path)
if code != 0:
offline_keywords = ["could not resolve", "connection refused", "unable to connect", "timed out", "the remote end hung up"]
if any(kw in err.lower() for kw in offline_keywords):
log.warning(f"[{name}] Remote inaccessible: {url}")
result["error"] = "Depot hors ligne (remote inaccessible)"
result["offline"] = True
else:
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], timeout=300)
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, timeout=120)
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(f"Git Update Checker v{VERSION}")
self.geometry("820x600")
self.minsize(700, 450)
self.configure(bg="#1e1e2e")
self.repos_config = load_repos()
self.repo_results = []
log.info(f"=== Demarrage Git Update Checker v{VERSION} ===")
self._cleanup_old_exe()
self._build_ui()
self.after(100, self._check_self_update_then_repos)
def _cleanup_old_exe(self):
"""Supprime l'ancien exe .old restant d'une mise a jour precedente."""
if getattr(sys, "frozen", False):
old_path = Path(sys.executable).with_suffix(".exe.old")
if old_path.exists():
try:
old_path.unlink()
log.info(f"Nettoyage: {old_path.name} supprime")
except OSError:
log.warning(f"Nettoyage: impossible de supprimer {old_path.name}")
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=f"Git Update Checker v{VERSION}", 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 (mise a jour chaque seconde)
self.date_label = ttk.Label(self, text="", style="Status.TLabel")
self.date_label.pack(anchor="w", padx=15)
self._tick_clock()
# Panneau principal (PanedWindow vertical : cartes en haut, log en bas)
paned = ttk.PanedWindow(self, orient="vertical")
paned.pack(fill="both", expand=True, padx=15, pady=10)
# Zone scrollable pour les repos
container = ttk.Frame(paned)
paned.add(container, weight=3)
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 : cible le bon widget selon la position du curseur
self.bind_all("<MouseWheel>", self._on_mousewheel)
# Panneau de log en bas
log_frame = tk.Frame(paned, bg="#181825")
paned.add(log_frame, weight=1)
log_header = tk.Frame(log_frame, bg="#181825")
log_header.pack(fill="x", padx=8, pady=(6, 2))
tk.Label(log_header, text="Journal des operations", bg="#181825", fg="#a6adc8", font=("Segoe UI", 9, "bold")).pack(side="left")
tk.Button(log_header, text="Effacer", bg="#313244", fg="#cdd6f4", bd=0, font=("Segoe UI", 8),
command=self._clear_log_panel, activebackground="#45475a", activeforeground="#cdd6f4").pack(side="right")
self.log_text = tk.Text(log_frame, bg="#11111b", fg="#cdd6f4", font=("Consolas", 9),
height=8, bd=0, highlightthickness=0, wrap="word",
state="disabled", padx=8, pady=4)
log_scroll = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview)
self.log_text.configure(yscrollcommand=log_scroll.set)
self.log_text.pack(side="left", fill="both", expand=True, padx=(8, 0), pady=(0, 8))
log_scroll.pack(side="right", fill="y", padx=(0, 8), pady=(0, 8))
# Tags couleur pour le log GUI
self.log_text.tag_configure("info", foreground="#cdd6f4")
self.log_text.tag_configure("success", foreground="#a6e3a1")
self.log_text.tag_configure("warning", foreground="#f9e2af")
self.log_text.tag_configure("error", foreground="#f38ba8")
self.log_text.tag_configure("file_add", foreground="#a6e3a1")
self.log_text.tag_configure("file_mod", foreground="#f9e2af")
self.log_text.tag_configure("file_del", foreground="#f38ba8")
self.log_text.tag_configure("dim", foreground="#6c7086")
# 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 _tick_clock(self):
"""Met à jour le label de date chaque seconde."""
self.date_label.configure(text=datetime.now().strftime(" %d/%m/%Y %H:%M:%S"))
self.after(1000, self._tick_clock)
def _on_mousewheel(self, event):
"""Scroll le bon widget selon où se trouve la souris."""
w = event.widget
while w is not None:
if w is self.canvas or w is self.scroll_frame:
self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
return
if w is self.log_text:
self.log_text.yview_scroll(int(-1 * (event.delta / 120)), "units")
return
try:
w = w.master
except AttributeError:
break
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 _log_gui(self, message, tag="info"):
"""Ajoute une ligne dans le panneau de log."""
def _append():
self.log_text.configure(state="normal")
timestamp = datetime.now().strftime("%H:%M:%S")
self.log_text.insert("end", f"[{timestamp}] ", "dim")
self.log_text.insert("end", f"{message}\n", tag)
self.log_text.see("end")
self.log_text.configure(state="disabled")
# Appel thread-safe
if threading.current_thread() is threading.main_thread():
_append()
else:
self.after(0, _append)
def _clear_log_panel(self):
self.log_text.configure(state="normal")
self.log_text.delete("1.0", "end")
self.log_text.configure(state="disabled")
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, relance...")
relaunch_program()
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._log_gui("Verification des depots...", "info")
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 en parallèle (max 4 à la fois)."""
total = len(self.repos_config)
results = [None] * total
done = 0
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {}
for i, repo in enumerate(self.repos_config):
self._log_gui(f"[{repo['name']}] Verification...", "dim")
futures[executor.submit(check_repo, repo)] = i
for future in as_completed(futures):
idx = futures[future]
res = future.result()
results[idx] = res
done += 1
# Résumé concis selon le statut
if res.get("offline"):
self._log_gui(f"[{res['name']}] Hors ligne", "warning")
elif res.get("error"):
self._log_gui(f"[{res['name']}] Erreur : {res['error']}", "error")
elif res.get("needs_clone"):
self._log_gui(f"[{res['name']}] A cloner", "warning")
elif res["up_to_date"]:
self._log_gui(f"[{res['name']}] A jour", "success")
else:
count = len(res["commits"]) + len(res["local_changes"])
self._log_gui(f"[{res['name']}] {count} changement(s) disponible(s)", "warning")
self.after(0, lambda d=done, t=total: self.status_label.configure(text=f"Verification {d}/{t}..."))
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") and not res.get("offline") and not res.get("needs_clone"):
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._log_gui(f"Verification terminee : {up}/{total} depots a jour", "success" if up == total else "warning")
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("offline"):
tk.Label(top, text="HORS LIGNE", bg=bg_card, fg=yellow, font=("Segoe UI", 9, "bold")).pack(side="right")
elif 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, batch=False):
"""Met à jour un dépôt (pull + restore)."""
name = res["name"]
log.info(f"[{name}] MAJ unitaire demandee")
self._log_gui(f"[{name}] Mise a jour en cours...", "info")
if "_btn" in res:
res["_btn"].state(["disabled"])
def work():
messages = []
success = True
local_path = res["local_path"]
branch = res["branch"]
tag_map = {"A": "file_add", "M": "file_mod", "D": "file_del", "R": "file_mod"}
if res["commits"]:
log.info(f"[{name}] Pull de {len(res['commits'])} commit(s)...")
self._log_gui(f"[{name}] Telechargement de {len(res['commits'])} commit(s)...", "info")
ok, out, err = do_pull(local_path, branch)
if ok:
msg = f"Pull OK : {len(res['commits'])} commits telecharges."
log.info(f"[{name}] {msg}")
self._log_gui(f"[{name}] {msg}", "success")
# Logger chaque fichier distant
for f in res.get("files", []):
tag = tag_map.get(f["status_char"], "info")
self._log_gui(f" [{f['status']:>9}] {f['file']}", tag)
messages.append(msg)
else:
msg = f"Erreur pull : {err}"
log.error(f"[{name}] {msg}")
self._log_gui(f"[{name}] {msg}", "error")
messages.append(msg)
success = False
if res["local_changes"]:
log.info(f"[{name}] Restauration de {len(res['local_changes'])} fichier(s)...")
self._log_gui(f"[{name}] Restauration de {len(res['local_changes'])} fichier(s)...", "info")
ok, err = do_restore(local_path)
if ok:
msg = f"Restauration OK : {len(res['local_changes'])} fichiers restaures."
log.info(f"[{name}] {msg}")
self._log_gui(f"[{name}] {msg}", "success")
# Logger chaque fichier restauré
for f in res["local_changes"]:
tag = tag_map.get(f["status_char"], "info")
self._log_gui(f" [Restaure] {f['file']}", tag)
messages.append(msg)
else:
msg = f"Erreur restauration : {err}"
log.error(f"[{name}] {msg}")
self._log_gui(f"[{name}] {msg}", "error")
messages.append(msg)
success = False
status = "SUCCES" if success else "ECHEC"
log.info(f"[{name}] MAJ unitaire terminee - {status}")
self._log_gui(f"[{name}] Termine - {status}", "success" if success else "error")
self.after(0, lambda: self._show_update_result(res, messages, success, batch=batch))
threading.Thread(target=work, daemon=True).start()
def _do_clone(self, res):
"""Clone un dépôt."""
name = res["name"]
log.info(f"[{name}] Clonage demande - {res['url']}")
self._log_gui(f"[{name}] Clonage en cours depuis {res['url']}...", "info")
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 '{name}' clone avec succes !"
log.info(f"[{name}] {msg}")
self._log_gui(f"[{name}] {msg}", "success")
else:
msg = f"Erreur de clonage : {err}"
log.error(f"[{name}] {msg}")
self._log_gui(f"[{name}] {msg}", "error")
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, batch=False):
if batch:
# En mode batch : pas de messagebox individuelle, on ne rafraichit qu'une fois à la fin
self._batch_remaining -= 1
if self._batch_remaining == 0:
self._start_check()
else:
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")]
if not to_update:
return
log.info(f"MAJ globale demandee - {len(to_update)} depot(s) a mettre a jour")
self._log_gui(f"MAJ globale : {len(to_update)} depot(s) a mettre a jour", "warning")
# Compteur pour n'appeler _start_check qu'une seule fois quand tous sont termines
self._batch_remaining = len(to_update)
for res in to_update:
self._do_update(res, batch=True)
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()

14
go.mod Normal file
View File

@@ -0,0 +1,14 @@
module gitchecker
go 1.25.0
require (
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794
golang.org/x/image v0.38.0
golang.org/x/sys v0.18.0
)
require (
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
)

11
go.sum Normal file
View File

@@ -0,0 +1,11 @@
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=

710
gui.go Normal file
View File

@@ -0,0 +1,710 @@
package main
import (
"bytes"
_ "embed"
"fmt"
"image"
"image/png"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/lxn/walk"
. "github.com/lxn/walk/declarative"
xdraw "golang.org/x/image/draw"
)
//go:embed icon.png
var iconPNG []byte
// ── Modèle TableView ──────────────────────────────────────────────────────────
type RepoItem struct {
result RepoResult
progress float64 // 0.0 à 1.0
progressText string // ex: "Réception 45% (1.2 Go/2.5 Go)"
}
func (it *RepoItem) statusText() string {
r := it.result
if r.Pending {
return "Vérification..."
}
if r.Error != "" {
return r.Error
}
if r.NeedsClone {
return "À cloner"
}
if r.UpToDate {
return "À jour"
}
var parts []string
if r.HasUpdate {
parts = append(parts, "MAJ disponible")
}
if r.LocalChanges > 0 {
parts = append(parts, fmt.Sprintf("%d modif. locale(s)", r.LocalChanges))
}
if r.UntrackedFiles > 0 {
parts = append(parts, fmt.Sprintf("%d fichier(s) en trop", r.UntrackedFiles))
}
if len(parts) == 0 {
return "À jour"
}
return strings.Join(parts, ", ")
}
// progressBarText génère une barre de progression visuelle en Unicode.
// Ex: "████████░░░░ 67% Réception objets"
func progressBarText(pct float64, width int, label string) string {
if width <= 0 {
width = 20
}
filled := int(pct * float64(width))
if filled > width {
filled = width
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", width-filled)
pctInt := int(pct * 100)
if pctInt > 100 {
pctInt = 100
}
if label != "" {
return fmt.Sprintf("%s %3d%% %s", bar, pctInt, label)
}
return fmt.Sprintf("%s %3d%%", bar, pctInt)
}
func (it *RepoItem) textColor() walk.Color {
r := it.result
if r.Pending {
return walk.RGB(120, 120, 120)
}
if r.Error != "" {
return walk.RGB(200, 50, 50)
}
if r.NeedsClone {
return walk.RGB(180, 120, 0)
}
if r.UpToDate {
return walk.RGB(0, 150, 0)
}
return walk.RGB(180, 120, 0)
}
type RepoModel struct {
walk.TableModelBase
mu sync.RWMutex
items []*RepoItem
}
func newRepoModel(cfgs []RepoConfig) *RepoModel {
m := &RepoModel{}
m.items = make([]*RepoItem, len(cfgs))
for i, c := range cfgs {
m.items[i] = &RepoItem{result: RepoResult{Name: c.Name, Pending: true}}
}
return m
}
func (m *RepoModel) RowCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.items)
}
func (m *RepoModel) Value(row, col int) interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
if row >= len(m.items) {
return ""
}
it := m.items[row]
switch col {
case 0:
return it.result.Name
case 1:
return it.statusText()
case 2:
return it.progressText
}
return ""
}
func (m *RepoModel) StyleCell(style *walk.CellStyle) {
m.mu.RLock()
defer m.mu.RUnlock()
row := style.Row()
if row >= len(m.items) {
return
}
col := style.Col()
if col == 1 {
style.TextColor = m.items[row].textColor()
}
if col == 2 {
it := m.items[row]
if it.progress > 0 && it.progress < 1.0 {
style.TextColor = walk.RGB(0, 100, 180)
} else if it.progress >= 1.0 {
style.TextColor = walk.RGB(0, 150, 0)
}
}
}
func (m *RepoModel) setResult(row int, res RepoResult) {
m.mu.Lock()
if row < len(m.items) {
m.items[row].result = res
m.items[row].progress = 0
m.items[row].progressText = ""
}
m.mu.Unlock()
m.PublishRowChanged(row)
}
func (m *RepoModel) setProgress(row int, pct float64, text string) {
m.mu.Lock()
if row < len(m.items) {
m.items[row].progress = pct
m.items[row].progressText = text
}
m.mu.Unlock()
m.PublishRowChanged(row)
}
func (m *RepoModel) reset(cfgs []RepoConfig) {
m.mu.Lock()
m.items = make([]*RepoItem, len(cfgs))
for i, c := range cfgs {
m.items[i] = &RepoItem{result: RepoResult{Name: c.Name, Pending: true}}
}
m.mu.Unlock()
m.PublishRowsReset()
}
func (m *RepoModel) getResult(row int) (RepoResult, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
if row < 0 || row >= len(m.items) {
return RepoResult{}, false
}
return m.items[row].result, true
}
func (m *RepoModel) hasUpdates() bool {
m.mu.RLock()
defer m.mu.RUnlock()
for _, it := range m.items {
r := it.result
if !r.Pending && r.Error == "" && !r.Offline && (r.HasUpdate || r.LocalChanges > 0 || r.UntrackedFiles > 0 || r.NeedsClone) {
return true
}
}
return false
}
// ── Application ───────────────────────────────────────────────────────────────
type App struct {
mw *walk.MainWindow
iconView *walk.ImageView
statusLabel *walk.Label
tv *walk.TableView
model *RepoModel
logEdit *walk.TextEdit
btnRefresh *walk.PushButton
btnUpdateAll *walk.PushButton
btnAction *walk.PushButton
reposConfig []RepoConfig
suConfig SelfUpdateConfig
checking atomic.Bool
}
func runApp() error {
app := &App{}
var err error
app.reposConfig, app.suConfig, err = loadConfig()
if err != nil {
logWarn("Config: " + err.Error())
}
app.model = newRepoModel(app.reposConfig)
return app.buildAndRun()
}
func (a *App) buildAndRun() error {
if err := (MainWindow{
AssignTo: &a.mw,
Title: "Git Update Checker v" + VERSION,
MinSize: Size{Width: 750, Height: 400},
Size: Size{Width: 950, Height: 600},
Layout: VBox{Margins: Margins{Left: 10, Top: 10, Right: 10, Bottom: 10}},
Children: []Widget{
// En-tête
Composite{
Layout: HBox{MarginsZero: true},
Children: []Widget{
ImageView{
AssignTo: &a.iconView,
MinSize: Size{Width: 24, Height: 24},
MaxSize: Size{Width: 24, Height: 24},
Mode: ImageViewModeIdeal,
},
Label{
Text: "Git Update Checker v" + VERSION,
Font: Font{Bold: true, PointSize: 12},
},
HSpacer{},
Label{AssignTo: &a.statusLabel, Text: "Démarrage..."},
},
},
// Zone principale : table + log
VSplitter{
Children: []Widget{
TableView{
AssignTo: &a.tv,
AlternatingRowBG: true,
ColumnsOrderable: false,
Columns: []TableViewColumn{
{Title: "Dépôt", Width: 150},
{Title: "Statut", Width: 200},
{Title: "Progression", Width: 350},
},
Model: a.model,
OnCurrentIndexChanged: a.onSelectionChanged,
OnItemActivated: a.doAction,
},
Composite{
Layout: VBox{MarginsZero: true},
Children: []Widget{
Composite{
Layout: HBox{MarginsZero: true},
Children: []Widget{
Label{Text: "Journal", Font: Font{Bold: true}},
HSpacer{},
PushButton{
Text: "Effacer",
MaxSize: Size{Width: 65},
OnClicked: func() { a.logEdit.SetText("") },
},
},
},
TextEdit{
AssignTo: &a.logEdit,
ReadOnly: true,
VScroll: true,
Font: Font{Family: "Consolas", PointSize: 9},
},
},
},
},
},
// Boutons
Composite{
Layout: HBox{MarginsZero: true},
Children: []Widget{
PushButton{
AssignTo: &a.btnRefresh,
Text: "Rafraîchir",
OnClicked: a.startCheck,
},
PushButton{
AssignTo: &a.btnUpdateAll,
Text: "Tout mettre à jour",
Enabled: false,
OnClicked: a.updateAll,
},
PushButton{
AssignTo: &a.btnAction,
Text: "Mettre à jour",
Enabled: false,
OnClicked: a.doAction,
},
HSpacer{},
PushButton{Text: "config.ini", OnClicked: a.openConfig},
PushButton{Text: "Logs", OnClicked: a.openLogs},
},
},
},
}.Create()); err != nil {
return err
}
// Icône fenêtre (depuis fichier .ico externe)
if icoPath := filepath.Join(exeDir(), "icon.ico"); fileExists(icoPath) {
if icon, err := walk.NewIconFromFile(icoPath); err == nil {
a.mw.SetIcon(icon)
}
}
// Icône dans l'en-tête (depuis PNG embarqué dans l'exe, redimensionné 24x24)
if img, err := png.Decode(bytes.NewReader(iconPNG)); err != nil {
logWarn("Icône PNG: décodage échoué: " + err.Error())
} else {
const iconSize = 24
dst := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), xdraw.Over, nil)
bmp, err := walk.NewBitmapFromImageForDPI(dst, 96)
if err != nil {
logWarn("Icône PNG: bitmap échoué: " + err.Error())
} else {
a.iconView.SetImage(bmp)
}
}
// Lancer la vérification au démarrage
go func() {
time.Sleep(150 * time.Millisecond)
a.mw.Synchronize(a.checkSelfUpdateThenRepos)
}()
a.mw.Run()
return nil
}
// ── Vérifications ─────────────────────────────────────────────────────────────
func (a *App) checkSelfUpdateThenRepos() {
a.setStatus("Vérification auto-update...")
go func() {
needs, info, err := checkSelfUpdate(a.suConfig)
a.mw.Synchronize(func() {
if err != nil {
logWarn("Auto-update: " + err.Error())
}
if needs {
ans := walk.MsgBox(a.mw,
"Mise à jour disponible",
info+"\n\nTélécharger maintenant ?",
walk.MsgBoxYesNo|walk.MsgBoxIconQuestion,
)
if ans == walk.DlgCmdYes {
a.doSelfUpdate()
return
}
}
a.startCheck()
})
}()
}
func (a *App) startCheck() {
if !a.checking.CompareAndSwap(false, true) {
return
}
a.btnRefresh.SetEnabled(false)
a.btnUpdateAll.SetEnabled(false)
a.btnAction.SetEnabled(false)
a.model.reset(a.reposConfig)
a.setStatus(fmt.Sprintf("Vérification 0/%d...", len(a.reposConfig)))
logInfo("Vérification des dépôts...")
done := atomic.Int32{}
total := int32(len(a.reposConfig))
for i, cfg := range a.reposConfig {
i, cfg := i, cfg
go func() {
res := checkRepo(cfg)
a.mw.Synchronize(func() {
a.model.setResult(i, res)
a.appendLog(logLineForResult(res))
logInfo(fmt.Sprintf("[%s] %s", res.Name, logLineForResult(res)))
n := done.Add(1)
if int32(n) == total {
a.onCheckDone()
} else {
a.setStatus(fmt.Sprintf("Vérification %d/%d...", n, total))
}
})
}()
}
if total == 0 {
a.onCheckDone()
}
}
func (a *App) onCheckDone() {
a.checking.Store(false)
a.btnRefresh.SetEnabled(true)
a.btnUpdateAll.SetEnabled(a.model.hasUpdates())
a.setStatus(fmt.Sprintf("Dernière vérification : %s", time.Now().Format("15:04:05")))
}
func logLineForResult(r RepoResult) string {
if r.Error != "" {
return r.Error
}
if r.NeedsClone {
return "À cloner"
}
if r.UpToDate {
return "À jour"
}
var parts []string
if r.HasUpdate {
parts = append(parts, "MAJ disponible")
}
if r.LocalChanges > 0 {
parts = append(parts, fmt.Sprintf("%d modif. locale(s)", r.LocalChanges))
}
if r.UntrackedFiles > 0 {
parts = append(parts, fmt.Sprintf("%d fichier(s) en trop", r.UntrackedFiles))
}
return strings.Join(parts, ", ")
}
// recheckOne re-vérifie un seul dépôt sans toucher aux autres.
func (a *App) recheckOne(idx int) {
if idx < 0 || idx >= len(a.reposConfig) {
return
}
cfg := a.reposConfig[idx]
a.model.setResult(idx, RepoResult{Name: cfg.Name, Pending: true})
go func() {
res := checkRepo(cfg)
a.mw.Synchronize(func() {
a.model.setResult(idx, res)
a.btnUpdateAll.SetEnabled(a.model.hasUpdates())
a.onSelectionChanged()
})
}()
}
// proposeClean affiche un popup listant les fichiers non suivis et propose de les supprimer.
func (a *App) proposeClean(idx int, res RepoResult) {
// Construire la liste des fichiers (max 30 affichés)
list := ""
for i, f := range res.UntrackedList {
if i >= 30 {
list += fmt.Sprintf("\n... et %d autre(s)", len(res.UntrackedList)-30)
break
}
list += "\n - " + f
}
msg := fmt.Sprintf("[%s] %d fichier(s) non suivi(s) détecté(s) :%s\n\nSupprimer ces fichiers ?",
res.Name, res.UntrackedFiles, list)
ans := walk.MsgBox(a.mw, "Fichiers en trop", msg, walk.MsgBoxYesNo|walk.MsgBoxIconQuestion)
if ans == walk.DlgCmdYes {
a.appendLog(fmt.Sprintf("[%s] Nettoyage de %d fichier(s)...", res.Name, res.UntrackedFiles))
go func() {
err := doClean(res)
a.mw.Synchronize(func() {
if err != nil {
a.appendLog(fmt.Sprintf("[%s] Erreur nettoyage: %v", res.Name, err))
logError(fmt.Sprintf("[%s] clean: %v", res.Name, err))
} else {
a.appendLog(fmt.Sprintf("[%s] %d fichier(s) supprimé(s)", res.Name, res.UntrackedFiles))
logInfo(fmt.Sprintf("[%s] %d fichier(s) supprimé(s)", res.Name, res.UntrackedFiles))
}
a.recheckOne(idx)
})
}()
} else {
a.recheckOne(idx)
}
}
// ── Progression ───────────────────────────────────────────────────────────────
// makeProgressCB crée un callback de progression pour la ligne row du tableau.
// Le callback est appelé depuis un goroutine git et synchronise l'UI via mw.Synchronize.
func (a *App) makeProgressCB(row int) ProgressCallback {
// Limiter les mises à jour UI (max ~10/s) pour ne pas surcharger
var lastUpdate time.Time
return func(info ProgressInfo) {
now := time.Now()
if now.Sub(lastUpdate) < 100*time.Millisecond && info.Percent < 1.0 {
return
}
lastUpdate = now
label := info.Phase
if info.Speed != "" {
label += " " + info.Speed
}
text := progressBarText(info.Percent, 20, label)
a.mw.Synchronize(func() {
a.model.setProgress(row, info.Percent, text)
})
}
}
// ── Actions dépôt ─────────────────────────────────────────────────────────────
func (a *App) onSelectionChanged() {
idx := a.tv.CurrentIndex()
res, ok := a.model.getResult(idx)
if !ok || res.Pending || res.Offline || res.Error != "" {
a.btnAction.SetEnabled(false)
return
}
if res.NeedsClone {
a.btnAction.SetText("Cloner")
a.btnAction.SetEnabled(true)
} else if res.HasUpdate || res.LocalChanges > 0 || res.UntrackedFiles > 0 {
a.btnAction.SetText("Mettre à jour")
a.btnAction.SetEnabled(true)
} else {
a.btnAction.SetEnabled(false)
}
}
func (a *App) doAction() {
idx := a.tv.CurrentIndex()
res, ok := a.model.getResult(idx)
if !ok {
return
}
cfg := a.reposConfig[idx]
// Si uniquement des fichiers en trop, proposer directement le nettoyage
if res.UntrackedFiles > 0 && !res.HasUpdate && res.LocalChanges == 0 && !res.NeedsClone {
a.proposeClean(idx, res)
return
}
a.btnAction.SetEnabled(false)
a.appendLog(fmt.Sprintf("[%s] Mise à jour en cours...", res.Name))
cb := a.makeProgressCB(idx)
go func() {
var err error
if res.NeedsClone {
err = doCloneWithProgress(cfg, cb)
} else {
if res.LocalChanges > 0 {
err = doCheckout(res)
}
if err == nil && res.HasUpdate {
err = doPullWithProgress(res, cb)
}
}
a.mw.Synchronize(func() {
if err != nil {
a.model.setProgress(idx, 0, "Erreur")
a.appendLog(fmt.Sprintf("[%s] Erreur: %v", res.Name, err))
logError(fmt.Sprintf("[%s] %v", res.Name, err))
} else {
a.model.setProgress(idx, 1.0, progressBarText(1.0, 20, "Terminé"))
a.appendLog(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
logInfo(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
}
// Si des fichiers en trop après la mise à jour, proposer le nettoyage
if res.UntrackedFiles > 0 {
a.proposeClean(idx, res)
return
}
// Re-vérifier uniquement ce dépôt, pas tous
a.recheckOne(idx)
})
}()
}
func (a *App) updateAll() {
a.btnUpdateAll.SetEnabled(false)
a.btnRefresh.SetEnabled(false)
pending := atomic.Int32{}
for i, cfg := range a.reposConfig {
res, ok := a.model.getResult(i)
if !ok || res.Pending || res.UpToDate || res.Offline || res.Error != "" {
continue
}
pending.Add(1)
i, cfg, res := i, cfg, res
cb := a.makeProgressCB(i)
go func() {
var err error
if res.NeedsClone {
err = doCloneWithProgress(cfg, cb)
} else {
if res.LocalChanges > 0 {
err = doCheckout(res)
}
if err == nil && res.HasUpdate {
err = doPullWithProgress(res, cb)
}
if err == nil && res.UntrackedFiles > 0 {
err = doClean(res)
}
}
a.mw.Synchronize(func() {
if err != nil {
a.model.setProgress(i, 0, "Erreur")
logError(fmt.Sprintf("[%s] %v", res.Name, err))
a.appendLog(fmt.Sprintf("[%s] Erreur: %v", res.Name, err))
} else {
a.model.setProgress(i, 1.0, progressBarText(1.0, 20, "Terminé"))
logInfo(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
a.appendLog(fmt.Sprintf("[%s] Mise à jour OK", res.Name))
}
if pending.Add(-1) == 0 {
a.startCheck()
}
})
}()
}
if pending.Load() == 0 {
a.startCheck()
}
}
// ── Auto-update programme ─────────────────────────────────────────────────────
func (a *App) doSelfUpdate() {
a.setStatus("Téléchargement de la mise à jour...")
go func() {
err := doSelfUpdate(a.suConfig)
a.mw.Synchronize(func() {
if err != nil {
walk.MsgBox(a.mw, "Erreur", "Mise à jour échouée :\n"+err.Error(), walk.MsgBoxIconError)
logError("Auto-update: " + err.Error())
a.startCheck()
return
}
logInfo("Auto-update: mise à jour appliquée, redémarrage...")
walk.MsgBox(a.mw, "Mise à jour", "Mise à jour installée.\nLe programme va redémarrer.", walk.MsgBoxIconInformation)
exePath, _ := os.Executable()
relaunchAfterUpdate(exePath)
a.mw.Close()
})
}()
}
// ── Utilitaires GUI ───────────────────────────────────────────────────────────
func (a *App) setStatus(text string) {
a.statusLabel.SetText(text)
}
func (a *App) appendLog(line string) {
ts := time.Now().Format("15:04:05")
current := a.logEdit.Text()
if current != "" {
current += "\r\n"
}
a.logEdit.SetText(current + "[" + ts + "] " + line)
// Scroller en bas
a.logEdit.SendMessage(0x0115 /*WM_VSCROLL*/, 7 /*SB_BOTTOM*/, 0)
}
func (a *App) openConfig() {
p := filepath.Join(exeDir(), "config.ini")
exec.Command("notepad", p).Start()
}
func (a *App) openLogs() {
p := filepath.Join(exeDir(), "log")
exec.Command("explorer", p).Start()
}

BIN
icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 944 KiB

After

Width:  |  Height:  |  Size: 793 KiB

44
logger.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"fmt"
"os"
"path/filepath"
"time"
)
var logFile *os.File
func initLogger() {
dir := filepath.Join(exeDir(), "log")
os.MkdirAll(dir, 0755)
// Nettoyage logs > 30 jours
entries, _ := os.ReadDir(dir)
cutoff := time.Now().AddDate(0, 0, -30)
for _, e := range entries {
if !e.IsDir() {
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(dir, e.Name()))
}
}
}
logPath := filepath.Join(dir, time.Now().Format("2006-01-02")+".log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
}
logFile = f
}
func logMsg(level, msg string) {
line := fmt.Sprintf("[%s] %-5s %s\n", time.Now().Format("15:04:05"), level, msg)
if logFile != nil {
logFile.WriteString(line)
}
}
func logInfo(msg string) { logMsg("INFO", msg) }
func logWarn(msg string) { logMsg("WARN", msg) }
func logError(msg string) { logMsg("ERROR", msg) }

36
main.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/lxn/walk"
)
const VERSION = "0.7.7"
func exeDir() string {
exe, err := os.Executable()
if err != nil {
return "."
}
return filepath.Dir(exe)
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func main() {
runtime.LockOSThread()
initLogger()
logInfo(fmt.Sprintf("=== Demarrage Git Update Checker v%s ===", VERSION))
if err := runApp(); err != nil {
walk.MsgBox(nil, "Erreur fatale", err.Error(), walk.MsgBoxIconError)
os.Exit(1)
}
}

40
platform_windows.go Normal file
View File

@@ -0,0 +1,40 @@
//go:build windows
package main
import (
"context"
"fmt"
"os"
"os/exec"
"golang.org/x/sys/windows"
)
const createNoWindow = 0x08000000
// newGitCmd crée une commande git sans fenêtre console.
func newGitCmd(ctx context.Context, args []string, cwd string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", args...)
if cwd != "" {
cmd.Dir = cwd
}
cmd.SysProcAttr = &windows.SysProcAttr{
CreationFlags: createNoWindow,
}
return cmd
}
// relaunchAfterUpdate crée un batch qui attend 1s, relance l'exe et nettoie le .old.
func relaunchAfterUpdate(exePath string) {
oldPath := exePath + ".old"
batPath := exePath + "_update.bat"
content := fmt.Sprintf(
"@echo off\r\ntimeout /t 1 /nobreak >nul\r\nstart \"\" \"%s\"\r\ndel \"%s\" 2>nul\r\ndel \"%%~f0\"\r\n",
exePath, oldPath,
)
os.WriteFile(batPath, []byte(content), 0644)
cmd := exec.Command("cmd", "/c", batPath)
cmd.SysProcAttr = &windows.SysProcAttr{CreationFlags: createNoWindow}
cmd.Start()
}

95
selfupdate.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
func parseVersion(v string) [3]int {
var t [3]int
for i, p := range strings.SplitN(strings.TrimSpace(v), ".", 3) {
fmt.Sscanf(p, "%d", &t[i])
}
return t
}
func versionGreater(remote, local string) bool {
r, l := parseVersion(remote), parseVersion(local)
for i := 0; i < 3; i++ {
if r[i] > l[i] {
return true
}
if r[i] < l[i] {
return false
}
}
return false
}
func checkSelfUpdate(cfg SelfUpdateConfig) (needsUpdate bool, info string, err error) {
if cfg.URL == "" {
return false, "", nil
}
url := fmt.Sprintf("%s/raw/branch/%s/version.txt", cfg.URL, cfg.Branch)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return false, "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
remote := strings.TrimSpace(string(data))
if !versionGreater(remote, VERSION) {
return false, "", nil
}
return true, fmt.Sprintf("Version actuelle : %s\nVersion disponible : %s", VERSION, remote), nil
}
func doSelfUpdate(cfg SelfUpdateConfig) error {
exePath, err := os.Executable()
if err != nil {
return err
}
newPath := exePath + ".new"
oldPath := exePath + ".old"
url := fmt.Sprintf("%s/raw/branch/%s/%s", cfg.URL, cfg.Branch, cfg.ExeName)
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
f, err := os.Create(newPath)
if err != nil {
return err
}
n, copyErr := io.Copy(f, resp.Body)
f.Close()
if copyErr != nil {
os.Remove(newPath)
return copyErr
}
if n < 1000 {
os.Remove(newPath)
return fmt.Errorf("fichier telecharge invalide (%d octets)", n)
}
// Supprimer le Mark of the Web (Zone.Identifier)
os.Remove(newPath + ":Zone.Identifier")
if err := os.Rename(exePath, oldPath); err != nil {
os.Remove(newPath)
return err
}
if err := os.Rename(newPath, exePath); err != nil {
os.Rename(oldPath, exePath) // restaurer
return err
}
return nil
}

View File

@@ -1 +1 @@
0.5.6 0.7.7