Skip to content
go-libfossil Artifact [3baec0a81b]
Login

Artifact [3baec0a81b]

Artifact 3baec0a81b78b04f67ef9dc9a01e8d5d53631b8c2663f0e9d0ad7b3e970630fe:


package libfossil

import (
	"context"
	"errors"
	"fmt"

	"github.com/danmestas/go-libfossil/internal/checkout"
	"github.com/danmestas/go-libfossil/internal/fsltype"
	"github.com/danmestas/go-libfossil/simio"
)

// Checkout represents a working directory linked to a Fossil repository.
// A Checkout is not safe for concurrent use. Callers must serialize
// access to a single Checkout instance.
type Checkout struct {
	inner *checkout.Checkout
}

// CheckoutCreateOpts configures creating a new checkout.
type CheckoutCreateOpts struct {
	Env      *simio.Env       // nil = real env
	Observer CheckoutObserver // nil = nop
}

// CheckoutOpenOpts configures opening an existing checkout.
type CheckoutOpenOpts struct {
	SearchParents bool       // search parent dirs for .fslckout
	Env           *simio.Env // nil = real env
	Observer      CheckoutObserver
}

// ExtractOpts configures file extraction from a checkin.
type ExtractOpts struct {
	Force bool // overwrite uncommitted changes
}

// UpdateOpts configures updating to a new version with merge.
type UpdateOpts struct {
	// TargetRID is the check-in to update to. 0 picks it as fossil's update
	// does with no version named: the newest open leaf descended from the
	// checkout on its branch. Update fails when several such leaves remain.
	TargetRID int64
}

// UpdateResult reports what Update actually did. Paths, not counts: a
// caller needs to know which files to show a user, not merely how many
// changed. All three slices are sorted (sort.Strings) before Update
// returns, so the order is deterministic — safe for golden-file tests or
// reflect.DeepEqual — rather than depending on Go's randomized map
// iteration order.
//
// Outcome mapping (Update's error return is the other half of this):
//
//	clean       - err == nil, Conflicted empty
//	conflicted  - err == nil, Conflicted non-empty
//	failed      - err != nil, UpdateResult is the zero value
//
// A non-empty Conflicted means three-way merge could not resolve those
// files cleanly and wrote "<<<<<<<"-style conflict markers directly into
// them on disk. That is NOT an error — Update still returns a nil error,
// because the update itself succeeded and the working tree is usable; it
// just isn't clean. A caller that only checks the returned error gets
// today's pre-conflict-detection behavior. A caller that needs to warn a
// user about marker text in their files must check Conflicted. Every path
// in Conflicted also appears in FilesWritten: the marker text is what was
// written there.
type UpdateResult struct {
	FilesWritten []string // paths written (added, updated, or merged, clean or not); includes every Conflicted path
	FilesRemoved []string // paths deleted from the working tree
	Conflicted   []string // paths that now contain conflict markers — not an error; also present in FilesWritten
}

// RevertOpts configures reverting file changes.
type RevertOpts struct {
	Files []string // empty = revert all
}

// CommitOpts configures creating a checkin from the checkout.
// (This type is distinct from the existing libfossil.CommitOpts which takes
// explicit file content for direct repo commits without a checkout.)
type CheckoutCommitOpts struct {
	Message string
	User    string
	Branch  string   // empty = current branch
	Tags    []string // additional tag names
	Delta   bool
	// Files limits the commit to these checkout-relative paths; empty
	// commits every change.
	Files []string
	// AllowConflict commits files that still hold merge conflict markers,
	// which Checkin otherwise refuses, as fossil's commit does.
	AllowConflict bool
	// AllowEmpty commits even when nothing changed, which Checkin otherwise
	// refuses, as fossil's commit does.
	AllowEmpty bool
}

// CheckoutMergeOpts configures Checkout.Merge.
type CheckoutMergeOpts struct {
	Version  int64  // rid of the checkin to merge in; required
	Strategy string // strategy for every file; "" picks one per file
	DryRun   bool   // report what the merge would do, changing nothing
}

// CheckoutMergeResult reports a merge.
type CheckoutMergeResult struct {
	Ancestor int64               // the common ancestor the merge diffed against
	Files    []CheckoutMergeFile // sorted by name; empty when there was nothing to merge
}

