Artifact dbc67bfe8d79f5b4f5293c18e89a03613c13fe16d78286819bf21ca05e20b5f6:
- File
internal/checkout/status.go
— part of check-in
[0069100efe]
at
2026-10-03 15:20:47
on branch trunk
— fix(checkout): read checkouts made by the fossil binary (#228) (#229)
Status on a checkout created by `fossil open` failed with "converting NULL to string" on vfile.mhash. go-libfossil treated mhash as every file's baseline hash; fossil only fills it while a merge is pending (the hash of mrid when mrid != rid) and judges a file against the blob that vfile.mrid names. A NULL-tolerant scan alone would not have fixed it: with no baseline the comparison panics, and skipping it would hide real edits.
ScanChanges now compares each file with blob.uuid for vfile.mrid and moves chnged through fossil's own transitions (vfile_check_signature): a pending add (mrid=0) becomes 1, unchanged and clean-merge states (0, 2, 4) become 1 on a difference, a reverted edit returns to 0, and other merge states are kept. Comparing against the artifact's own uuid also picks the hash algorithm per file, independent of hash-policy.
The non-forced Extract guard read mhash with its error discarded, so on a fossil checkout it skipped the check and overwrote local edits. It now uses the same baseline, propagates read errors, and refuses while a merge is pending (mrid != rid or chnged > 1).
Status and HasChanges treat a rename as origname not in ('', pathname), since fossil stores origname == pathname on unrenamed rows, and HasChanges now sees `fossil mv`. A malformed row returns an error instead of being reported as ChangeNone.
LoadVFile and Manage stop writing mhash, and Manage writes islink, which fossil's vfile schema declares without a default.
Tests drive real fossil-made checkouts: clean status, edits and adds, a pending merge (status, preserved merge state, extract refusal), local edits under extract, fossil mv, and a go-libfossil Add read back by both tools. (user: [email protected], size: 4273) [annotate] [blame] [check-ins using]
package checkout import ( "database/sql" "fmt" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // HasChanges returns true if the checkout has any modified, deleted, or renamed files. // This is a DB-only check that does NOT scan the filesystem. // Use ScanChanges first if you need to detect on-disk modifications. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) HasChanges() (bool, error) { if c == nil { panic("checkout.HasChanges: nil *Checkout") } // Get current checkout version rid, _, err := c.Version() if err != nil { return false, fmt.Errorf("checkout.HasChanges: %w", err) } // Check for any changed, deleted, or renamed files. Fossil stores // origname==pathname on rows that were never renamed, so only a // different origname counts. var count int err = c.db.QueryRow(` SELECT count(*) FROM vfile WHERE (chnged > 0 OR deleted > 0 OR origname NOT IN ('', pathname)) AND vid = ? `, int64(rid)).Scan(&count) if err != nil { return false, fmt.Errorf("checkout.HasChanges: query: %w", err) } return count > 0, nil } // VisitChanges iterates over all changed files in the checkout, calling fn for each. // If scan=true, calls ScanChanges(ScanHash) first to detect on-disk modifications. // Otherwise, only reports files marked as changed in the vfile table. // // The function classifies each changed file: // - deleted > 0 → ChangeRemoved // - rid == 0 → ChangeAdded (newly managed file, not yet committed) // - origname != "" and != pathname → ChangeRenamed // - chnged > 0 → ChangeModified // // 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") } // Scan filesystem if requested if scan { if err := c.ScanChanges(ScanHash); err != nil { return fmt.Errorf("checkout.VisitChanges: %w", err) } } // Query all changed/deleted/renamed files rows, err := c.db.Query(` SELECT id, pathname, CAST(chnged AS INTEGER), CAST(deleted AS INTEGER), CAST(isexe AS INTEGER), CAST(islink AS INTEGER), origname, rid FROM vfile WHERE vid = ? AND (chnged > 0 OR deleted > 0 OR origname NOT IN ('', pathname)) `, int64(vid)) if err != nil { return fmt.Errorf("checkout.VisitChanges: query: %w", err) } defer rows.Close() // Iterate over each changed file for rows.Next() { var id, chnged, deleted, isexe, islink, rid int64 var pathname string var origname sql.NullString if err := rows.Scan( &id, &pathname, &chnged, &deleted, &isexe, &islink, &origname, &rid, ); err != nil { return fmt.Errorf("checkout.VisitChanges: scan: %w", err) } changeType := classifyChange(pathname, origname, chnged, deleted, rid) if changeType == ChangeNone { // The WHERE clause and classifyChange disagree, which only a // malformed row can cause (e.g. a non-integer chnged). return fmt.Errorf("checkout.VisitChanges: malformed vfile row for %s", pathname) } // Build change entry entry := ChangeEntry{ Name: pathname, Change: changeType, VFileID: libfossil.FslID(id), IsExec: isexe != 0, IsLink: islink != 0, OrigName: origname.String, } // Call visitor function if err := fn(entry); err != nil { return fmt.Errorf("checkout.VisitChanges: visitor for %s: %w", pathname, err) } } if err := rows.Err(); err != nil { return fmt.Errorf("checkout.VisitChanges: iterate: %w", err) } return nil } // classifyChange maps a vfile row to its change type, in priority order // deleted > added > renamed > modified. Fossil stores origname==pathname on // rows that were never renamed, so only a different origname is a rename. func classifyChange( pathname string, origname sql.NullString, chnged, deleted, rid int64, ) FileChange { if pathname == "" { panic("checkout.classifyChange: empty pathname") } if deleted > 0 { return ChangeRemoved } if rid == 0 { return ChangeAdded } if origname.Valid { if origname.String != "" && origname.String != pathname { return ChangeRenamed } } if chnged > 0 { return ChangeModified } return ChangeNone }