mydev/internal/checks/repos.go
Victor Abrell 7140397a13 Initial commit: MyDev dev-environment TUI
CLI + live dashboard wrapping aws/docker/git/awsmfa for local dev:
AWS MFA auth status + expiry, dev compose stack control, container
drift vs the dev repo, git repo status, prod-data and DB-access tools.
Config-driven (~/.config/mydev/config.yaml). Dashboard runs commands
via a command palette with captured/interactive modes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:31:58 +02:00

155 lines
3.7 KiB
Go

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: "<behind>\t<ahead>" 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
}