Skip to content
quarry Artifact [7183c37844]
Login

Artifact [7183c37844]

Artifact 7183c3784406bda9a48754ae24c61eb552a1077f82839c78c1876e8acc7ee53c:


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)
	}
}