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::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use ansi_str::AnsiStr;
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>>>>,
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, 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(())
}
}
fn parse_reload_remove_token(cmd: &str) -> String {
let arg = cmd.strip_prefix("remove ").unwrap_or("").trim();
let unquoted = arg
.strip_prefix('\'')
.and_then(|inner| inner.strip_suffix('\''))
.unwrap_or(arg);
unquoted.replace("'\\''", "'")
}
impl CommandCollector for PickerCollector {
fn invoke(
&mut self,
cmd: &str,
components_to_stop: Arc<AtomicUsize>,
) -> (SkimItemReceiver, Sender<i32>) {
{
let selected_output = parse_reload_remove_token(cmd);
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 items = self.items.lock().unwrap();
let (tx, rx) = unbounded();
let batch: Vec<Arc<dyn SkimItem>> = items.iter().map(Arc::clone).collect();
let _ = tx.send(batch);
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().unwrap_or(usize::MAX);
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 approvals = Arc::new(Approvals::load().context("Failed to load approvals")?);
let collector = PickerCollector {
items: Arc::clone(&shared_items),
repo: repo.clone(),
approvals,
};
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())
.reverse(true)
.highlight_line(true)
.header_lines(1usize) .multi(false)
.no_info(true) .preview("") .preview_window(preview_window_spec.as_str())
.color("fg:-1,bg:-1,header:-1,matched:108,current:237,current_bg:251,current_match:108")
.cmd_collector(Rc::new(RefCell::new(collector)) as Rc<RefCell<dyn CommandCollector>>)
.bind(vec![
format!(
"alt-1:execute-silent(echo 1 > {0})+refresh-preview",
state_path_str
),
format!(
"alt-2:execute-silent(echo 2 > {0})+refresh-preview",
state_path_str
),
format!(
"alt-3:execute-silent(echo 3 > {0})+refresh-preview",
state_path_str
),
format!(
"alt-4:execute-silent(echo 4 > {0})+refresh-preview",
state_path_str
),
format!(
"alt-5:execute-silent(echo 5 > {0})+refresh-preview",
state_path_str
),
format!(
"tab:execute-silent(tr 12345 23451 < {0} > {0}.tmp; mv {0}.tmp {0})+refresh-preview",
state_path_str
),
format!(
"btab:execute-silent(tr 12345 51234 < {0} > {0}.tmp; mv {0}.tmp {0})+refresh-preview",
state_path_str
),
format!(
"shift-btab:execute-silent(tr 12345 51234 < {0} > {0}.tmp; mv {0}.tmp {0})+refresh-preview",
state_path_str
),
format!(
"shift-tab:execute-silent(tr 12345 51234 < {0} > {0}.tmp; mv {0}.tmp {0})+refresh-preview",
state_path_str
),
"alt-c:accept(create)".to_string(),
"alt-r:reload(remove {})".to_string(),
"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 render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>> = Arc::new(OnceLock::new());
let handler: Arc<progressive_handler::PickerHandler> =
Arc::new(progressive_handler::PickerHandler {
tx: tx.clone(),
render_tx: Arc::clone(&render_tx),
last_render_poke: Mutex::new(Instant::now()),
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<dyn collect::PickerProgressHandler> = handler.clone();
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);
let dry_run_handler = is_dry_run.then_some(handler);
if skip_tui {
drop(rx);
let _ = bg_handle.join();
orchestrator.wait_for_idle();
if is_dry_run {
drain_stashed_warnings(&stashed_warnings);
let rows: Vec<String> = dry_run_handler
.as_ref()
.and_then(|h| h.rendered_slots.get())
.map(|slots| {
slots
.iter()
.map(|slot| slot.lock().unwrap().ansi_strip().trim_end().to_string())
.collect()
})
.unwrap_or_default();
let dump = serde_json::json!({
"rows": rows,
"entries": orchestrator.cache_entries_json(),
});
println!("{}", serde_json::to_string_pretty(&dump)?);
}
return Ok(());
}
let output = run_skim(options, rx, &render_tx, &state.path);
drop(bg_handle);
drain_stashed_warnings(&stashed_warnings);
let out = output?;
if !out.is_abort {
let action = match &out.final_event {
Event::Action(Action::Accept(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.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(())
}
struct ModeWatcher {
stop: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl ModeWatcher {
fn spawn(event_tx: tokio::sync::mpsc::Sender<Event>, path: PathBuf) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = Arc::clone(&stop);
let handle = std::thread::Builder::new()
.name("picker-mode-watcher".into())
.spawn(move || {
let mut last = std::fs::read(&path).ok();
while !stop_thread.load(Ordering::Relaxed) {
std::thread::sleep(Duration::from_millis(20));
let current = std::fs::read(&path).ok();
if current != last {
last = current;
let _ = event_tx.try_send(Event::RunPreview);
}
}
})
.ok();
Self { stop, handle }
}
fn stop(mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
fn run_skim(
options: SkimOptions,
rx: SkimItemReceiver,
render_tx: &Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
preview_mode_path: &Path,
) -> anyhow::Result<SkimOutput> {
let mut skim: Skim = Skim::init(options, Some(rx))
.map_err(|e| anyhow::anyhow!("failed to initialize picker: {e}"))?;
skim.start();
if skim.should_enter() {
skim.init_tui()
.map_err(|e| anyhow::anyhow!("failed to initialize picker TUI: {e}"))?;
let event_tx = skim.event_sender();
let _ = render_tx.set(event_tx.clone());
let runtime =
tokio::runtime::Runtime::new().context("failed to start picker event-loop runtime")?;
let watcher = ModeWatcher::spawn(event_tx, preview_mode_path.to_path_buf());
let result = runtime.block_on(async {
skim.enter().await?;
skim.run().await
});
watcher.stop();
result.map_err(|e| anyhow::anyhow!("interactive picker failed: {e}"))?;
}
Ok(skim.output())
}
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,
parse_reload_remove_token, 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_parse_reload_remove_token() {
assert_eq!(
parse_reload_remove_token("remove 'worktree-path:/tmp/wt foo'"),
"worktree-path:/tmp/wt foo"
);
assert_eq!(parse_reload_remove_token("remove 'feature/x'"), "feature/x");
assert_eq!(parse_reload_remove_token("remove ''"), "");
assert_eq!(parse_reload_remove_token("remove 'it'\\''s'"), "it's");
assert_eq!(parse_reload_remove_token("remove plain"), "plain");
}
#[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 collector = PickerCollector {
items: Arc::new(Mutex::new(Vec::new())),
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 collector = PickerCollector {
items: Arc::new(Mutex::new(Vec::new())),
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 items = Arc::new(Mutex::new(vec![
Arc::clone(&first_item),
Arc::clone(&second_item),
]));
let mut collector = PickerCollector {
items: Arc::clone(&items),
repo: repo.clone(),
approvals: Arc::new(Approvals::default()),
};
let cmd = format!("remove '{second_output}'");
let (_rx, _interrupt) = collector.invoke(&cmd, 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"
);
}
#[test]
fn test_invoke_empty_selection_is_noop() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let item = branch_only_picker_item("some-branch");
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
let mut collector = PickerCollector {
items: Arc::clone(&items),
repo,
approvals: Arc::new(Approvals::default()),
};
let (_rx, _interrupt) = collector.invoke("remove ''", Arc::new(AtomicUsize::new(0)));
assert_eq!(
items.lock().unwrap().len(),
1,
"empty selection must not remove anything"
);
}
}