Artifact 9a6cc311240343cc8f4951ac32220feeef4f565ab007ab4534668c38ef5b2315:
- File
internal/checkout/checkout.go
— part of check-in
[bb9805a794]
at
2026-10-21 11:58:06
on branch feat/submanifest-reader
— fix(checkout): recover a checkout marker move a crash interrupted
A crash between moveMarker's link and unlink left one checkout database under both the native and the private name, which discovery refused, so the checkout could not be opened again. moveMarker now records its target name in the database before linking, and Open completes the move to that name. A still-running transition keeps the same name, so an Open racing it converges instead of removing both names, and moveMarker treats an old name already removed as done.
Crash injection at every step of narrowing, widening and legacy migration reopens as the old or new state with files matching the cone; stock fossil either refuses the directory or lists the dense record, before and after recovery. Clears the SC-4.1-2 scope note. (user: [email protected], size: 14383) [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" "os" "path/filepath" "runtime" "strconv" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/cone" 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 dbPath string cone *cone.Cone // nil for a dense checkout 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 } // initLocalRecords records the repository, so tools can find it, and the // sparse cone, if any. func initLocalRecords(ckdb *sql.DB, r *repo.Repo, sparse *cone.Cone) error { if err := setVVar(ckdb, "repository", r.Path()); err != nil { return err } return writeCone(ckdb, sparse) } // Create creates a checkout database, finds the tip checkin from the repo, // and sets vvar checkout/checkout-hash. Dense checkouts use the native name; // sparse checkouts use .libfossil-checkout, which native Fossil does not open. // // 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") } // SQLite uses the real path even when working files use simulated storage. for _, storage := range []simio.Storage{env.Storage, simio.OSStorage{}} { marker, err := checkoutMarker(storage, dir) if err != nil { return nil, fmt.Errorf("checkout.Create: %w", err) } if marker != "" { return nil, fmt.Errorf("checkout.Create: checkout marker already exists: %s", marker) } } name := PreferredDBName() if opts.Cone != nil { name = sparseDBName } dbPath := filepath.Join(dir, name) if err := checkMarkerTarget(dbPath); err != nil { return nil, fmt.Errorf("checkout.Create: %w", err) } // 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 } if opts.Cone != nil { if err := ensureSparsePending(ckdb); err != nil { ckdb.Close() return nil, err } } if err := initLocalRecords(ckdb, r, opts.Cone); err != nil { ckdb.Close() return nil, fmt.Errorf("checkout.Create: %w", err) } c := &Checkout{ db: ckdb, dbPath: dbPath, repo: r, env: env, obs: obs, dir: dir, cone: opts.Cone, } 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") } ckdb, dbPath, err := openCheckoutDB(env.Storage, dir, opts.SearchParents) if err != nil { return nil, fmt.Errorf("checkout.Open: %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") } sparse, err := readCone(ckdb) if err != nil { ckdb.Close() return nil, fmt.Errorf("checkout.Open: %w", err) } if sparse != nil { if err := ensureSparsePending(ckdb); err != nil { ckdb.Close() return nil, err } } c := &Checkout{ db: ckdb, dbPath: dbPath, repo: r, env: env, obs: obs, dir: filepath.Dir(dbPath), cone: sparse, } if err := c.ValidateFingerprint(); err != nil { ckdb.Close() return nil, fmt.Errorf("checkout.Open: %w", err) } if err := c.migrateSparseRows(); err != nil { c.db.Close() return nil, fmt.Errorf("checkout.Open: migrate sparse records: %w", err) } return c, nil } // openCheckoutDB finds the checkout database, first completing a marker move // a crash interrupted, and opens it. func openCheckoutDB(storage simio.Storage, dir string, searchParents bool) (*sql.DB, string, error) { dbPath, err := FindCheckoutDB(storage, dir, searchParents) var pair *markerPairError if errors.As(err, &pair) { if err := recoverMarkerPair(pair); err != nil { return nil, "", err } dbPath, err = FindCheckoutDB(storage, dir, searchParents) } if err != nil { return nil, "", err } ckdb, err := db.OpenSQL(dbPath, db.OpenConfig{}, nil) if err != nil { return nil, "", fmt.Errorf("sql.Open: %w", err) } if err := ckdb.Ping(); err != nil { ckdb.Close() return nil, "", fmt.Errorf("ping: %w", err) } return ckdb, dbPath, 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++ { marker, err := checkoutMarker(storage, currentDir) if err != nil { return "", fmt.Errorf("checkout.FindCheckoutDB: %w", err) } if marker != "" { return marker, 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: %w", dir, os.ErrNotExist) } // 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" } const sparseDBName = ".libfossil-checkout" func checkoutMarker(storage simio.Storage, dir string) (string, error) { var found string for _, name := range DBNames() { candidate := filepath.Join(dir, name) stat := storage.Stat if local, ok := storage.(interface { Lstat(string) (os.FileInfo, error) }); ok { stat = local.Lstat } _, err := stat(candidate) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return "", fmt.Errorf("inspect checkout marker %s: %w", candidate, err) } if found != "" { if pair := linkedMarkerPair(found, candidate); pair != nil { return "", pair } return "", fmt.Errorf("multiple checkout markers in %s: %s and %s", dir, found, candidate) } found = candidate } return found, nil } // DBNames returns all possible checkout DB names, with the preferred native name first. func DBNames() []string { if runtime.GOOS == "windows" { return []string{"_FOSSIL_", ".fslckout", sparseDBName} } return []string{".fslckout", "_FOSSIL_", sparseDBName} }