git-cleaner 0.1.0

Bulk cleanup of git worktrees and merged branches across multiple repositories
mod branch;
mod cli;
mod error;
mod scanner;
mod worktree;

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

use clap::Parser;
use colored::Colorize;
use git2::Repository;

use cli::{Cli, Command};

fn main() {
    let cli = Cli::parse();

    if let Err(e) = run(cli) {
        eprintln!("{} {e}", "error:".red().bold());
        std::process::exit(1);
    }
}

fn run(cli: Cli) -> error::Result<()> {
    match cli.command {
        Command::Scan { path } => cmd_scan(&path),
        Command::Worktrees { path, execute } => cmd_worktrees(&path, execute),
        Command::Branches { path, execute } => cmd_branches(&path, execute),
        Command::All { path, execute } => {
            cmd_worktrees(&path, execute)?;
            println!();
            cmd_branches(&path, execute)?;
            Ok(())
        }
    }
}

fn cmd_scan(base: &Path) -> error::Result<()> {
    let repos = scanner::scan_repos(base)?;
    println!(
        "{} {} repositories in {}",
        "Scanning".cyan().bold(),
        repos.len(),
        base.display()
    );
    println!();

    let mut total_worktrees = 0usize;
    let mut total_removable = 0usize;
    let mut total_branches = 0usize;

    for entry in &repos {
        let repo = match Repository::open(&entry.path) {
            Ok(r) => r,
            Err(_) => continue,
        };

        let wt_statuses = worktree::analyze_worktrees(&repo).unwrap_or_default();
        let branch_report =
            branch::find_merged_branches(&repo).unwrap_or_else(|_| branch::BranchReport {
                merged_branches: Vec::new(),
            });

        let removable_count = wt_statuses.iter().filter(|w| w.removable).count();
        let merged_count = branch_report.merged_branches.len();

        if wt_statuses.is_empty() && merged_count == 0 {
            continue;
        }

        println!("{}", entry.name.bold());

        if !wt_statuses.is_empty() {
            for wt in &wt_statuses {
                let status = if wt.removable {
                    "[DELETE]".green().to_string()
                } else {
                    "[KEEP]".yellow().to_string()
                };
                let branch_label = wt.branch.as_deref().unwrap_or("(unknown)");
                let detail = format_worktree_detail(wt);
                println!(
                    "  {status} {name} ({branch_label}) {detail}",
                    name = wt.name
                );
            }
            total_worktrees += wt_statuses.len();
            total_removable += removable_count;
        }

        if merged_count > 0 {
            println!(
                "  {} merged branches: {}",
                merged_count.to_string().yellow(),
                branch_report
                    .merged_branches
                    .iter()
                    .map(|b| b.name.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            );
            total_branches += merged_count;
        }

        println!();
    }

    println!("{}", "--- Summary ---".bold());
    println!(
        "Worktrees: {} total, {} removable",
        total_worktrees,
        total_removable.to_string().green()
    );
    println!("Merged branches: {}", total_branches.to_string().yellow());

    Ok(())
}

fn cmd_worktrees(base: &Path, execute: bool) -> error::Result<()> {
    let repos = scanner::scan_repos(base)?;
    println!(
        "{} worktrees in {} repositories",
        if execute {
            "Cleaning".red().bold()
        } else {
            "Scanning".cyan().bold()
        },
        repos.len()
    );
    println!();

    let mut targets: Vec<(String, String, std::path::PathBuf)> = Vec::new();

    for entry in &repos {
        let repo = match Repository::open(&entry.path) {
            Ok(r) => r,
            Err(_) => continue,
        };

        let wt_statuses = worktree::analyze_worktrees(&repo).unwrap_or_default();
        let removable: Vec<_> = wt_statuses.iter().filter(|w| w.removable).collect();

        if removable.is_empty() {
            continue;
        }

        println!("{}", entry.name.bold());
        for wt in &removable {
            let branch_label = wt.branch.as_deref().unwrap_or("(unknown)");
            println!("  {} {} ({})", "[DELETE]".green(), wt.name, branch_label);
            targets.push((
                entry.path.display().to_string(),
                wt.name.clone(),
                wt.path.clone(),
            ));
        }

        for wt in wt_statuses.iter().filter(|w| !w.removable) {
            let branch_label = wt.branch.as_deref().unwrap_or("(unknown)");
            let detail = format_worktree_detail(wt);
            println!(
                "  {} {} ({}) {detail}",
                "[KEEP]".yellow(),
                wt.name,
                branch_label,
            );
        }
        println!();
    }

    if targets.is_empty() {
        println!("No removable worktrees found.");
        return Ok(());
    }

    if !execute {
        println!(
            "{} {} worktrees can be removed. Use {} to execute.",
            "Dry-run:".cyan().bold(),
            targets.len(),
            "--execute".bold()
        );
        return Ok(());
    }

    if !confirm(&format!("Remove {} worktrees?", targets.len()))? {
        println!("Cancelled.");
        return Ok(());
    }

    for (repo_path, wt_name, wt_path) in &targets {
        let repo = Repository::open(repo_path).map_err(|e| error::Error::Git {
            path: repo_path.into(),
            source: e,
        })?;

        match worktree::remove_worktree(&repo, wt_name) {
            Ok(()) => println!("  {} {}", "Removed".green(), wt_path.display()),
            Err(e) => eprintln!("  {} {wt_name}: {e}", "Failed".red()),
        }
    }

    Ok(())
}

fn cmd_branches(base: &Path, execute: bool) -> error::Result<()> {
    let repos = scanner::scan_repos(base)?;
    println!(
        "{} merged branches in {} repositories",
        if execute {
            "Cleaning".red().bold()
        } else {
            "Scanning".cyan().bold()
        },
        repos.len()
    );
    println!();

    let mut targets: Vec<(String, Vec<String>)> = Vec::new();
    let mut total = 0usize;

    for entry in &repos {
        let repo = match Repository::open(&entry.path) {
            Ok(r) => r,
            Err(_) => continue,
        };

        let report = branch::find_merged_branches(&repo).unwrap_or_else(|_| branch::BranchReport {
            merged_branches: Vec::new(),
        });

        if report.merged_branches.is_empty() {
            continue;
        }

        let count = report.merged_branches.len();
        let names: Vec<String> = report
            .merged_branches
            .iter()
            .map(|b| b.name.clone())
            .collect();

        println!(
            "{}{} merged branches",
            entry.name.bold(),
            count.to_string().yellow()
        );
        for name in &names {
            println!("  {name}");
        }
        println!();

        total += count;
        targets.push((entry.path.display().to_string(), names));
    }

    if targets.is_empty() {
        println!("No merged branches found.");
        return Ok(());
    }

    if !execute {
        println!(
            "{} {} merged branches can be deleted. Use {} to execute.",
            "Dry-run:".cyan().bold(),
            total,
            "--execute".bold()
        );
        return Ok(());
    }

    if !confirm(&format!("Delete {} merged branches?", total))? {
        println!("Cancelled.");
        return Ok(());
    }

    for (repo_path, branch_names) in &targets {
        let repo = Repository::open(repo_path).map_err(|e| error::Error::Git {
            path: repo_path.into(),
            source: e,
        })?;

        for name in branch_names {
            match branch::delete_branch(&repo, name) {
                Ok(()) => println!("  {} {repo_path}: {name}", "Deleted".green()),
                Err(e) => eprintln!("  {} {repo_path}: {name}: {e}", "Failed".red()),
            }
        }
    }

    Ok(())
}

fn format_worktree_detail(wt: &worktree::WorktreeStatus) -> String {
    let mut parts = Vec::new();
    if wt.unmerged_commits > 0 {
        parts.push(format!("{} unmerged commits", wt.unmerged_commits));
    }
    if wt.has_uncommitted_changes {
        parts.push("uncommitted changes".to_string());
    }
    if parts.is_empty() {
        return String::new();
    }
    format!("{}", parts.join(", "))
}

fn confirm(message: &str) -> io::Result<bool> {
    print!("{message} [y/N] ");
    io::stdout().flush()?;

    let mut input = String::new();
    io::stdin().lock().read_line(&mut input)?;

    Ok(matches!(input.trim(), "y" | "Y" | "yes" | "YES"))
}