Artifact ac6a8dcc45b901c367c45238a83feeb9e4f1576f3b49d81a02ad6c4d1b80080a:
- File
internal/checkout/checkout.go
— part of check-in
[a317896683]
at
2026-10-03 16:16:26
on branch trunk
— fix(checkout): refuse to load a version with missing content (#231) (#240)
* fix(checkout): refuse to load a version with missing content (#231)
LoadVFile wrote a vfile row with rid=0 for each file whose content the repo does not hold (a phantom, or a delta on one), as a partial sync leaves it. In fossil's vfile rid=0 means "added", so those rows were indistinguishable from pending adds, and Extract then panicked in content.Expand(0) trying to write them.
LoadVFile now does what fossil's checkout does: it resolves every file first and refuses the version, naming up to ten missing files and wrapping errMissingContent, before changing vfile at all. It no longer returns a missing count, which no caller used.
Create is the one caller that tolerates the refusal. A tip that a partial sync has not finished is no reason to fail CreateCheckout: the caller may be about to extract an older, complete version (EdgeSync's ExtractTo). vfile then stays empty instead of claiming files the repo cannot produce. Create's environment defaulting, shared with Open, moves to resolveEnv, and the initial load to loadInitialVFile.
Tests cover the refusal (vfile untouched, files named), Extract leaving the checkout as it was, Create on a partial tip followed by Extract of a complete version, and parity with the fossil binary, which refuses the same repository with "missing content".
* fix(checkout): refuse missing content in Update before writing (#231)
Update wrote changed files to disk and only then reloaded vfile, which now refuses a version with missing content. A target sharing a missing file with the current version (its content is never read, because it does not change) could therefore leave disk on the target while vvar and vfile stayed on the old version. Like fossil's update, Update now resolves the target's content before writing anything, through resolveFiles, the resolution step split out of LoadVFile.
Update moves its target selection into updateTarget, reuses Extract's checkinTime, and builds its result through sortedUpdateResult, bringing it under 70 lines. Create now reports a tip it could not load to the observer instead of tolerating it silently.
* fix(checkout): report Update refusals to observers; tighten checks (#231)
The missing-content check in Update moves after ExtractStarted, so a refused update still reaches observers as an ExtractCompleted carrying the error, as other Update failures do. A negative target from the public UpdateOpts is an error, not a panic. resolveFiles errors carry their own prefix instead of LoadVFile's.
Adds the entry and exit assertions the new helpers were missing, a compile-time check on missingNamesMax, and wraps lines over 100 columns; tests check Version's error instead of discarding it. (user: [email protected], size: 11294) [annotate] [blame] [check-ins using]
// 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 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{} } 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 } // 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") } 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_"} }