Skip to content
go-libfossil Artifact [9a6cc31124]
Login

Artifact [9a6cc31124]

Artifact 9a6cc311240343cc8f4951ac32220feeef4f565ab007ab4534668c38ef5b2315:


// 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}
}