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"))
}