use std::path::{Component, Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
pub struct Event<'a> {
pub cwd: &'a Path,
pub worktree_path: &'a Path,
pub worktree_name: &'a str,
}
pub fn run(script: &Path, event: &Event) -> Result<()> {
let full = resolve(script, event.cwd)?;
let status = Command::new(&full)
.current_dir(event.cwd)
.env("LIMB_WORKTREE_PATH", event.worktree_path)
.env("LIMB_WORKTREE_NAME", event.worktree_name)
.status()
.with_context(|| format!("spawn hook {}", full.display()))?;
if !status.success() {
anyhow::bail!(
"hook exited {}: {}",
status.code().unwrap_or(-1),
full.display()
);
}
Ok(())
}
pub fn run_required(script: Option<&Path>, label: &str, event: &Event) -> Result<()> {
let Some(s) = script else { return Ok(()) };
run(s, event).with_context(|| format!("{label} hook: {}", s.display()))
}
pub fn run_best_effort(script: Option<&Path>, label: &str, event: &Event) {
let Some(s) = script else { return };
if let Err(e) = run(s, event) {
eprintln!("warning: {label} hook ({}): {e:#}", s.display());
}
}
fn resolve(script: &Path, cwd: &Path) -> Result<PathBuf> {
if script.is_absolute() {
return Ok(script.to_path_buf());
}
if script
.components()
.any(|c| matches!(c, Component::ParentDir))
{
anyhow::bail!(
"hook path {} contains '..'; relative hook paths must stay under the repo root",
script.display()
);
}
Ok(cwd.join(script))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn absolute_path_passes_through() {
let abs = if cfg!(windows) {
PathBuf::from(r"C:\tmp\hook.sh")
} else {
PathBuf::from("/tmp/hook.sh")
};
let out = resolve(&abs, Path::new("/anything")).unwrap();
assert_eq!(out, abs);
}
#[test]
fn relative_path_joined_to_cwd() {
let out = resolve(Path::new("scripts/x.sh"), Path::new("/repo")).unwrap();
assert_eq!(out, PathBuf::from("/repo/scripts/x.sh"));
}
#[test]
fn parent_dir_component_rejected() {
let err = resolve(Path::new("../escape.sh"), Path::new("/repo"))
.unwrap_err()
.to_string();
assert!(err.contains("'..'"), "got: {err}");
}
#[test]
fn nested_parent_dir_rejected() {
let err = resolve(Path::new("scripts/../../etc/passwd"), Path::new("/repo"))
.unwrap_err()
.to_string();
assert!(err.contains("'..'"), "got: {err}");
}
}