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