use anyhow::{Context, Result};
use regex::Regex;
use std::path::Path;
use std::time::SystemTime;
use std::{thread, time::Duration};
use crate::config::MuxMode;
use crate::multiplexer::{Multiplexer, util::prefixed};
use crate::shell::shell_quote;
use crate::{cmd, git};
use tracing::{debug, info, warn};
pub use git::get_worktree_mode;
use super::context::WorkflowContext;
use super::types::{CleanupResult, DeferredCleanup};
const WINDOW_CLOSE_DELAY_MS: u64 = 300;
fn resolve_worktree_admin_dir(
worktree_path: &Path,
git_common_dir: &Path,
) -> Option<std::path::PathBuf> {
let git_file = worktree_path.join(".git");
if git_file.is_file() {
match std::fs::read_to_string(&git_file) {
Ok(content) => {
if let Some(raw) = content.trim().strip_prefix("gitdir: ") {
let p = Path::new(raw.trim());
let abs = if p.is_absolute() {
p.to_path_buf()
} else {
worktree_path.join(p)
};
return Some(abs);
}
warn!(
path = %git_file.display(),
"cleanup:worktree .git file missing 'gitdir:' prefix"
);
}
Err(e) => {
warn!(
path = %git_file.display(),
error = %e,
"cleanup:failed to read worktree .git file"
);
}
}
}
worktree_path
.file_name()
.map(|name| git_common_dir.join("worktrees").join(name))
}
fn remove_dir_contents(path: &Path) {
if !path.exists() {
return;
}
let entries = match std::fs::read_dir(path) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let entry_path = entry.path();
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
if is_dir {
let _ = std::fs::remove_dir_all(&entry_path);
} else {
let _ = std::fs::remove_file(&entry_path);
}
}
}
fn find_matching_windows(mux: &dyn Multiplexer, prefix: &str, handle: &str) -> Result<Vec<String>> {
let all_windows = mux.get_all_window_names()?;
let base_name = prefixed(prefix, handle);
let escaped_base = regex::escape(&base_name);
let pattern = format!(r"^{}(-\d+)?$", escaped_base);
let re = Regex::new(&pattern).expect("Invalid regex pattern");
let matching: Vec<String> = all_windows.into_iter().filter(|w| re.is_match(w)).collect();
Ok(matching)
}
fn is_inside_matching_target(
mux: &dyn Multiplexer,
prefix: &str,
handle: &str,
mode: MuxMode,
) -> Result<Option<String>> {
let current_name = if mode == MuxMode::Session {
mux.current_session()
} else {
mux.current_window_name()?
};
let current_name = match current_name {
Some(name) => name,
None => return Ok(None),
};
let base_name = prefixed(prefix, handle);
let escaped_base = regex::escape(&base_name);
let pattern = format!(r"^{}(-\d+)?$", escaped_base);
let re = Regex::new(&pattern).expect("Invalid regex pattern");
if re.is_match(¤t_name) {
Ok(Some(current_name))
} else {
Ok(None)
}
}
pub fn cleanup(
context: &WorkflowContext,
branch_name: &str,
handle: &str,
worktree_path: &Path,
force: bool,
keep_branch: bool,
no_hooks: bool,
) -> Result<CleanupResult> {
let mode = get_worktree_mode(handle);
let is_session_mode = mode == MuxMode::Session;
let kind = crate::multiplexer::handle::mode_label(mode);
info!(
branch = branch_name,
handle = handle,
path = %worktree_path.display(),
force,
keep_branch,
mode = kind,
"cleanup:start"
);
context.chdir_to_main_worktree()?;
let mux_running = context.mux.is_running().unwrap_or(false);
let current_matching_target = if mux_running {
is_inside_matching_target(context.mux.as_ref(), &context.prefix, handle, mode)?
} else {
None
};
let running_inside_target = current_matching_target.is_some();
let mut result = CleanupResult {
tmux_window_killed: false,
worktree_removed: false,
local_branch_deleted: false,
window_to_close_later: None,
trash_path_to_delete: None,
deferred_cleanup: None,
};
let perform_fs_git_cleanup = |result: &mut CleanupResult| -> Result<()> {
let worktree_admin_dir = resolve_worktree_admin_dir(worktree_path, &context.git_common_dir);
if worktree_path.exists() && !no_hooks {
if let Some(pre_remove_hooks) = &context.config.pre_remove {
info!(
branch = branch_name,
count = pre_remove_hooks.len(),
"cleanup:running pre-remove hooks"
);
let abs_worktree_path = worktree_path
.canonicalize()
.unwrap_or_else(|_| worktree_path.to_path_buf());
let abs_project_root = context
.main_worktree_root
.canonicalize()
.unwrap_or_else(|_| context.main_worktree_root.clone());
let worktree_path_str = abs_worktree_path.to_string_lossy();
let project_root_str = abs_project_root.to_string_lossy();
let hook_env = [
("WORKMUX_HANDLE", handle),
("WM_HANDLE", handle),
("WM_WORKTREE_PATH", worktree_path_str.as_ref()),
("WM_PROJECT_ROOT", project_root_str.as_ref()),
];
for command in pre_remove_hooks {
cmd::shell_command_with_env(command, worktree_path, &hook_env).with_context(
|| format!("Failed to run pre-remove command: '{}'", command),
)?;
}
}
} else {
debug!(
path = %worktree_path.display(),
"cleanup:skipping pre-remove hooks, worktree directory does not exist"
);
}
let mut trash_path: Option<std::path::PathBuf> = None;
if worktree_path.exists() {
let parent = worktree_path.parent().unwrap_or_else(|| Path::new("."));
let dir_name = worktree_path
.file_name()
.ok_or_else(|| anyhow::anyhow!("Invalid worktree path: no directory name"))?;
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let trash_name = format!(
".workmux_trash_{}_{}",
dir_name.to_string_lossy(),
timestamp
);
let target_trash_path = parent.join(&trash_name);
debug!(
from = %worktree_path.display(),
to = %target_trash_path.display(),
"cleanup:renaming worktree to trash"
);
std::fs::rename(worktree_path, &target_trash_path).with_context(|| {
format!(
"Failed to rename worktree directory to trash location '{}'. \
Please close any terminals or editors using this directory and try again.",
target_trash_path.display()
)
})?;
trash_path = Some(target_trash_path);
result.worktree_removed = true;
info!(branch = branch_name, path = %worktree_path.display(), "cleanup:worktree directory removed");
}
let temp_dir = std::env::temp_dir();
let prefix = format!("workmux-prompt-{}", branch_name);
if let Ok(entries) = std::fs::read_dir(&temp_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str())
&& filename.starts_with(&prefix)
&& filename.ends_with(".md")
{
if let Err(e) = std::fs::remove_file(&path) {
warn!(path = %path.display(), error = %e, "cleanup:failed to remove prompt file");
} else {
debug!(path = %path.display(), "cleanup:prompt file removed");
}
}
}
}
if let Some(ref admin_dir) = worktree_admin_dir {
let locked_file = admin_dir.join("locked");
match std::fs::remove_file(&locked_file) {
Ok(()) => debug!(path = %locked_file.display(), "cleanup:removed worktree lock"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
warn!(path = %locked_file.display(), error = %e, "cleanup:failed to remove worktree lock")
}
}
}
git::prune_worktrees_in(&context.git_common_dir).context("Failed to prune worktrees")?;
debug!("cleanup:git worktrees pruned");
if !keep_branch {
git::delete_branch_in(branch_name, force, &context.git_common_dir)
.context("Failed to delete local branch")?;
result.local_branch_deleted = true;
info!(branch = branch_name, "cleanup:local branch deleted");
}
if let Some(tp) = trash_path {
if result.window_to_close_later.is_some() {
debug!(path = %tp.display(), "cleanup:deferring trash deletion until window close");
result.trash_path_to_delete = Some(tp);
} else {
remove_dir_contents(&tp);
if let Err(e) = std::fs::remove_dir(&tp) {
warn!(
path = %tp.display(),
error = %e,
"cleanup:failed to remove trash directory (likely held by active shell). \
The directory is empty and harmless."
);
} else {
debug!(path = %tp.display(), "cleanup:trash directory removed");
}
}
}
Ok(())
};
if running_inside_target {
let current_target = current_matching_target.unwrap();
info!(
branch = branch_name,
current_target = current_target,
kind,
"cleanup:running inside matching target, deferring destructive cleanup",
);
if mux_running && !is_session_mode {
let matching_windows =
find_matching_windows(context.mux.as_ref(), &context.prefix, handle)?;
let mut killed_count = 0;
for window in &matching_windows {
if window != ¤t_target {
if let Err(e) = context.mux.kill_window(window) {
warn!(window = window, error = %e, "cleanup:failed to kill duplicate window");
} else {
killed_count += 1;
debug!(window = window, "cleanup:killed duplicate window");
}
}
}
if killed_count > 0 {
info!(
count = killed_count,
kind, "cleanup:killed duplicate {}s", kind
);
}
}
result.window_to_close_later = Some(current_target);
if worktree_path.exists()
&& !no_hooks
&& let Some(pre_remove_hooks) = &context.config.pre_remove
{
info!(
branch = branch_name,
count = pre_remove_hooks.len(),
"cleanup:running pre-remove hooks"
);
let abs_worktree_path = worktree_path
.canonicalize()
.unwrap_or_else(|_| worktree_path.to_path_buf());
let abs_project_root = context
.main_worktree_root
.canonicalize()
.unwrap_or_else(|_| context.main_worktree_root.clone());
let worktree_path_str = abs_worktree_path.to_string_lossy();
let project_root_str = abs_project_root.to_string_lossy();
let hook_env = [
("WORKMUX_HANDLE", handle),
("WM_HANDLE", handle),
("WM_WORKTREE_PATH", worktree_path_str.as_ref()),
("WM_PROJECT_ROOT", project_root_str.as_ref()),
];
for command in pre_remove_hooks {
cmd::shell_command_with_env(command, worktree_path, &hook_env)
.with_context(|| format!("Failed to run pre-remove command: '{}'", command))?;
}
}
let temp_dir = std::env::temp_dir();
let prefix = format!("workmux-prompt-{}", branch_name);
if let Ok(entries) = std::fs::read_dir(&temp_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str())
&& filename.starts_with(&prefix)
&& filename.ends_with(".md")
{
if let Err(e) = std::fs::remove_file(&path) {
warn!(path = %path.display(), error = %e, "cleanup:failed to remove prompt file");
} else {
debug!(path = %path.display(), "cleanup:prompt file removed");
}
}
}
}
if worktree_path.exists() {
let parent = worktree_path.parent().unwrap_or_else(|| Path::new("."));
let dir_name = worktree_path
.file_name()
.ok_or_else(|| anyhow::anyhow!("Invalid worktree path: no directory name"))?;
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let trash_name = format!(
".workmux_trash_{}_{}",
dir_name.to_string_lossy(),
timestamp
);
let trash_path = parent.join(&trash_name);
let worktree_admin_dir =
resolve_worktree_admin_dir(worktree_path, &context.git_common_dir);
result.deferred_cleanup = Some(DeferredCleanup {
worktree_path: worktree_path.to_path_buf(),
trash_path,
branch_name: branch_name.to_string(),
handle: handle.to_string(),
keep_branch,
force,
git_common_dir: context.git_common_dir.clone(),
worktree_admin_dir,
});
debug!(
worktree = %worktree_path.display(),
kind,
"cleanup:deferred destructive cleanup until target close",
);
}
} else {
if mux_running {
if is_session_mode {
let session_name = prefixed(&context.prefix, handle);
if context.mux.session_exists(&session_name)? {
if let Err(e) = context.mux.kill_session(&session_name) {
warn!(session = session_name, error = %e, "cleanup:failed to kill session");
} else {
result.tmux_window_killed = true;
info!(session = session_name, "cleanup:killed session");
const MAX_RETRIES: u32 = 20;
const RETRY_DELAY: Duration = Duration::from_millis(50);
for _ in 0..MAX_RETRIES {
if !context.mux.session_exists(&session_name)? {
break;
}
thread::sleep(RETRY_DELAY);
}
}
}
} else {
let matching_windows =
find_matching_windows(context.mux.as_ref(), &context.prefix, handle)?;
let mut killed_count = 0;
for window in &matching_windows {
if let Err(e) = context.mux.kill_window(window) {
warn!(window = window, error = %e, "cleanup:failed to kill window");
} else {
killed_count += 1;
debug!(window = window, "cleanup:killed window");
}
}
if killed_count > 0 {
result.tmux_window_killed = true;
info!(
count = killed_count,
handle = handle,
"cleanup:killed all matching windows"
);
const MAX_RETRIES: u32 = 20;
const RETRY_DELAY: Duration = Duration::from_millis(50);
for _ in 0..MAX_RETRIES {
let remaining =
find_matching_windows(context.mux.as_ref(), &context.prefix, handle)?;
if remaining.is_empty() {
break;
}
thread::sleep(RETRY_DELAY);
}
}
}
}
perform_fs_git_cleanup(&mut result)?;
}
if result.deferred_cleanup.is_none()
&& let Err(e) = git::remove_worktree_meta(handle)
{
warn!(handle = handle, error = %e, "cleanup:failed to remove worktree metadata");
}
Ok(result)
}
fn build_deferred_cleanup_script(dc: &DeferredCleanup) -> String {
let wt = shell_quote(&dc.worktree_path.to_string_lossy());
let trash = shell_quote(&dc.trash_path.to_string_lossy());
let git_dir = shell_quote(&dc.git_common_dir.to_string_lossy());
let mut cmds = Vec::new();
cmds.push(format!("mv {} {} >/dev/null 2>&1", wt, trash));
if let Some(ref admin_dir) = dc.worktree_admin_dir
&& admin_dir.is_absolute()
{
let locked = shell_quote(&admin_dir.join("locked").to_string_lossy());
cmds.push(format!("rm -f {} >/dev/null 2>&1", locked));
}
cmds.push(format!("git -C {} worktree prune >/dev/null 2>&1", git_dir));
if !dc.keep_branch {
let branch = shell_quote(&dc.branch_name);
let force_flag = if dc.force { "-D" } else { "-d" };
cmds.push(format!(
"git -C {} branch {} {} >/dev/null 2>&1",
git_dir, force_flag, branch
));
}
let handle = shell_quote(&dc.handle);
cmds.push(format!(
"git -C {} config --local --remove-section workmux.worktree.{} >/dev/null 2>&1",
git_dir, handle
));
cmds.push(format!("rm -rf {} >/dev/null 2>&1", trash));
format!("; {}", cmds.join("; "))
}
pub fn navigate_to_target_and_close(
mux: &dyn Multiplexer,
prefix: &str,
target_window_name: &str,
source_handle: &str,
cleanup_result: &CleanupResult,
mode: MuxMode,
) -> Result<()> {
use crate::multiplexer::MuxHandle;
let mux_running = mux.is_running()?;
let target_full = prefixed(prefix, target_window_name);
let (target_exists, target_mode) = if mux_running {
let is_session = mux.session_exists(&target_full).unwrap_or(false);
let is_window = mux
.window_exists_by_full_name(&target_full)
.unwrap_or(false);
if is_session {
(true, MuxMode::Session)
} else if is_window {
(true, MuxMode::Window)
} else {
(false, mode) }
} else {
(false, mode)
};
let kind = crate::multiplexer::handle::mode_label(mode);
let source_full = cleanup_result
.window_to_close_later
.clone()
.unwrap_or_else(|| prefixed(prefix, source_handle));
let kill_source_cmd = MuxHandle::shell_kill_cmd_full(mux, mode, &source_full).ok();
let select_target_cmd = MuxHandle::shell_select_cmd_full(mux, target_mode, &target_full).ok();
debug!(
prefix = prefix,
target_window_name = target_window_name,
mux_running = mux_running,
target_exists = target_exists,
kind,
window_to_close = ?cleanup_result.window_to_close_later,
deferred_cleanup = cleanup_result.deferred_cleanup.is_some(),
"navigate_to_target_and_close:entry"
);
if !mux_running || !target_exists {
if let Some(ref window_to_close) = cleanup_result.window_to_close_later {
let delay = Duration::from_millis(WINDOW_CLOSE_DELAY_MS);
let delay_secs = format!("{:.3}", delay.as_secs_f64());
let cleanup_script = if let Some(ref dc) = cleanup_result.deferred_cleanup {
build_deferred_cleanup_script(dc)
} else {
cleanup_result
.trash_path_to_delete
.as_ref()
.map(|tp| format!("; rm -rf {}", shell_quote(&tp.to_string_lossy())))
.unwrap_or_default()
};
let switch_last_part = if mode == MuxMode::Session {
mux.shell_switch_to_last_session_cmd()
.ok()
.map(|cmd| format!("{}; ", cmd))
.unwrap_or_default()
} else {
String::new()
};
let kill_part = kill_source_cmd
.as_ref()
.map(|cmd| format!("{}; ", cmd))
.unwrap_or_default();
let script = format!(
"sleep {delay}; {switch}{kill}{cleanup}",
delay = delay_secs,
switch = switch_last_part,
kill = kill_part,
cleanup = cleanup_script.strip_prefix("; ").unwrap_or(&cleanup_script),
);
debug!(
script = script,
kind, "navigate_to_target_and_close:kill_only_script"
);
match mux.run_deferred_script(&script) {
Ok(_) => info!(
target = window_to_close,
script = script,
kind,
"cleanup:scheduled target close",
),
Err(e) => warn!(
target = window_to_close,
error = ?e,
kind,
"cleanup:failed to schedule target close",
),
}
}
return Ok(());
}
if cleanup_result.window_to_close_later.is_some() {
let delay = Duration::from_millis(WINDOW_CLOSE_DELAY_MS);
let delay_secs = format!("{:.3}", delay.as_secs_f64());
let cleanup_script = if let Some(ref dc) = cleanup_result.deferred_cleanup {
build_deferred_cleanup_script(dc)
} else {
cleanup_result
.trash_path_to_delete
.as_ref()
.map(|tp| format!("; rm -rf {}", shell_quote(&tp.to_string_lossy())))
.unwrap_or_default()
};
let select_part = select_target_cmd
.as_ref()
.map(|cmd| format!("{}; ", cmd))
.unwrap_or_default();
let kill_part = kill_source_cmd
.as_ref()
.map(|cmd| format!("{}; ", cmd))
.unwrap_or_default();
let script = format!(
"sleep {delay}; {select}{kill}{cleanup}",
delay = delay_secs,
select = select_part,
kill = kill_part,
cleanup = cleanup_script.strip_prefix("; ").unwrap_or(&cleanup_script),
);
debug!(
script = script,
kind, "navigate_to_target_and_close:nav_and_kill_script"
);
match mux.run_deferred_script(&script) {
Ok(_) => info!(
source = source_handle,
target = target_window_name,
kind,
"cleanup:scheduled navigation to target and source close",
),
Err(e) => warn!(
source = source_handle,
error = ?e,
kind,
"cleanup:failed to schedule navigation and source close",
),
}
} else if !cleanup_result.tmux_window_killed {
let target = MuxHandle::new(mux, target_mode, prefix, target_window_name);
target.select()?;
info!(
handle = source_handle,
target = target_window_name,
kind,
"cleanup:navigated to target branch",
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_deferred_cleanup(
worktree: &str,
trash: &str,
branch: &str,
handle: &str,
git_dir: &str,
keep_branch: bool,
force: bool,
) -> DeferredCleanup {
DeferredCleanup {
worktree_path: PathBuf::from(worktree),
trash_path: PathBuf::from(trash),
branch_name: branch.to_string(),
handle: handle.to_string(),
keep_branch,
force,
git_common_dir: PathBuf::from(git_dir),
worktree_admin_dir: None,
}
}
#[test]
fn deferred_cleanup_script_includes_all_steps() {
let dc = make_deferred_cleanup(
"/repo/worktrees/feature",
"/repo/worktrees/.workmux_trash_feature_123",
"feature",
"feature",
"/repo/.git",
false,
false,
);
let script = build_deferred_cleanup_script(&dc);
assert!(script.contains(
"mv /repo/worktrees/feature /repo/worktrees/.workmux_trash_feature_123 >/dev/null 2>&1"
));
assert!(script.contains("git -C /repo/.git worktree prune >/dev/null 2>&1"));
assert!(script.contains("git -C /repo/.git branch -d feature >/dev/null 2>&1"));
assert!(script.contains("git -C /repo/.git config --local --remove-section workmux.worktree.feature >/dev/null 2>&1"));
assert!(
script.contains("rm -rf /repo/worktrees/.workmux_trash_feature_123 >/dev/null 2>&1")
);
}
#[test]
fn deferred_cleanup_script_keep_branch_skips_branch_delete() {
let dc = make_deferred_cleanup(
"/repo/worktrees/feature",
"/repo/worktrees/.trash",
"feature",
"feature",
"/repo/.git",
true, false,
);
let script = build_deferred_cleanup_script(&dc);
assert!(
!script.contains("branch -d"),
"Should not delete branch when keep_branch is set"
);
assert!(
!script.contains("branch -D"),
"Should not delete branch when keep_branch is set"
);
assert!(script.contains("mv "));
assert!(script.contains("worktree prune"));
assert!(script.contains("config --local --remove-section"));
assert!(script.contains("rm -rf"));
}
#[test]
fn deferred_cleanup_script_force_uses_capital_d() {
let dc = make_deferred_cleanup(
"/repo/worktrees/feature",
"/repo/worktrees/.trash",
"feature",
"feature",
"/repo/.git",
false,
true, );
let script = build_deferred_cleanup_script(&dc);
assert!(
script.contains("branch -D feature"),
"Force delete should use -D flag"
);
assert!(
!script.contains("branch -d feature"),
"Force delete should not use -d flag"
);
}
#[test]
fn deferred_cleanup_script_quotes_paths_with_spaces() {
let dc = make_deferred_cleanup(
"/my repo/worktrees/my feature",
"/my repo/worktrees/.trash_123",
"my-feature",
"my-feature",
"/my repo/.git",
false,
false,
);
let script = build_deferred_cleanup_script(&dc);
assert!(
script.contains("'/my repo/worktrees/my feature'"),
"Worktree path with spaces should be quoted: {script}"
);
assert!(
script.contains("'/my repo/worktrees/.trash_123'"),
"Trash path with spaces should be quoted: {script}"
);
assert!(
script.contains("'/my repo/.git'"),
"Git dir with spaces should be quoted: {script}"
);
}
#[test]
fn deferred_cleanup_script_preserves_command_order() {
let dc = make_deferred_cleanup(
"/repo/wt/feat",
"/repo/wt/.trash",
"feat",
"feat",
"/repo/.git",
false,
false,
);
let script = build_deferred_cleanup_script(&dc);
let mv_pos = script.find("mv ").expect("should contain mv");
let prune_pos = script.find("worktree prune").expect("should contain prune");
let branch_pos = script.find("branch -d").expect("should contain branch -d");
let config_pos = script
.find("config --local --remove-section")
.expect("should contain config remove");
let rm_pos = script.find("rm -rf").expect("should contain rm -rf");
assert!(mv_pos < prune_pos, "mv should precede prune");
assert!(prune_pos < branch_pos, "prune should precede branch delete");
assert!(
branch_pos < config_pos,
"branch delete should precede config remove"
);
assert!(config_pos < rm_pos, "config remove should precede rm");
}
#[test]
fn deferred_cleanup_script_starts_with_separator() {
let dc = make_deferred_cleanup(
"/repo/wt/feat",
"/repo/wt/.trash",
"feat",
"feat",
"/repo/.git",
false,
false,
);
let script = build_deferred_cleanup_script(&dc);
assert!(
script.starts_with("; "),
"Script should start with '; ' so it can be appended to other commands: {script}"
);
}
#[test]
fn deferred_cleanup_script_simple_paths_not_quoted() {
let dc = make_deferred_cleanup(
"/repo/worktrees/feature-branch",
"/repo/worktrees/.trash_feature",
"feature-branch",
"feature-branch",
"/repo/.git",
false,
false,
);
let script = build_deferred_cleanup_script(&dc);
assert!(
script.contains("mv /repo/worktrees/feature-branch /repo/worktrees/.trash_feature"),
"Simple paths should not be quoted: {script}"
);
}
#[test]
fn deferred_cleanup_script_removes_lock_when_admin_dir_set() {
let mut dc = make_deferred_cleanup(
"/repo/worktrees/feature",
"/repo/worktrees/.trash",
"feature",
"feature",
"/repo/.git",
false,
false,
);
dc.worktree_admin_dir = Some(PathBuf::from("/repo/.git/worktrees/feature"));
let script = build_deferred_cleanup_script(&dc);
assert!(
script.contains("rm -f /repo/.git/worktrees/feature/locked"),
"Should remove lock file when admin dir is set: {script}"
);
let mv_pos = script.find("mv ").unwrap();
let lock_pos = script
.find("rm -f /repo/.git/worktrees/feature/locked")
.unwrap();
let prune_pos = script.find("worktree prune").unwrap();
assert!(mv_pos < lock_pos, "lock removal should follow mv");
assert!(lock_pos < prune_pos, "lock removal should precede prune");
}
#[test]
fn deferred_cleanup_script_no_lock_step_without_admin_dir() {
let dc = make_deferred_cleanup(
"/repo/worktrees/feature",
"/repo/worktrees/.trash",
"feature",
"feature",
"/repo/.git",
false,
false,
);
let script = build_deferred_cleanup_script(&dc);
assert!(
!script.contains("/locked"),
"Should not have lock removal without admin dir: {script}"
);
}
}