feat: ghost snapshot v2 (#8055)

This PR updates ghost snapshotting to avoid capturing oversized
untracked artifacts while keeping undo safe. Snapshot creation now
builds a temporary index from `git status --porcelain=2 -z`, writes a
tree and detached commit without touching refs, and records any ignored
large files/dirs in the snapshot report. Undo uses that metadata to
preserve large local artifacts while still cleaning up new transient
files.
This commit is contained in:
jif-oai authored and GitHub committed 2025-12-15 11:14:36 +01:00
1 parent 4274e6189a
commit 0d9801d448
3 files changed
+188 -129

No files matched your search

+3 -3
View File
@@ -44,7 +44,7 @@ dependencies = [
"bytestring", "bytestring",
"derive_more 2.0.1", "derive_more 2.0.1",
"encoding_rs", "encoding_rs",
"foldhash", "foldhash 0.1.5",
"futures-core", "futures-core",
"http 0.2.12", "http 0.2.12",
"httparse", "httparse",
@@ -139,7 +139,7 @@ dependencies = [
"cfg-if", "cfg-if",
"derive_more 2.0.1", "derive_more 2.0.1",
"encoding_rs", "encoding_rs",
"foldhash", "foldhash 0.1.5",
"futures-core", "futures-core",
"futures-util", "futures-util",
"impl-more", "impl-more",
@@ -154,7 +154,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"smallvec", "smallvec",
"socket2 0.6.0", "socket2 0.6.1",
"time", "time",
"tracing", "tracing",
"url", "url",
+18 -37
View File
@@ -8,8 +8,7 @@ use async_trait::async_trait;
use codex_git::CreateGhostCommitOptions; use codex_git::CreateGhostCommitOptions;
use codex_git::GhostSnapshotReport; use codex_git::GhostSnapshotReport;
use codex_git::GitToolingError; use codex_git::GitToolingError;
use codex_git::capture_ghost_snapshot_report; use codex_git::create_ghost_commit_with_report;
use codex_git::create_ghost_commit;
use codex_protocol::models::ResponseItem; use codex_protocol::models::ResponseItem;
use codex_protocol::user_input::UserInput; use codex_protocol::user_input::UserInput;
use codex_utils_readiness::Readiness; use codex_utils_readiness::Readiness;
@@ -74,48 +73,30 @@ impl SessionTask for GhostSnapshotTask {
_ = async { _ = async {
let repo_path = ctx_for_task.cwd.clone(); let repo_path = ctx_for_task.cwd.clone();
let ghost_snapshot = ctx_for_task.ghost_snapshot.clone(); let ghost_snapshot = ctx_for_task.ghost_snapshot.clone();
let ignore_large_untracked_dirs = ghost_snapshot.ignore_large_untracked_dirs; let ghost_snapshot_for_commit = ghost_snapshot.clone();
// First, compute a snapshot report so we can warn about
// large untracked directories before running the heavier
// snapshot logic.
if let Ok(Ok(report)) = tokio::task::spawn_blocking({
let repo_path = repo_path.clone();
let ghost_snapshot = ghost_snapshot.clone();
move || {
let options =
CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot);
capture_ghost_snapshot_report(&options)
}
})
.await
{
for message in
format_snapshot_warnings(
ghost_snapshot.ignore_large_untracked_files,
ignore_large_untracked_dirs,
&report,
)
{
session
.session
.send_event(
&ctx_for_task,
EventMsg::Warning(WarningEvent { message }),
)
.await;
}
}
// Required to run in a dedicated blocking pool. // Required to run in a dedicated blocking pool.
match tokio::task::spawn_blocking(move || { match tokio::task::spawn_blocking(move || {
let options = let options =
CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot); CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot_for_commit);
create_ghost_commit(&options) create_ghost_commit_with_report(&options)
}) })
.await .await
{ {
Ok(Ok(ghost_commit)) => { Ok(Ok((ghost_commit, report))) => {
info!("ghost snapshot blocking task finished"); info!("ghost snapshot blocking task finished");
for message in format_snapshot_warnings(
ghost_snapshot.ignore_large_untracked_files,
ghost_snapshot.ignore_large_untracked_dirs,
&report,
) {
session
.session
.send_event(
&ctx_for_task,
EventMsg::Warning(WarningEvent { message }),
)
.await;
}
session session
.session .session
.record_conversation_items(&ctx, &[ResponseItem::GhostSnapshot { .record_conversation_items(&ctx, &[ResponseItem::GhostSnapshot {
+167 -89
View File
@@ -306,13 +306,14 @@ pub fn create_ghost_commit_with_report(
let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path); let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path);
let parent = resolve_head(repo_root.as_path())?; let parent = resolve_head(repo_root.as_path())?;
let force_include = prepare_force_include(repo_prefix.as_deref(), &options.force_include)?; let force_include = prepare_force_include(repo_prefix.as_deref(), &options.force_include)?;
let existing_untracked = capture_existing_untracked( let status_snapshot = capture_status_snapshot(
repo_root.as_path(), repo_root.as_path(),
repo_prefix.as_deref(), repo_prefix.as_deref(),
options.ghost_snapshot.ignore_large_untracked_files, options.ghost_snapshot.ignore_large_untracked_files,
options.ghost_snapshot.ignore_large_untracked_dirs, options.ghost_snapshot.ignore_large_untracked_dirs,
&force_include, &force_include,
)?; )?;
let existing_untracked = status_snapshot.untracked;
let warning_ignored_files = existing_untracked let warning_ignored_files = existing_untracked
.ignored_untracked_files .ignored_untracked_files
@@ -336,6 +337,12 @@ pub fn create_ghost_commit_with_report(
OsString::from("GIT_INDEX_FILE"), OsString::from("GIT_INDEX_FILE"),
OsString::from(index_path.as_os_str()), OsString::from(index_path.as_os_str()),
)]; )];
// Use a temporary index so snapshotting does not disturb the user's index state.
// Example plumbing sequence:
// GIT_INDEX_FILE=/tmp/index git read-tree HEAD
// GIT_INDEX_FILE=/tmp/index git add --all -- <paths>
// GIT_INDEX_FILE=/tmp/index git write-tree
// GIT_INDEX_FILE=/tmp/index git commit-tree <tree> -p <parent> -m "codex snapshot"
// Pre-populate the temporary index with HEAD so unchanged tracked files // Pre-populate the temporary index with HEAD so unchanged tracked files
// are included in the snapshot tree. // are included in the snapshot tree.
@@ -347,22 +354,12 @@ pub fn create_ghost_commit_with_report(
)?; )?;
} }
let mut add_args = vec![OsString::from("add"), OsString::from("--all")]; let mut index_paths = status_snapshot.tracked_paths;
if let Some(prefix) = repo_prefix.as_deref() { index_paths.extend(existing_untracked.untracked_files_for_index.iter().cloned());
add_args.extend([OsString::from("--"), prefix.as_os_str().to_os_string()]); let index_paths = dedupe_paths(index_paths);
} // Stage tracked + new files into the temp index so write-tree reflects the working tree.
// We use `git add --all` to make deletions show up in the snapshot tree too.
run_git_for_status(repo_root.as_path(), add_args, Some(base_env.as_slice()))?; add_paths_to_index(repo_root.as_path(), base_env.as_slice(), &index_paths)?;
remove_large_untracked_dirs_from_index(
repo_root.as_path(),
base_env.as_slice(),
&existing_untracked.ignored_large_untracked_dir_files,
)?;
remove_large_untracked_files_from_index(
repo_root.as_path(),
base_env.as_slice(),
&existing_untracked.ignored_untracked_files,
)?;
if !force_include.is_empty() { if !force_include.is_empty() {
let mut args = Vec::with_capacity(force_include.len() + 2); let mut args = Vec::with_capacity(force_include.len() + 2);
args.push(OsString::from("add")); args.push(OsString::from("add"));
@@ -393,6 +390,8 @@ pub fn create_ghost_commit_with_report(
result result
}; };
// `git commit-tree` writes a detached commit object without updating refs,
// which keeps snapshots out of the user's branch history.
// Retrieve commit ID. // Retrieve commit ID.
let commit_id = run_git_for_stdout( let commit_id = run_git_for_stdout(
repo_root.as_path(), repo_root.as_path(),
@@ -468,6 +467,9 @@ fn restore_to_commit_inner(
repo_prefix: Option<&Path>, repo_prefix: Option<&Path>,
commit_id: &str, commit_id: &str,
) -> Result<(), GitToolingError> { ) -> Result<(), GitToolingError> {
// `git restore` resets both the index and working tree to the snapshot commit.
// Example:
// git restore --source <commit> --worktree --staged -- <prefix>
let mut restore_args = vec![ let mut restore_args = vec![
OsString::from("restore"), OsString::from("restore"),
OsString::from("--source"), OsString::from("--source"),
@@ -490,22 +492,30 @@ fn restore_to_commit_inner(
struct UntrackedSnapshot { struct UntrackedSnapshot {
files: Vec<PathBuf>, files: Vec<PathBuf>,
dirs: Vec<PathBuf>, dirs: Vec<PathBuf>,
untracked_files_for_index: Vec<PathBuf>,
ignored_untracked_files: Vec<IgnoredUntrackedFile>, ignored_untracked_files: Vec<IgnoredUntrackedFile>,
ignored_large_untracked_dirs: Vec<LargeUntrackedDir>, ignored_large_untracked_dirs: Vec<LargeUntrackedDir>,
ignored_large_untracked_dir_files: Vec<PathBuf>, ignored_large_untracked_dir_files: Vec<PathBuf>,
} }
/// Captures the untracked and ignored entries under `repo_root`, optionally limited by `repo_prefix`. #[derive(Default)]
/// Returns the result as an `UntrackedSnapshot`. struct StatusSnapshot {
fn capture_existing_untracked( tracked_paths: Vec<PathBuf>,
untracked: UntrackedSnapshot,
}
/// Captures the working tree status under `repo_root`, optionally limited by `repo_prefix`.
/// Returns the result as a `StatusSnapshot`.
fn capture_status_snapshot(
repo_root: &Path, repo_root: &Path,
repo_prefix: Option<&Path>, repo_prefix: Option<&Path>,
ignore_large_untracked_files: Option<i64>, ignore_large_untracked_files: Option<i64>,
ignore_large_untracked_dirs: Option<i64>, ignore_large_untracked_dirs: Option<i64>,
force_include: &[PathBuf], force_include: &[PathBuf],
) -> Result<UntrackedSnapshot, GitToolingError> { ) -> Result<StatusSnapshot, GitToolingError> {
// Ask git for the zero-delimited porcelain status so we can enumerate // Ask git for the zero-delimited porcelain status so we can enumerate
// every untracked path (including ones filtered by prefix). // tracked, untracked, and ignored entries (including ones filtered by prefix).
// This keeps the snapshot consistent without multiple git invocations.
let mut args = vec![ let mut args = vec![
OsString::from("status"), OsString::from("status"),
OsString::from("--porcelain=2"), OsString::from("--porcelain=2"),
@@ -519,55 +529,90 @@ fn capture_existing_untracked(
let output = run_git_for_stdout_all(repo_root, args, None)?; let output = run_git_for_stdout_all(repo_root, args, None)?;
if output.is_empty() { if output.is_empty() {
return Ok(UntrackedSnapshot::default()); return Ok(StatusSnapshot::default());
} }
let mut snapshot = UntrackedSnapshot::default(); let mut snapshot = StatusSnapshot::default();
let mut untracked_files_for_dir_scan: Vec<PathBuf> = Vec::new(); let mut untracked_files_for_dir_scan: Vec<PathBuf> = Vec::new();
// Each entry is of the form "<code> <path>" where code is '?' (untracked) let mut expect_rename_source = false;
// or '!' (ignored); everything else is irrelevant to this snapshot.
for entry in output.split('\0') { for entry in output.split('\0') {
if entry.is_empty() { if entry.is_empty() {
continue; continue;
} }
let mut parts = entry.splitn(2, ' '); if expect_rename_source {
let code = parts.next(); let normalized = normalize_relative_path(Path::new(entry))?;
let path_part = parts.next(); snapshot.tracked_paths.push(normalized);
let (Some(code), Some(path_part)) = (code, path_part) else { expect_rename_source = false;
continue;
};
if code != "?" && code != "!" {
continue;
}
if path_part.is_empty() {
continue; continue;
} }
let normalized = normalize_relative_path(Path::new(path_part))?; let record_type = entry.as_bytes().first().copied().unwrap_or(b' ');
if should_ignore_for_snapshot(&normalized) { match record_type {
continue; b'?' | b'!' => {
} let mut parts = entry.splitn(2, ' ');
let absolute = repo_root.join(&normalized); let code = parts.next();
let is_dir = absolute.is_dir(); let path_part = parts.next();
if is_dir { let (Some(code), Some(path_part)) = (code, path_part) else {
snapshot.dirs.push(normalized); continue;
} else if code == "?" { };
untracked_files_for_dir_scan.push(normalized.clone()); if path_part.is_empty() {
if let Some(threshold) = ignore_large_untracked_files continue;
&& threshold > 0 }
&& !is_force_included(&normalized, force_include)
&& let Ok(Some(byte_size)) = untracked_file_size(&absolute) let normalized = normalize_relative_path(Path::new(path_part))?;
&& byte_size > threshold if should_ignore_for_snapshot(&normalized) {
{ continue;
snapshot.ignored_untracked_files.push(IgnoredUntrackedFile { }
path: normalized, let absolute = repo_root.join(&normalized);
byte_size, let is_dir = absolute.is_dir();
}); if is_dir {
} else { snapshot.untracked.dirs.push(normalized);
snapshot.files.push(normalized); } else if code == "?" {
untracked_files_for_dir_scan.push(normalized.clone());
if let Some(threshold) = ignore_large_untracked_files
&& threshold > 0
&& !is_force_included(&normalized, force_include)
&& let Ok(Some(byte_size)) = untracked_file_size(&absolute)
&& byte_size > threshold
{
snapshot
.untracked
.ignored_untracked_files
.push(IgnoredUntrackedFile {
path: normalized,
byte_size,
});
} else {
snapshot.untracked.files.push(normalized.clone());
snapshot
.untracked
.untracked_files_for_index
.push(normalized);
}
} else {
snapshot.untracked.files.push(normalized);
}
} }
} else { b'1' => {
snapshot.files.push(normalized); if let Some(path) = extract_status_path_after_fields(entry, 8) {
let normalized = normalize_relative_path(Path::new(path))?;
snapshot.tracked_paths.push(normalized);
}
}
b'2' => {
if let Some(path) = extract_status_path_after_fields(entry, 9) {
let normalized = normalize_relative_path(Path::new(path))?;
snapshot.tracked_paths.push(normalized);
}
expect_rename_source = true;
}
b'u' => {
if let Some(path) = extract_status_path_after_fields(entry, 10) {
let normalized = normalize_relative_path(Path::new(path))?;
snapshot.tracked_paths.push(normalized);
}
}
_ => {}
} }
} }
@@ -576,7 +621,7 @@ fn capture_existing_untracked(
{ {
let ignored_large_untracked_dirs = detect_large_untracked_dirs( let ignored_large_untracked_dirs = detect_large_untracked_dirs(
&untracked_files_for_dir_scan, &untracked_files_for_dir_scan,
&snapshot.dirs, &snapshot.untracked.dirs,
Some(threshold), Some(threshold),
) )
.into_iter() .into_iter()
@@ -590,28 +635,69 @@ fn capture_existing_untracked(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
snapshot snapshot
.untracked
.files .files
.retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir))); .retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir)));
snapshot snapshot
.untracked
.dirs .dirs
.retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir))); .retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir)));
snapshot.ignored_untracked_files.retain(|file| { snapshot
.untracked
.untracked_files_for_index
.retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir)));
snapshot.untracked.ignored_untracked_files.retain(|file| {
!ignored_dir_paths !ignored_dir_paths
.iter() .iter()
.any(|dir| file.path.starts_with(dir)) .any(|dir| file.path.starts_with(dir))
}); });
snapshot.ignored_large_untracked_dir_files = untracked_files_for_dir_scan snapshot.untracked.ignored_large_untracked_dir_files = untracked_files_for_dir_scan
.into_iter() .into_iter()
.filter(|path| ignored_dir_paths.iter().any(|dir| path.starts_with(dir))) .filter(|path| ignored_dir_paths.iter().any(|dir| path.starts_with(dir)))
.collect(); .collect();
snapshot.ignored_large_untracked_dirs = ignored_large_untracked_dirs; snapshot.untracked.ignored_large_untracked_dirs = ignored_large_untracked_dirs;
} }
} }
Ok(snapshot) Ok(snapshot)
} }
/// Captures the untracked and ignored entries under `repo_root`, optionally limited by `repo_prefix`.
/// Returns the result as an `UntrackedSnapshot`.
fn capture_existing_untracked(
repo_root: &Path,
repo_prefix: Option<&Path>,
ignore_large_untracked_files: Option<i64>,
ignore_large_untracked_dirs: Option<i64>,
force_include: &[PathBuf],
) -> Result<UntrackedSnapshot, GitToolingError> {
Ok(capture_status_snapshot(
repo_root,
repo_prefix,
ignore_large_untracked_files,
ignore_large_untracked_dirs,
force_include,
)?
.untracked)
}
fn extract_status_path_after_fields(record: &str, fields_before_path: i64) -> Option<&str> {
if fields_before_path <= 0 {
return None;
}
let mut spaces = 0_i64;
for (idx, byte) in record.as_bytes().iter().enumerate() {
if *byte == b' ' {
spaces += 1;
if spaces == fields_before_path {
return record.get((idx + 1)..).filter(|path| !path.is_empty());
}
}
}
None
}
fn should_ignore_for_snapshot(path: &Path) -> bool { fn should_ignore_for_snapshot(path: &Path) -> bool {
path.components().any(|component| { path.components().any(|component| {
if let Component::Normal(name) = component if let Component::Normal(name) = component
@@ -657,27 +743,7 @@ fn untracked_file_size(path: &Path) -> io::Result<Option<i64>> {
Ok(Some(len_i64)) Ok(Some(len_i64))
} }
fn remove_large_untracked_files_from_index( fn add_paths_to_index(
repo_root: &Path,
env: &[(OsString, OsString)],
ignored: &[IgnoredUntrackedFile],
) -> Result<(), GitToolingError> {
let paths = ignored
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>();
remove_paths_from_index(repo_root, env, &paths)
}
fn remove_large_untracked_dirs_from_index(
repo_root: &Path,
env: &[(OsString, OsString)],
paths: &[PathBuf],
) -> Result<(), GitToolingError> {
remove_paths_from_index(repo_root, env, paths)
}
fn remove_paths_from_index(
repo_root: &Path, repo_root: &Path,
env: &[(OsString, OsString)], env: &[(OsString, OsString)],
paths: &[PathBuf], paths: &[PathBuf],
@@ -686,20 +752,32 @@ fn remove_paths_from_index(
return Ok(()); return Ok(());
} }
const CHUNK_SIZE: usize = 64; let chunk_size = usize::try_from(64_i64).unwrap_or(1);
for chunk in paths.chunks(CHUNK_SIZE) { for chunk in paths.chunks(chunk_size) {
let mut args = vec![ let mut args = vec![
OsString::from("update-index"), OsString::from("add"),
OsString::from("--force-remove"), OsString::from("--all"),
OsString::from("--"), OsString::from("--"),
]; ];
args.extend(chunk.iter().map(|path| path.as_os_str().to_os_string())); args.extend(chunk.iter().map(|path| path.as_os_str().to_os_string()));
// Chunk the argv to avoid oversized command lines on large repos.
run_git_for_status(repo_root, args, Some(env))?; run_git_for_status(repo_root, args, Some(env))?;
} }
Ok(()) Ok(())
} }
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut result = Vec::new();
for path in paths {
if seen.insert(path.clone()) {
result.push(path);
}
}
result
}
fn merge_preserved_untracked_files( fn merge_preserved_untracked_files(
mut files: Vec<PathBuf>, mut files: Vec<PathBuf>,
ignored: &[IgnoredUntrackedFile], ignored: &[IgnoredUntrackedFile],