package checks import ( "context" "os" "os/exec" "path/filepath" "strings" "time" ) // Repo is one git repo's working-tree state. type Repo struct { Name string Branch string Dirty bool Ahead int Err string } // ReposMulti scans several roots and concatenates the results. The first // root that errors stops the scan and returns that error message. func ReposMulti(ctx context.Context, roots []string) ([]Repo, string) { var all []Repo for _, root := range roots { rs, errMsg := Repos(ctx, root) if errMsg != "" { return all, errMsg } all = append(all, rs...) } return all, "" } // Repos scans the immediate subdirectories of root for git repos and // reports branch + dirty state for each. Shallow scan (one level) on // purpose — point it at the parent of your cloned repos. func Repos(ctx context.Context, root string) ([]Repo, string) { entries, err := os.ReadDir(root) if err != nil { return nil, err.Error() } var repos []Repo for _, e := range entries { if !e.IsDir() { continue } dir := filepath.Join(root, e.Name()) if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil { continue // not a repo } repos = append(repos, repoStatus(ctx, dir, e.Name())) } return repos, "" } func repoStatus(ctx context.Context, dir, name string) Repo { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() r := Repo{Name: name} branch, err := git(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") if err != nil { r.Err = err.Error() return r } r.Branch = branch // Porcelain status: any output => dirty working tree. status, err := git(ctx, dir, "status", "--porcelain") if err != nil { r.Err = err.Error() return r } r.Dirty = strings.TrimSpace(status) != "" return r } // Freshness reports how a repo's checked-out branch compares to its upstream. type Freshness struct { Branch string Behind int // commits on upstream not in HEAD (you need to pull) Ahead int // commits in HEAD not on upstream (unpushed) NoUpstream bool // branch has no tracking remote Err string } // UpToDate is true when the branch is level with its upstream. func (f Freshness) UpToDate() bool { return f.Err == "" && !f.NoUpstream && f.Behind == 0 && f.Ahead == 0 } // GitFreshness checks one repo against its upstream. When fetch is true it runs // `git fetch` first (network) so "behind" reflects the real remote, not a stale // local tracking ref. func GitFreshness(ctx context.Context, dir string, fetch bool) Freshness { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() f := Freshness{} branch, err := git(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD") if err != nil { f.Err = err.Error() return f } f.Branch = branch if fetch { if _, err := git(ctx, dir, "fetch", "--quiet"); err != nil { f.Err = "fetch: " + err.Error() return f } } // No upstream configured → nothing to compare against. if _, err := git(ctx, dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"); err != nil { f.NoUpstream = true return f } // left-right count: "\t" relative to upstream...HEAD. out, err := git(ctx, dir, "rev-list", "--left-right", "--count", "@{u}...HEAD") if err != nil { f.Err = err.Error() return f } parts := strings.Fields(out) if len(parts) == 2 { f.Behind = atoi(parts[0]) f.Ahead = atoi(parts[1]) } return f } func atoi(s string) int { n := 0 for _, r := range s { if r < '0' || r > '9' { return n } n = n*10 + int(r-'0') } return n } func git(ctx context.Context, dir string, args ...string) (string, error) { c := exec.CommandContext(ctx, "git", args...) c.Dir = dir out, err := c.Output() return strings.TrimSpace(string(out)), err }