package engineeringdashboard import ( "context" "fmt" "strconv" "strings" "time" ) // GitCollectResult is the git collector output. type GitCollectResult struct { Source SourceState Repository RepositoryIdentity Branches []GitBranchView Facts []Fact Warnings []string } // CollectGit gathers read-only git observations via runner. func CollectGit(ctx context.Context, repoRoot string, runner CommandRunner, now time.Time) GitCollectResult { start := time.Now() res := GitCollectResult{ Repository: RepositoryIdentity{Root: repoRoot}, } if runner == nil { runner = ExecRunner{} } branch, err := gitOut(ctx, runner, repoRoot, "rev-parse", "--abbrev-ref", "HEAD") if err != nil { return gitFail(res, start, now, err) } head, err := gitOut(ctx, runner, repoRoot, "rev-parse", "HEAD") if err != nil { return gitFail(res, start, now, err) } res.Repository.Branch = strings.TrimSpace(branch) res.Repository.Head = strings.TrimSpace(head) status, err := gitOut(ctx, runner, repoRoot, "status", "--porcelain=v2", "--branch") if err != nil { return gitFail(res, start, now, err) } dirty := false for _, line := range strings.Split(status, "\n") { if line == "" || strings.HasPrefix(line, "#") { continue } dirty = true break } res.Repository.Dirty = dirty refs, err := gitOut(ctx, runner, repoRoot, "for-each-ref", "--format=%(refname:short)|%(objectname:short)|%(upstream:short)", "refs/heads") if err != nil { res.Warnings = append(res.Warnings, err.Error()) } worktrees, _ := gitOut(ctx, runner, repoRoot, "worktree", "list", "--porcelain") wtByBranch := parseWorktrees(worktrees) remoteNote := "based on local refs; not network-refreshed" for _, line := range strings.Split(refs, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, "|") name := parts[0] sha := "" up := "" if len(parts) > 1 { sha = parts[1] } if len(parts) > 2 { up = parts[2] } ahead, behind := 0, 0 if up != "" { ab, err := gitOut(ctx, runner, repoRoot, "rev-list", "--left-right", "--count", up+"..."+name) if err == nil { fields := strings.Fields(strings.TrimSpace(ab)) if len(fields) == 2 { behind, _ = strconv.Atoi(fields[0]) ahead, _ = strconv.Atoi(fields[1]) } } } view := GitBranchView{ Name: name, IsCurrent: name == res.Repository.Branch, Head: sha, Upstream: up, Ahead: ahead, Behind: behind, Worktree: wtByBranch[name], Dirty: name == res.Repository.Branch && dirty, RemoteNote: remoteNote, ObservedAt: now, } res.Branches = append(res.Branches, view) res.Facts = append(res.Facts, Fact{ ID: FactID("git:"+name, "ahead_behind", "git"), Subject: "git:" + name, Field: "ahead_behind", Value: fmt.Sprintf("ahead=%d behind=%d", ahead, behind), Provenance: ProvenanceObserved, SourceRef: "git", ObservedAt: now, }) } res.Source = SourceState{ Name: "git", Status: SourceOK, Freshness: FreshnessFresh, ObservedAt: now, LastSuccessAt: &now, DurationMS: time.Since(start).Milliseconds(), } return res } func gitOut(ctx context.Context, runner CommandRunner, dir string, args ...string) (string, error) { stdout, stderr, err := runner.Run(ctx, dir, "git", args...) if err != nil { msg := strings.TrimSpace(stderr) if msg == "" { msg = err.Error() } return stdout, fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) } return stdout, nil } func gitFail(res GitCollectResult, start time.Time, now time.Time, err error) GitCollectResult { res.Source = SourceState{ Name: "git", Status: SourceError, Freshness: FreshnessError, ObservedAt: now, Error: err.Error(), DurationMS: time.Since(start).Milliseconds(), } return res } func parseWorktrees(porcelain string) map[string]string { out := map[string]string{} var path, branch string flush := func() { if path != "" && branch != "" { out[branch] = path } path, branch = "", "" } for _, line := range strings.Split(porcelain, "\n") { if line == "" { flush() continue } if strings.HasPrefix(line, "worktree ") { path = strings.TrimPrefix(line, "worktree ") } if strings.HasPrefix(line, "branch ") { branch = strings.TrimPrefix(line, "branch refs/heads/") } } flush() return out }