limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Implements `limb add`. Creates a new worktree.

use std::path::PathBuf;

use anyhow::{Context as _, Result};

use crate::cli::AddArgs;
use crate::config::{Hooks, Repo};
use crate::context::Context;
use crate::git;
use crate::hooks::{self, Event};
use crate::worktree;

/// Runs `limb add`.
///
/// Resolves a creation plan from the CLI args + any `.limb.toml` template,
/// runs the `pre_add` hook (if any), invokes `git worktree add` via
/// [`worktree::add`], then runs the `post_add` hook on a best-effort basis.
///
/// # Errors
///
/// Returns an error if the repo cannot be resolved, the template name is
/// unknown, `pre_add` exits non-zero, or the git invocation fails.
pub fn run(ctx: &Context, args: &AddArgs) -> Result<()> {
    let repo = ctx.repo()?;
    let repo_config = ctx.repo_config_optional();
    let resolved = resolve_plan(args, repo_config.as_ref())?;

    let base_dir = repo_config
        .as_ref()
        .map_or_else(|| PathBuf::from(".."), |r| r.worktrees_base_dir.clone());
    let target = worktree::target_path(&repo, &resolved.name, &base_dir)?;

    if args.dry_run {
        if ctx.json {
            let out = serde_json::json!({
                "name": resolved.name,
                "path": target.display().to_string(),
                "branch": resolved.branch,
                "from": resolved.from,
                "dry_run": true,
            });
            println!("{}", serde_json::to_string_pretty(&out)?);
        } else if !ctx.quiet {
            eprintln!(
                "would add worktree: {} at {}",
                resolved.name,
                target.display()
            );
        }
        return Ok(());
    }

    let cwd = repo_config
        .as_ref()
        .map_or_else(|| repo.clone(), |r| r.root.clone());
    let event = Event {
        cwd: &cwd,
        worktree_path: &target,
        worktree_name: &resolved.name,
    };

    hooks::run_required(resolved.hooks.pre_add.as_deref(), "pre_add", &event)?;
    ctx.cancel.check()?;

    let add_result = worktree::add(
        &repo,
        &resolved.name,
        &base_dir,
        worktree::AddOptions {
            branch: resolved.branch.as_deref(),
            from: resolved.from.as_deref(),
            track: args.track,
            no_track: args.no_track,
            detach: args.detach,
            orphan: args.orphan,
            lock: args.lock,
            reason: args.reason.as_deref(),
            force_branch: args.force_branch.as_deref(),
            guess_remote: args.guess_remote,
            no_guess_remote: args.no_guess_remote,
            no_checkout: args.no_checkout,
            relative_paths: args.relative_paths,
            no_relative_paths: args.no_relative_paths,
            quiet: ctx.quiet,
        },
    );
    let created = match add_result {
        Ok(path) => path,
        Err(e) => {
            let target_str = target.to_string_lossy();
            let _ = git::run(&repo, &["worktree", "remove", "--force", &target_str]);
            let _ = git::run(&repo, &["worktree", "prune"]);
            return Err(e);
        }
    };

    hooks::run_best_effort(resolved.hooks.post_add.as_deref(), "post_add", &event);

    if ctx.json {
        let out = serde_json::json!({
            "name": resolved.name,
            "path": created.display().to_string(),
            "branch": resolved.branch,
            "from": resolved.from,
        });
        println!("{}", serde_json::to_string_pretty(&out)?);
    } else if !ctx.quiet {
        let ok = crate::style::OK;
        anstream::eprintln!("{ok}✓{ok:#} added worktree: {}", created.display());
    }
    Ok(())
}

struct Plan {
    name: String,
    branch: Option<String>,
    from: Option<String>,
    hooks: Hooks,
}

fn resolve_plan(args: &AddArgs, repo_config: Option<&Repo>) -> Result<Plan> {
    let explicit_from = args.from.clone().or_else(|| args.base.clone());

    if let Some(template_name) = args.template.as_ref() {
        let config = repo_config.context(
            "--template given but no .limb.toml in this repo; \
             try: add `[templates.<name>]` to .limb.toml",
        )?;
        let template = config.templates.get(template_name).with_context(|| {
            let known: Vec<&str> = config.templates.keys().map(String::as_str).collect();
            let known = if known.is_empty() {
                "no templates defined".to_string()
            } else {
                format!("known: {}", known.join(", "))
            };
            format!("unknown template: {template_name}; {known}")
        })?;
        let name = template
            .name_pattern
            .as_deref()
            .map_or_else(|| args.name.clone(), |p| interpolate(p, &args.name));
        Ok(Plan {
            name,
            branch: args.branch.clone(),
            from: explicit_from.or_else(|| template.base_branch.clone()),
            hooks: template.hooks.clone(),
        })
    } else {
        Ok(Plan {
            name: args.name.clone(),
            branch: args.branch.clone(),
            from: explicit_from,
            hooks: repo_config.map_or_else(Hooks::default, |r| r.hooks.clone()),
        })
    }
}

fn interpolate(pattern: &str, slug: &str) -> String {
    const PLACEHOLDER: &str = "{slug}";
    pattern.replace(PLACEHOLDER, slug)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn interpolate_replaces_slug() {
        assert_eq!(interpolate("feat-{slug}", "auth"), "feat-auth");
        assert_eq!(interpolate("{slug}", "x"), "x");
        assert_eq!(interpolate("no-placeholder", "x"), "no-placeholder");
    }
}