package index
import (
"slices"
"strings"
"testing"
)
// pathFixture is a small history with a branch:
//
// trunk c1 ── c2 ────────── c3(merge) ── c5
// \ /
// branch b1 ──────── b2
//
// a.go is added in c1, changed on the branch in b1, and changed again in
// the merge c3. c2 and b2 change other files. dir/ gains a file in c1
// and another in c5.
func pathFixture(t *testing.T) *Index {
t.Helper()
ix := openIndex(t)
type ci struct {
hash, date, branch string
parents, files []string
}
for _, c := range []ci{
{"c1", "2026-01-01T00:00:01", "trunk", nil, []string{"a.go", "dir/x"}},
{"b1", "2026-01-01T00:00:02", "br", []string{"c1"}, []string{"a.go"}},
{"c2", "2026-01-01T00:00:03", "trunk", []string{"c1"}, []string{"b.txt"}},
{"b2", "2026-01-01T00:00:04", "br", []string{"b1"}, []string{"c.txt"}},
{"c3", "2026-01-01T00:00:05", "trunk", []string{"c2", "b2"}, []string{"a.go", "c.txt"}},
{"c5", "2026-01-01T00:00:06", "trunk", []string{"c3"}, []string{"dir/y"}},
} {
// A trigger writes the check-in's event row.
if _, err := ix.w.Exec(`INSERT INTO checkin (hash, date, user, comment, branch) VALUES (?, ?, 'dan', ?, ?)`,
c.hash, c.date, "made "+c.hash, c.branch); err != nil {
t.Fatal(err)
}
for i, p := range c.parents {
if _, err := ix.w.Exec(`INSERT INTO checkin_parent (child, parent, ord) VALUES (?, ?, ?)`, c.hash, p, i); err != nil {
t.Fatal(err)
}
}
for _, e := range ancestorPaths(c.files) {
if _, err := ix.w.Exec(`INSERT INTO filechange (path, checkin, date, dir) VALUES (?, ?, ?, ?)`,
e.Path, c.hash, c.date, e.Dir); err != nil {
t.Fatal(err)
}
}
}
return ix
}
func hashesOf(es []TimelineEntry) []string {
var out []string
for _, e := range es {
out = append(out, e.Hash)
}
return out
}
// A file's history lists the check-ins that changed it, newest first,
// and nothing else — fossil's file history, read as a timeline.
func TestTimelinePathListsTheCheckinsThatChangedIt(t *testing.T) {
ix := pathFixture(t)
for _, c := range []struct {
path string
want []string
}{
{"a.go", []string{"c3", "b1", "c1"}},
{"/a.go", []string{"c3", "b1", "c1"}}, // a slash either side is the same path
{"dir", []string{"c5", "c1"}}, // a folder: anything under it
{"dir/", []string{"c5", "c1"}},
{"nope", nil},
} {
got, _, err := ix.Timeline(TimelineFilter{Limit: 50, Path: c.path})
if err != nil {
t.Fatalf("%s: %v", c.path, err)
}
if !slices.Equal(hashesOf(got), c.want) {
t.Errorf("%s: got %v, want %v", c.path, hashesOf(got), c.want)
}
}
}
// The graph joins each version of the path to the one before it along
// each parent line: the merge c3 joins c1 (through c2, which did not
// touch a.go) and b1 (through b2), so the branch forks off and joins
// back instead of the line breaking wherever an unrelated check-in fell.
func TestTimelinePathParentsAreThePathsOwnVersions(t *testing.T) {
ix := pathFixture(t)
got, _, err := ix.Timeline(TimelineFilter{Limit: 50, Path: "a.go"})
if err != nil {
t.Fatal(err)
}
want := map[string][]string{"c3": {"c1", "b1"}, "b1": {"c1"}, "c1": nil}
for _, e := range got {
if !slices.Equal(e.Parents, want[e.Hash]) {
t.Errorf("%s parents: got %v, want %v", e.Hash, e.Parents, want[e.Hash])
}
}
// Children are the path's too: c1's real children include c2, which
// never touched a.go, and counting it drew a line up off the page.
children := map[string]int{"c3": 0, "b1": 1, "c1": 2}
for _, e := range got {
if e.Children != children[e.Hash] {
t.Errorf("%s children: got %d, want %d", e.Hash, e.Children, children[e.Hash])
}
}
// The merge is still a merge: the badge reads the check-in's own
// parents, not the path's.
if !got[0].IsMerge {
t.Error("c3 lost its merge flag")
}
}
// Paging a path's history by cursor visits each check-in once, and a
// branch filter narrows it as on the whole timeline.
func TestTimelinePathPagesAndFilters(t *testing.T) {
ix := pathFixture(t)
var seen []string
f := TimelineFilter{Limit: 1, Path: "a.go"}
for range 5 {
page, more, err := ix.Timeline(f)
if err != nil {
t.Fatal(err)
}
seen = append(seen, hashesOf(page)...)
if !more {
break
}
f.Before = page[len(page)-1].Date + "~" + page[len(page)-1].Hash
}
if !slices.Equal(seen, []string{"c3", "b1", "c1"}) {
t.Errorf("paged: got %v", seen)
}
got, _, err := ix.Timeline(TimelineFilter{Limit: 50, Path: "a.go", Branch: "br"})
if err != nil {
t.Fatal(err)
}
if !slices.Equal(hashesOf(got), []string{"b1"}) {
t.Errorf("branch br: got %v", hashesOf(got))
}
}
func TestPathKind(t *testing.T) {
ix := pathFixture(t)
for path, want := range map[string]string{"a.go": "file", "dir": "dir", "dir/": "dir", "nope": ""} {
got, err := ix.PathKind(path)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Errorf("PathKind(%q) = %q, want %q", path, got, want)
}
}
}
// A path's history is walked from filechange_by_path, not by reading
// every event in the repository looking for the few that changed it.
func TestTimelinePathWalksFilechange(t *testing.T) {
ix := openIndex(t)
q, args, ok, err := ix.timelineQuery(TimelineFilter{Limit: 50, Path: "a.go"})
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
plan := queryPlan(t, ix, q, args...)
if !strings.Contains(plan, "filechange") || strings.Contains(plan, "SCAN e") {
t.Errorf("path timeline does not walk filechange by path:\n%s", plan)
}
}