Artifact 70968173bbaa46d4663bfc2b9e6e1395179b69a75f2ceb61fff651ced96de7b8:
- File
checkout.go
— part of check-in
[80764da743]
at
2026-10-19 20:09:26
on branch feat/submanifest-reader
— fix(checkout): revert renames onto taken names, saving undo first
Checkout.Revert refused to undo a rename when another file held the old name: a swap, a chain, a new add or an untracked file there. Like fossil's revert it now saves each file it writes or deletes for undo, then reverts in one transaction, so Undo or fossil undo brings back what it overwrote, and a failed revert puts the files back.
Undoing a rename takes back the old name from whatever row holds it: a row renamed onto it is reverted to its own old name, and any other row is dropped, as fossil's UPDATE OR REPLACE drops it. Every committed file ends under its committed name; fossil's own revert leaves a swap renamed and stops tracking the second file of a chain.
Tracked in #248. (user: [email protected], size: 22481) [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 // Cone makes the checkout sparse: Extract, Update and Status work only on // the files it covers, and Checkin carries the others from the parent // unchanged. nil is dense. See Cone and Checkout.SetCone. Cone *Cone } // 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. // // Unmerged lists files left for the caller to reconcile that hold no // markers: a file edited locally that the target deletes (kept on disk, // no longer tracked), a file added both locally and by the target (the // local file kept, so it shows as edited), a binary file both sides // changed (the target's content written, the working copy saved as // NAME-original), and a file edited locally that the target renamed // without changing it (left under its old name, untracked; the new name // shows as missing, as in fossil). Like Conflicted, it is not an error. 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 Unmerged []string // paths left to reconcile without markers — not an error // OutOfCone counts the changes a sparse checkout's update applied to files // outside its cone: recorded for the next checkin, never written to disk. OutOfCone int } // 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. // // Change is "added", "modified", "deleted", "renamed" or "missing", or one of // the mode changes fossil's changes command reports: "executable" (became // executable), "symlink" (became a symlink), "unexec" (lost the executable // bit) or "unlink" (was a symlink, now is not). type CheckoutChange struct { Name string Change string } // 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), Cone: unwrapCone(opts.Cone), }) 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 moves the checkout to a new version as fossil's update does, // carrying uncommitted changes into it: a locally edited file is merged // three ways (the checkout's version as base, the working file as local, // the target as other), one the target deletes is kept untracked, and // pending adds and removals stay pending. 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, Unmerged: res.Unmerged, OutOfCone: res.OutOfCone, }, 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. A renamed file goes // back to its committed name, whatever holds that name now. Each file it // writes or deletes is saved for undo first, so Undo brings it back. 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" case checkout.ChangeExecutable: return "executable" case checkout.ChangeSymlink: return "symlink" case checkout.ChangeUnexec: return "unexec" case checkout.ChangeUnlink: return "unlink" 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 }