Skip to main content

anodizer_core/git/
worktree.rs

1//! `git worktree` wrapper for hermetic per-run workspaces.
2//!
3//! The determinism harness uses this to obtain a clean copy of the
4//! workspace rooted at a specific commit, so `.gitignored` byproducts
5//! (`target/`, `dist/`, `node_modules/`, etc.) from prior runs cannot
6//! leak between builds.
7//!
8//! `Worktree::add` constructs the worktree (detached HEAD at the supplied
9//! commit). `Drop` is best-effort: it runs `git worktree remove --force`
10//! against the parent repo so the temporary tree is cleaned up even on
11//! panic. Failure to remove is surfaced via `tracing::warn!` with the
12//! captured stderr — we never panic during `Drop`, but silent swallowing
13//! left operators with no signal when the cleanup raced an I/O error.
14//! Operators can still run `git worktree prune` to reap the stale
15//! administrative entry after such a leak.
16
17use anyhow::{Context as _, Result};
18use std::path::{Path, PathBuf};
19use std::process::Command;
20
21pub struct Worktree {
22    repo_root: PathBuf,
23    path: PathBuf,
24}
25
26impl Worktree {
27    /// Create a new detached worktree at `path`, checked out at `commit`.
28    ///
29    /// `commit` may be any valid git revision (sha, ref name, `HEAD`).
30    /// The parent repository is `repo_root`; `git -C <repo_root>
31    /// worktree add --detach <path> <commit>` is invoked verbatim.
32    ///
33    /// On failure (path collision, locked worktree, invalid commit,
34    /// dirty index) the returned error includes the captured stderr
35    /// from git so the operator has an actionable detail rather than
36    /// an opaque "git worktree add failed".
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if `path` contains ASCII whitespace. The
41    /// determinism harness composes `path` into RUSTFLAGS via
42    /// `--remap-path-prefix=<path>=/anodize`, and RUSTFLAGS is a
43    /// space-delimited token list with no quoting mechanism — a path
44    /// containing whitespace would be parsed as multiple arguments by
45    /// rustc and either silently misremap or hard-fail the build. Reject
46    /// at construction so the operator sees a clear "rename the
47    /// scratch dir" message instead of an opaque rustc parse error
48    /// later.
49    pub fn add(repo_root: &Path, path: &Path, commit: &str) -> Result<Self> {
50        if path.to_string_lossy().chars().any(char::is_whitespace) {
51            anyhow::bail!(
52                "git worktree path {} contains whitespace; pick a scratch directory \
53                 without spaces or tabs (the determinism harness composes this path \
54                 into RUSTFLAGS via `--remap-path-prefix`, which is space-delimited \
55                 with no quoting support)",
56                path.display()
57            );
58        }
59        let out = Command::new("git")
60            .arg("-C")
61            .arg(repo_root)
62            .args(["worktree", "add", "--detach"])
63            .arg(path)
64            .arg(commit)
65            .output()
66            .with_context(|| format!("spawn 'git worktree add' for {}", path.display()))?;
67        if !out.status.success() {
68            anyhow::bail!(
69                "git worktree add failed (exit {:?}) for {}: {}",
70                out.status.code(),
71                path.display(),
72                String::from_utf8_lossy(&out.stderr).trim()
73            );
74        }
75        Ok(Self {
76            repo_root: repo_root.to_path_buf(),
77            path: path.to_path_buf(),
78        })
79    }
80
81    /// Absolute path to the worktree on disk.
82    pub fn path(&self) -> &Path {
83        &self.path
84    }
85}
86
87impl Drop for Worktree {
88    fn drop(&mut self) {
89        // Best-effort: never panic in Drop. If `git worktree remove`
90        // fails (e.g. the worktree was already removed manually, or the
91        // path was deleted externally), surface the failure via
92        // `tracing::warn!` with the captured stderr so the operator can
93        // run `git worktree prune` in the parent repo to reap the
94        // stale administrative entry. Silent swallowing (the previous
95        // behavior) left operators with no signal that a leak had
96        // happened.
97        match Command::new("git")
98            .arg("-C")
99            .arg(&self.repo_root)
100            .args(["worktree", "remove", "--force"])
101            .arg(&self.path)
102            .output()
103        {
104            Ok(out) if out.status.success() => {}
105            Ok(out) => {
106                tracing::warn!(
107                    "git worktree remove '{}' failed during Drop (exit {:?}: {}); \
108                     run `git worktree prune` in the parent repo to reap the stale entry",
109                    self.path.display(),
110                    out.status.code(),
111                    String::from_utf8_lossy(&out.stderr).trim(),
112                );
113            }
114            Err(err) => {
115                tracing::warn!(
116                    "failed to spawn 'git worktree remove' for '{}' during Drop ({err}); \
117                     run `git worktree prune` in the parent repo to reap the stale entry",
118                    self.path.display(),
119                );
120            }
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use std::process::Command;
129    use tempfile::TempDir;
130
131    fn init_repo() -> TempDir {
132        let dir = tempfile::tempdir().unwrap();
133        anodizer_core::test_helpers::output_with_spawn_retry(
134            || {
135                let mut cmd = Command::new("git");
136                cmd.arg("-C").arg(dir.path()).arg("init");
137                cmd
138            },
139            "git",
140        );
141        anodizer_core::test_helpers::output_with_spawn_retry(
142            || {
143                let mut cmd = Command::new("git");
144                cmd.arg("-C")
145                    .arg(dir.path())
146                    .args(["config", "user.email", "test@example.com"]);
147                cmd
148            },
149            "git",
150        );
151        anodizer_core::test_helpers::output_with_spawn_retry(
152            || {
153                let mut cmd = Command::new("git");
154                cmd.arg("-C")
155                    .arg(dir.path())
156                    .args(["config", "user.name", "test"]);
157                cmd
158            },
159            "git",
160        );
161        std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
162        anodizer_core::test_helpers::output_with_spawn_retry(
163            || {
164                let mut cmd = Command::new("git");
165                cmd.arg("-C").arg(dir.path()).args(["add", "a.txt"]);
166                cmd
167            },
168            "git",
169        );
170        anodizer_core::test_helpers::output_with_spawn_retry(
171            || {
172                let mut cmd = Command::new("git");
173                cmd.arg("-C").arg(dir.path()).args(["commit", "-m", "init"]);
174                cmd
175            },
176            "git",
177        );
178        dir
179    }
180
181    #[test]
182    fn worktree_add_creates_directory_at_given_path() {
183        let repo = init_repo();
184        let wt_dir = tempfile::tempdir().unwrap();
185        let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt1"), "HEAD").unwrap();
186        assert!(wt.path().exists());
187        assert!(wt.path().join("a.txt").exists());
188    }
189
190    #[test]
191    fn worktree_drop_removes_directory_and_prunes() {
192        let repo = init_repo();
193        let wt_dir = tempfile::tempdir().unwrap();
194        let path: PathBuf;
195        {
196            let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt2"), "HEAD").unwrap();
197            path = wt.path().to_path_buf();
198            assert!(path.exists());
199        } // dropped here
200        // After drop, the path should be gone.
201        assert!(!path.exists(), "worktree path persisted after Drop");
202    }
203
204    #[test]
205    fn worktree_add_for_explicit_commit_checks_out_that_commit() {
206        let repo = init_repo();
207        // Get HEAD commit hash
208        let out = anodizer_core::test_helpers::output_with_spawn_retry(
209            || {
210                let mut cmd = Command::new("git");
211                cmd.arg("-C").arg(repo.path()).args(["rev-parse", "HEAD"]);
212                cmd
213            },
214            "git",
215        );
216        let head_hash = String::from_utf8(out.stdout).unwrap().trim().to_string();
217        let wt_dir = tempfile::tempdir().unwrap();
218        let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt3"), &head_hash).unwrap();
219        // Verify the worktree's HEAD matches.
220        let out = anodizer_core::test_helpers::output_with_spawn_retry(
221            || {
222                let mut cmd = Command::new("git");
223                cmd.arg("-C").arg(wt.path()).args(["rev-parse", "HEAD"]);
224                cmd
225            },
226            "git",
227        );
228        let wt_head = String::from_utf8(out.stdout).unwrap().trim().to_string();
229        assert_eq!(wt_head, head_hash);
230    }
231
232    #[test]
233    fn worktree_concurrent_adds_do_not_collide() {
234        let repo = init_repo();
235        let wt_dir = tempfile::tempdir().unwrap();
236        let wt1 = Worktree::add(repo.path(), &wt_dir.path().join("wt-a"), "HEAD").unwrap();
237        let wt2 = Worktree::add(repo.path(), &wt_dir.path().join("wt-b"), "HEAD").unwrap();
238        assert_ne!(wt1.path(), wt2.path());
239        assert!(wt1.path().exists());
240        assert!(wt2.path().exists());
241    }
242
243    #[test]
244    fn worktree_add_surfaces_stderr_on_failure() {
245        // Invalid commit-ish: git emits a `fatal:` stderr line. The
246        // returned error must carry that detail through, not the
247        // previous opaque "git worktree add failed" string.
248        let repo = init_repo();
249        let wt_dir = tempfile::tempdir().unwrap();
250        let result = Worktree::add(
251            repo.path(),
252            &wt_dir.path().join("wt-bad"),
253            "this-ref-does-not-exist-anywhere",
254        );
255        let err = match result {
256            Err(e) => e,
257            Ok(_) => panic!("invalid commit must error"),
258        };
259        let msg = err.to_string();
260        assert!(
261            msg.contains("fatal:")
262                || msg.contains("invalid reference")
263                || msg.contains("not a valid"),
264            "error must include captured git stderr; got: {msg}",
265        );
266        assert!(
267            msg.contains("git worktree add failed"),
268            "error must still identify the failing operation; got: {msg}",
269        );
270    }
271
272    #[test]
273    fn worktree_add_rejects_whitespace_in_path() {
274        // Whitespace in the worktree path breaks RUSTFLAGS injection
275        // downstream (--remap-path-prefix=<path>=...), so Worktree::add
276        // must reject the path before git ever runs.
277        let repo = init_repo();
278        let wt_dir = tempfile::tempdir().unwrap();
279        let bad_path = wt_dir.path().join("wt with spaces");
280        let err = match Worktree::add(repo.path(), &bad_path, "HEAD") {
281            Err(e) => e,
282            Ok(_) => panic!("whitespace path must be rejected"),
283        };
284        let msg = err.to_string();
285        assert!(
286            msg.contains("whitespace"),
287            "error must explain the whitespace constraint; got: {msg}"
288        );
289        assert!(
290            msg.contains("RUSTFLAGS"),
291            "error must point at the downstream RUSTFLAGS reason; got: {msg}"
292        );
293    }
294
295    #[test]
296    fn worktree_drop_does_not_panic_when_path_already_removed() {
297        // Simulate an external actor (operator, racing CI cleanup)
298        // removing the worktree directory out from under us. Drop must
299        // not panic; the failure is surfaced via tracing::warn! which
300        // the test harness does not assert on directly — we only
301        // assert the absence of a panic.
302        let repo = init_repo();
303        let wt_dir = tempfile::tempdir().unwrap();
304        let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt-vanish"), "HEAD").unwrap();
305        let path = wt.path().to_path_buf();
306        std::fs::remove_dir_all(&path).expect("manual remove should succeed");
307        assert!(!path.exists());
308        // Drop here — must not panic even though the path is gone.
309        drop(wt);
310    }
311}