Compare commits

...

29 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
20 changed files with 1590 additions and 1088 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

@@ -10,7 +10,11 @@
"Bash(cmd /c build.bat)",
"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 --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/
dist/
build/
__pycache__/
rsrc.syso
*.spec
*.exe.old
_update.bat

107
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.
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
```
Lanceur-geco/
├── git_updater.py # Script principal Python (GUI tkinter)
├── version.txt # Fichier contenant le numéro de version (ex: "0.5.1")
├── config.ini # Configuration multi-repo
├── build.bat # Script de compilation en .exe via PyInstaller
├── log/ # Dossier de logs (créé automatiquement, 1 fichier par jour)
├── main.go # Point d'entrée, constante VERSION
├── 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
├── build.bat # Script de compilation en .exe
├── 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
```
## Règles importantes
- **Tous les chemins doivent être relatifs** à l'emplacement de l'exe. Jamais de chemin absolu (pas de `C:\`, `G:\`, etc.). Utiliser `..` et des chemins relatifs pour référencer les dossiers.
- **Accès lecture seule** : le programme ne fait que `git fetch`, `git pull` et `git checkout`. Jamais de `git push`, `git commit`, `git add`, ou toute opération d'écriture vers le remote.
- **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`.
## 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)
- Format : **semver simplifié** `MAJEUR.MINEUR.PATCH` (ex: `0.5.1`)
- **Les deux doivent toujours être synchronisés** : quand on change la version, mettre à jour `VERSION` dans `git_updater.py` ET `version.txt`
- 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 `main.go` ET `version.txt`
### Mise à jour de la version
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
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 :
```
@@ -50,18 +66,18 @@ Changements :
Exemple :
```
v0.5.2 - Detection depot hors ligne
v0.7.6 - Clone dossier non-vide et verification rapide
Changements :
- Ajout verification connectivite remote avant fetch (git ls-remote)
- Affichage "HORS LIGNE" si le serveur est inaccessible
- Synchronisation auto de l'URL origin depuis config.ini
- Clone dans dossier non-vide (git init + remote add + fetch + checkout)
- Verification rapide via git ls-remote au lieu de git fetch
- Support branche par repo dans config.ini
```
### 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
- 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
- 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/{branch}/version.txt`) et compare avec la `VERSION` locale
- Si la version distante est supérieure, une mise à jour est proposée
## Fonctionnement
@@ -71,64 +87,77 @@ Changements :
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
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
1. Lit la liste des dépôts depuis `config.ini` (chemins relatifs à l'exe)
2. Pour chaque dépôt :
- `git fetch` pour récupérer l'état distant
- Compare commits locaux vs distants
- Détecte les fichiers supprimés/modifiés localement
3. Affiche le résultat dans une interface graphique (tkinter)
2. Pour chaque dépôt (en parallèle via goroutines) :
- `git ls-remote` pour vérifier la disponibilité et comparer les hashs (rapide, timeout 15s)
- `git status --porcelain` pour détecter les fichiers modifiés/non suivis localement
3. Affiche le résultat dans une interface graphique (walk/TableView)
4. Propose pour chaque dépôt :
- `git pull` si nouveaux commits distants
- `git checkout -- .` si fichiers locaux manquants/modifiés
- `git pull` si MAJ disponible (hash distant différent)
- `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)
Supporte plusieurs sections `[repo:NomDuRepo]` :
```ini
[repo:Batch]
url = http://192.168.1.235:3125/zogzog/Batch
path = ../Batch
[self-update]
url = http://192.168.1.235:3125/zogzog/Lanceur-geco
exe_name = GitUpdateChecker.exe
branch = master
[repo:Powershell]
url = http://192.168.1.235:3125/zogzog/Powershell
path = ../Powershell
[repo:Scripts]
url = http://192.168.1.235:3125/zogzog/Scripts
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
- `path` : Chemin **relatif** vers le dossier local du dépôt (relatif à l'exe)
- `branch` : Branche à suivre (optionnel, défaut: `master`)
## Logging
- Les logs sont écrits dans `log/` à côté de l'exe (1 fichier par jour, format `YYYY-MM-DD.log`)
- Les vieux logs sont nettoyés automatiquement (30 jours de rétention)
- Chaque action git, erreur, et résultat est loggé avec timestamp
- Bouton "Ouvrir les logs" dans la GUI pour accéder au dossier
- Bouton "Logs" dans la GUI pour ouvrir le dossier
## Build
```bat
build.bat
```
Requiert Python + pip. Installe PyInstaller automatiquement si absent.
Produit `dist/GitUpdateChecker.exe`. Copier `config.ini` à côté de l'exe.
Requiert Go installé et dans le PATH. Installe `rsrc` automatiquement si absent.
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
- **Chemins relatifs** : Tout est relatif à l'exe, jamais de chemin absolu
- **Encodage** : Force UTF-8 pour les caractères Unicode
- **Clé USB** : Fonctionne sur n'importe quelle lettre de lecteur
- **Git requis** : Git doit être installé et dans le PATH de la machine
- **Serveur Gitea** : Le remote origin pointe vers une instance Gitea locale (192.168.1.235:3125)
- **Lecture seule** : Aucune opération d'écriture vers le remote (pas de push/commit)
- **Interface** : GUI tkinter (inclus dans Python, pas de dépendance externe)
- **Interface** : GUI native Windows via walk (pas de console)
- **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
- Langage : Python 3, pas de dépendances externes (seulement stdlib + tkinter)
- Interface : GUI tkinter en français
- Langue : Français pour l'interface utilisateur
- Langage : Go 1.22+
- GUI : github.com/lxn/walk (contrôles natifs Windows)
- 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,37 +1,38 @@
@echo off
echo ========================================
echo Build Git Update Checker (.exe)
echo Build Git Update Checker (.exe) - Go
echo ========================================
echo.
:: Vérifier que Python est installé
python --version >nul 2>&1
where go >nul 2>&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
exit /b 1
)
:: Installer PyInstaller si nécessaire
pip show pyinstaller >nul 2>&1
echo [*] Telechargement des dependances...
go mod tidy
if errorlevel 1 (
echo [*] Installation de PyInstaller...
pip install pyinstaller
echo [ERREUR] go mod tidy a echoue.
pause
exit /b 1
)
echo [*] Conversion icon.png -> icon.ico...
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)])"
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.
pyinstaller --onefile --noconsole --name "GitUpdateChecker" --icon=icon.ico git_updater.py
go build -ldflags "-H windowsgui -s -w" -o GitUpdateChecker.exe .
echo.
if exist "dist\GitUpdateChecker.exe" (
echo [OK] Executable cree : dist\GitUpdateChecker.exe
echo.
echo N'oublie pas de copier config.ini a cote de l'exe !
if exist "GitUpdateChecker.exe" (
echo [OK] GitUpdateChecker.exe cree - exe unique, aucune dependance.
) else (
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]
url = http://192.168.1.235:3125/zogzog/Lanceur-geco
exe_name = GitUpdateChecker.exe
branch = feature/go-rewrite
[repo: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
}

File diff suppressed because it is too large Load Diff

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.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

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.9
0.7.7