From b816969bea6fe45f1bff873ad43c3a33c605207e Mon Sep 17 00:00:00 2001 From: codeAnqiang-ma <273298913+codeAnqiang-ma@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:23:00 +0800 Subject: [PATCH] fix(restore): do not clobber a backup slot the undo skipped The undo direction never overwrites: every conflict check skips the entry and leaves the backup where it is. Redo had no matching check on the store side, so it renamed the live file into a slot that was still holding the only copy of a deleted file, counted it as restored, and that copy was gone. Guard the two redo paths that write into the session store, so an entry whose undo was skipped is skipped on the way back too. This keeps the promise in the package doc that both directions preserve data. Co-authored-by: Cursor --- internal/restore/restore.go | 8 +++++ internal/restore/restore_test.go | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/internal/restore/restore.go b/internal/restore/restore.go index eb175d6..7fc24ec 100644 --- a/internal/restore/restore.go +++ b/internal/restore/restore.go @@ -191,6 +191,10 @@ func Run(s *session.Session, dir Direction, opts Options) (*Result, error) { done = false break } + if exists(field(1)) { + skip("backup still holds the deleted file (this entry was never undone)") + continue + } if !act() { continue } @@ -256,6 +260,10 @@ func Run(s *session.Session, dir Direction, opts Options) (*Result, error) { skip("target occupied, use --force to overwrite") continue } + if exists(new_) && hasBak && exists(bak) { + skip("backup still holds the clobbered target (this entry was never undone)") + continue + } if !act() { continue } diff --git a/internal/restore/restore_test.go b/internal/restore/restore_test.go index 1d6fa22..22180e8 100644 --- a/internal/restore/restore_test.go +++ b/internal/restore/restore_test.go @@ -88,6 +88,34 @@ func TestUndoUnlinkConflictSkipsWithoutForce(t *testing.T) { } } +func TestRedoUnlinkKeepsOccupiedBackup(t *testing.T) { + work := t.TempDir() + victim := filepath.Join(work, "back.txt") + write(t, victim, "recreated since") // path exists again + s := newSession(t, nil) + backup := filepath.Join(s.Dir, "data", "b1") + write(t, backup, "the only copy") + s.Entries = []journal.Entry{{Op: journal.OpUnlink, Fields: []string{victim, backup}}} + + // the undo skips, so the backup still holds the deleted file + res, _ := Run(s, Undo, Options{}) + if res.Done != 0 || len(res.Skipped) != 1 { + t.Fatalf("want 0 done / 1 skipped, got %d done / %d skipped", res.Done, len(res.Skipped)) + } + + // redoing must not park the live file on top of it + res, _ = Run(s, Redo, Options{}) + if res.Done != 0 || len(res.Skipped) != 1 { + t.Fatalf("want 0 done / 1 skipped, got %d done / %d skipped", res.Done, len(res.Skipped)) + } + if got := read(t, backup); got != "the only copy" { + t.Errorf("redo overwrote the backup: %q", got) + } + if got := read(t, victim); got != "recreated since" { + t.Errorf("redo moved the live file away: %q", got) + } +} + func TestModSwapsBothDirections(t *testing.T) { work := t.TempDir() file := filepath.Join(work, "config.yaml") @@ -153,6 +181,35 @@ func TestRenameUndoRestoresBothSides(t *testing.T) { } } +func TestRedoRenameKeepsOccupiedBackup(t *testing.T) { + work := t.TempDir() + oldp := filepath.Join(work, "a.txt") + newp := filepath.Join(work, "b.txt") + write(t, oldp, "recreated since") // the source path exists again + write(t, newp, "moved content") + s := newSession(t, nil) + backup := filepath.Join(s.Dir, "data", "b1") + write(t, backup, "the only copy of b") + s.Entries = []journal.Entry{{Op: journal.OpRename, Fields: []string{oldp, newp, backup}}} + + // the undo skips, so the backup still holds what the mv clobbered + res, _ := Run(s, Undo, Options{}) + if res.Done != 0 || len(res.Skipped) != 1 { + t.Fatalf("want 0 done / 1 skipped, got %d done / %d skipped", res.Done, len(res.Skipped)) + } + + res, _ = Run(s, Redo, Options{}) + if res.Done != 0 || len(res.Skipped) != 1 { + t.Fatalf("want 0 done / 1 skipped, got %d done / %d skipped", res.Done, len(res.Skipped)) + } + if got := read(t, backup); got != "the only copy of b" { + t.Errorf("redo overwrote the backup: %q", got) + } + if got := read(t, newp); got != "moved content" { + t.Errorf("redo moved b.txt away: %q", got) + } +} + func TestRmdirUndoRecreatesDirectory(t *testing.T) { work := t.TempDir() gone := filepath.Join(work, "sub")