Artifact 5503528da38158d367beba5d9552e69f0dbf39af41ceb5406aa3a6ff5aa8d487:
- File
checkout.go
— part of check-in
[6e6969bf97]
at
2026-10-18 21:59:04
on branch main
— fix(checkout): keep uncommitted changes across Update, as fossil does (#450)
* fix(checkout): keep uncommitted changes across Update, as fossil does
Update compared only committed versions: it wrote the target over a locally edited file, deleted an edited file the target removed, and merged the current and target check-ins with their ancestor while ignoring the working file.
It now follows fossil's update (src/update.c): scan the checkout, then for each file take the target's content when there is no local change, merge a local edit three ways (the checkout's version as base, the working file as local, the target as other) with conflicts listed in Conflicted, keep an edited file the target deletes as an untracked file, keep pending adds and removals, restore a missing file, and copy an untracked file the target adds over to NAME-original first. A dry run writes nothing. Files written with the target's content record the mtime of the write. Files are matched by name; a target-side rename is not followed.
The two tests that relied on merging two branches' committed changes now make the clashing edit locally. A fossil 2.28 interop test runs the same edits through fossil update and compares files, fossil changes and Status.
Tracked in #443
* fix(checkout): make Update safe on binary files, failures and dry runs
A binary file (a NUL byte, or a line over 32,767 bytes, as fossil's line diff refuses) is no longer line-merged. Update saves the working copy as NAME-original and writes the target, as fossil's update does; Merge keeps the checkout's copy, as fossil's merge does. Both used to splice the versions and report a clean merge. merge.IsText is fossil's test.
Update refuses before writing anything when a file it must write or delete has a directory or a read-only file in the way, moves the checkout database to the target in one transaction, and in a dry run leaves the database untouched (the scan's findings stay in memory). Every target file gets the target's executable bit, as fossil's checkout_set_all_exe does, so a bit-only change reaches the disk.
UpdateResult.Unmerged lists files left to reconcile without markers: edited files the target deletes, files added both locally and by the target, and binary conflicts. The interop test now covers a binary conflict against fossil 2.28.
Tracked in #443
* fix(checkout): check real write access before Update writes anything
The pre-write check read the owner-write bit: it missed an unwritable directory (a remove failed after a merge had been written), files owned by others, and wrongly refused for root. It now asks the storage, via an optional CheckWritable: OSStorage uses access(2) with W_OK on unix and a read-only-attribute check elsewhere (js/wasm, Windows). Every step needs its directory writable; a write or merge over an existing file needs the file writable too.
A read-only untracked file where the target adds one is backed up to NAME-original, deleted and replaced, as fossil does, instead of refused.
Merge keeps a binary file whatever its strategy, as fossil's merge does. setVVar's precondition now catches a nil *sql.DB or *sql.Tx inside the interface.
Tracked in #443 (user: [email protected], size: 21284) [annotate] [blame] [check-ins using] [more...]
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. // // 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), and a binary file both sides // changed (the target's content written, the working copy saved as // NAME-original). 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 } // 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 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, }, 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 }