package index
import (
"craftdesign.group/quarry/internal/idn"
"fmt"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/artifact"
)
// ciu is ci with an explicit author. The shared helper hardcodes one
// user, and every claim in this file is about attributing work to the
// right person.
func ciu(t *testing.T, ix *Index, hash, user, branch, date string, parents ...string) {
t.Helper()
if _, err := ix.db.Exec(`INSERT OR REPLACE INTO checkin (hash, date, user, comment, branch)
VALUES (?, ?, ?, '', ?)`, hash, date, user, branch); err != nil {
t.Fatal(err)
}
for i, p := range parents {
if _, err := ix.db.Exec(`INSERT OR REPLACE INTO checkin_parent (child, parent, ord)
VALUES (?, ?, ?)`, hash, p, i); err != nil {
t.Fatal(err)
}
}
}
// changed runs the real fold so the dir flag under test is the one
// production writes, not one the test invented.
func changed(t *testing.T, ix *Index, hash, date string, paths ...string) {
t.Helper()
if err := ix.FoldChangesBulk([]BulkChanges{{
Hash: hash, Art: &artifact.Artifact{Date: date}, Changes: paths,
}}); err != nil {
t.Fatal(err)
}
}
func release(t *testing.T, ix *Index, hash, date, name string) {
t.Helper()
if _, err := ix.db.Exec(`INSERT OR REPLACE INTO checkin_tag (src, date, checkin, name, sign, value)
VALUES (?, ?, ?, ?, '+', '')`, hash, date, hash, name); err != nil {
t.Fatal(err)
}
}
// mergeDAG: dan writes one file on trunk, eve writes one on a branch,
// dan merges. The merge's manifest carries three paths — two of them
// eve's work, one of them a file neither of them touched in the window
// — which is the shape that inflates a merger's footprint on every
// forge that counts them.
func mergeDAG(t *testing.T) *Index {
t.Helper()
ix := openIndex(t)
ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
ciu(t, ix, "b1", "eve", "feat", "2026-06-02T10:00:00", "a1")
ciu(t, ix, "m1", "dan", "trunk", "2026-06-03T11:00:00", "a1", "b1")
changed(t, ix, "a1", "2026-06-01T09:00:00", "src/one.go")
changed(t, ix, "b1", "2026-06-02T10:00:00", "src/two.go")
changed(t, ix, "m1", "2026-06-03T11:00:00", "src/two.go", "src/three.go", "doc/four.md")
return ix
}
func june(t *testing.T, ix *Index, q ActivityQuery) Activity {
t.Helper()
q.Since, q.Until = "2026-06-01", "2026-06-30"
got, err := ix.Activity(q)
if err != nil {
t.Fatal(err)
}
return got
}
func rowFor(t *testing.T, a Activity, hash string) ActivityRow {
t.Helper()
for _, r := range a.Rows {
if r.Hash == hash {
return r
}
}
t.Fatalf("no row for %s in %+v", hash, a.Rows)
return ActivityRow{}
}
func authorFor(t *testing.T, as []ActivityAuthor, name string) ActivityAuthor {
t.Helper()
for _, a := range as {
if a.Name == name {
return a
}
}
t.Fatalf("no author %s in %+v", name, as)
return ActivityAuthor{}
}
// The invariant the whole feature rests on: a merge's file list is the
// absorbed branch's work. It counts as a check-in — it happened — and
// as no files at all.
func TestActivityMergeFilesAreInheritedNotAuthored(t *testing.T) {
got := june(t, mergeDAG(t), ActivityQuery{})
if len(got.Rows) != 3 {
t.Fatalf("got %d rows, want 3: %+v", len(got.Rows), got.Rows)
}
m := rowFor(t, got, "m1")
if m.Merge != 1 {
t.Fatalf("m1 Merge = %d, want 1 — a second parent is the whole test", m.Merge)
}
if m.Files != 0 {
t.Fatalf("m1 Files = %d, want 0 — the merge carried three inherited paths", m.Files)
}
if r := rowFor(t, got, "a1"); r.Merge != 0 || r.Files != 1 {
t.Fatalf("a1 = %+v, want Merge 0 Files 1", r)
}
// Five leaf touches: one each for a1 and b1, three inherited by m1.
if len(got.Touches) != 5 {
t.Fatalf("got %d touches, want 5: %+v", len(got.Touches), got.Touches)
}
inherited := 0
for _, tc := range got.Touches {
if tc.Inherited == 1 {
inherited++
}
}
if inherited != 3 {
t.Fatalf("%d inherited touches, want 3", inherited)
}
dan := authorFor(t, got.Authors, "dan")
if dan.Checkins != 2 {
t.Fatalf("dan Checkins = %d, want 2 — the merge is still a check-in", dan.Checkins)
}
if dan.Files != 1 {
t.Fatalf("dan Files = %d, want 1 — the merge's three paths are eve's work", dan.Files)
}
if eve := authorFor(t, got.Authors, "eve"); eve.Checkins != 1 || eve.Files != 1 {
t.Fatalf("eve = %+v, want Checkins 1 Files 1", eve)
}
if got.Authors[0].Name != "dan" {
t.Fatalf("authors not ranked by check-ins: %+v", got.Authors)
}
}
// Directories are in filechange so a directory's last change is one
// lookup. They are not files, and the tree axis is files.
func TestActivityExcludesDirectoryRows(t *testing.T) {
got := june(t, mergeDAG(t), ActivityQuery{})
for _, p := range got.Paths {
if p == "src" || p == "doc" {
t.Fatalf("directory %q reached Paths: %v", p, got.Paths)
}
}
// src/three.go and doc/four.md were only ever inherited by the
// merge, so they fold into their directories' rows (below); the
// folded row sorts first in its directory.
want := []string{"doc/", "src/", "src/one.go", "src/two.go"}
if len(got.Paths) != len(want) {
t.Fatalf("paths = %v, want %v", got.Paths, want)
}
for i := range want {
if got.Paths[i] != want[i] {
t.Fatalf("paths = %v, want %v — sorted by (dirname, lowercase path)", got.Paths, want)
}
}
}
// A path nobody authored in the window — every touch of it came through
// a merge — is one row per directory, named "dir/", with one inherited
// touch per merge that brought files into that directory. A vendored
// tree landing by merge is then one ghost on one row, not a row per
// file. Paths someone did author keep their own row; the merge's touch
// of that path stays on it.
func TestActivityInheritedOnlyPathsFoldIntoDirectoryRows(t *testing.T) {
ix := mergeDAG(t)
// A second merge carries a whole vendored tree nobody authored.
ciu(t, ix, "m2", "dan", "trunk", "2026-06-05T12:00:00", "m1", "b1")
changed(t, ix, "m2", "2026-06-05T12:00:00",
"compat/zlib/adler32.c", "compat/zlib/crc32.c", "compat/zlib/zlib.h", "src/three.go")
got := june(t, ix, ActivityQuery{})
want := []string{"compat/", "doc/", "src/", "src/one.go", "src/two.go"}
if strings.Join(got.Paths, " ") != strings.Join(want, " ") {
t.Fatalf("paths = %v, want %v", got.Paths, want)
}
idx := map[string]int{}
for i, p := range got.Paths {
idx[p] = i
}
m2 := rowFor(t, got, "m2")
var zlib, srcFold, two int
for _, tc := range got.Touches {
switch {
case tc.Path == idx["compat/"]:
zlib++
if tc.Inherited != 1 || tc.Day != m2.Day {
t.Fatalf("compat/ touch %+v, want inherited on m2's day", tc)
}
case tc.Path == idx["src/"]:
srcFold++
case tc.Path == idx["src/two.go"]:
two++
}
}
if zlib != 1 {
t.Fatalf("compat/ has %d touches, want 1 — three files fold to one ghost", zlib)
}
if srcFold != 2 {
t.Fatalf("src/ has %d touches, want 2 — m1 and m2 each inherited src/three.go", srcFold)
}
if two != 2 {
t.Fatalf("src/two.go has %d touches, want 2 — authored by b1, inherited by m1", two)
}
}
// A sweep — one check-in touching sweepFiles or more paths — is not
// work on each of those files. Files touched only by sweeps fold like
// inherited ones; the sweep is one authored touch on the folded row.
// A file the sweep touched that someone also edited keeps its row.
func TestActivitySweptPathsFoldIntoDirectoryRows(t *testing.T) {
ix := mergeDAG(t)
var ps []string
for i := 0; i < sweepFiles; i++ {
ps = append(ps, fmt.Sprintf("vendor/lib/sub%d/f%02d.c", i%3, i))
}
ps = append(ps, "src/one.go")
ciu(t, ix, "s1", "dan", "trunk", "2026-06-05T12:00:00", "m1")
changed(t, ix, "s1", "2026-06-05T12:00:00", ps...)
got := june(t, ix, ActivityQuery{})
// vendor/ has no authored file anywhere beneath it, so its three
// swept subdirectories roll up to one row.
want := []string{"doc/", "src/", "src/one.go", "src/two.go", "vendor/"}
if strings.Join(got.Paths, " ") != strings.Join(want, " ") {
t.Fatalf("paths = %v, want %v", got.Paths, want)
}
s1 := rowFor(t, got, "s1")
if s1.Files != sweepFiles+1 {
t.Fatalf("s1 Files = %d, want %d — the fold changes rows, not counts", s1.Files, sweepFiles+1)
}
var vendor, one int
for _, tc := range got.Touches {
if tc.Day != s1.Day {
continue
}
switch got.Paths[tc.Path] {
case "vendor/":
vendor++
if tc.Inherited != 0 {
t.Fatalf("sweep touch marked inherited: %+v", tc)
}
case "src/one.go":
one++
}
}
if vendor != 1 || one != 1 {
t.Fatalf("s1 touches: vendor/ %d, src/one.go %d; want 1 and 1", vendor, one)
}
}
// day is days since Since, and hour is the check-in's own UTC hour.
func TestActivityDayAndHourAreRelativeToTheWindow(t *testing.T) {
got := june(t, mergeDAG(t), ActivityQuery{})
for _, c := range []struct {
hash string
day, hour int
}{{"a1", 0, 9}, {"b1", 1, 10}, {"m1", 2, 11}} {
r := rowFor(t, got, c.hash)
if r.Day != c.day || r.Hour != c.hour {
t.Fatalf("%s = day %d hour %d, want day %d hour %d", c.hash, r.Day, r.Hour, c.day, c.hour)
}
}
}
// A path the check-in names directly stays a leaf (dir=0) even when it
// is also an ancestor of another named path — ancestorPaths decides the
// leaf set from the check-in's changes before it walks anybody's
// prefixes.
func TestActivityKeepsALeafPathThatIsAlsoADirectoryPrefix(t *testing.T) {
ix := openIndex(t)
ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
changed(t, ix, "a1", "2026-06-01T09:00:00", "src", "src/one.go")
got := june(t, ix, ActivityQuery{})
found := false
for _, p := range got.Paths {
if p == "src" {
found = true
}
}
if !found {
t.Fatalf("src missing from Paths: %v", got.Paths)
}
if r := rowFor(t, got, "a1"); r.Files != 2 {
t.Fatalf("a1 Files = %d, want 2 — src is a named leaf, not just an ancestor", r.Files)
}
var dir int
if err := ix.db.QueryRow(`SELECT dir FROM filechange WHERE path = 'src' AND checkin = 'a1'`).
Scan(&dir); err != nil {
t.Fatal(err)
}
if dir != 0 {
t.Fatalf("src dir = %d, want 0 — it was named directly, not synthesised", dir)
}
}
func TestActivityFiltersByBranchAndUser(t *testing.T) {
ix := mergeDAG(t)
onFeat := june(t, ix, ActivityQuery{Branch: "feat"})
if len(onFeat.Rows) != 1 || onFeat.Rows[0].Hash != "b1" {
t.Fatalf("branch filter = %+v, want only b1", onFeat.Rows)
}
if len(onFeat.Touches) != 1 || onFeat.Paths[0] != "src/two.go" {
t.Fatalf("branch filter touches = %+v paths %v", onFeat.Touches, onFeat.Paths)
}
byEve := june(t, ix, ActivityQuery{User: "eve"})
if len(byEve.Rows) != 1 || byEve.Rows[0].Hash != "b1" {
t.Fatalf("user filter = %+v, want only b1", byEve.Rows)
}
if len(byEve.Authors) != 1 || byEve.Authors[0].Name != "eve" {
t.Fatalf("user filter authors = %+v", byEve.Authors)
}
}
// A given Since is honoured verbatim and says so; the client needs to
// know whether the window it sees is the one it asked for.
func TestActivityWindowGivenIsHonoured(t *testing.T) {
got := june(t, mergeDAG(t), ActivityQuery{})
if got.Window != "given" {
t.Fatalf("Window = %q, want given", got.Window)
}
if got.Since != "2026-06-01" || got.Until != "2026-06-30" {
t.Fatalf("window = %s..%s, want the dates asked for", got.Since, got.Until)
}
}
// A small repository sees all of itself: 900 days back from its own
// last check-in covers everything, and the window is the server's.
func TestActivityWindowPickedCoversASmallHistory(t *testing.T) {
got, err := mergeDAG(t).Activity(ActivityQuery{})
if err != nil {
t.Fatal(err)
}
if got.Window != "picked" {
t.Fatalf("Window = %q, want picked", got.Window)
}
if len(got.Rows) != 3 {
t.Fatalf("picked window holds %d rows, want the whole 3-commit history", len(got.Rows))
}
if got.Since != "2026-06-01" || got.Until != "2026-06-03" {
t.Fatalf("window = %s..%s, want it clamped to the history", got.Since, got.Until)
}
}
// bulkHistory writes n check-ins two per day ending on the newest day,
// in one transaction — the window rule needs thousands of rows and the
// test needs to stay fast.
func bulkHistory(t *testing.T, ix *Index, days int, last time.Time) {
t.Helper()
tx, err := ix.db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
st, err := tx.Prepare(`INSERT INTO checkin (hash, date, user, comment, branch)
VALUES (?, ?, 'dan', '', 'trunk')`)
if err != nil {
t.Fatal(err)
}
defer st.Close()
for d := range days {
day := last.AddDate(0, 0, d-days+1).Format("2006-01-02")
for k := range 2 {
if _, err := st.Exec(fmt.Sprintf("h%d-%d", d, k),
fmt.Sprintf("%sT%02d:00:00", day, 8+k)); err != nil {
t.Fatal(err)
}
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
// 900 days is a floor, not a ceiling. A repository whose recent 900
// days hold fewer than 2,000 check-ins gets the window widened until
// they do, so the chart is never a handful of dots on an empty field.
func TestActivityWindowPickedExtendsBackwardsForVolume(t *testing.T) {
ix := openIndex(t)
last := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)
bulkHistory(t, ix, 1200, last) // 2,400 check-ins, two a day
got, err := ix.Activity(ActivityQuery{})
if err != nil {
t.Fatal(err)
}
if got.Window != "picked" {
t.Fatalf("Window = %q, want picked", got.Window)
}
if len(got.Rows) < 2000 {
t.Fatalf("picked window holds %d rows, want at least 2000", len(got.Rows))
}
// 900 days at two a day is 1,800 — short. The 2,000th newest
// check-in sits on the 1,000th newest day.
want := last.AddDate(0, 0, -999).Format("2006-01-02")
if got.Since != want {
t.Fatalf("Since = %s, want %s — the window did not extend past 900 days", got.Since, want)
}
}
// The rule ActivityDays honours the same way Activity does: a merge's
// touches are the absorbed branch's work, not the merger's, so they
// never count against the merging author's Files.
func TestActivityDaysAuthorFilesExcludeMergeTouches(t *testing.T) {
got, err := mergeDAG(t).ActivityDays("")
if err != nil {
t.Fatal(err)
}
if dan := authorFor(t, got.Authors, "dan"); dan.Files != 1 {
t.Fatalf("dan Files = %d, want 1 — the merge's three paths are eve's inherited work", dan.Files)
}
if eve := authorFor(t, got.Authors, "eve"); eve.Files != 1 {
t.Fatalf("eve Files = %d, want 1", eve.Files)
}
}
// Only Until named still honours the 900-day/2000-check-in floor rule —
// the widening math anchors on Until, not on the repository's actual
// last check-in.
func TestActivityWindowUntilWithoutSinceHonoursTheFloorRule(t *testing.T) {
ix := openIndex(t)
last := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)
bulkHistory(t, ix, 1200, last) // 2,400 check-ins, two a day
until := last.AddDate(0, 0, -100).Format("2006-01-02")
got, err := ix.Activity(ActivityQuery{Until: until})
if err != nil {
t.Fatal(err)
}
if got.Window != "picked" {
t.Fatalf("Window = %q, want picked — only Until was given", got.Window)
}
if got.Until != until {
t.Fatalf("Until = %s, want %s", got.Until, until)
}
untilT, perr := time.Parse("2006-01-02", until)
if perr != nil {
t.Fatal(perr)
}
want := untilT.AddDate(0, 0, -999).Format("2006-01-02")
if got.Since != want {
t.Fatalf("Since = %s, want %s — the floor rule did not honour Until", got.Since, want)
}
}
func TestActivityDaysLeadAuthorMergesAndReleases(t *testing.T) {
ix := openIndex(t)
// One day, three check-ins: dan twice (one of them a merge), eve
// once. dan leads, eve is second, one merge.
ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
ciu(t, ix, "b1", "eve", "trunk", "2026-06-01T10:00:00", "a1")
ciu(t, ix, "s1", "eve", "side", "2026-05-31T10:00:00")
ciu(t, ix, "m1", "dan", "trunk", "2026-06-01T11:00:00", "b1", "s1")
changed(t, ix, "a1", "2026-06-01T09:00:00", "src/one.go", "src/two.go")
changed(t, ix, "b1", "2026-06-01T10:00:00", "doc/x.md")
changed(t, ix, "m1", "2026-06-01T11:00:00", "doc/a.md", "doc/b.md", "doc/c.md")
release(t, ix, "m1", "2026-06-01T10:00:00", "sym-release")
release(t, ix, "m1", "2026-06-01T11:00:00", "sym-version-1.2")
got, err := ix.ActivityDays("")
if err != nil {
t.Fatal(err)
}
if got.First != "2026-05-31" || got.Last != "2026-06-01" {
t.Fatalf("span = %s..%s", got.First, got.Last)
}
d, ok := got.Days["2026-06-01"]
if !ok {
t.Fatalf("no day row: %+v", got.Days)
}
if d.N != 3 || d.Merges != 1 || d.Authors != 2 {
t.Fatalf("day = %+v, want N 3 Merges 1 Authors 2", d)
}
if len(d.Top) < 2 {
t.Fatalf("Top = %v, want at least two authors", d.Top)
}
if got.Authors[d.Top[0][0]].Name != "dan" || d.Top[0][1] != 2 {
t.Fatalf("lead = %+v (%v), want dan with 2", got.Authors[d.Top[0][0]], d.Top[0])
}
if got.Authors[d.Top[1][0]].Name != "eve" || d.Top[1][1] != 1 {
t.Fatalf("second = %+v (%v), want eve with 1", got.Authors[d.Top[1][0]], d.Top[1])
}
// The merge's three doc/ paths are inherited: src, with two
// authored touches, is the day's directory.
if d.Path < 0 || got.Paths[d.Path] != "src" {
t.Fatalf("day path = %d (%v), want src — merges do not choose it", d.Path, got.Paths)
}
// The version wins the day over the bare release tag, whichever
// came first.
if got.Releases["2026-06-01"] != "version-1.2" {
t.Fatalf("releases = %v, want version-1.2 on 2026-06-01", got.Releases)
}
}
// The memo has the same contract Counts does: it may lag nothing.
func TestActivityDaysRefreshAfterAFold(t *testing.T) {
ix := openIndex(t)
ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
first, err := ix.ActivityDays("")
if err != nil {
t.Fatal(err)
}
if len(first.Days) != 1 {
t.Fatalf("got %d days, want 1", len(first.Days))
}
ciu(t, ix, "a2", "dan", "trunk", "2026-06-02T09:00:00")
ix.invalidateCounts()
again, err := ix.ActivityDays("")
if err != nil {
t.Fatal(err)
}
if len(again.Days) != 2 {
t.Fatalf("got %d days after invalidation, want 2 — the memo went stale", len(again.Days))
}
}
func TestActivityDaysFiltersByBranch(t *testing.T) {
ix := openIndex(t)
ciu(t, ix, "a1", "dan", "trunk", "2026-06-01T09:00:00")
ciu(t, ix, "s1", "eve", "side", "2026-06-02T09:00:00")
got, err := ix.ActivityDays("side")
if err != nil {
t.Fatal(err)
}
if len(got.Days) != 1 {
t.Fatalf("got %d days, want 1: %+v", len(got.Days), got.Days)
}
if _, ok := got.Days["2026-06-02"]; !ok {
t.Fatalf("wrong day kept: %+v", got.Days)
}
if len(got.Authors) != 1 || got.Authors[0].Name != "eve" {
t.Fatalf("authors = %+v, want only eve", got.Authors)
}
}
// Colour is a pure function of the login so every renderer — Go SVG,
// browser canvas, a third-party client — draws the same person the
// same way. Pinning the arithmetic is what makes that a guarantee.
func TestHueIsDeterministic(t *testing.T) {
for _, name := range []string{"dan", "eve", "drh", ""} {
if hue(name) != hue(name) {
t.Fatalf("hue(%q) is not stable", name)
}
if h := hue(name); h < 0 || h >= 360 {
t.Fatalf("hue(%q) = %d, outside 0..359", name, h)
}
}
if got, want := hue("dan"), idn.Hue("dan"); got != want {
t.Fatalf("hue(dan) = %d, want the identicon hue %d", got, want)
}
if hue("dan") == hue("eve") {
t.Fatal("two different logins share a hue; the fixture needs different names")
}
}
// Two people whose hues land within 14° would be the same colour on
// screen. The later-ranked one steps its lightness instead.
func TestShadeStepsCollidingHues(t *testing.T) {
// Hand-built so the collision is the fixture, not a coincidence of
// real logins: three authors at the same hue, ranked.
as := []ActivityAuthor{
{Name: "a", Checkins: 3, Hue: 100},
{Name: "b", Checkins: 2, Hue: 107},
{Name: "c", Checkins: 1, Hue: 113},
{Name: "d", Checkins: 1, Hue: 200},
}
assignShades(as)
if as[0].Shade != 0 {
t.Fatalf("the top-ranked author took a shade: %+v", as[0])
}
if as[1].Shade != 1 || as[2].Shade != 2 {
t.Fatalf("shades = %d %d, want 1 2", as[1].Shade, as[2].Shade)
}
if as[3].Shade != 0 {
t.Fatalf("a distinct hue took a shade: %+v", as[3])
}
}
// The wrap-around case: 358° and 4° are 6° apart, not 354°.
func TestShadeCollisionIsCircular(t *testing.T) {
as := []ActivityAuthor{
{Name: "a", Checkins: 2, Hue: 358},
{Name: "b", Checkins: 1, Hue: 4},
}
assignShades(as)
if as[1].Shade != 1 {
t.Fatalf("shade = %d, want 1 — 358 and 4 are 6 degrees apart", as[1].Shade)
}
}
// Every author the ops hand out carries a colour; a renderer must never
// have to invent one.
func TestActivityAuthorsCarryColour(t *testing.T) {
got := june(t, mergeDAG(t), ActivityQuery{})
for _, a := range got.Authors {
if a.Hue != hue(a.Name) {
t.Fatalf("%s Hue = %d, want %d", a.Name, a.Hue, hue(a.Name))
}
}
days, err := mergeDAG(t).ActivityDays("")
if err != nil {
t.Fatal(err)
}
for _, a := range days.Authors {
if a.Hue != hue(a.Name) {
t.Fatalf("days %s Hue = %d, want %d", a.Name, a.Hue, hue(a.Name))
}
}
}
// Simulates a store folded before the dir column existed: every row —
// including the ancestor directory the fold synthesises — reset to 0
// (all a pre-migration store could ever write), then the column itself
// dropped so applySchema's ALTER has real work to do. Reopening through
// applySchema must correct the directory row in place; the file rows it
// shares a check-in with must stay untouched.
func TestFilechangeDirBackfillsExistingRows(t *testing.T) {
ix := openIndex(t)
changed(t, ix, "a1", "2026-06-01T09:00:00", "src/one.go", "doc/two.md")
if _, err := ix.db.Exec(`UPDATE filechange SET dir = 0`); err != nil {
t.Fatal(err)
}
if _, err := ix.db.Exec(`ALTER TABLE filechange DROP COLUMN dir`); err != nil {
t.Fatal(err)
}
if err := applySchema(ix.db); err != nil {
t.Fatal(err)
}
rows, err := ix.db.Query(`SELECT path, dir FROM filechange ORDER BY path`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
got := map[string]int{}
for rows.Next() {
var path string
var dir int
if err := rows.Scan(&path, &dir); err != nil {
t.Fatal(err)
}
got[path] = dir
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
want := map[string]int{"src": 1, "doc": 1, "src/one.go": 0, "doc/two.md": 0}
for path, wantDir := range want {
if got[path] != wantDir {
t.Fatalf("%s dir = %d, want %d after backfill: %v", path, got[path], wantDir, got)
}
}
}