use crate::git::load;
use crate::git::{
SEP, display_name, find_repos, first_line, git_capture, git_run, prunes_on_fetch,
};
use colored::Colorize;
use rayon::prelude::*;
use std::path::PathBuf;
#[derive(clap::Args)]
pub struct Args {
#[arg(default_value = ".")]
pub path: PathBuf,
#[arg(short, long, default_value_t = 3)]
pub depth: usize,
#[arg(short, long)]
pub all: bool,
#[arg(short, long)]
pub prune: bool,
}
struct Branch {
name: String,
age: String,
landed: bool,
}
pub fn run(args: Args) {
let repos = find_repos(&args.path, args.depth);
let failed = if args.prune {
prune_all(&repos)
} else {
Vec::new()
};
let mut total_repos = 0usize;
let mut total_branches = 0usize;
let mut total_holding = 0usize;
let mut stale_possible = false;
for repo in &repos {
let repo_str = match repo.to_str() {
Some(s) => s,
None => continue,
};
let name = display_name(repo);
let current = git_capture(repo_str, &["rev-parse", "--abbrev-ref", "HEAD"])
.unwrap_or_else(|| "HEAD".to_string());
if !stale_possible && !args.prune {
stale_possible = !prunes_on_fetch(repo_str);
}
let gone = gone_branches(repo_str, ¤t);
if gone.is_empty() {
if args.all {
println!(
"{} {}",
format!("๐ {}", name).bold(),
"nothing to clean up".dimmed()
);
}
continue;
}
let (safe, holding): (Vec<&Branch>, Vec<&Branch>) = gone.iter().partition(|b| b.landed);
total_repos += usize::from(!safe.is_empty());
total_branches += safe.len();
total_holding += holding.len();
println!(
"{} {}",
format!("๐ {}", name).bold(),
format!("(on {})", current).dimmed()
);
let width = gone.iter().map(|b| b.name.len()).max().unwrap_or(0);
let list = |branches: &[&Branch]| {
for b in branches {
println!(
" {} {}",
format!("{:width$}", b.name).yellow(),
b.age.dimmed()
);
}
};
if !safe.is_empty() {
println!(
" {}",
"upstream gone, changes already on the remote - safe to delete:".dimmed()
);
list(&safe);
let names = safe
.iter()
.map(|b| b.name.as_str())
.collect::<Vec<_>>()
.join(" ");
println!(" {} git branch -D {}", "โณ".dimmed(), names.dimmed());
}
if !holding.is_empty() {
println!(
" {}",
"upstream gone, but holding changes the remote does not have - left alone:"
.dimmed()
);
list(&holding);
println!(
" {} {}",
"โณ".dimmed(),
"look before deleting: slu ibranch -g".dimmed()
);
}
println!();
}
if total_branches == 0 && total_holding == 0 {
println!(
"{}",
"No branches with a gone upstream to clean up.".green()
);
} else {
let mut summary = format!(
"{} branch(es) across {} repo(s) can be deleted",
total_branches, total_repos
);
if total_holding > 0 {
summary.push_str(&format!(
" ยท {total_holding} hold changes the remote does not have"
));
}
println!("{}", summary.dimmed());
}
if stale_possible {
println!(
"{}",
"refs are only as fresh as your last prune - run `slu tidy --prune`, or `git config --global fetch.prune true`"
.dimmed()
);
}
if !failed.is_empty() {
for (name, err) in &failed {
eprintln!("slu tidy: {name}: {err}");
}
std::process::exit(1);
}
}
fn prune_all(repos: &[PathBuf]) -> Vec<(String, String)> {
repos
.par_iter()
.filter_map(|repo| {
let repo_str = repo.to_str()?;
let failure = prune(repo_str)?;
Some((display_name(repo), failure))
})
.collect()
}
fn prune(repo: &str) -> Option<String> {
let remotes = git_capture(repo, &["remote"]).unwrap_or_default();
for remote in remotes.lines().filter(|r| !r.is_empty()) {
let (ok, out) = git_run(repo, &["remote", "prune", remote]);
if !ok {
return Some(first_line(&out));
}
}
None
}
fn gone_branches(repo: &str, current: &str) -> Vec<Branch> {
let fmt = format!(
"--format=%(refname){SEP}%(upstream:track){SEP}%(committerdate:relative){SEP}%(upstream:remotename)"
);
git_capture(
repo,
&["for-each-ref", "--sort=-committerdate", &fmt, "refs/heads"],
)
.map(|out| {
out.lines()
.filter_map(|line| {
let mut f = line.split(SEP);
let refname = f.next()?;
let track = f.next()?;
if !track.contains("gone") {
return None;
}
let name = refname.strip_prefix("refs/heads/").unwrap_or(refname);
if name == current {
return None; }
let age = f.next().unwrap_or("").to_string();
let remote = f.next().filter(|r| !r.is_empty()).unwrap_or("origin");
Some(Branch {
landed: landed(repo, name, remote),
name: name.to_string(),
age,
})
})
.collect()
})
.unwrap_or_default()
}
fn landed(repo: &str, branch: &str, remote: &str) -> bool {
load::unique_commits(repo, branch) == 0
|| load::trunk(repo, remote).is_some_and(|t| load::landed(repo, branch, &t))
}