Files
Agent-System/internal/engineeringdashboard/source_git.go
T
weichenandCursor a07ba1065b feat(engineering-dashboard): U2-U4 docs git and runtime collectors
Add allowlisted markdown indexing, read-only git plumbing via injected
runners, and docker/readiness collection with path and loopback guards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 07:03:32 +00:00

161 lines
4.2 KiB
Go

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
}