Artifact a285b7c0742936761262bb1a9fb9876db92cbd594e8dfe920bc78a0a9c5592a9:
- File
checkout_mtime_interop_test.go
— part of check-in
[a71bf662ee]
at
2026-10-18 20:59:23
on branch main
— fix(checkout): record and trust vfile.mtime as fossil does (#444)
* fix(checkout): record and trust vfile.mtime as fossil does
go-libfossil wrote vfile.mtime = 0 everywhere and hashed every file on every scan, so it and fossil rehashed a go-libfossil checkout on every command.
The scan is now fossil's vfile_check_signature: a file whose size matches its artifact (blob.size of mrid) and whose mtime matches vfile.mtime is taken as unchanged without being read, unless ScanHash is given or the mtime-changes setting is off; the scan stores the file's mtime in whole seconds and runs its updates in one transaction. Status, commit, merge, revert and extract use the default scan, as fossil's commands do.
Mtimes are recorded where fossil records them: extract and update store the mtime of each file written with its artifact's content, an unchanged file keeps its mtime through LoadVFile (fossil's update transfer and in-place commit), committed files keep theirs, revert stores the restored file's, and SetMTime stores the time it gives. Undoing a rename drops the mtime, since the file was not checked.
One deliberate difference: an mtime not older than the current second is stored as 0, so the next scan (ours or fossil's) hashes the file and an edit that keeps the size within that second is still seen. A stored 0 is never trusted.
MemStorage now stamps each write with a new mtime from its own clock, as a filesystem does, so in-memory checkouts see same-size edits.
Status on the sqlite repository's 2,220-file checkout: about 250 ms before, about 30 ms after.
* test(checkout): pin the same-second guard against fossil
A same-size edit in the second a file was scanned is committed, and fossil 2.28 reading a row the guard stored as 0 reports the edit. Both tests fail with the guard removed. The guard's comment and the CHANGELOG name it as the one deliberate difference from vfile_check_signature.
* fix(checkout): record the mtime of each write as it happens
Extract and update statted their files only after every file was written and vfile reloaded, so an edit in between was recorded as the written content. They now stat each file right after writing it, as fossil's vfile_to_disk does, and carry that mtime to the row. Revert and the new writtenMTime read the clock before the stat, as the scan does.
A caller's Env without a Clock now gets the real clock, as Storage defaults to the OS; scans read it. The mtime-changes setting is read from the repository only: go-libfossil reads no global fossil settings, which the comment and CHANGELOG now say.
New tests: an edit after extract's or update's write is seen, a stored 0 is never trusted, and a simulated clock at a realistic time drives the mtime fast path. (user: [email protected], size: 6990) [annotate] [blame] [check-ins using] [more...]
package libfossil_test // Interop tests for vfile.mtime: fossil and go-libfossil record a checkout // file's mtime the same way and trust it the same way, so each reads the // other's checkout as it reads its own. import ( "os" "path/filepath" "sort" "strings" "testing" "time" libfossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/db" ) // agedTime is a whole second long past, given to checkout files so that // their mtimes are recorded. var agedTime = time.Date(2020, 5, 6, 7, 8, 9, 0, time.UTC) func setMTime(t *testing.T, path string, when time.Time) { t.Helper() if err := os.Chtimes(path, when, when); err != nil { t.Fatal(err) } } // vfileMTimes reads pathname → vfile.mtime from a checkout's database. func vfileMTimes(t *testing.T, ckDir string) map[string]int64 { t.Helper() d, err := db.Open(filepath.Join(ckDir, ".fslckout")) if err != nil { t.Fatal(err) } defer d.Close() rows, err := d.Query("SELECT pathname, coalesce(mtime, 0) FROM vfile") if err != nil { t.Fatal(err) } defer rows.Close() out := map[string]int64{} for rows.Next() { var name string var mtime int64 if err := rows.Scan(&name, &mtime); err != nil { t.Fatal(err) } out[name] = mtime } if err := rows.Err(); err != nil { t.Fatal(err) } return out } // fossilEdited lists the files fossil's changes command reports EDITED. func fossilEdited(t *testing.T, bin, ckDir string) []string { t.Helper() var out []string for _, line := range strings.Split(fossilRun(t, bin, ckDir, "changes"), "\n") { if f := strings.Fields(line); len(f) == 2 && f[0] == "EDITED" { out = append(out, f[1]) } } sort.Strings(out) return out } // libfossilEdited lists the files Status reports modified. func libfossilEdited(t *testing.T, ck *libfossil.Checkout) []string { t.Helper() var out []string for _, line := range statusLines(t, ck) { if name, ok := strings.CutPrefix(line, "modified "); ok { out = append(out, name) } } return out } // editKeepingSignature gives a.txt new content of the same size and puts // its recorded mtime back: a scan that trusts mtimes cannot see it. b.txt // changes size, which any scan sees. func editKeepingSignature(t *testing.T, ckDir string) { t.Helper() writeFile(t, filepath.Join(ckDir, "a.txt"), "ONE\n") setMTime(t, filepath.Join(ckDir, "a.txt"), agedTime) writeFile(t, filepath.Join(ckDir, "b.txt"), "two, longer\n") setMTime(t, filepath.Join(ckDir, "b.txt"), agedTime) } // assertBothSee checks that fossil and go-libfossil report want as edited. func assertBothSee(t *testing.T, bin, ckDir string, ck *libfossil.Checkout, want string) { t.Helper() if got := strings.Join(libfossilEdited(t, ck), ","); got != want { t.Errorf("go-libfossil edited = %q, want %q", got, want) } if got := strings.Join(fossilEdited(t, bin, ckDir), ","); got != want { t.Errorf("fossil edited = %q, want %q", got, want) } } // A checkout fossil opened and scanned: go-libfossil trusts the mtimes // fossil recorded exactly as fossil does, and both hash every file once // mtime-changes is off. func TestFossilRecordedMTimesTrustedLikeFossil(t *testing.T) { bin, repoPath, ckDir := newFossilCheckout(t) setMTime(t, filepath.Join(ckDir, "a.txt"), agedTime) setMTime(t, filepath.Join(ckDir, "b.txt"), agedTime) fossilRun(t, bin, ckDir, "changes") for name, m := range vfileMTimes(t, ckDir) { if m != agedTime.Unix() { t.Fatalf("fossil recorded %s mtime %d, want %d; the test's premise is wrong", name, m, agedTime.Unix()) } } editKeepingSignature(t, ckDir) ck := openLibfossilCheckout(t, repoPath, ckDir) assertBothSee(t, bin, ckDir, ck, "b.txt") fossilRun(t, bin, ckDir, "settings", "mtime-changes", "off") assertBothSee(t, bin, ckDir, ck, "a.txt,b.txt") } // A checkout go-libfossil made and scanned: fossil finds the mtimes it would // have recorded itself, leaves them as they are, and trusts them. func TestLibfossilRecordedMTimesTrustedByFossil(t *testing.T) { bin, repoPath, fossilDir := newFossilCheckout(t) fossilRun(t, bin, fossilDir, "close") ckDir := filepath.Join(t.TempDir(), "ck") if err := os.Mkdir(ckDir, 0o755); err != nil { t.Fatal(err) } r, err := libfossil.Open(repoPath) if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Close() }) ck, err := r.CreateCheckout(ckDir, libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatal(err) } t.Cleanup(func() { ck.Close() }) rid, _, err := ck.Version() if err != nil { t.Fatal(err) } if err := ck.Extract(rid, libfossil.ExtractOpts{Force: true}); err != nil { t.Fatal(err) } setMTime(t, filepath.Join(ckDir, "a.txt"), agedTime) setMTime(t, filepath.Join(ckDir, "b.txt"), agedTime) if got := statusLines(t, ck); len(got) != 0 { t.Fatalf("fresh checkout: Status = %q, want none", got) } recorded := vfileMTimes(t, ckDir) for name, m := range recorded { if m != agedTime.Unix() { t.Fatalf("go-libfossil recorded %s mtime %d, want %d", name, m, agedTime.Unix()) } } fossilRun(t, bin, ckDir, "changes") for name, m := range vfileMTimes(t, ckDir) { if m != recorded[name] { t.Errorf("fossil rewrote %s mtime %d to %d", name, recorded[name], m) } } editKeepingSignature(t, ckDir) assertBothSee(t, bin, ckDir, ck, "b.txt") } // A file whose mtime is not older than the scan's second is recorded with // mtime 0, so fossil reading the row hashes the file and sees a same-size // edit that keeps the mtime; fossil's own row would have hidden it. A time // ahead of the clock stands in for the current second, deterministically. func TestFossilSeesEditBehindUnrecordedMTime(t *testing.T) { bin, repoPath, fossilDir := newFossilCheckout(t) fossilRun(t, bin, fossilDir, "close") ckDir := filepath.Join(t.TempDir(), "ck") if err := os.Mkdir(ckDir, 0o755); err != nil { t.Fatal(err) } r, err := libfossil.Open(repoPath) if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Close() }) ck, err := r.CreateCheckout(ckDir, libfossil.CheckoutCreateOpts{}) if err != nil { t.Fatal(err) } t.Cleanup(func() { ck.Close() }) rid, _, err := ck.Version() if err != nil { t.Fatal(err) } if err := ck.Extract(rid, libfossil.ExtractOpts{Force: true}); err != nil { t.Fatal(err) } current := time.Now().Truncate(time.Second).Add(time.Hour) setMTime(t, filepath.Join(ckDir, "a.txt"), current) setMTime(t, filepath.Join(ckDir, "b.txt"), agedTime) if got := statusLines(t, ck); len(got) != 0 { t.Fatalf("fresh checkout: Status = %q, want none", got) } if m := vfileMTimes(t, ckDir)["a.txt"]; m != 0 { t.Fatalf("a.txt recorded with mtime %d, want 0", m) } writeFile(t, filepath.Join(ckDir, "a.txt"), "ONE\n") setMTime(t, filepath.Join(ckDir, "a.txt"), current) if got := strings.Join(fossilEdited(t, bin, ckDir), ","); got != "a.txt" { t.Errorf("fossil edited = %q, want %q", got, "a.txt") } if got := strings.Join(libfossilEdited(t, ck), ","); got != "a.txt" { t.Errorf("go-libfossil edited = %q, want %q", got, "a.txt") } }