Artifact 64f33dcc1c6a1fae572fb9f1fff8d16941e87c5692445a4813f9839d34626c4c:
- File
internal/checkout/checkout.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: 11611) [annotate] [blame] [check-ins using] [more...]
// Package checkout provides working directory management for Fossil repositories. // It implements file extraction, change tracking, staging, and commit operations, // ported from libfossil's checkout API. All filesystem operations go through // simio.Env for platform-agnostic operation (native, WASI, browser WASM). // // A Checkout is not safe for concurrent use. Callers must serialize // access to a single Checkout instance. package checkout import ( "context" "database/sql" "errors" "fmt" "path/filepath" "runtime" "strconv" "github.com/danmestas/go-libfossil/db" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/simio" ) // Checkout represents an opened or created checkout database. // A Checkout owns the checkout DB (*sql.DB) but NOT the repo — the caller // owns the repo lifecycle. type Checkout struct { db *sql.DB repo *repo.Repo env *simio.Env obs Observer dir string checkinQueue map[string]bool // in-memory staging queue (session-scoped) } // initCheckoutVersion finds the tip checkin from the repo and sets // vvar checkout/checkout-hash in the checkout DB. Empty repos use the // unborn checkout state: checkout=0 with empty checkout-hash. func initCheckoutVersion(ckdb *sql.DB, r *repo.Repo) error { var tipRID int64 var tipUUID string err := r.DB().QueryRow(` SELECT l.rid, b.uuid FROM leaf l JOIN event e ON e.objid=l.rid JOIN blob b ON b.rid=l.rid WHERE e.type='ci' ORDER BY e.mtime DESC LIMIT 1 `).Scan(&tipRID, &tipUUID) if err != nil { if err == sql.ErrNoRows { return initUnbornCheckoutVersion(ckdb) } return fmt.Errorf("checkout.Create: query tip: %w", err) } if err := setVVar(ckdb, "checkout", strconv.FormatInt(tipRID, 10)); err != nil { return fmt.Errorf("checkout.Create: %w", err) } if err := setVVar(ckdb, "checkout-hash", tipUUID); err != nil { return fmt.Errorf("checkout.Create: %w", err) } return nil } func initUnbornCheckoutVersion(ckdb *sql.DB) error { if err := setVVar(ckdb, "checkout", "0"); err != nil { return fmt.Errorf("checkout.Create: %w", err) } if err := setVVar(ckdb, "checkout-hash", ""); err != nil { return fmt.Errorf("checkout.Create: %w", err) } return nil } func initUndoState(ckdb *sql.DB) error { if err := setVVar(ckdb, "undo_available", "0"); err != nil { return fmt.Errorf("checkout.Create: %w", err) } if err := setVVar(ckdb, "undo_checkout", "0"); err != nil { return fmt.Errorf("checkout.Create: %w", err) } return nil } // Create creates a new checkout database at dir/.fslckout (or dir/_FOSSIL_ on Windows), // finds the tip checkin from the repo, and sets vvar checkout/checkout-hash. // // Panics if r or dir is nil/empty (TigerStyle precondition). func Create(r *repo.Repo, dir string, opts CreateOpts) (*Checkout, error) { if r == nil { panic("checkout.Create: nil *repo.Repo") } if dir == "" { panic("checkout.Create: empty dir") } env := resolveEnv(opts.Env) obs := resolveObserver(opts.Observer) // Verify a SQLite driver is registered before creating the checkout DB. if db.RegisteredDriver() == nil { return nil, fmt.Errorf("checkout.Create: no SQLite driver registered") } // Determine DB path dbPath := filepath.Join(dir, PreferredDBName()) // Create parent directory if needed if err := env.Storage.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("checkout.Create: mkdir: %w", err) } // Open the checkout database ckdb, err := db.OpenSQL(dbPath, db.OpenConfig{}, nil) if err != nil { return nil, fmt.Errorf("checkout.Create: sql.Open: %w", err) } // Ensure tables exist if err := EnsureTables(ckdb); err != nil { ckdb.Close() return nil, fmt.Errorf("checkout.Create: %w", err) } // Find the tip checkin and set vvar checkout/checkout-hash if err := initCheckoutVersion(ckdb, r); err != nil { ckdb.Close() return nil, err } if err := initUndoState(ckdb); err != nil { ckdb.Close() return nil, err } // Set the repository vvar so tools can find the repo. if err := setVVar(ckdb, "repository", r.Path()); err != nil { ckdb.Close() return nil, fmt.Errorf("checkout.Create: %w", err) } c := &Checkout{ db: ckdb, repo: r, env: env, obs: obs, dir: dir, } if err := c.loadInitialVFile(); err != nil { ckdb.Close() return nil, err } return c, nil } // loadInitialVFile loads vfile for the version Create recorded. A tip that a // partial sync has not finished is no reason to fail: the caller may be about // to extract an older, complete version. vfile then stays empty rather than // claiming files the repo cannot produce. func (c *Checkout) loadInitialVFile() error { if c == nil { panic("checkout.loadInitialVFile: nil *Checkout") } if c.obs == nil { panic("checkout.loadInitialVFile: nil observer") } rid, _, err := c.Version() if err != nil { return err } if rid == 0 { return nil // Empty repository: nothing to load. } err = c.LoadVFile(rid, true) if errors.Is(err, errMissingContent) { // Tolerated, but not silently: observers learn why vfile is empty. c.obs.Error(context.Background(), fmt.Errorf("checkout.Create: tip not loaded: %w", err)) return nil } return err } // resolveEnv fills in the defaults Create and Open share: the real // environment when none is given, and OS storage and the real clock when it // has none. func resolveEnv(env *simio.Env) *simio.Env { if env == nil { env = simio.RealEnv() } if env.Storage == nil { env.Storage = simio.OSStorage{} } if env.Clock == nil { env.Clock = simio.RealClock{} } return env } // Open opens an existing checkout database. If opts.SearchParents is true, // it searches parent directories for the checkout DB using FindCheckoutDB. // // Panics if r or dir is nil/empty (TigerStyle precondition). func Open(r *repo.Repo, dir string, opts OpenOpts) (*Checkout, error) { if r == nil { panic("checkout.Open: nil *repo.Repo") } if dir == "" { panic("checkout.Open: empty dir") } env := resolveEnv(opts.Env) obs := resolveObserver(opts.Observer) // Verify a SQLite driver is registered before opening the checkout DB. if db.RegisteredDriver() == nil { return nil, fmt.Errorf("checkout.Open: no SQLite driver registered") } // Find the checkout DB dbPath, err := FindCheckoutDB(env.Storage, dir, opts.SearchParents) if err != nil { return nil, fmt.Errorf("checkout.Open: %w", err) } // Open the checkout database ckdb, err := db.OpenSQL(dbPath, db.OpenConfig{}, nil) if err != nil { return nil, fmt.Errorf("checkout.Open: sql.Open: %w", err) } // Ping to validate the connection if err := ckdb.Ping(); err != nil { ckdb.Close() return nil, fmt.Errorf("checkout.Open: ping: %w", err) } // Verify vvar checkout exists checkoutVal, err := getVVar(ckdb, "checkout") if err != nil { ckdb.Close() return nil, fmt.Errorf("checkout.Open: %w", err) } if checkoutVal == "" { ckdb.Close() return nil, fmt.Errorf("checkout.Open: vvar checkout not found") } c := &Checkout{ db: ckdb, repo: r, env: env, obs: obs, dir: filepath.Dir(dbPath), } if err := c.ValidateFingerprint(); err != nil { ckdb.Close() return nil, fmt.Errorf("checkout.Open: %w", err) } return c, nil } // Close closes the checkout database. Does NOT close the repo. func (c *Checkout) Close() error { if c == nil { panic("checkout.Close: nil *Checkout") } if c.db == nil { panic("checkout.Close: nil *sql.DB (already closed?)") } return c.db.Close() } // Dir returns the checkout directory. func (c *Checkout) Dir() string { if c == nil { panic("checkout.Dir: nil *Checkout") } return c.dir } // Repo returns the associated repo. func (c *Checkout) Repo() *repo.Repo { if c == nil { panic("checkout.Repo: nil *Checkout") } return c.repo } // ErrNoVersion is returned (wrapped) by Version when the checkout database // records no checked-out version, as after an open with nothing to check out. var ErrNoVersion = errors.New("checkout: no checkout version") // Version returns the current checkout version (RID and UUID). func (c *Checkout) Version() (libfossil.FslID, string, error) { if c == nil { panic("checkout.Version: nil *Checkout") } ridStr, err := getVVar(c.db, "checkout") if err != nil { return 0, "", fmt.Errorf("checkout.Version: %w", err) } if ridStr == "" { return 0, "", fmt.Errorf("checkout.Version: vvar checkout not set: %w", ErrNoVersion) } rid64, err := strconv.ParseInt(ridStr, 10, 64) if err != nil { return 0, "", fmt.Errorf("checkout.Version: parse RID: %w", err) } if rid64 < 0 { return 0, "", fmt.Errorf("checkout.Version: negative checkout RID %d", rid64) } uuid, err := getVVar(c.db, "checkout-hash") if err != nil { return 0, "", fmt.Errorf("checkout.Version: %w", err) } if rid64 != 0 && uuid == "" { return 0, "", fmt.Errorf("checkout.Version: vvar checkout-hash not set") } if rid64 == 0 && uuid != "" { return 0, "", fmt.Errorf("checkout.Version: unborn checkout has non-empty checkout-hash") } return libfossil.FslID(rid64), uuid, nil } // ValidateFingerprint verifies that the checkout-hash vvar matches the blob uuid in the repo. func (c *Checkout) ValidateFingerprint() error { if c == nil { panic("checkout.ValidateFingerprint: nil *Checkout") } rid, uuid, err := c.Version() if err != nil { return fmt.Errorf("checkout.ValidateFingerprint: %w", err) } if rid == 0 { if uuid != "" { return fmt.Errorf("checkout.ValidateFingerprint: unborn checkout has non-empty checkout-hash") } return nil } // Query the repo for the blob uuid var repoUUID string err = c.repo.DB().QueryRow("SELECT uuid FROM blob WHERE rid = ?", rid).Scan(&repoUUID) if err != nil { return fmt.Errorf("checkout.ValidateFingerprint: query blob: %w", err) } if repoUUID != uuid { return fmt.Errorf( "checkout.ValidateFingerprint: mismatch (checkout: %s, repo: %s)", uuid, repoUUID, ) } return nil } // maxParentSearchDepth is the maximum number of parent directories to search. const maxParentSearchDepth = 256 // FindCheckoutDB searches for a checkout database (.fslckout or _FOSSIL_) in dir, // and optionally in parent directories if searchParents is true. // Returns the full path to the DB file. func FindCheckoutDB(storage simio.Storage, dir string, searchParents bool) (string, error) { if storage == nil { panic("checkout.FindCheckoutDB: nil Storage") } if dir == "" { panic("checkout.FindCheckoutDB: empty dir") } currentDir := dir for depth := 0; depth <= maxParentSearchDepth; depth++ { // Try each DB name for _, name := range DBNames() { dbPath := filepath.Join(currentDir, name) if _, err := storage.Stat(dbPath); err == nil { return dbPath, nil } } if !searchParents { break } // Move to parent directory parentDir := filepath.Dir(currentDir) if parentDir == currentDir { // Reached the root break } currentDir = parentDir } return "", fmt.Errorf("checkout.FindCheckoutDB: no checkout DB found in %s", dir) } // PreferredDBName returns the preferred checkout DB name based on the OS. // Returns "_FOSSIL_" on Windows, ".fslckout" elsewhere. func PreferredDBName() string { if runtime.GOOS == "windows" { return "_FOSSIL_" } return ".fslckout" } // DBNames returns all possible checkout DB names, with the preferred name first. func DBNames() []string { if runtime.GOOS == "windows" { return []string{"_FOSSIL_", ".fslckout"} } return []string{".fslckout", "_FOSSIL_"} }