Compare commits
55 Commits
ba1cf1ea25
...
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 | |||
| 83b437cb29 | |||
| 0e92b76687 | |||
| 6438605d7c | |||
| b5068b3a97 | |||
| 6d29250fc4 | |||
| ad1ec0c024 | |||
| ed7885fa29 | |||
| 14898275b4 | |||
| c7779b7ce7 | |||
| b8776b6594 | |||
| 94af36fccf | |||
| 85a2217615 | |||
| 8b5b92bb4f | |||
| 4cf30c6110 | |||
| 056ab94a10 | |||
| e0e70a41b8 | |||
| db69b77739 | |||
| fe56e563f3 |
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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,17 @@
|
||||
"Bash(python git_updater.py)",
|
||||
"Bash(pip show:*)",
|
||||
"Bash(pip install:*)",
|
||||
"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(python -m PyInstaller --onefile --console --name \"GitUpdateChecker\" --icon=NONE git_updater.py)",
|
||||
"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/
|
||||
dist/
|
||||
build/
|
||||
__pycache__/
|
||||
rsrc.syso
|
||||
*.spec
|
||||
*.exe.old
|
||||
_update.bat
|
||||
|
||||
129
CLAUDE.md
129
CLAUDE.md
@@ -6,87 +6,158 @@ Outil Windows (.exe) avec interface graphique qui vérifie les mises à jour de
|
||||
Conçu pour être placé sur une **clé USB** dont la lettre de lecteur peut changer.
|
||||
Le programme peut **s'auto-mettre à jour** car il est lui-même dans un dépôt git.
|
||||
|
||||
## Langage
|
||||
|
||||
**Go** (anciennement Python, migré en Go depuis v0.7.x).
|
||||
- GUI : `github.com/lxn/walk` (contrôles natifs Windows)
|
||||
- Exe unique, aucune dépendance externe à l'exécution
|
||||
- Build : `go build` via `build.bat`
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
Lanceur-geco/
|
||||
├── git_updater.py # Script principal Python (GUI tkinter)
|
||||
├── main.go # Point d'entrée, constante VERSION
|
||||
├── config.go # Chargement config.ini (repos + self-update)
|
||||
├── git.go # Opérations git (check, clone, pull, checkout, clean)
|
||||
├── gui.go # Interface graphique (walk/TableView)
|
||||
├── logger.go # Logging fichier (1 fichier/jour, rotation 30j)
|
||||
├── selfupdate.go # Auto-mise à jour du programme
|
||||
├── platform_windows.go # Code spécifique Windows (création processus)
|
||||
├── version.txt # Numéro de version (utilisé par l'auto-update distant)
|
||||
├── config.ini # Configuration multi-repo
|
||||
├── build.bat # Script de compilation en .exe via PyInstaller
|
||||
├── log/ # Dossier de logs (créé automatiquement, 1 fichier par jour)
|
||||
├── build.bat # Script de compilation en .exe
|
||||
├── app.manifest # Manifeste Windows (DPI, elevation)
|
||||
├── icon.ico # Icône application
|
||||
├── go.mod / go.sum # Dépendances Go
|
||||
├── log/ # Dossier de logs (créé automatiquement)
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Règles importantes
|
||||
|
||||
- **Tous les chemins doivent être relatifs** à l'emplacement de l'exe. Jamais de chemin absolu (pas de `C:\`, `G:\`, etc.). Utiliser `..` et des chemins relatifs pour référencer les dossiers.
|
||||
- **Accès lecture seule** : le programme ne fait que `git fetch`, `git pull` et `git checkout`. Jamais de `git push`, `git commit`, `git add`, ou toute opération d'écriture vers le remote.
|
||||
- **Accès lecture seule** : le programme ne fait que `git ls-remote`, `git fetch`, `git pull`, `git checkout` et `git clean`. Jamais de `git push`, `git commit`, `git add`, ou toute opération d'écriture vers le remote.
|
||||
- **Multi-repo** : le programme peut surveiller plusieurs dépôts Git configurés dans `config.ini`.
|
||||
|
||||
## Versioning
|
||||
|
||||
- La version est définie en dur dans `main.go` via la constante `VERSION` (ex: `const VERSION = "0.7.6"`)
|
||||
- Le fichier `version.txt` à la racine du projet contient le même numéro de version (utilisé par le mécanisme d'auto-update distant)
|
||||
- Format : **semver simplifié** `MAJEUR.MINEUR.PATCH` (ex: `0.7.6`)
|
||||
- **Les deux doivent toujours être synchronisés** : quand on change la version, mettre à jour `VERSION` dans `main.go` ET `version.txt`
|
||||
|
||||
### Mise à jour de la version
|
||||
|
||||
A chaque changement de version, il faut mettre à jour **4 éléments** :
|
||||
1. `VERSION` dans `main.go` (constante en haut du fichier)
|
||||
2. `version.txt` à 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 :
|
||||
|
||||
```
|
||||
v{VERSION} - {description courte des changements}
|
||||
|
||||
Changements :
|
||||
- {changement 1}
|
||||
- {changement 2}
|
||||
- ...
|
||||
```
|
||||
|
||||
Exemple :
|
||||
```
|
||||
v0.7.6 - Clone dossier non-vide et verification rapide
|
||||
|
||||
Changements :
|
||||
- Clone dans dossier non-vide (git init + remote add + fetch + checkout)
|
||||
- Verification rapide via git ls-remote au lieu de git fetch
|
||||
- Support branche par repo dans config.ini
|
||||
```
|
||||
|
||||
### Mécanisme de comparaison
|
||||
|
||||
- La fonction `parseVersion(v)` convertit la chaîne version en `[3]int` (ex: `"0.7.6"` -> `[0, 7, 6]`) pour permettre la comparaison numérique
|
||||
- L'auto-update télécharge `version.txt` depuis le serveur Gitea via HTTP (`{repo_url}/raw/branch/{branch}/version.txt`) et compare avec la `VERSION` locale
|
||||
- Si la version distante est supérieure, une mise à jour est proposée
|
||||
|
||||
## Fonctionnement
|
||||
|
||||
### Auto-update
|
||||
1. Au démarrage, le programme vérifie si son propre dossier est un dépôt git
|
||||
2. Si oui, il fait un `git fetch` et compare avec le remote
|
||||
3. Si une MAJ du programme est dispo, il propose de la télécharger (`git pull`)
|
||||
4. Après mise à jour, il demande un redémarrage
|
||||
1. Au démarrage, le programme télécharge `version.txt` depuis le serveur Gitea via HTTP
|
||||
2. Compare la version distante avec la constante `VERSION` locale (comparaison par tuple numérique)
|
||||
3. Si la version distante est supérieure, propose de télécharger le nouvel exe
|
||||
4. Stratégie de remplacement : télécharge dans `.new`, renomme l'exe actuel en `.old`, place le nouveau
|
||||
5. Après mise à jour, lance un script batch de redémarrage
|
||||
|
||||
### Vérification des dépôts
|
||||
1. Lit la liste des dépôts depuis `config.ini` (chemins relatifs à l'exe)
|
||||
2. Pour chaque dépôt :
|
||||
- `git fetch` pour récupérer l'état distant
|
||||
- Compare commits locaux vs distants
|
||||
- Détecte les fichiers supprimés/modifiés localement
|
||||
3. Affiche le résultat dans une interface graphique (tkinter)
|
||||
2. Pour chaque dépôt (en parallèle via goroutines) :
|
||||
- `git ls-remote` pour vérifier la disponibilité et comparer les hashs (rapide, timeout 15s)
|
||||
- `git status --porcelain` pour détecter les fichiers modifiés/non suivis localement
|
||||
3. Affiche le résultat dans une interface graphique (walk/TableView)
|
||||
4. Propose pour chaque dépôt :
|
||||
- `git pull` si nouveaux commits distants
|
||||
- `git checkout -- .` si fichiers locaux manquants/modifiés
|
||||
- `git pull` si MAJ disponible (hash distant différent)
|
||||
- `git checkout -- .` si fichiers locaux modifiés
|
||||
- `git clean -fd` si fichiers non suivis en trop
|
||||
|
||||
### Clone dans dossier non-vide
|
||||
Si le dossier cible existe déjà mais n'a pas de `.git` (ex: repos imbriqués), le programme fait un clone "in-place" :
|
||||
`git init` + `git remote add` + `git fetch` + `git checkout -b <branch>`
|
||||
|
||||
## Configuration (config.ini)
|
||||
|
||||
Supporte plusieurs sections `[repo:NomDuRepo]` :
|
||||
|
||||
```ini
|
||||
[repo:Batch]
|
||||
url = http://192.168.1.235:3125/zogzog/Batch
|
||||
path = ../Batch
|
||||
[self-update]
|
||||
url = http://192.168.1.235:3125/zogzog/Lanceur-geco
|
||||
exe_name = GitUpdateChecker.exe
|
||||
branch = master
|
||||
|
||||
[repo:Powershell]
|
||||
url = http://192.168.1.235:3125/zogzog/Powershell
|
||||
path = ../Powershell
|
||||
[repo:Scripts]
|
||||
url = http://192.168.1.235:3125/zogzog/Scripts
|
||||
path = ../SOFT/Batch/Scripts
|
||||
|
||||
[repo:Soft]
|
||||
url = http://192.168.1.235:3125/zogzog/Soft.git
|
||||
path = ../SOFT/
|
||||
branch = master
|
||||
```
|
||||
|
||||
- `url` : URL du dépôt Git distant
|
||||
- `path` : Chemin **relatif** vers le dossier local du dépôt (relatif à l'exe)
|
||||
- `branch` : Branche à suivre (optionnel, défaut: `master`)
|
||||
|
||||
## Logging
|
||||
|
||||
- Les logs sont écrits dans `log/` à côté de l'exe (1 fichier par jour, format `YYYY-MM-DD.log`)
|
||||
- Les vieux logs sont nettoyés automatiquement (30 jours de rétention)
|
||||
- Chaque action git, erreur, et résultat est loggé avec timestamp
|
||||
- Bouton "Ouvrir les logs" dans la GUI pour accéder au dossier
|
||||
- Bouton "Logs" dans la GUI pour ouvrir le dossier
|
||||
|
||||
## Build
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
```
|
||||
Requiert Python + pip. Installe PyInstaller automatiquement si absent.
|
||||
Produit `dist/GitUpdateChecker.exe`. Copier `config.ini` à côté de l'exe.
|
||||
Requiert Go installé et dans le PATH. Installe `rsrc` automatiquement si absent.
|
||||
Produit `GitUpdateChecker.exe` à la racine (exe unique, pas de dépendances).
|
||||
Flags de build : `-H windowsgui -s -w` (pas de console, symboles strippés).
|
||||
|
||||
## Contraintes techniques
|
||||
|
||||
- **Chemins relatifs** : Tout est relatif à l'exe, jamais de chemin absolu
|
||||
- **Encodage** : Force UTF-8 pour les caractères Unicode
|
||||
- **Clé USB** : Fonctionne sur n'importe quelle lettre de lecteur
|
||||
- **Git requis** : Git doit être installé et dans le PATH de la machine
|
||||
- **Serveur Gitea** : Le remote origin pointe vers une instance Gitea locale (192.168.1.235:3125)
|
||||
- **Lecture seule** : Aucune opération d'écriture vers le remote (pas de push/commit)
|
||||
- **Interface** : GUI tkinter (inclus dans Python, pas de dépendance externe)
|
||||
- **Interface** : GUI native Windows via walk (pas de console)
|
||||
- **Logs** : Dossier `log/` à côté de l'exe, rotation automatique 30 jours
|
||||
- **Repos imbriqués** : Supporte les dépôts git imbriqués (ex: parent/enfant) via clone in-place
|
||||
|
||||
## Conventions
|
||||
|
||||
- Langage : Python 3, pas de dépendances externes (seulement stdlib + tkinter)
|
||||
- Interface : GUI tkinter en français
|
||||
- Langue : Français pour l'interface utilisateur
|
||||
- Langage : Go 1.22+
|
||||
- GUI : github.com/lxn/walk (contrôles natifs Windows)
|
||||
- Interface en français
|
||||
- Pas de console (flag `-H windowsgui`)
|
||||
|
||||
Binary file not shown.
32
app.manifest
Normal file
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 ========================================
|
||||
echo Build Git Update Checker (.exe)
|
||||
echo Build Git Update Checker (.exe) - Go
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
:: Vérifier que Python est installé
|
||||
python --version >nul 2>&1
|
||||
where go >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [ERREUR] Python n'est pas installe ou pas dans le PATH.
|
||||
echo [ERREUR] Go n'est pas installe ou pas dans le PATH.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: Installer PyInstaller si nécessaire
|
||||
pip show pyinstaller >nul 2>&1
|
||||
echo [*] Telechargement des dependances...
|
||||
go mod tidy
|
||||
if errorlevel 1 (
|
||||
echo [*] Installation de PyInstaller...
|
||||
pip install pyinstaller
|
||||
echo [ERREUR] go mod tidy a echoue.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [*] Generation du manifeste Windows (rsrc.syso)...
|
||||
where rsrc >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [*] Installation de rsrc...
|
||||
go install github.com/akavel/rsrc@latest
|
||||
)
|
||||
rsrc -manifest app.manifest -ico icon.ico -o rsrc.syso
|
||||
|
||||
echo [*] Compilation en cours...
|
||||
echo.
|
||||
|
||||
pyinstaller --onefile --console --name "GitUpdateChecker" --icon=NONE git_updater.py
|
||||
go build -ldflags "-H windowsgui -s -w" -o GitUpdateChecker.exe .
|
||||
|
||||
echo.
|
||||
if exist "dist\GitUpdateChecker.exe" (
|
||||
echo [OK] Executable cree : dist\GitUpdateChecker.exe
|
||||
echo.
|
||||
echo N'oublie pas de copier config.ini a cote de l'exe !
|
||||
if exist "GitUpdateChecker.exe" (
|
||||
echo [OK] GitUpdateChecker.exe cree - exe unique, aucune dependance.
|
||||
) else (
|
||||
echo [ERREUR] La compilation a echoue.
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
; Les chemins (path) sont relatifs a l'emplacement de l'exe
|
||||
; Ajouter autant de sections [repo:NomDuRepo] que necessaire
|
||||
|
||||
[self-update]
|
||||
url = http://192.168.1.235:3125/zogzog/Lanceur-geco
|
||||
exe_name = GitUpdateChecker.exe
|
||||
branch = feature/go-rewrite
|
||||
|
||||
[repo:Scripts]
|
||||
url = http://192.168.1.235:3125/zogzog/Scripts
|
||||
|
||||
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
|
||||
}
|
||||
787
git_updater.py
787
git_updater.py
@@ -1,787 +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.1"
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import configparser
|
||||
import logging
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Forcer UTF-8 sur Windows
|
||||
if sys.platform == "win32":
|
||||
os.system("chcp 65001 >nul 2>&1")
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Utilitaires ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_exe_dir():
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).parent
|
||||
return Path(__file__).parent
|
||||
|
||||
|
||||
def resolve_relative(path_str):
|
||||
"""Résout un chemin relatif par rapport au dossier de l'exe."""
|
||||
p = Path(path_str)
|
||||
if not p.is_absolute():
|
||||
p = get_exe_dir() / p
|
||||
return p.resolve()
|
||||
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def setup_logging():
|
||||
"""Configure le logging dans un dossier log/ à côté de l'exe."""
|
||||
log_dir = get_exe_dir() / "log"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
log_file = log_dir / f"{datetime.now().strftime('%Y-%m-%d')}.log"
|
||||
|
||||
logger = logging.getLogger("GitUpdateChecker")
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Handler fichier
|
||||
fh = logging.FileHandler(log_file, encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S"))
|
||||
logger.addHandler(fh)
|
||||
|
||||
# Nettoyage des vieux logs (garder 30 jours)
|
||||
for old_log in sorted(log_dir.glob("*.log"))[:-30]:
|
||||
try:
|
||||
old_log.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
log = setup_logging()
|
||||
|
||||
|
||||
def run_git(args, cwd=None):
|
||||
# -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=30,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
)
|
||||
if result.returncode != 0 and result.stderr.strip():
|
||||
log.warning(f"git {args[0]} erreur: {result.stderr.strip()}")
|
||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||
except FileNotFoundError:
|
||||
log.error("Git non trouve dans le PATH")
|
||||
return -1, "", "Git n'est pas installe ou pas dans le PATH."
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error(f"Timeout: git {' '.join(args)}")
|
||||
return 1, "", "Timeout"
|
||||
|
||||
|
||||
# ── Auto-update du programme ─────────────────────────────────────────────────
|
||||
|
||||
def check_self_update():
|
||||
"""
|
||||
Vérifie si le dossier de l'exe est un dépôt git avec des MAJ disponibles.
|
||||
Retourne (needs_update: bool, info: str).
|
||||
"""
|
||||
exe_dir = str(get_exe_dir())
|
||||
|
||||
if not os.path.isdir(os.path.join(exe_dir, ".git")):
|
||||
log.info("Auto-update: pas de .git dans le dossier de l'exe, skip")
|
||||
return False, ""
|
||||
|
||||
log.info("Auto-update: verification...")
|
||||
|
||||
code, branch, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=exe_dir)
|
||||
if code != 0:
|
||||
return False, "Impossible de lire la branche"
|
||||
|
||||
code, _, err = run_git(["fetch", "origin"], cwd=exe_dir)
|
||||
if code != 0:
|
||||
log.warning(f"Auto-update: fetch echoue: {err}")
|
||||
return False, f"Fetch echoue: {err}"
|
||||
|
||||
code, local_hash, _ = run_git(["rev-parse", "HEAD"], cwd=exe_dir)
|
||||
code2, remote_hash, _ = run_git(["rev-parse", f"origin/{branch}"], cwd=exe_dir)
|
||||
|
||||
if code != 0 or code2 != 0:
|
||||
return False, "Impossible de comparer les commits"
|
||||
|
||||
if local_hash == remote_hash:
|
||||
log.info("Auto-update: programme a jour")
|
||||
return False, ""
|
||||
|
||||
# Compter les commits en retard
|
||||
code, log_out, _ = run_git(
|
||||
["log", "--oneline", f"HEAD..origin/{branch}"],
|
||||
cwd=exe_dir,
|
||||
)
|
||||
count = len(log_out.splitlines()) if code == 0 and log_out else 0
|
||||
info = f"{count} commit(s) en retard sur origin/{branch}"
|
||||
log.info(f"Auto-update: MAJ disponible - {info}")
|
||||
return True, info
|
||||
|
||||
|
||||
def do_self_update():
|
||||
"""
|
||||
Met à jour le programme lui-même.
|
||||
Sur Windows, un .exe en cours d'exécution ne peut pas être écrasé.
|
||||
Stratégie : renommer l'exe actuel en .old, puis git pull le nouveau.
|
||||
Retourne (ok, message).
|
||||
"""
|
||||
exe_dir = str(get_exe_dir())
|
||||
is_frozen = getattr(sys, "frozen", False)
|
||||
|
||||
code, branch, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=exe_dir)
|
||||
if code != 0:
|
||||
return False, "Impossible de lire la branche"
|
||||
|
||||
# Si on tourne en .exe, renommer l'exe actuel pour libérer le fichier
|
||||
exe_old_path = None
|
||||
if is_frozen:
|
||||
exe_path = Path(sys.executable)
|
||||
exe_old_path = exe_path.with_suffix(".exe.old")
|
||||
try:
|
||||
# Supprimer un ancien .old s'il existe
|
||||
if exe_old_path.exists():
|
||||
exe_old_path.unlink()
|
||||
# Windows permet de renommer un exe en cours d'exécution
|
||||
exe_path.rename(exe_old_path)
|
||||
log.info(f"Auto-update: exe renomme {exe_path.name} -> {exe_old_path.name}")
|
||||
except OSError as e:
|
||||
log.error(f"Auto-update: impossible de renommer l'exe: {e}")
|
||||
return False, f"Impossible de renommer l'exe: {e}"
|
||||
|
||||
# Restaurer les fichiers locaux modifiés puis pull
|
||||
run_git(["checkout", "--", "."], cwd=exe_dir)
|
||||
code, _, err = run_git(["pull", "origin", branch], cwd=exe_dir)
|
||||
|
||||
if code == 0:
|
||||
log.info("Auto-update: pull OK")
|
||||
return True, "Mise a jour reussie !\nLe programme va redemarrer."
|
||||
else:
|
||||
# En cas d'échec, restaurer l'ancien exe
|
||||
if is_frozen and exe_old_path and exe_old_path.exists():
|
||||
try:
|
||||
exe_old_path.rename(Path(sys.executable))
|
||||
log.info("Auto-update: exe restaure apres echec")
|
||||
except OSError:
|
||||
pass
|
||||
log.error(f"Auto-update: pull echoue: {err}")
|
||||
return False, f"Erreur pull: {err}"
|
||||
|
||||
|
||||
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") 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,
|
||||
}
|
||||
|
||||
# Vérifier si le dossier existe et est un repo git
|
||||
if not os.path.isdir(os.path.join(local_path, ".git")):
|
||||
if os.path.isdir(local_path) and os.listdir(local_path):
|
||||
result["error"] = "Le dossier existe mais n'est pas un depot git."
|
||||
return result
|
||||
result["needs_clone"] = True
|
||||
return result
|
||||
|
||||
result["exists"] = True
|
||||
|
||||
# Branche courante
|
||||
code, branch, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=local_path)
|
||||
if code != 0:
|
||||
result["error"] = "Impossible de lire la branche courante."
|
||||
return result
|
||||
result["branch"] = branch
|
||||
|
||||
# Fetch
|
||||
code, _, err = run_git(["fetch", "origin"], cwd=local_path)
|
||||
if code != 0:
|
||||
result["error"] = f"Erreur fetch : {err}"
|
||||
return result
|
||||
|
||||
# Comparer HEAD local vs origin
|
||||
code, local_hash, _ = run_git(["rev-parse", "HEAD"], cwd=local_path)
|
||||
if code != 0:
|
||||
result["error"] = "Impossible de lire le commit local."
|
||||
return result
|
||||
|
||||
code, remote_hash, _ = run_git(["rev-parse", f"origin/{branch}"], cwd=local_path)
|
||||
if code != 0:
|
||||
result["error"] = f"Impossible de trouver origin/{branch}."
|
||||
return result
|
||||
|
||||
result["local_hash"] = local_hash[:8]
|
||||
result["remote_hash"] = remote_hash[:8]
|
||||
|
||||
# Modifications locales
|
||||
code, local_diff, _ = run_git(["diff", "--name-status", "HEAD"], cwd=local_path)
|
||||
if code == 0 and local_diff:
|
||||
for line in local_diff.splitlines():
|
||||
parts = line.split("\t", 1)
|
||||
if len(parts) == 2:
|
||||
sc = parts[0].strip()
|
||||
fn = parts[1].strip()
|
||||
status_map = {"D": "Supprime localement", "M": "Modifie localement"}
|
||||
result["local_changes"].append({
|
||||
"status": status_map.get(sc[0], sc),
|
||||
"status_char": sc[0],
|
||||
"file": fn,
|
||||
})
|
||||
|
||||
if local_hash == remote_hash and not result["local_changes"]:
|
||||
log.info(f"[{name}] A jour ({local_hash[:8]})")
|
||||
result["up_to_date"] = True
|
||||
return result
|
||||
|
||||
if local_hash == remote_hash:
|
||||
log.info(f"[{name}] {len(result['local_changes'])} fichier(s) locaux modifies/supprimes")
|
||||
result["local_only"] = True
|
||||
return result
|
||||
|
||||
# Nouveaux commits distants
|
||||
code, log_out, _ = run_git(
|
||||
["log", "--oneline", "--format=%h|%an|%ar|%s", f"HEAD..origin/{branch}"],
|
||||
cwd=local_path,
|
||||
)
|
||||
if code == 0 and log_out:
|
||||
for line in log_out.splitlines():
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) == 4:
|
||||
result["commits"].append({
|
||||
"hash": parts[0], "author": parts[1],
|
||||
"date": parts[2], "message": parts[3],
|
||||
})
|
||||
|
||||
# Fichiers distants modifiés
|
||||
code, diff_out, _ = run_git(
|
||||
["diff", "--name-status", f"HEAD..origin/{branch}"],
|
||||
cwd=local_path,
|
||||
)
|
||||
if code == 0 and diff_out:
|
||||
for line in diff_out.splitlines():
|
||||
parts = line.split("\t", 1)
|
||||
if len(parts) == 2:
|
||||
sc = parts[0].strip()
|
||||
fn = parts[1].strip()
|
||||
status_map = {"A": "Ajoute", "M": "Modifie", "D": "Supprime", "R": "Renomme"}
|
||||
result["files"].append({
|
||||
"status": status_map.get(sc[0], sc),
|
||||
"status_char": sc[0],
|
||||
"file": fn,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def do_clone(repo):
|
||||
"""Clone un dépôt."""
|
||||
local_path = str(resolve_relative(repo["path"]))
|
||||
log.info(f"Clonage: {repo['url']} -> {local_path}")
|
||||
code, _, err = run_git(["clone", repo["url"], local_path])
|
||||
if code == 0:
|
||||
log.info(f"Clonage reussi: {local_path}")
|
||||
else:
|
||||
log.error(f"Clonage echoue: {err}")
|
||||
return code == 0, err
|
||||
|
||||
|
||||
def do_pull(local_path, branch):
|
||||
"""Pull les mises à jour (lecture seule)."""
|
||||
log.info(f"Pull: {local_path} (branche {branch})")
|
||||
code, out, err = run_git(["pull", "origin", branch], cwd=local_path)
|
||||
if code == 0:
|
||||
log.info(f"Pull reussi: {local_path}")
|
||||
else:
|
||||
log.error(f"Pull echoue: {err}")
|
||||
return code == 0, out, err
|
||||
|
||||
|
||||
def do_restore(local_path):
|
||||
"""Restaure les fichiers locaux modifiés/supprimés."""
|
||||
log.info(f"Restauration: {local_path}")
|
||||
code, _, err = run_git(["checkout", "--", "."], cwd=local_path)
|
||||
if code == 0:
|
||||
log.info(f"Restauration reussie: {local_path}")
|
||||
else:
|
||||
log.error(f"Restauration echouee: {err}")
|
||||
return code == 0, err
|
||||
|
||||
|
||||
# ── Interface graphique ──────────────────────────────────────────────────────
|
||||
|
||||
class App(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title(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._build_ui()
|
||||
self.after(100, self._check_self_update_then_repos)
|
||||
|
||||
def _build_ui(self):
|
||||
style = ttk.Style(self)
|
||||
style.theme_use("clam")
|
||||
|
||||
# Couleurs sombres
|
||||
bg = "#1e1e2e"
|
||||
fg = "#cdd6f4"
|
||||
accent = "#89b4fa"
|
||||
green = "#a6e3a1"
|
||||
yellow = "#f9e2af"
|
||||
red = "#f38ba8"
|
||||
|
||||
style.configure("TFrame", background=bg)
|
||||
style.configure("TLabel", background=bg, foreground=fg, font=("Segoe UI", 10))
|
||||
style.configure("Title.TLabel", background=bg, foreground=fg, font=("Segoe UI", 14, "bold"))
|
||||
style.configure("Status.TLabel", background=bg, foreground=accent, font=("Segoe UI", 9))
|
||||
style.configure("TButton", font=("Segoe UI", 10))
|
||||
style.configure("Green.TLabel", background=bg, foreground=green, font=("Segoe UI", 10, "bold"))
|
||||
style.configure("Yellow.TLabel", background=bg, foreground=yellow, font=("Segoe UI", 10, "bold"))
|
||||
style.configure("Red.TLabel", background=bg, foreground=red, font=("Segoe UI", 10, "bold"))
|
||||
|
||||
# Header
|
||||
header = ttk.Frame(self)
|
||||
header.pack(fill="x", padx=15, pady=(15, 5))
|
||||
ttk.Label(header, text=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
|
||||
date_label = ttk.Label(self, text=datetime.now().strftime(" %d/%m/%Y %H:%M:%S"), style="Status.TLabel")
|
||||
date_label.pack(anchor="w", padx=15)
|
||||
|
||||
# Zone scrollable pour les repos
|
||||
container = ttk.Frame(self)
|
||||
container.pack(fill="both", expand=True, padx=15, pady=10)
|
||||
|
||||
self.canvas = tk.Canvas(container, bg=bg, highlightthickness=0)
|
||||
scrollbar = ttk.Scrollbar(container, orient="vertical", command=self.canvas.yview)
|
||||
self.scroll_frame = ttk.Frame(self.canvas)
|
||||
|
||||
self.scroll_frame.bind("<Configure>", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")))
|
||||
self.canvas.create_window((0, 0), window=self.scroll_frame, anchor="nw")
|
||||
self.canvas.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
self.canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
# Scroll avec la molette
|
||||
self.canvas.bind_all("<MouseWheel>", lambda e: self.canvas.yview_scroll(int(-1 * (e.delta / 120)), "units"))
|
||||
|
||||
# Boutons en bas
|
||||
btn_frame = ttk.Frame(self)
|
||||
btn_frame.pack(fill="x", padx=15, pady=(0, 15))
|
||||
|
||||
self.btn_refresh = ttk.Button(btn_frame, text="Rafraichir", command=self._start_check)
|
||||
self.btn_refresh.pack(side="left", padx=(0, 10))
|
||||
|
||||
self.btn_update_all = ttk.Button(btn_frame, text="Tout mettre a jour", command=self._update_all)
|
||||
self.btn_update_all.pack(side="left")
|
||||
self.btn_update_all.state(["disabled"])
|
||||
|
||||
ttk.Button(btn_frame, text="Ouvrir config.ini", command=self._open_config).pack(side="right")
|
||||
|
||||
self.btn_open_log = ttk.Button(btn_frame, text="Ouvrir les logs", command=self._open_log)
|
||||
self.btn_open_log.pack(side="right", padx=(0, 10))
|
||||
|
||||
def _check_self_update_then_repos(self):
|
||||
"""Vérifie d'abord la MAJ du programme, puis les repos."""
|
||||
self.status_label.configure(text="Verification auto-update...")
|
||||
|
||||
def work():
|
||||
needs, info = check_self_update()
|
||||
self.after(0, lambda: self._handle_self_update(needs, info))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _handle_self_update(self, needs_update, info):
|
||||
"""Gère le résultat de l'auto-update."""
|
||||
if needs_update:
|
||||
answer = messagebox.askyesno(
|
||||
"Mise a jour du programme",
|
||||
f"Une mise a jour du programme est disponible !\n\n{info}\n\nMettre a jour maintenant ?",
|
||||
)
|
||||
if answer:
|
||||
self.status_label.configure(text="Mise a jour du programme...")
|
||||
|
||||
def work():
|
||||
ok, msg = do_self_update()
|
||||
self.after(0, lambda: self._self_update_done(ok, msg))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
return
|
||||
|
||||
self._start_check()
|
||||
|
||||
def _self_update_done(self, ok, msg):
|
||||
"""Callback après auto-update."""
|
||||
if ok:
|
||||
messagebox.showinfo("Auto-update", msg)
|
||||
log.info("Auto-update appliquee, 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.repos_config = load_repos()
|
||||
|
||||
if not self.repos_config:
|
||||
self._clear_cards()
|
||||
self.status_label.configure(text="Aucun depot configure")
|
||||
self._show_no_repos()
|
||||
self.btn_refresh.state(["!disabled"])
|
||||
return
|
||||
|
||||
threading.Thread(target=self._check_all, daemon=True).start()
|
||||
|
||||
def _check_all(self):
|
||||
"""Vérifie tous les repos (dans un thread)."""
|
||||
results = []
|
||||
for repo in self.repos_config:
|
||||
results.append(check_repo(repo))
|
||||
self.repo_results = results
|
||||
self.after(0, self._display_results)
|
||||
|
||||
def _display_results(self):
|
||||
"""Affiche les résultats dans la GUI."""
|
||||
self._clear_cards()
|
||||
|
||||
has_any_updates = False
|
||||
for res in self.repo_results:
|
||||
card = self._create_card(res)
|
||||
card.pack(fill="x", pady=5)
|
||||
if not res["up_to_date"] and not res.get("error"):
|
||||
has_any_updates = True
|
||||
|
||||
total = len(self.repo_results)
|
||||
up = sum(1 for r in self.repo_results if r["up_to_date"])
|
||||
log.info(f"Resultat: {up}/{total} depots a jour")
|
||||
self.status_label.configure(text=f"{up}/{total} depots a jour")
|
||||
self.btn_refresh.state(["!disabled"])
|
||||
|
||||
if has_any_updates:
|
||||
self.btn_update_all.state(["!disabled"])
|
||||
|
||||
def _clear_cards(self):
|
||||
for w in self.scroll_frame.winfo_children():
|
||||
w.destroy()
|
||||
|
||||
def _create_card(self, res):
|
||||
"""Crée une carte pour un dépôt."""
|
||||
bg_card = "#313244"
|
||||
fg = "#cdd6f4"
|
||||
green = "#a6e3a1"
|
||||
yellow = "#f9e2af"
|
||||
red = "#f38ba8"
|
||||
cyan = "#89dceb"
|
||||
dim = "#6c7086"
|
||||
|
||||
card = tk.Frame(self.scroll_frame, bg=bg_card, bd=0, highlightthickness=1, highlightbackground="#45475a")
|
||||
|
||||
# Header de la carte
|
||||
top = tk.Frame(card, bg=bg_card)
|
||||
top.pack(fill="x", padx=12, pady=(10, 5))
|
||||
|
||||
tk.Label(top, text=res["name"], bg=bg_card, fg=fg, font=("Segoe UI", 11, "bold")).pack(side="left")
|
||||
|
||||
if res.get("error"):
|
||||
tk.Label(top, text="ERREUR", bg=bg_card, fg=red, font=("Segoe UI", 9, "bold")).pack(side="right")
|
||||
elif res.get("needs_clone"):
|
||||
tk.Label(top, text="A CLONER", bg=bg_card, fg=yellow, font=("Segoe UI", 9, "bold")).pack(side="right")
|
||||
elif res["up_to_date"]:
|
||||
tk.Label(top, text="A JOUR", bg=bg_card, fg=green, font=("Segoe UI", 9, "bold")).pack(side="right")
|
||||
else:
|
||||
count = len(res["commits"]) + len(res["local_changes"])
|
||||
tk.Label(top, text=f"MAJ DISPO ({count})", bg=bg_card, fg=yellow, font=("Segoe UI", 9, "bold")).pack(side="right")
|
||||
|
||||
# Infos
|
||||
info = tk.Frame(card, bg=bg_card)
|
||||
info.pack(fill="x", padx=12)
|
||||
tk.Label(info, text=f"Chemin : {res['relative_path']}", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w")
|
||||
if res["branch"]:
|
||||
tk.Label(info, text=f"Branche : {res['branch']} | {res['local_hash']}", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w")
|
||||
|
||||
# Erreur
|
||||
if res.get("error"):
|
||||
tk.Label(card, text=f" {res['error']}", bg=bg_card, fg=red, font=("Segoe UI", 9), anchor="w").pack(fill="x", padx=12, pady=(5, 0))
|
||||
|
||||
# Commits distants
|
||||
if res["commits"]:
|
||||
tk.Label(card, text=f"Nouveaux commits ({len(res['commits'])}) :", bg=bg_card, fg=fg, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(8, 2))
|
||||
for c in res["commits"][:5]:
|
||||
line_frame = tk.Frame(card, bg=bg_card)
|
||||
line_frame.pack(fill="x", padx=20)
|
||||
tk.Label(line_frame, text=c["hash"], bg=bg_card, fg=cyan, font=("Consolas", 9)).pack(side="left")
|
||||
tk.Label(line_frame, text=f" {c['message']}", bg=bg_card, fg=fg, font=("Segoe UI", 9), anchor="w").pack(side="left", fill="x")
|
||||
if len(res["commits"]) > 5:
|
||||
tk.Label(card, text=f" ... et {len(res['commits']) - 5} autres commits", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w", padx=20)
|
||||
|
||||
# Fichiers distants modifiés
|
||||
if res["files"]:
|
||||
tk.Label(card, text=f"Fichiers distants modifies ({len(res['files'])}) :", bg=bg_card, fg=fg, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(5, 2))
|
||||
color_map = {"A": green, "M": yellow, "D": red, "R": cyan}
|
||||
for f in res["files"][:8]:
|
||||
line_frame = tk.Frame(card, bg=bg_card)
|
||||
line_frame.pack(fill="x", padx=20)
|
||||
c = color_map.get(f["status_char"], fg)
|
||||
tk.Label(line_frame, text=f"[{f['status']:>9}]", bg=bg_card, fg=c, font=("Consolas", 9)).pack(side="left")
|
||||
tk.Label(line_frame, text=f" {f['file']}", bg=bg_card, fg=fg, font=("Segoe UI", 9)).pack(side="left")
|
||||
if len(res["files"]) > 8:
|
||||
tk.Label(card, text=f" ... et {len(res['files']) - 8} autres fichiers", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w", padx=20)
|
||||
|
||||
# Fichiers locaux manquants
|
||||
if res["local_changes"]:
|
||||
tk.Label(card, text=f"Fichiers locaux a restaurer ({len(res['local_changes'])}) :", bg=bg_card, fg=fg, font=("Segoe UI", 9, "bold"), anchor="w").pack(fill="x", padx=12, pady=(5, 2))
|
||||
for f in res["local_changes"][:8]:
|
||||
line_frame = tk.Frame(card, bg=bg_card)
|
||||
line_frame.pack(fill="x", padx=20)
|
||||
c = red if f["status_char"] == "D" else yellow
|
||||
tk.Label(line_frame, text=f"[{f['status']:>20}]", bg=bg_card, fg=c, font=("Consolas", 8)).pack(side="left")
|
||||
tk.Label(line_frame, text=f" {f['file']}", bg=bg_card, fg=fg, font=("Segoe UI", 9)).pack(side="left")
|
||||
if len(res["local_changes"]) > 8:
|
||||
tk.Label(card, text=f" ... et {len(res['local_changes']) - 8} autres fichiers", bg=bg_card, fg=dim, font=("Segoe UI", 8)).pack(anchor="w", padx=20)
|
||||
|
||||
# Bouton d'action
|
||||
if not res["up_to_date"] and not res.get("error"):
|
||||
btn_frame = tk.Frame(card, bg=bg_card)
|
||||
btn_frame.pack(fill="x", padx=12, pady=(8, 0))
|
||||
|
||||
if res.get("needs_clone"):
|
||||
btn = ttk.Button(btn_frame, text="Cloner le depot", command=lambda r=res: self._do_clone(r))
|
||||
else:
|
||||
btn = ttk.Button(btn_frame, text="Mettre a jour", command=lambda r=res: self._do_update(r))
|
||||
btn.pack(side="left")
|
||||
# Stocker ref du bouton pour le désactiver après
|
||||
res["_btn"] = btn
|
||||
|
||||
# Padding bas
|
||||
tk.Frame(card, bg=bg_card, height=10).pack()
|
||||
|
||||
return card
|
||||
|
||||
def _do_update(self, res):
|
||||
"""Met à jour un dépôt (pull + restore)."""
|
||||
log.info(f"[{res['name']}] MAJ unitaire demandee")
|
||||
if "_btn" in res:
|
||||
res["_btn"].state(["disabled"])
|
||||
|
||||
def work():
|
||||
messages = []
|
||||
success = True
|
||||
local_path = res["local_path"]
|
||||
branch = res["branch"]
|
||||
|
||||
if res["commits"]:
|
||||
log.info(f"[{res['name']}] Pull de {len(res['commits'])} commit(s)...")
|
||||
ok, out, err = do_pull(local_path, branch)
|
||||
if ok:
|
||||
msg = f"Pull OK : {len(res['commits'])} commits telecharges."
|
||||
log.info(f"[{res['name']}] {msg}")
|
||||
messages.append(msg)
|
||||
else:
|
||||
msg = f"Erreur pull : {err}"
|
||||
log.error(f"[{res['name']}] {msg}")
|
||||
messages.append(msg)
|
||||
success = False
|
||||
|
||||
if res["local_changes"]:
|
||||
log.info(f"[{res['name']}] Restauration de {len(res['local_changes'])} fichier(s)...")
|
||||
ok, err = do_restore(local_path)
|
||||
if ok:
|
||||
msg = f"Restauration OK : {len(res['local_changes'])} fichiers restaures."
|
||||
log.info(f"[{res['name']}] {msg}")
|
||||
messages.append(msg)
|
||||
else:
|
||||
msg = f"Erreur restauration : {err}"
|
||||
log.error(f"[{res['name']}] {msg}")
|
||||
messages.append(msg)
|
||||
success = False
|
||||
|
||||
status = "SUCCES" if success else "ECHEC"
|
||||
log.info(f"[{res['name']}] MAJ unitaire terminee - {status}")
|
||||
self.after(0, lambda: self._show_update_result(res, messages, success))
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _do_clone(self, res):
|
||||
"""Clone un dépôt."""
|
||||
log.info(f"[{res['name']}] Clonage demande - {res['url']}")
|
||||
if "_btn" in res:
|
||||
res["_btn"].state(["disabled"])
|
||||
|
||||
repo = {"url": res["url"], "path": res["relative_path"]}
|
||||
|
||||
def work():
|
||||
ok, err = do_clone(repo)
|
||||
if ok:
|
||||
msg = f"Depot '{res['name']}' clone avec succes !"
|
||||
log.info(f"[{res['name']}] {msg}")
|
||||
else:
|
||||
msg = f"Erreur de clonage : {err}"
|
||||
log.error(f"[{res['name']}] {msg}")
|
||||
self.after(0, lambda: messagebox.showinfo("Clonage", msg))
|
||||
self.after(100, self._start_check)
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
def _show_update_result(self, res, messages, success):
|
||||
title = "Mise a jour" if success else "Erreur"
|
||||
messagebox.showinfo(title, f"{res['name']}\n\n" + "\n".join(messages))
|
||||
self._start_check()
|
||||
|
||||
def _update_all(self):
|
||||
"""Met à jour tous les dépôts qui ont des MAJ."""
|
||||
to_update = [r for r in self.repo_results if not r["up_to_date"] and not r.get("error") and not r.get("needs_clone")]
|
||||
log.info(f"MAJ globale demandee - {len(to_update)} depot(s) a mettre a jour")
|
||||
for res in to_update:
|
||||
self._do_update(res)
|
||||
|
||||
def _show_no_repos(self):
|
||||
bg_card = "#313244"
|
||||
fg = "#cdd6f4"
|
||||
dim = "#6c7086"
|
||||
|
||||
card = tk.Frame(self.scroll_frame, bg=bg_card, bd=0, highlightthickness=1, highlightbackground="#45475a")
|
||||
card.pack(fill="x", pady=5)
|
||||
|
||||
tk.Label(card, text="Aucun depot configure", bg=bg_card, fg=fg, font=("Segoe UI", 11, "bold")).pack(padx=12, pady=(12, 5))
|
||||
tk.Label(card, text="Edite config.ini pour ajouter des depots :", bg=bg_card, fg=dim, font=("Segoe UI", 9)).pack(padx=12)
|
||||
example = (
|
||||
"[repo:MonRepo]\n"
|
||||
"url = http://192.168.1.235:3125/user/repo\n"
|
||||
"path = ../MonRepo"
|
||||
)
|
||||
tk.Label(card, text=example, bg="#1e1e2e", fg="#a6e3a1", font=("Consolas", 9), justify="left", padx=10, pady=8).pack(padx=12, pady=8, fill="x")
|
||||
tk.Frame(card, bg=bg_card, height=10).pack()
|
||||
|
||||
def _open_config(self):
|
||||
config_path = str(get_config_path())
|
||||
if sys.platform == "win32":
|
||||
os.startfile(config_path)
|
||||
|
||||
def _open_log(self):
|
||||
log_dir = str(get_exe_dir() / "log")
|
||||
if sys.platform == "win32":
|
||||
os.startfile(log_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = App()
|
||||
app.mainloop()
|
||||
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
version.txt
Normal file
1
version.txt
Normal file
@@ -0,0 +1 @@
|
||||
0.7.9
|
||||
Reference in New Issue
Block a user