use dialoguer::Confirm;
use git2::BranchType;
use log::debug;
use miette::{IntoDiagnostic, Result};
use serde_json::json;
use workon::{get_default_branch, get_repo, get_worktrees, WorktreeDescriptor};
use crate::cli::Prune;
use crate::output;
use super::Run;
impl Run for Prune {
fn run(&self) -> Result<Option<WorktreeDescriptor>> {
let repo = get_repo(None)?;
let config = workon::WorkonConfig::new(&repo)?;
let protected_patterns = config.prune_protected_branches()?;
let worktrees = get_worktrees(&repo)?;
let mut candidates: Vec<(&WorktreeDescriptor, PruneCandidate)> = Vec::new();
for name in &self.names {
let matching_wt = worktrees.iter().find(|wt| {
if let Some(wt_name) = wt.name() {
if wt_name == name {
return true;
}
}
if let Ok(Some(branch)) = wt.branch() {
if branch == *name {
return true;
}
}
false
});
if let Some(wt) = matching_wt {
let branch_name = match wt.branch() {
Ok(Some(name)) => name,
Ok(None) => "(detached HEAD)".to_string(),
Err(_) => "(error reading branch)".to_string(),
};
candidates.push((
wt,
PruneCandidate {
worktree_name: wt.name().unwrap_or("").to_string(),
worktree_path: wt.path().to_path_buf(),
branch_name,
reason: PruneReason::Explicit,
},
));
} else {
output::warn(&format!("worktree '{}' not found, skipping", name));
}
}
let pb = output::create_spinner();
pb.set_message("Checking worktree status...");
let filter_candidates: Vec<(&WorktreeDescriptor, PruneCandidate)> = worktrees
.iter()
.filter_map(|wt| {
if candidates.iter().any(|(c, _)| c.name() == wt.name()) {
return None;
}
let branch_name = match wt.branch() {
Ok(Some(name)) => name,
Ok(None) | Err(_) => return None, };
let branch_exists = repo
.find_branch(&branch_name, git2::BranchType::Local)
.is_ok();
if !branch_exists {
debug!("'{}': branch deleted, candidate for pruning", branch_name);
Some((
wt,
PruneCandidate {
worktree_name: wt.name()?.to_string(),
worktree_path: wt.path().to_path_buf(),
branch_name,
reason: PruneReason::BranchDeleted,
},
))
} else if self.gone {
match is_upstream_gone(&repo, &branch_name) {
Ok(true) => {
debug!("'{}': upstream gone, candidate for pruning", branch_name);
Some((
wt,
PruneCandidate {
worktree_name: wt.name()?.to_string(),
worktree_path: wt.path().to_path_buf(),
branch_name,
reason: PruneReason::RemoteGone,
},
))
}
_ => None,
}
} else if let Some(ref merged_target) = self.merged {
let target_branch = if merged_target.is_empty() {
match get_default_branch(&repo) {
Ok(b) => b,
Err(_) => return None, }
} else {
merged_target.clone()
};
match wt.is_merged_into(&target_branch) {
Ok(true) => Some((
wt,
PruneCandidate {
worktree_name: wt.name()?.to_string(),
worktree_path: wt.path().to_path_buf(),
branch_name,
reason: PruneReason::Merged(target_branch),
},
)),
_ => None,
}
} else {
debug!("'{}': no prune criteria matched, skipping", branch_name);
None
}
})
.collect();
pb.finish_and_clear();
candidates.extend(filter_candidates);
let default_branch = get_default_branch(&repo).ok();
let mut skipped: Vec<(PruneCandidate, String)> = Vec::new();
let to_prune: Vec<PruneCandidate> = candidates
.into_iter()
.filter_map(|(wt, candidate)| {
if !self.force && is_protected(&candidate.branch_name, &protected_patterns) {
debug!("'{}': skipped (protected branch)", candidate.branch_name);
skipped.push((
candidate,
"protected by workon.pruneProtectedBranches".to_string(),
));
return None;
}
if !self.force {
if let Some(ref branch) = default_branch {
if candidate.branch_name == *branch {
skipped.push((candidate, "is the default worktree".to_string()));
return None;
}
}
}
if !self.force && !self.include_locked {
if let Ok(true) = wt.is_locked() {
skipped.push((
candidate,
"locked (use --include-locked to override)".to_string(),
));
return None;
}
}
if !self.force && !self.allow_dirty {
let dirty = if matches!(candidate.reason, PruneReason::RemoteGone) {
wt.has_tracked_changes()
} else {
wt.is_dirty()
};
match dirty {
Ok(true) => {
skipped.push((
candidate,
"has uncommitted changes, use --allow-dirty to override"
.to_string(),
));
return None;
}
Err(_) => {
skipped.push((candidate, "could not check status".to_string()));
return None;
}
_ => {}
}
}
if !self.force
&& !self.allow_unmerged
&& !matches!(
candidate.reason,
PruneReason::BranchDeleted
| PruneReason::RemoteGone
| PruneReason::Merged(_)
)
{
if let Some(ref branch) = default_branch {
if let Ok(false) = wt.is_merged_into(branch) {
skipped.push((
candidate,
"has unmerged commits, use --allow-unmerged to override"
.to_string(),
));
return None;
}
}
}
Some(candidate)
})
.collect();
if self.json {
let delete_branch = !self.keep_branch;
let mut pruned_with_status: Vec<(&PruneCandidate, bool)> = Vec::new();
let force_locked = self.force || self.include_locked;
if !self.dry_run {
for candidate in &to_prune {
let branch_deleted =
prune_worktree(&repo, candidate, delete_branch, force_locked)?;
pruned_with_status.push((candidate, branch_deleted));
}
} else {
for candidate in &to_prune {
pruned_with_status.push((candidate, false));
}
}
let result = json!({
"pruned": pruned_with_status.iter().map(|(c, branch_deleted)| json!({
"name": c.worktree_name,
"path": c.worktree_path.to_str(),
"branch": c.branch_name,
"reason": c.reason.to_string(),
"branch_deleted": branch_deleted,
})).collect::<Vec<_>>(),
"skipped": skipped.iter().map(|(c, reason)| json!({
"name": c.worktree_name,
"path": c.worktree_path.to_str(),
"branch": c.branch_name,
"reason": reason,
})).collect::<Vec<_>>(),
"dry_run": self.dry_run,
});
let output = serde_json::to_string_pretty(&result).into_diagnostic()?;
println!("{}", output);
return Ok(None);
}
if !skipped.is_empty() {
output::notice("Skipped worktrees (unsafe to prune):");
for (candidate, reason) in &skipped {
output::detail(&format!(
" {} ({})",
candidate.worktree_path.display(),
reason
));
}
eprintln!();
}
if to_prune.is_empty() {
output::status("No worktrees to prune");
return Ok(None);
}
output::info("Worktrees to prune:");
for candidate in &to_prune {
output::detail(&format!(
" {} (branch: {}, reason: {})",
candidate.worktree_path.display(),
candidate.branch_name,
candidate.reason
));
}
if self.dry_run {
output::notice("\nDry run - no changes made");
return Ok(None);
}
if !self.yes {
let prompt = if !self.keep_branch {
format!(
"Prune {} worktree(s) and delete their branches?",
to_prune.len()
)
} else {
format!("Prune {} worktree(s)?", to_prune.len())
};
let confirmed = Confirm::new()
.with_prompt(prompt)
.default(false)
.interact()
.into_diagnostic()?;
if !confirmed {
output::notice("Cancelled");
return Ok(None);
}
}
let delete_branch = !self.keep_branch;
let force_locked = self.force || self.include_locked;
for candidate in &to_prune {
prune_worktree(&repo, candidate, delete_branch, force_locked)?;
}
output::success(&format!("Pruned {} worktree(s)", to_prune.len()));
Ok(None)
}
}
#[derive(Debug)]
enum PruneReason {
BranchDeleted,
RemoteGone,
Merged(String),
Explicit,
}
impl std::fmt::Display for PruneReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PruneReason::BranchDeleted => write!(f, "branch deleted"),
PruneReason::RemoteGone => write!(f, "remote gone"),
PruneReason::Merged(target) => write!(f, "merged into {}", target),
PruneReason::Explicit => write!(f, "explicitly requested"),
}
}
}
struct PruneCandidate {
worktree_name: String,
worktree_path: std::path::PathBuf,
branch_name: String,
reason: PruneReason,
}
fn is_upstream_gone(repo: &git2::Repository, branch_name: &str) -> Result<bool> {
let branch = match repo.find_branch(branch_name, BranchType::Local) {
Ok(b) => b,
Err(_) => return Ok(false), };
let config = repo.config().into_diagnostic()?;
let remote_key = format!("branch.{}.remote", branch_name);
let merge_key = format!("branch.{}.merge", branch_name);
let _remote = match config.get_string(&remote_key) {
Ok(r) => r,
Err(_) => return Ok(false), };
let _merge = match config.get_string(&merge_key) {
Ok(m) => m,
Err(_) => return Ok(false), };
match branch.upstream() {
Ok(_) => Ok(false), Err(_) => Ok(true), }
}
fn prune_worktree(
repo: &git2::Repository,
candidate: &PruneCandidate,
delete_branch: bool,
force_locked: bool,
) -> Result<bool> {
if candidate.worktree_path.exists() {
std::fs::remove_dir_all(&candidate.worktree_path).into_diagnostic()?;
}
let worktree = repo
.find_worktree(&candidate.worktree_name)
.into_diagnostic()?;
let mut opts = git2::WorktreePruneOptions::new();
opts.valid(true); if force_locked {
opts.locked(true); }
worktree.prune(Some(&mut opts)).into_diagnostic()?;
let branch_deleted = if delete_branch
&& !matches!(candidate.reason, PruneReason::BranchDeleted)
&& !candidate.branch_name.starts_with('(')
{
match repo.find_branch(&candidate.branch_name, BranchType::Local) {
Ok(mut branch) => match branch.delete() {
Ok(()) => true,
Err(e) => {
output::warn(&format!(
"could not delete branch '{}': {}",
candidate.branch_name, e
));
false
}
},
Err(_) => false,
}
} else {
false
};
if branch_deleted {
output::success(&format!(
" Pruned {} (branch '{}' deleted)",
candidate.worktree_path.display(),
candidate.branch_name
));
} else {
output::success(&format!(" Pruned {}", candidate.worktree_path.display()));
}
Ok(branch_deleted)
}
fn is_protected(branch_name: &str, patterns: &[String]) -> bool {
for pattern in patterns {
if glob_match(pattern, branch_name) {
return true;
}
}
false
}
fn glob_match(pattern: &str, text: &str) -> bool {
if pattern == text {
return true;
}
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix("/*") {
return text.starts_with(prefix)
&& text.len() > prefix.len()
&& text[prefix.len()..].starts_with('/');
}
if let Some(suffix) = pattern.strip_prefix("*/") {
return text.ends_with(suffix)
&& text.len() > suffix.len()
&& text[..text.len() - suffix.len()].ends_with('/');
}
false
}