package index
import "testing"
// 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)
}
}