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 Pending bool UpToDate bool Offline bool NeedsClone bool Error string NewCommits int 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 checkRepo(cfg RepoConfig) RepoResult { res := RepoResult{Name: cfg.Name, URL: cfg.URL} 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 { 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) { 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) 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) { res.Offline = true res.Error = "Hors ligne" return res } } res.Error = "Fetch: " + stderr return res } _, branch, _ := runGit([]string{"rev-parse", "--abbrev-ref", "HEAD"}, local, 5*time.Second) if branch == "" { branch = "master" } _, countStr, _ := runGit([]string{"rev-list", "--count", "HEAD..origin/" + branch}, local, 5*time.Second) fmt.Sscanf(countStr, "%d", &res.NewCommits) _, 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.NewCommits == 0 && 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 } code, _, stderr := runGit([]string{"clone", cfg.URL, local}, "", 300*time.Second) if code != 0 { return fmt.Errorf("%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 } 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 }