mod items;
mod log_formatter;
mod pager;
mod preview;
pub(crate) mod preview_cache;
mod preview_orchestrator;
mod progressive_handler;
mod summary;
use std::cell::RefCell;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::AtomicUsize;
use std::sync::{Arc, Mutex};
use anyhow::Context;
use skim::prelude::*;
use skim::reader::CommandCollector;
use worktrunk::HookType;
use worktrunk::config::Approvals;
use worktrunk::git::{Repository, current_or_recover};
use worktrunk::styling::eprintln;
use super::hook_plan::{ApprovedHookPlan, HookPlanBuilder};
use super::hooks::HookAnnouncer;
use super::list::collect;
use super::list::progressive::RenderTarget;
use super::repository_ext::{RemoveTarget, RepositoryCliExt};
use super::worktree::{RemoveResult, SwitchPipeline};
use crate::cli::SwitchFormat;
use crate::output::{BackgroundFallbackMode, handle_remove_output};
use worktrunk::git::{BranchDeletionMode, delete_branch_if_safe};
use items::{PreviewCache, WORKTREE_OUTPUT_PREFIX};
use preview::{PreviewLayout, PreviewMode, PreviewState};
use preview_orchestrator::PreviewOrchestrator;
fn drain_stashed_warnings(stash: &Mutex<Vec<String>>) {
for line in stash.lock().unwrap().drain(..) {
eprintln!("{line}");
}
}
enum PickerAction {
Switch,
Create,
}
enum PickerRemovalTarget {
WorktreePath(PathBuf),
Branch(String),
}
impl PickerRemovalTarget {
fn from_signal(signal: &str) -> Option<Self> {
let signal = signal.trim();
if signal.is_empty() {
return None;
}
if let Some(path) = signal.strip_prefix(WORKTREE_OUTPUT_PREFIX) {
if path.is_empty() {
return None;
}
return Some(Self::WorktreePath(PathBuf::from(path)));
}
Some(Self::Branch(signal.to_string()))
}
}
fn picker_item_identifier(item: &dyn SkimItem) -> String {
let output = item.output().to_string();
match PickerRemovalTarget::from_signal(&output) {
Some(PickerRemovalTarget::WorktreePath(path)) => path.to_string_lossy().into_owned(),
_ => output,
}
}
struct PickerCollector {
items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
signal_path: PathBuf,
repo: Repository,
approvals: Arc<Approvals>,
}
impl PickerCollector {
fn prepare_removal(
&self,
target: &PickerRemovalTarget,
) -> anyhow::Result<(Repository, RemoveResult)> {
let repo = Repository::at(self.repo.discovery_path())?;
let caller_path = repo.current_worktree().root().ok();
let result = {
let config = repo.user_config();
let remove_target = match target {
PickerRemovalTarget::WorktreePath(path) => RemoveTarget::Path(path),
PickerRemovalTarget::Branch(branch) => RemoveTarget::Branch(branch),
};
repo.prepare_worktree_removal(
remove_target,
BranchDeletionMode::SafeDelete,
false,
config,
caller_path,
None,
None,
)?
};
Ok((repo, result))
}
fn do_removal(
repo: &Repository,
result: &RemoveResult,
approvals: &Approvals,
) -> anyhow::Result<()> {
match result {
RemoveResult::RemovedWorktree {
main_path,
worktree_path,
..
} => {
let main_repo = Repository::at(main_path)?;
let plan = approved_removal_plan(repo, main_path, worktree_path, approvals)?;
let mut announcer = HookAnnouncer::new(&main_repo, main_repo.user_config(), false);
handle_remove_output(
result,
true,
&plan,
true,
true,
&mut announcer,
BackgroundFallbackMode::Detached,
)?;
announcer.flush()?;
}
RemoveResult::BranchOnly {
branch_name,
deletion_mode,
..
} => {
if !deletion_mode.should_keep() {
let default_branch = repo.default_branch();
let target = default_branch.as_deref().unwrap_or("HEAD");
if let Ok(snapshot) = repo.capture_refs() {
let _ = delete_branch_if_safe(
repo,
&snapshot,
branch_name,
target,
deletion_mode.is_force(),
);
}
}
}
}
Ok(())
}
}
impl CommandCollector for PickerCollector {
fn invoke(
&mut self,
_cmd: &str,
components_to_stop: Arc<AtomicUsize>,
) -> (SkimItemReceiver, Sender<i32>) {
if let Ok(signal) = std::fs::read_to_string(&self.signal_path) {
let selected_output = signal.trim().to_string();
if let Some(removal_target) = PickerRemovalTarget::from_signal(&selected_output) {
let preparation = self.prepare_removal(&removal_target);
match preparation {
Ok((planning_repo, result)) => {
{
let mut items = self.items.lock().unwrap();
items.retain(|item| item.output().as_ref() != selected_output);
}
if matches!(
&result,
RemoveResult::RemovedWorktree {
changed_directory: true,
..
}
) && let Some(home) = result.destination_path()
{
let _ = std::env::set_current_dir(home);
if let Ok(repo) = Repository::at(home) {
self.repo = repo;
}
}
let repo = planning_repo.clone();
let approvals = Arc::clone(&self.approvals);
let _ = std::thread::Builder::new()
.name(format!("picker-remove-{selected_output}"))
.spawn(move || {
if let Err(e) = Self::do_removal(&repo, &result, &approvals) {
log::warn!(
"picker: failed to remove '{selected_output}': {e:#}"
);
}
});
}
Err(e) => {
log::info!("picker: cannot remove '{selected_output}': {e:#}");
}
}
let _ = std::fs::write(&self.signal_path, "");
}
}
let items = self.items.lock().unwrap();
let (tx, rx) = unbounded();
for item in items.iter() {
let _ = tx.send(Arc::clone(item));
}
drop(tx);
let _ = components_to_stop;
let (tx_interrupt, _rx_interrupt) = bounded(1);
(rx, tx_interrupt)
}
}
fn approved_removal_plan(
repo: &Repository,
main_path: &Path,
worktree_path: &Path,
approvals: &Approvals,
) -> anyhow::Result<ApprovedHookPlan> {
let project_id = repo.project_identifier().ok();
let pid = project_id.as_deref();
let user = repo.user_config();
let project_config = repo.load_project_config()?;
let mut builder = HookPlanBuilder::new(project_config.as_ref(), user, pid);
builder.add(worktree_path, &[HookType::PreRemove, HookType::PostRemove]);
builder.add(main_path, &[HookType::PostSwitch]);
Ok(builder.finish().approve_readonly(approvals, pid))
}
pub fn handle_picker(
cli_branches: bool,
cli_remotes: bool,
change_dir_flag: Option<bool>,
format: SwitchFormat,
) -> anyhow::Result<()> {
let is_dry_run = std::env::var_os("WORKTRUNK_PICKER_DRY_RUN").is_some();
let is_preview_bench = std::env::var_os("WORKTRUNK_PREVIEW_BENCH").is_some();
let skip_tui = is_dry_run || is_preview_bench;
if !skip_tui && !std::io::stdin().is_terminal() {
anyhow::bail!("Interactive picker requires an interactive terminal");
}
worktrunk::trace::instant("Picker started");
let (repo, is_recovered) = current_or_recover()?;
let config = repo.config();
let change_dir = change_dir_flag.unwrap_or_else(|| config.switch.cd());
let show_branches = cli_branches || config.list.branches();
let show_remotes = cli_remotes || config.list.remotes();
worktrunk::trace::instant("Picker config resolved");
let state = PreviewState::new();
worktrunk::trace::instant("Picker layout detected");
let _ = repo.current_worktree().prewarm_info();
let orchestrator = Arc::new(PreviewOrchestrator::new(repo.clone()));
let preview_cache: PreviewCache = Arc::clone(&orchestrator.cache);
if let (Ok(Some(branch)), Ok(path)) = (
repo.current_worktree().branch(),
repo.current_worktree().root(),
) {
use super::list::model::{ItemKind, ListItem, WorktreeData};
let mut item = ListItem::new_branch(String::new(), branch);
item.kind = ItemKind::Worktree(Box::new(WorktreeData {
path,
..Default::default()
}));
let dims = state.initial_layout.preview_dimensions(0);
orchestrator.spawn_preview(Arc::new(item), PreviewMode::WorkingTree, dims);
}
let skip_tasks: std::collections::HashSet<collect::TaskKind> =
[collect::TaskKind::BranchDiff, collect::TaskKind::CiStatus]
.into_iter()
.collect();
let command_timeout = config.list.task_timeout();
let terminal_width = crate::display::terminal_width();
let skim_list_width = match state.initial_layout {
PreviewLayout::Right => terminal_width / 2,
PreviewLayout::Down => terminal_width,
};
let num_items_estimate = {
let cap = preview::MAX_VISIBLE_ITEMS;
let mut estimate = repo.list_worktrees().map(|w| w.len()).unwrap_or(cap);
if estimate < cap && show_branches {
let local = repo.local_branches().map(|b| b.len()).unwrap_or(cap);
estimate = estimate.max(local);
}
if estimate < cap && show_remotes {
let remotes = repo.remote_branches().map(|b| b.len()).unwrap_or(0);
estimate = estimate.saturating_add(remotes);
}
estimate
};
worktrunk::trace::instant("Picker estimate computed");
let preview_window_spec = state
.initial_layout
.to_preview_window_spec(num_items_estimate);
let preview_dims = state.initial_layout.preview_dimensions(num_items_estimate);
let (llm_command, summary_hint) =
if config.list.summary() && config.commit_generation.is_configured() {
(config.commit_generation.command.clone(), None)
} else {
let hint = if !config.commit_generation.is_configured() {
"Configure [commit.generation] command to enable LLM summaries.\n\n\
Example in ~/.config/worktrunk/config.toml:\n\n\
[commit.generation]\n\
command = \"llm -m haiku\"\n\n\
[list]\n\
summary = true\n"
} else {
"Enable summaries in ~/.config/worktrunk/config.toml:\n\n\
[list]\n\
summary = true\n"
};
(None, Some(hint.to_string()))
};
let shared_items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>> = Arc::new(Mutex::new(Vec::new()));
let signal_path = state.path.with_extension("remove");
let approvals = Arc::new(Approvals::load().context("Failed to load approvals")?);
let collector = PickerCollector {
items: Arc::clone(&shared_items),
signal_path: signal_path.clone(),
repo: repo.clone(),
approvals,
};
let signal_path_escaped =
shell_escape::unix::escape(signal_path.display().to_string().into()).into_owned();
let state_path_display = state.path.display().to_string();
let state_path_str = shell_escape::unix::escape(state_path_display.into()).into_owned();
let half_page = terminal_size::terminal_size()
.map(|(_, terminal_size::Height(h))| (h as usize * 45 / 100).max(5))
.unwrap_or(10);
let options = SkimOptionsBuilder::default()
.height("90%".to_string())
.layout("reverse".to_string())
.header_lines(1) .multi(false)
.no_info(true) .preview(Some("".to_string())) .preview_window(preview_window_spec)
.no_clear_start(true)
.color(Some(
"fg:-1,bg:-1,header:-1,matched:108,current:237,current_bg:251,current_match:108"
.to_string(),
))
.cmd_collector(Rc::new(RefCell::new(collector)) as Rc<RefCell<dyn CommandCollector>>)
.bind(vec![
format!(
"1:execute-silent(echo 1 > {0})+refresh-preview",
state_path_str
),
format!(
"2:execute-silent(echo 2 > {0})+refresh-preview",
state_path_str
),
format!(
"3:execute-silent(echo 3 > {0})+refresh-preview",
state_path_str
),
format!(
"4:execute-silent(echo 4 > {0})+refresh-preview",
state_path_str
),
format!(
"5:execute-silent(echo 5 > {0})+refresh-preview",
state_path_str
),
"alt-c:accept(create)".to_string(),
format!(
"alt-r:execute-silent(echo {{}} > {0})+reload(remove)",
signal_path_escaped
),
"alt-p:toggle-preview".to_string(),
format!("ctrl-u:preview-up({half_page})"),
format!("ctrl-d:preview-down({half_page})"),
])
.build()
.map_err(|e| anyhow::anyhow!("Failed to build skim options: {}", e))?;
worktrunk::trace::instant("Picker skim options built");
let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded();
let stashed_warnings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let handler: Arc<dyn collect::PickerProgressHandler> =
Arc::new(progressive_handler::PickerHandler {
tx: tx.clone(),
shared_items: Arc::clone(&shared_items),
rendered_slots: std::sync::OnceLock::new(),
preview_cache: Arc::clone(&preview_cache),
orchestrator: Arc::clone(&orchestrator),
preview_dims,
llm_command,
summary_hint,
stashed_warnings: Arc::clone(&stashed_warnings),
deferred_items: std::sync::OnceLock::new(),
});
let bg_handler = Arc::clone(&handler);
let bg_repo = repo.clone();
let bg_skip_tasks = skip_tasks.clone();
let bg_handle = std::thread::Builder::new()
.name("picker-collect".into())
.spawn(move || {
let _ = collect::collect(
&bg_repo,
collect::ShowConfig::Resolved {
show_branches,
show_remotes,
skip_tasks: bg_skip_tasks,
command_timeout,
collect_deadline: None,
list_width: Some(skim_list_width),
progressive_handler: Some(bg_handler),
},
RenderTarget::Json,
);
})
.context("Failed to spawn picker-collect thread")?;
worktrunk::trace::instant("Picker collect spawned");
drop(tx);
drop(handler);
if skip_tui {
drop(rx);
let _ = bg_handle.join();
orchestrator.wait_for_idle();
if is_dry_run {
drain_stashed_warnings(&stashed_warnings);
println!("{}", orchestrator.dump_cache_json());
}
return Ok(());
}
let output = Skim::run_with(&options, Some(rx));
drop(bg_handle);
drain_stashed_warnings(&stashed_warnings);
if let Some(out) = output
&& !out.is_abort
{
let action = match &out.final_event {
Event::EvActAccept(Some(label)) if label == "create" => PickerAction::Create,
_ => PickerAction::Switch,
};
let should_create = matches!(action, PickerAction::Create);
let selected = out.selected_items.first();
let selected_name = selected.map(|item| picker_item_identifier(item.as_ref()));
let query = out.query.trim().to_string();
let identifier = resolve_identifier(&action, query, selected_name)?;
let repo = if is_recovered {
repo.clone()
} else {
Repository::current().context("Failed to switch worktree")?
};
let mut config = repo.user_config().clone();
SwitchPipeline {
repo: &repo,
config: &mut config,
identifier: &identifier,
create: should_create,
base: None,
clobber: false,
verify: true,
yes: false,
change_dir,
format,
is_recovered,
suggestion_ctx: None,
capture_source: false,
execute: None,
execute_args: &[],
shell_integration_binary: None,
}
.run()?;
}
Ok(())
}
fn resolve_identifier(
action: &PickerAction,
query: String,
selected_name: Option<String>,
) -> anyhow::Result<String> {
match action {
PickerAction::Create => {
if query.is_empty() {
anyhow::bail!("Cannot create worktree: no branch name entered");
}
Ok(query)
}
PickerAction::Switch => match selected_name {
Some(name) => Ok(name),
None => {
if query.is_empty() {
anyhow::bail!("No worktree selected");
} else {
anyhow::bail!(
"No worktree matches '{query}' — use alt-c to create a new worktree"
);
}
}
},
}
}
#[cfg(test)]
pub mod tests {
use super::items::WorktreeSkimItem;
use super::preview::{PreviewLayout, PreviewMode, PreviewStateData};
use super::{
PickerAction, PickerCollector, PickerRemovalTarget, drain_stashed_warnings,
picker_item_identifier, resolve_identifier,
};
use crate::commands::list::model::{ItemKind, ListItem, WorktreeData};
use crate::commands::worktree::RemoveResult;
use skim::prelude::SkimItem;
use skim::reader::CommandCollector;
use std::fs;
use std::path::Path;
use std::sync::atomic::AtomicUsize;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use worktrunk::config::Approvals;
use worktrunk::git::BranchDeletionMode;
#[test]
fn drain_stashed_warnings_empties_the_stash() {
let stash = Mutex::new(vec!["one".to_string(), "two".to_string()]);
drain_stashed_warnings(&stash);
assert!(stash.lock().unwrap().is_empty());
}
#[test]
fn drain_stashed_warnings_handles_empty_stash() {
let stash: Mutex<Vec<String>> = Mutex::new(Vec::new());
drain_stashed_warnings(&stash);
assert!(stash.lock().unwrap().is_empty());
}
#[test]
fn test_preview_state_data_roundtrip() {
let state_path = PreviewStateData::state_path();
let _ = fs::write(&state_path, "1");
assert_eq!(PreviewStateData::read_mode(), PreviewMode::WorkingTree);
let _ = fs::write(&state_path, "2");
assert_eq!(PreviewStateData::read_mode(), PreviewMode::Log);
let _ = fs::write(&state_path, "3");
assert_eq!(PreviewStateData::read_mode(), PreviewMode::BranchDiff);
let _ = fs::write(&state_path, "4");
assert_eq!(PreviewStateData::read_mode(), PreviewMode::UpstreamDiff);
let _ = fs::write(&state_path, "5");
assert_eq!(PreviewStateData::read_mode(), PreviewMode::Summary);
let _ = fs::remove_file(&state_path);
}
#[test]
fn test_preview_layout() {
let spec = PreviewLayout::Right.to_preview_window_spec(10);
assert!(spec.starts_with("right:"));
let spec = PreviewLayout::Down.to_preview_window_spec(5);
assert!(spec.starts_with("down:"));
}
#[test]
fn test_resolve_identifier() {
let result = resolve_identifier(
&PickerAction::Switch,
String::new(),
Some("feature/foo".into()),
);
assert_eq!(result.unwrap(), "feature/foo");
let result = resolve_identifier(&PickerAction::Switch, String::new(), None);
assert!(
result
.unwrap_err()
.to_string()
.contains("No worktree selected")
);
let result = resolve_identifier(&PickerAction::Switch, "nonexistent".into(), None);
let err = result.unwrap_err().to_string();
assert!(err.contains("No worktree matches 'nonexistent'"));
assert!(err.contains("alt-c"));
let result = resolve_identifier(&PickerAction::Create, "new-branch".into(), None);
assert_eq!(result.unwrap(), "new-branch");
let result = resolve_identifier(&PickerAction::Create, String::new(), None);
assert!(result.unwrap_err().to_string().contains("no branch name"));
}
#[test]
fn test_picker_removal_target_from_signal() {
assert!(PickerRemovalTarget::from_signal("").is_none());
assert!(PickerRemovalTarget::from_signal(" ").is_none());
assert!(PickerRemovalTarget::from_signal("worktree-path:").is_none());
assert!(matches!(
PickerRemovalTarget::from_signal("feature/foo"),
Some(PickerRemovalTarget::Branch(branch)) if branch == "feature/foo"
));
assert!(matches!(
PickerRemovalTarget::from_signal("worktree-path:/tmp/wt"),
Some(PickerRemovalTarget::WorktreePath(path)) if path == std::path::Path::new("/tmp/wt")
));
}
#[test]
fn test_picker_item_identifier() {
let branched = branched_picker_item("feature/foo", Path::new("/tmp/wt-branched"));
assert_eq!(
picker_item_identifier(branched.as_ref()),
"/tmp/wt-branched"
);
let detached = detached_picker_item(Path::new("/tmp/wt-detached"));
assert_eq!(
picker_item_identifier(detached.as_ref()),
"/tmp/wt-detached"
);
let branch_only = branch_only_picker_item("feature/bar");
assert_eq!(picker_item_identifier(branch_only.as_ref()), "feature/bar");
}
#[test]
fn test_do_removal_removes_worktree_and_branch() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let wt_dir = tempfile::tempdir().unwrap();
let wt_path = wt_dir.path().join("feature");
repo.run_command(&[
"worktree",
"add",
"-b",
"feature",
wt_path.to_str().unwrap(),
])
.unwrap();
assert!(wt_path.exists());
let result = RemoveResult::RemovedWorktree {
main_path: test.path().to_path_buf(),
worktree_path: wt_path.clone(),
changed_directory: false,
branch_name: Some("feature".to_string()),
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".to_string()),
force_worktree: false,
expected_path: None,
removed_commit: None,
};
PickerCollector::do_removal(&repo, &result, &Approvals::default()).unwrap();
assert!(!wt_path.exists(), "worktree should be removed");
let output = repo.run_command(&["branch", "--list", "feature"]).unwrap();
assert!(output.is_empty(), "branch should be deleted");
}
#[test]
fn test_do_removal_branch_only_deletes_integrated_branch() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
repo.run_command(&["branch", "feature"]).unwrap();
let result = RemoveResult::BranchOnly {
branch_name: "feature".to_string(),
deletion_mode: BranchDeletionMode::SafeDelete,
pruned: false,
target_branch: None,
integration_reason: None,
};
PickerCollector::do_removal(&repo, &result, &Approvals::default()).unwrap();
let output = repo.run_command(&["branch", "--list", "feature"]).unwrap();
assert!(output.is_empty(), "integrated branch should be deleted");
}
#[test]
fn test_do_removal_branch_only_retains_unmerged_branch() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
repo.run_command(&["checkout", "-b", "unmerged"]).unwrap();
fs::write(test.path().join("new.txt"), "unmerged work").unwrap();
repo.run_command(&["add", "."]).unwrap();
repo.run_command(&["commit", "-m", "unmerged work"])
.unwrap();
repo.run_command(&["checkout", "main"]).unwrap();
let result = RemoveResult::BranchOnly {
branch_name: "unmerged".to_string(),
deletion_mode: BranchDeletionMode::SafeDelete,
pruned: false,
target_branch: None,
integration_reason: None,
};
PickerCollector::do_removal(&repo, &result, &Approvals::default()).unwrap();
let output = repo.run_command(&["branch", "--list", "unmerged"]).unwrap();
assert!(
!output.is_empty(),
"unmerged branch should be retained with SafeDelete"
);
}
#[test]
fn test_do_removal_removes_detached_worktree() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let wt_dir = tempfile::tempdir().unwrap();
let wt_path = wt_dir.path().join("detached");
repo.run_command(&[
"worktree",
"add",
"-b",
"to-detach",
wt_path.to_str().unwrap(),
])
.unwrap();
worktrunk::shell_exec::Cmd::new("git")
.args(["checkout", "--detach", "HEAD"])
.current_dir(&wt_path)
.run()
.unwrap();
assert!(wt_path.exists());
let result = RemoveResult::RemovedWorktree {
main_path: test.path().to_path_buf(),
worktree_path: wt_path.clone(),
changed_directory: false,
branch_name: None,
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".to_string()),
force_worktree: false,
expected_path: None,
removed_commit: None,
};
PickerCollector::do_removal(&repo, &result, &Approvals::default()).unwrap();
assert!(!wt_path.exists(), "detached worktree should be removed");
}
#[test]
fn test_prepare_removal_resolves_branch_only_item() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
repo.run_command(&["branch", "branch-only-feature"])
.unwrap();
let state_dir = tempfile::tempdir().unwrap();
let collector = PickerCollector {
items: Arc::new(Mutex::new(Vec::new())),
signal_path: state_dir.path().join("remove"),
repo,
approvals: Arc::new(Approvals::default()),
};
let target = PickerRemovalTarget::from_signal("branch-only-feature").unwrap();
let (_planning_repo, result) = collector.prepare_removal(&target).unwrap();
assert!(
matches!(&result, RemoveResult::BranchOnly { branch_name, .. } if branch_name == "branch-only-feature"),
"a branch with no worktree should resolve to BranchOnly"
);
}
#[test]
fn test_prepare_removal_errors_on_unknown_target() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let state_dir = tempfile::tempdir().unwrap();
let collector = PickerCollector {
items: Arc::new(Mutex::new(Vec::new())),
signal_path: state_dir.path().join("remove"),
repo,
approvals: Arc::new(Approvals::default()),
};
let target = PickerRemovalTarget::from_signal("no-such-branch").unwrap();
let err = collector
.prepare_removal(&target)
.map(|_| ())
.expect_err("unknown removal target should fail validation");
assert!(
err.to_string().contains("no-such-branch"),
"error should name the unresolved target: {err:#}"
);
}
#[test]
fn test_do_removal_skips_unapproved_pre_remove_hook() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let wt_dir = tempfile::tempdir().unwrap();
let wt_path = wt_dir.path().join("feature");
repo.run_command(&[
"worktree",
"add",
"-b",
"feature",
wt_path.to_str().unwrap(),
])
.unwrap();
let marker_dir = tempfile::tempdir().unwrap();
let marker = marker_dir.path().join("pre-remove-ran");
fs::create_dir_all(test.path().join(".config")).unwrap();
fs::write(
test.path().join(".config/wt.toml"),
format!("pre-remove = {:?}\n", format!("touch {}", marker.display())),
)
.unwrap();
let result = RemoveResult::RemovedWorktree {
main_path: test.path().to_path_buf(),
worktree_path: wt_path.clone(),
changed_directory: false,
branch_name: Some("feature".to_string()),
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".to_string()),
force_worktree: false,
expected_path: None,
removed_commit: None,
};
let approvals = Approvals::default();
PickerCollector::do_removal(&repo, &result, &approvals).unwrap();
assert!(!wt_path.exists(), "worktree should be removed");
assert!(!marker.exists(), "unapproved pre-remove hook must not run");
}
fn picker_item(branch_name: &str, item: ListItem) -> Arc<dyn SkimItem> {
Arc::new(WorktreeSkimItem {
search_text: branch_name.to_string(),
rendered: Arc::new(Mutex::new(String::new())),
branch_name: branch_name.to_string(),
item: Arc::new(item),
preview_cache: Arc::new(dashmap::DashMap::new()),
}) as Arc<dyn SkimItem>
}
fn detached_picker_item(path: &Path) -> Arc<dyn SkimItem> {
let mut item = ListItem::new_branch("abc123".to_string(), "(detached)".to_string());
item.branch = None;
item.kind = ItemKind::Worktree(Box::new(WorktreeData {
path: path.to_path_buf(),
detached: true,
..Default::default()
}));
picker_item("(detached)", item)
}
fn branched_picker_item(branch: &str, path: &Path) -> Arc<dyn SkimItem> {
let mut item = ListItem::new_branch("abc123".to_string(), branch.to_string());
item.kind = ItemKind::Worktree(Box::new(WorktreeData {
path: path.to_path_buf(),
..Default::default()
}));
picker_item(branch, item)
}
fn branch_only_picker_item(branch: &str) -> Arc<dyn SkimItem> {
picker_item(
branch,
ListItem::new_branch("abc123".to_string(), branch.to_string()),
)
}
#[test]
fn test_invoke_removes_selected_detached_worktree_by_path_token() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let wt_dir = tempfile::tempdir().unwrap();
let first_path = wt_dir.path().join("detached-one");
let second_path = wt_dir.path().join("detached-two");
for (branch, path) in [
("to-detach-one", first_path.as_path()),
("to-detach-two", second_path.as_path()),
] {
repo.run_command(&["worktree", "add", "-b", branch, path.to_str().unwrap()])
.unwrap();
worktrunk::shell_exec::Cmd::new("git")
.args(["checkout", "--detach", "HEAD"])
.current_dir(path)
.run()
.unwrap();
}
let reported_paths: Vec<_> = repo
.list_worktrees()
.unwrap()
.iter()
.filter(|wt| wt.branch.is_none())
.map(|wt| wt.path.clone())
.collect();
let first_reported = reported_paths
.iter()
.find(|path| path.file_name().is_some_and(|name| name == "detached-one"))
.unwrap();
let second_reported = reported_paths
.iter()
.find(|path| path.file_name().is_some_and(|name| name == "detached-two"))
.unwrap();
let first_item = detached_picker_item(first_reported);
let second_item = detached_picker_item(second_reported);
let first_output = first_item.output().to_string();
let second_output = second_item.output().to_string();
assert_ne!(first_output, second_output);
assert_eq!(
picker_item_identifier(second_item.as_ref()),
second_reported.to_string_lossy()
);
let signal_dir = tempfile::tempdir().unwrap();
let signal_path = signal_dir.path().join("remove-signal");
fs::write(&signal_path, &second_output).unwrap();
let items = Arc::new(Mutex::new(vec![
Arc::clone(&first_item),
Arc::clone(&second_item),
]));
let mut collector = PickerCollector {
items: Arc::clone(&items),
signal_path,
repo: repo.clone(),
approvals: Arc::new(Approvals::default()),
};
let (_rx, _interrupt) = collector.invoke("remove", Arc::new(AtomicUsize::new(0)));
let remaining: Vec<_> = items
.lock()
.unwrap()
.iter()
.map(|item| item.output().to_string())
.collect();
assert_eq!(remaining, vec![first_output]);
let deadline = Instant::now() + Duration::from_secs(5);
while second_path.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert!(first_path.exists(), "first detached worktree should remain");
assert!(
!second_path.exists(),
"selected detached worktree should be removed"
);
}
}