Skip to main content

git_worktree_manager/
hooks.rs

1//! Lifecycle hooks executed by gw at key worktree transitions.
2//!
3//! Configured via `hooks.post_new` / `hooks.pre_rm` keys in
4//! `~/.config/git-worktree-manager/config.json` or `.cwconfig.json`.
5
6use std::path::Path;
7use std::process::Command;
8
9use crate::error::Result;
10
11/// Run the configured hook for `event` (one of `"post_new"`, `"pre_rm"`),
12/// resolving the hook command from the layered config rooted at the main
13/// repo (resolved from `cwd` via `git::get_main_repo_root`, falling back to
14/// `cwd` if that fails). The hook runs with `cwd` as its working directory.
15///
16/// No-op (returns `Ok(())`) when the event name is unknown or the hook is
17/// unset. Hook is run as `sh -c <cmd>`. A non-zero exit propagates as
18/// `CwError::Other`.
19///
20/// **Caller policy** — this function always returns `Err` on non-zero exit;
21/// callers decide what that means:
22/// - `pre_rm` callers treat the error as **advisory**: log a warning, then
23///   continue with removal (aligned with Claude Code's `WorktreeRemove` hook,
24///   which cannot block cleanup).
25/// - `post_new` callers treat the error as **blocking**: propagate it as a
26///   non-zero `gw new` exit code and skip the AI tool launch.
27pub fn run_event(event: &str, cwd: &Path) -> Result<()> {
28    // Worktrees are siblings of the main repo (default ../<repo>-<branch>),
29    // so walking up from `cwd` would never find the main repo's .cwconfig.json.
30    // Resolve to the main repo root for config lookup; `cwd` is kept as the
31    // shell's working directory so hooks run inside the worktree they pertain to.
32    let config_root =
33        crate::git::get_main_repo_root(Some(cwd)).unwrap_or_else(|_| cwd.to_path_buf());
34    let cfg = crate::config::load_effective_config(&config_root)?;
35    let cmd = match event {
36        "post_new" => cfg.hooks.post_new,
37        "pre_rm" => cfg.hooks.pre_rm,
38        _ => return Ok(()),
39    };
40    let Some(cmd) = cmd else {
41        return Ok(());
42    };
43    // Hooks require a POSIX `sh` shell. Return a clear error on non-Unix
44    // platforms rather than failing cryptically when `sh` is missing.
45    #[cfg(not(unix))]
46    return Err(crate::error::CwError::Other(
47        "hooks require 'sh' shell which is not available on Windows".into(),
48    ));
49    // sh -c lets users write pipes/conditionals like "npm install && npm test".
50    // stderr is inherited from Command::status() so users see hook output directly.
51    #[cfg(unix)]
52    {
53        let status = Command::new("sh")
54            .arg("-c")
55            .arg(&cmd)
56            .current_dir(cwd)
57            .status()?;
58        if !status.success() {
59            return Err(crate::error::CwError::Other(format!(
60                "hook '{}' (`{}`) exited with {}",
61                event,
62                cmd.chars().take(60).collect::<String>(),
63                status.code().unwrap_or(-1)
64            )));
65        }
66        Ok(())
67    }
68}