1use std::ffi::OsString;
2use std::path::Path;
3use std::path::PathBuf;
4
5use tempfile::Builder;
6
7use crate::GhostCommit;
8use crate::GitToolingError;
9use crate::operations::apply_repo_prefix_to_force_include;
10use crate::operations::ensure_git_repository;
11use crate::operations::normalize_relative_path;
12use crate::operations::repo_subdir;
13use crate::operations::resolve_head;
14use crate::operations::resolve_repository_root;
15use crate::operations::run_git_for_status;
16use crate::operations::run_git_for_stdout;
17
18const DEFAULT_COMMIT_MESSAGE: &str = "code snapshot";
20
21pub struct CreateGhostCommitOptions<'a> {
23 pub repo_path: &'a Path,
24 pub message: Option<&'a str>,
25 pub force_include: Vec<PathBuf>,
26 pub parent: Option<&'a str>,
27 pub post_commit_hook: Option<&'a dyn Fn()>,
28}
29
30impl<'a> CreateGhostCommitOptions<'a> {
31 pub fn new(repo_path: &'a Path) -> Self {
33 Self {
34 repo_path,
35 message: None,
36 force_include: Vec::new(),
37 parent: None,
38 post_commit_hook: None,
39 }
40 }
41
42 pub fn message(mut self, message: &'a str) -> Self {
44 self.message = Some(message);
45 self
46 }
47
48 pub fn parent(mut self, parent: &'a str) -> Self {
50 self.parent = Some(parent);
51 self
52 }
53
54 pub fn post_commit_hook(mut self, hook: &'a dyn Fn()) -> Self {
56 self.post_commit_hook = Some(hook);
57 self
58 }
59
60 pub fn force_include<I>(mut self, paths: I) -> Self
62 where
63 I: IntoIterator<Item = PathBuf>,
64 {
65 self.force_include = paths.into_iter().collect();
66 self
67 }
68
69 pub fn push_force_include<P>(mut self, path: P) -> Self
71 where
72 P: Into<PathBuf>,
73 {
74 self.force_include.push(path.into());
75 self
76 }
77}
78
79pub fn create_ghost_commit(
81 options: &CreateGhostCommitOptions<'_>,
82) -> Result<GhostCommit, GitToolingError> {
83 ensure_git_repository(options.repo_path)?;
84
85 let repo_root = resolve_repository_root(options.repo_path)?;
86 let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path);
87 let parent_override = options.parent.map(std::string::ToString::to_string);
88 let resolved_parent = resolve_head(repo_root.as_path())?;
89 let parent_ref = parent_override
90 .as_deref()
91 .or(resolved_parent.as_deref())
92 .map(std::string::ToString::to_string);
93
94 let normalized_force = options
95 .force_include
96 .iter()
97 .map(|path| normalize_relative_path(path))
98 .collect::<Result<Vec<_>, _>>()?;
99 let force_include =
100 apply_repo_prefix_to_force_include(repo_prefix.as_deref(), &normalized_force);
101 let index_tempdir = Builder::new().prefix("dev-git-index-").tempdir()?;
102 let index_path = index_tempdir.path().join("index");
103 let base_env = vec![(
104 OsString::from("GIT_INDEX_FILE"),
105 OsString::from(index_path.as_os_str()),
106 )];
107
108 let mut add_args = vec![OsString::from("add"), OsString::from("--all")];
109 if let Some(prefix) = repo_prefix.as_deref() {
110 add_args.extend([OsString::from("--"), prefix.as_os_str().to_os_string()]);
111 }
112
113 run_git_for_status(repo_root.as_path(), add_args, Some(base_env.as_slice()))?;
114 if !force_include.is_empty() {
115 let mut args = Vec::with_capacity(force_include.len() + 2);
116 args.push(OsString::from("add"));
117 args.push(OsString::from("--force"));
118 args.extend(
119 force_include
120 .iter()
121 .map(|path| OsString::from(path.as_os_str())),
122 );
123 run_git_for_status(repo_root.as_path(), args, Some(base_env.as_slice()))?;
124 }
125
126 let tree_id = run_git_for_stdout(
127 repo_root.as_path(),
128 vec![OsString::from("write-tree")],
129 Some(base_env.as_slice()),
130 )?;
131
132 let mut commit_env = base_env;
133 commit_env.extend(default_commit_identity());
134 let message = options.message.unwrap_or(DEFAULT_COMMIT_MESSAGE);
135 let commit_args = {
136 let mut result = vec![OsString::from("commit-tree"), OsString::from(&tree_id)];
137 if let Some(parent) = parent_ref.as_deref() {
138 result.extend([OsString::from("-p"), OsString::from(parent)]);
139 }
140 result.extend([OsString::from("-m"), OsString::from(message)]);
141 result
142 };
143
144 let commit_id = run_git_for_stdout(
146 repo_root.as_path(),
147 commit_args,
148 Some(commit_env.as_slice()),
149 )?;
150
151 if let Some(hook) = options.post_commit_hook {
152 hook();
153 }
154
155 Ok(GhostCommit::new(commit_id, parent_ref))
156}
157
158pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> {
160 restore_to_commit(repo_path, commit.id())
161}
162
163pub fn restore_to_commit(repo_path: &Path, commit_id: &str) -> Result<(), GitToolingError> {
165 ensure_git_repository(repo_path)?;
166
167 let repo_root = resolve_repository_root(repo_path)?;
168 let repo_prefix = repo_subdir(repo_root.as_path(), repo_path);
169
170 let mut restore_args = vec![
171 OsString::from("restore"),
172 OsString::from("--source"),
173 OsString::from(commit_id),
174 OsString::from("--worktree"),
175 OsString::from("--staged"),
176 OsString::from("--"),
177 ];
178 if let Some(prefix) = repo_prefix.as_deref() {
179 restore_args.push(prefix.as_os_str().to_os_string());
180 } else {
181 restore_args.push(OsString::from("."));
182 }
183
184 run_git_for_status(repo_root.as_path(), restore_args, None)?;
185 Ok(())
186}
187
188fn default_commit_identity() -> Vec<(OsString, OsString)> {
190 vec![
191 (
192 OsString::from("GIT_AUTHOR_NAME"),
193 OsString::from("Code Snapshot"),
194 ),
195 (
196 OsString::from("GIT_AUTHOR_EMAIL"),
197 OsString::from("snapshot@code.local"),
198 ),
199 (
200 OsString::from("GIT_COMMITTER_NAME"),
201 OsString::from("Code Snapshot"),
202 ),
203 (
204 OsString::from("GIT_COMMITTER_EMAIL"),
205 OsString::from("snapshot@code.local"),
206 ),
207 ]
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::operations::run_git_for_stdout;
214 use pretty_assertions::assert_eq;
215 use std::process::Command;
216
217 fn run_git_in(repo_path: &Path, args: &[&str]) {
219 let status = Command::new("git")
220 .current_dir(repo_path)
221 .args(args)
222 .status()
223 .expect("git command");
224 assert!(status.success(), "git command failed: {args:?}");
225 }
226
227 fn run_git_stdout(repo_path: &Path, args: &[&str]) -> String {
229 let output = Command::new("git")
230 .current_dir(repo_path)
231 .args(args)
232 .output()
233 .expect("git command");
234 assert!(output.status.success(), "git command failed: {args:?}");
235 String::from_utf8_lossy(&output.stdout).trim().to_string()
236 }
237
238 fn init_test_repo(repo: &Path) {
240 let init_status = Command::new("git")
241 .current_dir(repo)
242 .args(["init", "--initial-branch=main"])
243 .output()
244 .expect("git command");
245
246 if !init_status.status.success() {
247 let fallback = Command::new("git")
248 .current_dir(repo)
249 .arg("init")
250 .status()
251 .expect("git command");
252 assert!(
253 fallback.success(),
254 "git init failed without --initial-branch"
255 );
256
257 let set_head = Command::new("git")
258 .current_dir(repo)
259 .args(["symbolic-ref", "HEAD", "refs/heads/main"])
260 .status()
261 .expect("git command");
262 assert!(
263 set_head.success(),
264 "git symbolic-ref HEAD refs/heads/main failed"
265 );
266 }
267
268 run_git_in(repo, &["config", "core.autocrlf", "false"]);
269 }
270
271 #[test]
272 fn create_and_restore_roundtrip() -> Result<(), GitToolingError> {
274 let temp = tempfile::tempdir()?;
275 let repo = temp.path();
276 init_test_repo(repo);
277 std::fs::write(repo.join("tracked.txt"), "initial\n")?;
278 std::fs::write(repo.join("delete-me.txt"), "to be removed\n")?;
279 run_git_in(repo, &["add", "tracked.txt", "delete-me.txt"]);
280 run_git_in(
281 repo,
282 &[
283 "-c",
284 "user.name=Tester",
285 "-c",
286 "user.email=test@example.com",
287 "commit",
288 "-m",
289 "init",
290 ],
291 );
292
293 let tracked_contents = "modified contents\n";
294 std::fs::write(repo.join("tracked.txt"), tracked_contents)?;
295 std::fs::remove_file(repo.join("delete-me.txt"))?;
296 let new_file_contents = "hello ghost\n";
297 std::fs::write(repo.join("new-file.txt"), new_file_contents)?;
298 std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?;
299 let ignored_contents = "ignored but captured\n";
300 std::fs::write(repo.join("ignored.txt"), ignored_contents)?;
301
302 let options =
303 CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]);
304 let ghost = create_ghost_commit(&options)?;
305
306 assert!(ghost.parent().is_some());
307 let cat = run_git_for_stdout(
308 repo,
309 vec![
310 OsString::from("show"),
311 OsString::from(format!("{}:ignored.txt", ghost.id())),
312 ],
313 None,
314 )?;
315 assert_eq!(cat, ignored_contents.trim());
316
317 std::fs::write(repo.join("tracked.txt"), "other state\n")?;
318 std::fs::write(repo.join("ignored.txt"), "changed\n")?;
319 std::fs::remove_file(repo.join("new-file.txt"))?;
320 std::fs::write(repo.join("ephemeral.txt"), "temp data\n")?;
321
322 restore_ghost_commit(repo, &ghost)?;
323
324 let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?;
325 assert_eq!(tracked_after, tracked_contents);
326 let ignored_after = std::fs::read_to_string(repo.join("ignored.txt"))?;
327 assert_eq!(ignored_after, ignored_contents);
328 let new_file_after = std::fs::read_to_string(repo.join("new-file.txt"))?;
329 assert_eq!(new_file_after, new_file_contents);
330 assert_eq!(repo.join("delete-me.txt").exists(), false);
331 assert!(repo.join("ephemeral.txt").exists());
332
333 Ok(())
334 }
335
336 #[test]
337 fn create_snapshot_without_existing_head() -> Result<(), GitToolingError> {
339 let temp = tempfile::tempdir()?;
340 let repo = temp.path();
341 init_test_repo(repo);
342
343 let tracked_contents = "first contents\n";
344 std::fs::write(repo.join("tracked.txt"), tracked_contents)?;
345 let ignored_contents = "ignored but captured\n";
346 std::fs::write(repo.join(".gitignore"), "ignored.txt\n")?;
347 std::fs::write(repo.join("ignored.txt"), ignored_contents)?;
348
349 let options =
350 CreateGhostCommitOptions::new(repo).force_include(vec![PathBuf::from("ignored.txt")]);
351 let ghost = create_ghost_commit(&options)?;
352
353 assert!(ghost.parent().is_none());
354
355 let message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]);
356 assert_eq!(message, DEFAULT_COMMIT_MESSAGE);
357
358 let ignored = run_git_stdout(repo, &["show", &format!("{}:ignored.txt", ghost.id())]);
359 assert_eq!(ignored, ignored_contents.trim());
360
361 Ok(())
362 }
363
364 #[test]
365 fn create_ghost_commit_uses_custom_message() -> Result<(), GitToolingError> {
367 let temp = tempfile::tempdir()?;
368 let repo = temp.path();
369 init_test_repo(repo);
370
371 std::fs::write(repo.join("tracked.txt"), "contents\n")?;
372 run_git_in(repo, &["add", "tracked.txt"]);
373 run_git_in(
374 repo,
375 &[
376 "-c",
377 "user.name=Tester",
378 "-c",
379 "user.email=test@example.com",
380 "commit",
381 "-m",
382 "initial",
383 ],
384 );
385
386 let message = "custom message";
387 let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo).message(message))?;
388 let commit_message = run_git_stdout(repo, &["log", "-1", "--format=%s", ghost.id()]);
389 assert_eq!(commit_message, message);
390
391 Ok(())
392 }
393
394 #[test]
395 fn post_commit_hook_runs_after_creation() -> Result<(), GitToolingError> {
397 use std::sync::atomic::AtomicUsize;
398 use std::sync::atomic::Ordering;
399 static CALLS: AtomicUsize = AtomicUsize::new(0);
400
401 let temp = tempfile::tempdir()?;
402 let repo = temp.path();
403 init_test_repo(repo);
404
405 std::fs::write(repo.join("tracked.txt"), "contents\n")?;
406 run_git_in(repo, &["add", "tracked.txt"]);
407 run_git_in(
408 repo,
409 &[
410 "-c",
411 "user.name=Tester",
412 "-c",
413 "user.email=test@example.com",
414 "commit",
415 "-m",
416 "initial",
417 ],
418 );
419
420 fn hook() {
421 CALLS.fetch_add(1, Ordering::SeqCst);
422 }
423
424 let options = CreateGhostCommitOptions::new(repo).post_commit_hook(&hook);
425 let _ = create_ghost_commit(&options)?;
426 assert_eq!(CALLS.load(Ordering::SeqCst), 1);
427 Ok(())
428 }
429
430 #[test]
431 fn create_ghost_commit_rejects_force_include_parent_path() {
433 let temp = tempfile::tempdir().expect("tempdir");
434 let repo = temp.path();
435 init_test_repo(repo);
436 let options = CreateGhostCommitOptions::new(repo)
437 .force_include(vec![PathBuf::from("../outside.txt")]);
438 let err = create_ghost_commit(&options).unwrap_err();
439 assert!(matches!(err, GitToolingError::PathEscapesRepository { .. }));
440 }
441
442 #[test]
443 fn restore_requires_git_repository() {
445 let temp = tempfile::tempdir().expect("tempdir");
446 let err = restore_to_commit(temp.path(), "deadbeef").unwrap_err();
447 assert!(matches!(err, GitToolingError::NotAGitRepository { .. }));
448 }
449
450 #[test]
451 fn restore_from_subdirectory_restores_files_relatively() -> Result<(), GitToolingError> {
453 let temp = tempfile::tempdir()?;
454 let repo = temp.path();
455 init_test_repo(repo);
456
457 std::fs::create_dir_all(repo.join("workspace"))?;
458 let workspace = repo.join("workspace");
459 std::fs::write(repo.join("root.txt"), "root contents\n")?;
460 std::fs::write(workspace.join("nested.txt"), "nested contents\n")?;
461 run_git_in(repo, &["add", "."]);
462 run_git_in(
463 repo,
464 &[
465 "-c",
466 "user.name=Tester",
467 "-c",
468 "user.email=test@example.com",
469 "commit",
470 "-m",
471 "initial",
472 ],
473 );
474
475 std::fs::write(repo.join("root.txt"), "root modified\n")?;
476 std::fs::write(workspace.join("nested.txt"), "nested modified\n")?;
477
478 let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?;
479
480 std::fs::write(repo.join("root.txt"), "root after\n")?;
481 std::fs::write(workspace.join("nested.txt"), "nested after\n")?;
482
483 restore_ghost_commit(&workspace, &ghost)?;
484
485 let root_after = std::fs::read_to_string(repo.join("root.txt"))?;
486 assert_eq!(root_after, "root after\n");
487 let nested_after = std::fs::read_to_string(workspace.join("nested.txt"))?;
488 assert_eq!(nested_after, "nested modified\n");
489 assert!(!workspace.join("code-rs").exists());
490
491 Ok(())
492 }
493
494 #[test]
495 fn restore_from_subdirectory_preserves_parent_vscode() -> Result<(), GitToolingError> {
497 let temp = tempfile::tempdir()?;
498 let repo = temp.path();
499 init_test_repo(repo);
500
501 let workspace = repo.join("code-rs");
502 std::fs::create_dir_all(&workspace)?;
503 std::fs::write(repo.join(".gitignore"), ".vscode/\n")?;
504 std::fs::write(workspace.join("tracked.txt"), "snapshot version\n")?;
505 run_git_in(repo, &["add", "."]);
506 run_git_in(
507 repo,
508 &[
509 "-c",
510 "user.name=Tester",
511 "-c",
512 "user.email=test@example.com",
513 "commit",
514 "-m",
515 "initial",
516 ],
517 );
518
519 std::fs::write(workspace.join("tracked.txt"), "snapshot delta\n")?;
520 let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(&workspace))?;
521
522 std::fs::write(workspace.join("tracked.txt"), "post-snapshot\n")?;
523 let vscode = repo.join(".vscode");
524 std::fs::create_dir_all(&vscode)?;
525 std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?;
526
527 restore_ghost_commit(&workspace, &ghost)?;
528
529 let tracked_after = std::fs::read_to_string(workspace.join("tracked.txt"))?;
530 assert_eq!(tracked_after, "snapshot delta\n");
531 assert!(vscode.join("settings.json").exists());
532 let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?;
533 assert_eq!(settings_after, "{\n \"after\": true\n}\n");
534
535 Ok(())
536 }
537
538 #[test]
539 fn restore_preserves_ignored_files() -> Result<(), GitToolingError> {
541 let temp = tempfile::tempdir()?;
542 let repo = temp.path();
543 init_test_repo(repo);
544
545 std::fs::write(repo.join(".gitignore"), ".vscode/\n")?;
546 std::fs::write(repo.join("tracked.txt"), "snapshot version\n")?;
547 let vscode = repo.join(".vscode");
548 std::fs::create_dir_all(&vscode)?;
549 std::fs::write(vscode.join("settings.json"), "{\n \"before\": true\n}\n")?;
550 run_git_in(repo, &["add", ".gitignore", "tracked.txt"]);
551 run_git_in(
552 repo,
553 &[
554 "-c",
555 "user.name=Tester",
556 "-c",
557 "user.email=test@example.com",
558 "commit",
559 "-m",
560 "initial",
561 ],
562 );
563
564 std::fs::write(repo.join("tracked.txt"), "snapshot delta\n")?;
565 let ghost = create_ghost_commit(&CreateGhostCommitOptions::new(repo))?;
566
567 std::fs::write(repo.join("tracked.txt"), "post-snapshot\n")?;
568 std::fs::write(vscode.join("settings.json"), "{\n \"after\": true\n}\n")?;
569 std::fs::write(repo.join("temp.txt"), "new file\n")?;
570
571 restore_ghost_commit(repo, &ghost)?;
572
573 let tracked_after = std::fs::read_to_string(repo.join("tracked.txt"))?;
574 assert_eq!(tracked_after, "snapshot delta\n");
575 assert!(vscode.join("settings.json").exists());
576 let settings_after = std::fs::read_to_string(vscode.join("settings.json"))?;
577 assert_eq!(settings_after, "{\n \"after\": true\n}\n");
578 assert!(repo.join("temp.txt").exists());
579
580 Ok(())
581 }
582}