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>
161 lines
4.2 KiB
Go
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
|
|
}
|