limb 0.1.0

A focused CLI for git worktree management
Documentation
//! User-defined hook scripts.
//!
//! `.limb.toml` may declare four hooks. `pre_add`, `post_add`,
//! `pre_remove`, `post_remove`. And each template can override them. Hooks
//! receive `LIMB_WORKTREE_PATH` and `LIMB_WORKTREE_NAME` in their
//! environment and run with `cwd` set to the repo root that declared them.
//!
//! Hook paths are resolved relative to that declaring `cwd` and are
//! **rejected** if they contain `..` components, so a hook script cannot
//! escape the repo tree.

use std::path::{Component, Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result};

/// Contextual data passed to every hook via environment variables.
pub struct Event<'a> {
    /// Working directory the hook runs in (repo root of the declaring
    /// `.limb.toml`).
    pub cwd: &'a Path,
    /// Absolute path of the worktree being added or removed
    /// (`$LIMB_WORKTREE_PATH`).
    pub worktree_path: &'a Path,
    /// Name (basename) of the worktree (`$LIMB_WORKTREE_NAME`).
    pub worktree_name: &'a str,
}

/// Resolves and executes `script` with `event`'s context.
///
/// # Errors
///
/// Returns an error if `script` contains `..` components, fails to spawn,
/// or exits non-zero.
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(())
}

/// Runs `script` and propagates any failure, tagged with `label`.
///
/// Used for `pre_*` hooks where a non-zero exit must block the operation.
/// A `None` script is a no-op that returns `Ok(())`.
///
/// # Errors
///
/// Returns an error if the hook fails to spawn or exits non-zero; the
/// error is wrapped with `{label} hook: {path}` context.
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()))
}

/// Runs `script` and logs failures to stderr without propagating.
///
/// Used for `post_*` hooks where the worktree has already been created or
/// removed, so a hook failure is reported but does not change the outcome.
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}");
    }
}