aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/undo.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/undo.go')
-rw-r--r--internal/admin/undo.go134
1 files changed, 73 insertions, 61 deletions
diff --git a/internal/admin/undo.go b/internal/admin/undo.go
index c2ea61d..3001c8b 100644
--- a/internal/admin/undo.go
+++ b/internal/admin/undo.go
@@ -227,6 +227,17 @@ func (us *UndoStack) Redo() *ChangeDesc {
return &last
}
+// requireSnapshot rejects a nil before/after image on a change that must
+// restore real content. A failed before-image read leaves nil, and writing
+// it would truncate the file to zero bytes (which parses as a zero-value
+// def), so a missing image must fail the undo/redo instead.
+func requireSnapshot(content []byte, path, action string) error {
+ if content == nil {
+ return fmt.Errorf("%s %s: no snapshot recorded", action, path)
+ }
+ return nil
+}
+
// applyUndo reverts a change: the primary file, then every extra file.
// Extra-file failures propagate: a multi-file undo that restored only part
// of a change must be reported as failed, never as success.
@@ -270,6 +281,9 @@ func (us *UndoStack) applyUndo(c ChangeDesc) error {
return fmt.Errorf("remove created %s: %w", c.FilePath, err)
}
} else {
+ if err := requireSnapshot(c.OldContent, c.FilePath, "revert"); err != nil {
+ return err
+ }
if err := us.writeSnapshot(c.FilePath, restored); err != nil {
return fmt.Errorf("revert %s: %w", c.FilePath, err)
}
@@ -332,6 +346,9 @@ func (us *UndoStack) applyRedo(c ChangeDesc) error {
if c.NewFilePath != "" {
target = c.NewFilePath
}
+ if err := requireSnapshot(c.NewContent, target, "reapply"); err != nil {
+ return err
+ }
if err := us.writeSnapshot(target, restored); err != nil {
return fmt.Errorf("reapply %s: %w", target, err)
}
@@ -362,6 +379,9 @@ func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error {
return fmt.Errorf("undo restore %s: %w", ef.FilePath, err)
}
case ef.NewFilePath != "":
+ if err := requireSnapshot(ef.OldContent, ef.FilePath, "undo restore"); err != nil {
+ return err
+ }
if err := os.Remove(ef.NewFilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("undo remove %s: %w", ef.NewFilePath, err)
}
@@ -369,6 +389,9 @@ func (us *UndoStack) applyUndoExtraFile(ef ExtraFile) error {
return fmt.Errorf("undo restore %s: %w", ef.FilePath, err)
}
default:
+ if err := requireSnapshot(ef.OldContent, ef.FilePath, "undo revert"); err != nil {
+ return err
+ }
if err := us.writeRestored(ef.FilePath, ef.OldContent); err != nil {
return fmt.Errorf("undo revert %s: %w", ef.FilePath, err)
}
@@ -387,6 +410,9 @@ func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error {
return fmt.Errorf("redo create %s: %w", ef.FilePath, err)
}
case ef.NewFilePath != "":
+ if err := requireSnapshot(ef.NewContent, ef.NewFilePath, "redo create"); err != nil {
+ return err
+ }
if err := os.Remove(ef.FilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("redo remove %s: %w", ef.FilePath, err)
}
@@ -394,6 +420,9 @@ func (us *UndoStack) applyRedoExtraFile(ef ExtraFile) error {
return fmt.Errorf("redo create %s: %w", ef.NewFilePath, err)
}
default:
+ if err := requireSnapshot(ef.NewContent, ef.FilePath, "redo reapply"); err != nil {
+ return err
+ }
if err := us.writeRestored(ef.FilePath, ef.NewContent); err != nil {
return fmt.Errorf("redo reapply %s: %w", ef.FilePath, err)
}
@@ -450,7 +479,9 @@ func (us *UndoStack) writeSnapshot(path string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
- if err := os.WriteFile(path, content, us.fileMode(path)); err != nil {
+ // Atomic temp+fsync+rename: a crash mid-undo must never leave a
+ // truncated world file (empty YAML parses as a zero-value def).
+ if err := persistence.WriteFile(path, content, us.fileMode(path)); err != nil {
return err
}
if us.isPlayerDataPath(path) {
@@ -513,39 +544,46 @@ func (us *UndoStack) Info() UndoInfo {
return info
}
+// undoExtraEntry and undoEntry are the persisted undo/redo wire types. They
+// are declared ONCE and shared by save and load: when each function declared
+// its own copy, a field added to one and not the other silently dropped
+// persisted history on the next restart.
+type undoExtraEntry struct {
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete,omitempty"`
+ IsCreate bool `json:"is_create,omitempty"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
+}
+
+type undoEntry struct {
+ Time string `json:"time"`
+ Description string `json:"description"`
+ FilePath string `json:"file_path"`
+ NewFilePath string `json:"new_file_path,omitempty"`
+ OldContent string `json:"old_content"`
+ NewContent string `json:"new_content"`
+ IsDelete bool `json:"is_delete"`
+ IsCreate bool `json:"is_create"`
+ IsDirDelete bool `json:"is_dir_delete,omitempty"`
+ ExtraFiles []undoExtraEntry `json:"extra_files,omitempty"`
+ IDMap map[int]int `json:"id_map,omitempty"`
+}
+
+type undoFile struct {
+ History []undoEntry `json:"history"`
+ Redo []undoEntry `json:"redo"`
+}
+
func (us *UndoStack) save() {
- type extraEntry struct {
- FilePath string `json:"file_path"`
- NewFilePath string `json:"new_file_path,omitempty"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
- IsDelete bool `json:"is_delete,omitempty"`
- IsCreate bool `json:"is_create,omitempty"`
- IsDirDelete bool `json:"is_dir_delete,omitempty"`
- }
- type entry struct {
- Time string `json:"time"`
- Description string `json:"description"`
- FilePath string `json:"file_path"`
- NewFilePath string `json:"new_file_path,omitempty"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
- IsDelete bool `json:"is_delete"`
- IsCreate bool `json:"is_create"`
- IsDirDelete bool `json:"is_dir_delete,omitempty"`
- ExtraFiles []extraEntry `json:"extra_files,omitempty"`
- IDMap map[int]int `json:"id_map,omitempty"`
- }
- type saveData struct {
- History []entry `json:"history"`
- Redo []entry `json:"redo"`
- }
- toEntries := func(changes []ChangeDesc) []entry {
- var entries []entry
+ toEntries := func(changes []ChangeDesc) []undoEntry {
+ var entries []undoEntry
for _, c := range changes {
- var extra []extraEntry
+ var extra []undoExtraEntry
for _, ef := range c.ExtraFiles {
- extra = append(extra, extraEntry{
+ extra = append(extra, undoExtraEntry{
FilePath: ef.FilePath,
NewFilePath: ef.NewFilePath,
OldContent: string(us.redactContent(ef.OldContent, ef.FilePath, ef.NewFilePath)),
@@ -555,7 +593,7 @@ func (us *UndoStack) save() {
IsDirDelete: ef.IsDirDelete,
})
}
- entries = append(entries, entry{
+ entries = append(entries, undoEntry{
Time: c.Time,
Description: c.Description,
FilePath: c.FilePath,
@@ -571,7 +609,7 @@ func (us *UndoStack) save() {
}
return entries
}
- data := saveData{
+ data := undoFile{
History: toEntries(us.history),
Redo: toEntries(us.redo),
}
@@ -708,38 +746,12 @@ func (us *UndoStack) load() {
if err != nil {
return
}
- type extraEntry struct {
- FilePath string `json:"file_path"`
- NewFilePath string `json:"new_file_path,omitempty"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
- IsDelete bool `json:"is_delete,omitempty"`
- IsCreate bool `json:"is_create,omitempty"`
- IsDirDelete bool `json:"is_dir_delete,omitempty"`
- }
- type entry struct {
- Time string `json:"time"`
- Description string `json:"description"`
- FilePath string `json:"file_path"`
- NewFilePath string `json:"new_file_path,omitempty"`
- OldContent string `json:"old_content"`
- NewContent string `json:"new_content"`
- IsDelete bool `json:"is_delete"`
- IsCreate bool `json:"is_create"`
- IsDirDelete bool `json:"is_dir_delete,omitempty"`
- ExtraFiles []extraEntry `json:"extra_files,omitempty"`
- IDMap map[int]int `json:"id_map,omitempty"`
- }
- type saveData struct {
- History []entry `json:"history"`
- Redo []entry `json:"redo"`
- }
- var sd saveData
+ var sd undoFile
if err := json.Unmarshal(data, &sd); err != nil {
log.Printf("undo: failed to load history: %v", err)
return
}
- toChanges := func(entries []entry) []ChangeDesc {
+ toChanges := func(entries []undoEntry) []ChangeDesc {
out := make([]ChangeDesc, 0, len(entries))
for _, e := range entries {
c := ChangeDesc{