feat: git tooling for undo (#3914)

## Summary
Introduces a “ghost commit” workflow that snapshots the tree without
touching refs.
1. git commit-tree writes an unreferenced commit object from the current
index, optionally pointing to the current HEAD as its parent.
2. We then stash that commit id and use git restore --source <ghost> to
roll the worktree (and index) back to the recorded snapshot later on.

## Details
- Ghost commits live only as loose objects—we never update branches or
tags—so the repo history stays untouched while still giving us a full
tree snapshot.
- Force-included paths let us stage otherwise ignored files before
capturing the tree.
- Restoration rehydrates both tracked and force-included files while
leaving untracked/ignored files alone.
This commit is contained in:
jif-oai
2025-09-23 16:59:52 +01:00
committed by GitHub
parent 76ecbb3d8e
commit e0fbc112c7
14 changed files with 979 additions and 9 deletions

View File

@@ -92,7 +92,8 @@ mod session_header;
use self::session_header::SessionHeader;
use crate::streaming::controller::AppEventHistorySink;
use crate::streaming::controller::StreamController;
//
use std::path::Path;
use codex_common::approval_presets::ApprovalPreset;
use codex_common::approval_presets::builtin_approval_presets;
use codex_common::model_presets::ModelPreset;
@@ -103,7 +104,13 @@ use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_file_search::FileMatch;
use std::path::Path;
use codex_git_tooling::CreateGhostCommitOptions;
use codex_git_tooling::GhostCommit;
use codex_git_tooling::GitToolingError;
use codex_git_tooling::create_ghost_commit;
use codex_git_tooling::restore_ghost_commit;
const MAX_TRACKED_GHOST_COMMITS: usize = 20;
// Track information about an in-flight exec command.
struct RunningCommand {
@@ -197,6 +204,9 @@ pub(crate) struct ChatWidget {
pending_notification: Option<Notification>,
// Simple review mode flag; used to adjust layout and banners.
is_review_mode: bool,
// List of ghost commits corresponding to each turn.
ghost_snapshots: Vec<GhostCommit>,
ghost_snapshots_disabled: bool,
}
struct UserMessage {
@@ -787,6 +797,8 @@ impl ChatWidget {
suppress_session_configured_redraw: false,
pending_notification: None,
is_review_mode: false,
ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: true,
}
}
@@ -846,6 +858,8 @@ impl ChatWidget {
suppress_session_configured_redraw: true,
pending_notification: None,
is_review_mode: false,
ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: true,
}
}
@@ -978,6 +992,9 @@ impl ChatWidget {
}
self.app_event_tx.send(AppEvent::ExitRequest);
}
SlashCommand::Undo => {
self.undo_last_snapshot();
}
SlashCommand::Diff => {
self.add_diff_in_progress();
let tx = self.app_event_tx.clone();
@@ -1088,6 +1105,12 @@ impl ChatWidget {
fn submit_user_message(&mut self, user_message: UserMessage) {
let UserMessage { text, image_paths } = user_message;
if text.is_empty() && image_paths.is_empty() {
return;
}
self.capture_ghost_snapshot();
let mut items: Vec<InputItem> = Vec::new();
if !text.is_empty() {
@@ -1098,10 +1121,6 @@ impl ChatWidget {
items.push(InputItem::LocalImage { path });
}
if items.is_empty() {
return;
}
self.codex_op_tx
.send(Op::UserInput { items })
.unwrap_or_else(|e| {
@@ -1123,6 +1142,57 @@ impl ChatWidget {
}
}
fn capture_ghost_snapshot(&mut self) {
if self.ghost_snapshots_disabled {
return;
}
let options = CreateGhostCommitOptions::new(&self.config.cwd);
match create_ghost_commit(&options) {
Ok(commit) => {
self.ghost_snapshots.push(commit);
if self.ghost_snapshots.len() > MAX_TRACKED_GHOST_COMMITS {
self.ghost_snapshots.remove(0);
}
}
Err(err) => {
self.ghost_snapshots_disabled = true;
let (message, hint) = match &err {
GitToolingError::NotAGitRepository { .. } => (
"Snapshots disabled: current directory is not a Git repository."
.to_string(),
None,
),
_ => (
format!("Snapshots disabled after error: {err}"),
Some(
"Restart Codex after resolving the issue to re-enable snapshots."
.to_string(),
),
),
};
self.add_info_message(message, hint);
tracing::warn!("failed to create ghost snapshot: {err}");
}
}
}
fn undo_last_snapshot(&mut self) {
let Some(commit) = self.ghost_snapshots.pop() else {
self.add_info_message("No snapshot available to undo.".to_string(), None);
return;
};
if let Err(err) = restore_ghost_commit(&self.config.cwd, &commit) {
self.add_error_message(format!("Failed to restore snapshot: {err}"));
self.ghost_snapshots.push(commit);
return;
}
let short_id: String = commit.id().chars().take(8).collect();
self.add_info_message(format!("Restored workspace to snapshot {short_id}"), None);
}
/// Replay a subset of initial events into the UI to seed the transcript when
/// resuming an existing session. This approximates the live event flow and
/// is intentionally conservative: only safe-to-replay items are rendered to

View File

@@ -335,6 +335,8 @@ fn make_chatwidget_manual() -> (
suppress_session_configured_redraw: false,
pending_notification: None,
is_review_mode: false,
ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: false,
};
(widget, rx, op_rx)
}

View File

@@ -570,7 +570,6 @@ mod tests {
// Create a unique CODEX_HOME per test to isolate auth.json writes.
let codex_home = get_next_codex_home();
std::fs::create_dir_all(&codex_home).expect("create unique CODEX_HOME");
Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),

View File

@@ -18,6 +18,7 @@ pub enum SlashCommand {
New,
Init,
Compact,
Undo,
Diff,
Mention,
Status,
@@ -35,7 +36,8 @@ impl SlashCommand {
SlashCommand::New => "start a new chat during a conversation",
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my changes and find issues",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Undo => "restore the workspace to the last Codex snapshot",
SlashCommand::Quit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
@@ -61,6 +63,7 @@ impl SlashCommand {
SlashCommand::New
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Undo
| SlashCommand::Model
| SlashCommand::Approvals
| SlashCommand::Review
@@ -79,5 +82,20 @@ impl SlashCommand {
/// Return all built-in commands in a Vec paired with their command string.
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
SlashCommand::iter().map(|c| (c.command(), c)).collect()
let show_beta_features = beta_features_enabled();
SlashCommand::iter()
.filter(|cmd| {
if *cmd == SlashCommand::Undo {
show_beta_features
} else {
true
}
})
.map(|c| (c.command(), c))
.collect()
}
fn beta_features_enabled() -> bool {
std::env::var_os("BETA_FEATURE").is_some()
}