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 path = %self.path.display(),
108 exit = ?out.status.code(),
109 stderr = %String::from_utf8_lossy(&out.stderr).trim(),
110 "git worktree remove failed during Drop; \
111 run `git worktree prune` in the parent repo to reap the stale entry"
112 );
113 }
114 Err(err) => {
115 tracing::warn!(
116 path = %self.path.display(),
117 error = %err,
118 "failed to spawn 'git worktree remove' during Drop; \
119 run `git worktree prune` in the parent repo to reap the stale entry"
120 );
121 }
122 }
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use std::process::Command;
130 use tempfile::TempDir;
131
132 fn init_repo() -> TempDir {
133 let dir = tempfile::tempdir().unwrap();
134 Command::new("git")
135 .arg("-C")
136 .arg(dir.path())
137 .arg("init")
138 .output()
139 .unwrap();
140 Command::new("git")
141 .arg("-C")
142 .arg(dir.path())
143 .args(["config", "user.email", "test@example.com"])
144 .output()
145 .unwrap();
146 Command::new("git")
147 .arg("-C")
148 .arg(dir.path())
149 .args(["config", "user.name", "test"])
150 .output()
151 .unwrap();
152 std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
153 Command::new("git")
154 .arg("-C")
155 .arg(dir.path())
156 .args(["add", "a.txt"])
157 .output()
158 .unwrap();
159 Command::new("git")
160 .arg("-C")
161 .arg(dir.path())
162 .args(["commit", "-m", "init"])
163 .output()
164 .unwrap();
165 dir
166 }
167
168 #[test]
169 fn worktree_add_creates_directory_at_given_path() {
170 let repo = init_repo();
171 let wt_dir = tempfile::tempdir().unwrap();
172 let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt1"), "HEAD").unwrap();
173 assert!(wt.path().exists());
174 assert!(wt.path().join("a.txt").exists());
175 }
176
177 #[test]
178 fn worktree_drop_removes_directory_and_prunes() {
179 let repo = init_repo();
180 let wt_dir = tempfile::tempdir().unwrap();
181 let path: PathBuf;
182 {
183 let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt2"), "HEAD").unwrap();
184 path = wt.path().to_path_buf();
185 assert!(path.exists());
186 } assert!(!path.exists(), "worktree path persisted after Drop");
189 }
190
191 #[test]
192 fn worktree_add_for_explicit_commit_checks_out_that_commit() {
193 let repo = init_repo();
194 let out = Command::new("git")
196 .arg("-C")
197 .arg(repo.path())
198 .args(["rev-parse", "HEAD"])
199 .output()
200 .unwrap();
201 let head_hash = String::from_utf8(out.stdout).unwrap().trim().to_string();
202 let wt_dir = tempfile::tempdir().unwrap();
203 let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt3"), &head_hash).unwrap();
204 let out = Command::new("git")
206 .arg("-C")
207 .arg(wt.path())
208 .args(["rev-parse", "HEAD"])
209 .output()
210 .unwrap();
211 let wt_head = String::from_utf8(out.stdout).unwrap().trim().to_string();
212 assert_eq!(wt_head, head_hash);
213 }
214
215 #[test]
216 fn worktree_concurrent_adds_do_not_collide() {
217 let repo = init_repo();
218 let wt_dir = tempfile::tempdir().unwrap();
219 let wt1 = Worktree::add(repo.path(), &wt_dir.path().join("wt-a"), "HEAD").unwrap();
220 let wt2 = Worktree::add(repo.path(), &wt_dir.path().join("wt-b"), "HEAD").unwrap();
221 assert_ne!(wt1.path(), wt2.path());
222 assert!(wt1.path().exists());
223 assert!(wt2.path().exists());
224 }
225
226 #[test]
227 fn worktree_add_surfaces_stderr_on_failure() {
228 let repo = init_repo();
232 let wt_dir = tempfile::tempdir().unwrap();
233 let result = Worktree::add(
234 repo.path(),
235 &wt_dir.path().join("wt-bad"),
236 "this-ref-does-not-exist-anywhere",
237 );
238 let err = match result {
239 Err(e) => e,
240 Ok(_) => panic!("invalid commit must error"),
241 };
242 let msg = err.to_string();
243 assert!(
244 msg.contains("fatal:")
245 || msg.contains("invalid reference")
246 || msg.contains("not a valid"),
247 "error must include captured git stderr; got: {msg}",
248 );
249 assert!(
250 msg.contains("git worktree add failed"),
251 "error must still identify the failing operation; got: {msg}",
252 );
253 }
254
255 #[test]
256 fn worktree_add_rejects_whitespace_in_path() {
257 let repo = init_repo();
261 let wt_dir = tempfile::tempdir().unwrap();
262 let bad_path = wt_dir.path().join("wt with spaces");
263 let err = match Worktree::add(repo.path(), &bad_path, "HEAD") {
264 Err(e) => e,
265 Ok(_) => panic!("whitespace path must be rejected"),
266 };
267 let msg = err.to_string();
268 assert!(
269 msg.contains("whitespace"),
270 "error must explain the whitespace constraint; got: {msg}"
271 );
272 assert!(
273 msg.contains("RUSTFLAGS"),
274 "error must point at the downstream RUSTFLAGS reason; got: {msg}"
275 );
276 }
277
278 #[test]
279 fn worktree_drop_does_not_panic_when_path_already_removed() {
280 let repo = init_repo();
286 let wt_dir = tempfile::tempdir().unwrap();
287 let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt-vanish"), "HEAD").unwrap();
288 let path = wt.path().to_path_buf();
289 std::fs::remove_dir_all(&path).expect("manual remove should succeed");
290 assert!(!path.exists());
291 drop(wt);
293 }
294}