Compare commits
37 Commits
83b437cb29
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ba377a4e4a | |||
| 19efbe6dd7 | |||
| 9bf66f6d90 | |||
| 3f0f13147b | |||
| 2b2eb87f45 | |||
| 3ffbb550ec | |||
| 98b5187bfc | |||
| 18fe9d7186 | |||
| d8f3a29f8e | |||
| 55663e3a19 | |||
| db57cfacaf | |||
| 037f211d9d | |||
| da8fc74a68 | |||
| 30ece54758 | |||
| d03ff595ed | |||
| 8c5257e96f | |||
| af439a8e69 | |||
| 50c8ad9823 | |||
| 959298fc2d | |||
| fa17301842 | |||
| 8718b04a7d | |||
| 1a51d6b5fa | |||
| 997d82bb96 | |||
| 7d77f4d901 | |||
| 3c9a6e70eb | |||
| 62d6c7f89f | |||
| 415a09a12d | |||
| b460502626 | |||
| ead08293fa | |||
| 6954d7cc06 | |||
| a2cefb2c4c | |||
| dc3ac499d5 | |||
| 345171f035 | |||
| 6863fbad98 | |||
| 5f2cd13072 | |||
| 97db6a8033 | |||
| ef3ce2b12b |
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(pyinstaller --onedir --noconsole --name \"GitUpdateChecker\" --icon=icon.ico -y git_updater.py)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,14 @@
|
|||||||
"Bash(pyinstaller --onefile --windowed --name \"GitUpdateChecker\" git_updater.py)",
|
"Bash(pyinstaller --onefile --windowed --name \"GitUpdateChecker\" git_updater.py)",
|
||||||
"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(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
2
.gitignore
vendored
@@ -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
105
CLAUDE.md
@@ -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
32
app.manifest
Normal 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>
|
||||||
34
build.bat
34
build.bat
@@ -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 --console --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.
|
||||||
)
|
)
|
||||||
|
|||||||
82
config.go
Normal file
82
config.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
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"] != "" {
|
||||||
|
repos = append(repos, RepoConfig{
|
||||||
|
Name: name,
|
||||||
|
URL: kv["url"],
|
||||||
|
Path: kv["path"],
|
||||||
|
Branch: kv["branch"], // vide = auto-détection
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
473
git.go
Normal file
473
git.go
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectDefaultBranch détecte la branche par défaut d'un remote via ls-remote --symref HEAD.
|
||||||
|
// Retourne "main", "master", etc. ou "" si indétectable.
|
||||||
|
func detectDefaultBranch(urlOrRemote string, cwd string) string {
|
||||||
|
_, out, _ := runGit([]string{"ls-remote", "--symref", urlOrRemote, "HEAD"}, cwd, 15*time.Second)
|
||||||
|
// Format attendu : "ref: refs/heads/main\tHEAD"
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if strings.HasPrefix(line, "ref: refs/heads/") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return strings.TrimPrefix(parts[0], "ref: refs/heads/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkRepo(cfg RepoConfig) RepoResult {
|
||||||
|
res := RepoResult{Name: cfg.Name, URL: cfg.URL, Branch: cfg.Branch}
|
||||||
|
local := absRepoPath(cfg.Path)
|
||||||
|
res.Path = local
|
||||||
|
|
||||||
|
// Détecter la branche si non spécifiée dans la config
|
||||||
|
branch := cfg.Branch
|
||||||
|
if branch == "" {
|
||||||
|
detected := detectDefaultBranch(cfg.URL, "")
|
||||||
|
if detected != "" {
|
||||||
|
branch = detected
|
||||||
|
} else {
|
||||||
|
branch = "master"
|
||||||
|
}
|
||||||
|
res.Branch = branch
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Si la branche n'existe pas sur le remote, détecter la branche par défaut
|
||||||
|
if remoteHash == "" {
|
||||||
|
detected := detectDefaultBranch("origin", local)
|
||||||
|
if detected == "" || detected == branch {
|
||||||
|
res.Error = fmt.Sprintf("Branche '%s' introuvable sur le remote", branch)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
logInfo(fmt.Sprintf("[%s] Branche '%s' introuvable, utilisation de '%s'", cfg.Name, branch, detected))
|
||||||
|
branch = detected
|
||||||
|
res.Branch = detected
|
||||||
|
_, lsOut, _ = runGit([]string{"ls-remote", "origin", "refs/heads/" + branch}, local, 15*time.Second)
|
||||||
|
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(parent context.Context, args []string, cwd string, timeout time.Duration, cb ProgressCallback) (int, string, string) {
|
||||||
|
ctx, cancel := context.WithTimeout(parent, 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.Canceled {
|
||||||
|
return 1, stdout, "Annulé"
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
// branch est la branche à checkout (détectée ou configurée).
|
||||||
|
func doCloneWithProgress(ctx context.Context, cfg RepoConfig, branch string, 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 {
|
||||||
|
args := []string{"clone", "--progress"}
|
||||||
|
if branch != "" {
|
||||||
|
args = append(args, "-b", branch)
|
||||||
|
}
|
||||||
|
args = append(args, cfg.URL, local)
|
||||||
|
code, _, stderr := runGitWithProgress(
|
||||||
|
ctx, args, "", 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(
|
||||||
|
ctx, []string{"fetch", "--progress", "origin"},
|
||||||
|
local, 2*time.Hour, cb,
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
return fmt.Errorf("fetch: %s", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(ctx context.Context, 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(
|
||||||
|
ctx, []string{"pull", "--progress", "origin", branch},
|
||||||
|
res.Path, 2*time.Hour, cb,
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
return fmt.Errorf("%s", stderr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
996
git_updater.py
996
git_updater.py
@@ -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
14
go.mod
Normal 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
11
go.sum
Normal 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=
|
||||||
751
gui.go
Normal file
751
gui.go
Normal file
@@ -0,0 +1,751 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
_ "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
|
||||||
|
btnStop *walk.PushButton
|
||||||
|
|
||||||
|
reposConfig []RepoConfig
|
||||||
|
suConfig SelfUpdateConfig
|
||||||
|
checking atomic.Bool
|
||||||
|
cancelMu sync.Mutex
|
||||||
|
cancelFunc context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
PushButton{
|
||||||
|
AssignTo: &a.btnStop,
|
||||||
|
Text: "Arrêter",
|
||||||
|
Enabled: false,
|
||||||
|
OnClicked: a.stopOperations,
|
||||||
|
},
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Annulation ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// newCancelCtx crée un nouveau contexte annulable et stocke la fonction cancel.
|
||||||
|
func (a *App) newCancelCtx() context.Context {
|
||||||
|
a.cancelMu.Lock()
|
||||||
|
defer a.cancelMu.Unlock()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
a.cancelFunc = cancel
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopOperations annule toutes les opérations git en cours.
|
||||||
|
func (a *App) stopOperations() {
|
||||||
|
a.cancelMu.Lock()
|
||||||
|
fn := a.cancelFunc
|
||||||
|
a.cancelFunc = nil
|
||||||
|
a.cancelMu.Unlock()
|
||||||
|
if fn != nil {
|
||||||
|
fn()
|
||||||
|
a.appendLog("Opérations annulées par l'utilisateur")
|
||||||
|
logInfo("Opérations annulées par l'utilisateur")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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.btnStop.SetEnabled(true)
|
||||||
|
a.appendLog(fmt.Sprintf("[%s] Mise à jour en cours...", res.Name))
|
||||||
|
|
||||||
|
ctx := a.newCancelCtx()
|
||||||
|
cb := a.makeProgressCB(idx)
|
||||||
|
go func() {
|
||||||
|
var err error
|
||||||
|
if res.NeedsClone {
|
||||||
|
err = doCloneWithProgress(ctx, cfg, res.Branch, cb)
|
||||||
|
} else {
|
||||||
|
if res.LocalChanges > 0 {
|
||||||
|
err = doCheckout(res)
|
||||||
|
}
|
||||||
|
if err == nil && res.HasUpdate {
|
||||||
|
err = doPullWithProgress(ctx, res, cb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.mw.Synchronize(func() {
|
||||||
|
a.btnStop.SetEnabled(false)
|
||||||
|
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)
|
||||||
|
a.btnStop.SetEnabled(true)
|
||||||
|
pending := atomic.Int32{}
|
||||||
|
ctx := a.newCancelCtx()
|
||||||
|
|
||||||
|
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(ctx, cfg, res.Branch, cb)
|
||||||
|
} else {
|
||||||
|
if res.LocalChanges > 0 {
|
||||||
|
err = doCheckout(res)
|
||||||
|
}
|
||||||
|
if err == nil && res.HasUpdate {
|
||||||
|
err = doPullWithProgress(ctx, 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.btnStop.SetEnabled(false)
|
||||||
|
a.startCheck()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if pending.Load() == 0 {
|
||||||
|
a.btnStop.SetEnabled(false)
|
||||||
|
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()
|
||||||
|
}
|
||||||
44
logger.go
Normal file
44
logger.go
Normal 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
36
main.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/lxn/walk"
|
||||||
|
)
|
||||||
|
|
||||||
|
const VERSION = "0.7.9"
|
||||||
|
|
||||||
|
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
40
platform_windows.go
Normal 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
95
selfupdate.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
0.5.6
|
0.7.9
|
||||||
|
|||||||
Reference in New Issue
Block a user