diff --git a/GitUpdateChecker_go_test.exe b/GitUpdateChecker_go_test.exe index c59e3d7..6e5756a 100644 Binary files a/GitUpdateChecker_go_test.exe and b/GitUpdateChecker_go_test.exe differ diff --git a/git.go b/git.go index a93e74f..d16cde5 100644 --- a/git.go +++ b/git.go @@ -1,12 +1,16 @@ package main import ( + "bufio" "context" "errors" "fmt" + "io" "os" "os/exec" "path/filepath" + "regexp" + "strconv" "strings" "time" ) @@ -71,7 +75,7 @@ func checkRepo(cfg RepoConfig) RepoResult { runGit([]string{"remote", "set-url", "origin", cfg.URL}, local, 10*time.Second) - code, _, stderr := runGit([]string{"fetch", "origin"}, local, 60*time.Second) + code, _, stderr := runGit([]string{"fetch", "origin"}, local, 5*time.Minute) if code != 0 { for _, kw := range []string{"could not resolve", "connection refused", "unable to connect", "timed out", "the remote end hung up"} { if strings.Contains(strings.ToLower(stderr), kw) { @@ -132,3 +136,149 @@ func doCheckout(res RepoResult) error { } return nil } + +// ── Progression Git ─────────────────────────────────────────────────────────── + +// ProgressInfo contient l'état de progression d'une opération git. +type ProgressInfo struct { + Phase string // ex: "Receiving objects", "Resolving deltas" + Percent float64 // 0.0 à 1.0 + Current int64 + Total int64 + Speed string // ex: "1.2 MiB/s" +} + +// ProgressCallback est appelé à chaque mise à jour de la progression. +type ProgressCallback func(ProgressInfo) + +// reGitProgress capture les lignes de progression git : +// +// "Receiving objects: 45% (123/456), 1.20 MiB | 500.00 KiB/s" +// "Resolving deltas: 100% (89/89), done." +var reGitProgress = regexp.MustCompile( + `(?i)([\w\s]+):\s+(\d+)%\s+\((\d+)/(\d+)\)(?:.*\|\s*(.+/s))?`, +) + +// parseGitProgress analyse une ligne de sortie git et renvoie un ProgressInfo. +func parseGitProgress(line string) (ProgressInfo, bool) { + m := reGitProgress.FindStringSubmatch(line) + if m == nil { + return ProgressInfo{}, false + } + pct, _ := strconv.Atoi(m[2]) + cur, _ := strconv.ParseInt(m[3], 10, 64) + tot, _ := strconv.ParseInt(m[4], 10, 64) + speed := strings.TrimSpace(m[5]) + return ProgressInfo{ + Phase: strings.TrimSpace(m[1]), + Percent: float64(pct) / 100.0, + Current: cur, + Total: tot, + Speed: speed, + }, true +} + +// runGitWithProgress exécute une commande git et capture la progression en temps réel. +// Le timeout est désactivé (0) ou très long pour les gros dépôts. +func runGitWithProgress(args []string, cwd string, timeout time.Duration, cb ProgressCallback) (int, string, string) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + fullArgs := append([]string{"-c", "safe.directory=*"}, args...) + cmd := newGitCmd(ctx, fullArgs, cwd) + + var outBuf strings.Builder + cmd.Stdout = &outBuf + + // Pipe stderr pour lire la progression en temps réel + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return -1, "", err.Error() + } + + if err := cmd.Start(); err != nil { + return -1, "", err.Error() + } + + // Lire stderr byte par byte pour détecter les \r (git écrase la ligne) + var stderrBuf strings.Builder + reader := bufio.NewReader(stderrPipe) + var lineBuf strings.Builder + + for { + b, err := reader.ReadByte() + if err != nil { + if err != io.EOF { + stderrBuf.WriteString(err.Error()) + } + break + } + stderrBuf.WriteByte(b) + + if b == '\r' || b == '\n' { + line := lineBuf.String() + lineBuf.Reset() + if cb != nil && line != "" { + if info, ok := parseGitProgress(line); ok { + cb(info) + } + } + } else { + lineBuf.WriteByte(b) + } + } + // Dernière ligne sans \r\n + if lineBuf.Len() > 0 && cb != nil { + if info, ok := parseGitProgress(lineBuf.String()); ok { + cb(info) + } + } + + err = cmd.Wait() + stdout := strings.TrimSpace(outBuf.String()) + stderr := strings.TrimSpace(stderrBuf.String()) + + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return 1, stdout, "Timeout" + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode(), stdout, stderr + } + return -1, stdout, err.Error() + } + return 0, stdout, stderr +} + +// doCloneWithProgress clone un dépôt avec suivi de progression. +func doCloneWithProgress(cfg RepoConfig, cb ProgressCallback) error { + local := absRepoPath(cfg.Path) + if err := os.MkdirAll(filepath.Dir(local), 0755); err != nil { + return err + } + code, _, stderr := runGitWithProgress( + []string{"clone", "--progress", cfg.URL, local}, + "", 2*time.Hour, cb, + ) + if code != 0 { + return fmt.Errorf("%s", stderr) + } + return nil +} + +// doPullWithProgress fait un pull avec suivi de progression. +func doPullWithProgress(res RepoResult, cb ProgressCallback) error { + _, branch, _ := runGit([]string{"rev-parse", "--abbrev-ref", "HEAD"}, res.Path, 5*time.Second) + if branch == "" { + branch = "master" + } + code, _, stderr := runGitWithProgress( + []string{"pull", "--progress", "origin", branch}, + res.Path, 2*time.Hour, cb, + ) + if code != 0 { + return fmt.Errorf("%s", stderr) + } + return nil +} diff --git a/gui.go b/gui.go index 853fd2e..a2ee10b 100644 --- a/gui.go +++ b/gui.go @@ -5,6 +5,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync" "sync/atomic" "time" @@ -16,7 +17,9 @@ import ( // ── Modèle TableView ────────────────────────────────────────────────────────── type RepoItem struct { - result RepoResult + 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 { @@ -49,6 +52,27 @@ func (it *RepoItem) statusText() string { return msg } +// 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 { @@ -94,28 +118,54 @@ func (m *RepoModel) Value(row, col int) interface{} { return "" } it := m.items[row] - if col == 0 { + switch col { + case 0: return it.result.Name + case 1: + return it.statusText() + case 2: + return it.progressText } - return it.statusText() + return "" } func (m *RepoModel) StyleCell(style *walk.CellStyle) { - if style.Col() != 1 { - return - } m.mu.RLock() defer m.mu.RUnlock() - if style.Row() >= len(m.items) { + row := style.Row() + if row >= len(m.items) { return } - style.TextColor = m.items[style.Row()].textColor() + 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) @@ -184,8 +234,8 @@ func (a *App) buildAndRun() error { if err := (MainWindow{ AssignTo: &a.mw, Title: "Git Update Checker v" + VERSION, - MinSize: Size{Width: 650, Height: 400}, - Size: Size{Width: 820, Height: 600}, + 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 @@ -208,8 +258,9 @@ func (a *App) buildAndRun() error { AlternatingRowBG: true, ColumnsOrderable: false, Columns: []TableViewColumn{ - {Title: "Dépôt", Width: 180}, - {Title: "Statut", Width: 350}, + {Title: "Dépôt", Width: 150}, + {Title: "Statut", Width: 200}, + {Title: "Progression", Width: 350}, }, Model: a.model, OnCurrentIndexChanged: a.onSelectionChanged, @@ -382,6 +433,31 @@ func logLineForResult(r RepoResult) string { return msg } +// ── Progression ─────────────────────────────────────────────────────────────── + +// makeProgressCB crée un callback de progression pour la ligne row du tableau. +// Le callback est appelé depuis un goroutine git et synchronise l'UI via mw.Synchronize. +func (a *App) makeProgressCB(row int) ProgressCallback { + // Limiter les mises à jour UI (max ~10/s) pour ne pas surcharger + var lastUpdate time.Time + return func(info ProgressInfo) { + now := time.Now() + if now.Sub(lastUpdate) < 100*time.Millisecond && info.Percent < 1.0 { + return + } + lastUpdate = now + + label := info.Phase + if info.Speed != "" { + label += " " + info.Speed + } + text := progressBarText(info.Percent, 20, label) + a.mw.Synchronize(func() { + a.model.setProgress(row, info.Percent, text) + }) + } +} + // ── Actions dépôt ───────────────────────────────────────────────────────────── func (a *App) onSelectionChanged() { @@ -413,23 +489,26 @@ func (a *App) doAction() { a.btnAction.SetEnabled(false) a.appendLog(fmt.Sprintf("[%s] Mise à jour en cours...", res.Name)) + cb := a.makeProgressCB(idx) go func() { var err error if res.NeedsClone { - err = doClone(cfg) + err = doCloneWithProgress(cfg, cb) } else { if res.LocalChanges > 0 { err = doCheckout(res) } if err == nil && res.NewCommits > 0 { - err = doPull(res) + err = doPullWithProgress(res, cb) } } a.mw.Synchronize(func() { if err != nil { + a.model.setProgress(idx, 0, "Erreur") walk.MsgBox(a.mw, "Erreur", res.Name+"\n\n"+err.Error(), walk.MsgBoxIconError) 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)) } @@ -449,24 +528,27 @@ func (a *App) updateAll() { continue } pending.Add(1) - cfg, res := cfg, res + i, cfg, res := i, cfg, res + cb := a.makeProgressCB(i) go func() { var err error if res.NeedsClone { - err = doClone(cfg) + err = doCloneWithProgress(cfg, cb) } else { if res.LocalChanges > 0 { err = doCheckout(res) } if err == nil && res.NewCommits > 0 { - err = doPull(res) + err = doPullWithProgress(res, cb) } } 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)) }