Artifact 26f58e3bb5cc7a507cc89b4bc357027ccd7e7dc94272c144ece2dc72badf797a:
- File
internal/checkout/vfile.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: 19401) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "strings" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/manifest" ) // errMissingContent reports a version whose files' content the repository // does not fully hold (a phantom, or a delta on one), as a partial sync // leaves it. var errMissingContent = errors.New("content missing") // missingNamesMax bounds how many missing files an error names. const missingNamesMax = 10 // Compile-time check: an error must be able to name at least one file. const _ = uint(missingNamesMax - 1) // LoadVFile populates the vfile table with the files of checkin rid. // If clear=true, deletes all vfile rows for OTHER versions (keeps only vid=rid), // after a new row has taken the mtime of an unchanged row it replaces // (inheritMTimes). // // Like fossil's checkout, it refuses a version whose content is not all in // the repository, before writing anything: a row it cannot back with content // would read as a pending add (rid=0 means added in fossil's vfile) and could // never be extracted. The error wraps errMissingContent and names the files. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) LoadVFile(rid libfossil.FslID, clear bool) error { if c == nil { panic("checkout.LoadVFile: nil *Checkout") } return c.loadVFile(c.db, rid, clear) } // loadVFile is LoadVFile writing through q. func (c *Checkout) loadVFile(q sqlExecer, rid libfossil.FslID, clear bool) error { // Resolve every file before changing anything, so a refusal leaves // vfile as it was. files, blobRIDs, err := c.resolveFiles(rid) if err != nil { return fmt.Errorf("checkout.LoadVFile: %w", err) } for i, file := range files { if blobRIDs[i] <= 0 { panic("checkout.LoadVFile: unresolved blob for " + file.Name) } isexe := 0 if strings.Contains(file.Perm, "x") { isexe = 1 } // INSERT OR IGNORE keeps an existing row for this version as it is. _, err := q.Exec(` INSERT OR IGNORE INTO vfile(vid, pathname, rid, mrid, isexe, islink) VALUES(?, ?, ?, ?, ?, 0)`, int64(rid), file.Name, int64(blobRIDs[i]), int64(blobRIDs[i]), isexe, ) if err != nil { return fmt.Errorf("checkout.LoadVFile: insert %s: %w", file.Name, err) } } if !clear { return nil } if err := c.inheritMTimes(q, rid); err != nil { return fmt.Errorf("checkout.LoadVFile: %w", err) } if _, err := q.Exec("DELETE FROM vfile WHERE vid != ?", int64(rid)); err != nil { return fmt.Errorf("checkout.LoadVFile: clear: %w", err) } return nil } // resolveFiles lists the files of checkin rid with the blob each one's // content lives in. It fails, wrapping errMissingContent, when the repository // does not hold all of that content; callers run it before changing anything // so that a version they cannot finish is refused up front. func (c *Checkout) resolveFiles( rid libfossil.FslID, ) ([]manifest.FileEntry, []libfossil.FslID, error) { if rid <= 0 { panic("checkout.resolveFiles: rid must be positive") } if c.repo == nil { panic("checkout.resolveFiles: nil repo") } files, err := manifest.ListFiles(c.repo, rid) if err != nil { return nil, nil, fmt.Errorf("checkout.resolveFiles: %w", err) } blobRIDs := make([]libfossil.FslID, len(files)) var missing []string for i, file := range files { // A database fault must not read as missing content: Create // tolerates missing content, and would swallow the fault with it. blobRID, available, err := content.CheckAvailableByUUID(c.repo.DB(), file.UUID) if err != nil { return nil, nil, fmt.Errorf("checkout.resolveFiles: %s: %w", file.Name, err) } if !available { missing = append(missing, file.Name) continue } blobRIDs[i] = blobRID } if len(missing) > 0 { return nil, nil, missingContentError(missing) } if len(blobRIDs) != len(files) { panic("checkout.resolveFiles: blobRIDs out of step with files") } return files, blobRIDs, nil } // missingContentError names up to missingNamesMax of the missing files. func missingContentError(missing []string) error { if len(missing) == 0 { panic("checkout.missingContentError: no missing files") } named := missing if len(named) > missingNamesMax { named = named[:missingNamesMax] } if len(named) == 0 { panic("checkout.missingContentError: nothing to name") } more := "" if len(missing) > len(named) { more = fmt.Sprintf(" and %d more", len(missing)-len(named)) } return fmt.Errorf( "%w for %d file(s): %s%s", errMissingContent, len(missing), strings.Join(named, ", "), more, ) } // UnloadVFile removes all vfile entries for the specified version. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) UnloadVFile(rid libfossil.FslID) error { if c == nil { panic("checkout.UnloadVFile: nil *Checkout") } _, err := c.db.Exec("DELETE FROM vfile WHERE vid = ?", int64(rid)) if err != nil { return fmt.Errorf("checkout.UnloadVFile: %w", err) } return nil } // scanVFileEntry holds a single vfile row for scan processing. type scanVFileEntry struct { id int64 pathname string mergeRid int64 // vfile.mrid: the artifact last written for the file, 0 if none chnged int64 deleted int64 mtime int64 // vfile.mtime: seconds since 1970, 0 when not known } // baseline returns the hash and size of the artifact a file on disk is // judged against: the blob vfile.mrid names. mrid equals rid except while a // merge is pending, when it names the merged-in version. This is fossil's // rule (its scan joins blob on mrid). vfile.mhash is deliberately not read: // fossil only fills it during a merge, so it is NULL on ordinary rows (#228). func (c *Checkout) baseline(mergeRid int64) (string, int64, error) { if mergeRid <= 0 { panic("checkout.baseline: mergeRid must be positive") } var uuid string var size int64 err := c.repo.DB().QueryRow( "SELECT uuid, size FROM blob WHERE rid = ?", mergeRid, ).Scan(&uuid, &size) if err != nil { return "", 0, fmt.Errorf("blob for rid %d: %w", mergeRid, err) } if !hash.IsValidHash(uuid) { return "", 0, fmt.Errorf("blob rid %d has malformed uuid %q", mergeRid, uuid) } return uuid, size, nil } // nextChangedState is the content half of fossil's vfile_check_signature: // the vfile.chnged value its scan stores for a file present on disk. A row // with no artifact behind it (mrid=0) is a pending add, written with // chnged=0, which the scan promotes to 1. A size other than the artifact's is // an edit outright. A file believed edited (1) is hashed, and returns to 0 // when its content is back at the baseline. An unchanged file (0) or a clean // merge outcome (2, 4) is hashed only when its mtime cannot be trusted, and // becomes 1 when its content differs. Every other state is kept: the // remaining merge outcomes (3, 5) and the mode changes (6 through 9). // differs hashes the file; it is called only when the verdict needs it. func nextChangedState( chnged int64, hasBaseline, sizeDiffers, mtimeTrusted bool, differs func() (bool, error), ) (int64, error) { if chnged < 0 { panic("checkout.nextChangedState: negative chnged") } if differs == nil { panic("checkout.nextChangedState: nil differs") } if chnged == 0 && !hasBaseline { chnged = 1 } unchanged := chnged == 0 || chnged == 2 || chnged == 4 switch { case sizeDiffers: return 1, nil case chnged == 1 && hasBaseline: d, err := differs() if err != nil || d { return 1, err } return 0, nil case unchanged && !mtimeTrusted: if !hasBaseline { return 1, nil // nothing to verify against, as fossil finds } d, err := differs() if err != nil || d { return 1, err } return chnged, nil } return chnged, nil } // scanPolicy is how a scan decides whether a file needs hashing. type scanPolicy struct { trustMTime bool // fossil's useMtime: no ScanHash and mtime-changes on now int64 // the scan's time; see recordableMTime } // newScanPolicy reads the policy for flags: a hash scan (fossil's --hash) // never trusts mtimes, and neither does any scan while the repository's // mtime-changes setting is off. func (c *Checkout) newScanPolicy(flags ScanFlags) (scanPolicy, error) { p := scanPolicy{now: c.env.Clock.Now().Unix()} if flags&ScanHash != 0 { return p, nil } on, err := c.mtimeChanges() if err != nil { return p, err } p.trustMTime = on return p, nil } // mtimeChanges reads fossil's mtime-changes setting, on by default, as // db_get_boolean reads it from the repository's config table. Fossil's // db_get falls back to global_config in ~/.fossil (`fossil settings // mtime-changes off --global`); go-libfossil reads no global fossil settings, // so a global value is not seen here. func (c *Checkout) mtimeChanges() (bool, error) { var value sql.NullString err := c.repo.DB().QueryRow( "SELECT value FROM config WHERE name = 'mtime-changes'", ).Scan(&value) if errors.Is(err, sql.ErrNoRows) || (err == nil && !value.Valid) { return true, nil } if err != nil { return false, fmt.Errorf("read mtime-changes: %w", err) } return settingBoolean(value.String, true), nil } // settingBoolean is fossil's db_get_boolean on a stored value: is_truth // ("on", "yes", "true" in any case, or a nonzero C atoi) gives true, is_false // ("off", "no", "false", "0" in any case) gives false, anything else dflt. func settingBoolean(value string, dflt bool) bool { switch strings.ToLower(value) { case "on", "yes", "true": return true case "off", "no", "false", "0": return false } digits := strings.TrimLeft(value, " \t\n\v\f\r") if digits != "" && (digits[0] == '+' || digits[0] == '-') { digits = digits[1:] } digits = strings.TrimLeft(digits, "0") if digits != "" && digits[0] >= '1' && digits[0] <= '9' { return true // a nonzero atoi } return dflt } // fileDiffers reports whether the file at fullPath hashes to other than // uuid. func (c *Checkout) fileDiffers(fullPath, uuid string) (bool, error) { data, err := c.env.Storage.ReadFile(fullPath) if err != nil { return false, fmt.Errorf("read %s: %w", fullPath, err) } return hash.ContentHash(data, uuid) != uuid, nil } // scanWrite is one vfile row a scan updates. type scanWrite struct { id, mtime, chnged int64 } // storeScan stores a scan's updates in one transaction, as fossil's scan // runs in one. func (c *Checkout) storeScan(writes []scanWrite) (err error) { if len(writes) == 0 { return nil } tx, err := c.db.Begin() if err != nil { return fmt.Errorf("checkout.ScanChanges: begin: %w", err) } defer func() { if err != nil { err = errors.Join(err, tx.Rollback()) } }() for _, w := range writes { if _, err := tx.Exec( "UPDATE vfile SET mtime = ?, chnged = ? WHERE id = ?", w.mtime, w.chnged, w.id, ); err != nil { return fmt.Errorf("checkout.ScanChanges: update vfile row %d: %w", w.id, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("checkout.ScanChanges: commit: %w", err) } return nil } // scanSingleEntry checks a single vfile entry against the file on disk the // way fossil's vfile_check_signature does, appending to writes the update // that stores the verdict in vfile.chnged and the file's mtime in // vfile.mtime. Returns (changed, missing): changed when the file newly // became edited. func (c *Checkout) scanSingleEntry( e scanVFileEntry, p scanPolicy, writes *[]scanWrite, ) (changed, missing bool, err error) { if e.deleted != 0 { return false, false, nil } fullPath, err := c.safePath(e.pathname) if err != nil { return false, false, fmt.Errorf( "checkout.ScanChanges: path traversal in %s: %w", e.pathname, err, ) } // Anything that is not a regular file is missing, as fossil's scan // treats a directory where a file belongs (NOT_A_FILE). info, err := c.env.Storage.Stat(fullPath) if err != nil { if errors.Is(err, os.ErrNotExist) { return false, true, nil } return false, false, fmt.Errorf( "checkout.ScanChanges: stat %s: %w", fullPath, err, ) } if !info.Mode().IsRegular() { return false, true, nil } uuid, size := "", int64(0) if e.mergeRid > 0 { if uuid, size, err = c.baseline(e.mergeRid); err != nil { return false, false, fmt.Errorf("checkout.ScanChanges: %s: %w", e.pathname, err) } } // A recorded 0 means not known, even for a file dated 1970. trusted := p.trustMTime && e.mtime != 0 && info.ModTime().Unix() == e.mtime next, err := nextChangedState(e.chnged, e.mergeRid > 0, info.Size() != size, trusted, func() (bool, error) { return c.fileDiffers(fullPath, uuid) }) if err != nil { return false, false, fmt.Errorf("checkout.ScanChanges: %w", err) } mtime := recordableMTime(info.ModTime(), p.now) if next == e.chnged && mtime == e.mtime { return false, false, nil } *writes = append(*writes, scanWrite{id: e.id, mtime: mtime, chnged: next}) return next == 1 && e.chnged != 1, false, nil } // loadScanEntries reads every vfile row of version rid. The rows are // collected up front so no cursor is held open during file I/O and the // per-row updates. Fossil leaves mtime NULL on a row it adds. func (c *Checkout) loadScanEntries(rid libfossil.FslID) ([]scanVFileEntry, error) { if rid < 0 { panic("checkout.loadScanEntries: negative rid") } rows, err := c.db.Query(` SELECT id, pathname, mrid, CAST(chnged AS INTEGER), CAST(deleted AS INTEGER), CAST(coalesce(mtime, 0) AS INTEGER) FROM vfile WHERE vid = ? `, int64(rid)) if err != nil { return nil, fmt.Errorf("checkout.ScanChanges: query vfile: %w", err) } defer rows.Close() var entries []scanVFileEntry for rows.Next() { var e scanVFileEntry if err := rows.Scan( &e.id, &e.pathname, &e.mergeRid, &e.chnged, &e.deleted, &e.mtime, ); err != nil { return nil, fmt.Errorf("checkout.ScanChanges: scan vfile row: %w", err) } entries = append(entries, e) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("checkout.ScanChanges: iterate vfile rows: %w", err) } return entries, nil } // refreshChanged brings vfile.chnged for every row of version vid up to date // with the files on disk, as fossil's default scan does, without observer // notifications. It is the part of ScanChanges that other operations need // before they judge what is safe to do; the scan they run is their own // detail, not a scan to report. func (c *Checkout) refreshChanged(vid libfossil.FslID) error { if vid <= 0 { panic("checkout.refreshChanged: vid must be positive") } writes, err := c.scanVersion(vid) if err != nil { return err } return c.storeScan(writes) } // scanVersion runs fossil's default scan over version vid and returns the // vfile updates it found, without storing them. func (c *Checkout) scanVersion(vid libfossil.FslID) ([]scanWrite, error) { if vid <= 0 { panic("checkout.scanVersion: vid must be positive") } p, err := c.newScanPolicy(0) if err != nil { return nil, err } entries, err := c.loadScanEntries(vid) if err != nil { return nil, err } var writes []scanWrite for _, e := range entries { if _, _, err := c.scanSingleEntry(e, p, &writes); err != nil { return nil, err } } return writes, nil } // ScanChanges detects modified and missing files in the checkout. // Walks the vfile table, checks each file on disk, and updates vfile.chnged accordingly. // // As fossil's scan, a file whose size and mtime match what vfile records is // taken as unchanged without reading it, unless flags includes ScanHash // (fossil's --hash) or the mtime-changes setting is off; any other file is // hashed and compared with the artifact vfile.mrid names (see baseline). // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) ScanChanges(flags ScanFlags) error { if c == nil { panic("checkout.ScanChanges: nil *Checkout") } ctx := c.obs.ScanStarted(context.Background()) var filesScanned, filesChanged, filesMissing, filesExtra int defer func() { c.obs.ScanCompleted(ctx, ScanEnd{ FilesScanned: filesScanned, FilesChanged: filesChanged, FilesMissing: filesMissing, FilesExtra: filesExtra, }) }() rid, _, err := c.Version() if err != nil { return fmt.Errorf("checkout.ScanChanges: %w", err) } p, err := c.newScanPolicy(flags) if err != nil { return fmt.Errorf("checkout.ScanChanges: %w", err) } entries, err := c.loadScanEntries(rid) if err != nil { return err } // Build a set of tracked pathnames for extra-file detection. tracked := make(map[string]bool, len(entries)) var writes []scanWrite for _, e := range entries { tracked[e.pathname] = true filesScanned++ changed, missing, scanErr := c.scanSingleEntry(e, p, &writes) if scanErr != nil { return scanErr } if changed { filesChanged++ } if missing { filesMissing++ c.obs.Error(ctx, fmt.Errorf( "checkout.ScanChanges: file missing: %s", e.pathname, )) } } if err := c.storeScan(writes); err != nil { return err } filesExtra = c.countExtra(ctx, tracked) return nil } // countExtra walks the checkout directory to count EXTRA files (on disk but // not in vfile). Errors are non-fatal: logged via observer, counting none. func (c *Checkout) countExtra(ctx context.Context, tracked map[string]bool) int { diskFiles, walkErr := c.walkDir(c.dir) if walkErr != nil { c.obs.Error(ctx, walkErr) return 0 } extra := 0 for relPath := range diskFiles { if !tracked[relPath] { extra++ } } return extra } // maxWalkDepth is the maximum directory nesting depth walkDir will traverse. const maxWalkDepth = 256 // walkDirEntry is a stack element for iterative directory traversal. type walkDirEntry struct { path string depth int } // walkDir iteratively lists all files under a directory via Storage.ReadDir. // Returns a map of relative paths (relative to c.dir) to true. // This helper prepares for detecting EXTRA files (files on disk not in vfile). // // Uses an explicit stack instead of recursion (TigerStyle: no recursion). // Enforces a maximum traversal depth of maxWalkDepth. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) walkDir(dir string) (map[string]bool, error) { if c == nil { panic("checkout.walkDir: nil *Checkout") } result := make(map[string]bool) stack := []walkDirEntry{{path: dir, depth: 0}} for len(stack) > 0 { // Pop from stack top := stack[len(stack)-1] stack = stack[:len(stack)-1] if top.depth > maxWalkDepth { return nil, fmt.Errorf("walkDir: exceeded max depth %d at %s", maxWalkDepth, top.path) } entries, err := c.env.Storage.ReadDir(top.path) if err != nil { if os.IsNotExist(err) { continue } return nil, fmt.Errorf("walkDir: read %s: %w", top.path, err) } for _, entry := range entries { name := entry.Name() // Skip checkout databases and VCS directories switch name { case ".fslckout", "_FOSSIL_", ".git", ".hg", ".svn": continue } fullPath := filepath.Join(top.path, name) if entry.IsDir() { stack = append(stack, walkDirEntry{path: fullPath, depth: top.depth + 1}) } else { relPath, err := filepath.Rel(c.dir, fullPath) if err != nil { return nil, fmt.Errorf("walkDir: compute relative path for %s: %w", fullPath, err) } result[relPath] = true } } } return result, nil }