Ghost Commits & Snapshots
Feature Definition
Section titled “Feature Definition”A ghost commit is a Git commit that captures the full working tree state — including untracked and ignored files — but is not attached to any branch. It lives only in the reflog, invisible to git log and git status. The commit SHA is stored in memory (and in the session rollout log), and the agent uses it as a “restore point” for undo.
The problem ghost commits solve: standard Git operations can only snapshot files that are tracked and staged. An AI agent modifies and creates many files per turn, including files that were never committed. If you want to undo everything an agent did in one turn — including new untracked files, modified ignored files, and half-written edits — you need a mechanism that captures all of it.
Ghost commits are distinct from the undo/redo feature (see Undo/Redo), which focuses on the user-facing /undo command. This page covers the underlying snapshot mechanics: how the working tree is captured, what gets included or excluded, and how restoration is performed without corrupting the user’s staging area.
Aider uses git reset HEAD~1 for undo — which only works for committed changes and trashes the user’s staging area. OpenCode uses in-memory snapshots of file content (no git involvement). Codex’s ghost commit approach is the most complete: it handles untracked files, ignored files, new-directory creation, and large-file exclusions.
Aider Implementation
Section titled “Aider Implementation”Aider does not use ghost commits. Its undo mechanism is:
dirty_commits=True— before the agent runs, any dirty state is committed to a “dirty” commit so nothing is lost.- After the agent runs, Aider creates a real “aider” commit for each file it changed.
/undorunsgit reset HEAD~1to pop the last aider commit.
This only works for files that were already tracked by Git. Files the agent created from scratch (untracked at the time) are not captured. If the agent creates a new file and /undo is called, the new file remains on disk.
File: aider/repo.py:568 — cmd_undo() in aider/commands.py
Codex Implementation
Section titled “Codex Implementation”Codex implements ghost commits in a dedicated crate.
Crate and File
Section titled “Crate and File”File: codex-rs/utils/git/src/ghost_commits.rs (1780 lines)
Public API:
// Create a snapshotpub fn create_ghost_commit(options: &CreateGhostCommitOptions) -> Result<GhostCommit>pub fn create_ghost_commit_with_report(options: &CreateGhostCommitOptions) -> Result<(GhostCommit, GhostSnapshotReport)>
// Restore a snapshotpub fn restore_ghost_commit(repo: &Path, ghost: &GhostCommit) -> Result<()>pub fn restore_ghost_commit_with_options( options: &RestoreGhostCommitOptions, ghost: &GhostCommit,) -> Result<()>GhostCommit Data Structure
Section titled “GhostCommit Data Structure”pub struct GhostCommit { /// SHA of the detached commit id: String, /// SHA of the parent HEAD at snapshot time (None for empty repos) parent: Option<String>, /// Untracked files that existed before the snapshot /// These are preserved during restoration preexisting_untracked_files: Vec<PathBuf>, /// Untracked directories that existed before the snapshot preexisting_untracked_dirs: Vec<PathBuf>,}CreateGhostCommitOptions
Section titled “CreateGhostCommitOptions”pub struct CreateGhostCommitOptions { pub repo: PathBuf, // working directory (can be subdirectory) pub message: Option<String>, // custom commit message pub ghost_snapshot: Option<GhostSnapshotConfig>, pub force_include: Vec<PathBuf>, // always capture these paths}
pub struct GhostSnapshotConfig { pub ignore_large_untracked_files: Option<u64>, // byte threshold (default 10 MiB) pub ignore_large_untracked_dirs: Option<i64>, // file-count threshold (default 200) pub disable_warnings: bool,}Creation Flow
Section titled “Creation Flow”Step 1 — Status capture (lines 514–669)
Codex uses git status --porcelain=2 -z with zero-delimited output. This is more parseable than regular porcelain v1 because it separates fields cleanly and avoids quoting edge cases.
let output = run_git_for_stdout_all( repo_root, &["status", "--porcelain=2", "-z", "--untracked-files=all"], None,)?;Porcelain v2 record types:
1— ordinary tracked file change (8 fields before path)2— rename or copy (9 fields before path)u— unmerged (10 fields before path)?— untracked file!— ignored file
The captured status snapshot (UntrackedSnapshot) stores:
untracked_files: Vec<PathBuf>— untracked files to be included in the commituntracked_dirs: Vec<PathBuf>— untracked directories to be includedignored_untracked_files: Vec<PathBuf>— files excluded due to sizeignored_untracked_dirs: Vec<PathBuf>— dirs excluded due to file count
Step 2 — Large file/dir exclusion (lines 575–622)
Before recording an untracked file, its size is checked:
if let Some(threshold) = ignore_large_untracked_files && !is_force_included(&normalized, force_include) && let Ok(Some(byte_size)) = untracked_file_size(&absolute) && byte_size > threshold{ snapshot.untracked.ignored_untracked_files.push(normalized.clone()); continue; // skip: don't add to snapshot}For directories, the file count is checked before recursing:
if dir_file_count > threshold { snapshot.untracked.ignored_untracked_dirs.push(...) continue; // skip whole directory}Default ignored directory names (always skipped regardless of size):
node_modules, .venv, venv, .git, __pycache__, target, dist, build, .cache
This is critical: capturing node_modules into a ghost commit would make the snapshot gigabytes large and poison the repo’s object store.
Step 3 — Temporary index (lines 337–347)
To avoid disturbing the user’s staging area, Codex uses a temporary Git index:
// Set GIT_INDEX_FILE to a temp pathlet temp_index = tempdir()?.path().join("temp_index");let env = [("GIT_INDEX_FILE", temp_index.to_str().unwrap())];
// Read HEAD into the temp index (preserves user's staging area)run_git_with_env(repo_root, &["read-tree", "HEAD"], env)?;All subsequent git add calls use this temp index, not the user’s real .git/index.
Step 4 — Stage everything (lines 362–381)
// Stage all tracked modificationsrun_git_with_env(repo_root, &["add", "--all", "--"], env)?;
// Stage untracked filesfor file in &snapshot.untracked.untracked_files { run_git_with_env(repo_root, &["add", "--force", "--", file], env)?;}
// Stage force-included files (e.g., ignored files the caller needs)for path in &options.force_include { run_git_with_env(repo_root, &["add", "--force", "--", path], env)?;}Step 5 — Create the detached commit (lines 383–402)
// Write the tree from temp indexlet tree_sha = run_git_stdout_with_env(repo_root, &["write-tree"], env)?;
// Create commit with ghost identitylet parent_args = match &parent_sha { Some(sha) => vec!["-p", sha], None => vec![], // initial commit case};
let commit_sha = run_git_stdout_with_env_identity( repo_root, &["commit-tree", &tree_sha, "-m", message, ..parent_args], env, "Codex Snapshot", "snapshot@codex.local",)?;The commit SHA is returned in GhostCommit.id. No branch or ref points to it — it exists only in the object store and the caller’s memory.
Restoration Flow
Section titled “Restoration Flow”File: codex-rs/utils/git/src/ghost_commits.rs:427
pub fn restore_ghost_commit_with_options( options: &RestoreGhostCommitOptions, ghost: &GhostCommit,) -> Result<()>Step 1 — Restore tracked files:
run_git( repo_root, &["restore", "--source", ghost.id(), "--worktree", "--", prefix],)?;git restore --source <sha> --worktree checks out files from the ghost commit’s tree to the working directory without touching the index. The user’s staged changes are preserved.
Step 2 — Remove files created after the snapshot:
fn remove_new_untracked( repo_root: &Path, ghost: &GhostCommit, options: &RestoreGhostCommitOptions,) -> Result<()>This is the hard part. The ghost commit captures what files existed before the agent ran. During restoration, any file not in ghost.preexisting_untracked_files and not tracked in the ghost commit tree must be deleted (it was created by the agent during its turn).
The algorithm:
- Walk the current untracked files via
git status --porcelain=2 -z - For each untracked file, check if it was in
ghost.preexisting_untracked_files - If yes: preserve it (it existed before the agent ran)
- If no: delete it (the agent created it)
Step 3 — Preserve ignored directories:
Files in DEFAULT_IGNORED_DIR_NAMES directories (node_modules, .venv, etc.) are always preserved during cleanup, even if they were created after the snapshot. The reasoning: these are dependency directories, and deleting them would break the project until the user runs npm install again.
This is a deliberate design tradeoff: ghost commits can’t undo changes inside dependency directories, but they also won’t destroy them.
Subdirectory-Scoped Snapshots
Section titled “Subdirectory-Scoped Snapshots”Ghost commits can be scoped to a subdirectory. When Codex is operating inside ./packages/auth/, the snapshot only captures the state of that subdirectory, not the entire repo:
CreateGhostCommitOptions::new(&workspace) // workspace = ./packages/auth/Test (ghost_commits.rs:1494):
fn restore_from_subdirectory_restores_files_relatively() { // Snapshot: workspace/nested.txt = "nested modified" // After snapshot: workspace/nested.txt = "nested after", root.txt = "root after"
restore_ghost_commit(&workspace, &ghost)?;
let root_after = read("root.txt"); assert_eq!(root_after, "root after"); // root unchanged! out of scope
let nested_after = read("workspace/nested.txt"); assert_eq!(nested_after, "nested modified"); // restored to snapshot state}The Report
Section titled “The Report”create_ghost_commit_with_report() returns a GhostSnapshotReport alongside the commit:
pub struct GhostSnapshotReport { pub large_untracked_dirs: Vec<LargeUntrackedDir>,}
pub struct LargeUntrackedDir { pub path: PathBuf, pub file_count: usize,}This report is shown to the user: “Warning: snapshot excluded node_modules/ (52 files)”. It lets the user know what the snapshot doesn’t cover.
DEFAULT_COMMIT_MESSAGE
Section titled “DEFAULT_COMMIT_MESSAGE”pub const DEFAULT_COMMIT_MESSAGE: &str = "codex-snapshot";The ghost commit’s message is always this string. Codex identifies ghost commits in the reflog by searching for this message.
OpenCode Implementation
Section titled “OpenCode Implementation”OpenCode does not use git for snapshots. Its snapshot system is in-memory:
File: opencode/packages/opencode/src/session/index.ts
When the agent starts a turn, OpenCode reads the current content of any file it intends to modify and stores it in a Snapshot map (path → content). On undo, it writes those contents back.
This is simpler than ghost commits but has significant limitations:
- No untracked file tracking — if the agent creates a new file, it’s not in the snapshot, and undo cannot delete it.
- No ignored file tracking — changes to
.env, build artifacts, etc. are not captured. - Memory-bounded — for agents editing large files (10MB+ binaries), storing the full previous content in memory is expensive.
- No cross-session persistence — if the process crashes, the snapshot is lost.
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”Ghost Commits Pollute the Reflog
Section titled “Ghost Commits Pollute the Reflog”Every turn creates one ghost commit. In a long session with 50 turns, there are 50 ghost commits in the reflog. git reflog shows them all under HEAD@{n}. For users who rely on the reflog for their own recovery, this is noisy.
Codex’s approach: the session rollout log records the ghost commit SHA for each turn. On session cleanup, it could (but doesn’t yet) call git gc or prune specific SHAs. In practice, reflogs expire after 90 days by default.
OpenOxide mitigation: Tag ghost commits with a special prefix in the commit message (openoxide-snapshot) so users can filter them in git reflog | grep -v openoxide-snapshot.
The Initial Commit Edge Case
Section titled “The Initial Commit Edge Case”If the repository has no commits yet (HEAD is unborn), git read-tree HEAD fails. Codex handles this by setting parent = None and skipping the -p flag in commit-tree.
Test (ghost_commits.rs:1417):
fn create_snapshot_without_existing_head() { // No commits in repo let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; assert!(ghost.parent().is_none());}File Restoration vs. Index
Section titled “File Restoration vs. Index”git restore --source <sha> --worktree does NOT update the index. If the user had staged changes before the agent ran, those staged changes remain staged after restoration. This is intentional: Codex preserves the user’s staged state. But it means git status after undo shows staged changes that reference the restored working tree state, which can be confusing.
If the agent staged files during its turn (via auto-commit), those staged files are still in the index after restore --worktree. The user must git reset HEAD or git restore --staged to unstage them.
Nested Large Untracked Dirs
Section titled “Nested Large Untracked Dirs”A large untracked directory nested under a tracked parent directory is correctly excluded. The walker descends into tracked parent directories and measures child directories independently.
Test (ghost_commits.rs:1356):
fn create_snapshot_reports_nested_large_untracked_dirs_under_tracked_parent() { // src/ is tracked // src/generated/cache/ is untracked with 201 files
let (ghost, report) = create_ghost_commit_with_report(...)?; assert_eq!(report.large_untracked_dirs.len(), 1); assert!(report.large_untracked_dirs[0].path.starts_with("src/generated")); // But src/generated/cache/file-0.bin is NOT in the commit}Force-Include Security
Section titled “Force-Include Security”The force_include option allows the caller to capture files that would otherwise be excluded (e.g., an .env file). But Codex validates that force-included paths don’t escape the repository root:
Test (ghost_commits.rs:1475):
fn create_ghost_commit_rejects_force_include_parent_path() { let options = CreateGhostCommitOptions::new(repo) .force_include(vec![PathBuf::from("../outside.txt")]); let err = create_ghost_commit(&options).unwrap_err(); assert_matches!(err, GitToolingError::PathEscapesRepository { .. });}OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Ghost Commit via git2
Section titled “Ghost Commit via git2”OpenOxide should use the git2 crate rather than spawning git subprocesses. This is faster (no process fork), more portable, and avoids the environment variable tricks needed for the temporary index.
use git2::{Repository, Index, Signature, Oid};
pub struct GhostCommitCreator<'repo> { repo: &'repo Repository, options: CreateGhostCommitOptions,}
impl<'repo> GhostCommitCreator<'repo> { pub fn create(&self) -> Result<GhostCommit> { // 1. Open a fresh in-memory index (no file I/O needed) let mut temp_index = Index::new()?;
// 2. Read HEAD tree into the in-memory index let head_tree = self.repo.head()?.peel_to_tree()?; temp_index.read_tree(&head_tree)?;
// 3. Add all tracked modifications temp_index.update_all(["*"].iter(), None)?;
// 4. Add untracked files (after size/name filtering) let untracked = self.capture_untracked()?; for path in &untracked.include { temp_index.add_path(path)?; }
// 5. Write tree let tree_oid = temp_index.write_tree_to(self.repo)?; let tree = self.repo.find_tree(tree_oid)?;
// 6. Create detached commit let sig = Signature::now("OpenOxide Snapshot", "snapshot@openoxide.local")?; let parent_commit = self.repo.head()?.peel_to_commit().ok(); let parents = parent_commit.as_ref().map(|c| vec![c]).unwrap_or_default();
let commit_oid = self.repo.commit( None, // no ref — detached &sig, &sig, "openoxide-snapshot", &tree, &parents.iter().collect::<Vec<_>>(), )?;
Ok(GhostCommit { id: commit_oid.to_string(), parent: parent_commit.map(|c| c.id().to_string()), preexisting_untracked_files: untracked.preexisting, preexisting_untracked_dirs: untracked.preexisting_dirs, }) }}Status Capture via git2
Section titled “Status Capture via git2”Instead of parsing git status --porcelain=2, use git2::Repository::statuses():
pub fn capture_working_tree_status(repo: &Repository) -> Result<WorkingTreeStatus> { let mut opts = StatusOptions::new(); opts.include_untracked(true) .include_ignored(false) // skip ignored unless force-included .recurse_untracked_dirs(true);
let statuses = repo.statuses(Some(&mut opts))?;
let mut untracked = vec![]; let mut modified = vec![];
for entry in statuses.iter() { let path = PathBuf::from(entry.path().unwrap_or("")); match entry.status() { s if s.contains(Status::WT_NEW) => untracked.push(path), s if s.contains(Status::WT_MODIFIED) => modified.push(path), _ => {} } }
Ok(WorkingTreeStatus { untracked, modified })}Restoration via git2
Section titled “Restoration via git2”pub fn restore_ghost_commit(repo: &Repository, ghost: &GhostCommit) -> Result<()> { let ghost_commit = repo.find_commit(Oid::from_str(&ghost.id)?)?; let ghost_tree = ghost_commit.tree()?;
// checkout the ghost tree to working directory (index unchanged) let mut checkout_opts = CheckoutBuilder::new(); checkout_opts .force() .update_index(false) // don't touch staging area .remove_untracked(false); // we handle untracked cleanup ourselves
repo.checkout_tree(ghost_tree.as_object(), Some(&mut checkout_opts))?;
// Remove files the agent created that weren't in the snapshot cleanup_new_untracked(repo, ghost)?;
Ok(())}Crates
Section titled “Crates”git2— all git operations (no subprocess spawn)walkdir— directory traversal for untracked file scanningserde+serde_json— serializingGhostCommitto session rollout file
Key Design Decisions
Section titled “Key Design Decisions”- In-memory index via git2 — avoid spawning
gitsubprocesses;Index::new()+write_tree_to()is the pure-Rust equivalent of the temp index trick. - Always create snapshots at turn start — create the ghost commit before any tool calls in a turn. This guarantees undo is available even if the agent crashes mid-turn.
- Report excluded dirs to the user — show
GhostSnapshotReport.large_untracked_dirsas a warning: “Snapshot excluded target/ (4,312 files)”. - Reflog cleanup at session end — when a session is archived, walk its rollout log, collect ghost commit SHAs, and call
git gc --prune=<date>only if no other session references those SHAs. Don’t silently delete ghost commits mid-session. DEFAULT_IGNORED_DIR_NAMESis a compile-time constant — hardcode the common dependency directories. Users should not configure this; the point is to protect the user from accidentally bloating their object store.