use super::pool;
use super::shared::{
compute_worktree_details, find_current_worktree, find_worktree, managed_worktrees_dir,
run_blocking_hook, spawn_background_hook, worktree_removal_blockers,
};
use crate::config::Config;
use crate::engine::BranchMetadata;
use crate::git::GitRepo;
use crate::git::repo::WorktreeInfo;
use anyhow::{Result, bail};
use colored::Colorize;
use dialoguer::{Confirm, theme::ColorfulTheme};
use std::io::IsTerminal;
fn effective_remove_force(force: bool, confirmed_dirty_removal: bool) -> bool {
force || confirmed_dirty_removal
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RemovalMode {
AllowParking,
RealRemove,
}
pub(crate) fn remove_worktree_with_hooks(
repo: &GitRepo,
config: &Config,
worktree: &WorktreeInfo,
force: bool,
mode: RemovalMode,
) -> Result<String> {
if worktree.is_main {
bail!("Cannot remove the main worktree.");
}
if !worktree.path.exists() {
bail!(
"Worktree path '{}' no longer exists. Run `stax worktree prune`.",
worktree.path.display()
);
}
let main_workdir = repo.main_repo_workdir()?;
run_blocking_hook(
config.worktree.hooks.pre_remove.as_deref(),
&main_workdir,
"pre_remove",
)?;
let display_name = worktree
.branch
.clone()
.unwrap_or_else(|| worktree.name.clone());
let removing_current_process_worktree = process_is_inside_worktree(&worktree.path);
let pool_dir = if config.worktree.reuse_slots && !removing_current_process_worktree {
pool_dir_for(repo, config, &worktree.path)?
} else {
None
};
if mode == RemovalMode::AllowParking && config.worktree.reuse_slots && !force {
if let Some(worktrees_dir) = pool_dir.as_deref() {
if try_park_slot(repo, config, worktree, worktrees_dir)? {
spawn_background_hook(
config.worktree.hooks.post_remove.as_deref(),
&main_workdir,
"post_remove",
)?;
return Ok(display_name);
}
}
}
repo.worktree_remove(&worktree.path, force)?;
if config.worktree.reuse_slots {
if let Some(worktrees_dir) = pool_dir.as_deref() {
let path = worktree.path.clone();
let _ = pool::with_lock(worktrees_dir, |pool| {
pool.remove_path(&path);
Ok(())
});
}
}
spawn_background_hook(
config.worktree.hooks.post_remove.as_deref(),
&main_workdir,
"post_remove",
)?;
Ok(display_name)
}
fn process_is_inside_worktree(worktree_path: &std::path::Path) -> bool {
let Ok(cwd) = std::env::current_dir() else {
return false;
};
let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
let canonical_worktree =
std::fs::canonicalize(worktree_path).unwrap_or_else(|_| worktree_path.to_path_buf());
canonical_cwd.starts_with(canonical_worktree)
}
fn pool_dir_for(
repo: &GitRepo,
config: &Config,
worktree_path: &std::path::Path,
) -> Result<Option<std::path::PathBuf>> {
let worktrees_dir = managed_worktrees_dir(repo, config)?;
let canonical_root =
std::fs::canonicalize(&worktrees_dir).unwrap_or_else(|_| worktrees_dir.clone());
let canonical_path =
std::fs::canonicalize(worktree_path).unwrap_or_else(|_| worktree_path.to_path_buf());
Ok(canonical_path
.starts_with(&canonical_root)
.then_some(worktrees_dir))
}
fn try_park_slot(
repo: &GitRepo,
config: &Config,
worktree: &WorktreeInfo,
worktrees_dir: &std::path::Path,
) -> Result<bool> {
let detail = compute_worktree_details(repo, worktree.clone())?;
if detail.dirty || !worktree_removal_blockers(&detail).is_empty() {
return Ok(false);
}
if let Some(branch) = worktree.branch.as_deref() {
if !repo.is_branch_merged_equivalent_to_trunk(branch)? {
return Ok(false);
}
}
let idle_count = pool::load(worktrees_dir)?.idle_count();
if idle_count >= config.worktree.max_idle_slots {
return Ok(false);
}
let trunk = repo.trunk_branch()?;
repo.park_slot(&worktree.path, &trunk)?;
let path = worktree.path.clone();
let branch = worktree.branch.clone();
pool::with_lock(worktrees_dir, |pool| {
pool.mark_idle(&path, branch);
Ok(())
})?;
Ok(true)
}
pub(crate) fn run_real_remove(
name: Option<String>,
force: bool,
delete_branch: bool,
) -> Result<()> {
run_with_mode(name, force, delete_branch, RemovalMode::RealRemove)
}
pub fn run(name: Option<String>, force: bool, delete_branch: bool) -> Result<()> {
run_with_mode(name, force, delete_branch, RemovalMode::AllowParking)
}
fn run_with_mode(
name: Option<String>,
force: bool,
delete_branch: bool,
mode: RemovalMode,
) -> Result<()> {
let repo = GitRepo::open()?;
let config = Config::load()?;
let worktree = match name {
Some(name) => find_worktree(&repo, &name)?
.ok_or_else(|| anyhow::anyhow!("No worktree named '{}'", name))?,
None => find_current_worktree(&repo)?,
};
if worktree.is_main {
bail!("Cannot remove the main worktree.");
}
if !worktree.path.exists() {
bail!(
"Worktree path '{}' no longer exists. Run `stax worktree prune`.",
worktree.path.display()
);
}
let mut confirmed_dirty_removal = false;
if !force && repo.is_dirty_at(&worktree.path)? {
eprintln!(
"{} Worktree '{}' has uncommitted changes.",
"Warning:".yellow().bold(),
worktree.name
);
if !std::io::stdin().is_terminal() {
bail!(
"`st wt rm` needs confirmation to remove a dirty worktree in non-interactive mode. Re-run with `--force`."
);
}
let proceed = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Remove anyway?")
.default(false)
.interact()?;
if !proceed {
println!("{}", "Aborted.".dimmed());
return Ok(());
}
confirmed_dirty_removal = true;
}
let branch = worktree.branch.clone();
let main_workdir = repo.main_repo_workdir()?;
let remove_force = effective_remove_force(force, confirmed_dirty_removal);
let display_name = remove_worktree_with_hooks(&repo, &config, &worktree, remove_force, mode)?;
if delete_branch {
let repo = GitRepo::open_from_path(&main_workdir)?;
if let Some(branch) = branch.as_deref() {
match repo.delete_branch(branch, force) {
Ok(()) => {
let _ = BranchMetadata::delete(repo.inner(), branch);
println!(" Deleted branch '{}'", branch.blue());
}
Err(error) => {
eprintln!(
"{}",
format!(" Warning: could not delete branch '{}': {}", branch, error)
.yellow()
);
}
}
} else {
eprintln!(
"{}",
" Warning: detached worktrees do not have a branch to delete.".yellow()
);
}
}
println!(
"{} worktree '{}'",
"Removed".green().bold(),
display_name.cyan()
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::effective_remove_force;
#[test]
fn confirmed_dirty_removal_upgrades_to_force() {
assert!(effective_remove_force(false, true));
}
#[test]
fn force_flag_stays_enabled_without_extra_confirmation() {
assert!(effective_remove_force(true, false));
}
#[test]
fn clean_non_forced_removal_stays_non_forced() {
assert!(!effective_remove_force(false, false));
}
}