Artifact 809d3088c52a5508d74bba2e818b53ee5bf7413a2398ed5c4dd33483744b56a9:
- File
internal/checkout/update.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: 22053) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "context" "errors" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/merge" "github.com/danmestas/go-libfossil/internal/vfile" ) // Update moves the checkout to another version the way fossil's update // does (src/update.c), carrying uncommitted changes into it. It first scans // the checkout, then for each file: // // - one without local changes takes the target's content, or is removed // when the target does not have it; // - one edited locally that the target also changed is merged three ways: // the checkout's version is the base, the working file the local side // and the target the other; a merge that cannot resolve writes conflict // markers and is listed in UpdateResult.Conflicted; // - one edited locally that the target deletes is kept on disk, no longer // tracked; // - a pending add stays added, and a pending removal stays removed; // - a file the target adds over an untracked file is written after the // untracked one is copied to NAME-original; // - a binary file edited locally and changed by the target is not merged: // the working copy is saved as NAME-original and the target written. // // Every file then gets the target's executable bit. An update that could // not finish (a directory where a file must be written, or a file or // directory this process may not change) is refused before anything is // written, and the checkout // database moves to the target in one transaction. A dry run reports the // same result and changes nothing, on disk or in the checkout database. // // Unlike fossil, nothing follows a file the target renamed: files are // matched by name, and no undo state is saved. // // If opts.TargetRID is 0, CalcUpdateVersion is used to find the target. // If the target equals the current version, Update returns a zero-value // UpdateResult and a nil error (nothing to do). // // A non-nil error means the update genuinely failed; the returned // UpdateResult's contents are not meaningful in that case (see UpdateResult // for the outcome mapping). Update never returns a non-nil error together // with a non-empty UpdateResult.Conflicted — a conflicted merge is a // success, not a failure. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) Update(opts UpdateOpts) (UpdateResult, error) { if c == nil { panic("checkout.Update: nil *Checkout") } target, current, err := c.updateTarget(opts.TargetRID) if err != nil { return UpdateResult{}, err } if target == 0 { return UpdateResult{}, nil // nothing to update, or already there } ctx := c.obs.ExtractStarted(context.Background(), ExtractStart{ Operation: "update", TargetRID: target, }) var res UpdateResult var updateErr error defer func() { c.obs.ExtractCompleted(ctx, ExtractEnd{ Operation: "update", TargetRID: target, FilesWritten: len(res.FilesWritten), FilesRemoved: len(res.FilesRemoved), Conflicts: len(res.Conflicted), Err: updateErr, }) }() steps, err := c.planUpdate(current, target, opts.DryRun) if err == nil && !opts.DryRun { err = c.checkUpdateTargets(steps) } if err != nil { updateErr = fmt.Errorf("checkout.Update: %w", err) return UpdateResult{}, updateErr } fresh := map[string]writtenFile{} if res, updateErr = c.applyUpdate(ctx, steps, opts, fresh); updateErr != nil { return UpdateResult{}, updateErr } if opts.DryRun { return res, nil } if updateErr = c.finalizeUpdate(target, steps, fresh); updateErr != nil { return UpdateResult{}, updateErr } if opts.SetMTime { if updateErr = c.setFileMTimes(target); updateErr != nil { return UpdateResult{}, updateErr } } return res, nil } // updateAction is what fossil's update does with one file. type updateAction int const ( updateNothing updateAction = iota // same artifact in both, or edited and unchanged by the target updateWrite // take the target's content: new, unedited, or missing locally updateAddOver // the target adds it over an untracked file: back that up, then write updateMerge // edited locally and changed by the target: merge updateRemove // unedited, and the target does not have it: delete it updateKeepEdited // edited locally, and the target does not have it: keep it, untracked updateKeepDuplicate // added locally and by the target: keep the local file updateKeepAdd // added locally only: it stays a pending add ) // updateTargetFile is a file of the version Update moves to. type updateTargetFile struct { rid int64 exe bool } // writtenFile is a file Update wrote with the target's artifact rid, and // the vfile.mtime that write gave it. type writtenFile struct { rid, mtime int64 } // updateStep is one file's part in an update. type updateStep struct { name string row *vfile.Row // the current version's row, nil when it has none target *updateTargetFile // nil when the target does not have the file action updateAction } // classifyUpdate is fossil's update loop deciding one file, in its order. // missing reports that row's file is not on disk; unmanaged that an // untracked file stands where the target adds one. As fossil's scan leaves // them, a removed or missing file counts as changed. func classifyUpdate(row *vfile.Row, tgt *updateTargetFile, missing, unmanaged bool) updateAction { if row == nil && tgt == nil { panic("checkout.classifyUpdate: file in neither version") } if row == nil { if unmanaged { return updateAddOver } return updateWrite } deleted := row.Deleted > 0 changed := row.Chnged > 0 || deleted || missing switch { case row.RID == 0 && tgt != nil: return updateKeepDuplicate case tgt != nil && tgt.rid != row.RID && (!changed || deleted): return updateWrite case tgt != nil && !deleted && missing: return updateWrite case tgt == nil && row.RID == 0: return updateKeepAdd case tgt == nil && changed: return updateKeepEdited case tgt == nil: return updateRemove case tgt.rid != row.RID: return updateMerge } return updateNothing } // updateTarget picks the version Update moves to and the current one. A // target of 0 means there is nothing to do: no newer version, or the // checkout is already there. func (c *Checkout) updateTarget( requested libfossil.FslID, ) (target, current libfossil.FslID, err error) { if c == nil { panic("checkout.updateTarget: nil *Checkout") } if requested < 0 { return 0, 0, fmt.Errorf("checkout.Update: invalid target rid %d", requested) } target = requested if target == 0 { target, err = c.CalcUpdateVersion() if err != nil { return 0, 0, fmt.Errorf("checkout.Update: %w", err) } if target == 0 { return 0, 0, nil } } current, _, err = c.Version() if err != nil { return 0, 0, fmt.Errorf("checkout.Update: %w", err) } if current == target { return 0, current, nil } if target <= 0 { panic("checkout.updateTarget: chose a non-positive target") } return target, current, nil } // planUpdate scans the checkout, as fossil's update does first, and decides // each file of the current version vid and the target. It refuses a target // whose content is not all here before anything is written. A dry run keeps // the scan's findings to itself. func (c *Checkout) planUpdate(vid, target libfossil.FslID, dryRun bool) ([]updateStep, error) { files, rids, err := c.resolveFiles(target) if err != nil { return nil, err } var scanned []scanWrite if vid > 0 { if scanned, err = c.scanVersion(vid); err != nil { return nil, err } } if !dryRun { if err := c.storeScan(scanned); err != nil { return nil, err } } targets := make(map[string]*updateTargetFile, len(files)) for i, f := range files { targets[f.Name] = &updateTargetFile{rid: int64(rids[i]), exe: strings.Contains(f.Perm, "x")} } rows, err := vfile.Load(c.db, int64(vid)) if err != nil { return nil, err } overlayScan(rows, scanned) steps := make([]updateStep, 0, len(rows)+len(files)) for i := range rows { row := &rows[i] missing, err := c.isMissing(*row) if err != nil { return nil, err } tgt := targets[row.Pathname] delete(targets, row.Pathname) steps = append(steps, updateStep{ name: row.Pathname, row: row, target: tgt, action: classifyUpdate(row, tgt, missing, false), }) } for _, f := range files { tgt, ok := targets[f.Name] if !ok { continue } unmanaged, err := c.isUntrackedFile(f.Name) if err != nil { return nil, err } steps = append(steps, updateStep{ name: f.Name, target: tgt, action: classifyUpdate(nil, tgt, false, unmanaged), }) } return steps, nil } // isUntrackedFile reports whether a regular file stands at name. func (c *Checkout) isUntrackedFile(name string) (bool, error) { fullPath, err := c.safePath(name) if err != nil { return false, err } info, err := c.env.Storage.Stat(fullPath) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, fmt.Errorf("stat %s: %w", fullPath, err) } return info.Mode().IsRegular(), nil } // applyUpdate carries out the steps, reports each to the observer and the // caller's callback, and records in fresh the mtime of each file written // with the target's content. With opts.DryRun nothing is written. func (c *Checkout) applyUpdate( ctx context.Context, steps []updateStep, opts UpdateOpts, fresh map[string]writtenFile, ) (UpdateResult, error) { strategy, ok := merge.StrategyByName("three-way") if !ok { return UpdateResult{}, fmt.Errorf("checkout.Update: three-way merge strategy not registered") } var res UpdateResult for _, s := range steps { change, err := c.applyUpdateStep(s, strategy, opts.DryRun, fresh) if err != nil { return UpdateResult{}, fmt.Errorf("checkout.Update: %s: %w", s.name, err) } switch change { case UpdateNone: continue case UpdateAdded, UpdateUpdated, UpdateMerged, UpdateConflictUnmanaged: res.FilesWritten = append(res.FilesWritten, s.name) case UpdateConflictMerged: res.FilesWritten = append(res.FilesWritten, s.name) res.Conflicted = append(res.Conflicted, s.name) c.obs.Error(ctx, fmt.Errorf("checkout.Update: merge conflict in %s", s.name)) case UpdateConflictBinary: res.FilesWritten = append(res.FilesWritten, s.name) res.Unmerged = append(res.Unmerged, s.name) case UpdateConflictRm, UpdateConflictAdded: res.Unmerged = append(res.Unmerged, s.name) case UpdateRemoved: res.FilesRemoved = append(res.FilesRemoved, s.name) } c.obs.ExtractFileCompleted(ctx, s.name, change) if opts.Callback != nil { if err := opts.Callback(s.name, change); err != nil { return UpdateResult{}, fmt.Errorf("checkout.Update: callback for %s: %w", s.name, err) } } } if !opts.DryRun { if err := c.setUpdatedModes(steps); err != nil { return UpdateResult{}, fmt.Errorf("checkout.Update: %w", err) } } sort.Strings(res.FilesWritten) sort.Strings(res.FilesRemoved) sort.Strings(res.Conflicted) sort.Strings(res.Unmerged) return res, nil } // applyUpdateStep carries out one step and names the change it made. func (c *Checkout) applyUpdateStep( s updateStep, strategy merge.Strategy, dryRun bool, fresh map[string]writtenFile, ) (UpdateChange, error) { switch s.action { case updateNothing: return UpdateNone, nil case updateWrite, updateAddOver: change := UpdateUpdated if s.row == nil { change = UpdateAdded } if s.action == updateAddOver { change = UpdateConflictUnmanaged } if dryRun { return change, nil } if s.action == updateAddOver { // As fossil: back the untracked file up, then delete it, so a // read-only one is replaced too. if err := c.backUpOriginal(s.name); err != nil { return UpdateNone, err } if err := c.removeUpdated(s.name); err != nil { return UpdateNone, err } } return change, c.writeUpdateTarget(s, fresh) case updateMerge: return c.mergeUpdate(s, strategy, dryRun, fresh) case updateRemove: if dryRun { return UpdateRemoved, nil } return UpdateRemoved, c.removeUpdated(s.name) case updateKeepEdited: return UpdateConflictRm, nil case updateKeepDuplicate: return UpdateConflictAdded, nil case updateKeepAdd: return UpdateAddPropagated, nil } panic(fmt.Sprintf("checkout.applyUpdateStep: unknown action %d", s.action)) } // writeUpdateTarget writes the target's content for s and records in fresh // the mtime the write gave it. func (c *Checkout) writeUpdateTarget(s updateStep, fresh map[string]writtenFile) error { data, err := content.Expand(c.repo.DB(), libfossil.FslID(s.target.rid)) if err != nil { return fmt.Errorf("expand rid %d: %w", s.target.rid, err) } if err := c.writeMergedFile(s.name, data, s.target.exe); err != nil { return err } fullPath, err := c.safePath(s.name) if err != nil { return err } mtime, err := c.writtenMTime(fullPath) if err != nil { return err } fresh[s.name] = writtenFile{rid: s.target.rid, mtime: mtime} return nil } // mergeUpdate merges the working file's changes into the target's content: // the checkout's version of the file is the base, the working file the // local side, the target the other, as fossil's update calls merge_3way. // // Binary content is not line-merged: as fossil does, the working copy is // saved as NAME-original and the target's content written. func (c *Checkout) mergeUpdate( s updateStep, strategy merge.Strategy, dryRun bool, fresh map[string]writtenFile, ) (UpdateChange, error) { if s.row == nil || s.row.RID <= 0 || s.target == nil { panic("checkout.mergeUpdate: merge needs both versions") } base, err := content.Expand(c.repo.DB(), libfossil.FslID(s.row.RID)) if err != nil { return UpdateNone, fmt.Errorf("expand rid %d: %w", s.row.RID, err) } local, present, err := c.readWorkingFile(*s.row) if err != nil { return UpdateNone, err } if !present { panic("checkout.mergeUpdate: a missing file is written, not merged") } other, err := content.Expand(c.repo.DB(), libfossil.FslID(s.target.rid)) if err != nil { return UpdateNone, fmt.Errorf("expand rid %d: %w", s.target.rid, err) } if !(merge.IsText(base) && merge.IsText(local) && merge.IsText(other)) { if dryRun { return UpdateConflictBinary, nil } if err := c.backUpOriginal(s.name); err != nil { return UpdateNone, err } return UpdateConflictBinary, c.writeUpdateTarget(s, fresh) } merged, err := strategy.Merge(base, local, other) if err != nil { return UpdateNone, err } change := UpdateMerged if !merged.Clean { change = UpdateConflictMerged } if dryRun { return change, nil } // The row the target's version gets records no mtime, so the next scan // hashes the merged file and finds it edited. return change, c.writeMergedFile(s.name, merged.Content, s.target.exe) } // removeUpdated deletes name's file, which the target does not have. One // already gone is fine. func (c *Checkout) removeUpdated(name string) error { fullPath, err := c.safePath(name) if err != nil { return err } if err := c.env.Storage.Remove(fullPath); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("remove %s: %w", fullPath, err) } return nil } // backUpOriginal copies the untracked file at name to NAME-original, or // NAME-original-N for the first N free, as fossil's file_newname names it, // before the target's file is written over it. func (c *Checkout) backUpOriginal(name string) error { fullPath, err := c.safePath(name) if err != nil { return err } data, err := c.env.Storage.ReadFile(fullPath) if err != nil { return fmt.Errorf("read %s: %w", fullPath, err) } backup := fullPath + "-original" for n := 0; ; n++ { if n > backupNamesMax { return fmt.Errorf("no free backup name for %s", fullPath) } if _, err := c.env.Storage.Stat(backup); errors.Is(err, os.ErrNotExist) { break } backup = fmt.Sprintf("%s-original-%d", fullPath, n) } if err := c.env.Storage.WriteFile(backup, data, 0o644); err != nil { return fmt.Errorf("back up %s: %w", fullPath, err) } return nil } // backupNamesMax bounds the search for a free backup name. const backupNamesMax = 1000 // finalizeUpdate moves the checkout database to target in one // transaction. A pending add keeps its row, now in the target version, as // fossil's update moves it; the target's files are loaded, a file removed // locally stays removed, and each file written with the target's content // records the mtime of that write (an unchanged file keeps its mtime // through loadVFile, as fossil's update transfers it). Last, vvar names the // new version. func (c *Checkout) finalizeUpdate( target libfossil.FslID, steps []updateStep, fresh map[string]writtenFile, ) (err error) { tx, err := c.db.Begin() if err != nil { return fmt.Errorf("checkout.Update: begin: %w", err) } defer func() { if err != nil { err = errors.Join(err, tx.Rollback()) } }() if err := c.moveUpdateRows(tx, target, steps); err != nil { return err } for name, w := range fresh { if err := c.recordMTime(tx, target, name, w.rid, w.mtime); err != nil { return fmt.Errorf("checkout.Update: %w", err) } } if err := c.setUpdatedVersion(tx, target); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("checkout.Update: commit: %w", err) } return nil } // moveUpdateRows carries the rows of the steps over to version target // through q: pending adds move, the target's files load, and local removals // stay. func (c *Checkout) moveUpdateRows(q sqlExecer, target libfossil.FslID, steps []updateStep) error { for _, s := range steps { if s.action != updateKeepAdd { continue } if _, err := q.Exec("UPDATE vfile SET vid = ? WHERE id = ?", int64(target), s.row.ID); err != nil { return fmt.Errorf("checkout.Update: keep the add of %s: %w", s.name, err) } } if err := c.loadVFile(q, target, true); err != nil { return fmt.Errorf("checkout.Update: reload vfile: %w", err) } for _, s := range steps { if s.row == nil || s.row.Deleted == 0 || s.target == nil { continue } if _, err := q.Exec("UPDATE vfile SET deleted = 1 WHERE vid = ? AND pathname = ?", int64(target), s.name); err != nil { return fmt.Errorf("checkout.Update: keep the removal of %s: %w", s.name, err) } } return nil } // setUpdatedVersion records target as the checked-out version in vvar. func (c *Checkout) setUpdatedVersion(q sqlExecer, target libfossil.FslID) error { var targetUUID string err := c.repo.DB().QueryRow("SELECT uuid FROM blob WHERE rid = ?", int64(target)).Scan(&targetUUID) if err != nil { return fmt.Errorf("checkout.Update: query target uuid: %w", err) } if err := setVVar(q, "checkout", strconv.FormatInt(int64(target), 10)); err != nil { return fmt.Errorf("checkout.Update: %w", err) } if err := setVVar(q, "checkout-hash", targetUUID); err != nil { return fmt.Errorf("checkout.Update: %w", err) } return nil } // overlayScan applies a scan's findings to rows read before they were // stored, so a dry run plans from them without writing them. func overlayScan(rows []vfile.Row, scanned []scanWrite) { chnged := make(map[int64]int64, len(scanned)) for _, w := range scanned { chnged[w.id] = w.chnged } for i := range rows { if v, ok := chnged[rows[i].ID]; ok { rows[i].Chnged = v } } } // checkUpdateTargets refuses, before anything is written, an update that // could not finish: a file it writes or deletes where something other than // a regular file stands or under a parent that is not a directory // (checkMergeTarget), or one this process may not change. Fossil rolls such // an update back; go-libfossil cannot roll back disk, so it refuses first. func (c *Checkout) checkUpdateTargets(steps []updateStep) error { for _, s := range steps { switch s.action { case updateWrite, updateAddOver, updateMerge, updateRemove: default: continue } if err := c.checkMergeTarget(s.name); err != nil { return err } if err := c.checkWritable(s); err != nil { return fmt.Errorf("cannot update %s: %w", s.name, err) } } return nil } // writableChecker is the optional storage method that reports whether this // process may write a path. Storages without permissions do not have it. type writableChecker interface { CheckWritable(path string) error } // checkWritable checks the access step s needs. Every step changes the // entries of the file's directory (its nearest existing ancestor, should // it have to be made): a remove, a new file, a NAME-original backup. A // write or merge over an existing file also writes the file itself; an // untracked file the target adds over is removed, not written. func (c *Checkout) checkWritable(s updateStep) error { checker, ok := c.env.Storage.(writableChecker) if !ok { return nil } fullPath, err := c.safePath(s.name) if err != nil { return err } dir := filepath.Dir(fullPath) for i := 0; ; i++ { if i > maxWalkDepth { return fmt.Errorf("no existing parent directory for %s", fullPath) } if _, err := c.env.Storage.Stat(dir); err == nil { break } dir = filepath.Dir(dir) } if err := checker.CheckWritable(dir); err != nil { return err } if s.action != updateWrite && s.action != updateMerge { return nil } if _, err := c.env.Storage.Stat(fullPath); errors.Is(err, os.ErrNotExist) { return nil } return checker.CheckWritable(fullPath) } // setUpdatedModes gives every file of the target on disk the target's // executable bit, as fossil's checkout_set_all_exe does after an update. func (c *Checkout) setUpdatedModes(steps []updateStep) error { for _, s := range steps { if s.target == nil { continue } if err := c.setMergedMode(s.name, s.target.exe); err != nil { return err } } return nil }