codex_git/
ghost_commits.rs

1use std::collections::BTreeMap;
2use std::collections::HashSet;
3use std::ffi::OsString;
4use std::fs;
5use std::io;
6use std::path::Path;
7use std::path::PathBuf;
8
9use tempfile::Builder;
10
11use crate::GhostCommit;
12use crate::GitToolingError;
13use crate::operations::apply_repo_prefix_to_force_include;
14use crate::operations::ensure_git_repository;
15use crate::operations::normalize_relative_path;
16use crate::operations::repo_subdir;
17use crate::operations::resolve_head;
18use crate::operations::resolve_repository_root;
19use crate::operations::run_git_for_status;
20use crate::operations::run_git_for_stdout;
21use crate::operations::run_git_for_stdout_all;
22
23/// Default commit message used for ghost commits when none is provided.
24const DEFAULT_COMMIT_MESSAGE: &str = "codex snapshot";
25/// Default threshold that triggers a warning about large untracked directories.
26const LARGE_UNTRACKED_WARNING_THRESHOLD: usize = 200;
27
28/// Options to control ghost commit creation.
29pub struct CreateGhostCommitOptions<'a> {
30    pub repo_path: &'a Path,
31    pub message: Option<&'a str>,
32    pub force_include: Vec<PathBuf>,
33}
34
35/// Summary produced alongside a ghost snapshot.
36#[derive(Debug, Default, Clone, PartialEq, Eq)]
37pub struct GhostSnapshotReport {
38    pub large_untracked_dirs: Vec<LargeUntrackedDir>,
39}
40
41/// Directory containing a large amount of untracked content.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LargeUntrackedDir {
44    pub path: PathBuf,
45    pub file_count: usize,
46}
47
48impl<'a> CreateGhostCommitOptions<'a> {
49    /// Creates options scoped to the provided repository path.
50    pub fn new(repo_path: &'a Path) -> Self {
51        Self {
52            repo_path,
53            message: None,
54            force_include: Vec::new(),
55        }
56    }
57
58    /// Sets a custom commit message for the ghost commit.
59    pub fn message(mut self, message: &'a str) -> Self {
60        self.message = Some(message);
61        self
62    }
63
64    /// Supplies the entire force-include path list at once.
65    pub fn force_include<I>(mut self, paths: I) -> Self
66    where
67        I: IntoIterator<Item = PathBuf>,
68    {
69        self.force_include = paths.into_iter().collect();
70        self
71    }
72
73    /// Adds a single path to the force-include list.
74    pub fn push_force_include<P>(mut self, path: P) -> Self
75    where
76        P: Into<PathBuf>,
77    {
78        self.force_include.push(path.into());
79        self
80    }
81}
82
83fn detect_large_untracked_dirs(files: &[PathBuf], dirs: &[PathBuf]) -> Vec<LargeUntrackedDir> {
84    let mut counts: BTreeMap<PathBuf, usize> = BTreeMap::new();
85
86    let mut sorted_dirs: Vec<&PathBuf> = dirs.iter().collect();
87    sorted_dirs.sort_by(|a, b| {
88        let a_components = a.components().count();
89        let b_components = b.components().count();
90        b_components.cmp(&a_components).then_with(|| a.cmp(b))
91    });
92
93    for file in files {
94        let mut key: Option<PathBuf> = None;
95        for dir in &sorted_dirs {
96            if file.starts_with(dir.as_path()) {
97                key = Some((*dir).clone());
98                break;
99            }
100        }
101        let key = key.unwrap_or_else(|| {
102            file.parent()
103                .map(PathBuf::from)
104                .unwrap_or_else(|| PathBuf::from("."))
105        });
106        let entry = counts.entry(key).or_insert(0);
107        *entry += 1;
108    }
109
110    let mut result: Vec<LargeUntrackedDir> = counts
111        .into_iter()
112        .filter(|(_, count)| *count >= LARGE_UNTRACKED_WARNING_THRESHOLD)
113        .map(|(path, file_count)| LargeUntrackedDir { path, file_count })
114        .collect();
115    result.sort_by(|a, b| {
116        b.file_count
117            .cmp(&a.file_count)
118            .then_with(|| a.path.cmp(&b.path))
119    });
120    result
121}
122
123fn to_session_relative_path(path: &Path, repo_prefix: Option<&Path>) -> PathBuf {
124    match repo_prefix {
125        Some(prefix) => path
126            .strip_prefix(prefix)
127            .map(PathBuf::from)
128            .unwrap_or_else(|_| path.to_path_buf()),
129        None => path.to_path_buf(),
130    }
131}
132
133/// Create a ghost commit capturing the current state of the repository's working tree.
134pub fn create_ghost_commit(
135    options: &CreateGhostCommitOptions<'_>,
136) -> Result<GhostCommit, GitToolingError> {
137    create_ghost_commit_with_report(options).map(|(commit, _)| commit)
138}
139
140/// Compute a report describing the working tree for a ghost snapshot without creating a commit.
141pub fn capture_ghost_snapshot_report(
142    options: &CreateGhostCommitOptions<'_>,
143) -> Result<GhostSnapshotReport, GitToolingError> {
144    ensure_git_repository(options.repo_path)?;
145
146    let repo_root = resolve_repository_root(options.repo_path)?;
147    let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path);
148    let existing_untracked =
149        capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?;
150
151    let warning_files = existing_untracked
152        .files
153        .iter()
154        .map(|path| to_session_relative_path(path, repo_prefix.as_deref()))
155        .collect::<Vec<_>>();
156    let warning_dirs = existing_untracked
157        .dirs
158        .iter()
159        .map(|path| to_session_relative_path(path, repo_prefix.as_deref()))
160        .collect::<Vec<_>>();
161
162    Ok(GhostSnapshotReport {
163        large_untracked_dirs: detect_large_untracked_dirs(&warning_files, &warning_dirs),
164    })
165}
166
167/// Create a ghost commit capturing the current state of the repository's working tree along with a report.
168pub fn create_ghost_commit_with_report(
169    options: &CreateGhostCommitOptions<'_>,
170) -> Result<(GhostCommit, GhostSnapshotReport), GitToolingError> {
171    ensure_git_repository(options.repo_path)?;
172
173    let repo_root = resolve_repository_root(options.repo_path)?;
174    let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path);
175    let parent = resolve_head(repo_root.as_path())?;
176    let existing_untracked =
177        capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?;
178
179    let warning_files = existing_untracked
180        .files
181        .iter()
182        .map(|path| to_session_relative_path(path, repo_prefix.as_deref()))
183        .collect::<Vec<_>>();
184    let warning_dirs = existing_untracked
185        .dirs
186        .iter()
187        .map(|path| to_session_relative_path(path, repo_prefix.as_deref()))
188        .collect::<Vec<_>>();
189    let large_untracked_dirs = detect_large_untracked_dirs(&warning_files, &warning_dirs);
190
191    let normalized_force = options
192        .force_include
193        .iter()
194        .map(|path| normalize_relative_path(path))
195        .collect::<Result<Vec<_>, _>>()?;
196    let force_include =
197        apply_repo_prefix_to_force_include(repo_prefix.as_deref(), &normalized_force);
198    let index_tempdir = Builder::new().prefix("codex-git-index-").tempdir()?;
199    let index_path = index_tempdir.path().join("index");
200    let base_env = vec![(
201        OsString::from("GIT_INDEX_FILE"),
202        OsString::from(index_path.as_os_str()),
203    )];
204
205    // Pre-populate the temporary index with HEAD so unchanged tracked files
206    // are included in the snapshot tree.
207    if let Some(parent_sha) = parent.as_deref() {
208        run_git_for_status(
209            repo_root.as_path(),
210            vec![OsString::from("read-tree"), OsString::from(parent_sha)],
211            Some(base_env.as_slice()),
212        )?;
213    }
214
215    let mut add_args = vec![OsString::from("add"), OsString::from("--all")];
216    if let Some(prefix) = repo_prefix.as_deref() {
217        add_args.extend([OsString::from("--"), prefix.as_os_str().to_os_string()]);
218    }
219
220    run_git_for_status(repo_root.as_path(), add_args, Some(base_env.as_slice()))?;
221    if !force_include.is_empty() {
222        let mut args = Vec::with_capacity(force_include.len() + 2);
223        args.push(OsString::from("add"));
224        args.push(OsString::from("--force"));
225        args.extend(
226            force_include
227                .iter()
228                .map(|path| OsString::from(path.as_os_str())),
229        );
230        run_git_for_status(repo_root.as_path(), args, Some(base_env.as_slice()))?;
231    }
232
233    let tree_id = run_git_for_stdout(
234        repo_root.as_path(),
235        vec![OsString::from("write-tree")],
236        Some(base_env.as_slice()),
237    )?;
238
239    let mut commit_env = base_env;
240    commit_env.extend(default_commit_identity());
241    let message = options.message.unwrap_or(DEFAULT_COMMIT_MESSAGE);
242    let commit_args = {
243        let mut result = vec![OsString::from("commit-tree"), OsString::from(&tree_id)];
244        if let Some(parent) = parent.as_deref() {
245            result.extend([OsString::from("-p"), OsString::from(parent)]);
246        }
247        result.extend([OsString::from("-m"), OsString::from(message)]);
248        result
249    };
250
251    // Retrieve commit ID.
252    let commit_id = run_git_for_stdout(
253        repo_root.as_path(),
254        commit_args,
255        Some(commit_env.as_slice()),
256    )?;
257
258    let ghost_commit = GhostCommit::new(
259        commit_id,
260        parent,
261        existing_untracked.files,
262        existing_untracked.dirs,
263    );
264
265    Ok((
266        ghost_commit,
267        GhostSnapshotReport {
268            large_untracked_dirs,
269        },
270    ))
271}
272
273/// Restore the working tree to match the provided ghost commit.
274pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> {
275    ensure_git_repository(repo_path)?;
276
277    let repo_root = resolve_repository_root(repo_path)?;
278    let repo_prefix = repo_subdir(repo_root.as_path(), repo_path);
279    let current_untracked =
280        capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?;
281    restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit.id())?;
282    remove_new_untracked(
283        repo_root.as_path(),
284        commit.preexisting_untracked_files(),
285        commit.preexisting_untracked_dirs(),
286        current_untracked,
287    )
288}
289
290/// Restore the working tree to match the given commit ID.
291pub fn restore_to_commit(repo_path: &Path, commit_id: &str) -> Result<(), GitToolingError> {
292    ensure_git_repository(repo_path)?;
293
294    let repo_root = resolve_repository_root(repo_path)?;
295    let repo_prefix = repo_subdir(repo_root.as_path(), repo_path);
296    restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit_id)
297}
298
299/// Restores the working tree and index to the given commit using `git restore`.
300/// The repository root and optional repository-relative prefix limit the restore scope.
301fn restore_to_commit_inner(
302    repo_root: &Path,
303    repo_prefix: Option<&Path>,
304    commit_id: &str,
305) -> Result<(), GitToolingError> {
306    let mut restore_args = vec![
307        OsString::from("restore"),
308        OsString::from("--source"),
309        OsString::from(commit_id),
310        OsString::from("--worktree"),
311        OsString::from("--staged"),
312        OsString::from("--"),
313    ];
314    if let Some(prefix) = repo_prefix {
315        restore_args.push(prefix.as_os_str().to_os_string());
316    } else {
317        restore_args.push(OsString::from("."));
318    }
319
320    run_git_for_status(repo_root, restore_args, None)?;
321    Ok(())
322}
323
324#[derive(Default)]
325struct UntrackedSnapshot {
326    files: Vec<PathBuf>,
327    dirs: Vec<PathBuf>,
328}
329
330/// Captures the untracked and ignored entries under `repo_root`, optionally limited by `repo_prefix`.
331/// Returns the result as an `UntrackedSnapshot`.
332fn capture_existing_untracked(
333    repo_root: &Path,
334    repo_prefix: Option<&Path>,
335) -> Result<UntrackedSnapshot, GitToolingError> {
336    // Ask git for the zero-delimited porcelain status so we can enumerate
337    // every untracked or ignored path (including ones filtered by prefix).
338    let mut args = vec![
339        OsString::from("status"),
340        OsString::from("--porcelain=2"),
341        OsString::from("-z"),
342        OsString::from("--ignored=matching"),
343        OsString::from("--untracked-files=all"),
344    ];
345    if let Some(prefix) = repo_prefix {
346        args.push(OsString::from("--"));
347        args.push(prefix.as_os_str().to_os_string());
348    }
349
350    let output = run_git_for_stdout_all(repo_root, args, None)?;
351    if output.is_empty() {
352        return Ok(UntrackedSnapshot::default());
353    }
354
355    let mut snapshot = UntrackedSnapshot::default();
356    // Each entry is of the form "<code> <path>" where code is '?' (untracked)
357    // or '!' (ignored); everything else is irrelevant to this snapshot.
358    for entry in output.split('\0') {
359        if entry.is_empty() {
360            continue;
361        }
362        let mut parts = entry.splitn(2, ' ');
363        let code = parts.next();
364        let path_part = parts.next();
365        let (Some(code), Some(path_part)) = (code, path_part) else {
366            continue;
367        };
368        if code != "?" && code != "!" {
369            continue;
370        }
371        if path_part.is_empty() {
372            continue;
373        }
374
375        let normalized = normalize_relative_path(Path::new(path_part))?;
376        let absolute = repo_root.join(&normalized);
377        let is_dir = absolute.is_dir();
378        if is_dir {
379            snapshot.dirs.push(normalized);
380        } else {
381            snapshot.files.push(normalized);
382        }
383    }
384
385    Ok(snapshot)
386}
387
388/// Removes untracked files and directories that were not present when the snapshot was captured.
389fn remove_new_untracked(
390    repo_root: &Path,
391    preserved_files: &[PathBuf],
392    preserved_dirs: &[PathBuf],
393    current: UntrackedSnapshot,
394) -> Result<(), GitToolingError> {
395    if current.files.is_empty() && current.dirs.is_empty() {
396        return Ok(());
397    }
398
399    let preserved_file_set: HashSet<PathBuf> = preserved_files.iter().cloned().collect();
400    let preserved_dirs_vec: Vec<PathBuf> = preserved_dirs.to_vec();
401
402    for path in current.files {
403        if should_preserve(&path, &preserved_file_set, &preserved_dirs_vec) {
404            continue;
405        }
406        remove_path(&repo_root.join(&path))?;
407    }
408
409    for dir in current.dirs {
410        if should_preserve(&dir, &preserved_file_set, &preserved_dirs_vec) {
411            continue;
412        }
413        remove_path(&repo_root.join(&dir))?;
414    }
415
416    Ok(())
417}
418
419/// Determines whether an untracked path should be kept because it existed in the snapshot.
420fn should_preserve(
421    path: &Path,
422    preserved_files: &HashSet<PathBuf>,
423    preserved_dirs: &[PathBuf],
424) -> bool {
425    if preserved_files.contains(path) {
426        return true;
427    }
428
429    preserved_dirs
430        .iter()
431        .any(|dir| path.starts_with(dir.as_path()))
432}
433
434/// Deletes the file or directory at the provided path, ignoring if it is already absent.
435fn remove_path(path: &Path) -> Result<(), GitToolingError> {
436    match fs::symlink_metadata(path) {
437        Ok(metadata) => {
438            if metadata.is_dir() {
439                fs::remove_dir_all(path)?;
440            } else {
441                fs::remove_file(path)?;
442            }
443        }
444        Err(err) => {
445            if err.kind() == io::ErrorKind::NotFound {
446                return Ok(());
447            }
448            return Err(err.into());
449        }
450    }
451    Ok(())
452}
453
454/// Returns the default author and committer identity for ghost commits.
455fn default_commit_identity() -> Vec<(OsString, OsString)> {
456    vec![
457        (
458            OsString::from("GIT_AUTHOR_NAME"),
459            OsString::from("Codex Snapshot"),
460        ),
461        (
462            OsString::from("GIT_AUTHOR_EMAIL"),
463            OsString::from("snapshot@codex.local"),
464        ),
465        (
466            OsString::from("GIT_COMMITTER_NAME"),
467            OsString::from("Codex Snapshot"),
468        ),
469        (
470            OsString::from("GIT_COMMITTER_EMAIL"),
471            OsString::from("snapshot@codex.local"),
472        ),
473    ]
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::operations::run_git_for_stdout;
480    use assert_matches::assert_matches;
481    use pretty_assertions::assert_eq;
482    use std::process::Command;
483
484    /// Runs a git command in the test repository and asserts success.
485    fn run_git_in(repo_path: &Path, args: &[&str]) {
486        let status = Command::new("git")
487            .current_dir(repo_path)
488            .args(args)
489            .status()
490            .expect("git command");
491        assert!(status.success(), "git command failed: {args:?}");
492    }
493
494    /// Runs a git command and returns its trimmed stdout output.
495    fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String {
496        let output = Command::new("git")
497            .current_dir(repo_path)
498            .args(args)
499            .output()
500            .expect("git command");
501        assert!(output.status.success(), "git command failed: {args:?}");
502        String::from_utf8_lossy(&output.stdout).trim().to_string()
503    }
504
505    /// Initializes a repository with consistent settings for cross-platform tests.
506    fn init_test_repo(repo: &Path) {
507        run_git_in(repo, &["init", "--initial-branch=main"]);
508        run_git_in(repo, &["config", "core.autocrlf", "false"]);
509    }
510
511    #[test]
512    /// Verifies a ghost commit can be created and restored end to end.
513    fn create_and_restore_roundtrip() -> Result<(), GitToolingError> {
514        let temp = tempfile::tempdir()?;
515        let repo = temp.path();
516        init_test_repo(repo);
517        std::fs::write(repo.join("tracked.txt"), "initial\n")?;
518        std::fs::write(repo.join("delete-me.txt"), "to be removed\n")?;
519        run_git_in(repo, &["add", "tracked.txt", "delete-me.txt"]);
520        run_git_in(
521            repo,
522            &[
523                "-c",
524                "user.name=Tester",
525                "-c",
526                "user.email=test@example.com",
527                "commit",
528                "-m",
529                "init",
530            ],
531        );
532
533        let preexisting_untracked = repo.join("notes.txt");
534        std::fs::write(&preexisting_untracked, "notes before\n")?;
535
536        let tracked_contents = "modified contents\n";
537        std::fs::write(repo.join("tracked.txt"), tracked_contents)?;
538        std::fs::remove_file(repo.join("delete-me.txt"))?;
539        let new_file_contents = "hello ghost\n";
540        std::fs::write(repo.join("new-file.txt"), new_file_contents)?;
541        std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?;
542        let ignored_contents = "ignored but captured\n";
543        std::fs::write(repo.join("ignored.txt"), ignored_contents)?;
544
545        let options =
546            CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]);
547        let ghost = create_ghost_commit(&options)?;
548
549        assert!(ghost.parent().is_some());
550        let cat = run_git_for_stdout(
551            repo,
552            vec![
553                OsString::from("show"),
554                OsString::from(format!("{}:ignored.txt", ghost.id())),
555            ],
556            None,
557        )?;
558        assert_eq!(cat, ignored_contents.trim());
559
560        std::fs::write(repo.join("tracked.txt"), "other state\n")?;
561        std::fs::write(repo.join("ignored.txt"), "changed\n")?;
562        std::fs::remove_file(repo.join("new-file.txt"))?;
563        std::fs::write(repo.join("ephemeral.txt"), "temp data\n")?;
564        std::fs::write(&preexisting_untracked, "notes after\n")?;
565
566        restore_ghost_commit(repo, &ghost)?;
567
568        let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?;
569        assert_eq!(tracked_after, tracked_contents);
570        let ignored_after = std::fs::read_to_string(repo.join("ignored.txt"))?;
571        assert_eq!(ignored_after, ignored_contents);
572        let new_file_after = std::fs::read_to_string(repo.join("new-file.txt"))?;
573        assert_eq!(new_file_after, new_file_contents);
574        assert_eq!(repo.join("delete-me.txt").exists(), false);
575        assert!(!repo.join("ephemeral.txt").exists());
576        let notes_after = std::fs::read_to_string(&preexisting_untracked)?;
577        assert_eq!(notes_after, "notes before\n");
578
579        Ok(())
580    }
581
582    #[test]
583    fn create_snapshot_reports_large_untracked_dirs() -> Result<(), GitToolingError> {
584        let temp = tempfile::tempdir()?;
585        let repo = temp.path();
586        init_test_repo(repo);
587
588        std::fs::write(repo.join("tracked.txt"), "contents\n")?;
589        run_git_in(repo, &["add", "tracked.txt"]);
590        run_git_in(
591            repo,
592            &[
593                "-c",
594                "user.name=Tester",
595                "-c",
596                "user.email=test@example.com",
597                "commit",
598                "-m",
599                "initial",
600            ],
601        );
602
603        let models = repo.join("models");
604        std::fs::create_dir(&models)?;
605        for idx in 0..(LARGE_UNTRACKED_WARNING_THRESHOLD + 1) {
606            let file = models.join(format!("weights-{idx}.bin"));
607            std::fs::write(file, "data\n")?;
608        }
609
610        let (ghost, report) =
611            create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?;
612        assert!(ghost.parent().is_some());
613        assert_eq!(
614            report.large_untracked_dirs,
615            vec![LargeUntrackedDir {
616                path: PathBuf::from("models"),
617                file_count: LARGE_UNTRACKED_WARNING_THRESHOLD + 1,
618            }]
619        );
620
621        Ok(())
622    }
623
624    #[test]
625    fn create_snapshot_reports_nested_large_untracked_dirs_under_tracked_parent()
626    -> Result<(), GitToolingError> {
627        let temp = tempfile::tempdir()?;
628        let repo = temp.path();
629        init_test_repo(repo);
630
631        // Create a tracked src directory.
632        let src = repo.join("src");
633        std::fs::create_dir(&src)?;
634        std::fs::write(src.join("main.rs"), "fn main() {}\n")?;
635        run_git_in(repo, &["add", "src/main.rs"]);
636        run_git_in(
637            repo,
638            &[
639                "-c",
640                "user.name=Tester",
641                "-c",
642                "user.email=test@example.com",
643                "commit",
644                "-m",
645                "initial",
646            ],
647        );
648
649        // Create a large untracked tree nested under the tracked src directory.
650        let generated = src.join("generated").join("cache");
651        std::fs::create_dir_all(&generated)?;
652        for idx in 0..(LARGE_UNTRACKED_WARNING_THRESHOLD + 1) {
653            let file = generated.join(format!("file-{idx}.bin"));
654            std::fs::write(file, "data\n")?;
655        }
656
657        let (_, report) = create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?;
658        assert_eq!(report.large_untracked_dirs.len(), 1);
659        let entry = &report.large_untracked_dirs[0];
660        assert_ne!(entry.path, PathBuf::from("src"));
661        assert!(
662            entry.path.starts_with(Path::new("src/generated")),
663            "unexpected path for large untracked directory: {}",
664            entry.path.display()
665        );
666        assert_eq!(entry.file_count, LARGE_UNTRACKED_WARNING_THRESHOLD + 1);
667
668        Ok(())
669    }
670
671    #[test]
672    /// Ensures ghost commits succeed in repositories without an existing HEAD.
673    fn create_snapshot_without_existing_head() -> Result<(), GitToolingError> {
674        let temp = tempfile::tempdir()?;
675        let repo = temp.path();
676        init_test_repo(repo);
677
678        let tracked_contents = "first contents\n";
679        std::fs::write(repo.join("tracked.txt"), tracked_contents)?;
680        let ignored_contents = "ignored but captured\n";
681        std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?;
682        std::fs::write(repo.join("ignored.txt"), ignored_contents)?;
683
684        let options =
685            CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]);
686        let ghost = create_ghost_commit(&options)?;
687
688        assert!(ghost.parent().is_none());
689
690        let message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]);
691        assert_eq!(message, DEFAULT_COMMIT_MESSAGE);
692
693        let ignored = run_git_stdout(repo, &["show", &format!("{}:ignored.txt", ghost.id())]);
694        assert_eq!(ignored, ignored_contents.trim());
695
696        Ok(())
697    }
698
699    #[test]
700    /// Confirms custom messages are used when creating ghost commits.
701    fn create_ghost_commit_uses_custom_message() -> Result<(), GitToolingError> {
702        let temp = tempfile::tempdir()?;
703        let repo = temp.path();
704        init_test_repo(repo);
705
706        std::fs::write(repo.join("tracked.txt"), "contents\n")?;
707        run_git_in(repo, &["add", "tracked.txt"]);
708        run_git_in(
709            repo,
710            &[
711                "-c",
712                "user.name=Tester",
713                "-c",
714                "user.email=test@example.com",
715                "commit",
716                "-m",
717                "initial",
718            ],
719        );
720
721        let message = "custom message";
722        let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo).message(message))?;
723        let commit_message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]);
724        assert_eq!(commit_message, message);
725
726        Ok(())
727    }
728
729    #[test]
730    /// Rejects force-included paths that escape the repository.
731    fn create_ghost_commit_rejects_force_include_parent_path() {
732        let temp = tempfile::tempdir().expect("tempdir");
733        let repo = temp.path();
734        init_test_repo(repo);
735        let options = CreateGhostCommitOptions::new(repo)
736            .force_include(vec![PathBuf::from("../outside.txt")]);
737        let err = create_ghost_commit(&options).unwrap_err();
738        assert_matches!(err, GitToolingError::PathEscapesRepository { .. });
739    }
740
741    #[test]
742    /// Restoring a ghost commit from a non-git directory fails.
743    fn restore_requires_git_repository() {
744        let temp = tempfile::tempdir().expect("tempdir");
745        let err = restore_to_commit(temp.path(), "deadbeef").unwrap_err();
746        assert_matches!(err, GitToolingError::NotAGitRepository { .. });
747    }
748
749    #[test]
750    /// Restoring from a subdirectory affects only that subdirectory.
751    fn restore_from_subdirectory_restores_files_relatively() -> Result<(), GitToolingError> {
752        let temp = tempfile::tempdir()?;
753        let repo = temp.path();
754        init_test_repo(repo);
755
756        std::fs::create_dir_all(repo.join("workspace"))?;
757        let workspace = repo.join("workspace");
758        std::fs::write(repo.join("root.txt"), "root contents\n")?;
759        std::fs::write(workspace.join("nested.txt"), "nested contents\n")?;
760        run_git_in(repo, &["add", "."]);
761        run_git_in(
762            repo,
763            &[
764                "-c",
765                "user.name=Tester",
766                "-c",
767                "user.email=test@example.com",
768                "commit",
769                "-m",
770                "initial",
771            ],
772        );
773
774        std::fs::write(repo.join("root.txt"), "root modified\n")?;
775        std::fs::write(workspace.join("nested.txt"), "nested modified\n")?;
776
777        let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?;
778
779        std::fs::write(repo.join("root.txt"), "root after\n")?;
780        std::fs::write(workspace.join("nested.txt"), "nested after\n")?;
781
782        restore_ghost_commit(&workspace, &ghost)?;
783
784        let root_after = std::fs::read_to_string(repo.join("root.txt"))?;
785        assert_eq!(root_after, "root after\n");
786        let nested_after = std::fs::read_to_string(workspace.join("nested.txt"))?;
787        assert_eq!(nested_after, "nested modified\n");
788        assert!(!workspace.join("codex-rs").exists());
789
790        Ok(())
791    }
792
793    #[test]
794    /// Restoring from a subdirectory preserves ignored files in parent folders.
795    fn restore_from_subdirectory_preserves_parent_vscode() -> Result<(), GitToolingError> {
796        let temp = tempfile::tempdir()?;
797        let repo = temp.path();
798        init_test_repo(repo);
799
800        let workspace = repo.join("codex-rs");
801        std::fs::create_dir_all(&workspace)?;
802        std::fs::write(repo.join(".gitignore"), ".vscode/\n")?;
803        std::fs::write(workspace.join("tracked.txt"), "snapshot version\n")?;
804        run_git_in(repo, &["add", "."]);
805        run_git_in(
806            repo,
807            &[
808                "-c",
809                "user.name=Tester",
810                "-c",
811                "user.email=test@example.com",
812                "commit",
813                "-m",
814                "initial",
815            ],
816        );
817
818        std::fs::write(workspace.join("tracked.txt"), "snapshot delta\n")?;
819        let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?;
820
821        std::fs::write(workspace.join("tracked.txt"), "post-snapshot\n")?;
822        let vscode = repo.join(".vscode");
823        std::fs::create_dir_all(&vscode)?;
824        std::fs::write(vscode.join("settings.json"), "{\n  \"after\": true\n}\n")?;
825
826        restore_ghost_commit(&workspace, &ghost)?;
827
828        let tracked_after = std::fs::read_to_string(workspace.join("tracked.txt"))?;
829        assert_eq!(tracked_after, "snapshot delta\n");
830        assert!(vscode.join("settings.json").exists());
831        let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?;
832        assert_eq!(settings_after, "{\n  \"after\": true\n}\n");
833
834        Ok(())
835    }
836
837    #[test]
838    /// Restoring from the repository root keeps ignored files intact.
839    fn restore_preserves_ignored_files() -> Result<(), GitToolingError> {
840        let temp = tempfile::tempdir()?;
841        let repo = temp.path();
842        init_test_repo(repo);
843
844        std::fs::write(repo.join(".gitignore"), ".vscode/\n")?;
845        std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?;
846        let vscode = repo.join(".vscode");
847        std::fs::create_dir_all(&vscode)?;
848        std::fs::write(vscode.join("settings.json"), "{\n  \"before\": true\n}\n")?;
849        run_git_in(repo, &["add", ".gitignore", "tracked.txt"]);
850        run_git_in(
851            repo,
852            &[
853                "-c",
854                "user.name=Tester",
855                "-c",
856                "user.email=test@example.com",
857                "commit",
858                "-m",
859                "initial",
860            ],
861        );
862
863        std::fs::write(repo.join("tracked.txt"), "snapshot delta\n")?;
864        let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?;
865
866        std::fs::write(repo.join("tracked.txt"), "post-snapshot\n")?;
867        std::fs::write(vscode.join("settings.json"), "{\n  \"after\": true\n}\n")?;
868        std::fs::write(repo.join("temp.txt"), "new file\n")?;
869
870        restore_ghost_commit(repo, &ghost)?;
871
872        let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?;
873        assert_eq!(tracked_after, "snapshot delta\n");
874        assert!(vscode.join("settings.json").exists());
875        let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?;
876        assert_eq!(settings_after, "{\n  \"after\": true\n}\n");
877        assert!(!repo.join("temp.txt").exists());
878
879        Ok(())
880    }
881
882    #[test]
883    /// Restoring removes ignored directories created after the snapshot.
884    fn restore_removes_new_ignored_directory() -> Result<(), GitToolingError> {
885        let temp = tempfile::tempdir()?;
886        let repo = temp.path();
887        init_test_repo(repo);
888
889        std::fs::write(repo.join(".gitignore"), ".vscode/\n")?;
890        std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?;
891        run_git_in(repo, &["add", ".gitignore", "tracked.txt"]);
892        run_git_in(
893            repo,
894            &[
895                "-c",
896                "user.name=Tester",
897                "-c",
898                "user.email=test@example.com",
899                "commit",
900                "-m",
901                "initial",
902            ],
903        );
904
905        let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?;
906
907        let vscode = repo.join(".vscode");
908        std::fs::create_dir_all(&vscode)?;
909        std::fs::write(vscode.join("settings.json"), "{\n  \"after\": true\n}\n")?;
910
911        restore_ghost_commit(repo, &ghost)?;
912
913        assert!(!vscode.exists());
914
915        Ok(())
916    }
917}