1use std::path::PathBuf;
9use std::process::Command;
10
11use crate::cx::Cx;
12use crate::error::{Error, Result};
13
14#[derive(Debug, Clone)]
16pub struct HookContext {
17 pub worktree_path: PathBuf,
19 pub branch: String,
21 pub repo_root: PathBuf,
23 pub base_ref: Option<String>,
25 pub pr_number: Option<u64>,
27}
28
29pub trait HookRunner {
31 fn run(&self, command: &str, ctx: &HookContext) -> Result<i32>;
33}
34
35fn 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#[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#[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
93pub 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
124pub 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 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 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 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 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}