Skip to content

Undo and Redo

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:

  1. 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.
  2. 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 checkout on a file that didn’t exist in the previous state fails.
  3. Untracked file preservation. The user may have untracked files (build artifacts, local configs) that existed before the turn. Undo must not delete these.
  4. 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.”
  5. 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.


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.

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:

  1. check_for_dirty_commit() at line 2175 iterates files in the chat set.
  2. For each file with local modifications, it adds the path to self.need_commit_before_edits.
  3. 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.

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 logic lives in aider/commands.py:553–655:

Validation phase (lines 565–621):

  1. 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.
  2. Check for merge commits (multiple parents) — undo doesn’t handle these (lines 581–585).
  3. Get the list of files changed in the commit via last_commit.diff(prev_commit).
  4. Check if any of those files have uncommitted local modifications — if so, abort to avoid data loss (lines 591–595).
  5. 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.

  • Single undo only. The /undo command 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 /undo refuses 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 push between 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.

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.

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 -A equivalent)
  • 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.

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.

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/null additions.
  • Uses the similar crate 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.

codex-rs/core/src/tasks/undo.rs:34–125:

Snapshot discovery (lines 67–90):

  1. Clone the session history.
  2. Scan backwards through items to find the most recent ResponseItem::GhostSnapshot.
  3. 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:

  1. git restore --source <ghost_commit_id> --worktree -- <scope> — restores tracked files from the snapshot.
  2. Preserves staged state intentionally (--staged is not used).
  3. 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.

codex-rs/core/tests/suite/undo.rs (lines 145–550) provides comprehensive coverage:

TestWhat it verifies
undo_removes_new_file_created_during_turnNew files are deleted
undo_restores_tracked_file_editModified tracked files revert
undo_restores_untracked_file_editModified untracked files revert
undo_reverts_only_latest_turnOnly the most recent turn is undone
undo_does_not_touch_unrelated_filesFiles not in the turn are preserved
undo_sequential_turns_consumes_snapshotsStacked undos work
undo_restores_moves_and_renamesFile renames are reversed
undo_does_not_touch_ignored_directory_contents.gitignore respected
undo_overwrites_manual_edits_after_turnUser edits after agent turn are lost (intentional)
undo_preserves_unrelated_staged_changesStaged 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.


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).

packages/opencode/src/snapshot/index.ts:51–77:

  1. A separate Git repository is initialized in a hidden directory (GIT_DIR), with GIT_WORK_TREE pointing to the actual project directory.
  2. One-time setup: git init, plus git config core.autocrlf false to prevent line-ending interference.
  3. Before each turn:
    git add .
    const treeHash = git("write-tree")
  4. git write-tree creates 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.

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.

packages/opencode/src/session/revert.ts:24–138:

  1. 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.
  2. Pre-revert snapshot (line 59): Take a new snapshot of the current state before reverting (for potential re-redo).
  3. 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).
  1. 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.
AspectCodexOpenCode
Object typeFull Git commitGit tree object
MetadataParent, message, untracked file listsNone (just a tree hash)
Untracked filesExplicitly tracked and preservedImplicit — checkout-based heuristic
StorageIn conversation history (ResponseItem)In message parts (MessagePart)
GranularityPer-turnPer-message (finer)
Sequential undoPeel off snapshots in orderRevert 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.”


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.

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.

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.

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.

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.

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.


A Git-based snapshot system combining Codex’s robustness with OpenCode’s message-level granularity.

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.

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>;
}
  1. git read-tree <commit_id> — set index to snapshot state.
  2. git checkout-index -a -f — write all indexed files to worktree.
  3. For each file in the current worktree that is NOT in the snapshot tree and NOT in preexisting_untracked: delete it.
  4. 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.

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.

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,
}
  • 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.