Artifact 8f721073f5ef4d7938b77485e60b8903ac9373042eb6a06316ec9e5452514d45:
- File
internal/verify/rebuild_fossil_test.go
— part of check-in
[2c2e5e74b6]
at
2026-10-09 15:35:38
on branch trunk
— Store check-in, link and tag rows as fossil does (#258) (#261)
* fix(manifest): store check-in, link and tag rows as fossil does (#258)
A rebuilt or locally written repository differed from fossil's in three derived values: event and plink times in their last digits, event.omtime left NULL, and a valueless tag stored as '' instead of NULL.
Fossil stores a tag time as SQLite's julianday() bound as a double, but writes event, plink, attachment and forumpost times into SQL text with its own printf("%.17g"), which emits 16 digits in long double arithmetic. TimeToJulian is now julianday() exactly (whole milliseconds over 86400000) and TimeToTextJulian ports fossil's printf conversion, so both match fossil bit for bit. The conversion follows builds where long double is a double (macOS, Windows); fossil itself differs across platforms in the last digit.
Check-in events record omtime, tag writes bind NULL for an empty value, the readers of event and plink times under the ncruces driver rebuild the text form, and the CLI's tag listing reads NULL values.
* test(db): sweep both stored julian forms; scan the text form from driver times (#258)
* test(verify): compare text times bit for bit only where fossil computes them in double (#258) (user: [email protected], size: 5388) [annotate] [blame] [check-ins using]
package verify_test // verify.Rebuild against tables the fossil binary wrote (#258): a repository // made entirely by fossil is copied and rebuilt, and every event, plink and // tagxref row must come back with fossil's values, times bit for bit. import ( "fmt" "io" "os" "os/exec" "path/filepath" "reflect" "runtime" "strings" "testing" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/verify" "github.com/danmestas/go-libfossil/testutil" ) func fossilCmd(t *testing.T, bin, dir string, args ...string) { t.Helper() cmd := exec.Command(bin, args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("fossil %s: %v\n%s", strings.Join(args, " "), err, out) } } // fossilHistory commits a few versions with the fossil binary, on trunk and // a branch, and tags one with a value and one without. func fossilHistory(t *testing.T) string { t.Helper() bin := testutil.RequireFossilBin(t) dir := t.TempDir() repoPath := filepath.Join(dir, "f.fossil") ck := filepath.Join(dir, "ck") if err := os.Mkdir(ck, 0o755); err != nil { t.Fatal(err) } fossilCmd(t, bin, dir, "init", repoPath) fossilCmd(t, bin, ck, "open", repoPath) path := filepath.Join(ck, "f.txt") mtime := time.Unix(1_700_000_000, 0) for v := 0; v < 4; v++ { if err := os.WriteFile(path, []byte(fmt.Sprintf("version %d\n", v)), 0o644); err != nil { t.Fatal(err) } mtime = mtime.Add(10 * time.Second) if err := os.Chtimes(path, mtime, mtime); err != nil { t.Fatal(err) } if v == 0 { fossilCmd(t, bin, ck, "add", "f.txt") } args := []string{"commit", "-m", fmt.Sprintf("v%d", v)} if v == 2 { args = append(args, "--branch", "feat") } fossilCmd(t, bin, ck, args...) } fossilCmd(t, bin, ck, "tag", "add", "release", "current") fossilCmd(t, bin, ck, "tag", "add", "note", "current", "with a value") return repoPath } // fossilTextTimesExact reports whether the fossil binary on this machine // writes text times the way TimeToTextJulian computes them: its %.17g runs in // long double, which is a 64-bit double on macOS arm64 and Windows but wider // on Linux and Intel macOS, where the 16th digit can differ. func fossilTextTimesExact() bool { return (runtime.GOOS == "darwin" && runtime.GOARCH == "arm64") || runtime.GOOS == "windows" } // textTime renders a column fossil writes as SQL text: bit for bit where // this machine's fossil computes it as TimeToTextJulian does, otherwise to // the millisecond the value stands for. func textTime(col string) string { if fossilTextTimesExact() { return "printf('%!.20g', " + col + ")" } return "CAST(round((" + col + " - 2440587.5) * 86400000) AS INTEGER)" } // derivedRows reads the derived tables #258 compares, in a stable order. // Tag times are SQLite's julianday() on every platform, so they are compared // bit for bit; see textTime for the others. func derivedRows(t *testing.T, path string) map[string][]string { t.Helper() d, err := db.OpenSQL(path, db.OpenConfig{}, nil) if err != nil { t.Fatal(err) } defer func() { if err := d.Close(); err != nil { t.Errorf("close %s: %v", path, err) } }() queries := map[string]string{ // Check-in events only: Rebuild does not yet recreate the events of // other artifacts (control artifacts, wiki, tickets, forum), a gap // apart from the values #258 is about. "event": "SELECT objid, type, " + textTime("mtime") + ", " + textTime("omtime") + ", user, comment FROM event WHERE type='ci' ORDER BY objid", "plink": "SELECT pid, cid, isprim, " + textTime("mtime") + " FROM plink ORDER BY cid, pid", "tagxref": `SELECT tagid, rid, tagtype, quote(value), printf('%!.20g', mtime) FROM tagxref ORDER BY tagid, rid`, } out := map[string][]string{} for name, q := range queries { rows, err := d.Query(q) if err != nil { t.Fatalf("%s: %v", name, err) } cols, err := rows.Columns() if err != nil { t.Fatal(err) } for rows.Next() { vals := make([]any, len(cols)) ptrs := make([]any, len(cols)) for i := range vals { ptrs[i] = &vals[i] } if err := rows.Scan(ptrs...); err != nil { t.Fatal(err) } out[name] = append(out[name], fmt.Sprint(vals...)) } if err := rows.Err(); err != nil { t.Fatal(err) } if err := rows.Close(); err != nil { t.Fatal(err) } } return out } func copyFile(t *testing.T, from, to string) { t.Helper() src, err := os.Open(from) if err != nil { t.Fatal(err) } defer src.Close() dst, err := os.Create(to) if err != nil { t.Fatal(err) } if _, err := io.Copy(dst, src); err != nil { t.Fatal(err) } if err := dst.Close(); err != nil { t.Fatal(err) } } func TestRebuildMatchesFossilDerivedValues(t *testing.T) { orig := fossilHistory(t) rebuilt := filepath.Join(t.TempDir(), "rebuilt.fossil") copyFile(t, orig, rebuilt) r, err := repo.Open(rebuilt) if err != nil { t.Fatal(err) } if _, err := verify.Rebuild(r); err != nil { t.Fatalf("Rebuild: %v", err) } if err := r.Close(); err != nil { t.Fatal(err) } want, got := derivedRows(t, orig), derivedRows(t, rebuilt) for table := range want { if !reflect.DeepEqual(got[table], want[table]) { t.Errorf("%s after Rebuild:\n %s\nfossil wrote:\n %s", table, strings.Join(got[table], "\n "), strings.Join(want[table], "\n ")) } } }