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:
11
codex-rs/Cargo.lock
generated
11
codex-rs/Cargo.lock
generated
@@ -778,6 +778,16 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codex-git-tooling"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"pretty_assertions",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.16",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codex-linux-sandbox"
|
name = "codex-linux-sandbox"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
@@ -919,6 +929,7 @@ dependencies = [
|
|||||||
"codex-common",
|
"codex-common",
|
||||||
"codex-core",
|
"codex-core",
|
||||||
"codex-file-search",
|
"codex-file-search",
|
||||||
|
"codex-git-tooling",
|
||||||
"codex-login",
|
"codex-login",
|
||||||
"codex-ollama",
|
"codex-ollama",
|
||||||
"codex-protocol",
|
"codex-protocol",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ members = [
|
|||||||
"exec",
|
"exec",
|
||||||
"execpolicy",
|
"execpolicy",
|
||||||
"file-search",
|
"file-search",
|
||||||
|
"git-tooling",
|
||||||
"linux-sandbox",
|
"linux-sandbox",
|
||||||
"login",
|
"login",
|
||||||
"mcp-client",
|
"mcp-client",
|
||||||
@@ -39,6 +40,7 @@ codex-common = { path = "common" }
|
|||||||
codex-core = { path = "core" }
|
codex-core = { path = "core" }
|
||||||
codex-exec = { path = "exec" }
|
codex-exec = { path = "exec" }
|
||||||
codex-file-search = { path = "file-search" }
|
codex-file-search = { path = "file-search" }
|
||||||
|
codex-git-tooling = { path = "git-tooling" }
|
||||||
codex-linux-sandbox = { path = "linux-sandbox" }
|
codex-linux-sandbox = { path = "linux-sandbox" }
|
||||||
codex-login = { path = "login" }
|
codex-login = { path = "login" }
|
||||||
codex-mcp-client = { path = "mcp-client" }
|
codex-mcp-client = { path = "mcp-client" }
|
||||||
|
|||||||
20
codex-rs/git-tooling/Cargo.toml
Normal file
20
codex-rs/git-tooling/Cargo.toml
Normal file
@@ -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"
|
||||||
20
codex-rs/git-tooling/README.md
Normal file
20
codex-rs/git-tooling/README.md
Normal file
@@ -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()])`.
|
||||||
35
codex-rs/git-tooling/src/errors.rs
Normal file
35
codex-rs/git-tooling/src/errors.rs
Normal file
@@ -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),
|
||||||
|
}
|
||||||
494
codex-rs/git-tooling/src/ghost_commits.rs
Normal file
494
codex-rs/git-tooling/src/ghost_commits.rs
Normal file
@@ -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<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<I>(mut self, paths: I) -> Self
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = PathBuf>,
|
||||||
|
{
|
||||||
|
self.force_include = paths.into_iter().collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a single path to the force-include list.
|
||||||
|
pub fn push_force_include<P>(mut self, path: P) -> Self
|
||||||
|
where
|
||||||
|
P: Into<PathBuf>,
|
||||||
|
{
|
||||||
|
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<GhostCommit, GitToolingError> {
|
||||||
|
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::<Result<Vec<_>, _>>()?;
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
43
codex-rs/git-tooling/src/lib.rs
Normal file
43
codex-rs/git-tooling/src/lib.rs
Normal file
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GhostCommit {
|
||||||
|
/// Create a new ghost commit wrapper from a raw commit ID and optional parent.
|
||||||
|
pub fn new(id: String, parent: Option<String>) -> 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
218
codex-rs/git-tooling/src/operations.rs
Normal file
218
codex-rs/git-tooling/src/operations.rs
Normal file
@@ -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<Option<String>, 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<PathBuf, GitToolingError> {
|
||||||
|
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<PathBuf, GitToolingError> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(path.to_path_buf())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn run_git_for_status<I, S>(
|
||||||
|
dir: &Path,
|
||||||
|
args: I,
|
||||||
|
env: Option<&[(OsString, OsString)]>,
|
||||||
|
) -> Result<(), GitToolingError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
run_git(dir, args, env)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn run_git_for_stdout<I, S>(
|
||||||
|
dir: &Path,
|
||||||
|
args: I,
|
||||||
|
env: Option<&[(OsString, OsString)]>,
|
||||||
|
) -> Result<String, GitToolingError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
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<I, S>(
|
||||||
|
dir: &Path,
|
||||||
|
args: I,
|
||||||
|
env: Option<&[(OsString, OsString)]>,
|
||||||
|
) -> Result<GitRun, GitToolingError>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<OsStr>,
|
||||||
|
{
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
format!("git {joined}")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GitRun {
|
||||||
|
command: String,
|
||||||
|
output: std::process::Output,
|
||||||
|
}
|
||||||
37
codex-rs/git-tooling/src/platform.rs
Normal file
37
codex-rs/git-tooling/src/platform.rs
Normal file
@@ -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");
|
||||||
@@ -35,6 +35,7 @@ codex-common = { workspace = true, features = [
|
|||||||
] }
|
] }
|
||||||
codex-core = { workspace = true }
|
codex-core = { workspace = true }
|
||||||
codex-file-search = { workspace = true }
|
codex-file-search = { workspace = true }
|
||||||
|
codex-git-tooling = { workspace = true }
|
||||||
codex-login = { workspace = true }
|
codex-login = { workspace = true }
|
||||||
codex-ollama = { workspace = true }
|
codex-ollama = { workspace = true }
|
||||||
codex-protocol = { workspace = true }
|
codex-protocol = { workspace = true }
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ mod session_header;
|
|||||||
use self::session_header::SessionHeader;
|
use self::session_header::SessionHeader;
|
||||||
use crate::streaming::controller::AppEventHistorySink;
|
use crate::streaming::controller::AppEventHistorySink;
|
||||||
use crate::streaming::controller::StreamController;
|
use crate::streaming::controller::StreamController;
|
||||||
//
|
use std::path::Path;
|
||||||
|
|
||||||
use codex_common::approval_presets::ApprovalPreset;
|
use codex_common::approval_presets::ApprovalPreset;
|
||||||
use codex_common::approval_presets::builtin_approval_presets;
|
use codex_common::approval_presets::builtin_approval_presets;
|
||||||
use codex_common::model_presets::ModelPreset;
|
use codex_common::model_presets::ModelPreset;
|
||||||
@@ -103,7 +104,13 @@ use codex_core::protocol::AskForApproval;
|
|||||||
use codex_core::protocol::SandboxPolicy;
|
use codex_core::protocol::SandboxPolicy;
|
||||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||||
use codex_file_search::FileMatch;
|
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.
|
// Track information about an in-flight exec command.
|
||||||
struct RunningCommand {
|
struct RunningCommand {
|
||||||
@@ -197,6 +204,9 @@ pub(crate) struct ChatWidget {
|
|||||||
pending_notification: Option<Notification>,
|
pending_notification: Option<Notification>,
|
||||||
// Simple review mode flag; used to adjust layout and banners.
|
// Simple review mode flag; used to adjust layout and banners.
|
||||||
is_review_mode: bool,
|
is_review_mode: bool,
|
||||||
|
// List of ghost commits corresponding to each turn.
|
||||||
|
ghost_snapshots: Vec<GhostCommit>,
|
||||||
|
ghost_snapshots_disabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UserMessage {
|
struct UserMessage {
|
||||||
@@ -787,6 +797,8 @@ impl ChatWidget {
|
|||||||
suppress_session_configured_redraw: false,
|
suppress_session_configured_redraw: false,
|
||||||
pending_notification: None,
|
pending_notification: None,
|
||||||
is_review_mode: false,
|
is_review_mode: false,
|
||||||
|
ghost_snapshots: Vec::new(),
|
||||||
|
ghost_snapshots_disabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,6 +858,8 @@ impl ChatWidget {
|
|||||||
suppress_session_configured_redraw: true,
|
suppress_session_configured_redraw: true,
|
||||||
pending_notification: None,
|
pending_notification: None,
|
||||||
is_review_mode: false,
|
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);
|
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||||
}
|
}
|
||||||
|
SlashCommand::Undo => {
|
||||||
|
self.undo_last_snapshot();
|
||||||
|
}
|
||||||
SlashCommand::Diff => {
|
SlashCommand::Diff => {
|
||||||
self.add_diff_in_progress();
|
self.add_diff_in_progress();
|
||||||
let tx = self.app_event_tx.clone();
|
let tx = self.app_event_tx.clone();
|
||||||
@@ -1088,6 +1105,12 @@ impl ChatWidget {
|
|||||||
|
|
||||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||||
let UserMessage { text, image_paths } = user_message;
|
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();
|
let mut items: Vec<InputItem> = Vec::new();
|
||||||
|
|
||||||
if !text.is_empty() {
|
if !text.is_empty() {
|
||||||
@@ -1098,10 +1121,6 @@ impl ChatWidget {
|
|||||||
items.push(InputItem::LocalImage { path });
|
items.push(InputItem::LocalImage { path });
|
||||||
}
|
}
|
||||||
|
|
||||||
if items.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.codex_op_tx
|
self.codex_op_tx
|
||||||
.send(Op::UserInput { items })
|
.send(Op::UserInput { items })
|
||||||
.unwrap_or_else(|e| {
|
.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
|
/// 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
|
/// resuming an existing session. This approximates the live event flow and
|
||||||
/// is intentionally conservative: only safe-to-replay items are rendered to
|
/// is intentionally conservative: only safe-to-replay items are rendered to
|
||||||
|
|||||||
@@ -335,6 +335,8 @@ fn make_chatwidget_manual() -> (
|
|||||||
suppress_session_configured_redraw: false,
|
suppress_session_configured_redraw: false,
|
||||||
pending_notification: None,
|
pending_notification: None,
|
||||||
is_review_mode: false,
|
is_review_mode: false,
|
||||||
|
ghost_snapshots: Vec::new(),
|
||||||
|
ghost_snapshots_disabled: false,
|
||||||
};
|
};
|
||||||
(widget, rx, op_rx)
|
(widget, rx, op_rx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -570,7 +570,6 @@ mod tests {
|
|||||||
// Create a unique CODEX_HOME per test to isolate auth.json writes.
|
// Create a unique CODEX_HOME per test to isolate auth.json writes.
|
||||||
let codex_home = get_next_codex_home();
|
let codex_home = get_next_codex_home();
|
||||||
std::fs::create_dir_all(&codex_home).expect("create unique CODEX_HOME");
|
std::fs::create_dir_all(&codex_home).expect("create unique CODEX_HOME");
|
||||||
|
|
||||||
Config::load_from_base_config_with_overrides(
|
Config::load_from_base_config_with_overrides(
|
||||||
ConfigToml::default(),
|
ConfigToml::default(),
|
||||||
ConfigOverrides::default(),
|
ConfigOverrides::default(),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub enum SlashCommand {
|
|||||||
New,
|
New,
|
||||||
Init,
|
Init,
|
||||||
Compact,
|
Compact,
|
||||||
|
Undo,
|
||||||
Diff,
|
Diff,
|
||||||
Mention,
|
Mention,
|
||||||
Status,
|
Status,
|
||||||
@@ -35,7 +36,8 @@ impl SlashCommand {
|
|||||||
SlashCommand::New => "start a new chat during a conversation",
|
SlashCommand::New => "start a new chat during a conversation",
|
||||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
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::Quit => "exit Codex",
|
||||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||||
SlashCommand::Mention => "mention a file",
|
SlashCommand::Mention => "mention a file",
|
||||||
@@ -61,6 +63,7 @@ impl SlashCommand {
|
|||||||
SlashCommand::New
|
SlashCommand::New
|
||||||
| SlashCommand::Init
|
| SlashCommand::Init
|
||||||
| SlashCommand::Compact
|
| SlashCommand::Compact
|
||||||
|
| SlashCommand::Undo
|
||||||
| SlashCommand::Model
|
| SlashCommand::Model
|
||||||
| SlashCommand::Approvals
|
| SlashCommand::Approvals
|
||||||
| SlashCommand::Review
|
| SlashCommand::Review
|
||||||
@@ -79,5 +82,20 @@ impl SlashCommand {
|
|||||||
|
|
||||||
/// Return all built-in commands in a Vec paired with their command string.
|
/// Return all built-in commands in a Vec paired with their command string.
|
||||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user