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