Artifact 905ad19f2ba5cb663f928d0b4ebe5652a2cc1fc23e256e2efe4c55e03d30c023:
- File
internal/checkout/revert.go
— part of check-in
[c314e928f8]
at
2026-10-21 12:29:31
on branch feat/submanifest-reader
— fix(stash): stash a pending merge as fossil does
Save no longer refuses while a merge is pending. As fossil's stash save, it stashes each changed file against the content last written for it (mrid) and reverts only the stashed files, so vmerge is kept and the next commit records the merge parent. A file the merge added is not in the checked-out version, so its revert deletes it.
Apply now fails before writing anything when a stashed modified file is missing from disk, as fossil's pop fails with "no such file" and rolls back; this is what a merge-added file meets after the save.
In a sparse checkout the record-only merge outside the cone is left pending and unmaterialized, and the in-cone files round-trip. (user: [email protected], size: 16474) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "database/sql" "errors" "fmt" "strconv" "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. Each file it writes or deletes is saved for undo first // (except under js/wasm, which has no undo), so Undo brings back the edits // it discarded. 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 // - If renamed: move it back to its committed name, whatever holds that // name now (see planRevertSteps). // // 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 := c.effectiveRows(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) } } return c.revertRows(rows, targets, inVersion, true, callback) } // 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. 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 := c.effectiveRows(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, false, callback) } // revertAction is what revert does to one row. planRevertSteps decides it // for every row before anything changes. type revertAction int const ( revertRestore revertAction = iota revertUnmanage revertRemoveMergeAdded revertUndoRename // revertDisplace drops a row that holds the name a rename goes back to, // as fossil's UPDATE OR REPLACE does; the renamed file is written over // its file. revertDisplace ) // 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 } // revertStep is one row's part in a revert. type revertStep struct { row vfile.Row action revertAction recordOnly bool } // planRevertSteps plans the revert of targets, chosen from rows (every row // of the version). Undoing a rename takes back the old name from whatever // row holds it now, as fossil's revert does: a row renamed onto it (a swap // or a chain) is reverted too, to its own old name, and any other row there // (a new add) is dropped, its file overwritten. Fossil leaves a swap's rows // renamed and drops a chain's displaced row; here every committed file ends // under its committed name. func planRevertSteps(rows, targets []vfile.Row, inVersion map[string]bool) []revertStep { byPath := make(map[string]vfile.Row, len(rows)) for _, r := range rows { byPath[r.Pathname] = r } steps := make([]revertStep, 0, len(targets)) planned := make(map[int64]int, len(targets)) // row id -> index in steps add := func(r vfile.Row) { planned[r.ID] = len(steps) steps = append(steps, revertStep{row: r, action: planRevert(r, inVersion)}) } for _, t := range targets { if _, ok := planned[t.ID]; !ok { add(t) } } // steps grows as renames pull in the rows holding their old names; each // row is planned once, so the loop ends. for i := 0; i < len(steps); i++ { if steps[i].action != revertUndoRename { continue } holder, ok := byPath[steps[i].row.RenamedFrom()] if !ok || holder.ID == steps[i].row.ID { continue } j, ok := planned[holder.ID] if !ok { add(holder) j = len(steps) - 1 } if steps[j].action == revertRestore { steps[j].action = revertDisplace } } if len(steps) > len(rows) { panic("checkout.planRevertSteps: more steps than rows") } return steps } // revertRows reverts targets, chosen from rows (every row of the version), // and with clearMerge drops the pending merge. Like fossil's revert it saves // every file it writes or deletes for undo, then changes vfile and the files // in one transaction; should a step fail, the transaction rolls back and the // saved files are put back, as fossil's undo_rollback does. func (c *Checkout) revertRows( rows, targets []vfile.Row, inVersion map[string]bool, clearMerge bool, callback func(string, RevertChange) error, ) (err error) { steps := planRevertSteps(rows, targets, inVersion) for i := range steps { steps[i].recordOnly = !c.covers(steps[i].row.Pathname) } saved, err := c.readRevertUndo(steps) if err != nil { return err } tx, err := c.db.Begin() if err != nil { return fmt.Errorf("begin revert: %w", err) } committed := false defer func() { if !committed { err = errors.Join(err, tx.Rollback(), c.rollBackRevert(saved)) } }() if err := saved.save(tx); err != nil { return fmt.Errorf("save undo state: %w", err) } if clearMerge { if _, err := tx.Exec("DELETE FROM vmerge"); err != nil { return fmt.Errorf("clear vmerge: %w", err) } } if err := c.applyRevert(tx, steps, callback); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit revert: %w", err) } committed = true return nil } // revertNames returns the names whose files steps write or delete, each // once, for undo: a row's own name unless it is only un-managed or dropped, // and a rename's old name. func revertNames(steps []revertStep) []string { seen := make(map[string]bool, len(steps)) var names []string note := func(name string) { if !seen[name] { seen[name] = true names = append(names, name) } } for _, s := range steps { if s.recordOnly { continue } switch s.action { case revertUnmanage, revertDisplace: case revertUndoRename: note(s.row.Pathname) note(s.row.RenamedFrom()) default: note(s.row.Pathname) } } return names } // applyRevert carries out steps within tx, in three passes so a swap or a // chain never writes a file or a row over one still to move: first every // file leaving its name is removed and every row leaving vfile deleted, a // renamed row parked on a placeholder name; then the renamed rows take // their old names; then committed content is written. Each step is // reported to callback, under the name its file ends up with. func (c *Checkout) applyRevert( tx *sql.Tx, steps []revertStep, callback func(string, RevertChange) error, ) error { paths := make([]string, len(steps)) for i, s := range steps { if s.recordOnly { continue } name := s.row.Pathname if s.action == revertUndoRename { name = s.row.RenamedFrom() } p, err := c.safePath(name) if err != nil { return fmt.Errorf("path traversal in %s: %w", name, err) } paths[i] = p } for _, s := range steps { if err := c.vacateRevertRow(tx, s); err != nil { return err } } for _, s := range steps { if s.recordOnly || s.action != revertUndoRename { continue } if _, err := tx.Exec( "UPDATE vfile SET pathname = ?, origname = NULL WHERE id = ?", s.row.RenamedFrom(), s.row.ID, ); err != nil { return fmt.Errorf("rename %s back to %s: %w", s.row.Pathname, s.row.RenamedFrom(), err) } } for i, s := range steps { name, change := s.row.Pathname, RevertContents switch s.action { case revertRestore, revertUndoRename: if s.action == revertUndoRename { name, change = s.row.RenamedFrom(), RevertRename } if !s.recordOnly { if err := c.restoreCommitted(tx, s.row.ID, name, paths[i], s.row.RID); err != nil { return err } } case revertUnmanage: change = RevertUnmanage case revertRemoveMergeAdded: change = RevertRemove case revertDisplace: continue default: panic("checkout.applyRevert: unknown action") } if callback != nil { if err := callback(name, change); err != nil { return fmt.Errorf("callback for %s: %w", name, err) } } } return nil } // vacateRevertRow is applyRevert's first pass for step s: a renamed file is // removed from its new name and its row parked on a placeholder, a file a // merge added is removed with its row, and an add or a displaced row leaves // vfile, its file left on disk. func (c *Checkout) vacateRevertRow(tx *sql.Tx, s revertStep) error { if s.recordOnly { _, err := tx.Exec("DELETE FROM sparse_pending WHERE pathname=?", s.row.Pathname) return err } switch s.action { case revertRestore: return nil case revertUndoRename: if s.row.RID <= 0 { panic("checkout.vacateRevertRow: a pending add has no committed name") } if !s.recordOnly { if err := c.removeTrackedFile(s.row.Pathname); err != nil { return err } } // A leading slash is never a checkout path, so the placeholder // cannot collide with a row. parked := "/revert/" + strconv.FormatInt(s.row.ID, 10) if _, err := tx.Exec("UPDATE vfile SET pathname = ? WHERE id = ?", parked, s.row.ID); err != nil { return fmt.Errorf("move %s aside: %w", s.row.Pathname, err) } return nil case revertRemoveMergeAdded: if s.row.RID <= 0 { panic("checkout.vacateRevertRow: pending add has no merged-in content") } if !s.recordOnly { if err := c.removeTrackedFile(s.row.Pathname); err != nil { return err } } case revertUnmanage, revertDisplace: default: panic("checkout.vacateRevertRow: unknown action") } if _, err := tx.Exec("DELETE FROM vfile WHERE id = ?", s.row.ID); err != nil { return fmt.Errorf("delete vfile for %s: %w", s.row.Pathname, err) } return nil } // removeTrackedFile removes the regular file a row names, if there is one. func (c *Checkout) removeTrackedFile(name string) error { fullPath, err := c.safePath(name) if err != nil { return fmt.Errorf("path traversal in %s: %w", name, err) } return c.removeRegularFile(fullPath) } // VersionPaths returns the names the checked-out version's manifest holds, // covered by the cone or not. func (c *Checkout) VersionPaths() (map[string]bool, error) { if c == nil { panic("checkout.VersionPaths: nil *Checkout") } vid, _, err := c.Version() if err != nil { return nil, fmt.Errorf("checkout.VersionPaths: %w", err) } return c.versionPaths(vid) } // 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 } // removeRegularFile deletes fullPath if a file is there, a symlink seen as // itself (vfile.Disk, as fossil's file_delete removes the link); 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") } disk, err := c.newDiskView() if err != nil { return fmt.Errorf("checkout: %w", err) } info, err := disk.Stat(fullPath) if err != nil { return fmt.Errorf("checkout: stat %s: %w", fullPath, err) } if !isFileOrLink(info) { return nil } if err := c.env.Storage.Remove(fullPath); err != nil { return fmt.Errorf("checkout: remove %s: %w", fullPath, err) } return nil } // restoreCommitted writes the committed content of blob rid back to fullPath, // with the row's executable and symlink bits, as fossil's revert does // (vfile.Disk.Write: a symlink is made a symlink again, and nothing is // written through one), and marks row id unchanged and unmerged, within tx. func (c *Checkout) restoreCommitted(tx *sql.Tx, 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, islink int64 err = tx.QueryRow( "SELECT CAST(isexe AS INTEGER), CAST(coalesce(islink, 0) AS INTEGER) FROM vfile WHERE id = ?", id, ).Scan(&isexe, &islink) if err != nil { return fmt.Errorf("checkout.Revert: query vfile metadata for %s: %w", pathname, err) } disk, err := c.newDiskView() if err != nil { return fmt.Errorf("checkout.Revert: %w", err) } if err := disk.Write(fullPath, data, isexe != 0, islink != 0); err != nil { return fmt.Errorf("checkout.Revert: %w", err) } mtime, err := disk.MTime(fullPath) if err != nil { return fmt.Errorf("checkout.Revert: %w", err) } // mrid and mhash go back to the committed version, so no merged-in // content outlives the revert, and the mtime is the restored file's // (fossil resets them the same way). if _, err := tx.Exec( "UPDATE vfile SET chnged = 0, deleted = 0, mrid = rid, mhash = NULL, mtime = ? WHERE id = ?", mtime, id, ); err != nil { return fmt.Errorf("checkout.Revert: update vfile for %s: %w", pathname, err) } return nil }