Artifact 34a44ccfd0db78269a53b121c77ccf6cd4c86be5abe6c29a9d83d9ce45f1f6f8:
- File
internal/checkout/status.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: 3351) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "errors" "fmt" "os" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/vfile" ) // HasChanges reports whether any file of the current version is added, // removed, renamed or modified. It reads only the checkout database: an edit // on disk counts once a scan has recorded it, but adds, removals and renames // are always seen. Missing files are not, because that needs the disk. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) HasChanges() (bool, error) { if c == nil { panic("checkout.HasChanges: nil *Checkout") } rid, _, err := c.Version() if err != nil { return false, fmt.Errorf("checkout.HasChanges: %w", err) } if rid < 0 { panic("checkout.HasChanges: negative checkout version") } rows, err := vfile.Load(c.db, int64(rid)) if err != nil { return false, fmt.Errorf("checkout.HasChanges: %w", err) } for _, r := range rows { if vfile.Classify(r, false) != ChangeNone { return true, nil } } return false, nil } // VisitChanges calls fn for each changed file of version vid, classified by // vfile.Classify. With scan=true it first runs ScanChanges(0), fossil's // default scan, and checks each file on disk, so edits and missing files are // reported; with scan=false it reports what the checkout database records. // // If fn returns a non-nil error, iteration stops and that error is returned. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) VisitChanges(vid libfossil.FslID, scan bool, fn ChangeVisitor) error { if c == nil { panic("checkout.VisitChanges: nil *Checkout") } if fn == nil { panic("checkout.VisitChanges: nil visitor") } if scan { if err := c.ScanChanges(0); err != nil { return fmt.Errorf("checkout.VisitChanges: %w", err) } } rows, err := vfile.Load(c.db, int64(vid)) if err != nil { return fmt.Errorf("checkout.VisitChanges: %w", err) } for _, r := range rows { missing := false if scan { if missing, err = c.isMissing(r); err != nil { return fmt.Errorf("checkout.VisitChanges: %w", err) } } change := vfile.Classify(r, missing) if change == ChangeNone { continue } entry := ChangeEntry{ Name: r.Pathname, Change: change, VFileID: libfossil.FslID(r.ID), IsExec: r.IsExe != 0, IsLink: r.IsLink != 0, OrigName: r.RenamedFrom(), } if err := fn(entry); err != nil { return fmt.Errorf("checkout.VisitChanges: visitor for %s: %w", r.Pathname, err) } } return nil } // isMissing reports whether a row's file is absent from disk, the way // fossil's changes command decides MISSING: anything that is not a regular // file counts. A removed row is never missing; it is meant to be gone. func (c *Checkout) isMissing(r vfile.Row) (bool, error) { if r.Pathname == "" { panic("checkout.isMissing: empty pathname") } if c.env == nil { panic("checkout.isMissing: nil env") } if r.Deleted > 0 { return false, nil } fullPath, err := c.safePath(r.Pathname) if err != nil { return false, fmt.Errorf("path traversal in %s: %w", r.Pathname, err) } info, err := c.env.Storage.Stat(fullPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return true, nil } return false, fmt.Errorf("stat %s: %w", fullPath, err) } return !info.Mode().IsRegular(), nil }