use std::collections::HashSet;
use std::path::Path;
use anyhow::Context;
use worktrunk::HookType;
use worktrunk::config::UserConfig;
use worktrunk::git::{BranchDeletionMode, ErrorExt, Repository, ResolvedWorktree};
use worktrunk::styling::{eprintln, info_message};
use crate::cli::{RemoveArgs, SwitchFormat};
use crate::output::{BackgroundFallbackMode, handle_remove_output};
use super::hook_plan::{ApprovedHookPlan, HookPlanBuilder};
use super::hooks::HookAnnouncer;
use super::repository_ext::RepositoryCliExt;
use super::worktree::RemoveResult;
use super::{RemoveTarget, flag_pair, resolve_worktree_arg};
struct RemovePlans {
others: Vec<RemoveResult>,
branch_only: Vec<RemoveResult>,
current: Option<RemoveResult>,
errors: Vec<anyhow::Error>,
}
impl RemovePlans {
fn has_valid_plans(&self) -> bool {
!self.others.is_empty() || !self.branch_only.is_empty() || self.current.is_some()
}
fn record_error(&mut self, e: anyhow::Error) {
let rendered = e.render_diagnostic().unwrap_or_else(|| e.to_string());
if !rendered.is_empty() {
eprintln!("{rendered}");
}
self.errors.push(e);
}
}
fn validate_remove_targets(
repo: &Repository,
branches: Vec<String>,
config: &UserConfig,
keep_branch: bool,
force_delete: bool,
force: bool,
) -> RemovePlans {
let current_worktree = repo
.current_worktree()
.root()
.ok()
.and_then(|p| dunce::canonicalize(&p).ok());
let branches: Vec<_> = {
let mut seen = HashSet::new();
branches
.into_iter()
.filter(|b| seen.insert(b.clone()))
.collect()
};
let deletion_mode = BranchDeletionMode::from_flags(keep_branch, force_delete);
let worktrees = repo.list_worktrees().ok();
let snapshot = repo.capture_refs().ok();
let mut plans = RemovePlans {
others: Vec::new(),
branch_only: Vec::new(),
current: None,
errors: Vec::new(),
};
for branch_name in &branches {
let resolved = match resolve_worktree_arg(repo, branch_name) {
Ok(r) => r,
Err(e) => {
plans.record_error(e);
continue;
}
};
match resolved {
ResolvedWorktree::Worktree { path, branch } => {
let path_canonical = dunce::canonicalize(&path).unwrap_or(path);
let is_current = current_worktree.as_ref() == Some(&path_canonical);
if is_current {
match repo.prepare_worktree_removal(
RemoveTarget::Current,
deletion_mode,
force,
config,
None,
worktrees,
snapshot.as_ref(),
) {
Ok(result) => plans.current = Some(result),
Err(e) => plans.record_error(e),
}
continue;
}
let target = if let Some(ref branch_name) = branch {
RemoveTarget::Branch(branch_name)
} else {
RemoveTarget::Path(&path_canonical)
};
match repo.prepare_worktree_removal(
target,
deletion_mode,
force,
config,
None,
worktrees,
snapshot.as_ref(),
) {
Ok(result) => plans.others.push(result),
Err(e) => plans.record_error(e),
}
}
ResolvedWorktree::BranchOnly { branch } => {
match repo.prepare_worktree_removal(
RemoveTarget::Branch(&branch),
deletion_mode,
force,
config,
None,
worktrees,
snapshot.as_ref(),
) {
Ok(result) => plans.branch_only.push(result),
Err(e) => plans.record_error(e),
}
}
}
}
plans
}
pub fn handle_remove_command(args: RemoveArgs, yes: bool) -> anyhow::Result<()> {
let json_mode = args.format == SwitchFormat::Json;
let verify = args.hooks.resolve();
UserConfig::load()
.context("Failed to load config")
.and_then(|config| {
let repo = Repository::current().context("Failed to remove worktree")?;
let project = repo.project_identifier().ok();
let cli_override = flag_pair(args.delete_branch, args.no_delete_branch);
let delete_branch =
cli_override.unwrap_or_else(|| config.remove(project.as_deref()).delete_branch());
if !delete_branch && args.force_delete {
return Err(worktrunk::git::GitError::Other {
message: "Cannot use --force-delete with delete-branch=false (set via --no-delete-branch or [remove] delete-branch = false)".into(),
}
.into());
}
let approve_remove = |removed_worktree_paths: &[&Path],
destination_paths: &[&Path],
yes: bool|
-> anyhow::Result<ApprovedHookPlan> {
if !verify {
return Ok(ApprovedHookPlan::empty());
}
let project_id = repo.project_identifier().ok();
let pid = project_id.as_deref();
let project_config = repo.load_project_config()?;
let mut builder = HookPlanBuilder::new(project_config.as_ref(), &config, pid);
for &wt_path in removed_worktree_paths {
builder.add(wt_path, &[HookType::PreRemove, HookType::PostRemove]);
}
let mut seen_dests = std::collections::HashSet::new();
for &dest in destination_paths {
if !seen_dests.insert(dest) {
continue;
}
builder.add(dest, &[HookType::PostSwitch]);
}
match builder.finish().approve(pid, yes)? {
Some(plan) => Ok(plan),
None => {
eprintln!(
"{}",
info_message("Commands declined, continuing removal without hooks")
);
Ok(ApprovedHookPlan::empty())
}
}
};
let branches = args.branches;
if branches.is_empty() {
let result = repo
.prepare_worktree_removal(
RemoveTarget::Current,
BranchDeletionMode::from_flags(!delete_branch, args.force_delete),
args.force,
&config,
None,
None,
None,
)
.context("Failed to remove worktree")?;
if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() {
return Ok(());
}
let plan = approve_remove(
result.removed_worktree_path().as_slice(),
result.destination_path().as_slice(),
yes,
)?;
let mut announcer = HookAnnouncer::new(&repo, false);
handle_remove_output(
&result,
args.foreground,
&plan,
false,
false,
&mut announcer,
BackgroundFallbackMode::Detached,
)?;
announcer.flush()?;
if json_mode {
let json = serde_json::json!([result.to_json()]);
println!("{}", serde_json::to_string_pretty(&json)?);
}
super::process::run_internal_sweep(&repo);
Ok(())
} else {
let plans = validate_remove_targets(
&repo,
branches,
&config,
!delete_branch,
args.force_delete,
args.force,
);
if !plans.has_valid_plans() {
anyhow::bail!("");
}
if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() {
return Ok(());
}
let all_plans = || {
plans
.others
.iter()
.chain(&plans.branch_only)
.chain(plans.current.iter())
};
let removed_targets: Vec<&Path> =
all_plans().filter_map(|r| r.removed_worktree_path()).collect();
let destination_targets: Vec<&Path> =
all_plans().filter_map(|r| r.destination_path()).collect();
let plan = approve_remove(&removed_targets, &destination_targets, yes)?;
let show_branch =
plans.others.len() + plans.branch_only.len() + plans.current.iter().len() > 1;
let run = |result: &RemoveResult| -> anyhow::Result<()> {
let mut announcer = HookAnnouncer::new(&repo, show_branch);
handle_remove_output(
result,
args.foreground,
&plan,
false,
false,
&mut announcer,
BackgroundFallbackMode::Detached,
)?;
announcer.flush()
};
for result in &plans.others {
run(result)?;
}
for result in &plans.branch_only {
run(result)?;
}
if let Some(ref result) = plans.current {
run(result)?;
}
if json_mode {
let json_items: Vec<serde_json::Value> = plans
.others
.iter()
.chain(&plans.branch_only)
.chain(plans.current.as_ref())
.map(RemoveResult::to_json)
.collect();
println!("{}", serde_json::to_string_pretty(&json_items)?);
}
super::process::run_internal_sweep(&repo);
if !plans.errors.is_empty() {
anyhow::bail!("");
}
Ok(())
}
})
}