Artifact 3af6dd5b5df2c923d06b89d82913c218e7c93adec7a3644b252a990a2626af72:
- File
checkout.go
— part of check-in
[0889d8b777]
at
2026-10-13 13:36:12
on branch main
— feat(resolve): checkout-inclusive request where ckout names the working files (#420)
* feat(resolve): checkout-inclusive request where ckout names the working files
Checkout.ResolveWorking is fossil's "ci+" request (repo-state-01 8.3): while the checkout has a version, "ckout" answers the working files, reported as WorkingVersion.Working rather than as a pseudo-RID. Every other name goes through the same resolver as Checkout.ResolveVersion, restricted to check-ins as fossil's ci+ is. The draft also makes ckout a keyword in a plain "ci" request; this follows fossil, where only ci+ has it.
New repostate.8.3 cases run the request against fossil test-name-to-id --type ci+, and RS-8.3-4 loses its scope note. Tracked in #419.
* fix(resolve): record the ckout retry as a fossil deviation; tighten tests (user: [email protected], size: 20668) [annotate] [blame] [check-ins using]
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. // // repo-state-01 Section 8.3 makes "ckout" a keyword in a plain check-in // request too. This follows fossil, where only "ci+" has it (an erratum to // the draft is pending), so ResolveVersion and Repo.ResolveBranchStart still // read "ckout" as an ordinary name. It is a keyword only as the whole name: // in "root:ckout" or "ckout." fossil resolves the inner name as a plain // check-in request, and so does this. 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 }