Artifact dc3d6b241845a2d24edf9e98d738a2b07b3f7117093806993aaa0be7c00c129b:
- File
internal/checkout/status.go
— part of check-in
[f55d0ed2de]
at
2026-10-03 16:47:05
on branch trunk
— refactor(vfile): give the checkout's vfile encoding one owner (#233) (#243)
* refactor(vfile): give the checkout's vfile encoding one owner (#233)
Checkin, stash and status each decoded fossil's vfile columns on their own: rid=0 for an add, origname==pathname on unrenamed rows, chnged merge codes. That is how #228 happened, and stash still had it wrong: it selected chnged=1 only, so files changed by a merge (chnged 2..9) were silently left out, and it stashed a rename as a plain edit.
New package internal/vfile owns the encoding. Load reads a version's rows; Row answers what callers ask (IsAdded, IsRemoved, RenamedFrom, ContentChanged, PendingMerge); Classify gives the change type in fossil's order. checkout.FileChange is now an alias of vfile.Change, so the checkout API is unchanged.
internal/checkout's status, Extract safety check and checkin read rows through it. The never-read vfileCommitEntry.changed field is gone.
internal/stash selects changes with vfile.Classify against the checked- out version and refuses a pending merge or rename by name, since its save and revert paths restore files by current name against the committed version and would drop either. Its store-and-revert loop is split into stashRecord and revertStashedFile; removed and modified files share one revert path, keyed by row id.
* fix(vfile): a pending merge is chnged 2-5 or mrid != rid (#233)
PendingMerge counted every chnged above 1 as a merge, but 6 through 9 record a change to the executable or symlink bit; fossil leaves chnged=6 after a plain chmod +x. Stash then refused an ordinary permission change as "a pending merge". It also ignored mrid: an edit made on top of a merge drops chnged back to 1 while mrid still names the merged-in content, so the refusal could be bypassed and the merge half-dropped.
Row now carries mrid; PendingMerge is chnged 2..5 or mrid != rid, and ModeChanged covers 6..9. Stash refuses a permission change with that reason, since its revert writes default permissions.
Load reads a NULL rid as 0 and a NULL mrid as rid, the tolerance the checkin code had when it scanned rid into a NullInt64.
* refactor(checkout): ask vfile.Row about merges in Extract's check (#233)
Extract's safety check relaxed only rows with chnged == 1, decoding the column itself. It now asks the row (not a pending merge, not a mode change), keeping fossil's encoding in internal/vfile. Equivalent for every real row. nextChangedState's comment no longer calls the mode changes (6..9) merge states. (user: [email protected], size: 3341) [annotate] [blame] [check-ins using]
package checkout import ( "errors" "fmt" "os" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/vfile" ) // HasChanges reports whether any file of the current version is added, // removed, renamed or modified. It reads only the checkout database: an edit // on disk counts once a scan has recorded it, but adds, removals and renames // are always seen. Missing files are not, because that needs the disk. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) HasChanges() (bool, error) { if c == nil { panic("checkout.HasChanges: nil *Checkout") } rid, _, err := c.Version() if err != nil { return false, fmt.Errorf("checkout.HasChanges: %w", err) } if rid < 0 { panic("checkout.HasChanges: negative checkout version") } rows, err := vfile.Load(c.db, int64(rid)) if err != nil { return false, fmt.Errorf("checkout.HasChanges: %w", err) } for _, r := range rows { if vfile.Classify(r, false) != ChangeNone { return true, nil } } return false, nil } // VisitChanges calls fn for each changed file of version vid, classified by // vfile.Classify. With scan=true it first runs ScanChanges(ScanHash) and // checks each file on disk, so edits and missing files are reported; with // scan=false it reports what the checkout database records. // // If fn returns a non-nil error, iteration stops and that error is returned. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) VisitChanges(vid libfossil.FslID, scan bool, fn ChangeVisitor) error { if c == nil { panic("checkout.VisitChanges: nil *Checkout") } if fn == nil { panic("checkout.VisitChanges: nil visitor") } if scan { if err := c.ScanChanges(ScanHash); err != nil { return fmt.Errorf("checkout.VisitChanges: %w", err) } } rows, err := vfile.Load(c.db, int64(vid)) if err != nil { return fmt.Errorf("checkout.VisitChanges: %w", err) } for _, r := range rows { missing := false if scan { if missing, err = c.isMissing(r); err != nil { return fmt.Errorf("checkout.VisitChanges: %w", err) } } change := vfile.Classify(r, missing) if change == ChangeNone { continue } entry := ChangeEntry{ Name: r.Pathname, Change: change, VFileID: libfossil.FslID(r.ID), IsExec: r.IsExe != 0, IsLink: r.IsLink != 0, OrigName: r.RenamedFrom(), } if err := fn(entry); err != nil { return fmt.Errorf("checkout.VisitChanges: visitor for %s: %w", r.Pathname, err) } } return nil } // isMissing reports whether a row's file is absent from disk, the way // fossil's changes command decides MISSING: anything that is not a regular // file counts. A removed row is never missing; it is meant to be gone. func (c *Checkout) isMissing(r vfile.Row) (bool, error) { if r.Pathname == "" { panic("checkout.isMissing: empty pathname") } if c.env == nil { panic("checkout.isMissing: nil env") } if r.Deleted > 0 { return false, nil } fullPath, err := c.safePath(r.Pathname) if err != nil { return false, fmt.Errorf("path traversal in %s: %w", r.Pathname, err) } info, err := c.env.Storage.Stat(fullPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return true, nil } return false, fmt.Errorf("stat %s: %w", fullPath, err) } return !info.Mode().IsRegular(), nil }