Skip to main content

vcs_git/
blocking.rs

1//! Synchronous, best-effort helpers for Drop and other non-async contexts.
2
3use std::io;
4use std::path::Path;
5use std::process::Command;
6
7/// The repository redirectors normally removed by the async client's
8/// `managed_client!` profile. This direct `std::process` cleanup path cannot
9/// inherit them: a hook-provided `GIT_DIR` could otherwise force destructive
10/// worktree removal against another repository.
11const REPO_REDIRECTORS: &[&str] = &[
12    "GIT_DIR",
13    "GIT_WORK_TREE",
14    "GIT_INDEX_FILE",
15    "GIT_COMMON_DIR",
16    "GIT_OBJECT_DIRECTORY",
17    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
18    "GIT_NAMESPACE",
19];
20
21fn scrub_repo_redirectors(command: &mut Command) {
22    for name in REPO_REDIRECTORS {
23        command.env_remove(name);
24    }
25}
26
27/// Remove a worktree synchronously (`git worktree remove [--force] <path>`);
28/// see [`WorktreeRemove`](super::WorktreeRemove).
29pub fn worktree_remove(dir: &Path, spec: super::WorktreeRemove) -> std::io::Result<()> {
30    // Guard before spawning, matching the async twin's `reject_flag_like_path`
31    // — a leading-`-` path would otherwise be misparsed as a flag by `git
32    // worktree remove`. This helper has no async runtime to reuse the async
33    // client's guard through, so it re-derives an equivalent `io::Error`
34    // locally, keeping this function's `std::io::Result<()>` signature.
35    super::reject_flag_like_path("worktree path", &spec.path)
36        .map_err(|err| io::Error::other(err.to_string()))?;
37    let mut cmd = Command::new(super::BINARY);
38    cmd.current_dir(dir).args(["worktree", "remove"]);
39    scrub_repo_redirectors(&mut cmd);
40    if spec.force {
41        cmd.arg("--force");
42    }
43    cmd.arg(&spec.path);
44    let output = cmd.output()?;
45    if output.status.success() {
46        Ok(())
47    } else {
48        let stderr = String::from_utf8_lossy(&output.stderr);
49        Err(std::io::Error::other(format!(
50            "`git worktree remove` exited with {}: {}",
51            output.status,
52            stderr.trim(),
53        )))
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn blocking_cleanup_scrubs_every_repo_redirector() {
63        let mut command = Command::new("git");
64        scrub_repo_redirectors(&mut command);
65        let removed: std::collections::BTreeSet<_> = command
66            .get_envs()
67            .filter_map(|(name, value)| value.is_none().then_some(name.to_str().unwrap()))
68            .collect();
69        assert_eq!(
70            removed,
71            REPO_REDIRECTORS.iter().copied().collect(),
72            "every redirector must be removed regardless of Command's ordering"
73        );
74    }
75
76    // A flag-shaped path is refused before `cmd.output()` spawns anything — the
77    // guard's own message (not a `git`-produced "exited with"/spawn-failure
78    // message) proves rejection happened up front, not via a real `git` run.
79    #[test]
80    fn worktree_remove_rejects_flag_like_path_before_spawn() {
81        let err = worktree_remove(
82            Path::new("/repo"),
83            super::super::WorktreeRemove::new("--force"),
84        )
85        .expect_err("a flag-like worktree path must be refused");
86        let message = err.to_string();
87        assert!(
88            message.contains("would be parsed as a flag"),
89            "expected the guard's message, got: {message}"
90        );
91    }
92
93    #[test]
94    fn worktree_remove_rejects_empty_path_before_spawn() {
95        let err = worktree_remove(Path::new("/repo"), super::super::WorktreeRemove::new("  "))
96            .expect_err("an empty worktree path must be refused");
97        let message = err.to_string();
98        assert!(
99            message.contains("would be parsed as a flag"),
100            "expected the guard's message, got: {message}"
101        );
102    }
103
104    // A non-UTF-8 path (valid on Unix) must not panic anywhere in the guard —
105    // it may pass through to `cmd.output()` (which then fails to find a `git`
106    // worktree at a nonsense path) or be refused, but never abort the check.
107    #[cfg(unix)]
108    #[test]
109    fn worktree_remove_does_not_panic_on_non_utf8_path() {
110        use std::ffi::OsString;
111        use std::os::unix::ffi::OsStringExt;
112
113        let bytes = vec![0xFFu8, 0xFEu8, b'x'];
114        let path = std::path::PathBuf::from(OsString::from_vec(bytes));
115        // Must return, not panic — the outcome itself (Ok/Err from a missing
116        // `git`/repo) is not the point of this test.
117        let _ = worktree_remove(Path::new("/repo"), super::super::WorktreeRemove::new(path));
118    }
119
120    // This raw `Command` helper has no ProcessRunner seam. Keep the end-to-end
121    // assertion ignored, like the crate's other real-git tests, while comparing
122    // against the exact stderr emitted by the installed binary.
123    #[test]
124    #[ignore = "requires the git binary"]
125    fn worktree_remove_failure_includes_captured_stderr() {
126        let temp = vcs_testkit::TempDir::new("blocking-worktree-remove-failure");
127        let dir = temp.path();
128        let path = "missing-worktree";
129        let mut command = Command::new(super::super::BINARY);
130        command.current_dir(dir).args(["worktree", "remove", path]);
131        scrub_repo_redirectors(&mut command);
132        let expected = command.output().expect("run git directly");
133        assert!(
134            !expected.status.success(),
135            "the direct git command must fail for this diagnostic test"
136        );
137        let expected_stderr = String::from_utf8_lossy(&expected.stderr).trim().to_owned();
138        assert!(
139            !expected_stderr.is_empty(),
140            "the failing git command must emit stderr"
141        );
142
143        let err = worktree_remove(dir, super::super::WorktreeRemove::new(path))
144            .expect_err("remove must fail");
145        assert!(
146            err.to_string().contains(&expected_stderr),
147            "error must retain git stderr; expected {expected_stderr:?}, got: {err}"
148        );
149    }
150}