anodizer_core/git/
worktree.rs1use 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 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 pub fn path(&self) -> &Path {
83 &self.path
84 }
85}
86
87impl Drop for Worktree {
88 fn drop(&mut self) {
89 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 } 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 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 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 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 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 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(wt);
310 }
311}