Skip to main content

worktree_io/
hooks.rs

1use anyhow::Result;
2use std::process::Command;
3
4use crate::opener::augmented_path;
5
6/// Template variables available to hook scripts.
7pub struct HookContext {
8    /// GitHub owner / organization name.
9    pub owner: String,
10    /// Repository name.
11    pub repo: String,
12    /// Issue number or Linear UUID as a string.
13    pub issue: String,
14    /// Git branch name for the worktree.
15    pub branch: String,
16    /// Absolute path to the worktree directory.
17    pub worktree_path: String,
18}
19
20impl HookContext {
21    /// Expand `{{owner}}`, `{{repo}}`, `{{issue}}`, `{{branch}}`, and
22    /// `{{worktree_path}}` placeholders in `template`.
23    #[must_use]
24    pub fn render(&self, template: &str) -> String {
25        template
26            .replace("{{owner}}", &self.owner)
27            .replace("{{repo}}", &self.repo)
28            .replace("{{issue}}", &self.issue)
29            .replace("{{branch}}", &self.branch)
30            .replace("{{worktree_path}}", &self.worktree_path)
31    }
32}
33
34/// Render `script` with `ctx`, write to a temp file, and execute it.
35/// Stdout and stderr are forwarded to the caller's terminal.
36/// A non-zero exit code prints a warning but does not return an error.
37///
38/// # Errors
39///
40/// Returns an error if the temp file cannot be written or its permissions
41/// cannot be set.
42pub fn run_hook(script: &str, ctx: &HookContext) -> Result<()> {
43    let rendered = ctx.render(script);
44
45    let ext = if cfg!(windows) { ".bat" } else { ".sh" };
46    let tmp_path =
47        std::env::temp_dir().join(format!("worktree-hook-{}{ext}", uuid::Uuid::new_v4()));
48    std::fs::write(&tmp_path, rendered.as_bytes())?;
49
50    #[cfg(unix)]
51    {
52        use std::os::unix::fs::PermissionsExt;
53        std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))?;
54    }
55
56    #[cfg(windows)]
57    let result = Command::new("cmd")
58        .args([std::ffi::OsStr::new("/C"), tmp_path.as_os_str()])
59        .env("PATH", augmented_path())
60        .status();
61    #[cfg(not(windows))]
62    let result = Command::new("sh")
63        .arg(&tmp_path)
64        .env("PATH", augmented_path())
65        .status();
66    let _ = std::fs::remove_file(&tmp_path);
67
68    match result {
69        Ok(status) if !status.success() => {
70            eprintln!("Warning: hook exited with status {:?}", status.code());
71        }
72        // LLVM_COV_EXCL_START
73        Err(e) => {
74            eprintln!("Warning: failed to run hook: {e}");
75        }
76        // LLVM_COV_EXCL_STOP
77        _ => {}
78    }
79
80    Ok(())
81}
82
83#[cfg(test)]
84#[path = "hooks_tests.rs"]
85mod tests;
86
87#[cfg(test)]
88#[path = "hooks_multiline_tests.rs"]
89mod multiline_tests;