Artifact 49c05cbf8f2281f6c313c54d1da0f0c302f02a81079b1d2497fdd6e42407a81f:
- File
internal/checkout/revert.go
— part of check-in
[50d1a7f72b]
at
2026-10-03 17:38:39
on branch trunk
— fix(checkout): revert undoes a pending rename (#242) (#249)
* fix(checkout): revert undoes a pending rename (#242)
Revert-all selected rows by content and removal, so a rename recorded by fossil mv (origname set, chnged 0) was skipped; a renamed and edited file had its committed content written under the new name, leaving the rename pending. A named revert only matched the current name.
Like fossil's revert, a pending rename is now undone: the file under the new name is removed, the row takes its old name back (UPDATE OR REPLACE, as fossil does), and the committed content is written there. Revert-all includes renamed rows, and a named revert answers to either the new or the old name. A renamed file a merge added is still removed outright, as before and as in fossil.
removeRegularFile is shared by the rename undo and removeMergeAdded, so neither deletes a directory the user made at that path.
* fix(checkout): plan a revert before changing anything (#242)
Undoing a rename with UPDATE OR REPLACE let a swap destroy files: reverting a.txt and b.txt exchanged through fossil mv deleted the other row, the loop then removed a file it had just restored from its stale row list, and the next restore failed half way, leaving both files gone from disk. A new file added under the old name was overwritten the same way. A named revert of the old name also stopped at the first match.
Revert now plans, checks, then acts. planRevert decides every target row's action (restore, un-manage, remove a merge-added file, undo a rename) before anything changes; checkRenameUndoFree refuses the whole revert when undoing a rename would land on a name another tracked row holds, since go-libfossil has no undo copy to recover an overwritten file (fossil saves one); revertFile carries out the planned action. The rename undo is a plain UPDATE, which fails rather than replace a row. A name selects every row it matches, new name or old. The callback reports a rename under the name the file gets back.
Swaps and chains, which fossil completes via its undo store, are #248.
* fix(checkout): revert named paths as one; refuse untracked file at old name (#242)
Review of the planned revert found two remaining ways to lose work:
- Several named paths were reverted one at a time, so in `revert b.txt z.txt` the edit to b.txt was discarded before z.txt's rename undo was refused. revertPaths now gathers every named row (new or old name) and plans and checks them as one revert: a refusal reverts none of them. - Undoing a rename restored the committed content over an untracked file at the old name. checkRenameUndoFree now also refuses when a file on disk there differs from the committed content (wouldClobber), for the same reason it refuses a tracked one: there is no undo copy.
* docs(changelog): note revert's untracked-file and multi-path rules (#242) (user: [email protected], size: 13019) [annotate] [blame] [check-ins using]
package checkout import ( "errors" "fmt" "os" "path/filepath" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/vfile" ) // Revert restores files to their checkout version state, the way fossil's // revert does. Edits to the reverted files are discarded: fossil saves an undo // copy first, but go-libfossil has no undo yet, so they are gone. If opts.Paths is empty, reverts ALL changed files. Change // flags are refreshed from disk first, so an edit not yet scanned is // reverted too. // // For each file to revert: // - If rid==0 (newly added): un-manage it (DELETE from vfile). The file stays // on disk: it was never committed, so deleting it would destroy the only copy. // - If rid>0 (existing, modified/deleted): restore original content, reset chnged=0, deleted=0 // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) Revert(opts RevertOpts) error { if c == nil { panic("checkout.Revert: nil *Checkout") } // Get current version vid, _, err := c.Version() if err != nil { return fmt.Errorf("checkout.Revert: %w", err) } if vid > 0 { if err := c.refreshChanged(vid); err != nil { return fmt.Errorf("checkout.Revert: %w", err) } } if len(opts.Paths) > 0 { if err := c.revertPaths(vid, opts.Paths, opts.Callback); err != nil { return fmt.Errorf("checkout.Revert: %w", err) } return nil } if err := c.revertAll(vid, opts.Callback); err != nil { return fmt.Errorf("checkout.Revert: %w", err) } return nil } // revertAll reverts every changed file of version vid and drops any pending // merge, as fossil's revert does with no file named. A file is reverted when // its content changed, it is removed, an add is pending, a merge touched it, // it is renamed, or it is missing from disk. func (c *Checkout) revertAll(vid libfossil.FslID, callback func(string, RevertChange) error) error { if vid < 0 { panic("checkout.revertAll: negative vid") } rows, err := vfile.Load(c.db, int64(vid)) if err != nil { return err } inVersion, err := c.versionPaths(vid) if err != nil { return err } var targets []vfile.Row for _, r := range rows { missing, err := c.isMissing(r) if err != nil { return err } if needsRevert(r, missing) { targets = append(targets, r) } } if err := c.revertRows(rows, targets, inVersion, callback); err != nil { return err } if _, err := c.db.Exec("DELETE FROM vmerge"); err != nil { return fmt.Errorf("clear vmerge: %w", err) } return nil } // needsRevert reports whether revert-all restores row r: its content // changed, it is removed or renamed, a merge touched it, or it is missing // from disk. func needsRevert(r vfile.Row, missing bool) bool { if missing { return true } if r.ContentChanged() { return true } if r.IsRemoved() { return true } if r.RenamedFrom() != "" { return true } return r.PendingMerge() } // revertPaths reverts the named files whatever their change flags say, as // fossil's revert does for files it is given: a file missing from disk is // restored too. A renamed file answers to its new name and its old one, so a // name can select more than one row. Every named row is planned and checked // as one revert, so a refusal reverts none of them. A path the checkout does // not track is a no-op, as in fossil. func (c *Checkout) revertPaths( vid libfossil.FslID, paths []string, callback func(string, RevertChange) error, ) error { if len(paths) == 0 { panic("checkout.revertPaths: no paths") } named := make(map[string]bool, len(paths)) for _, p := range paths { named[p] = true } rows, err := vfile.Load(c.db, int64(vid)) if err != nil { return err } var targets []vfile.Row for _, r := range rows { if named[r.Pathname] || named[r.RenamedFrom()] { targets = append(targets, r) } } if len(targets) == 0 { return nil } inVersion, err := c.versionPaths(vid) if err != nil { return err } return c.revertRows(rows, targets, inVersion, callback) } // revertAction is what revert does to one row. planRevert decides it for // every row before anything changes, so a revert that cannot be done safely // is refused whole. type revertAction int const ( revertRestore revertAction = iota revertUnmanage revertRemoveMergeAdded revertUndoRename ) // planRevert decides a row's revert the way fossil's revert does, by whether // the checked-out version has the file (under its name, or its name before a // pending rename): // - a pending add (rid=0) is un-managed and left on disk: there is no // committed copy, so deleting it would destroy the only one; // - a file the version lacks but the checkout tracks (one a merge added) // is deleted with its row: its content lives on in the merged-in version; // - a pending rename is undone: the file under the new name is removed and // the committed content is restored under the old one; // - anything else gets its committed content back. func planRevert(r vfile.Row, inVersion map[string]bool) revertAction { if inVersion == nil { panic("checkout.planRevert: nil inVersion") } if r.IsAdded() { return revertUnmanage } if !inVersion[r.Pathname] && !inVersion[r.RenamedFrom()] { return revertRemoveMergeAdded } if r.RenamedFrom() != "" { return revertUndoRename } return revertRestore } // revertRows reverts targets, chosen from rows (every row of the version). // It first checks that undoing each rename among them lands on a free name; // if one does not, nothing changes. func (c *Checkout) revertRows( rows, targets []vfile.Row, inVersion map[string]bool, callback func(string, RevertChange) error, ) error { if err := c.checkRenameUndoFree(rows, targets, inVersion); err != nil { return err } for _, t := range targets { if err := c.revertFile(t, planRevert(t, inVersion), callback); err != nil { return err } } return nil } // checkRenameUndoFree refuses a revert that would move a renamed file back // onto a name that is taken: by another tracked row (a swap, a chain, or a // new add at the old name), or by an untracked file on disk whose content // differs from the committed one. Moving it anyway would overwrite that file, // and go-libfossil has no undo copy to recover it from, unlike fossil. func (c *Checkout) checkRenameUndoFree( rows, targets []vfile.Row, inVersion map[string]bool, ) error { held := make(map[string]int64, len(rows)) for _, r := range rows { held[r.Pathname] = r.ID } for _, t := range targets { if planRevert(t, inVersion) != revertUndoRename { continue } oldName := t.RenamedFrom() if id, ok := held[oldName]; ok && id != t.ID { return fmt.Errorf( "checkout.Revert: cannot move %s back to %s: another tracked file has that "+ "name; revert or commit it first", t.Pathname, oldName) } committed, err := c.baselineHash(t.RID) if err != nil { return fmt.Errorf("checkout.Revert: %s: %w", t.Pathname, err) } clobber, err := c.wouldClobber(oldName, committed) if err != nil { return err } if clobber { return fmt.Errorf( "checkout.Revert: cannot move %s back to %s: an untracked file is there; "+ "move it aside first", t.Pathname, oldName) } } return nil } // versionPaths returns the set of pathnames in checked-out version vid's // manifest; empty when nothing is checked out yet. func (c *Checkout) versionPaths(vid libfossil.FslID) (map[string]bool, error) { paths := make(map[string]bool) if vid <= 0 { return paths, nil } files, err := manifest.ListFiles(c.repo, vid) if err != nil { return nil, fmt.Errorf("list version files: %w", err) } for _, f := range files { paths[f.Name] = true } return paths, nil } // revertFile carries out the action planRevert chose for row r, and reports // it under the name the file ends up with. func (c *Checkout) revertFile( r vfile.Row, action revertAction, callback func(string, RevertChange) error, ) error { fullPath, err := c.safePath(r.Pathname) if err != nil { return fmt.Errorf("checkout.Revert: path traversal in %s: %w", r.Pathname, err) } name, change := r.Pathname, RevertContents switch action { case revertUnmanage: change = RevertUnmanage if _, err := c.db.Exec("DELETE FROM vfile WHERE id = ?", r.ID); err != nil { return fmt.Errorf("checkout.Revert: delete vfile for %s: %w", r.Pathname, err) } case revertRemoveMergeAdded: change = RevertRemove if err := c.removeMergeAdded(r, fullPath); err != nil { return err } case revertUndoRename: name, change = r.RenamedFrom(), RevertRename if err := c.undoRename(r, fullPath); err != nil { return err } case revertRestore: if err := c.restoreCommitted(r.ID, r.Pathname, fullPath, r.RID); err != nil { return err } default: panic("checkout.revertFile: unknown action") } if callback != nil { if err := callback(name, change); err != nil { return fmt.Errorf("checkout.Revert: callback for %s: %w", name, err) } } return nil } // undoRename moves a pending rename back, as fossil's revert does: the file // at the new name (fullPath) is removed, the row takes its old name again, // and the committed content is written there. checkRenameUndoFree has made // sure no other row holds the old name, so a plain UPDATE suffices; it fails // rather than replacing a row if that ever stops being true. func (c *Checkout) undoRename(r vfile.Row, fullPath string) error { oldName := r.RenamedFrom() if oldName == "" { panic("checkout.undoRename: row records no rename") } if r.RID <= 0 { panic("checkout.undoRename: a pending add has no committed name") } oldPath, err := c.safePath(oldName) if err != nil { return fmt.Errorf("checkout.Revert: path traversal in %s: %w", oldName, err) } if err := c.removeRegularFile(fullPath); err != nil { return err } if _, err := c.db.Exec( "UPDATE vfile SET pathname = ?, origname = NULL WHERE id = ?", oldName, r.ID, ); err != nil { return fmt.Errorf("checkout.Revert: rename %s back to %s: %w", r.Pathname, oldName, err) } return c.restoreCommitted(r.ID, oldName, oldPath, r.RID) } // removeRegularFile deletes fullPath if a regular file is there; anything // else (nothing, or a directory the user made) is left alone. func (c *Checkout) removeRegularFile(fullPath string) error { if fullPath == "" { panic("checkout.removeRegularFile: empty path") } info, err := c.env.Storage.Stat(fullPath) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("checkout.Revert: stat %s: %w", fullPath, err) } if !info.Mode().IsRegular() { return nil } if err := c.env.Storage.Remove(fullPath); err != nil { return fmt.Errorf("checkout.Revert: remove %s: %w", fullPath, err) } return nil } // removeMergeAdded deletes a file the checked-out version does not have, // from disk and from vfile. func (c *Checkout) removeMergeAdded(r vfile.Row, fullPath string) error { if r.RID <= 0 { panic("checkout.removeMergeAdded: pending add has no merged-in content") } // Only a regular file is the merge's: anything else at that path (a // directory the user made) is left alone. if err := c.removeRegularFile(fullPath); err != nil { return err } if _, err := c.db.Exec("DELETE FROM vfile WHERE id = ?", r.ID); err != nil { return fmt.Errorf("checkout.Revert: delete vfile for %s: %w", r.Pathname, err) } return nil } // restoreCommitted writes the committed content of blob rid back to fullPath, // with the row's executable bit, and marks row id unchanged and unmerged. func (c *Checkout) restoreCommitted(id int64, pathname, fullPath string, rid int64) error { if rid <= 0 { panic("checkout.restoreCommitted: rid must be positive") } if fullPath == "" { panic("checkout.restoreCommitted: empty fullPath") } data, err := content.Expand(c.repo.DB(), libfossil.FslID(rid)) if err != nil { return fmt.Errorf("checkout.Revert: expand blob for %s: %w", pathname, err) } var isexe int64 err = c.db.QueryRow("SELECT CAST(isexe AS INTEGER) FROM vfile WHERE id = ?", id).Scan(&isexe) if err != nil { return fmt.Errorf("checkout.Revert: query vfile metadata for %s: %w", pathname, err) } parentDir := filepath.Dir(fullPath) if err := c.env.Storage.MkdirAll(parentDir, 0o755); err != nil { return fmt.Errorf("checkout.Revert: mkdir %s: %w", parentDir, err) } perm := os.FileMode(0o644) if isexe != 0 { perm = 0o755 } if err := c.env.Storage.WriteFile(fullPath, data, perm); err != nil { return fmt.Errorf("checkout.Revert: write %s: %w", fullPath, err) } // mrid and mhash go back to the committed version, so no merged-in // content outlives the revert (fossil resets them the same way). if _, err := c.db.Exec( "UPDATE vfile SET chnged = 0, deleted = 0, mrid = rid, mhash = NULL WHERE id = ?", id, ); err != nil { return fmt.Errorf("checkout.Revert: update vfile for %s: %w", pathname, err) } return nil }