Skip to main content

wt/
hooks.rs

1//! Post-create and pre-remove hooks (spec §8). Hooks run via `sh -c` (Unix) or
2//! `cmd /C` (Windows) with the new worktree as the working directory and the
3//! `WT_*` variables in the environment.
4//!
5//! Execution policy: a failed `post_create` is a non-fatal warning; a failed
6//! `pre_remove` aborts the removal unless `--force` (then it is a warning).
7
8use std::path::PathBuf;
9use std::process::Command;
10
11use crate::cx::Cx;
12use crate::error::{Error, Result};
13
14/// The context passed to a hook as `WT_*` environment variables.
15#[derive(Debug, Clone)]
16pub struct HookContext {
17    /// `WT_WORKTREE_PATH` and the working directory for the hook.
18    pub worktree_path: PathBuf,
19    /// `WT_BRANCH`.
20    pub branch: String,
21    /// `WT_REPO_ROOT`.
22    pub repo_root: PathBuf,
23    /// `WT_BASE_REF` (set only when known).
24    pub base_ref: Option<String>,
25    /// `WT_PR_NUMBER` (set only for PR-originated worktrees).
26    pub pr_number: Option<u64>,
27}
28
29/// Runs hook commands. Abstracted so tests can inject a fake.
30pub trait HookRunner {
31    /// Runs `command` with the hook context, returning its exit code.
32    fn run(&self, command: &str, ctx: &HookContext) -> Result<i32>;
33}
34
35/// Builds the shell [`Command`] for a hook: `sh -c` (Unix) / `cmd /C` (Windows),
36/// run in the worktree directory with the `WT_*` variables set. Shared by both
37/// runners so they differ only in how the child's stdio is handled.
38fn build_hook_command(command: &str, ctx: &HookContext) -> Command {
39    let mut cmd = if cfg!(windows) {
40        let mut c = Command::new("cmd");
41        c.args(["/C", command]);
42        c
43    } else {
44        let mut c = Command::new("sh");
45        c.args(["-c", command]);
46        c
47    };
48    cmd.current_dir(&ctx.worktree_path);
49    cmd.env("WT_WORKTREE_PATH", &ctx.worktree_path);
50    cmd.env("WT_BRANCH", &ctx.branch);
51    cmd.env("WT_REPO_ROOT", &ctx.repo_root);
52    if let Some(base) = &ctx.base_ref {
53        cmd.env("WT_BASE_REF", base);
54    }
55    if let Some(pr) = ctx.pr_number {
56        cmd.env("WT_PR_NUMBER", pr.to_string());
57    }
58    cmd
59}
60
61/// The production [`HookRunner`] that spawns a shell and lets the hook inherit
62/// the terminal's stdio (so output is visible to the user on the CLI paths,
63/// which suspend the TUI first).
64#[derive(Debug, Clone, Copy, Default)]
65pub struct RealHookRunner;
66
67impl HookRunner for RealHookRunner {
68    fn run(&self, command: &str, ctx: &HookContext) -> Result<i32> {
69        let status = build_hook_command(command, ctx)
70            .status()
71            .map_err(|e| Error::operation(format!("failed to run hook: {e}")))?;
72        Ok(status.code().unwrap_or(-1))
73    }
74}
75
76/// A [`HookRunner`] that captures the hook's stdout/stderr instead of inheriting
77/// the terminal. Used by the TUI's background jobs (issue #46), which keep the
78/// alternate screen up and animate a spinner — inherited hook output would
79/// otherwise paint over the rendered UI. Behaviorally identical to
80/// [`RealHookRunner`] except the captured output is discarded.
81#[derive(Debug, Clone, Copy, Default)]
82pub struct CapturingHookRunner;
83
84impl HookRunner for CapturingHookRunner {
85    fn run(&self, command: &str, ctx: &HookContext) -> Result<i32> {
86        let output = build_hook_command(command, ctx)
87            .output()
88            .map_err(|e| Error::operation(format!("failed to run hook: {e}")))?;
89        Ok(output.status.code().unwrap_or(-1))
90    }
91}
92
93/// Runs the `post_create` hook (spec §8). A non-zero exit (or run failure) is a
94/// non-fatal warning. `no_hooks` or an absent command is a no-op.
95pub fn run_post_create(
96    runner: &dyn HookRunner,
97    cx: &mut Cx,
98    command: Option<&str>,
99    ctx: &HookContext,
100    no_hooks: bool,
101) -> Result<()> {
102    if no_hooks {
103        return Ok(());
104    }
105    let Some(command) = command else {
106        return Ok(());
107    };
108    match runner.run(command, ctx) {
109        Ok(0) => Ok(()),
110        Ok(code) => {
111            cx.err.line(&format!(
112                "warning: post_create hook exited with status {code}"
113            ))?;
114            Ok(())
115        }
116        Err(e) => {
117            cx.err
118                .line(&format!("warning: post_create hook failed: {e}"))?;
119            Ok(())
120        }
121    }
122}
123
124/// Runs the `pre_remove` hook (spec §8). A non-zero exit aborts the removal
125/// unless `force` is set, in which case it is reported as a warning and removal
126/// proceeds. `no_hooks` or an absent command is a no-op.
127pub fn run_pre_remove(
128    runner: &dyn HookRunner,
129    cx: &mut Cx,
130    command: Option<&str>,
131    ctx: &HookContext,
132    no_hooks: bool,
133    force: bool,
134) -> Result<()> {
135    if no_hooks {
136        return Ok(());
137    }
138    let Some(command) = command else {
139        return Ok(());
140    };
141    match runner.run(command, ctx) {
142        Ok(0) => Ok(()),
143        Ok(code) if force => {
144            cx.err.line(&format!(
145                "warning: pre_remove hook exited with status {code}; proceeding due to --force"
146            ))?;
147            Ok(())
148        }
149        Ok(code) => Err(Error::operation(format!(
150            "pre_remove hook exited with status {code}; aborting (use --force to override)"
151        ))),
152        Err(e) if force => {
153            cx.err.line(&format!(
154                "warning: pre_remove hook failed: {e}; proceeding due to --force"
155            ))?;
156            Ok(())
157        }
158        Err(e) => Err(e),
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::sync::Mutex;
166
167    fn ctx(dir: &std::path::Path) -> HookContext {
168        HookContext {
169            worktree_path: dir.to_path_buf(),
170            branch: "feature/x".into(),
171            repo_root: dir.to_path_buf(),
172            base_ref: Some("main".into()),
173            pr_number: None,
174        }
175    }
176
177    /// A fake runner returning a fixed code and recording the command.
178    struct FakeRunner {
179        code: i32,
180        last: Mutex<Option<String>>,
181    }
182    impl HookRunner for FakeRunner {
183        fn run(&self, command: &str, _ctx: &HookContext) -> Result<i32> {
184            *self.last.lock().unwrap() = Some(command.to_string());
185            Ok(self.code)
186        }
187    }
188
189    #[test]
190    fn real_runner_sets_wt_env_and_returns_code() {
191        let dir = tempfile::tempdir().unwrap();
192        // post_create-style: write the environment to a file.
193        let code = RealHookRunner
194            .run("env | grep '^WT_' > wt_env.txt", &ctx(dir.path()))
195            .unwrap();
196        assert_eq!(code, 0);
197        let env = std::fs::read_to_string(dir.path().join("wt_env.txt")).unwrap();
198        assert!(env.contains("WT_BRANCH=feature/x"));
199        assert!(env.contains("WT_REPO_ROOT="));
200        assert!(env.contains("WT_BASE_REF=main"));
201        assert!(env.contains("WT_WORKTREE_PATH="));
202        // WT_PR_NUMBER is unset when there is no PR.
203        assert!(!env.contains("WT_PR_NUMBER"));
204    }
205
206    #[test]
207    fn capturing_runner_sets_wt_env_and_returns_code() {
208        let dir = tempfile::tempdir().unwrap();
209        let code = CapturingHookRunner
210            .run("env | grep '^WT_' > wt_env.txt", &ctx(dir.path()))
211            .unwrap();
212        assert_eq!(code, 0);
213        let env = std::fs::read_to_string(dir.path().join("wt_env.txt")).unwrap();
214        assert!(env.contains("WT_BRANCH=feature/x"));
215        assert!(env.contains("WT_REPO_ROOT="));
216        assert!(env.contains("WT_BASE_REF=main"));
217        assert!(env.contains("WT_WORKTREE_PATH="));
218    }
219
220    #[test]
221    fn capturing_runner_captures_output_and_propagates_exit() {
222        let dir = tempfile::tempdir().unwrap();
223        // The hook writes to stdout (captured, not inherited) and exits non-zero;
224        // the captured output is discarded but the exit code is returned.
225        let code = CapturingHookRunner
226            .run("echo noise; exit 4", &ctx(dir.path()))
227            .unwrap();
228        assert_eq!(code, 4);
229    }
230
231    #[test]
232    fn real_runner_sets_pr_number_when_present() {
233        let dir = tempfile::tempdir().unwrap();
234        let mut c = ctx(dir.path());
235        c.pr_number = Some(123);
236        RealHookRunner
237            .run("printenv WT_PR_NUMBER > pr.txt", &c)
238            .unwrap();
239        assert_eq!(
240            std::fs::read_to_string(dir.path().join("pr.txt"))
241                .unwrap()
242                .trim(),
243            "123"
244        );
245    }
246
247    #[test]
248    fn real_runner_propagates_nonzero_exit() {
249        let dir = tempfile::tempdir().unwrap();
250        assert_eq!(RealHookRunner.run("exit 3", &ctx(dir.path())).unwrap(), 3);
251    }
252
253    #[test]
254    fn post_create_failure_is_a_warning() {
255        let dir = tempfile::tempdir().unwrap();
256        let runner = FakeRunner {
257            code: 1,
258            last: Mutex::new(None),
259        };
260        let mut t = crate::testutil::test_cx(&[], "/tmp");
261        run_post_create(
262            &runner,
263            &mut t.cx,
264            Some("do-thing"),
265            &ctx(dir.path()),
266            false,
267        )
268        .unwrap();
269        assert!(
270            t.err
271                .contents()
272                .contains("warning: post_create hook exited with status 1")
273        );
274    }
275
276    #[test]
277    fn post_create_skipped_when_no_hooks_or_absent() {
278        let dir = tempfile::tempdir().unwrap();
279        let runner = FakeRunner {
280            code: 1,
281            last: Mutex::new(None),
282        };
283        let mut t = crate::testutil::test_cx(&[], "/tmp");
284        run_post_create(&runner, &mut t.cx, Some("x"), &ctx(dir.path()), true).unwrap();
285        run_post_create(&runner, &mut t.cx, None, &ctx(dir.path()), false).unwrap();
286        assert!(runner.last.lock().unwrap().is_none());
287        assert!(t.err.contents().is_empty());
288    }
289
290    #[test]
291    fn pre_remove_failure_aborts_without_force() {
292        let dir = tempfile::tempdir().unwrap();
293        let runner = FakeRunner {
294            code: 2,
295            last: Mutex::new(None),
296        };
297        let mut t = crate::testutil::test_cx(&[], "/tmp");
298        let err = run_pre_remove(
299            &runner,
300            &mut t.cx,
301            Some("guard"),
302            &ctx(dir.path()),
303            false,
304            false,
305        )
306        .unwrap_err();
307        assert_eq!(err.exit_code(), 1);
308    }
309
310    #[test]
311    fn pre_remove_failure_warns_and_proceeds_with_force() {
312        let dir = tempfile::tempdir().unwrap();
313        let runner = FakeRunner {
314            code: 2,
315            last: Mutex::new(None),
316        };
317        let mut t = crate::testutil::test_cx(&[], "/tmp");
318        run_pre_remove(
319            &runner,
320            &mut t.cx,
321            Some("guard"),
322            &ctx(dir.path()),
323            false,
324            true,
325        )
326        .unwrap();
327        assert!(t.err.contents().contains("proceeding due to --force"));
328    }
329
330    #[test]
331    fn pre_remove_success_proceeds() {
332        let dir = tempfile::tempdir().unwrap();
333        let runner = FakeRunner {
334            code: 0,
335            last: Mutex::new(None),
336        };
337        let mut t = crate::testutil::test_cx(&[], "/tmp");
338        run_pre_remove(
339            &runner,
340            &mut t.cx,
341            Some("guard"),
342            &ctx(dir.path()),
343            false,
344            false,
345        )
346        .unwrap();
347        assert_eq!(runner.last.lock().unwrap().as_deref(), Some("guard"));
348    }
349}