package index
import (
"path/filepath"
"testing"
)
// openIndexAt is openIndex against a chosen path, so a test can close
// an index and open the SAME file again — which is the only way to
// exercise what Open does to an index that already has history.
func openIndexAt(t *testing.T, path string) *Index {
t.Helper()
ix, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
return ix
}
// foldSymbols runs the symbol derivation the ingest fold runs, and only
// that. These fixtures write the branch column directly, so they must
// not go through recomputeBranches, which derives that column from tags
// and would blank a fixture that carries none — the shared
// foldBranches helper has the same limitation and describes itself more
// broadly than it behaves.
func foldSymbols(t *testing.T, ix *Index) {
t.Helper()
tx, err := ix.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := recomputeSymbols(tx); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
// A branch NAME is not the same thing as a check-in's branch FIELD.
//
// fossil lets a check-in carry a propagating `sym-NAME` tag for a name
// other than its own branch=, which is how a branch is renamed without
// orphaning the old name: the tip says branch=main and keeps a
// propagating sym-trunk, so both names resolve to it. fossil and zit
// both answer that way.
//
// quarry answered from checkin.branch alone — one column, one name —
// so the old name resolved to the last check-in whose branch FIELD
// still said it, years back. On the tcl mirror that is why `trunk`
// served a 2020 tree while the repository's actual trunk was current.
// Reported by jva, who reproduced it on a two-check-in repository
// rather than arguing from the big one (6833076f).
func TestARenamedBranchKeepsResolvingUnderItsOldName(t *testing.T) {
ix := openIndex(t)
// The original trunk.
ci(t, ix, "c1", "trunk", "2026-01-01T10:00:00", "first")
// The rename: branch= becomes main, and a PROPAGATING sym-trunk
// keeps the old name pointing here.
ci(t, ix, "c2", "main", "2026-01-02T10:00:00", "renamed to main", "c1")
tag(t, ix, "c2", "sym-trunk", "*", "2026-01-02T10:00:00")
foldSymbols(t, ix)
if got, err := ix.BranchTip("main"); err != nil || got != "c2" {
t.Errorf("main resolved to %q (err %v), want c2", got, err)
}
// The one that was wrong: trunk must follow the propagating tag to
// c2, not stop at the last check-in whose branch field said trunk.
got, err := ix.BranchTip("trunk")
if err != nil {
t.Fatal(err)
}
if got == "c1" {
t.Error("trunk still resolves to the pre-rename check-in: " +
"the resolver read checkin.branch and ignored the propagating sym-trunk tag")
}
if got != "c2" {
t.Errorf("trunk resolved to %q, want c2", got)
}
}
// A cancelled sym tag stops counting. Otherwise removing a name would
// be impossible and a branch could never really be renamed away.
func TestACancelledSymTagStopsResolving(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "c1", "trunk", "2026-01-01T10:00:00", "first")
ci(t, ix, "c2", "main", "2026-01-02T10:00:00", "renamed", "c1")
tag(t, ix, "c2", "sym-trunk", "*", "2026-01-02T10:00:00")
// Later artifact cancels the old name on this check-in.
tag(t, ix, "c2", "sym-trunk", "-", "2026-01-03T10:00:00")
foldSymbols(t, ix)
if got, _ := ix.BranchTip("trunk"); got == "c2" {
t.Error("a cancelled sym-trunk still resolved trunk to the check-in that dropped it")
}
}
// The ordinary case must not move: a branch with no sym games resolves
// to its newest leaf exactly as before. This is the assertion that
// protects landing tip and target resolution, which is what branchTip
// is really for.
func TestAnOrdinaryBranchStillResolvesToItsNewestLeaf(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "a1", "feature", "2026-01-01T10:00:00", "one")
ci(t, ix, "a2", "feature", "2026-01-02T10:00:00", "two", "a1")
ci(t, ix, "a3", "feature", "2026-01-03T10:00:00", "three", "a2")
foldSymbols(t, ix)
if got, err := ix.BranchTip("feature"); err != nil || got != "a3" {
t.Errorf("feature resolved to %q (err %v), want a3", got, err)
}
if got, err := ix.BranchTip("nonesuch"); err != nil || got != "" {
t.Errorf("an unknown branch resolved to %q (err %v), want empty", got, err)
}
}
// THE tcl SHAPE, and the one the first version of this fix missed.
//
// The current tip carries no sym tag of its own: it INHERITS sym-trunk
// from an ancestor, because a propagating tag applies to every
// descendant until one cancels it. Reading only the carriers answered
// with the last check-in that literally wore the tag — on the tcl
// mirror, one from 2020 — while fossil and zit both answered with the
// current tip. My first attempt passed its own test because the test
// put the tag on the tip, which is precisely the case that is not the
// bug.
func TestAnInheritedSymTagResolvesToTheCurrentTip(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "c1", "trunk", "2026-01-01T10:00:00", "start of trunk")
tag(t, ix, "c1", "sym-trunk", "*", "2026-01-01T10:00:00")
// Descendants carry nothing at all. They inherit.
ci(t, ix, "c2", "trunk", "2026-01-02T10:00:00", "more work", "c1")
ci(t, ix, "c3", "main", "2026-01-03T10:00:00", "renamed the branch", "c2")
foldSymbols(t, ix)
got, err := ix.BranchTip("trunk")
if err != nil {
t.Fatal(err)
}
if got == "c1" {
t.Fatal("trunk stopped at the check-in that CARRIES the tag; " +
"a propagating tag is inherited by descendants")
}
if got != "c3" {
t.Errorf("trunk resolved to %q, want c3 — the newest check-in inheriting sym-trunk", got)
}
}
// Inheritance stops at a cancel, and does NOT resume below it.
func TestInheritanceStopsAtACancelAndStaysStopped(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "c1", "trunk", "2026-01-01T10:00:00", "start")
tag(t, ix, "c1", "sym-trunk", "*", "2026-01-01T10:00:00")
ci(t, ix, "c2", "trunk", "2026-01-02T10:00:00", "inherits", "c1")
// c3 leaves the branch; c4 descends from c3 and must not rejoin.
ci(t, ix, "c3", "side", "2026-01-03T10:00:00", "leaves trunk", "c2")
tag(t, ix, "c3", "sym-trunk", "-", "2026-01-03T10:00:00")
ci(t, ix, "c4", "side", "2026-01-04T10:00:00", "further from trunk", "c3")
foldSymbols(t, ix)
got, _ := ix.BranchTip("trunk")
if got == "c3" || got == "c4" {
t.Errorf("trunk resolved to %q, which is at or below the cancel", got)
}
if got != "c2" {
t.Errorf("trunk resolved to %q, want c2 — the last check-in before the cancel", got)
}
}
// An index that predates checkin_sym must derive it on open, or the
// resolver is correct only on repositories that happen to be busy.
//
// This is the gap the first deploy of the derived table had: the fold
// builds it, a fold only recomputes when a batch lands something
// branch-affecting, and tcl logged "ingested 0 artifacts". So the
// table stayed empty, branchTip fell back to the branch column, and
// the fix I had just shipped did nothing at all on the repository it
// was written for.
func TestAnOlderIndexDerivesSymbolsOnOpen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "idx.db")
// An index with history and sym tags but no derived rows — what
// every existing repository looked like after the first deploy.
ix := openIndexAt(t, path)
ci(t, ix, "c1", "trunk", "2026-01-01T10:00:00", "start")
tag(t, ix, "c1", "sym-trunk", "*", "2026-01-01T10:00:00")
ci(t, ix, "c2", "main", "2026-01-02T10:00:00", "renamed", "c1")
if _, err := ix.db.Exec(`DELETE FROM checkin_sym`); err != nil {
t.Fatal(err)
}
if err := ix.Close(); err != nil {
t.Fatal(err)
}
// Reopening is the whole fix: no push, no fold, no admin action.
again := openIndexAt(t, path)
var n int
if err := again.db.QueryRow(`SELECT count(*) FROM checkin_sym`).Scan(&n); err != nil {
t.Fatal(err)
}
if n == 0 {
t.Fatal("reopening an older index derived nothing: the resolver stays wrong " +
"until something happens to push, which on a quiet mirror is never")
}
if got, err := again.BranchTip("trunk"); err != nil || got != "c2" {
t.Errorf("trunk resolved to %q (err %v), want c2", got, err)
}
}
// TipOf is the resolver the TREE PAGE asks, and it has to answer the
// same question branchTip does.
//
// Fixing branchTip alone left tcl's /tree/trunk serving a 2020 tree
// while the index resolved trunk correctly — two resolvers, one
// taught and one not. Found by comparing the page against the store
// after deploying, which is the only reason I know.
func TestTipOfFollowsAnInheritedSymTag(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "c1", "trunk", "2026-01-01T10:00:00", "start of trunk")
tag(t, ix, "c1", "sym-trunk", "*", "2026-01-01T10:00:00")
ci(t, ix, "c2", "trunk", "2026-01-02T10:00:00", "more", "c1")
ci(t, ix, "c3", "main", "2026-01-03T10:00:00", "renamed", "c2")
foldSymbols(t, ix)
b, ok, err := ix.TipOf("trunk")
if err != nil || !ok {
t.Fatalf("TipOf(trunk): ok=%v err=%v", ok, err)
}
if b.Tip == "c1" {
t.Fatal("TipOf stopped at the check-in CARRYING the tag, so the tree page " +
"serves the pre-rename tree while the resolver says otherwise")
}
if b.Tip != "c3" {
t.Errorf("TipOf(trunk) = %q, want c3", b.Tip)
}
if b.Name != "trunk" {
t.Errorf("TipOf(trunk) named the branch %q, want trunk", b.Name)
}
// Both resolvers must agree, which is the property that was broken.
if bt, err := ix.BranchTip("trunk"); err != nil || bt != b.Tip {
t.Errorf("BranchTip=%q TipOf=%q — the two resolvers disagree", bt, b.Tip)
}
}
// An ordinary branch is unchanged, and an unknown name still answers
// not-found rather than an empty row.
func TestTipOfLeavesOrdinaryBranchesAlone(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "a1", "feature", "2026-01-01T10:00:00", "one")
ci(t, ix, "a2", "feature", "2026-01-02T10:00:00", "two", "a1")
foldSymbols(t, ix)
b, ok, err := ix.TipOf("feature")
if err != nil || !ok || b.Tip != "a2" {
t.Errorf("TipOf(feature) = %q ok=%v err=%v, want a2", b.Tip, ok, err)
}
if _, ok, err := ix.TipOf("nonesuch"); ok || err != nil {
t.Errorf("TipOf(nonesuch) ok=%v err=%v, want not-found", ok, err)
}
}
// listed returns the branch summary as name -> tip.
func listed(t *testing.T, ix *Index) map[string]string {
t.Helper()
tx, err := ix.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := recomputeSymbols(tx); err != nil {
t.Fatal(err)
}
if err := refreshBranches(tx); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
bs, err := ix.Branches()
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, b := range bs {
out[b.Name] = b.Tip
}
return out
}
// DEFECT ONE: a renamed branch's listed tip froze at the last check-in
// whose branch FIELD said the old name. On tcl the selector offered
// "trunk" dated 2020 beside a link that served the current tree — the
// list disagreeing with its own link, which is how a page loses a
// reader's trust.
func TestTheListedTipFollowsAnInheritedTag(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "c1", "trunk", "2026-01-01T10:00:00", "start")
tag(t, ix, "c1", "sym-trunk", "*", "2026-01-01T10:00:00")
ci(t, ix, "c2", "trunk", "2026-01-02T10:00:00", "more", "c1")
ci(t, ix, "c3", "main", "2026-01-03T10:00:00", "renamed", "c2")
got := listed(t, ix)
if got["trunk"] == "c2" {
t.Error("the listed trunk tip stopped at the last check-in whose field said trunk")
}
if got["trunk"] != "c3" {
t.Errorf("listed trunk tip = %q, want c3", got["trunk"])
}
// And the list must agree with both resolvers, which is the whole point.
if bt, _ := ix.BranchTip("trunk"); bt != got["trunk"] {
t.Errorf("list says %q, BranchTip says %q", got["trunk"], bt)
}
if b, _, _ := ix.TipOf("trunk"); b.Tip != got["trunk"] {
t.Errorf("list says %q, TipOf says %q", got["trunk"], b.Tip)
}
}
// DEFECT TWO: a name that exists ONLY as a tag was missing from the
// list entirely, so a branch you can check out could not be found in
// the selector. jva reported this as branch.list omitting `main`.
func TestATagOnlyNameIsListed(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "c1", "trunk", "2026-01-01T10:00:00", "start")
// `release` is never any check-in's branch field; it exists purely
// as a propagating tag.
tag(t, ix, "c1", "sym-release", "*", "2026-01-01T10:00:00")
ci(t, ix, "c2", "trunk", "2026-01-02T10:00:00", "more", "c1")
got := listed(t, ix)
if _, ok := got["release"]; !ok {
t.Fatalf("a name that resolves is absent from the list: %v", got)
}
if got["release"] != "c2" {
t.Errorf("release tip = %q, want c2 (the tag propagates to c2)", got["release"])
}
if got["trunk"] != "c2" {
t.Errorf("trunk tip = %q, want c2 — ordinary listing must not change", got["trunk"])
}
}
// An ordinary repository's list is unchanged: same names, same tips.
func TestAnOrdinaryListIsUnchanged(t *testing.T) {
ix := openIndex(t)
ci(t, ix, "a1", "trunk", "2026-01-01T10:00:00", "one")
ci(t, ix, "a2", "trunk", "2026-01-02T10:00:00", "two", "a1")
ci(t, ix, "b1", "feature", "2026-01-03T10:00:00", "side", "a2")
got := listed(t, ix)
if len(got) != 2 {
t.Errorf("list has %d names, want 2: %v", len(got), got)
}
if got["trunk"] != "a2" || got["feature"] != "b1" {
t.Errorf("tips moved on an ordinary repository: %v", got)
}
}
// A summary built by the OLD fold query must be rebuilt on open.
//
// This is the third time one derived thing in this file was correct
// only on repositories busy enough to re-fold. checkin_sym had no
// backfill; then the branch SUMMARY had none either, so tcl kept
// offering "trunk" dated 2020 after both resolvers were fixed and the
// symbol table filled. A guard on "is the table empty" cannot catch
// this: the table was full, just built by a query that no longer
// exists.
func TestTheBranchSummaryIsRebuiltOnOpen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "idx.db")
ix := openIndexAt(t, path)
ci(t, ix, "c1", "trunk", "2026-01-01T10:00:00", "start")
tag(t, ix, "c1", "sym-trunk", "*", "2026-01-01T10:00:00")
ci(t, ix, "c2", "trunk", "2026-01-02T10:00:00", "more", "c1")
ci(t, ix, "c3", "main", "2026-01-03T10:00:00", "renamed", "c2")
foldSymbols(t, ix)
// A summary as the old query would have left it: trunk pinned to
// the last check-in whose branch FIELD said trunk.
if _, err := ix.db.Exec(`DELETE FROM branch`); err != nil {
t.Fatal(err)
}
if _, err := ix.db.Exec(`INSERT INTO branch (name, tip, date, closed)
VALUES ('trunk','c2','2026-01-02T10:00:00',0)`); err != nil {
t.Fatal(err)
}
if err := ix.Close(); err != nil {
t.Fatal(err)
}
again := openIndexAt(t, path)
bs, err := again.Branches()
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, b := range bs {
got[b.Name] = b.Tip
}
if got["trunk"] == "c2" {
t.Fatal("reopening left the stale summary in place: the selector keeps showing " +
"the pre-rename tip while every resolver says otherwise")
}
if got["trunk"] != "c3" {
t.Errorf("trunk tip after reopen = %q, want c3", got["trunk"])
}
if got["main"] != "c3" {
t.Errorf("main tip after reopen = %q, want c3", got["main"])
}
}