Artifact b385947e4e9c2bb336e628b5270a84603190aec26f9abdb8b02d49e57e864342:
- File
internal/vfile/vfile.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: 4691) [annotate] [blame] [check-ins using]
// Package vfile reads a checkout's vfile table and says what each row means. // // The vfile table is fossil's record of a checkout: one row per tracked file. // Its columns encode state in ways that are easy to misread: rid=0 marks a // pending add, origname equals pathname on a row that was never renamed, // mrid differs from rid while a merge is pending, and chnged is not a flag: // 1 is an edit, 2 through 5 are merge and integrate outcomes, and 6 through 9 // are changes to the executable or symlink bit. Every caller that // needs to know what a row means asks this package instead of decoding the // columns itself, so fossil's encoding is interpreted in exactly one place. package vfile import ( "database/sql" "fmt" "github.com/danmestas/go-libfossil/db" ) // Change is how a tracked file differs from the checked-out version. type Change int const ( ChangeNone Change = iota ChangeAdded ChangeRemoved ChangeMissing ChangeRenamed ChangeModified ) // Row is one vfile row. type Row struct { ID int64 Pathname string Origname sql.NullString Chnged int64 Deleted int64 RID int64 MRID int64 // the artifact last written for the file; differs from RID mid-merge IsExe int64 IsLink int64 } // IsAdded reports a pending add: a file with no committed version (rid=0). // Fossil records an add with chnged left 0, so rid alone decides. func (r Row) IsAdded() bool { return r.RID == 0 } // IsRemoved reports a pending removal. func (r Row) IsRemoved() bool { return r.Deleted > 0 } // RenamedFrom returns the row's prior pathname when it records a rename, or // "". Fossil stores origname==pathname on rows that were never renamed. func (r Row) RenamedFrom() string { if !r.Origname.Valid { return "" } if r.Origname.String == r.Pathname { return "" } return r.Origname.String } // ContentChanged reports whether the file's content is not its committed // version: a pending add, an edit, or a merge outcome. func (r Row) ContentChanged() bool { if r.IsAdded() { return true } return r.Chnged > 0 } // PendingMerge reports a merge fossil has applied to the file but not yet // committed: a merge or integrate outcome (chnged 2 through 5), or merged-in // content (mrid differs from rid), which outlives the outcome code once the // file is edited on top of the merge. func (r Row) PendingMerge() bool { if r.Chnged >= 2 && r.Chnged <= 5 { return true } return r.MRID != r.RID } // ModeChanged reports a pending change to the executable or symlink bit // (chnged 6 through 9). func (r Row) ModeChanged() bool { return r.Chnged >= 6 && r.Chnged <= 9 } // Classify maps a row to its change type, in fossil's priority order (the // changes command): removed > missing > added > renamed > modified. missing // reports that the file is absent from disk; callers that have not looked // pass false. One difference from fossil: a renamed file that is also edited // is Renamed, not Modified, so callers that see only the change type keep // the rename. func Classify(r Row, missing bool) Change { if r.Pathname == "" { panic("vfile.Classify: empty pathname") } if r.Chnged < 0 { panic("vfile.Classify: negative chnged for " + r.Pathname) } if r.IsRemoved() { return ChangeRemoved } if missing { return ChangeMissing } if r.IsAdded() { return ChangeAdded } if r.RenamedFrom() != "" { return ChangeRenamed } if r.Chnged > 0 { return ChangeModified } return ChangeNone } // Load reads every row of version vid, ordered by pathname as fossil's // changes command lists them. A NULL rid reads as 0 (a pending add) and a // NULL mrid as rid, the way the checkin code always read them. Rows are // collected up front so no cursor is held open while callers read files or // update rows. func Load(q db.Querier, vid int64) ([]Row, error) { if q == nil { panic("vfile.Load: nil querier") } if vid < 0 { panic("vfile.Load: negative vid") } rows, err := q.Query(` SELECT id, pathname, origname, CAST(chnged AS INTEGER), CAST(deleted AS INTEGER), coalesce(rid, 0), coalesce(mrid, rid, 0), CAST(isexe AS INTEGER), CAST(islink AS INTEGER) FROM vfile WHERE vid = ? ORDER BY pathname`, vid) if err != nil { return nil, fmt.Errorf("vfile.Load: query: %w", err) } defer rows.Close() var out []Row for rows.Next() { var r Row if err := rows.Scan( &r.ID, &r.Pathname, &r.Origname, &r.Chnged, &r.Deleted, &r.RID, &r.MRID, &r.IsExe, &r.IsLink, ); err != nil { return nil, fmt.Errorf("vfile.Load: scan: %w", err) } out = append(out, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("vfile.Load: iterate: %w", err) } return out, nil }