Skip to content
go-libfossil Artifact [900a23bddc]
Login

Artifact [900a23bddc]

Artifact 900a23bddc0c396870cdf5c8e89aa6139d8effe13059aa355dd277627f971d26:


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

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