Artifact 8d950f7c39e98d2cc8de1a50b8ce3f13b339ff1bad6dd79e3856de9e7bcd7183:
- File
conformance/repostate_corpus_test.go
— part of check-in
[67fc6ced4b]
at
2026-10-13 11:59:57
on branch main
— fix(manifest): link check-ins with absent files; refuse absent baselines (#415)
* fix(manifest): link check-ins with absent files; refuse absent baselines
The crosslink sweep held a check-in back until every file it names was stored. A root check-in, or one on a present parent, whose files never arrived got no event, plink, mlink, leaf or tag rows. fossil links it against phantom files (add_one_mlink, uuid_to_rid(..., 1)), and repo-state-01 Section 4.2 makes an unresolved reference a phantom, not an error. The sweep -- Crosslink, the close of every sync and clone, and verify.Rebuild -- now waits only on a delta manifest's baseline. The receive-time cascade and receive linker still wait for files while blobs are still arriving; the closing sweep bounds that wait.
verify.Rebuild linked a delta manifest whose baseline was absent, with no baseline id. fossil refuses it (fetch_baseline) and files it in orphan under a phantom for the baseline; artifact-format-01 Section 8.3 calls this a soft failure retried when the baseline arrives. Every path now does the same, so a sync requests the baseline and the phantom-fill cascade links the manifest when it lands. Rebuild clears and rederives orphan.
The gap case repostate.4.2.root-absent-file passes and is unmarked; new cases cover a present parent with an absent file and an absent or late baseline, each also through verify.Rebuild where fossil rebuild agrees.
Tracked in #406.
* fix(manifest): refuse a non-manifest baseline and drop the orphan row on link
Tracked in #406. (user: [email protected], size: 6538) [annotate] [blame] [check-ins using]
package conformance import ( "fmt" "os" "path/filepath" "sort" "strings" "testing" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/verify" ) // TestRepoStateCorpus derives the tables of real repositories three ways // and compares them, table by table and column by column: `fossil rebuild` // on one copy is the reference; verify.Rebuild runs on a second copy, and a // manifest.Crosslink sweep over emptied tables on a third. It runs only // when CONFORMANCE_REPOS names the repositories, colon-separated, and // fossil(1) is present. The originals are never written. func TestRepoStateCorpus(t *testing.T) { paths := os.Getenv("CONFORMANCE_REPOS") if paths == "" { t.Skip("set CONFORMANCE_REPOS to colon-separated .fossil paths to run") } ref := newFossilReference(t) if ref == nil { t.Skip("fossil(1) not found") } for _, path := range strings.Split(paths, ":") { t.Run(filepath.Base(path), func(t *testing.T) { reference, wrote, err := ref.rebuildCopy(fossilRepo{path: copyRepo(t, path)}) if err != nil { t.Fatal(err) } if wrote { t.Errorf("fossil rebuild stored artifacts of its own; the reference is not " + "derived from the repository's artifacts alone") } want := corpusState(t, reference) for _, way := range []corpusWay{{"verify.Rebuild", goRebuild}, {"Crosslink", goSweep}} { got := corpusState(t, way.derive(t, copyRepo(t, path))) for _, line := range corpusDiff(want, got) { t.Errorf("%s: %s", way.name, line) } } }) } } // corpusWay is one way go-libfossil derives a repository's tables. type corpusWay struct { name string derive func(t *testing.T, path string) string } func goRebuild(t *testing.T, path string) string { r, err := repo.Open(path) if err != nil { t.Fatal(err) } defer r.Close() if _, err := verify.Rebuild(r); err != nil { t.Fatal(err) } return path } // goSweep empties the tables a crosslink fills, as they are in a clone // that has received every artifact, then crosslinks once. func goSweep(t *testing.T, path string) string { r, err := repo.Open(path) if err != nil { t.Fatal(err) } defer r.Close() for _, table := range []string{"event", "plink", "leaf", "mlink", "tagxref", "forumpost", "attachment", "backlink", "cherrypick", "orphan"} { if _, err := r.DB().Exec("DELETE FROM " + table); err != nil { t.Fatal(err) } } if _, err := manifest.Crosslink(r); err != nil { t.Fatal(err) } return path } // corpusKeys is how many leading columns identify a row of each table; the // rest are compared column by column. var corpusKeys = map[string]int{ "event": 1, "leaf": 1, "mlink": 2, "phantom": 1, "plink": 1, "tagxref": 2, } // corpusColumns names the compared columns, keys first, as rsQueries reads // them. var corpusColumns = map[string][]string{ "event": {"objid", "type", "mtime"}, "leaf": {"rid"}, "mlink": {"mid", "fid", "pid", "pmid", "name", "isaux"}, "phantom": {"uuid"}, "plink": {"pid>cid", "isprim", "mtime", "baseid"}, "tagxref": {"tag", "rid", "tagtype", "srcid", "origid", "value", "mtime"}, } // corpusState reads every table, keyed: rows[table][key] = the other // columns. mlink's key is mid and filename. func corpusState(t *testing.T, path string) map[string]map[string][]string { conn, err := db.OpenSQL(path, db.OpenConfig{}, nil) if err != nil { t.Fatal(err) } defer conn.Close() state := map[string]map[string][]string{} for _, table := range rsAllTables { rows, err := queryColumns(conn, rsQueries[table], nil) if err != nil { t.Fatalf("%s: %v", table, err) } keyed := map[string][]string{} for _, columns := range rows { if table == "mlink" { columns = append([]string{columns[0], columns[4]}, columns[1], columns[2], columns[3], columns[5]) } n := corpusKeys[table] keyed[strings.Join(columns[:n], " ")] = columns[n:] } state[table] = keyed } return state } // corpusDiff summarizes how got differs from want: rows missing and extra // per table, with the commonest first column, and differing values per // column. func corpusDiff(want, got map[string]map[string][]string) []string { var lines []string for _, table := range rsAllTables { missing, extra := map[string]int{}, map[string]int{} differ := map[string]int{} for key, values := range want[table] { other, ok := got[table][key] if !ok { missing[corpusGroup(table, key, values)]++ continue } for i := range values { if values[i] != other[i] { differ[corpusColumns[table][corpusKeys[table]+i]]++ } } } for key, values := range got[table] { if _, ok := want[table][key]; !ok { extra[corpusGroup(table, key, values)]++ } } lines = append(lines, corpusLines(table, missing, extra, differ)...) } return lines } func corpusLines(table string, missing, extra, differ map[string]int) []string { var lines []string if n := total(missing); n > 0 { lines = append(lines, fmt.Sprintf("%s: %d rows missing (%s)", table, n, breakdown(missing))) } if n := total(extra); n > 0 { lines = append(lines, fmt.Sprintf("%s: %d rows extra (%s)", table, n, breakdown(extra))) } for _, column := range sortedKeys(differ) { lines = append(lines, fmt.Sprintf("%s.%s: %d rows differ", table, column, differ[column])) } return lines } // corpusGroup is what a summary counts a row under: a tag row's tag name, // with the per-ticket, per-page and per-technote names folded, an event's // type, and nothing for tables keyed by hashes alone. func corpusGroup(table, key string, values []string) string { switch table { case "tagxref": name := strings.Fields(key)[0] for _, prefix := range []string{"tkt-", "wiki-", "event-"} { if strings.HasPrefix(name, prefix) { return prefix + "*" } } return name case "event": return "type " + values[0] default: return "rows" } } // breakdown lists the counts per group. func breakdown(counts map[string]int) string { var parts []string for _, name := range sortedKeys(counts) { parts = append(parts, fmt.Sprintf("%s %d", name, counts[name])) } return strings.Join(parts, ", ") } func total(counts map[string]int) int { n := 0 for _, c := range counts { n += c } return n } func sortedKeys(counts map[string]int) []string { keys := make([]string, 0, len(counts)) for k := range counts { keys = append(keys, k) } sort.Strings(keys) return keys }