Skip to main content

wt/
hooks.rs

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