use crate::cli::RemoveArgs;
use crate::color::{self, ColorConfig};
use crate::command::trust_check::{TrustHint, load_config_with_trust_check};
use crate::error::{Error, Result};
use crate::hook::{self, HookEnv};
use crate::interactive::{SafetyWarning, run_remove_confirmation, run_remove_selection};
use crate::output::Output;
use crate::prompt;
use crate::vcs::{self, WorkspaceInfo};
use std::path::PathBuf;
pub(crate) fn run(args: RemoveArgs, color: ColorConfig) -> Result<()> {
let output = Output::new(args.quiet, color);
let provider = vcs::get_provider()?;
if !provider.is_inside_repo() {
return Err(Error::NotInAnyRepo);
}
let repo_root = provider.main_workspace_root()?;
let config = load_config_with_trust_check(&repo_root, &repo_root, true, TrustHint::None)?;
color::set_cli_theme(&config.ui.colors);
let worktrees = provider.list_workspaces()?;
let targets = if args.interactive {
select_worktrees_interactively(&worktrees)?
} else if args.current {
let current_worktree = find_current_worktree(&worktrees)?;
let mut paths = vec![current_worktree];
if !args.paths.is_empty() {
paths.extend(resolve_worktree_paths(&args.paths, &worktrees)?);
}
paths
} else if args.paths.is_empty() {
return Err(Error::PathRequired);
} else {
resolve_worktree_paths(&args.paths, &worktrees)?
};
for path in &targets {
if is_main_worktree(path, &worktrees) {
return Err(Error::CannotRemoveMainWorktree { path: path.clone() });
}
}
let warnings = if !args.force {
collect_safety_warnings(&targets, provider.as_ref())?
} else {
vec![]
};
if !warnings.is_empty() {
if args.dry_run {
for warning in &warnings {
display_warning(&output, warning);
}
} else if prompt::is_interactive() {
if !run_remove_confirmation(&warnings)? {
return Err(Error::Aborted);
}
} else {
let first_warning = &warnings[0];
if first_warning.has_uncommitted {
return Err(Error::WorktreeHasUncommittedChanges {
path: first_warning.path.clone(),
});
} else {
return Err(Error::WorktreeHasUnpushedCommits {
path: first_warning.path.clone(),
});
}
}
}
for path in &targets {
let worktree_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let branch = worktrees
.iter()
.find(|wt| &wt.path == path)
.and_then(|wt| wt.branch.as_deref())
.map(|b| b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
let hook_shell = {
#[cfg(windows)]
{
args.hook_shell
.clone()
.or_else(|| config.hooks.hook_shell.clone())
}
#[cfg(not(windows))]
{
None
}
};
let hook_env = HookEnv {
worktree_path: path.to_string_lossy().to_string(),
worktree_name,
branch,
repo_root: repo_root.to_string_lossy().to_string(),
vcs_type: provider.name().to_string(),
change_id: None,
commit_id: None,
hook_shell,
};
if !config.hooks.pre_remove.is_empty() {
if args.dry_run {
if !args.quiet {
hook::dry_run_hooks("pre_remove", &config.hooks.pre_remove, &output);
}
} else {
hook::run_pre_remove(&config.hooks, &hook_env, path, &output)?;
}
}
if args.dry_run {
output.dry_run(&format!("Would remove: {}", path.display()));
} else {
let use_force = args.force || !warnings.is_empty();
provider.workspace_remove_checked(path, use_force)?;
output.remove(path);
}
if !config.hooks.post_remove.is_empty() {
if args.dry_run {
if !args.quiet {
hook::dry_run_hooks("post_remove", &config.hooks.post_remove, &output);
}
} else if let Err(e) =
hook::run_post_remove(&config.hooks, &hook_env, &repo_root, &output)
{
let exit_code = match &e {
Error::HookFailed { exit_code, .. } => *exit_code,
_ => None,
};
output.hook_warning("post_remove", &e.to_string(), exit_code);
output.hook_note("Worktree was removed but post-cleanup may be incomplete.");
}
}
}
Ok(())
}
fn select_worktrees_interactively(worktrees: &[WorkspaceInfo]) -> Result<Vec<PathBuf>> {
prompt::clear_screen_interactive()?;
let paths = run_remove_selection(worktrees)?;
Ok(paths)
}
fn find_current_worktree(worktrees: &[WorkspaceInfo]) -> Result<PathBuf> {
let current_dir = std::env::current_dir()?;
let current_dir = current_dir
.canonicalize()
.unwrap_or_else(|_| current_dir.clone());
for wt in worktrees {
if current_dir.starts_with(&wt.path) {
return Ok(wt.path.clone());
}
}
Err(Error::NotInWorktree)
}
fn resolve_worktree_paths(paths: &[PathBuf], worktrees: &[WorkspaceInfo]) -> Result<Vec<PathBuf>> {
let mut resolved = Vec::new();
for path in paths {
let abs_path = if path.is_absolute() {
path.clone()
} else {
std::env::current_dir()?.join(path)
};
let canonical = abs_path
.canonicalize()
.map_err(|_| Error::WorktreeNotFound { path: path.clone() })?;
if !worktrees.iter().any(|wt| wt.path == canonical) {
return Err(Error::WorktreeNotFound { path: path.clone() });
}
resolved.push(canonical);
}
Ok(resolved)
}
fn is_main_worktree(path: &PathBuf, worktrees: &[WorkspaceInfo]) -> bool {
worktrees
.iter()
.find(|wt| &wt.path == path)
.map(|wt| wt.is_main)
.unwrap_or(false)
}
fn collect_safety_warnings(
targets: &[PathBuf],
provider: &dyn vcs::VcsProvider,
) -> Result<Vec<SafetyWarning>> {
let mut warnings = Vec::new();
for path in targets {
let status = provider.workspace_status(path)?;
let unpushed = provider.workspace_unpushed(path)?;
if status.has_uncommitted_changes || unpushed.has_unpushed {
warnings.push(SafetyWarning {
path: path.clone(),
has_uncommitted: status.has_uncommitted_changes,
modified_count: status.modified_count,
deleted_count: status.deleted_count,
untracked_count: status.untracked_count,
has_unpushed: unpushed.has_unpushed,
unpushed_count: unpushed.count,
});
}
}
Ok(warnings)
}
fn display_warning(output: &Output, warning: &SafetyWarning) {
if warning.modified_count > 0 {
output.safety_warning(
&warning.path,
&format!("{} modified file(s)", warning.modified_count),
);
}
if warning.deleted_count > 0 {
output.safety_warning(
&warning.path,
&format!("{} deleted file(s)", warning.deleted_count),
);
}
if warning.untracked_count > 0 {
output.safety_warning(
&warning.path,
&format!("{} untracked file(s)", warning.untracked_count),
);
}
if warning.has_unpushed {
output.safety_warning(
&warning.path,
&format!("{} unpushed commit(s)", warning.unpushed_count),
);
}
}