// 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 -- `) 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::` // in single-repo graphs and `release::::` 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::` 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::` shape; non-empty yields `release::::`. // // 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 "/"; 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 }