Undo and Redo
Feature Definition
Section titled “Feature Definition”An AI coding agent modifies files on every turn — creating, editing, deleting, renaming. When the agent makes a mistake (wrong file, bad refactor, hallucinated import), the user needs to revert to the exact state before that turn. This is harder than it sounds:
- Multi-file atomicity. A single turn can touch 5–10 files. Undoing one file but not the others leaves the codebase in an inconsistent state. The undo must be all-or-nothing across every file the agent modified.
- New vs existing files. If the agent created a new file, undo must delete it. If it deleted a file, undo must restore it. Simply doing
git checkouton a file that didn’t exist in the previous state fails. - Untracked file preservation. The user may have untracked files (build artifacts, local configs) that existed before the turn. Undo must not delete these.
- Sequential rollback. Users may want to undo multiple turns, peeling back to an arbitrary point. This requires per-turn snapshots, not just “undo last change.”
- Concurrent manual edits. The user might edit a file between agent turns. The undo system must handle the case where the file’s current state doesn’t match the agent’s output.
The three reference tools take fundamentally different approaches: Aider uses Git commits as implicit snapshots, Codex creates hidden “ghost commits” with full metadata, and OpenCode uses lightweight Git tree objects.
Aider Implementation
Section titled “Aider Implementation”Pin: b9050e1d
Aider uses Git’s commit history as its undo mechanism. There are no explicit snapshots — instead, Aider auto-commits changes after each turn and uses git checkout HEAD~1 to revert.
Pre-Turn: Dirty Commit
Section titled “Pre-Turn: Dirty Commit”Before the agent edits files, Aider checks if any files in the chat have uncommitted changes (aider/coders/base_coder.py:2175–2423). If so, it creates a “dirty commit” — a commit of the user’s manual changes, separate from the agent’s upcoming edits:
check_for_dirty_commit()at line 2175 iterates files in the chat set.- For each file with local modifications, it adds the path to
self.need_commit_before_edits. dirty_commit()commits those files before agent edits; the message is generated through Aider’s normal commit-message flow (GitRepo.commit+get_commit_message), not a fixed hardcoded string.
This ensures the agent’s subsequent commit contains only the agent’s changes, making undo clean.
Post-Turn: Auto-Commit
Section titled “Post-Turn: Auto-Commit”After the agent applies edits, Aider auto-commits the result (see features/git/auto-commit.md for the full flow). The commit hash is stored in self.aider_commit_hashes (a set of short SHAs) and self.last_aider_commit_hash.
The /undo Command
Section titled “The /undo Command”The undo logic lives in aider/commands.py:553–655:
Validation phase (lines 565–621):
- Get the last commit hash and check if it’s in
self.coder.aider_commit_hashes— if not, the last commit wasn’t made by Aider, so undo is refused. - Check for merge commits (multiple parents) — undo doesn’t handle these (lines 581–585).
- Get the list of files changed in the commit via
last_commit.diff(prev_commit). - Check if any of those files have uncommitted local modifications — if so, abort to avoid data loss (lines 591–595).
- Check if the commit was already pushed to a remote — if so, abort to avoid history rewriting (lines 616–621).
Restoration phase (lines 623–651):
for fname in changed_files: git.checkout("HEAD~1", "--", fname)Then a soft reset to move HEAD back:
git.reset("--soft", "HEAD~1")The soft reset keeps the index intact (staged state from the reverted commit) while moving HEAD to the parent. This means the agent’s changes are now unstaged modifications that can be re-committed if the user changes their mind.
Limitations
Section titled “Limitations”- Single undo only. The
/undocommand reverts only the most recent Aider commit. There’s no way to undo multiple turns in sequence — after one undo, the previous commit is a dirty commit or user commit, and/undorefuses to touch it. - New files block undo. If a changed file didn’t exist in the parent commit, Aider aborts the undo as unsafe instead of partially restoring.
- Pushed commits are off-limits. If the user ran
git pushbetween turns, undo is blocked entirely. - No redo. There’s no mechanism to re-apply an undone change. The soft reset leaves changes in the working tree, but there’s no command to recommit them.
Codex Implementation
Section titled “Codex Implementation”Pin: 4ab44e2c5
Codex has the most robust undo system, using explicit “ghost snapshots” — hidden Git commits created before each turn that capture the full worktree state including untracked files.
Ghost Snapshot Creation
Section titled “Ghost Snapshot Creation”Before each agent turn, codex-rs/core/src/tasks/ghost_snapshot.rs:75–114 runs as an async task:
let options = CreateGhostCommitOptions::new(&repo_path) .ghost_snapshot(ghost_snapshot_config);let (ghost_commit, report) = create_ghost_commit_with_report(&options)?;The ghost commit captures:
- All tracked file content (via
git add -Aequivalent) - Lists of preexisting untracked files and directories
- Parent commit reference (current HEAD)
The snapshot is stored as a ResponseItem::GhostSnapshot in the conversation history (line 109), associating it with the turn that follows.
Ghost Snapshot Configuration
Section titled “Ghost Snapshot Configuration”The GhostSnapshotConfig in ghost_commits.rs:64–79:
pub struct GhostSnapshotConfig { pub ignore_large_untracked_files: Option<i64>, // Default: 10 MiB pub ignore_large_untracked_dirs: Option<i64>, // Default: 200 files pub disable_warnings: bool,}Large untracked files (>10 MiB) and large untracked directories (>200 files) are excluded from snapshots. Default ignored directory names include node_modules, .venv, venv, env, .env, dist, build — these would bloat the Git object store without providing useful undo capability.
Turn Diff Tracking
Section titled “Turn Diff Tracking”In addition to Git snapshots, Codex maintains in-memory file baselines via codex-rs/core/src/turn_diff_tracker.rs:
- When a file is first seen during a turn, its content is captured as a baseline.
- For new files, no baseline exists — diffs show as
/dev/nulladditions. - Uses the
similarcrate for in-memory diff computation. - Each external file path gets a UUID for stable rename tracking.
This allows the TUI to show “what changed this turn” diffs without touching Git.
The Undo Operation
Section titled “The Undo Operation”codex-rs/core/src/tasks/undo.rs:34–125:
Snapshot discovery (lines 67–90):
- Clone the session history.
- Scan backwards through items to find the most recent
ResponseItem::GhostSnapshot. - If none found, return error:
"No ghost snapshot available to undo.".
Restoration (lines 92–120):
restore_ghost_commit_with_options( repo_root, &ghost_commit, &config,).await?;The restoration performs:
git restore --source <ghost_commit_id> --worktree -- <scope>— restores tracked files from the snapshot.- Preserves staged state intentionally (
--stagedis not used). - Removes newly introduced untracked files while preserving preexisting untracked files recorded in snapshot metadata.
History cleanup (lines 103–104):
After restoration, the undone snapshot is removed from the history via sess.replace_history(items). This means subsequent undos will find the next ghost snapshot, enabling sequential rollback.
Test Coverage
Section titled “Test Coverage”codex-rs/core/tests/suite/undo.rs (lines 145–550) provides comprehensive coverage:
| Test | What it verifies |
|---|---|
undo_removes_new_file_created_during_turn | New files are deleted |
undo_restores_tracked_file_edit | Modified tracked files revert |
undo_restores_untracked_file_edit | Modified untracked files revert |
undo_reverts_only_latest_turn | Only the most recent turn is undone |
undo_does_not_touch_unrelated_files | Files not in the turn are preserved |
undo_sequential_turns_consumes_snapshots | Stacked undos work |
undo_restores_moves_and_renames | File renames are reversed |
undo_does_not_touch_ignored_directory_contents | .gitignore respected |
undo_overwrites_manual_edits_after_turn | User edits after agent turn are lost (intentional) |
undo_preserves_unrelated_staged_changes | Staged changes to unrelated files survive |
Key Design Decision: Overwrite Manual Edits
Section titled “Key Design Decision: Overwrite Manual Edits”The test undo_overwrites_manual_edits_after_turn reveals an intentional choice: if the user edits a file after the agent’s turn and then undoes, the user’s manual edits are lost. The snapshot restores the exact pre-turn state. This is documented behavior — the ghost commit represents a point-in-time, and restoration is absolute.
OpenCode Implementation
Section titled “OpenCode Implementation”Pin: 7ed449974
OpenCode uses a lightweight Git tree-based snapshot system. Instead of full commits, it creates Git tree objects (which have no parent or message metadata).
Snapshot Creation
Section titled “Snapshot Creation”packages/opencode/src/snapshot/index.ts:51–77:
- A separate Git repository is initialized in a hidden directory (
GIT_DIR), withGIT_WORK_TREEpointing to the actual project directory. - One-time setup:
git init, plusgit config core.autocrlf falseto prevent line-ending interference. - Before each turn:
git add .const treeHash = git("write-tree")
git write-treecreates a tree object (40-char SHA) without creating a commit. This is lighter than a full commit — no parent chain, no commit message, no author metadata.
Patch Tracking
Section titled “Patch Tracking”After a turn, patch(hash) at lines 85–110 computes which files changed:
git("diff", "--name-only", hash, "--", ".")This returns a list of file paths that differ between the snapshot tree and the current worktree. The list is stored as part of the message history for use during revert.
Revert Mechanism
Section titled “Revert Mechanism”packages/opencode/src/session/revert.ts:24–138:
- Target identification (lines 24–80): Scan message history backwards to find the target message or part to revert to. Collect all patches (file edit lists) from that message onwards.
- Pre-revert snapshot (line 59): Take a new snapshot of the current state before reverting (for potential re-redo).
- File-by-file restoration (snapshot/index.ts:131–161):
for (const item of patches) { for (const file of item.files) { try { git("checkout", treeHash, "--", file) } catch { const relativePath = path.relative(Instance.worktree, file) const existsInTree = git("ls-tree", treeHash, "--", relativePath).trim().length > 0 if (!existsInTree) { fs.unlink(file) // Delete if wasn't in snapshot } } }}For each file:
- Attempt
git checkout <tree_hash> -- <file>to restore it. - If checkout fails and the file didn’t exist in the snapshot tree: delete it (handles new files created by the agent).
- If checkout fails but the file was in the tree: keep the current state (defensive — shouldn’t happen normally).
- History cleanup (lines 91–137):
SessionRevert.cleanup()removes reverted messages from the SQLite database. If reverting a partial message (specific part), the message is kept but parts after the target are deleted.
Differences from Codex
Section titled “Differences from Codex”| Aspect | Codex | OpenCode |
|---|---|---|
| Object type | Full Git commit | Git tree object |
| Metadata | Parent, message, untracked file lists | None (just a tree hash) |
| Untracked files | Explicitly tracked and preserved | Implicit — checkout-based heuristic |
| Storage | In conversation history (ResponseItem) | In message parts (MessagePart) |
| Granularity | Per-turn | Per-message (finer) |
| Sequential undo | Peel off snapshots in order | Revert to any message |
OpenCode’s approach is lighter-weight but less robust. Without explicit untracked file tracking, there’s a risk of incorrectly deleting user files that happened to not be in the snapshot tree. The tree-based approach also can’t distinguish between “file was created by the agent this turn” and “file was created by the user before the snapshot was taken but not staged.”
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”Git as an Undo Backend
Section titled “Git as an Undo Backend”All three tools use Git for file state capture. This creates a coupling between the undo system and the repository’s Git configuration. If the user has .gitignore rules that exclude important files, those files won’t be captured in snapshots. If the repo uses Git LFS, large binary files may not be restored correctly. Submodules add another layer of complexity — none of the three tools handle submodule state in undo.
New File Deletion
Section titled “New File Deletion”The simplest bug in undo systems: the agent creates a new file, the user undoes, and the file remains. Aider avoids silent partial restore by aborting undo when restoration isn’t safe. Codex handles new-file cleanup after restoring tracked state from the ghost snapshot. OpenCode handles it via explicit fs.unlink() when checkout fails and ls-tree confirms the file wasn’t in the snapshot.
Snapshot Bloat
Section titled “Snapshot Bloat”Ghost snapshots (Codex) create real Git objects. On a large repo with many turns, this can bloat the object store. Codex mitigates this with the large-file and large-directory thresholds (10 MiB / 200 files), but a long session could still create significant overhead. Tree objects (OpenCode) are slightly lighter but still accumulate.
The “User Edited After Agent” Problem
Section titled “The “User Edited After Agent” Problem”If the user manually edits a file between agent turns, the next snapshot captures the user’s changes. Undoing the agent’s turn doesn’t restore the user’s manual edit — it restores the pre-turn state (which may be before the user’s edit). Codex explicitly tests and accepts this tradeoff (undo_overwrites_manual_edits_after_turn). There’s no good solution without tracking per-file edit provenance.
Non-File Side Effects
Section titled “Non-File Side Effects”Undo restores file state but not other side effects. If the agent ran a shell command that modified a database, installed a package, or sent an HTTP request, undo won’t reverse those. None of the three tools attempt to capture or restore non-file state.
Concurrent Agent Runs
Section titled “Concurrent Agent Runs”If multiple agent instances are running (e.g., background tasks), their snapshots can interleave. Codex’s ghost commits are per-session, so undoing in one session won’t affect another. But the underlying file state is shared — undoing one session’s changes may conflict with another session’s assumptions about file content.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Crate: openoxide-snapshot
Section titled “Crate: openoxide-snapshot”A Git-based snapshot system combining Codex’s robustness with OpenCode’s message-level granularity.
Core Design: Ghost Commits with Metadata
Section titled “Core Design: Ghost Commits with Metadata”Follow Codex’s approach — use full Git commits (not just tree objects) as snapshots. The metadata is valuable:
pub struct Snapshot { /// Git commit SHA of the ghost commit. pub commit_id: git2::Oid, /// Parent commit (HEAD at snapshot time). pub parent_id: git2::Oid, /// Files that were untracked before this turn (must be preserved on undo). pub preexisting_untracked: Vec<PathBuf>, /// Session and turn identifiers for history association. pub session_id: SessionId, pub turn_id: TurnId, /// Timestamp for ordering and expiry. pub created_at: SystemTime,}Use git2 (libgit2 bindings) rather than shelling out to the git CLI. This gives programmatic control over tree creation, object writing, and index manipulation without subprocess overhead.
Snapshot Lifecycle
Section titled “Snapshot Lifecycle”pub trait SnapshotManager: Send + Sync { /// Create a snapshot of the current worktree state. /// Called before each agent turn. async fn capture(&self, session_id: SessionId, turn_id: TurnId) -> Result<Snapshot>;
/// Restore the worktree to a specific snapshot. /// Preserves preexisting untracked files. async fn restore(&self, snapshot: &Snapshot) -> Result<RestoredFiles>;
/// List snapshots for a session, newest first. fn list(&self, session_id: SessionId) -> Vec<&Snapshot>;
/// Garbage-collect snapshots older than the given duration. async fn gc(&self, max_age: Duration) -> Result<usize>;}Restoration Algorithm
Section titled “Restoration Algorithm”git read-tree <commit_id>— set index to snapshot state.git checkout-index -a -f— write all indexed files to worktree.- For each file in the current worktree that is NOT in the snapshot tree and NOT in
preexisting_untracked: delete it. - Return the list of files that were modified, created, or deleted.
Step 3 handles the new-file problem. By explicitly tracking preexisting untracked files (like Codex), we avoid accidentally deleting user files.
Sequential Undo
Section titled “Sequential Undo”Store snapshots in the session’s turn history (similar to Codex’s ResponseItem::GhostSnapshot). Each undo consumes the most recent snapshot and removes it from history, exposing the previous snapshot for the next undo.
Configuration
Section titled “Configuration”pub struct SnapshotConfig { /// Skip untracked files larger than this. Default: 10 MiB. pub max_untracked_file_size: u64, /// Skip untracked directories with more files than this. Default: 200. pub max_untracked_dir_entries: usize, /// Directory names to always skip. Default: node_modules, .venv, dist, build, etc. pub ignored_dir_names: Vec<String>, /// Maximum snapshot age before GC. Default: 24 hours. pub gc_max_age: Duration,}Crates
Section titled “Crates”- git2 — Libgit2 bindings for all Git operations (tree creation, index manipulation, checkout).
- tokio — Async snapshot creation (I/O-bound on large repos).
- similar — For in-memory diff computation (turn diff display, separate from undo).
- uuid — Per-file tracking IDs for rename detection.