Skip to content

fix(snapshot): /redo touches all files unnecessarily, destroying date modified metadata and triggering editor reload prompts #15391

@NamedIdentity

Description

@NamedIdentity

Description

User Comment:
As a newbie developer that vibe codes, I'm hesitant to try to modify the snapshot system. My agents proposed solution seems sound. However, I'd appreciate an experienced developer reviewing this matter and providing an analysis of the proposed solution.

The Problem

When a user presses /redo (cancel an undo), every file in the project gets its modification timestamp updated — even files whose content didn't change. This destroys useful metadata, and prevents users and agents from knowing when the user or agent last modified a file. This also causes external editors (Notepad++, VS Code, etc.) to prompt "This file has been modified outside the editor. Do you want to reload it?" for every open project file, not just the ones that should have been restored.

Steps to Reproduce

  1. Open a project with files open in an external editor
  2. Have an agent modify a few files during a turn
  3. Press /undo to revert the agent's changes
  4. Press /redo to re-apply them
  5. Date Modified for files that underwent no changes are 'updated'. Every open file in the editor shows a "file modified" prompt — not just the files the agent touched

Expected Behavior

Only the files that were actually part of the undone turn should be touched during /redo. Files the agent never modified should be completely unaffected.

Root Cause

There are two issues, one mechanical and one architectural:

1. Mechanical: checkout-index -a -f writes all files

In snapshot/index.ts, restore() uses:

read-tree ${snapshot} && checkout-index -a -f

checkout-index -a -f writes every file from the index to the working tree, regardless of whether the content changed. This updates the modification timestamp on every file, even byte-identical ones.

2. Architectural: /redo is blanket when it should be targeted

The /undo and /redo operations have an asymmetry in scope:

  • /undo (revert()) is turn-scoped — it receives patches[] containing the specific files from that turn, and only checks out those files one-by-one
  • /redo (unrevert()restore()) is blanket — it receives a single snapshot hash (the entire working tree state) and writes every file from that snapshot to disk

This means /redo overwrites the entire project to match the snapshot, when it only needs to restore the specific files that /undo reverted. The data needed for a targeted /redo — the list of files from the turn's patches — is already computed during /undo but isn't passed through to /redo.

Proposed Solution

Make /redo targeted like /undo: only checkout the files that were part of the reverted turn.

The patch data (which files each turn modified) is collected during /undo in revert() (revert.ts line 31-55). Currently, the unique file paths from these patches are used by Snapshot.revert(patches) for the targeted /undo, but they're discarded afterward. The snapshot hash is stored in session.revert.snapshot for /redo, but the file list is not.

The fix stores the file list alongside the snapshot hash, then uses it for targeted /redo — following the same checkout <hash> -- <file> pattern that revert() already uses successfully.

Why not just swap the git command?

We evaluated replacing checkout-index -a -f with read-tree --reset -u, which preserves mtime on unchanged files. However, read-tree --reset -u also deletes files present in the working tree but absent from the snapshot. This changes the /redo behavior: if a user creates a file between /undo and /redo, that file could be deleted. Since /redo should only restore what /undo removed — not modify unrelated files — a targeted approach is more correct.

Proposed Code Changes

1. Store the affected file list during /undo (revert.ts)

In revert(), after collecting patches, extract the unique file paths and store them in session.revert:

// revert.ts, inside the `if (revert)` block (after line 60)

// Collect unique files from patches for targeted redo
const revertFiles = [...new Set(patches.flatMap((p) => p.files))]

// ... existing code: await Snapshot.revert(patches) ...

// Store files alongside snapshot for targeted restore
revert.files = revertFiles

2. Pass the file list to restore() during /redo (revert.ts)

In unrevert(), pass the stored file list:

// revert.ts, unrevert() (line 82-89)
export async function unrevert(input: { sessionID: string }) {
  log.info("unreverting", input)
  SessionPrompt.assertNotBusy(input.sessionID)
  const session = await Session.get(input.sessionID)
  if (!session.revert) return session
  if (session.revert.snapshot)
    await Snapshot.restore(session.revert.snapshot, session.revert.files)
  return Session.clearRevert(input.sessionID)
}

3. Make restore() targeted when a file list is provided (snapshot/index.ts)

Refactor restore() to accept an optional file list. When provided, use the same per-file checkout pattern that revert() already uses:

// snapshot/index.ts, restore() (line 112-129)
export async function restore(snapshot: string, files?: string[]) {
  log.info("restore", { commit: snapshot, files: files?.length })
  const git = gitdir()

  if (files?.length) {
    // Targeted restore: only checkout the files that were part of the reverted turn
    const restored = new Set<string>()
    for (const file of files) {
      if (restored.has(file)) continue
      const result =
        await $`git --git-dir ${git} --work-tree ${Instance.worktree} checkout ${snapshot} -- ${file}`
          .quiet()
          .cwd(Instance.worktree)
          .nothrow()
      if (result.exitCode !== 0) {
        // Same fallback pattern as revert(): check if the file existed in the snapshot
        const relative = path.relative(Instance.worktree, file)
        const check =
          await $`git --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${snapshot} -- ${relative}`
            .quiet()
            .cwd(Instance.worktree)
            .nothrow()
        if (check.exitCode === 0 && check.text().trim()) {
          log.info("file existed in snapshot but checkout failed, keeping", { file })
        } else {
          log.info("file did not exist in snapshot, deleting", { file })
          await fs.unlink(file).catch(() => {})
        }
      }
      restored.add(file)
    }
    return
  }

  // Fallback: blanket restore (backward compatibility for callers without a file list)
  const result =
    await $`git --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
      .quiet()
      .cwd(Instance.worktree)
      .nothrow()
  if (result.exitCode !== 0) {
    log.error("failed to restore snapshot", {
      snapshot,
      exitCode: result.exitCode,
      stderr: result.stderr.toString(),
      stdout: result.stdout.toString(),
    })
  }
}

4. Add files to the revert type on Session.Info

Add an optional files field to the revert object stored on sessions:

// Wherever Session.Info["revert"] is defined
revert?: {
  messageID: string
  partID?: string
  snapshot?: string
  diff?: string
  files?: string[]    // ← add this
}

Why This Fix Is Safe

  • Follows an existing proven pattern: The targeted restore() is identical in structure to the existing revert() function, which already handles per-file checkout with ls-tree fallback for missing files
  • Backward compatible: The files parameter is optional — callers without a file list get the existing blanket behavior
  • Minimal scope: Only touches files that were part of the original turn — no effect on unrelated project files
  • Handles all file operations: Files modified by the agent are restored from the snapshot; files created by the agent (that don't exist in the snapshot) are deleted via the ls-tree check — symmetrical with how /undo handles agent-created files

Plugins

No response

OpenCode version

No response

Steps to reproduce

No response

Screenshot and/or share link

No response

Operating System

No response

Terminal

No response

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingcoreAnything pertaining to core functionality of the application (opencode server stuff)

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions