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}