From e0fbc112c772f87c9386cb2de992fbf8cdcb07d7 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 23 Sep 2025 16:59:52 +0100 Subject: [PATCH] feat: git tooling for undo (#3914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 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. --- codex-rs/Cargo.lock | 11 + codex-rs/Cargo.toml | 2 + codex-rs/git-tooling/Cargo.toml | 20 + codex-rs/git-tooling/README.md | 20 + codex-rs/git-tooling/src/errors.rs | 35 ++ codex-rs/git-tooling/src/ghost_commits.rs | 494 ++++++++++++++++++++++ codex-rs/git-tooling/src/lib.rs | 43 ++ codex-rs/git-tooling/src/operations.rs | 218 ++++++++++ codex-rs/git-tooling/src/platform.rs | 37 ++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/chatwidget.rs | 82 +++- codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/lib.rs | 1 - codex-rs/tui/src/slash_command.rs | 22 +- 14 files changed, 979 insertions(+), 9 deletions(-) create mode 100644 codex-rs/git-tooling/Cargo.toml create mode 100644 codex-rs/git-tooling/README.md create mode 100644 codex-rs/git-tooling/src/errors.rs create mode 100644 codex-rs/git-tooling/src/ghost_commits.rs create mode 100644 codex-rs/git-tooling/src/lib.rs create mode 100644 codex-rs/git-tooling/src/operations.rs create mode 100644 codex-rs/git-tooling/src/platform.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 768a7cc3..8fe05ac7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -778,6 +778,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-git-tooling" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "tempfile", + "thiserror 2.0.16", + "walkdir", +] + [[package]] name = "codex-linux-sandbox" version = "0.0.0" @@ -919,6 +929,7 @@ dependencies = [ "codex-common", "codex-core", "codex-file-search", + "codex-git-tooling", "codex-login", "codex-ollama", "codex-protocol", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 7e0d5620..de1c9e49 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -9,6 +9,7 @@ members = [ "exec", "execpolicy", "file-search", + "git-tooling", "linux-sandbox", "login", "mcp-client", @@ -39,6 +40,7 @@ codex-common = { path = "common" } codex-core = { path = "core" } codex-exec = { path = "exec" } codex-file-search = { path = "file-search" } +codex-git-tooling = { path = "git-tooling" } codex-linux-sandbox = { path = "linux-sandbox" } codex-login = { path = "login" } codex-mcp-client = { path = "mcp-client" } diff --git a/codex-rs/git-tooling/Cargo.toml b/codex-rs/git-tooling/Cargo.toml new file mode 100644 index 00000000..674a5e52 --- /dev/null +++ b/codex-rs/git-tooling/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "codex-git-tooling" +version.workspace = true +edition.workspace = true +readme = "README.md" + +[lib] +name = "codex_git_tooling" +path = "src/lib.rs" + +[dependencies] +tempfile = "3" +thiserror = "2" +walkdir = "2" + +[lints] +workspace = true + +[dev-dependencies] +pretty_assertions = "1.4.1" diff --git a/codex-rs/git-tooling/README.md b/codex-rs/git-tooling/README.md new file mode 100644 index 00000000..8833fc58 --- /dev/null +++ b/codex-rs/git-tooling/README.md @@ -0,0 +1,20 @@ +# codex-git-tooling + +Helpers for interacting with git. + +```rust,no_run +use std::path::Path; + +use codex_git_tooling::{create_ghost_commit, restore_ghost_commit, CreateGhostCommitOptions}; + +let repo = Path::new("/path/to/repo"); + +// Capture the current working tree as an unreferenced commit. +let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; + +// Later, undo back to that state. +restore_ghost_commit(repo, &ghost)?; +``` + +Pass a custom message with `.message("…")` or force-include ignored files with +`.force_include(["ignored.log".into()])`. diff --git a/codex-rs/git-tooling/src/errors.rs b/codex-rs/git-tooling/src/errors.rs new file mode 100644 index 00000000..41f7b6df --- /dev/null +++ b/codex-rs/git-tooling/src/errors.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; +use std::process::ExitStatus; +use std::string::FromUtf8Error; + +use thiserror::Error; +use walkdir::Error as WalkdirError; + +/// Errors returned while managing git worktree snapshots. +#[derive(Debug, Error)] +pub enum GitToolingError { + #[error("git command `{command}` failed with status {status}: {stderr}")] + GitCommand { + command: String, + status: ExitStatus, + stderr: String, + }, + #[error("git command `{command}` produced non-UTF-8 output")] + GitOutputUtf8 { + command: String, + #[source] + source: FromUtf8Error, + }, + #[error("{path:?} is not a git repository")] + NotAGitRepository { path: PathBuf }, + #[error("path {path:?} must be relative to the repository root")] + NonRelativePath { path: PathBuf }, + #[error("path {path:?} escapes the repository root")] + PathEscapesRepository { path: PathBuf }, + #[error("failed to process path inside worktree")] + PathPrefix(#[from] std::path::StripPrefixError), + #[error(transparent)] + Walkdir(#[from] WalkdirError), + #[error(transparent)] + Io(#[from] std::io::Error), +} diff --git a/codex-rs/git-tooling/src/ghost_commits.rs b/codex-rs/git-tooling/src/ghost_commits.rs new file mode 100644 index 00000000..06b43211 --- /dev/null +++ b/codex-rs/git-tooling/src/ghost_commits.rs @@ -0,0 +1,494 @@ +use std::ffi::OsString; +use std::path::Path; +use std::path::PathBuf; + +use tempfile::Builder; + +use crate::GhostCommit; +use crate::GitToolingError; +use crate::operations::apply_repo_prefix_to_force_include; +use crate::operations::ensure_git_repository; +use crate::operations::normalize_relative_path; +use crate::operations::repo_subdir; +use crate::operations::resolve_head; +use crate::operations::resolve_repository_root; +use crate::operations::run_git_for_status; +use crate::operations::run_git_for_stdout; + +/// Default commit message used for ghost commits when none is provided. +const DEFAULT_COMMIT_MESSAGE: &str = "codex snapshot"; + +/// Options to control ghost commit creation. +pub struct CreateGhostCommitOptions<'a> { + pub repo_path: &'a Path, + pub message: Option<&'a str>, + pub force_include: Vec, +} + +impl<'a> CreateGhostCommitOptions<'a> { + /// Creates options scoped to the provided repository path. + pub fn new(repo_path: &'a Path) -> Self { + Self { + repo_path, + message: None, + force_include: Vec::new(), + } + } + + /// Sets a custom commit message for the ghost commit. + pub fn message(mut self, message: &'a str) -> Self { + self.message = Some(message); + self + } + + /// Supplies the entire force-include path list at once. + pub fn force_include(mut self, paths: I) -> Self + where + I: IntoIterator, + { + self.force_include = paths.into_iter().collect(); + self + } + + /// Adds a single path to the force-include list. + pub fn push_force_include

(mut self, path: P) -> Self + where + P: Into, + { + self.force_include.push(path.into()); + self + } +} + +/// Create a ghost commit capturing the current state of the repository's working tree. +pub fn create_ghost_commit( + options: &CreateGhostCommitOptions<'_>, +) -> Result { + ensure_git_repository(options.repo_path)?; + + let repo_root = resolve_repository_root(options.repo_path)?; + let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path); + let parent = resolve_head(repo_root.as_path())?; + + let normalized_force = options + .force_include + .iter() + .map(|path| normalize_relative_path(path)) + .collect::, _>>()?; + let force_include = + apply_repo_prefix_to_force_include(repo_prefix.as_deref(), &normalized_force); + let index_tempdir = Builder::new().prefix("codex-git-index-").tempdir()?; + let index_path = index_tempdir.path().join("index"); + let base_env = vec![( + OsString::from("GIT_INDEX_FILE"), + OsString::from(index_path.as_os_str()), + )]; + + let mut add_args = vec![OsString::from("add"), OsString::from("--all")]; + if let Some(prefix) = repo_prefix.as_deref() { + add_args.extend([OsString::from("--"), prefix.as_os_str().to_os_string()]); + } + + run_git_for_status(repo_root.as_path(), add_args, Some(base_env.as_slice()))?; + if !force_include.is_empty() { + let mut args = Vec::with_capacity(force_include.len() + 2); + args.push(OsString::from("add")); + args.push(OsString::from("--force")); + args.extend( + force_include + .iter() + .map(|path| OsString::from(path.as_os_str())), + ); + run_git_for_status(repo_root.as_path(), args, Some(base_env.as_slice()))?; + } + + let tree_id = run_git_for_stdout( + repo_root.as_path(), + vec![OsString::from("write-tree")], + Some(base_env.as_slice()), + )?; + + let mut commit_env = base_env; + commit_env.extend(default_commit_identity()); + let message = options.message.unwrap_or(DEFAULT_COMMIT_MESSAGE); + let commit_args = { + let mut result = vec![OsString::from("commit-tree"), OsString::from(&tree_id)]; + if let Some(parent) = parent.as_deref() { + result.extend([OsString::from("-p"), OsString::from(parent)]); + } + result.extend([OsString::from("-m"), OsString::from(message)]); + result + }; + + // Retrieve commit ID. + let commit_id = run_git_for_stdout( + repo_root.as_path(), + commit_args, + Some(commit_env.as_slice()), + )?; + + Ok(GhostCommit::new(commit_id, parent)) +} + +/// Restore the working tree to match the provided ghost commit. +pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> { + restore_to_commit(repo_path, commit.id()) +} + +/// Restore the working tree to match the given commit ID. +pub fn restore_to_commit(repo_path: &Path, commit_id: &str) -> Result<(), GitToolingError> { + ensure_git_repository(repo_path)?; + + let repo_root = resolve_repository_root(repo_path)?; + let repo_prefix = repo_subdir(repo_root.as_path(), repo_path); + + let mut restore_args = vec![ + OsString::from("restore"), + OsString::from("--source"), + OsString::from(commit_id), + OsString::from("--worktree"), + OsString::from("--staged"), + OsString::from("--"), + ]; + if let Some(prefix) = repo_prefix.as_deref() { + restore_args.push(prefix.as_os_str().to_os_string()); + } else { + restore_args.push(OsString::from(".")); + } + + run_git_for_status(repo_root.as_path(), restore_args, None)?; + Ok(()) +} + +/// Returns the default author and committer identity for ghost commits. +fn default_commit_identity() -> Vec<(OsString, OsString)> { + vec![ + ( + OsString::from("GIT_AUTHOR_NAME"), + OsString::from("Codex Snapshot"), + ), + ( + OsString::from("GIT_AUTHOR_EMAIL"), + OsString::from("snapshot@codex.local"), + ), + ( + OsString::from("GIT_COMMITTER_NAME"), + OsString::from("Codex Snapshot"), + ), + ( + OsString::from("GIT_COMMITTER_EMAIL"), + OsString::from("snapshot@codex.local"), + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::operations::run_git_for_stdout; + use pretty_assertions::assert_eq; + use std::process::Command; + + /// Runs a git command in the test repository and asserts success. + fn run_git_in(repo_path: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(repo_path) + .args(args) + .status() + .expect("git command"); + assert!(status.success(), "git command failed: {args:?}"); + } + + /// Runs a git command and returns its trimmed stdout output. + fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(repo_path) + .args(args) + .output() + .expect("git command"); + assert!(output.status.success(), "git command failed: {args:?}"); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + /// Initializes a repository with consistent settings for cross-platform tests. + fn init_test_repo(repo: &Path) { + run_git_in(repo, &["init", "--initial-branch=main"]); + run_git_in(repo, &["config", "core.autocrlf", "false"]); + } + + #[test] + /// Verifies a ghost commit can be created and restored end to end. + fn create_and_restore_roundtrip() -> Result<(), GitToolingError> { + let temp = tempfile::tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + std::fs::write(repo.join("tracked.txt"), "initial\n")?; + std::fs::write(repo.join("delete-me.txt"), "to be removed\n")?; + run_git_in(repo, &["add", "tracked.txt", "delete-me.txt"]); + run_git_in( + repo, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "init", + ], + ); + + let tracked_contents = "modified contents\n"; + std::fs::write(repo.join("tracked.txt"), tracked_contents)?; + std::fs::remove_file(repo.join("delete-me.txt"))?; + let new_file_contents = "hello ghost\n"; + std::fs::write(repo.join("new-file.txt"), new_file_contents)?; + std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?; + let ignored_contents = "ignored but captured\n"; + std::fs::write(repo.join("ignored.txt"), ignored_contents)?; + + let options = + CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]); + let ghost = create_ghost_commit(&options)?; + + assert!(ghost.parent().is_some()); + let cat = run_git_for_stdout( + repo, + vec![ + OsString::from("show"), + OsString::from(format!("{}:ignored.txt", ghost.id())), + ], + None, + )?; + assert_eq!(cat, ignored_contents.trim()); + + std::fs::write(repo.join("tracked.txt"), "other state\n")?; + std::fs::write(repo.join("ignored.txt"), "changed\n")?; + std::fs::remove_file(repo.join("new-file.txt"))?; + std::fs::write(repo.join("ephemeral.txt"), "temp data\n")?; + + restore_ghost_commit(repo, &ghost)?; + + let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?; + assert_eq!(tracked_after, tracked_contents); + let ignored_after = std::fs::read_to_string(repo.join("ignored.txt"))?; + assert_eq!(ignored_after, ignored_contents); + let new_file_after = std::fs::read_to_string(repo.join("new-file.txt"))?; + assert_eq!(new_file_after, new_file_contents); + assert_eq!(repo.join("delete-me.txt").exists(), false); + assert!(repo.join("ephemeral.txt").exists()); + + Ok(()) + } + + #[test] + /// Ensures ghost commits succeed in repositories without an existing HEAD. + fn create_snapshot_without_existing_head() -> Result<(), GitToolingError> { + let temp = tempfile::tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + let tracked_contents = "first contents\n"; + std::fs::write(repo.join("tracked.txt"), tracked_contents)?; + let ignored_contents = "ignored but captured\n"; + std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?; + std::fs::write(repo.join("ignored.txt"), ignored_contents)?; + + let options = + CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]); + let ghost = create_ghost_commit(&options)?; + + assert!(ghost.parent().is_none()); + + let message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]); + assert_eq!(message, DEFAULT_COMMIT_MESSAGE); + + let ignored = run_git_stdout(repo, &["show", &format!("{}:ignored.txt", ghost.id())]); + assert_eq!(ignored, ignored_contents.trim()); + + Ok(()) + } + + #[test] + /// Confirms custom messages are used when creating ghost commits. + fn create_ghost_commit_uses_custom_message() -> Result<(), GitToolingError> { + let temp = tempfile::tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + std::fs::write(repo.join("tracked.txt"), "contents\n")?; + run_git_in(repo, &["add", "tracked.txt"]); + run_git_in( + repo, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "initial", + ], + ); + + let message = "custom message"; + let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo).message(message))?; + let commit_message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]); + assert_eq!(commit_message, message); + + Ok(()) + } + + #[test] + /// Rejects force-included paths that escape the repository. + fn create_ghost_commit_rejects_force_include_parent_path() { + let temp = tempfile::tempdir().expect("tempdir"); + let repo = temp.path(); + init_test_repo(repo); + 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 { .. })); + } + + #[test] + /// Restoring a ghost commit from a non-git directory fails. + fn restore_requires_git_repository() { + let temp = tempfile::tempdir().expect("tempdir"); + let err = restore_to_commit(temp.path(), "deadbeef").unwrap_err(); + assert!(matches!(err, GitToolingError::NotAGitRepository { .. })); + } + + #[test] + /// Restoring from a subdirectory affects only that subdirectory. + fn restore_from_subdirectory_restores_files_relatively() -> Result<(), GitToolingError> { + let temp = tempfile::tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + std::fs::create_dir_all(repo.join("workspace"))?; + let workspace = repo.join("workspace"); + std::fs::write(repo.join("root.txt"), "root contents\n")?; + std::fs::write(workspace.join("nested.txt"), "nested contents\n")?; + run_git_in(repo, &["add", "."]); + run_git_in( + repo, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "initial", + ], + ); + + std::fs::write(repo.join("root.txt"), "root modified\n")?; + std::fs::write(workspace.join("nested.txt"), "nested modified\n")?; + + let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?; + + std::fs::write(repo.join("root.txt"), "root after\n")?; + std::fs::write(workspace.join("nested.txt"), "nested after\n")?; + + restore_ghost_commit(&workspace, &ghost)?; + + let root_after = std::fs::read_to_string(repo.join("root.txt"))?; + assert_eq!(root_after, "root after\n"); + let nested_after = std::fs::read_to_string(workspace.join("nested.txt"))?; + assert_eq!(nested_after, "nested modified\n"); + assert!(!workspace.join("codex-rs").exists()); + + Ok(()) + } + + #[test] + /// Restoring from a subdirectory preserves ignored files in parent folders. + fn restore_from_subdirectory_preserves_parent_vscode() -> Result<(), GitToolingError> { + let temp = tempfile::tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + let workspace = repo.join("codex-rs"); + std::fs::create_dir_all(&workspace)?; + std::fs::write(repo.join(".gitignore"), ".vscode/\n")?; + std::fs::write(workspace.join("tracked.txt"), "snapshot version\n")?; + run_git_in(repo, &["add", "."]); + run_git_in( + repo, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "initial", + ], + ); + + std::fs::write(workspace.join("tracked.txt"), "snapshot delta\n")?; + let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?; + + std::fs::write(workspace.join("tracked.txt"), "post-snapshot\n")?; + let vscode = repo.join(".vscode"); + std::fs::create_dir_all(&vscode)?; + std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?; + + restore_ghost_commit(&workspace, &ghost)?; + + let tracked_after = std::fs::read_to_string(workspace.join("tracked.txt"))?; + assert_eq!(tracked_after, "snapshot delta\n"); + assert!(vscode.join("settings.json").exists()); + let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?; + assert_eq!(settings_after, "{\n \"after\": true\n}\n"); + + Ok(()) + } + + #[test] + /// Restoring from the repository root keeps ignored files intact. + fn restore_preserves_ignored_files() -> Result<(), GitToolingError> { + let temp = tempfile::tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + std::fs::write(repo.join(".gitignore"), ".vscode/\n")?; + std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?; + let vscode = repo.join(".vscode"); + std::fs::create_dir_all(&vscode)?; + std::fs::write(vscode.join("settings.json"), "{\n \"before\": true\n}\n")?; + run_git_in(repo, &["add", ".gitignore", "tracked.txt"]); + run_git_in( + repo, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "initial", + ], + ); + + std::fs::write(repo.join("tracked.txt"), "snapshot delta\n")?; + let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; + + std::fs::write(repo.join("tracked.txt"), "post-snapshot\n")?; + std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?; + std::fs::write(repo.join("temp.txt"), "new file\n")?; + + restore_ghost_commit(repo, &ghost)?; + + let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?; + assert_eq!(tracked_after, "snapshot delta\n"); + assert!(vscode.join("settings.json").exists()); + let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?; + assert_eq!(settings_after, "{\n \"after\": true\n}\n"); + assert!(repo.join("temp.txt").exists()); + + Ok(()) + } +} diff --git a/codex-rs/git-tooling/src/lib.rs b/codex-rs/git-tooling/src/lib.rs new file mode 100644 index 00000000..f41d1043 --- /dev/null +++ b/codex-rs/git-tooling/src/lib.rs @@ -0,0 +1,43 @@ +use std::fmt; + +mod errors; +mod ghost_commits; +mod operations; +mod platform; + +pub use errors::GitToolingError; +pub use ghost_commits::CreateGhostCommitOptions; +pub use ghost_commits::create_ghost_commit; +pub use ghost_commits::restore_ghost_commit; +pub use ghost_commits::restore_to_commit; +pub use platform::create_symlink; + +/// Details of a ghost commit created from a repository state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GhostCommit { + id: String, + parent: Option, +} + +impl GhostCommit { + /// Create a new ghost commit wrapper from a raw commit ID and optional parent. + pub fn new(id: String, parent: Option) -> Self { + Self { id, parent } + } + + /// Commit ID for the snapshot. + pub fn id(&self) -> &str { + &self.id + } + + /// Parent commit ID, if the repository had a `HEAD` at creation time. + pub fn parent(&self) -> Option<&str> { + self.parent.as_deref() + } +} + +impl fmt::Display for GhostCommit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.id) + } +} diff --git a/codex-rs/git-tooling/src/operations.rs b/codex-rs/git-tooling/src/operations.rs new file mode 100644 index 00000000..3b387f84 --- /dev/null +++ b/codex-rs/git-tooling/src/operations.rs @@ -0,0 +1,218 @@ +use std::ffi::OsStr; +use std::ffi::OsString; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +use crate::GitToolingError; + +pub(crate) fn ensure_git_repository(path: &Path) -> Result<(), GitToolingError> { + match run_git_for_stdout( + path, + vec![ + OsString::from("rev-parse"), + OsString::from("--is-inside-work-tree"), + ], + None, + ) { + Ok(output) if output.trim() == "true" => Ok(()), + Ok(_) => Err(GitToolingError::NotAGitRepository { + path: path.to_path_buf(), + }), + Err(GitToolingError::GitCommand { status, .. }) if status.code() == Some(128) => { + Err(GitToolingError::NotAGitRepository { + path: path.to_path_buf(), + }) + } + Err(err) => Err(err), + } +} + +pub(crate) fn resolve_head(path: &Path) -> Result, GitToolingError> { + match run_git_for_stdout( + path, + vec![ + OsString::from("rev-parse"), + OsString::from("--verify"), + OsString::from("HEAD"), + ], + None, + ) { + Ok(sha) => Ok(Some(sha)), + Err(GitToolingError::GitCommand { status, .. }) if status.code() == Some(128) => Ok(None), + Err(other) => Err(other), + } +} + +pub(crate) fn normalize_relative_path(path: &Path) -> Result { + let mut result = PathBuf::new(); + let mut saw_component = false; + for component in path.components() { + saw_component = true; + match component { + Component::Normal(part) => result.push(part), + Component::CurDir => {} + Component::ParentDir => { + if !result.pop() { + return Err(GitToolingError::PathEscapesRepository { + path: path.to_path_buf(), + }); + } + } + Component::RootDir | Component::Prefix(_) => { + return Err(GitToolingError::NonRelativePath { + path: path.to_path_buf(), + }); + } + } + } + + if !saw_component { + return Err(GitToolingError::NonRelativePath { + path: path.to_path_buf(), + }); + } + + Ok(result) +} + +pub(crate) fn resolve_repository_root(path: &Path) -> Result { + let root = run_git_for_stdout( + path, + vec![ + OsString::from("rev-parse"), + OsString::from("--show-toplevel"), + ], + None, + )?; + Ok(PathBuf::from(root)) +} + +pub(crate) fn apply_repo_prefix_to_force_include( + prefix: Option<&Path>, + paths: &[PathBuf], +) -> Vec { + if paths.is_empty() { + return Vec::new(); + } + + match prefix { + Some(prefix) => paths.iter().map(|path| prefix.join(path)).collect(), + None => paths.to_vec(), + } +} + +pub(crate) fn repo_subdir(repo_root: &Path, repo_path: &Path) -> Option { + if repo_root == repo_path { + return None; + } + + repo_path + .strip_prefix(repo_root) + .ok() + .and_then(non_empty_path) + .or_else(|| { + let repo_root_canon = repo_root.canonicalize().ok()?; + let repo_path_canon = repo_path.canonicalize().ok()?; + repo_path_canon + .strip_prefix(&repo_root_canon) + .ok() + .and_then(non_empty_path) + }) +} + +fn non_empty_path(path: &Path) -> Option { + if path.as_os_str().is_empty() { + None + } else { + Some(path.to_path_buf()) + } +} + +pub(crate) fn run_git_for_status( + dir: &Path, + args: I, + env: Option<&[(OsString, OsString)]>, +) -> Result<(), GitToolingError> +where + I: IntoIterator, + S: AsRef, +{ + run_git(dir, args, env)?; + Ok(()) +} + +pub(crate) fn run_git_for_stdout( + dir: &Path, + args: I, + env: Option<&[(OsString, OsString)]>, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let run = run_git(dir, args, env)?; + String::from_utf8(run.output.stdout) + .map(|value| value.trim().to_string()) + .map_err(|source| GitToolingError::GitOutputUtf8 { + command: run.command, + source, + }) +} + +fn run_git( + dir: &Path, + args: I, + env: Option<&[(OsString, OsString)]>, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let iterator = args.into_iter(); + let (lower, upper) = iterator.size_hint(); + let mut args_vec = Vec::with_capacity(upper.unwrap_or(lower)); + for arg in iterator { + args_vec.push(OsString::from(arg.as_ref())); + } + let command_string = build_command_string(&args_vec); + let mut command = Command::new("git"); + command.current_dir(dir); + if let Some(envs) = env { + for (key, value) in envs { + command.env(key, value); + } + } + command.args(&args_vec); + let output = command.output()?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(GitToolingError::GitCommand { + command: command_string, + status: output.status, + stderr, + }); + } + Ok(GitRun { + command: command_string, + output, + }) +} + +fn build_command_string(args: &[OsString]) -> String { + if args.is_empty() { + return "git".to_string(); + } + let joined = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>() + .join(" "); + format!("git {joined}") +} + +struct GitRun { + command: String, + output: std::process::Output, +} diff --git a/codex-rs/git-tooling/src/platform.rs b/codex-rs/git-tooling/src/platform.rs new file mode 100644 index 00000000..06feff75 --- /dev/null +++ b/codex-rs/git-tooling/src/platform.rs @@ -0,0 +1,37 @@ +use std::path::Path; + +use crate::GitToolingError; + +#[cfg(unix)] +pub fn create_symlink( + _source: &Path, + link_target: &Path, + destination: &Path, +) -> Result<(), GitToolingError> { + use std::os::unix::fs::symlink; + + symlink(link_target, destination)?; + Ok(()) +} + +#[cfg(windows)] +pub fn create_symlink( + source: &Path, + link_target: &Path, + destination: &Path, +) -> Result<(), GitToolingError> { + use std::os::windows::fs::FileTypeExt; + use std::os::windows::fs::symlink_dir; + use std::os::windows::fs::symlink_file; + + let metadata = std::fs::symlink_metadata(source)?; + if metadata.file_type().is_symlink_dir() { + symlink_dir(link_target, destination)?; + } else { + symlink_file(link_target, destination)?; + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +compile_error!("codex-git-tooling symlink support is only implemented for Unix and Windows"); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 38546d0b..6de2a347 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -35,6 +35,7 @@ codex-common = { workspace = true, features = [ ] } codex-core = { workspace = true } codex-file-search = { workspace = true } +codex-git-tooling = { workspace = true } codex-login = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fc368096..02580a79 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, // 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, + 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 = 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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index b983f6f5..960a25a0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 83aa2e23..0791a1cc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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(), diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index f043d62f..14604a73 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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() }