use anyhow::{Context as _, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct Worktree {
repo_root: PathBuf,
path: PathBuf,
}
impl Worktree {
pub fn add(repo_root: &Path, path: &Path, commit: &str) -> Result<Self> {
if path.to_string_lossy().chars().any(char::is_whitespace) {
anyhow::bail!(
"git worktree path {} contains whitespace; pick a scratch directory \
without spaces or tabs (the determinism harness composes this path \
into RUSTFLAGS via `--remap-path-prefix`, which is space-delimited \
with no quoting support)",
path.display()
);
}
let out = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["worktree", "add", "--detach"])
.arg(path)
.arg(commit)
.output()
.with_context(|| format!("spawn 'git worktree add' for {}", path.display()))?;
if !out.status.success() {
anyhow::bail!(
"git worktree add failed (exit {:?}) for {}: {}",
out.status.code(),
path.display(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(Self {
repo_root: repo_root.to_path_buf(),
path: path.to_path_buf(),
})
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for Worktree {
fn drop(&mut self) {
match Command::new("git")
.arg("-C")
.arg(&self.repo_root)
.args(["worktree", "remove", "--force"])
.arg(&self.path)
.output()
{
Ok(out) if out.status.success() => {}
Ok(out) => {
tracing::warn!(
path = %self.path.display(),
exit = ?out.status.code(),
stderr = %String::from_utf8_lossy(&out.stderr).trim(),
"git worktree remove failed during Drop; \
run `git worktree prune` in the parent repo to reap the stale entry"
);
}
Err(err) => {
tracing::warn!(
path = %self.path.display(),
error = %err,
"failed to spawn 'git worktree remove' during Drop; \
run `git worktree prune` in the parent repo to reap the stale entry"
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn init_repo() -> TempDir {
let dir = tempfile::tempdir().unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.arg("init")
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.email", "test@example.com"])
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.name", "test"])
.output()
.unwrap();
std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["add", "a.txt"])
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["commit", "-m", "init"])
.output()
.unwrap();
dir
}
#[test]
fn worktree_add_creates_directory_at_given_path() {
let repo = init_repo();
let wt_dir = tempfile::tempdir().unwrap();
let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt1"), "HEAD").unwrap();
assert!(wt.path().exists());
assert!(wt.path().join("a.txt").exists());
}
#[test]
fn worktree_drop_removes_directory_and_prunes() {
let repo = init_repo();
let wt_dir = tempfile::tempdir().unwrap();
let path: PathBuf;
{
let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt2"), "HEAD").unwrap();
path = wt.path().to_path_buf();
assert!(path.exists());
} assert!(!path.exists(), "worktree path persisted after Drop");
}
#[test]
fn worktree_add_for_explicit_commit_checks_out_that_commit() {
let repo = init_repo();
let out = Command::new("git")
.arg("-C")
.arg(repo.path())
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let head_hash = String::from_utf8(out.stdout).unwrap().trim().to_string();
let wt_dir = tempfile::tempdir().unwrap();
let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt3"), &head_hash).unwrap();
let out = Command::new("git")
.arg("-C")
.arg(wt.path())
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let wt_head = String::from_utf8(out.stdout).unwrap().trim().to_string();
assert_eq!(wt_head, head_hash);
}
#[test]
fn worktree_concurrent_adds_do_not_collide() {
let repo = init_repo();
let wt_dir = tempfile::tempdir().unwrap();
let wt1 = Worktree::add(repo.path(), &wt_dir.path().join("wt-a"), "HEAD").unwrap();
let wt2 = Worktree::add(repo.path(), &wt_dir.path().join("wt-b"), "HEAD").unwrap();
assert_ne!(wt1.path(), wt2.path());
assert!(wt1.path().exists());
assert!(wt2.path().exists());
}
#[test]
fn worktree_add_surfaces_stderr_on_failure() {
let repo = init_repo();
let wt_dir = tempfile::tempdir().unwrap();
let result = Worktree::add(
repo.path(),
&wt_dir.path().join("wt-bad"),
"this-ref-does-not-exist-anywhere",
);
let err = match result {
Err(e) => e,
Ok(_) => panic!("invalid commit must error"),
};
let msg = err.to_string();
assert!(
msg.contains("fatal:")
|| msg.contains("invalid reference")
|| msg.contains("not a valid"),
"error must include captured git stderr; got: {msg}",
);
assert!(
msg.contains("git worktree add failed"),
"error must still identify the failing operation; got: {msg}",
);
}
#[test]
fn worktree_add_rejects_whitespace_in_path() {
let repo = init_repo();
let wt_dir = tempfile::tempdir().unwrap();
let bad_path = wt_dir.path().join("wt with spaces");
let err = match Worktree::add(repo.path(), &bad_path, "HEAD") {
Err(e) => e,
Ok(_) => panic!("whitespace path must be rejected"),
};
let msg = err.to_string();
assert!(
msg.contains("whitespace"),
"error must explain the whitespace constraint; got: {msg}"
);
assert!(
msg.contains("RUSTFLAGS"),
"error must point at the downstream RUSTFLAGS reason; got: {msg}"
);
}
#[test]
fn worktree_drop_does_not_panic_when_path_already_removed() {
let repo = init_repo();
let wt_dir = tempfile::tempdir().unwrap();
let wt = Worktree::add(repo.path(), &wt_dir.path().join("wt-vanish"), "HEAD").unwrap();
let path = wt.path().to_path_buf();
std::fs::remove_dir_all(&path).expect("manual remove should succeed");
assert!(!path.exists());
drop(wt);
}
}