Artifact 369d59320c661749683e10aea9e6964c451fd9c589decdb45831deca64fe3ebe:
- File
checkout.go
— part of check-in
[cd4a11828f]
at
2026-10-12 20:17:22
on branch main
— fix(checkout): make WouldFork fossil's commit-time fork check (#347) (#348)
Since #291 WouldFork was fossil's leaf_ambiguity, which answers no at once for a version that is not a leaf. That is the case a commit forks in: when a pull brings in a child of the checked-out version, the version stops being a leaf, WouldFork said false, and the commit became a second child. EdgeSync's autosync relies on WouldFork to refuse that commit.
WouldFork is now fossil's commit refusal (src/checkin.c "Would fork", taken when !is_a_leaf(vid)): true when the version has a child on its own branch, read from plink as is_a_leaf does, merge children included. Another open leaf elsewhere on the branch no longer counts; fossil commits there, extending the checkout's leaf, and only warns afterwards.
The repo-state draft's Section 7.5 ambiguity test moves to checkout.LeafAmbiguous, and the repostate.7.5.ambiguity-* conformance cases call it. The superseded #291 changelog text and the WouldFork API doc are corrected. (user: [email protected], size: 17238) [annotate] [blame] [check-ins using]
package libfossil import ( "context" "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 int64 // 0 = tip } // 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 } }