Artifact 82c4471c6e6ff5e4c81748e209815baf28ccc74e9f9146b413fd87f66f5b03c3:
- File
internal/checkout/rename.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: 8014) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "database/sql" "errors" "fmt" "os" "path/filepath" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // moveFile reads a file from oldPath, writes it to newPath with the given // permissions, and removes the original. Ignores NotExist on remove. func (c *Checkout) moveFile(oldPath, newPath string, perm os.FileMode) error { data, err := c.env.Storage.ReadFile(oldPath) if err != nil { return fmt.Errorf("read %s: %w", oldPath, err) } newParentDir := filepath.Dir(newPath) if err := c.env.Storage.MkdirAll(newParentDir, 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", newParentDir, err) } if err := c.env.Storage.WriteFile(newPath, data, perm); err != nil { return fmt.Errorf("write %s: %w", newPath, err) } if err := c.env.Storage.Remove(oldPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", oldPath, err) } return nil } // vfilePerm queries the isexe flag for a vfile row and returns the file mode. func (c *Checkout) vfilePerm(vfileID int64) (os.FileMode, error) { var isexe int64 err := c.db.QueryRow("SELECT CAST(isexe AS INTEGER) FROM vfile WHERE id=?", vfileID).Scan(&isexe) if err != nil { return 0, fmt.Errorf("query vfile permissions: %w", err) } if isexe != 0 { return 0o755, nil } return 0o644, nil } // modeIsExecutable is the inverse of vfilePerm: given an on-disk file mode, // it reports whether the file should be recorded as executable. // // Matches Fossil's file_perm() predicate (src/file.c:316): // S_ISREG(st_mode) && (S_IXUSR & st_mode) != 0 — owner-execute on a regular // file. Directories, symlinks, and other non-regular files are never // executable, and group- or other-only execute bits (e.g. 0645) do not // qualify without the owner bit. // // On Windows, os.FileMode never carries an owner-execute bit, so this always // returns false — matching Fossil's PERM_REG-only behavior on that platform. func modeIsExecutable(mode os.FileMode) bool { return mode.IsRegular() && mode&0o100 != 0 } // Rename marks a file as renamed in vfile by updating pathname and origname. // Sets chnged=1 to indicate the file has been modified. // // If opts.DoFsMove is true, also moves the file in Storage from old to new path. // // Panics if c is nil, or if From or To is empty (TigerStyle precondition). func (c *Checkout) Rename(opts RenameOpts) error { if c == nil { panic("checkout.Rename: nil *Checkout") } if opts.From == "" { panic("checkout.Rename: empty From") } if opts.To == "" { panic("checkout.Rename: empty To") } // Get current version vid, _, err := c.Version() if err != nil { return fmt.Errorf("checkout.Rename: %w", err) } vfileID, err := c.renameRowID(vid, opts.From, opts.To) if err != nil { return err } // Refuse to move onto a file already on disk, before changing anything: // it is not tracked (checked above), so it may be the only copy of // someone's work. fossil mv --hard refuses the same way. if opts.DoFsMove { if err := c.checkRenameTargetFree(opts.To); err != nil { return err } } // Update vfile: set new pathname, store old pathname in origname, mark as changed _, err = c.db.Exec( "UPDATE vfile SET pathname=?, origname=?, chnged=1 WHERE id=?", opts.To, opts.From, vfileID, ) if err != nil { return fmt.Errorf("checkout.Rename: update vfile: %w", err) } // If DoFsMove, move the file in Storage if opts.DoFsMove { perm, err := c.vfilePerm(vfileID) if err != nil { return fmt.Errorf("checkout.Rename: %w", err) } oldPath, err := c.safePath(opts.From) if err != nil { return fmt.Errorf("checkout.Rename: path traversal in %s: %w", opts.From, err) } newPath, err := c.safePath(opts.To) if err != nil { return fmt.Errorf("checkout.Rename: path traversal in %s: %w", opts.To, err) } if err := c.moveFile(oldPath, newPath, perm); err != nil { return fmt.Errorf("checkout.Rename: %w", err) } } // Call callback if provided if opts.Callback != nil { if err := opts.Callback(opts.From, opts.To); err != nil { return fmt.Errorf("checkout.Rename: callback: %w", err) } } return nil } // RevertRename restores a renamed file to its original pathname. // Returns (true, nil) if the revert succeeded, (false, nil) if there was nothing to revert. // // Panics if c is nil or name is empty (TigerStyle precondition). func (c *Checkout) RevertRename(name string, doFsMove bool) (bool, error) { if c == nil { panic("checkout.RevertRename: nil *Checkout") } if name == "" { panic("checkout.RevertRename: empty name") } // Get current version vid, _, err := c.Version() if err != nil { return false, fmt.Errorf("checkout.RevertRename: %w", err) } // Look up vfile entry var vfileID int64 var origname sql.NullString err = c.db.QueryRow( "SELECT id, origname FROM vfile WHERE vid=? AND pathname=?", int64(vid), name, ).Scan(&vfileID, &origname) if err == sql.ErrNoRows { return false, fmt.Errorf("checkout.RevertRename: file %s not found in vfile", name) } if err != nil { return false, fmt.Errorf("checkout.RevertRename: query vfile for %s: %w", name, err) } // If origname is empty/NULL, nothing to revert if !origname.Valid || origname.String == "" { return false, nil } oldOrigName := origname.String // Update vfile: restore origname as pathname, clear origname, reset // chnged. The file was not checked, so its recorded mtime is dropped and // the next scan hashes it. _, err = c.db.Exec( "UPDATE vfile SET pathname=?, origname=NULL, chnged=0, mtime=0 WHERE id=?", oldOrigName, vfileID, ) if err != nil { return false, fmt.Errorf("checkout.RevertRename: update vfile: %w", err) } // If doFsMove, move the file back in Storage if doFsMove { perm, err := c.vfilePerm(vfileID) if err != nil { return false, fmt.Errorf("checkout.RevertRename: %w", err) } currentPath, err := c.safePath(name) if err != nil { return false, fmt.Errorf("checkout.RevertRename: path traversal in %s: %w", name, err) } originalPath, err := c.safePath(oldOrigName) if err != nil { return false, fmt.Errorf("checkout.RevertRename: path traversal in %s: %w", oldOrigName, err) } if err := c.moveFile(currentPath, originalPath, perm); err != nil { return false, fmt.Errorf("checkout.RevertRename: %w", err) } } return true, nil } // checkRenameTargetFree returns an error if anything already exists on disk // at the checkout-relative path to. func (c *Checkout) checkRenameTargetFree(to string) error { if to == "" { panic("checkout.checkRenameTargetFree: empty path") } newPath, err := c.safePath(to) if err != nil { return fmt.Errorf("checkout.Rename: path traversal in %s: %w", to, err) } _, err = c.env.Storage.Stat(newPath) if err == nil { return fmt.Errorf( "checkout.Rename: cannot move onto %s: something already exists there", to) } if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("checkout.Rename: stat %s: %w", newPath, err) } return nil } // renameRowID returns the vfile id of from in version vid, after checking // that to is not tracked already. func (c *Checkout) renameRowID(vid libfossil.FslID, from, to string) (int64, error) { if from == to { return 0, fmt.Errorf("checkout.Rename: %s is already named %s", from, to) } var vfileID int64 err := c.db.QueryRow( "SELECT id FROM vfile WHERE vid=? AND pathname=?", int64(vid), from, ).Scan(&vfileID) if err == sql.ErrNoRows { return 0, fmt.Errorf("checkout.Rename: file %s not found in vfile", from) } if err != nil { return 0, fmt.Errorf("checkout.Rename: query vfile for %s: %w", from, err) } var existingID int64 err = c.db.QueryRow( "SELECT id FROM vfile WHERE vid=? AND pathname=?", int64(vid), to, ).Scan(&existingID) if err == nil { return 0, fmt.Errorf("checkout.Rename: target file %s already exists in vfile", to) } if err != sql.ErrNoRows { return 0, fmt.Errorf("checkout.Rename: query vfile for %s: %w", to, err) } return vfileID, nil }