use anyhow::{bail, Result};
use crate::cli::CleanArgs;
use crate::commands::remove::{removal_blocker, remove_worktree, RemoveOptions};
use crate::git::{self, Tracking, Worktree};
use crate::repo::Repo;
use crate::tui;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Done,
Pushed,
Local,
Dirty,
}
impl State {
pub fn label(self) -> &'static str {
match self {
State::Done => "done",
State::Pushed => "pushed",
State::Local => "local",
State::Dirty => "dirty",
}
}
pub fn verdict(self, with_branch: bool) -> String {
let safe = match self {
State::Dirty => false,
State::Local => !with_branch,
State::Done | State::Pushed => true,
};
let yes_no = if safe { "yes" } else { "no" };
match self {
State::Done => yes_no.to_string(),
other => format!("{yes_no} ({})", other.label()),
}
}
pub fn preselected(self) -> bool {
self == State::Done
}
}
pub struct Candidate {
pub worktree: Worktree,
pub name: String,
pub state: State,
pub note: String,
}
pub fn run(args: CleanArgs) -> Result<()> {
let repo = Repo::discover()?;
let candidates = candidates(&repo)?;
if candidates.is_empty() {
eprintln!("Nothing to clean: no worktree besides the one you are in.");
return Ok(());
}
if !tui::is_available() {
print_table(&candidates, args.with_branch);
eprintln!();
eprintln!("gwx clean needs a terminal to choose in; nothing was removed.");
return Ok(());
}
let Some(chosen) = tui::choose_to_clean(&candidates, args.with_branch)? else {
return Ok(());
};
if chosen.is_empty() {
eprintln!("Nothing selected.");
return Ok(());
}
let mut removed = 0;
for index in chosen {
let candidate = &candidates[index];
if candidate.state == State::Dirty && !args.force {
eprintln!(
"Skipped {}: it has uncommitted changes (pass --force)",
candidate.name
);
continue;
}
let opts = RemoveOptions {
force: args.force,
with_branch: args.with_branch,
quiet: false,
no_hooks: args.no_hooks,
};
match remove_worktree(&repo, &candidate.worktree, opts) {
Ok(()) => {
removed += 1;
eprintln!("Removed {}", candidate.worktree.path.display());
}
Err(e) => eprintln!("Failed to remove {}: {e:#}", candidate.name),
}
}
if removed != 1 {
eprintln!("Removed {removed} worktrees.");
}
Ok(())
}
pub fn candidates(repo: &Repo) -> Result<Vec<Candidate>> {
let worktrees = repo.worktrees()?;
let Some(main) = worktrees.first().cloned() else {
bail!("no worktrees found");
};
let merged = git::merged_branches(&repo.main)?;
let tracking = git::tracking(&repo.main)?;
let mut out = Vec::new();
for worktree in worktrees.into_iter().skip(1) {
if removal_blocker(repo, &worktree, true).is_some() {
continue;
}
let dirty = git::is_dirty(&worktree.path).unwrap_or(false);
let branch = worktree.branch.clone();
let is_merged = branch.as_ref().is_some_and(|b| merged.contains(b));
let track = branch
.as_ref()
.and_then(|b| tracking.get(b).copied())
.unwrap_or(Tracking::Untracked);
let (state, note) = classify(dirty, is_merged, track);
out.push(Candidate {
name: repo.display_name(&worktree, &main),
worktree,
state,
note,
});
}
Ok(out)
}
fn classify(dirty: bool, merged: bool, track: Tracking) -> (State, String) {
if dirty {
return (
State::Dirty,
"uncommitted changes would be lost".to_string(),
);
}
if merged {
return (
State::Done,
"merged into HEAD, nothing uncommitted".to_string(),
);
}
match track {
Tracking::Pushed => (
State::Pushed,
"not merged; every commit is on its upstream".to_string(),
),
Tracking::Ahead(n) => (State::Local, format!("{n} commit(s) not on its upstream")),
Tracking::Gone => (
State::Local,
"its upstream is gone from the remote".to_string(),
),
Tracking::Untracked => (State::Local, "never pushed; it has no upstream".to_string()),
}
}
fn print_table(candidates: &[Candidate], with_branch: bool) {
let verdicts: Vec<String> = candidates
.iter()
.map(|c| c.state.verdict(with_branch))
.collect();
let name_width = candidates
.iter()
.map(|c| c.name.chars().count())
.max()
.unwrap_or(0)
.max(NAME_HEADER.len());
let verdict_width = verdicts
.iter()
.map(|v| v.chars().count())
.max()
.unwrap_or(0)
.max(VERDICT_HEADER.len());
println!("{NAME_HEADER:<name_width$} {VERDICT_HEADER:<verdict_width$} {NOTE_HEADER}");
for (candidate, verdict) in candidates.iter().zip(&verdicts) {
println!(
"{:<name_width$} {verdict:<verdict_width$} {}",
candidate.name, candidate.note,
);
}
}
pub const NAME_HEADER: &str = "WORKTREE";
pub const VERDICT_HEADER: &str = "SAFE TO REMOVE";
pub const NOTE_HEADER: &str = "NOTE";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uncommitted_changes_outrank_everything() {
let (state, note) = classify(true, true, Tracking::Pushed);
assert_eq!(state, State::Dirty);
assert!(note.contains("uncommitted"));
}
#[test]
fn only_merged_and_clean_is_preselected() {
assert!(classify(false, true, Tracking::Untracked).0.preselected());
for track in [Tracking::Pushed, Tracking::Ahead(2), Tracking::Gone] {
assert!(!classify(false, false, track).0.preselected());
}
assert!(!classify(true, true, Tracking::Pushed).0.preselected());
}
#[test]
fn an_unmerged_branch_is_told_apart_by_its_upstream() {
assert_eq!(
classify(false, false, Tracking::Pushed).0,
State::Pushed,
"everything is on the remote"
);
assert_eq!(classify(false, false, Tracking::Ahead(3)).0, State::Local);
assert_eq!(classify(false, false, Tracking::Untracked).0, State::Local);
assert_eq!(classify(false, false, Tracking::Gone).0, State::Local);
}
#[test]
fn the_verdict_answers_before_it_classifies() {
assert_eq!(State::Done.verdict(false), "yes");
assert_eq!(State::Pushed.verdict(false), "yes (pushed)");
assert_eq!(State::Local.verdict(false), "yes (local)");
assert_eq!(State::Dirty.verdict(false), "no (dirty)");
}
#[test]
fn taking_the_branch_too_makes_local_commits_unsafe() {
assert_eq!(State::Local.verdict(true), "no (local)");
assert_eq!(State::Pushed.verdict(true), "yes (pushed)");
assert_eq!(State::Done.verdict(true), "yes");
}
#[test]
fn the_note_says_how_many_commits_are_at_stake() {
let (_, note) = classify(false, false, Tracking::Ahead(3));
assert!(note.contains('3'), "{note}");
}
}