Artifact bb7abeed4bd1e8ed67873db3c4f2e6f858a67eddbf55915643f856d1d3e99c76:
- File
internal/checkout/mtime.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: 3928) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "fmt" "time" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // recordableMTime is the vfile.mtime a scan or a write stores for a file // whose mtime is mtime, in fossil's unit: whole seconds since 1970. // // This is the one deliberate difference from fossil. Fossil's // vfile_check_signature (src/vfile.c) stores the mtime as it is and later // skips hashing while size and mtime match, so it misses an edit that keeps // the size within the second it recorded. Here an mtime not older than now's // second is stored as 0, which makes the next scan, fossil's or ours, hash // the file once; a stored 0 is never trusted (scanSingleEntry). func recordableMTime(mtime time.Time, now int64) int64 { secs := mtime.Unix() if secs >= now { return 0 } return secs } // writtenMTime returns the vfile.mtime to record for the file at fullPath, // statting it right after it was written, as fossil's vfile_to_disk does. // now is read before the stat, so a write after the stat cannot slip past // the guard with an mtime equal to the one recorded. func (c *Checkout) writtenMTime(fullPath string) (int64, error) { now := c.env.Clock.Now().Unix() info, err := c.env.Storage.Stat(fullPath) if err != nil { return 0, fmt.Errorf("stat %s: %w", fullPath, err) } return recordableMTime(info.ModTime(), now), nil } // recordMTime stores mtime, from writtenMTime, on the row of version vid // named pathname when the row is based on artifact rid, the content that was // written. A row based on another artifact keeps its mtime, so a row only // ever vouches for the content it names. func (c *Checkout) recordMTime( q sqlExecer, vid libfossil.FslID, pathname string, rid, mtime int64, ) error { if rid <= 0 { panic("checkout.recordMTime: rid must be positive") } if _, err := q.Exec( "UPDATE vfile SET mtime = ? WHERE vid = ? AND pathname = ? AND mrid = ?", mtime, int64(vid), pathname, rid, ); err != nil { return fmt.Errorf("record mtime for %s: %w", pathname, err) } return nil } // inheritMTimes gives each row of version vid with no recorded mtime the one // a row of another version recorded for the same file, where that row vouches // for the same artifact: unchanged (chnged=0), not removed, and with the same // mrid. It is fossil's update moving an unchanged file's mtime to the target // version's row, and its commit keeping vfile rows in place. func (c *Checkout) inheritMTimes(q sqlExecer, vid libfossil.FslID) error { if vid <= 0 { panic("checkout.inheritMTimes: vid must be positive") } _, err := q.Exec(` UPDATE vfile SET mtime = old.mtime FROM vfile AS old WHERE vfile.vid = ? AND coalesce(vfile.mtime, 0) = 0 AND old.vid != vfile.vid AND old.pathname = vfile.pathname AND old.chnged = 0 AND NOT old.deleted AND old.mrid = vfile.mrid AND old.mtime != 0`, int64(vid)) if err != nil { return fmt.Errorf("inherit mtimes: %w", err) } return nil } // rowMTimes returns the recorded mtimes of version vid's rows named in names. func (c *Checkout) rowMTimes(vid libfossil.FslID, names []string) (map[string]int64, error) { out := make(map[string]int64, len(names)) for _, name := range names { var mtime int64 err := c.db.QueryRow( "SELECT CAST(coalesce(mtime, 0) AS INTEGER) FROM vfile WHERE vid = ? AND pathname = ?", int64(vid), name, ).Scan(&mtime) if err != nil { return nil, fmt.Errorf("read mtime of %s: %w", name, err) } if mtime != 0 { out[name] = mtime } } return out, nil } // setRowMTimes stores mtimes on version vid's rows, by pathname. func (c *Checkout) setRowMTimes(vid libfossil.FslID, mtimes map[string]int64) error { for name, mtime := range mtimes { if _, err := c.db.Exec( "UPDATE vfile SET mtime = ? WHERE vid = ? AND pathname = ?", mtime, int64(vid), name, ); err != nil { return fmt.Errorf("set mtime of %s: %w", name, err) } } return nil }