limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Implements `limb clean`. Removes worktrees whose upstream is gone.

use std::io::{self, BufRead, Write};
use std::path::Path;

use anyhow::Result;

use crate::cli::CleanArgs;
use crate::context::Context;
use crate::git;
use crate::worktree::{self, Worktree};

/// Runs `limb clean`.
///
/// Finds every non-bare worktree whose tracked branch reports
/// `[upstream: gone]` (deleted on the remote), confirms with the user
/// (unless `--yes`), and removes them.
///
/// # Errors
///
/// Returns an error if the repo cannot be resolved, the user-confirmation
/// prompt cannot be read, or any individual removal fails with the
/// failure propagated through the final summary.
pub fn run(ctx: &Context, args: &CleanArgs) -> Result<()> {
    let repo = ctx.repo()?;
    let trees = worktree::list(&repo)?;

    let candidates: Vec<Worktree> = trees
        .into_iter()
        .filter(|w| !w.bare)
        .filter(|w| {
            w.branch
                .as_deref()
                .is_some_and(|b| is_upstream_gone(&w.path, b))
        })
        .collect();

    if candidates.is_empty() {
        if ctx.json {
            println!(
                "{}",
                serde_json::to_string_pretty(&serde_json::json!({
                    "candidates": [],
                    "removed": [],
                }))?
            );
        } else {
            eprintln!("no stale worktrees (all upstreams alive)");
        }
        return Ok(());
    }

    if !ctx.json {
        eprintln!("worktrees whose upstream branch is gone:");
        for c in &candidates {
            eprintln!(
                "  {}  (branch: {})",
                c.name,
                c.branch.as_deref().unwrap_or("?")
            );
        }
    }

    if args.dry_run {
        if ctx.json {
            let summary = serde_json::json!({
                "candidates": candidates.iter().map(|c| &c.name).collect::<Vec<_>>(),
                "removed": Vec::<String>::new(),
                "dry_run": true,
            });
            println!("{}", serde_json::to_string_pretty(&summary)?);
        } else {
            eprintln!("dry-run; nothing removed");
        }
        return Ok(());
    }

    if !ctx.yes && !prompt_confirm(candidates.len())? {
        eprintln!("cancelled");
        return Ok(());
    }

    let mut removed: Vec<String> = Vec::new();
    let mut failed: Vec<(String, String)> = Vec::new();
    for c in &candidates {
        match worktree::remove(&repo, &c.name, false, ctx.quiet) {
            Ok(()) => {
                removed.push(c.name.clone());
                if !ctx.json {
                    eprintln!("removed: {}", c.name);
                }
            }
            Err(e) => failed.push((c.name.clone(), format!("{e:#}"))),
        }
    }

    if ctx.json {
        let summary = serde_json::json!({
            "candidates": candidates.iter().map(|c| &c.name).collect::<Vec<_>>(),
            "removed": removed,
            "failed": failed.iter().map(|(n, e)| serde_json::json!({"name": n, "error": e})).collect::<Vec<_>>(),
        });
        println!("{}", serde_json::to_string_pretty(&summary)?);
    } else {
        for (name, err) in &failed {
            eprintln!("failed: {name}: {err}");
        }
        eprintln!("cleaned {}/{}", removed.len(), candidates.len());
    }
    Ok(())
}

fn is_upstream_gone(worktree_path: &Path, branch: &str) -> bool {
    let ref_name = format!("refs/heads/{branch}");
    git::capture(
        worktree_path,
        &["for-each-ref", "--format=%(upstream:track)", &ref_name],
    )
    .is_ok_and(|s| s.contains("gone"))
}

fn prompt_confirm(n: usize) -> Result<bool> {
    let mut stderr = io::stderr().lock();
    write!(stderr, "proceed with removing {n} worktree(s)? [y/N] ")?;
    stderr.flush()?;
    drop(stderr);
    let stdin = io::stdin();
    let mut line = String::new();
    stdin.lock().read_line(&mut line)?;
    Ok(line.trim().eq_ignore_ascii_case("y"))
}