use std::collections::BTreeMap; use std::collections::HashSet; use std::ffi::OsString; use std::fs; use std::io; use std::path::Component; 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; use crate::operations::run_git_for_stdout_all; /// Default commit message used for ghost commits when none is provided. const DEFAULT_COMMIT_MESSAGE: &str = "codex snapshot"; /// Default threshold that triggers a warning about large untracked directories. const LARGE_UNTRACKED_WARNING_THRESHOLD: usize = 200; /// Directories that should always be ignored when capturing ghost snapshots, /// even if they are not listed in .gitignore. /// /// These are typically large dependency or build trees that are not useful /// for undo and can cause snapshots to grow without bound. const DEFAULT_IGNORED_DIR_NAMES: &[&str] = &[ "node_modules", ".venv", "venv", "env", ".env", "dist", "build", ".pytest_cache", ".mypy_cache", ".cache", ".tox", "__pycache__", ]; /// Options to control ghost commit creation. pub struct CreateGhostCommitOptions<'a> { pub repo_path: &'a Path, pub message: Option<&'a str>, pub force_include: Vec, } /// Summary produced alongside a ghost snapshot. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct GhostSnapshotReport { pub large_untracked_dirs: Vec, } /// Directory containing a large amount of untracked content. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LargeUntrackedDir { pub path: PathBuf, pub file_count: usize, } 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 } } fn detect_large_untracked_dirs(files: &[PathBuf], dirs: &[PathBuf]) -> Vec { let mut counts: BTreeMap = BTreeMap::new(); let mut sorted_dirs: Vec<&PathBuf> = dirs.iter().collect(); sorted_dirs.sort_by(|a, b| { let a_components = a.components().count(); let b_components = b.components().count(); b_components.cmp(&a_components).then_with(|| a.cmp(b)) }); for file in files { let mut key: Option = None; for dir in &sorted_dirs { if file.starts_with(dir.as_path()) { key = Some((*dir).clone()); break; } } let key = key.unwrap_or_else(|| { file.parent() .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) }); let entry = counts.entry(key).or_insert(0); *entry += 1; } let mut result: Vec = counts .into_iter() .filter(|(_, count)| *count >= LARGE_UNTRACKED_WARNING_THRESHOLD) .map(|(path, file_count)| LargeUntrackedDir { path, file_count }) .collect(); result.sort_by(|a, b| { b.file_count .cmp(&a.file_count) .then_with(|| a.path.cmp(&b.path)) }); result } fn to_session_relative_path(path: &Path, repo_prefix: Option<&Path>) -> PathBuf { match repo_prefix { Some(prefix) => path .strip_prefix(prefix) .map(PathBuf::from) .unwrap_or_else(|_| path.to_path_buf()), None => path.to_path_buf(), } } /// Create a ghost commit capturing the current state of the repository's working tree. pub fn create_ghost_commit( options: &CreateGhostCommitOptions<'_>, ) -> Result { create_ghost_commit_with_report(options).map(|(commit, _)| commit) } /// Compute a report describing the working tree for a ghost snapshot without creating a commit. pub fn capture_ghost_snapshot_report( 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 existing_untracked = capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?; let warning_files = existing_untracked .files .iter() .map(|path| to_session_relative_path(path, repo_prefix.as_deref())) .collect::>(); let warning_dirs = existing_untracked .dirs .iter() .map(|path| to_session_relative_path(path, repo_prefix.as_deref())) .collect::>(); Ok(GhostSnapshotReport { large_untracked_dirs: detect_large_untracked_dirs(&warning_files, &warning_dirs), }) } /// Create a ghost commit capturing the current state of the repository's working tree along with a report. pub fn create_ghost_commit_with_report( options: &CreateGhostCommitOptions<'_>, ) -> Result<(GhostCommit, GhostSnapshotReport), 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 existing_untracked = capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?; let warning_files = existing_untracked .files .iter() .map(|path| to_session_relative_path(path, repo_prefix.as_deref())) .collect::>(); let warning_dirs = existing_untracked .dirs .iter() .map(|path| to_session_relative_path(path, repo_prefix.as_deref())) .collect::>(); let large_untracked_dirs = detect_large_untracked_dirs(&warning_files, &warning_dirs); 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()), )]; // Pre-populate the temporary index with HEAD so unchanged tracked files // are included in the snapshot tree. if let Some(parent_sha) = parent.as_deref() { run_git_for_status( repo_root.as_path(), vec![OsString::from("read-tree"), OsString::from(parent_sha)], Some(base_env.as_slice()), )?; } 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()), )?; let ghost_commit = GhostCommit::new( commit_id, parent, existing_untracked.files, existing_untracked.dirs, ); Ok(( ghost_commit, GhostSnapshotReport { large_untracked_dirs, }, )) } /// Restore the working tree to match the provided ghost commit. pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> 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 current_untracked = capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?; restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit.id())?; remove_new_untracked( repo_root.as_path(), commit.preexisting_untracked_files(), commit.preexisting_untracked_dirs(), current_untracked, ) } /// 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); restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit_id) } /// Restores the working tree and index to the given commit using `git restore`. /// The repository root and optional repository-relative prefix limit the restore scope. fn restore_to_commit_inner( repo_root: &Path, repo_prefix: Option<&Path>, commit_id: &str, ) -> Result<(), GitToolingError> { 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 { restore_args.push(prefix.as_os_str().to_os_string()); } else { restore_args.push(OsString::from(".")); } run_git_for_status(repo_root, restore_args, None)?; Ok(()) } #[derive(Default)] struct UntrackedSnapshot { files: Vec, dirs: Vec, } /// 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>, ) -> Result { // Ask git for the zero-delimited porcelain status so we can enumerate // every untracked path (including ones filtered by prefix). let mut args = vec![ OsString::from("status"), OsString::from("--porcelain=2"), OsString::from("-z"), OsString::from("--untracked-files=all"), ]; if let Some(prefix) = repo_prefix { args.push(OsString::from("--")); args.push(prefix.as_os_str().to_os_string()); } let output = run_git_for_stdout_all(repo_root, args, None)?; if output.is_empty() { return Ok(UntrackedSnapshot::default()); } let mut snapshot = UntrackedSnapshot::default(); // Each entry is of the form " " where code is '?' (untracked) // or '!' (ignored); everything else is irrelevant to this snapshot. for entry in output.split('\0') { if entry.is_empty() { continue; } let mut parts = entry.splitn(2, ' '); let code = parts.next(); let path_part = parts.next(); let (Some(code), Some(path_part)) = (code, path_part) else { continue; }; if code != "?" && code != "!" { continue; } if path_part.is_empty() { continue; } let normalized = normalize_relative_path(Path::new(path_part))?; if should_ignore_for_snapshot(&normalized) { continue; } let absolute = repo_root.join(&normalized); let is_dir = absolute.is_dir(); if is_dir { snapshot.dirs.push(normalized); } else { snapshot.files.push(normalized); } } Ok(snapshot) } fn should_ignore_for_snapshot(path: &Path) -> bool { path.components().any(|component| { if let Component::Normal(name) = component && let Some(name_str) = name.to_str() { return DEFAULT_IGNORED_DIR_NAMES .iter() .any(|ignored| ignored == &name_str); } false }) } /// Removes untracked files and directories that were not present when the snapshot was captured. fn remove_new_untracked( repo_root: &Path, preserved_files: &[PathBuf], preserved_dirs: &[PathBuf], current: UntrackedSnapshot, ) -> Result<(), GitToolingError> { if current.files.is_empty() && current.dirs.is_empty() { return Ok(()); } let preserved_file_set: HashSet = preserved_files.iter().cloned().collect(); let preserved_dirs_vec: Vec = preserved_dirs.to_vec(); for path in current.files { if should_preserve(&path, &preserved_file_set, &preserved_dirs_vec) { continue; } remove_path(&repo_root.join(&path))?; } for dir in current.dirs { if should_preserve(&dir, &preserved_file_set, &preserved_dirs_vec) { continue; } remove_path(&repo_root.join(&dir))?; } Ok(()) } /// Determines whether an untracked path should be kept because it existed in the snapshot. fn should_preserve( path: &Path, preserved_files: &HashSet, preserved_dirs: &[PathBuf], ) -> bool { if preserved_files.contains(path) { return true; } preserved_dirs .iter() .any(|dir| path.starts_with(dir.as_path())) } /// Deletes the file or directory at the provided path, ignoring if it is already absent. fn remove_path(path: &Path) -> Result<(), GitToolingError> { match fs::symlink_metadata(path) { Ok(metadata) => { if metadata.is_dir() { fs::remove_dir_all(path)?; } else { fs::remove_file(path)?; } } Err(err) => { if err.kind() == io::ErrorKind::NotFound { return Ok(()); } return Err(err.into()); } } 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 assert_matches::assert_matches; use pretty_assertions::assert_eq; use std::process::Command; use walkdir::WalkDir; /// 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 preexisting_untracked = repo.join("notes.txt"); std::fs::write(&preexisting_untracked, "notes before\n")?; 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")?; std::fs::write(&preexisting_untracked, "notes after\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()); let notes_after = std::fs::read_to_string(&preexisting_untracked)?; assert_eq!(notes_after, "notes before\n"); Ok(()) } #[test] fn create_snapshot_reports_large_untracked_dirs() -> 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 models = repo.join("models"); std::fs::create_dir(&models)?; for idx in 0..(LARGE_UNTRACKED_WARNING_THRESHOLD + 1) { let file = models.join(format!("weights-{idx}.bin")); std::fs::write(file, "data\n")?; } let (ghost, report) = create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?; assert!(ghost.parent().is_some()); assert_eq!( report.large_untracked_dirs, vec![LargeUntrackedDir { path: PathBuf::from("models"), file_count: LARGE_UNTRACKED_WARNING_THRESHOLD + 1, }] ); Ok(()) } #[test] fn snapshot_ignores_default_ignored_directories() -> 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 node_modules = repo.join("node_modules"); std::fs::create_dir_all(node_modules.join("@scope/package/src"))?; for idx in 0..50 { let file = node_modules.join(format!("file-{idx}.js")); std::fs::write(file, "console.log('ignored');\n")?; } std::fs::write( node_modules.join("@scope/package/src/index.js"), "console.log('nested ignored');\n", )?; let venv = repo.join(".venv"); std::fs::create_dir_all(venv.join("lib/python/site-packages"))?; std::fs::write( venv.join("lib/python/site-packages/pkg.py"), "print('ignored')\n", )?; let (ghost, report) = create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?; assert!(ghost.parent().is_some()); for file in ghost.preexisting_untracked_files() { let components = file.components().collect::>(); let mut has_default_ignored_component = false; for component in components { if let Component::Normal(name) = component && let Some(name_str) = name.to_str() && DEFAULT_IGNORED_DIR_NAMES .iter() .any(|ignored| ignored == &name_str) { has_default_ignored_component = true; break; } } assert!( !has_default_ignored_component, "unexpected default-ignored file captured: {file:?}" ); } for dir in ghost.preexisting_untracked_dirs() { let components = dir.components().collect::>(); let mut has_default_ignored_component = false; for component in components { if let Component::Normal(name) = component && let Some(name_str) = name.to_str() && DEFAULT_IGNORED_DIR_NAMES .iter() .any(|ignored| ignored == &name_str) { has_default_ignored_component = true; break; } } assert!( !has_default_ignored_component, "unexpected default-ignored dir captured: {dir:?}" ); } for entry in &report.large_untracked_dirs { let components = entry.path.components().collect::>(); let mut has_default_ignored_component = false; for component in components { if let Component::Normal(name) = component && let Some(name_str) = name.to_str() && DEFAULT_IGNORED_DIR_NAMES .iter() .any(|ignored| ignored == &name_str) { has_default_ignored_component = true; break; } } assert!( !has_default_ignored_component, "unexpected default-ignored dir in large_untracked_dirs: {:?}", entry.path ); } Ok(()) } #[test] fn restore_preserves_default_ignored_directories() -> Result<(), GitToolingError> { let temp = tempfile::tempdir()?; let repo = temp.path(); init_test_repo(repo); std::fs::write(repo.join("tracked.txt"), "snapshot version\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 node_modules = repo.join("node_modules"); std::fs::create_dir_all(node_modules.join("pkg"))?; std::fs::write( node_modules.join("pkg/index.js"), "console.log('before');\n", )?; let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; std::fs::write(repo.join("tracked.txt"), "snapshot delta\n")?; std::fs::write(node_modules.join("pkg/index.js"), "console.log('after');\n")?; std::fs::write(node_modules.join("pkg/extra.js"), "console.log('extra');\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 version\n"); let node_modules_exists = node_modules.exists(); assert!(node_modules_exists); let files_under_node_modules: Vec<_> = WalkDir::new(&node_modules) .into_iter() .filter_map(Result::ok) .filter(|entry| entry.file_type().is_file()) .collect(); assert!(!files_under_node_modules.is_empty()); assert!(!repo.join("temp.txt").exists()); Ok(()) } #[test] fn create_snapshot_reports_nested_large_untracked_dirs_under_tracked_parent() -> Result<(), GitToolingError> { let temp = tempfile::tempdir()?; let repo = temp.path(); init_test_repo(repo); // Create a tracked src directory. let src = repo.join("src"); std::fs::create_dir(&src)?; std::fs::write(src.join("main.rs"), "fn main() {}\n")?; run_git_in(repo, &["add", "src/main.rs"]); run_git_in( repo, &[ "-c", "user.name=Tester", "-c", "user.email=test@example.com", "commit", "-m", "initial", ], ); // Create a large untracked tree nested under the tracked src directory. let generated = src.join("generated").join("cache"); std::fs::create_dir_all(&generated)?; for idx in 0..(LARGE_UNTRACKED_WARNING_THRESHOLD + 1) { let file = generated.join(format!("file-{idx}.bin")); std::fs::write(file, "data\n")?; } let (_, report) = create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?; assert_eq!(report.large_untracked_dirs.len(), 1); let entry = &report.large_untracked_dirs[0]; assert_ne!(entry.path, PathBuf::from("src")); assert!( entry.path.starts_with(Path::new("src/generated")), "unexpected path for large untracked directory: {}", entry.path.display() ); assert_eq!(entry.file_count, LARGE_UNTRACKED_WARNING_THRESHOLD + 1); 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(()) } #[test] /// Restoring leaves ignored directories created after the snapshot untouched. fn restore_preserves_new_ignored_directory() -> 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")?; 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", ], ); let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; 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(repo, &ghost)?; assert!(vscode.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 leaves ignored files created after the snapshot untouched. fn restore_preserves_new_ignored_file() -> Result<(), GitToolingError> { let temp = tempfile::tempdir()?; let repo = temp.path(); init_test_repo(repo); std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?; std::fs::write(repo.join("tracked.txt"), "snapshot version\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", ], ); let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; let ignored = repo.join("ignored.txt"); std::fs::write(&ignored, "created later\n")?; restore_ghost_commit(repo, &ghost)?; assert!(ignored.exists()); let contents = std::fs::read_to_string(&ignored)?; assert_eq!(contents, "created later\n"); Ok(()) } #[test] /// Restoring keeps deleted ignored files deleted when they were absent before the snapshot. fn restore_respects_removed_ignored_file() -> Result<(), GitToolingError> { let temp = tempfile::tempdir()?; let repo = temp.path(); init_test_repo(repo); std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?; std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?; let ignored = repo.join("ignored.txt"); std::fs::write(&ignored, "initial state\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", ], ); let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; std::fs::remove_file(&ignored)?; restore_ghost_commit(repo, &ghost)?; assert!(!ignored.exists()); Ok(()) } #[test] /// Restoring leaves files matched by glob ignores intact. fn restore_preserves_ignored_glob_matches() -> Result<(), GitToolingError> { let temp = tempfile::tempdir()?; let repo = temp.path(); init_test_repo(repo); std::fs::write(repo.join(".gitignore"), "dummy-dir/*.txt\n")?; std::fs::write(repo.join("tracked.txt"), "snapshot version\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", ], ); let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?; let dummy_dir = repo.join("dummy-dir"); std::fs::create_dir_all(&dummy_dir)?; let file1 = dummy_dir.join("file1.txt"); let file2 = dummy_dir.join("file2.txt"); std::fs::write(&file1, "first\n")?; std::fs::write(&file2, "second\n")?; restore_ghost_commit(repo, &ghost)?; assert!(file1.exists()); assert!(file2.exists()); assert_eq!(std::fs::read_to_string(file1)?, "first\n"); assert_eq!(std::fs::read_to_string(file2)?, "second\n"); Ok(()) } }