Artifact 33a552d13e368f57a9006b2b8ae1cf590b8fd5d8c98cdc04308b855637f12c0b:
- File
internal/leaf/leaf_test.go
— part of check-in
[41e39a41fc]
at
2026-10-19 21:17:29
on branch fix/verify-leaf-rule
— fix(verify): report stray leaf rows and read every parent tag
Review follow-ups for the leaf audit:
- leaf.Audit reports a row for a rid that no recheck reaches, that is a rid with no branch row in no plink row. A file artifact or a rid with no artifact used to pass. - Verify collects the parents named by every parent tag, as well as by P-cards, whose link plink no longer holds. A parent that an earlier, since replaced, reparent linked is then excused. repostate.7.3.reparent-twice-no-recheck covers that order. - The stale finding excuses a child carrying a parent tag row of any type, since a cancelled reparent leaves its link in place until a rebuild. - Every leaf-table case also runs Verify on the tables fossil(1) derives, per artifact and rebuilt, except where the case records fossil departing from the draft.
Tracked in #433. (user: [email protected], size: 11271) [annotate] [blame] [check-ins using]
package leaf_test import ( "path/filepath" "slices" "testing" "github.com/danmestas/go-libfossil/db" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/leaf" "github.com/danmestas/go-libfossil/internal/repo" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/simio" ) // graph is a repository whose plink and branch rows the test writes by hand, // so each case states exactly the edges and branches it is about. type graph struct { t *testing.T q db.Querier } func newGraph(t *testing.T) graph { t.Helper() path := filepath.Join(t.TempDir(), "leaf.fossil") r, err := repo.Create(path, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } t.Cleanup(func() { r.Close() }) return graph{t: t, q: r.DB()} } func (g graph) exec(query string, args ...any) { g.t.Helper() if _, err := g.q.Exec(query, args...); err != nil { g.t.Fatalf("%s: %v", query, err) } } // link adds a plink edge pid -> cid. func (g graph) link(pid, cid int64, primary bool) { g.exec("INSERT INTO plink(pid, cid, isprim, mtime) VALUES(?, ?, ?, 2460000.5)", pid, cid, primary) } // branch gives rid an active branch row. func (g graph) branch(rid int64, name string) { g.exec("REPLACE INTO tagxref(tagid, tagtype, srcid, origid, value, mtime, rid) "+ "VALUES(?, 2, ?, ?, ?, 2460000.5, ?)", leaf.BranchTagID, rid, rid, name, rid) } func (g graph) leaves() []int64 { g.t.Helper() rows, err := g.q.Query("SELECT rid FROM leaf ORDER BY rid") if err != nil { g.t.Fatal(err) } defer rows.Close() var out []int64 for rows.Next() { var rid int64 if err := rows.Scan(&rid); err != nil { g.t.Fatal(err) } out = append(out, rid) } return out } func (g graph) wantLeaves(stage string, want ...int64) { g.t.Helper() if got := g.leaves(); !slices.Equal(got, want) { g.t.Errorf("%s: leaves = %v, want %v", stage, got, want) } } // TestPendingChecksOnlyScheduled pins Section 7.3: only a scheduled check-in // and its parents are rechecked, so a check-in nothing scheduled keeps the // verdict it had even when a same-branch child has since been linked to it // (issue #426). func TestPendingChecksOnlyScheduled(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") p := leaf.NewPending() if err := p.Schedule(g.q, 1); err != nil { t.Fatal(err) } if err := p.Apply(g.q); err != nil { t.Fatal(err) } p.Reset() g.wantLeaves("1 alone", 1) // 2 is an untagged child of 1, on the default branch with it. Linking // it schedules nothing, so 1 stays a leaf and 2 is never one. g.link(1, 2, true) if err := p.Apply(g.q); err != nil { t.Fatal(err) } g.wantLeaves("2 linked", 1) // A branch row on 2 schedules 2 and its parent 1. g.branch(2, "trunk") if err := p.Schedule(g.q, 2); err != nil { t.Fatal(err) } if err := p.Apply(g.q); err != nil { t.Fatal(err) } g.wantLeaves("2 tagged", 2) } // TestPendingChecksParentsAtBothEnds pins leaf_eventually_check: the parents a // check-in has when it is scheduled are checked even if the batch then moves // it (a reparent), and so are the parents it has when the schedule runs. func TestPendingChecksParentsAtBothEnds(t *testing.T) { g := newGraph(t) for rid := int64(1); rid <= 3; rid++ { g.branch(rid, "trunk") } g.link(1, 2, true) g.link(2, 3, true) g.exec("INSERT INTO leaf(rid) VALUES(3)") p := leaf.NewPending() if err := p.Schedule(g.q, 3); err != nil { t.Fatal(err) } // Reparent 3 from 2 to 1. 1 is planted in leaf, stale, so the result // shows whether it was checked. g.exec("DELETE FROM plink WHERE cid=3") g.link(1, 3, true) g.exec("INSERT INTO leaf(rid) VALUES(1)") if err := p.Apply(g.q); err != nil { t.Fatal(err) } // 2 (the parent at scheduling) is now childless; 1 (the parent now) has // same-branch children. g.wantLeaves("after apply", 2, 3) } // TestPendingRollback pins Mark and Rollback: rechecks scheduled by writes // that rolled back are forgotten, earlier ones kept. func TestPendingRollback(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") g.branch(2, "trunk") p := leaf.NewPending() if err := p.Schedule(g.q, 1); err != nil { t.Fatal(err) } mark := p.Mark() if err := p.Schedule(g.q, 2); err != nil { t.Fatal(err) } if p.Len() != 2 { t.Fatalf("Len = %d, want 2", p.Len()) } p.Rollback(mark) if p.Len() != 1 { t.Fatalf("Len after rollback = %d, want 1", p.Len()) } if err := p.Apply(g.q); err != nil { t.Fatal(err) } g.wantLeaves("after apply", 1) } // TestRebuildIsSingleBatch pins Section 7.3's full rebuild: the candidates are // the check-ins with a branch row and the plink parents, so an untagged // check-in with no child, a candidate of nothing, is not a leaf. func TestRebuildIsSingleBatch(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") g.link(1, 2, true) // 2 untagged, on trunk by default: 1 is no leaf g.branch(3, "feat") g.link(2, 3, true) // 2 is a parent of feat's 3: a leaf on trunk g.exec("INSERT INTO leaf(rid) VALUES(1)") if err := leaf.Rebuild(g.q); err != nil { t.Fatal(err) } g.wantLeaves("after rebuild", 2, 3) if err := leaf.Check(g.q, libfossil.FslID(2)); err != nil { t.Fatal(err) } g.wantLeaves("after check", 2, 3) } // tagFrom gives rid an active branch row written by the artifact src: 0 for a // row propagated from a parent, another rid for a control artifact. func (g graph) tagFrom(rid int64, name string, src int64) { g.exec("REPLACE INTO tagxref(tagid, tagtype, srcid, origid, value, mtime, rid) "+ "VALUES(?, 2, ?, ?, ?, 2460000.5, ?)", leaf.BranchTagID, src, rid, name, rid) } func (g graph) wantAudit(stage string, stale, missing []libfossil.FslID) { g.t.Helper() g.wantAuditDetached(stage, nil, stale, missing) } func (g graph) wantAuditDetached(stage string, detached map[libfossil.FslID]struct{}, stale, missing []libfossil.FslID) { g.t.Helper() got, err := leaf.Audit(g.q, detached) if err != nil { g.t.Fatalf("%s: Audit: %v", stage, err) } if !slices.Equal(got.Stale, stale) || !slices.Equal(got.Missing, missing) { g.t.Errorf("%s: Audit = stale %v missing %v, want stale %v missing %v", stage, got.Stale, got.Missing, stale, missing) } if len(got.Stray) != 0 { g.t.Errorf("%s: Audit stray %v, want none", stage, got.Stray) } } // TestAuditAcceptsArrivalOrder pins the tables Section 7.3 allows that the // test of Section 7.2 alone would reject: an untagged check-in with no child // may lack a row, and a parent may keep its row when its same-branch child // carries no branch row, since that child's arrival scheduled nothing. func TestAuditAcceptsArrivalOrder(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") g.link(1, 2, true) // 2 untagged, on trunk g.exec("INSERT INTO leaf(rid) VALUES(1)") g.wantAudit("kept verdict", nil, nil) // The same repository rebuilt in one batch is just as consistent. if err := leaf.Rebuild(g.q); err != nil { t.Fatal(err) } g.wantLeaves("rebuilt") g.wantAudit("rebuilt", nil, nil) } // TestAuditFollowsBranches pins the Section 7.2 test Audit applies: a merged // branch tip and a branch point whose children are all on other branches are // leaves, though each has a child. func TestAuditFollowsBranches(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") g.branch(2, "feat") g.link(1, 2, true) // 1's only child is on feat g.branch(3, "trunk") g.link(2, 3, false) // 3 merges feat's tip 2 into trunk if err := leaf.Rebuild(g.q); err != nil { t.Fatal(err) } g.wantLeaves("rebuilt", 1, 2, 3) g.wantAudit("rebuilt", nil, nil) } // TestAuditReportsDecidable pins the two findings no arrival order excuses: a // row whose same-branch child carries a branch row written after the link, // and a tagged check-in with no same-branch child and no row. func TestAuditReportsDecidable(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") g.link(1, 2, true) g.tagFrom(2, "trunk", 0) // propagated to 2 along the link g.branch(3, "feat") g.link(1, 3, true) g.branch(4, "feat") g.link(3, 4, true) // 4's own T card: written as it was linked g.exec("INSERT INTO leaf(rid) VALUES(1), (3)") g.wantAudit("planted", []libfossil.FslID{1, 3}, []libfossil.FslID{2, 4}) if err := leaf.Rebuild(g.q); err != nil { t.Fatal(err) } g.wantAudit("rebuilt", nil, nil) } // TestAuditToleratesControlTag pins the limit of the stale finding: a branch // row a control artifact wrote may predate the child's link, when the tag // arrived before the check-in it names, so it schedules nothing on the parent. func TestAuditToleratesControlTag(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") g.link(1, 2, true) g.tagFrom(2, "trunk", 9) g.exec("INSERT INTO leaf(rid) VALUES(1), (2)") g.wantAudit("control-tagged child", nil, nil) } // TestAuditToleratesReparent pins the reparent exceptions: moving a child // schedules no recheck, so its new parent may keep a row and its old parent, // named in detached, may lack one. func TestAuditToleratesReparent(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") g.branch(2, "feat") g.link(1, 2, true) g.branch(3, "trunk") g.link(1, 3, true) // 3 was 2's child, on trunk by its own T card g.parentTag(3, 1) g.exec("INSERT INTO leaf(rid) VALUES(1)") g.wantAudit("reparented, 2 not detached", nil, []libfossil.FslID{2, 3}) g.wantAuditDetached("reparented", map[libfossil.FslID]struct{}{2: {}}, nil, []libfossil.FslID{3}) } // parentTag gives rid a parent tag row of tagtype: 1 for a reparent, 0 once a // later control artifact cancels it. func (g graph) parentTag(rid int64, tagtype int) { g.exec("INSERT OR IGNORE INTO tag(tagname) VALUES('parent')") g.exec("REPLACE INTO tagxref(tagid, tagtype, srcid, origid, value, mtime, rid) "+ "SELECT tagid, ?, 9, ?, 'h1', 2460000.5, ? FROM tag WHERE tagname='parent'", tagtype, rid, rid) } // TestAuditToleratesCancelledReparent pins a cancelled parent tag: the cancel // re-derives nothing, so the plink row the reparent wrote stays until a full // rebuild, and its new parent may keep the row it had before. func TestAuditToleratesCancelledReparent(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") g.branch(2, "feat") g.link(1, 2, true) g.branch(3, "trunk") g.link(1, 3, true) g.parentTag(3, 0) g.exec("INSERT INTO leaf(rid) VALUES(1)") g.wantAuditDetached("cancelled", map[libfossil.FslID]struct{}{2: {}}, nil, []libfossil.FslID{3}) } // TestAuditReportsStray pins the rows no recheck writes: a rid that is not a // candidate of Section 7.3 -- no branch row, in no plink row -- such as a // file artifact or a rid with no artifact at all. A parent a reparent took // its only child from is excused. func TestAuditReportsStray(t *testing.T) { g := newGraph(t) g.branch(1, "trunk") g.exec("INSERT INTO leaf(rid) VALUES(1), (7), (424242)") got, err := leaf.Audit(g.q, map[libfossil.FslID]struct{}{7: {}}) if err != nil { t.Fatal(err) } if want := []libfossil.FslID{424242}; !slices.Equal(got.Stray, want) { t.Errorf("stray = %v, want %v", got.Stray, want) } got, err = leaf.Audit(g.q, nil) if err != nil { t.Fatal(err) } if want := []libfossil.FslID{7, 424242}; !slices.Equal(got.Stray, want) { t.Errorf("stray without detached = %v, want %v", got.Stray, want) } }