a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
442 lines
14 KiB
Go
442 lines
14 KiB
Go
// Package churn computes per-symbol and per-file commit density from
|
|
// the git log of a chosen branch (typically the default branch) and
|
|
// persists the result on graph nodes. Once enriched, the MCP tool
|
|
// get_churn_rate is a pure graph scan — no `git` subprocess at read
|
|
// time. The graph store is the source of truth; the disk-backed
|
|
// SQLite backend keeps the data across daemon restarts, while
|
|
// in-memory backends recompute on demand.
|
|
//
|
|
// Design notes:
|
|
//
|
|
// - We blame at an explicit rev (the default branch) rather than
|
|
// HEAD. Feature-branch work-in-progress doesn't pollute the
|
|
// persisted churn signal — the data answers "what's churning on
|
|
// main" regardless of where the agent is checked out.
|
|
//
|
|
// - Per-file blame is invoked once and projected onto every symbol
|
|
// in the file. The repo walk inside `git blame` dominates the
|
|
// cost; per-symbol invocations would multiply it by the symbol
|
|
// count.
|
|
//
|
|
// - After mutating n.Meta we re-call g.AddNode(n). The in-memory
|
|
// store treats this as a no-op (the pointer is already in the
|
|
// graph); the disk backend treats it as an UPSERT that
|
|
// re-serialises Meta to its on-disk row. This is the only path
|
|
// that persists Meta mutations to disk — without it the
|
|
// enrichment would be invisible on the next daemon restart.
|
|
package churn
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/zzet/gortex/internal/blame"
|
|
"github.com/zzet/gortex/internal/gitcmd"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// Options controls how the enricher resolves and persists churn data.
|
|
type Options struct {
|
|
// Branch is the rev to blame and log. Required — call site is
|
|
// expected to resolve the repo's default branch (origin/main,
|
|
// origin/master, …) and pass it in. We do not default to HEAD
|
|
// because the whole point of pre-computation is to pin the
|
|
// signal to a stable branch.
|
|
Branch string
|
|
// Now lets tests fix the clock for deterministic age_days. When
|
|
// zero, time.Now() is used.
|
|
Now time.Time
|
|
}
|
|
|
|
// Result summarises an enrichment pass.
|
|
type Result struct {
|
|
Files int // file nodes stamped with a churn summary
|
|
Symbols int // function/method nodes stamped with per-symbol churn
|
|
Branch string // the rev used (echoed back for the CLI)
|
|
HeadSHA string // the resolved SHA at enrich time (stored on each file)
|
|
}
|
|
|
|
// EnrichGraph computes per-symbol and per-file churn and stamps the
|
|
// data on graph nodes. Returns counts plus the resolved SHA. Errors
|
|
// only when the repo can't be opened or the branch can't be resolved
|
|
// at all; per-file failures are best-effort and skip that file.
|
|
//
|
|
// Persistence: every mutated node is re-upserted via g.AddNode(n).
|
|
// On disk-backed stores this round-trips through the store's upsert
|
|
// path; on the in-memory store the pointer was already mutated in
|
|
// place, but the redundant AddNode call keeps the semantics uniform
|
|
// between backends and lets the enricher run against either.
|
|
func EnrichGraph(ctx context.Context, g graph.Store, repoRoot string, opts Options) (Result, error) {
|
|
if g == nil || repoRoot == "" {
|
|
return Result{}, fmt.Errorf("churn: graph and repoRoot are required")
|
|
}
|
|
if strings.TrimSpace(opts.Branch) == "" {
|
|
return Result{}, fmt.Errorf("churn: Options.Branch is required (default-branch resolution belongs to the caller)")
|
|
}
|
|
now := opts.Now
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
headSHA := runGit(repoRoot, "rev-parse", "--verify", "--quiet", opts.Branch)
|
|
if headSHA == "" {
|
|
return Result{}, fmt.Errorf("churn: branch %q does not resolve in %s", opts.Branch, repoRoot)
|
|
}
|
|
|
|
// Group symbols by file path. We deliberately keep file nodes in
|
|
// a separate map so we can stamp their summary even when no
|
|
// function/method is in scope (some files contain only types or
|
|
// constants).
|
|
type bucket struct {
|
|
file *graph.Node // optional — may be nil
|
|
symbols []*graph.Node
|
|
}
|
|
byPath := map[string]*bucket{}
|
|
for _, n := range g.AllNodes() {
|
|
if n.FilePath == "" {
|
|
continue
|
|
}
|
|
switch n.Kind {
|
|
case graph.KindFile:
|
|
b := byPath[n.FilePath]
|
|
if b == nil {
|
|
b = &bucket{}
|
|
byPath[n.FilePath] = b
|
|
}
|
|
b.file = n
|
|
case graph.KindFunction, graph.KindMethod:
|
|
if n.StartLine == 0 {
|
|
continue
|
|
}
|
|
b := byPath[n.FilePath]
|
|
if b == nil {
|
|
b = &bucket{}
|
|
byPath[n.FilePath] = b
|
|
}
|
|
b.symbols = append(b.symbols, n)
|
|
}
|
|
}
|
|
|
|
res := Result{Branch: opts.Branch, HeadSHA: headSHA}
|
|
churnWriter, useChurnSidecar := g.(graph.ChurnEnrichmentWriter)
|
|
var churnRows []graph.ChurnEnrichment
|
|
for filePath, b := range byPath {
|
|
if err := ctx.Err(); err != nil {
|
|
return res, err
|
|
}
|
|
if len(b.symbols) == 0 && b.file == nil {
|
|
continue
|
|
}
|
|
rel := stripRepoPrefix(filePath, repoRoot)
|
|
commits, err := fileCommits(repoRoot, opts.Branch, rel)
|
|
if err != nil || len(commits) == 0 {
|
|
continue
|
|
}
|
|
var blameLines map[int]blame.Author
|
|
if len(b.symbols) > 0 {
|
|
blameLines, _ = blame.RunAt(repoRoot, opts.Branch, rel)
|
|
}
|
|
|
|
// File summary: aggregate across all commits.
|
|
if b.file != nil {
|
|
stampFileChurn(b.file, commits, headSHA, opts.Branch, now)
|
|
if useChurnSidecar {
|
|
churnRows = append(churnRows, churnEnrichmentFromNode(b.file))
|
|
delete(b.file.Meta, "churn")
|
|
delete(b.file.Meta, "churn_meta")
|
|
} else {
|
|
g.AddNode(b.file)
|
|
}
|
|
res.Files++
|
|
}
|
|
|
|
if len(blameLines) == 0 {
|
|
continue
|
|
}
|
|
// Per-symbol: project blame line range, then look up each
|
|
// commit's timestamp/author in the commits map. Falls back
|
|
// to blame timestamps when the commit isn't in the log
|
|
// (shallow clones, signed-off cherry-picks).
|
|
for _, s := range b.symbols {
|
|
if stampSymbolChurn(s, blameLines, commits, now) {
|
|
if useChurnSidecar {
|
|
churnRows = append(churnRows, churnEnrichmentFromNode(s))
|
|
delete(s.Meta, "churn")
|
|
} else {
|
|
g.AddNode(s)
|
|
}
|
|
res.Symbols++
|
|
}
|
|
}
|
|
}
|
|
// Sidecar persist (change A): when the backend implements
|
|
// ChurnEnrichmentWriter, churn rides in the typed churn_enrichment
|
|
// table instead of nodes.meta, so the node hot path stops gob-
|
|
// encoding it and get_churn_rate reads via an index. Grouped by
|
|
// repo prefix since BulkSetChurn stamps one prefix per call.
|
|
if useChurnSidecar && len(churnRows) > 0 {
|
|
byPrefix := map[string][]graph.ChurnEnrichment{}
|
|
for _, r := range churnRows {
|
|
byPrefix[r.RepoPrefix] = append(byPrefix[r.RepoPrefix], r)
|
|
}
|
|
for prefix, rr := range byPrefix {
|
|
if err := churnWriter.BulkSetChurn(prefix, rr); err != nil {
|
|
return res, fmt.Errorf("churn: persist sidecar: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return res, nil
|
|
}
|
|
|
|
// commitRecord is one row of `git log --format=%H|%ct|%ae`.
|
|
type commitRecord struct {
|
|
SHA string
|
|
When time.Time
|
|
Email string
|
|
}
|
|
|
|
// fileCommits returns the commit history for relPath on branch.
|
|
// Ordered newest → oldest. Empty slice when the file has no history
|
|
// on that branch (untracked, or the rev predates the file).
|
|
func fileCommits(repoRoot, branch, relPath string) ([]commitRecord, error) {
|
|
out, err := gitcmd.Run(context.Background(), repoRoot, "log", branch,
|
|
"--no-merges", "--follow", "--format=%H|%ct|%ae", "--", relPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var records []commitRecord
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
scanner.Buffer(make([]byte, 64*1024), 8*1024*1024)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(line, "|", 3)
|
|
if len(parts) != 3 {
|
|
continue
|
|
}
|
|
ts, err := strconv.ParseInt(parts[1], 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
records = append(records, commitRecord{
|
|
SHA: parts[0],
|
|
When: time.Unix(ts, 0),
|
|
Email: parts[2],
|
|
})
|
|
}
|
|
return records, scanner.Err()
|
|
}
|
|
|
|
// churnEnrichmentFromNode projects the freshly-stamped Meta["churn"] /
|
|
// Meta["churn_meta"] payload into a typed ChurnEnrichment row for the
|
|
// sidecar. The stamp functions write int/float64 directly (no JSON
|
|
// widening at this point), so the type assertions are exact.
|
|
func churnEnrichmentFromNode(n *graph.Node) graph.ChurnEnrichment {
|
|
e := graph.ChurnEnrichment{NodeID: n.ID, RepoPrefix: n.RepoPrefix}
|
|
if m, ok := n.Meta["churn"].(map[string]any); ok {
|
|
if v, ok := m["commit_count"].(int); ok {
|
|
e.CommitCount = v
|
|
}
|
|
if v, ok := m["age_days"].(int); ok {
|
|
e.AgeDays = v
|
|
}
|
|
if v, ok := m["churn_rate"].(float64); ok {
|
|
e.ChurnRate = v
|
|
}
|
|
e.LastAuthor, _ = m["last_author"].(string)
|
|
e.LastCommitAt, _ = m["last_commit_at"].(string)
|
|
}
|
|
if m, ok := n.Meta["churn_meta"].(map[string]any); ok {
|
|
e.HeadSHA, _ = m["head_sha"].(string)
|
|
e.Branch, _ = m["branch"].(string)
|
|
e.ComputedAt, _ = m["computed_at"].(string)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// stampFileChurn writes the file-level summary onto n.Meta["churn"]
|
|
// and pins enrichment provenance under n.Meta["churn_meta"].
|
|
func stampFileChurn(n *graph.Node, commits []commitRecord, headSHA, branch string, now time.Time) {
|
|
if n.Meta == nil {
|
|
n.Meta = map[string]any{}
|
|
}
|
|
commitCount := len(commits)
|
|
first := commits[len(commits)-1].When
|
|
last := commits[0].When
|
|
ageDays := int(now.Sub(first).Hours() / 24)
|
|
activeDays := ageDays
|
|
if activeDays < 1 {
|
|
activeDays = 1
|
|
}
|
|
n.Meta["churn"] = map[string]any{
|
|
"commit_count": commitCount,
|
|
"age_days": ageDays,
|
|
"churn_rate": roundTwo(float64(commitCount) / float64(activeDays)),
|
|
"last_author": commits[0].Email,
|
|
"last_commit_at": last.UTC().Format(time.RFC3339),
|
|
}
|
|
n.Meta["churn_meta"] = map[string]any{
|
|
"head_sha": headSHA,
|
|
"branch": branch,
|
|
"computed_at": now.UTC().Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
// stampSymbolChurn projects the file's blame onto the symbol's line
|
|
// range and stamps n.Meta["churn"]. Returns true when the symbol's
|
|
// range had at least one blamed line — false when blame produced no
|
|
// coverage (uncommitted lines or the file is untracked at the rev).
|
|
func stampSymbolChurn(n *graph.Node, blameLines map[int]blame.Author, commits []commitRecord, now time.Time) bool {
|
|
endLine := n.EndLine
|
|
if endLine == 0 {
|
|
endLine = n.StartLine
|
|
}
|
|
commitsSeen := map[string]struct{}{}
|
|
var oldest, newest time.Time
|
|
latestEmail := ""
|
|
for line := n.StartLine; line <= endLine; line++ {
|
|
a, ok := blameLines[line]
|
|
if !ok {
|
|
continue
|
|
}
|
|
commitsSeen[a.Commit] = struct{}{}
|
|
if oldest.IsZero() || a.Timestamp.Before(oldest) {
|
|
oldest = a.Timestamp
|
|
}
|
|
if newest.IsZero() || a.Timestamp.After(newest) {
|
|
newest = a.Timestamp
|
|
latestEmail = a.Email
|
|
}
|
|
}
|
|
if len(commitsSeen) == 0 {
|
|
return false
|
|
}
|
|
// Prefer the canonical author email from the log over the blame
|
|
// author email when both exist — `git log` carries the merged-in
|
|
// author identity, while blame may show the original
|
|
// pre-rebase author.
|
|
if email := latestAuthorFromCommits(commitsSeen, commits); email != "" {
|
|
latestEmail = email
|
|
}
|
|
ageDays := 0
|
|
if !oldest.IsZero() {
|
|
ageDays = int(now.Sub(oldest).Hours() / 24)
|
|
}
|
|
activeDays := ageDays
|
|
if activeDays < 1 {
|
|
activeDays = 1
|
|
}
|
|
if n.Meta == nil {
|
|
n.Meta = map[string]any{}
|
|
}
|
|
n.Meta["churn"] = map[string]any{
|
|
"commit_count": len(commitsSeen),
|
|
"age_days": ageDays,
|
|
"churn_rate": roundTwo(float64(len(commitsSeen)) / float64(activeDays)),
|
|
"last_author": latestEmail,
|
|
"last_commit_at": newest.UTC().Format(time.RFC3339),
|
|
}
|
|
return true
|
|
}
|
|
|
|
// latestAuthorFromCommits picks the email of the most-recent commit
|
|
// that touches the symbol's range, using the per-file log as the
|
|
// authority for author identity (blame can lag a rebase / cherry-pick).
|
|
func latestAuthorFromCommits(commitsSeen map[string]struct{}, commits []commitRecord) string {
|
|
for _, c := range commits {
|
|
if _, ok := commitsSeen[c.SHA]; ok {
|
|
return c.Email
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// roundTwo rounds to two decimals so the JSON output stays compact
|
|
// — single-digit precision swallows the difference between 0.03 and
|
|
// 0.04 churn-per-day, which matters for ranking.
|
|
func roundTwo(v float64) float64 {
|
|
return float64(int64(v*100+0.5)) / 100
|
|
}
|
|
|
|
// stripRepoPrefix removes a leading repo segment from multi-repo
|
|
// indexer paths so the path we hand to git is repo-relative. Mirrors
|
|
// the helper in internal/blame; duplicated rather than exported
|
|
// because the blame copy is unexported by design.
|
|
func stripRepoPrefix(filePath, repoRoot string) string {
|
|
if !strings.Contains(filePath, "/") {
|
|
return filePath
|
|
}
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
return filePath
|
|
}
|
|
abs := filepath.Join(repoRoot, filePath)
|
|
if fileExists(abs) {
|
|
return filePath
|
|
}
|
|
if idx := strings.Index(filePath, "/"); idx >= 0 {
|
|
trimmed := filePath[idx+1:]
|
|
if fileExists(filepath.Join(repoRoot, trimmed)) {
|
|
return trimmed
|
|
}
|
|
}
|
|
return filePath
|
|
}
|
|
|
|
var fileExists = func(path string) bool {
|
|
cmd := exec.Command("test", "-f", path)
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
// runGit shells out and returns trimmed stdout, or "" on error. Used
|
|
// only for the one-shot rev-parse; full enrichment calls go through
|
|
// fileCommits / blame.RunAt directly.
|
|
func runGit(repoRoot string, args ...string) string {
|
|
out, err := gitcmd.Output(context.Background(), repoRoot, args...)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DefaultBranch returns the repository's default branch as a
|
|
// rev-parseable reference (preferring "origin/<name>" when an upstream
|
|
// is configured, falling back to a local branch when not). Returns ""
|
|
// when none of the candidates resolve — the caller is then expected
|
|
// to surface a clear error rather than silently picking the current
|
|
// branch (feature branches must not pollute the persisted data).
|
|
//
|
|
// Exposed so MCP-side enrich handlers can resolve the same branch
|
|
// the CLI does without duplicating the probe order across packages.
|
|
func DefaultBranch(repoRoot string) string {
|
|
probe := func(args ...string) (string, bool) {
|
|
out, err := gitcmd.Output(context.Background(), repoRoot, args...)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
return out, true
|
|
}
|
|
if ref, ok := probe("symbolic-ref", "--short", "refs/remotes/origin/HEAD"); ok && ref != "" {
|
|
return ref
|
|
}
|
|
for _, candidate := range []string{"origin/main", "origin/master", "origin/trunk"} {
|
|
if _, ok := probe("rev-parse", "--verify", "--quiet", candidate); ok {
|
|
return candidate
|
|
}
|
|
}
|
|
for _, candidate := range []string{"main", "master", "trunk"} {
|
|
if _, ok := probe("rev-parse", "--verify", "--quiet", candidate); ok {
|
|
return candidate
|
|
}
|
|
}
|
|
return ""
|
|
}
|