Skip to main content

hanzo_git_tooling/
ghost_commits.rs

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
18/// Default commit message used for ghost commits when none is provided.
19const DEFAULT_COMMIT_MESSAGE: &str = "code snapshot";
20
21/// Options to control ghost commit creation.
22pub 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    /// Creates options scoped to the provided repository path.
32    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    /// Sets a custom commit message for the ghost commit.
43    pub fn message(mut self, message: &'a str) -> Self {
44        self.message = Some(message);
45        self
46    }
47
48    /// Overrides the parent commit for the ghost snapshot when provided.
49    pub fn parent(mut self, parent: &'a str) -> Self {
50        self.parent = Some(parent);
51        self
52    }
53
54    /// Registers a hook to run after the ghost commit is created.
55    pub fn post_commit_hook(mut self, hook: &'a dyn Fn()) -> Self {
56        self.post_commit_hook = Some(hook);
57        self
58    }
59
60    /// Supplies the entire force-include path list at once.
61    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    /// Adds a single path to the force-include list.
70    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
79/// Create a ghost commit capturing the current state of the repository's working tree.
80pub 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    // Retrieve commit ID.
145    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
158/// Restore the working tree to match the provided ghost commit.
159pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> {
160    restore_to_commit(repo_path, commit.id())
161}
162
163/// Restore the working tree to match the given commit ID.
164pub 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
188/// Returns the default author and committer identity for ghost commits.
189fn 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    /// Runs a git command in the test repository and asserts success.
218    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    /// Runs a git command and returns its trimmed stdout output.
228    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    /// Initializes a repository with consistent settings for cross-platform tests.
239    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    /// Verifies a ghost commit can be created and restored end to end.
273    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    /// Ensures ghost commits succeed in repositories without an existing HEAD.
338    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    /// Confirms custom messages are used when creating ghost commits.
366    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    /// Verifies the optional post-commit hook runs after snapshot creation.
396    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    /// Rejects force-included paths that escape the repository.
432    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    /// Restoring a ghost commit from a non-git directory fails.
444    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    /// Restoring from a subdirectory affects only that subdirectory.
452    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    /// Restoring from a subdirectory preserves ignored files in parent folders.
496    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    /// Restoring from the repository root keeps ignored files intact.
540    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}