Artifact 8a02fe1986f8dc360225964593dbb2abe44d66ab504c43775da097e4f16de2d0:
- File
internal/manifest/ticket_fossilbin_test.go
— part of check-in
[39f646e0ae]
at
2026-07-27 19:23:13
on branch trunk
— fix: crosslink ticket artifacts and stop warning for ordinary file blobs (#192)
* fix: write the ticket event row with its tag (#184), quiet the cascade (#186)
crosslinkTicket applied the tkt-<uuid> tag and returned the event row as a pending item for a second pass that was a no-op stub, so the row was never written at all. collectCrosslinkCandidates excludes any blob that already has a tagxref row naming its own rid as srcid, so the tag the incomplete operation wrote also hid the artifact from every later sweep. Cloning fossil's own repository produced 96 event rows of type 't' against canonical fossil's 3,808 -- 100% of the D,J,K,U,Z ticket-change shape lost, with nothing logged and test-integrity still clean, because only derived tables were wrong.
Implement the ticket rebuild instead of reordering the writes: there was no event write to reorder. ticketRebuildEntry replays every artifact carrying the ticket's tag in mtime order and derives its ticket, ticketchng and event rows, mirroring fossil's ticket_rebuild_entry, ticket_insert and manifest_ticket_event. It runs inline in the transaction that writes the tag, so the marker meaning "already linked" and the rows that make that true commit together or not at all. Replaying the whole tag set per artifact also makes the result independent of arrival order.
The event wording, the ticket/ticketchng column mapping including '+field' append semantics, the %h escaping and the two hyperlink hash lengths were all derived from the fossil binary rather than assumed, and are pinned by TestFossilBinaryTicketParity: a corpus built with `fossil ticket add`/`change` is crosslinked by us, rebuilt by fossil, and the two derivations compared row for row. TestCrosslink_TicketArtifactsProduceEventRows is the count assertion whose absence let this ship.
Audited the other three exclusion predicates for the same split-write shape: forumpost and attachment are each written by the same function, in the same transaction, as the event row that accompanies them, and propagate errors, so neither can diverge. No change made to them.
Secondary (#186): AfterDephantomize warned once per blob that failed to parse as a manifest, which is the ordinary outcome for file content and what the whole-repository sweep has always skipped silently -- ~39k lines on a fossil clone, burying the failures that matter. It now skips them the way linkBatch does. A blob that parses but fails to link is still a real fault and still warns.
* docs: record the ticket rebuild's measured cost and why it stays inline
The inline rebuild is quadratic in one ticket's artifact count. The cheaper deferred shape stays atomic but needs two call sites to remember to invoke it, and a forgotten call reintroduces #184 silently. Write the measurements and that reasoning down at the function, so a later profile of a repository with one hot ticket does not turn into an optimization back into the bug. (user: [email protected], size: 8665) [annotate] [blame] [check-ins using]
package manifest import ( "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "testing" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/testutil" ) // ticketDerivedTables are the tables a ticket artifact is responsible for, // added to the ones every crosslink writes. Emptying all of them puts the // repository back in a freshly-transferred clone's pre-crosslink state. var ticketDerivedTables = append(append([]string{}, crosslinkDerivedTables...), "ticket", "ticketchng") // TestFossilBinaryTicketParity is the acceptance check for issue #184: the // rows Crosslink derives for a corpus of real ticket artifacts must be the // rows canonical fossil derives from the same blobs. // // The corpus is built by the fossil binary itself -- `ticket add` and `ticket // change` emit the D,J,K,U,Z card shape that was 100% lost -- so neither the // artifacts nor the expected output is anything this repository invented. // Every branch of the event comment fossil can produce is exercised: the // creating artifact, a change that sets a status, a change that sets a status // plus other fields, and a change that sets no status at all. func TestFossilBinaryTicketParity(t *testing.T) { bin := testutil.RequireFossilBin(t) dir := t.TempDir() repoPath := filepath.Join(dir, "tickets.fossil") work := filepath.Join(dir, "work") if err := os.Mkdir(work, 0o755); err != nil { t.Fatalf("mkdir work: %v", err) } runIn := func(wd string, args ...string) string { t.Helper() cmd := exec.Command(bin, args...) cmd.Dir = wd out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("fossil %s failed: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } runIn(dir, "init", repoPath, "-A", "alice") runIn(work, "open", repoPath, "--user", "alice") runIn(work, "user", "new", "bob", "Bob", "secret", "--user", "alice") // A check-in so the repository is not ticket-only: the sweep must reach // the ticket artifacts alongside everything else it links. if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil { t.Fatalf("write a.txt: %v", err) } runIn(work, "add", "a.txt") runIn(work, "commit", "-m", "c1", "--user", "alice") // Ticket 1: a title carrying every character fossil's %h conversion // escapes, so a comment built with the wrong escaper cannot pass. out := runIn(work, "ticket", "add", "title", `A <b>&"quoted"</b> it's title`, "status", "Open", "type", "Code_Defect", "comment", "first report", "--user", "alice") tkt1 := lastField(t, out) runIn(work, "ticket", "change", tkt1, "status", "Closed", "--user", "bob") runIn(work, "ticket", "change", tkt1, "priority", "High", "--user", "bob") runIn(work, "ticket", "change", tkt1, "status", "Verified", "resolution", "Fixed", "foundin", "1.0", "--user", "alice") runIn(work, "ticket", "change", tkt1, "severity", "Critical", "subsystem", "net", "--user", "bob") // Ticket 2 proves the per-ticket rebuild does not bleed across tickets. // The comment is seeded here so the '+comment' change below has something // to append to: appending to an absent value is indistinguishable from // assigning it. out = runIn(work, "ticket", "add", "title", "Second bug", "status", "Open", "type", "Feature_Request", "comment", "initial note", "--user", "bob") tkt2 := lastField(t, out) runIn(work, "ticket", "change", tkt2, "icomment", "a follow-up note", "--user", "alice") // A '+field' card appends to the stored value instead of replacing it, and // is the one J-card shape whose ticket-table write is not a plain // assignment. Without it the append branch would go unexercised. runIn(work, "ticket", "change", tkt2, "+comment", " and then more", "--user", "bob") wantTicketArtifacts := 8 // Put the repository back in its pre-crosslink state and re-derive. prep, err := db.Open(repoPath) if err != nil { t.Fatalf("db.Open: %v", err) } for _, tbl := range ticketDerivedTables { var exists int if err := prep.QueryRow( "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", tbl, ).Scan(&exists); err != nil { t.Fatalf("check %s: %v", tbl, err) } if exists == 0 { continue } if _, err := prep.Exec("DELETE FROM " + tbl); err != nil { t.Fatalf("clear %s: %v", tbl, err) } } if err := prep.Close(); err != nil { t.Fatalf("close: %v", err) } r, err := repo.Open(repoPath) if err != nil { t.Fatalf("repo.Open: %v", err) } if _, err := Crosslink(r); err != nil { t.Fatalf("Crosslink: %v", err) } if err := r.Close(); err != nil { t.Fatalf("repo.Close: %v", err) } got := snapshotTicketDerived(t, repoPath) // Canonical fossil re-derives the same tables from the same blobs. if out, err := exec.Command(bin, "rebuild", repoPath).CombinedOutput(); err != nil { t.Fatalf("fossil rebuild failed: %v\n%s", err, out) } reference := snapshotTicketDerived(t, repoPath) if reference["count"] != strconv.Itoa(wantTicketArtifacts) { t.Fatalf("canonical fossil derived %s ticket events, but the corpus has %d ticket artifacts; "+ "the fixture no longer means what this test assumes", reference["count"], wantTicketArtifacts) } for _, key := range []string{"count", "event", "ticket", "ticketchng"} { if got[key] != reference[key] { t.Errorf("%s differs from what fossil derived\n fossil: %s\n crosslink: %s", key, reference[key], got[key]) } } } // lastField returns the final whitespace-separated token of a fossil command's // output, which for `ticket add` is the new ticket's uuid. func lastField(t *testing.T, out string) string { t.Helper() fields := strings.Fields(strings.TrimSpace(out)) if len(fields) == 0 { t.Fatalf("fossil ticket add printed nothing to take a uuid from") } id := fields[len(fields)-1] if len(id) != 40 { t.Fatalf("fossil ticket add did not end in a ticket uuid: %q", out) } return id } // scalarString renders one digest column, which arrives as a string from // group_concat and as an integer from count(*) depending on the driver. func scalarString(v any) string { switch t := v.(type) { case nil: return "" case string: return t case []byte: return string(t) case int64: return strconv.FormatInt(t, 10) default: return fmt.Sprint(t) } } // snapshotTicketDerived digests every row a ticket artifact is responsible // for. event.mtime is deliberately excluded: fossil round-trips it through a // %.17g decimal literal, so the two derivations differ in the last unit in the // last place of a double. Every other column is compared literally, tagid via // the tag name it resolves to so the comparison does not depend on two // independent derivations assigning the same autoincrement id. func snapshotTicketDerived(t *testing.T, path string) map[string]string { t.Helper() queries := map[string]string{ "count": `SELECT count(*) FROM event WHERE type='t'`, "event": `SELECT group_concat(v, '|') FROM (SELECT objid || ':' || coalesce((SELECT tagname FROM tag WHERE tagid=e.tagid),'') || ':' || coalesce(user,'') || ':' || coalesce(comment,'') || ':' || coalesce(brief,'') AS v FROM event e WHERE type='t' ORDER BY objid)`, "ticket": `SELECT group_concat(v, '|') FROM (SELECT tkt_uuid || ':' || coalesce(type,'') || ':' || coalesce(status,'') || ':' || coalesce(subsystem,'') || ':' || coalesce(priority,'') || ':' || coalesce(severity,'') || ':' || coalesce(foundin,'') || ':' || coalesce(resolution,'') || ':' || coalesce(title,'') || ':' || coalesce(comment,'') AS v FROM ticket ORDER BY tkt_uuid)`, "ticketchng": `SELECT group_concat(v, '|') FROM (SELECT tkt_rid || ':' || coalesce(tkt_user,'') || ':' || coalesce(login,'') || ':' || coalesce(username,'') || ':' || coalesce(mimetype,'') || ':' || coalesce(icomment,'') AS v FROM ticketchng ORDER BY tkt_rid)`, } d, err := db.Open(path) if err != nil { t.Fatalf("snapshotTicketDerived open: %v", err) } defer d.Close() out := make(map[string]string, len(queries)) for name, q := range queries { var v any if err := d.QueryRow(q).Scan(&v); err != nil { t.Fatalf("snapshotTicketDerived %s: %v", name, err) } s := scalarString(v) if s == "" || s == "0" { t.Fatalf("snapshotTicketDerived %s: empty digest; the table has no rows "+ "and the comparison would be vacuous", name) } out[name] = s } return out }