zzet--gortex
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
241 行
8.3 KiB
Go
241 行
8.3 KiB
Go
// Package releases populates per-file release metadata from git
|
|
// tag history. Each file in the graph receives meta.added_in = the
|
|
// earliest tag whose tree contained that file path. Symbols
|
|
// inherit indirectly through their file — agents asking "added
|
|
// in v1.4?" walk to the file node first.
|
|
//
|
|
// Design choices:
|
|
//
|
|
// - One `git ls-tree -r` per tag rather than per file. Tag tree
|
|
// enumeration is fast (O(tag-tree-size) and amortised by git's
|
|
// packfile structure); the per-file equivalent (`git log
|
|
// --reverse -- <file>`) would multiply runtime by the file
|
|
// count.
|
|
//
|
|
// - Earliest-tag-wins policy: walk tags in ascending creator-
|
|
// date order, mark each file's added_in only on first sight.
|
|
// A file deleted then re-added later in history thus shows the
|
|
// first-add tag, which matches what an agent asking "when did
|
|
// this code first ship?" expects.
|
|
//
|
|
// - Tags without a file present produce no entry — releases that
|
|
// happened before the file existed are not relevant to it.
|
|
package releases
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/gitcmd"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// ListTags returns the repo's tags ordered by creator date, oldest
|
|
// first. Empty slice when the repo has no tags or git is
|
|
// unavailable. Errors silently produce an empty list — releases
|
|
// enrichment is best-effort like blame.
|
|
func ListTags(repoRoot string) []string {
|
|
return ListTagsOnBranch(repoRoot, "")
|
|
}
|
|
|
|
// ListTagsOnBranch is ListTags scoped to tags reachable from `branch`.
|
|
// Empty branch means "every tag in the repo", matching ListTags.
|
|
//
|
|
// Restricting to a single branch is the canonical defence against
|
|
// feature-branch tags polluting the release timeline: tags that were
|
|
// only ever pushed on a topic branch (a "v0.0.0-test" tag from a
|
|
// rebase scratch, for instance) shouldn't appear in the persisted
|
|
// release order. Pass the repo's default branch ("origin/main",
|
|
// "main", …) when callers want that semantic.
|
|
func ListTagsOnBranch(repoRoot, branch string) []string {
|
|
args := []string{"for-each-ref",
|
|
"--sort=creatordate", "--format=%(refname:short)"}
|
|
if strings.TrimSpace(branch) != "" {
|
|
args = append(args, "--merged="+branch)
|
|
}
|
|
args = append(args, "refs/tags/")
|
|
out, err := gitcmd.Run(context.Background(), repoRoot, args...)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var tags []string
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
for scanner.Scan() {
|
|
t := strings.TrimSpace(scanner.Text())
|
|
if t != "" {
|
|
tags = append(tags, t)
|
|
}
|
|
}
|
|
return tags
|
|
}
|
|
|
|
// FilesAtTag returns every tracked file path present in the tag's
|
|
// tree. Paths use forward slashes (git's convention regardless of
|
|
// OS) and are repo-relative. Errors return nil so the enrichment
|
|
// loop can continue past tags with broken refs.
|
|
func FilesAtTag(repoRoot, tag string) []string {
|
|
out, err := gitcmd.Run(context.Background(), repoRoot, "ls-tree", "-r", "--name-only", tag)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var files []string
|
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
|
scanner.Buffer(make([]byte, 64*1024), 16*1024*1024)
|
|
for scanner.Scan() {
|
|
f := strings.TrimSpace(scanner.Text())
|
|
if f != "" {
|
|
files = append(files, f)
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
// ReleaseNodeID returns the canonical KindRelease node ID for the
|
|
// given tag, scoped to a repo prefix in multi-repo mode. ID convention
|
|
// matches the schema docstring on graph.KindRelease: `release::<tag>`
|
|
// in single-repo graphs and `release::<repo>::<tag>` once a prefix is
|
|
// in play. Exported so the resolver / analyzers can construct the same
|
|
// ID without re-deriving the convention.
|
|
func ReleaseNodeID(repoPrefix, tag string) string {
|
|
if repoPrefix == "" {
|
|
return "release::" + tag
|
|
}
|
|
return "release::" + repoPrefix + "::" + tag
|
|
}
|
|
|
|
// EnrichGraph walks the repo's tags chronologically and stamps
|
|
// meta.added_in on file nodes, plus materialises one KindRelease
|
|
// node per tag so the schema's `release::<tag>` ID convention is
|
|
// queryable. Returns the number of file nodes enriched. The
|
|
// single-pass design (one ls-tree per tag, mark on first sight) is
|
|
// the right shape — re-running adjusts existing meta.added_in only
|
|
// when the tree contents have changed.
|
|
//
|
|
// repoPrefix scopes the materialised KindRelease IDs in multi-repo
|
|
// graphs so two repos that both ship a "v1.0" tag don't collide on
|
|
// the same node ID. Empty repoPrefix yields the single-repo
|
|
// `release::<tag>` shape; non-empty yields `release::<prefix>::<tag>`.
|
|
//
|
|
// Errors from individual git invocations are tolerated — a broken
|
|
// ref shouldn't kill enrichment for the rest of the tag set.
|
|
func EnrichGraph(g graph.Store, repoRoot string) (int, error) {
|
|
return EnrichGraphWithRepoPrefix(g, repoRoot, "")
|
|
}
|
|
|
|
// EnrichGraphWithRepoPrefix is the multi-repo-aware variant of
|
|
// EnrichGraph. EnrichGraph delegates to it with an empty prefix; the
|
|
// multi-repo enricher passes the per-repo prefix so KindRelease IDs
|
|
// stay collision-free across repos.
|
|
//
|
|
// Walks every tag in the repo. Use EnrichGraphForBranch when callers
|
|
// want to restrict the timeline to tags reachable from a specific
|
|
// branch — typically the default branch — so topic-branch tags don't
|
|
// pollute the persisted history.
|
|
func EnrichGraphWithRepoPrefix(g graph.Store, repoRoot, repoPrefix string) (int, error) {
|
|
return EnrichGraphForBranch(g, repoRoot, repoPrefix, "")
|
|
}
|
|
|
|
// EnrichGraphForBranch is EnrichGraphWithRepoPrefix scoped to tags
|
|
// reachable from `branch`. Empty branch means "every tag", matching
|
|
// the legacy behaviour. Mutations round-trip through g.AddNode so
|
|
// disk-backed stores persist the result.
|
|
func EnrichGraphForBranch(g graph.Store, repoRoot, repoPrefix, branch string) (int, error) {
|
|
if g == nil || repoRoot == "" {
|
|
return 0, nil
|
|
}
|
|
tags := ListTagsOnBranch(repoRoot, branch)
|
|
if len(tags) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
// Build a fast index from repo-relative file path → first
|
|
// containing tag. The repo-prefix-aware match logic later
|
|
// strips multi-repo prefixes when applying meta. We also
|
|
// remember every tag's contributing file count so the
|
|
// KindRelease nodes can carry size metadata cheaply.
|
|
addedIn := make(map[string]string, 1024)
|
|
tagFileCount := make(map[string]int, len(tags))
|
|
for _, tag := range tags {
|
|
files := FilesAtTag(repoRoot, tag)
|
|
tagFileCount[tag] = len(files)
|
|
for _, f := range files {
|
|
if _, ok := addedIn[f]; !ok {
|
|
addedIn[f] = tag
|
|
}
|
|
}
|
|
}
|
|
if len(addedIn) == 0 {
|
|
return 0, fmt.Errorf("no files in any tag — empty repo or invalid refs?")
|
|
}
|
|
|
|
// Materialise one KindRelease node per tag. Idempotent — graph
|
|
// AddNode dedupes by ID — and incremental-safe: re-running with
|
|
// a fresh tag set adds the new tag nodes without disturbing
|
|
// existing ones. Indexed-position metadata lets agents ask
|
|
// "what changed in v1.2?" with a single graph query.
|
|
for i, tag := range tags {
|
|
node := &graph.Node{
|
|
ID: ReleaseNodeID(repoPrefix, tag),
|
|
Kind: graph.KindRelease,
|
|
Name: tag,
|
|
RepoPrefix: repoPrefix,
|
|
Meta: map[string]any{
|
|
"tag": tag,
|
|
"file_count": tagFileCount[tag],
|
|
"order": i, // 0 = oldest, len-1 = newest
|
|
},
|
|
}
|
|
g.AddNode(node)
|
|
}
|
|
|
|
enriched := 0
|
|
relWriter, useRelSidecar := g.(graph.ReleaseEnrichmentWriter)
|
|
var relRows []graph.ReleaseEnrichment
|
|
for _, n := range g.AllNodes() {
|
|
if n.Kind != graph.KindFile {
|
|
continue
|
|
}
|
|
if n.FilePath == "" {
|
|
continue
|
|
}
|
|
// Multi-repo file paths look like "<repo-name>/<rel>"; the
|
|
// addedIn index keys on the repo-relative form. Try the
|
|
// untrimmed path first, then strip the leading segment.
|
|
path := n.FilePath
|
|
tag, ok := addedIn[path]
|
|
if !ok {
|
|
if idx := strings.Index(path, "/"); idx >= 0 {
|
|
if t, ok2 := addedIn[path[idx+1:]]; ok2 {
|
|
tag = t
|
|
ok = true
|
|
}
|
|
}
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
if useRelSidecar {
|
|
relRows = append(relRows, graph.ReleaseEnrichment{NodeID: n.ID, AddedIn: tag})
|
|
} else {
|
|
if n.Meta == nil {
|
|
n.Meta = map[string]any{}
|
|
}
|
|
n.Meta["added_in"] = tag
|
|
// Re-upsert so disk-backed stores persist the Meta change.
|
|
g.AddNode(n)
|
|
}
|
|
enriched++
|
|
}
|
|
// Sidecar persist (change A): release "added_in" rides in the typed
|
|
// release_enrichment table when the backend supports it.
|
|
if useRelSidecar && len(relRows) > 0 {
|
|
if err := relWriter.BulkSetReleases(repoPrefix, relRows); err != nil {
|
|
return enriched, fmt.Errorf("releases: persist sidecar: %w", err)
|
|
}
|
|
}
|
|
return enriched, nil
|
|
}
|