-
Notifications
You must be signed in to change notification settings - Fork 11.6k
Description
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
- Open a project with files open in an external editor
- Have an agent modify a few files during a turn
- Press
/undoto revert the agent's changes - Press
/redoto re-apply them - 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 -fcheckout-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 receivespatches[]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 = revertFiles2. 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 existingrevert()function, which already handles per-file checkout withls-treefallback for missing files - Backward compatible: The
filesparameter 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-treecheck — symmetrical with how/undohandles 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