// CheckoutMergeFile reports what a merge did to one file. Action is one of:
//
//	"updated"  - only the merged-in version changed it; its content was taken
//	"merged"   - both sides changed it and the strategy combined them cleanly
//	"conflict" - merged with conflict markers written into the file
//	"kept"     - could not be merged; the checkout's copy is unchanged (see Reason)
//	"forked"   - the conflict-fork strategy recorded every version for later
//	"added"    - new in the merged-in version
//	"deleted"  - deleted in the merged-in version, unchanged here
//	"mode"     - only the merged-in version changed its executable bit; taken
type CheckoutMergeFile struct {
	Name     string
	Action   string
	Strategy string // the strategy that merged it, when one ran
	Reason   string // why the file was kept, for "kept"
}

// CheckoutChange describes a single file change in the checkout.
type CheckoutChange struct {
	Name   string
	Change string // "added", "modified", "deleted", "renamed", "missing"
}

// CreateCheckout creates a new checkout directory linked to this repository.
// The directory is created if it does not exist. The checkout is initialized
// to the tip checkin.
func (r *Repo) CreateCheckout(dir string, opts CheckoutCreateOpts) (*Checkout, error) {
	inner, err := checkout.Create(r.inner, dir, checkout.CreateOpts{
		Env:      opts.Env,
		Observer: adaptCheckoutObserver(opts.Observer),
	})
	if err != nil {
		return nil, fmt.Errorf("libfossil: create checkout: %w", err)
	}
	return &Checkout{inner: inner}, nil
}

// OpenCheckout opens an existing checkout directory linked to this repository.
func (r *Repo) OpenCheckout(dir string, opts CheckoutOpenOpts) (*Checkout, error) {
	inner, err := checkout.Open(r.inner, dir, checkout.OpenOpts{
		SearchParents: opts.SearchParents,
		Env:           opts.Env,
		Observer:      adaptCheckoutObserver(opts.Observer),
	})
	if err != nil {
		return nil, fmt.Errorf("libfossil: open checkout: %w", err)
	}
	return &Checkout{inner: inner}, nil
}

// Close closes the checkout database. Does NOT close the repo.
func (c *Checkout) Close() error {
	if c == nil || c.inner == nil {
		return nil
	}
	return c.inner.Close()
}

// Dir returns the checkout directory path.
func (c *Checkout) Dir() string {
	return c.inner.Dir()
}

// Version returns the current checkout version (RID and UUID).
func (c *Checkout) Version() (int64, string, error) {
	rid, uuid, err := c.inner.Version()
	if err != nil {
		return 0, "", fmt.Errorf("libfossil: checkout version: %w", err)
	}
	return int64(rid), uuid, nil
}

// Extract writes files from the specified checkin to the working directory
// and makes it the checkout's current version. Unless opts.Force is set it
// refuses, changing nothing, when the checkout has unsaved changes or when
// an untracked file sits where the checkin would write different content.
func (c *Checkout) Extract(rid int64, opts ExtractOpts) error {
	err := c.inner.Extract(fsltype.FslID(rid), checkout.ExtractOpts{
		Force: opts.Force,
	})
	if err != nil {
		return fmt.Errorf("libfossil: extract: %w", err)
	}
	return nil
}

// Update updates the checkout to a new version, performing 3-way merge
// where needed to preserve local modifications. See UpdateResult for how to
// distinguish a clean update from one that wrote conflict markers into
// working-tree files.
func (c *Checkout) Update(opts UpdateOpts) (UpdateResult, error) {
	res, err := c.inner.Update(checkout.UpdateOpts{
		TargetRID: fsltype.FslID(opts.TargetRID),
	})
	if err != nil {
		return UpdateResult{}, fmt.Errorf("libfossil: update: %w", err)
	}
	return UpdateResult{
		FilesWritten: res.FilesWritten,
		FilesRemoved: res.FilesRemoved,
		Conflicted:   res.Conflicted,
	}, nil
}

// HasChanges reports whether the checkout has any added, removed, renamed or
// modified files. It reads only the checkout database, so an edit on disk
// counts once Status (or an Extract or Commit) has scanned it; adds, removals
// and renames always count. Missing files need the disk; Status reports them.
func (c *Checkout) HasChanges() (bool, error) {
	has, err := c.inner.HasChanges()
	if err != nil {
		return false, fmt.Errorf("libfossil: has changes: %w", err)
	}
	return has, nil
}

// Status scans the working directory and returns the changed files, each
// classified as added, deleted, missing, renamed or modified, the way fossil's
// changes command classifies them. Wraps ScanChanges + VisitChanges.
func (c *Checkout) Status() ([]CheckoutChange, error) {
	rid, _, err := c.inner.Version()
	if err != nil {
		return nil, fmt.Errorf("libfossil: status: %w", err)
	}

	var changes []CheckoutChange
	err = c.inner.VisitChanges(rid, true, func(entry checkout.ChangeEntry) error {
		changes = append(changes, CheckoutChange{
			Name:   entry.Name,
			Change: fileChangeString(entry.Change),
		})
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("libfossil: status: %w", err)
	}
	return changes, nil
}

// Add adds files to version tracking. Returns the number of files added.
// Files already tracked are silently skipped.
func (c *Checkout) Add(patterns []string) (int, error) {
	counts, err := c.inner.Manage(checkout.ManageOpts{
		Paths: patterns,
	})
	if err != nil {
		return 0, fmt.Errorf("libfossil: add: %w", err)
	}
	return counts.Added, nil
}

// Remove removes files from version tracking. Newly added files are
// deleted from vfile; committed files are marked as deleted.
func (c *Checkout) Remove(patterns []string) error {
	err := c.inner.Unmanage(checkout.UnmanageOpts{
		Paths: patterns,
	})
	if err != nil {
		return fmt.Errorf("libfossil: remove: %w", err)
	}
	return nil
}

// Rename marks a tracked file as renamed and moves it on disk.
func (c *Checkout) Rename(oldName, newName string) error {
	err := c.inner.Rename(checkout.RenameOpts{
		From:     oldName,
		To:       newName,
		DoFsMove: true,
	})
	if err != nil {
		return fmt.Errorf("libfossil: rename: %w", err)
	}
	return nil
}

// Revert restores files to their checkout version state.
// If opts.Files is empty, reverts all changed files.
func (c *Checkout) Revert(opts RevertOpts) error {
	err := c.inner.Revert(checkout.RevertOpts{
		Paths: opts.Files,
	})
	if err != nil {
		return fmt.Errorf("libfossil: revert: %w", err)
	}
	return nil
}

// Checkin creates a new checkin from the checkout working directory: every
// change, or only opts.Files (a directory names every tracked file under
// it). Pending merges become merge parents. Like fossil's commit, it refuses
// a name it does not track, a commit with nothing changed and no merge
// pending (unless opts.AllowEmpty), files that still hold merge conflict
// markers (unless opts.AllowConflict), and committing only some files of a
// pending merge.
// Returns the RID and UUID of the new checkin manifest.
func (c *Checkout) Checkin(opts CheckoutCommitOpts) (rid int64, uuid string, err error) {
	if len(opts.Files) > 0 {
		if err := c.inner.Enqueue(checkout.EnqueueOpts{Paths: opts.Files}); err != nil {
			return 0, "", fmt.Errorf("libfossil: checkin: %w", err)
		}
		// The queue lives only for this call.
		defer func() {
			if qerr := c.inner.DiscardQueue(); qerr != nil && err == nil {
				err = fmt.Errorf("libfossil: checkin: %w", qerr)
			}
		}()
	}
	innerRID, uuid, err := c.inner.Commit(checkout.CommitOpts{
		Message: opts.Message,
		User:    opts.User,
		Branch:  opts.Branch,
		Tags:    opts.Tags,
		Delta:   opts.Delta,

		AllowConflict: opts.AllowConflict,
		AllowEmpty:    opts.AllowEmpty,
	})
	if err != nil {
		return 0, "", fmt.Errorf("libfossil: checkin: %w", err)
	}
	return int64(innerRID), uuid, nil
}

// Merge merges another checkin into the checkout, as fossil's merge command
// does, leaving the result for the next Checkin to commit with the
// merged-in checkin as a merge parent. A file both sides changed is merged
// by its strategy; when the strategy cannot reconcile the changes, fossil's
// conflict markers are written into the file, and the file is in conflict
// until they are edited out (see Conflicts). A conflicted merge is not an
// error.
//
// Merge refuses, changing nothing, when the repository lacks content it
// needs or an added file would overwrite an untracked one. A file edited
// here but deleted in the merged-in version is kept and reported. Renames
// are not followed. Merging the checkout's own version or one of its
// ancestors does nothing.
func (c *Checkout) Merge(opts CheckoutMergeOpts) (CheckoutMergeResult, error) {
	res, err := c.inner.Merge(checkout.MergeOpts{
		Version:  fsltype.FslID(opts.Version),
		Strategy: opts.Strategy,
		DryRun:   opts.DryRun,
	})
	if err != nil {
		return CheckoutMergeResult{}, fmt.Errorf("libfossil: merge: %w", err)
	}
	out := CheckoutMergeResult{Ancestor: int64(res.Ancestor)}
	for _, f := range res.Files {
		out.Files = append(out.Files, CheckoutMergeFile{
			Name:     f.Name,
			Action:   mergeActionString(f.Action),
			Strategy: f.Strategy,
			Reason:   f.Reason,
		})
	}
	return out, nil
}

// Conflicts lists the files, sorted, that hold merge conflict markers: the
// files a merge or update left in conflict and nobody has resolved yet.
func (c *Checkout) Conflicts() ([]string, error) {
	files, err := c.inner.Conflicts()
	if err != nil {
		return nil, fmt.Errorf("libfossil: conflicts: %w", err)
	}
	return files, nil
}

// mergeActionString names a merge action for CheckoutMergeFile.Action.
func mergeActionString(a checkout.MergeAction) string {
	switch a {
	case checkout.MergeUpdated:
		return "updated"
	case checkout.MergeMerged:
		return "merged"
	case checkout.MergeConflict:
		return "conflict"
	case checkout.MergeKept:
		return "kept"
	case checkout.MergeForked:
		return "forked"
	case checkout.MergeAdded:
		return "added"
	case checkout.MergeDeleted:
		return "deleted"
	case checkout.MergeMode:
		return "mode"
	default:
		panic(fmt.Sprintf("libfossil: unknown merge action %d", a))
	}
}

// WouldFork reports whether committing on the checkout's version would
// create a fork: the version already has a child on its own branch (for
// example after a pull), so the commit would become a second leaf. This is
// fossil's "Would fork" commit refusal; run Update first, or commit to a new
// branch. Another open leaf elsewhere on the branch does not count.
func (c *Checkout) WouldFork() (bool, error) {
	fork, err := c.inner.WouldFork()
	if err != nil {
		return false, fmt.Errorf("libfossil: would fork: %w", err)
	}
	return fork, nil
}

// fileChangeString converts an internal FileChange enum to a human-readable string.
func fileChangeString(fc checkout.FileChange) string {
	switch fc {
	case checkout.ChangeAdded:
		return "added"
	case checkout.ChangeRemoved:
		return "deleted"
	case checkout.ChangeMissing:
		return "missing"
	case checkout.ChangeRenamed:
		return "renamed"
	case checkout.ChangeModified:
		return "modified"
	default:
		return "unknown"
	}
}

// checkoutObserverAdapter bridges the public CheckoutObserver to the internal
// checkout.Observer (which carries context.Context).
type checkoutObserverAdapter struct {
	pub CheckoutObserver
}

func (a *checkoutObserverAdapter) ExtractStarted(ctx context.Context, e checkout.ExtractStart) context.Context {
	a.pub.ExtractStarted(ExtractStart{
		RID: int64(e.TargetRID),
	})
	return ctx
}

func (a *checkoutObserverAdapter) ExtractFileCompleted(_ context.Context, name string, change checkout.UpdateChange) {
	a.pub.ExtractFileCompleted(name, convertUpdateChange(change))
}

func (a *checkoutObserverAdapter) ExtractCompleted(_ context.Context, e checkout.ExtractEnd) {
	a.pub.ExtractCompleted(ExtractEnd{
		FilesWritten: e.FilesWritten,
	})
}

func (a *checkoutObserverAdapter) ScanStarted(ctx context.Context) context.Context {
	a.pub.ScanStarted("")
	return ctx
}

func (a *checkoutObserverAdapter) ScanCompleted(_ context.Context, e checkout.ScanEnd) {
	a.pub.ScanCompleted(ScanEnd{
		FilesScanned: e.FilesScanned,
	})
}

func (a *checkoutObserverAdapter) CommitStarted(ctx context.Context, e checkout.CommitStart) context.Context {
	a.pub.CommitStarted(CommitStart{
		User:  e.User,
		Files: e.FilesEnqueued,
	})
	return ctx
}

func (a *checkoutObserverAdapter) CommitCompleted(_ context.Context, e checkout.CommitEnd) {
	a.pub.CommitCompleted(CommitEnd{
		UUID: e.UUID,
		RID:  int64(e.RID),
	})
}

func (a *checkoutObserverAdapter) Error(_ context.Context, err error) {
	a.pub.Error(err)
}

// adaptCheckoutObserver returns an internal checkout.Observer wrapping the
// public CheckoutObserver. Returns nil if pub is nil.
func adaptCheckoutObserver(pub CheckoutObserver) checkout.Observer {
	if pub == nil {
		return nil
	}
	return &checkoutObserverAdapter{pub: pub}
}

// convertUpdateChange maps internal checkout.UpdateChange to public UpdateChange.
func convertUpdateChange(c checkout.UpdateChange) UpdateChange {
	switch c {
	case checkout.UpdateAdded, checkout.UpdateAddPropagated:
		return ChangeAdded
	case checkout.UpdateRemoved, checkout.UpdateRmPropagated:
		return ChangeDeleted
	case checkout.UpdateUpdated, checkout.UpdateUpdatedBinary,
		checkout.UpdateMerged, checkout.UpdateEdited:
		return ChangeModified
	default:
		return ChangeModified
	}
}

// ResolveVersion resolves name as Repo.ResolveVersion does and also
// resolves the names relative to this checkout (repo-state-01 Section 8.3),
// tried after "tip" and before every other form, as fossil's
// symbolic_name_to_rid does (src/name.c):
//
//	"current"            the checkout's version
//	"prev", "previous"   its primary parent
//	"next"               a child: a primary child first, then the newest link
//
// A keyword that names nothing, such as "prev" at a root, falls through to the
// other forms, as in fossil. The forms that take a name, such as
// "root:current", resolve it the same way. "ckout" is an ordinary name here:
// it is a keyword only in the checkout-inclusive request, ResolveWorking.
func (c *Checkout) ResolveVersion(name string) (int64, error) {
	ckout, err := c.versionOrZero(name)
	if err != nil {
		return 0, err
	}
	return resolveVersion(c.inner.Repo().DB(), name, ckout, false)
}

// WorkingVersion is the answer of a checkout-inclusive request: a check-in,
// or the checkout's working files.
type WorkingVersion struct {
	// RID is the check-in the name resolves to. When Working is set it is
	// the checkout's version, the check-in the working files started from.
	RID int64
	// Working reports that the name was "ckout": the request asks for the
	// files on disk, edits included, rather than for any stored check-in.
	Working bool
}

// ResolveWorking is fossil's checkout-inclusive request ("ci+",
// symbolic_name_to_rid in src/name.c): while the checkout has a version,
// "ckout" names its working files, reported as Working. Every other name,
// and "ckout" in a checkout with no version, resolves as ResolveVersion
// does, except that only check-ins can answer: the request is a check-in
// request, so a technote or wiki page carrying the tag does not.
//
// As repo-state-01 Section 8.3 says, only "ci+" has the keyword, so
// ResolveVersion and Repo.ResolveBranchStart read "ckout" as an ordinary
// name. It is a keyword only as the whole name: in "root:ckout" or "ckout."
// the inner name is resolved as a plain check-in request, as in fossil.
func (c *Checkout) ResolveWorking(name string) (WorkingVersion, error) {
	ckout, err := c.versionOrZero(name)
	if err != nil {
		return WorkingVersion{}, err
	}
	if name == "ckout" && ckout > 0 {
		return WorkingVersion{RID: ckout, Working: true}, nil
	}
	rid, err := resolveVersion(c.inner.Repo().DB(), name, ckout, true)
	if err != nil {
		return WorkingVersion{}, err
	}
	return WorkingVersion{RID: rid}, nil
}

// versionOrZero is the checkout's version, or 0 when it records none: fossil
// guards its checkout keywords on a nonzero checkout version and resolves
// the other forms regardless.
func (c *Checkout) versionOrZero(name string) (int64, error) {
	if c == nil || c.inner == nil {
		panic("libfossil: resolve version on a nil or closed Checkout")
	}
	rid, _, err := c.inner.Version()
	if errors.Is(err, checkout.ErrNoVersion) {
		return 0, nil
	}
	if err != nil {
		return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err)
	}
	return int64(rid), nil
}