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 }