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>
155 lines
3.7 KiB
Go
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
|
|
}
|