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