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
23const DEFAULT_COMMIT_MESSAGE: &str = "codex snapshot";
25const LARGE_UNTRACKED_WARNING_THRESHOLD: usize = 200;
27
28pub struct CreateGhostCommitOptions<'a> {
30 pub repo_path: &'a Path,
31 pub message: Option<&'a str>,
32 pub force_include: Vec<PathBuf>,
33}
34
35#[derive(Debug, Default, Clone, PartialEq, Eq)]
37pub struct GhostSnapshotReport {
38 pub large_untracked_dirs: Vec<LargeUntrackedDir>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct LargeUntrackedDir {
44 pub path: PathBuf,
45 pub file_count: usize,
46}
47
48impl<'a> CreateGhostCommitOptions<'a> {
49 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 pub fn message(mut self, message: &'a str) -> Self {
60 self.message = Some(message);
61 self
62 }
63
64 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 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
133pub fn create_ghost_commit(
135 options: &CreateGhostCommitOptions<'_>,
136) -> Result<GhostCommit, GitToolingError> {
137 create_ghost_commit_with_report(options).map(|(commit, _)| commit)
138}
139
140pub 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
167pub 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 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 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
273pub 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
290pub 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
299fn 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
330fn capture_existing_untracked(
333 repo_root: &Path,
334 repo_prefix: Option<&Path>,
335) -> Result<UntrackedSnapshot, GitToolingError> {
336 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 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
388fn 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
419fn 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
434fn 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
454fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}