Files
max-sixty__worktrunk/src/commands/picker/mod.rs
T
Maximilian Roos bb43011c8d fix(picker): cancel background commands when the picker exits (#3560)
## What

`shell_exec` gains `cancel_background_commands()`: it latches a flag
that stops
background `Cmd::run` / `Cmd::pipe_into` before they spawn, and SIGTERMs
the
PIDs of those already running (tracked in a registry each capture-mode
command
joins for its lifetime). The picker calls it once, after skim releases
the
terminal and after the stashed-warning drain.

Preview diffs pick up `--no-optional-locks`, matching what `wt list`'s
`git status` already does: a preview reads a worktree the user may be
working
in, and can now be signalled mid-run.

Cancellation covers both accept and abort, and only ever targets
background
threads — the foreground thread is the one cancelling, and goes on to
run the
switch itself.

## Why

Accepting the picker abandoned its preview work rather than stopping it.
`wt`'s
exit ends the pool's threads but not the `git` children they spawned, so
those
children kept running, orphaned, computing diffs into an in-memory cache
that no
longer existed — churning disk on a repo the user had already left.

Measured on a 5-worktree fixture whose `git diff` sleeps 15s, with a
shell (not
`wt`) as the PTY session leader, since `wt` as session leader SIGHUPs
its own
children on exit and hides the effect entirely:

| | children alive at t+0 / +1 / +3 / +6s after `wt` exits |
| --- | --- |
| without cancellation | 12 / 12 / 12 / 12 |
| with cancellation | 0 / 0 / 0 / 0 |

Single-variable control: same build, same fixture, cancellation gated
off.
Accept latency is unchanged (0.13s).

## Why a latch, not just a sweep

A one-shot sweep over live PIDs isn't enough on its own. A task that has
already
cleared its caller's supersede check and parked on `CMD_SEMAPHORE` holds
no PID
for the sweep to find, and spawns the moment a permit frees — the very
permits
the sweep just freed by signalling everything holding one.

Capping concurrency at 2 to force a queue reproduces it: sweep-only
still leaves
survivors, the latch reaches 0. For the same reason a freshly spawned
child
re-reads the flag as it registers, so a child spawned into the sweep's
window
escapes neither.

This also replaced a first attempt that bumped the preview
orchestrator's spawn
generation. That stops *queued* tasks, but those tasks had already
passed the
generation check before parking on the semaphore, so it measured no
better than
the sweep alone. One mechanism in `shell_exec` covers all three states —
queued,
parked, running — so the orchestrator is untouched.

## Notes

- **`alt-x` removals are exempt.** A removal is dispatched to its own
thread so
  the picker stays live, so pressing Enter straight after can leave a
  `git worktree remove` in flight when the sweep fires. Its *result* is
discardable; its *effects* are not, and a signal between the worktree
remove
  and the branch delete would strand the user half-removed.
`shell_exec::uninterruptible` exempts a thread from both halves — the
sweep
skips its PIDs, and the latch doesn't refuse the calls it makes
afterwards,
since a removal is several git calls rather than one. The picker routes
every
  removal dispatch through one `spawn_removal` helper that applies the
exemption, so it's carried by the spawn path itself rather than
remembered at
  each call site.

- **SIGTERM, not SIGKILL**, so git's lockfile handlers run rather than
stranding
  an `index.lock` in a worktree the user is about to work in.
- **Cancel is ordered after `drain_stashed_warnings`.** A `--prs` forge
call
killed mid-flight fails like any other and stashes that failure as a
warning;
cancelling first would print a spurious "couldn't fetch PRs" to the
user.
- **`Cmd::run`'s plain branch now spawns explicitly** instead of calling
`cmd.output()`, whose stdio defaults it reproduces exactly — `output()`
hands
  back only the finished result, never the running child.
- **Windows** gets the latch but not the signal; a command already
running there
  still finishes. Bounding those would take a job object.
- **Tests.** The cancelled paths (both refusals and the mid-flight
signal) are
covered by `tests/cancel_background.rs`, deliberately its own test
binary:
cancellation latches process-wide state and signals every background PID
in
the process, so beside other tests it takes them out — `TestRepo`'s
helpers
drive git through the same `Cmd` path, and this is a real failure, not a
theoretical one. Alone in its process it has nothing to collide with,
and the
  test is deterministic (a file marker for readiness, not a sleep).

An earlier revision of this description claimed `codecov/patch`
contradicted
its own line data. That was wrong — a mis-read of the API on my part,
and the
check was right. Local `cargo llvm-cov` had been failing to build `skim`
(E0554), so I'd taken the API as a substitute for measuring. The build
failure
was a stale local tool: cargo-llvm-cov ≥ 0.7.0 instruments only
workspace
crates instead of the whole dependency graph, so on CI's pinned 0.8.7
`skim`
never sees the `cfg(coverage)` that trips its nightly feature gate. With
that
upgraded, local coverage reproduces codecov's four missed lines exactly.

Three were in `test_background_command_registers_while_running`, now
removed —
its poll loop's body only ran when the spawned thread hadn't registered
yet.
Coverage is the lesser reason: the test polled a process-global registry
for
non-emptiness, so under the shared-process runner the coverage job uses
it
could be satisfied by another test's PID and pass without its own
command ever
registering. `tests/cancel_background.rs` pins that property correctly,
and
more strongly — the sweep reaches a running command only if it
registered.

Three lines are left uncovered, knowingly. One is the spawn-race
re-check,
  which by construction only fires on a race that can't be scheduled
deterministically — covering it would need a scheduling hook in library
code,
which the repo's "no test code in library code" rule rules out. The
other two
  are pre-existing: the `spawn_removal` refactor re-indents the dispatch
  closures, which codecov counts as new patch lines, pulling in the
morph-failure backstop (the `warn` and `revert_morph` calls). That
backstop
only fires when a file appears in the TOCTOU window between the
removal's
safety check and the rename, so it can't be deterministically tested
either.
If `codecov/patch` reads red, this is why — the structural exemption was
  judged worth more than the metric.

- **PID reuse.** Between the kernel reaping a child and the guard's
`Drop`
removing its PID, a freed PID is briefly still listed. Inherent to
signalling
  by PID — the reap and the deregistration aren't one operation — and
deregistering before the wait isn't a fix but a removal, since the wait
*is*
the command's lifetime. Documented on `BackgroundPid` rather than
mitigated.

> _This was written by Claude Code on behalf of Maximilian Roos_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 16:36:25 -07:00

4534 lines
207 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Interactive branch/worktree selector.
//!
//! A skim-based TUI for selecting and switching between worktrees. The picker
//! shares `super::list::collect::collect` with `wt list` — see
//! `commands/list/collect/mod.rs` for the rendering-pipeline spec — but inverts
//! the ordering because skim's `preview_window` height is baked into
//! `SkimOptions` before skim takes over the terminal, so we have
//! to estimate the visible row count up front rather than learn it from
//! collect's skeleton pass.
//!
//! # "Skeleton"
//!
//! Same meaning as in `wt list`: the column/row frame with placeholder cells
//! the user sees first. In the picker, `collect::collect` builds those rows
//! and streams them via `on_skeleton` → `PickerHandler` → `SkimItemSender` →
//! skim. (Not to be confused with the rendered skeleton-row *strings* that
//! flow through that channel.)
//!
//! # Startup flow
//!
//! On the main thread, `handle_picker`:
//!
//! 1. `current_or_recover` + config resolution.
//! 2. Reads the terminal size once; `PreviewState::new` records the
//! Right-vs-Down layout detected from it. Every later sizing step (the
//! estimate cap, preview dimensions, half-page scroll) reuses that snapshot.
//! 3. Allocates the `PreviewOrchestrator` and kicks off a *speculative*
//! `git diff HEAD` for the current worktree on `COLLECT_POOL`.
//! That bg work overlaps with everything below.
//! 4. Computes `num_items_estimate` — `list_worktrees` plus (conditionally)
//! `local_branches` / `remote_branches`, capped at the Down layout's
//! `max_visible_items(available)`. Only used to size skim's `preview_window`.
//! 5. Builds `SkimOptions` (immutable after this — which is why steps 1-4 have
//! to run first).
//! 6. Spawns the `picker-collect` bg thread, which calls `collect::collect`.
//! 7. Calls `run_skim(rx)` (a thin wrapper over skim's `init`/`run` that also
//! hands the collect handler skim's event sender for progressive repaints);
//! skim paints the empty frame and then ingests skeleton rows from the
//! channel as the bg thread streams them via `on_skeleton`.
//!
//! Time-to-skeleton = steps 1-6 on the main thread *plus* collect's
//! pre-skeleton phase on the bg thread. See `commands/list/collect/mod.rs`
//! § "Forks on the Critical Path" for the subprocess inventory (five
//! forks, plus one more in `extensions.worktreeConfig` repos).
//!
//! ## Phase timings
//!
//! Representative medians on the worktrunk dev repo (7 worktrees, 6 branches,
//! warm caches, release build).
//!
//! | Phase (instant-to-instant) | median | cmds |
//! |-----------------------------|-------:|-----:|
//! | `Picker started → Picker config resolved` | ~16ms | 3 |
//! | `Picker config resolved → Picker layout detected` | <1ms | 0 |
//! | `Picker layout detected → Picker estimate computed` | ~39ms | 11 (includes bg preview `git diff`s) |
//! | `Picker estimate computed → Picker skim options built` | <1ms | 0 |
//! | `Picker skim options built → Picker collect spawned` | <100µs | 0 |
//! | `Picker collect spawned → List collect started` | <100µs | 0 |
//! | `List collect started → Skeleton rendered` (bg, pre-skeleton) | ~41ms | 25 |
//! | **Time-to-skeleton** (≈ main-thread prelude + bg pre-skeleton) | **~96ms** | |
//! | `Skeleton rendered → Spawning worker thread` (post-skeleton, pre-work) | ~156ms | 86 |
//! | `Parallel execution started → All results drained` (post-skeleton work) | ~1.1s | 254 |
//! | Wall clock under `WORKTRUNK_PICKER_DRY_RUN=1` (median / p95) | ~1.4s / ~4.4s | |
//!
//! Skim's own paint cost isn't observable from the dry-run path — skim is
//! bypassed there.
//!
//! ### Reproducing
//!
//! End-to-end time-to-first-output (criterion, synthetic repo):
//!
//! ```bash
//! cargo bench --bench time_to_first_output -- switch
//! ```
//!
//! Preview pre-compute workload — spawn → all preview tasks drained,
//! skim bypassed (criterion, synthetic repo):
//!
//! ```bash
//! cargo bench --bench picker_preview
//! ```
//!
//! Per-phase breakdown on a specific repo (a single trace is usually enough
//! to spot where time goes; re-run a few times if you want variance):
//!
//! ```bash
//! RUST_LOG=debug ./target/release/wt -C <repo> switch \
//! 2> >(cargo run -p wt-perf --release -q -- trace > trace.json)
//! # Open trace.json in Perfetto, or run the phase-duration SQL query
//! # documented in benches/CLAUDE.md §"What's on the critical path?".
//! ```
mod items;
mod log_formatter;
mod os;
mod pager;
mod pr_pane;
mod preview;
pub(crate) mod preview_cache;
mod preview_notify;
mod preview_orchestrator;
mod progressive_handler;
mod prs;
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::Instant;
use ansi_str::AnsiStr;
use anyhow::Context;
use color_print::cformat;
// bounded/unbounded/Sender are re-exported by skim::prelude
use skim::prelude::*;
use skim::reader::CommandCollector;
use skim::tui::event::ActionCallback;
use worktrunk::HookType;
use worktrunk::config::Approvals;
use worktrunk::git::{ErrorExt, Repository, current_or_recover};
use worktrunk::path::format_path_for_display;
use worktrunk::styling::{
eprintln, error_message, hint_message, info_message, strip_osc8_hyperlinks, warning_message,
};
use super::hook_plan::{ApprovedHookPlan, HookPlanBuilder};
use super::hooks::HookAnnouncer;
use super::list::collect;
use super::list::model::{BranchScope, ItemKind, ListItem};
use super::list::progressive::RenderTarget;
use super::list::render::PLACEHOLDER;
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::{LocalContent, LocalContentSlot, PreviewCache, ShortcutTable, WORKTREE_OUTPUT_PREFIX};
use preview::{PreviewLayout, PreviewMode, PreviewState, PreviewStateData};
use preview_orchestrator::PreviewOrchestrator;
/// Drain stashed warnings to stderr. Called after skim has released the
/// terminal (or in the dry-run path after the bg thread joins) — eprintln
/// during the picker would corrupt skim's frame, so collect routes warnings
/// through `PickerProgressHandler::stash_warning` and we emit them here.
///
/// TODO(picker-feedback): the declined-removal diagnostics (the main-worktree /
/// dirty / unmerged "can't remove this row" messages from the `alt-x` keep paths)
/// only surface here, on exit — the user presses `alt-x`, the row visibly stays,
/// and the reason scrolls past after they quit. Consider a short in-picker message
/// at `alt-x` time so the *why* lands immediately. skim has no footer slot (see the
/// dropped Stall-indicator work), so the realistic slot is the header line — swap
/// it to a transient "main worktree can't be removed" for a beat, then restore. The
/// stash stays the fallback for background failures that surface after exit.
fn drain_stashed_warnings(stash: &Mutex<Vec<String>>) {
for line in stash.lock().unwrap().drain(..) {
eprintln!("{line}");
}
}
/// Action selected by the user in the picker.
enum PickerAction {
/// Switch to the selected worktree (Enter key).
Switch,
/// Create a new worktree from the search query (alt-c).
Create,
}
/// The alt-x removal target parsed back out of a row's `output()` token.
///
/// A worktree-backed row's token is `worktree-path:<path>` (paths are
/// unique — detached worktrees would otherwise collide on the shared
/// `(detached)` label); a branch-only row's token is the bare branch name.
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()))
}
}
/// Resolve the switch identifier for a selected picker row, decoded from its
/// `output()` token: the worktree path for any worktree-backed row, the branch
/// name for a branch-only row.
///
/// `wt switch` accepts a worktree path for any existing worktree (`plan_switch`
/// phase 2b), so a worktree-backed row always switches by its unique path —
/// detached *and* branched alike. A branch-only row has no worktree, so its
/// branch name is the only handle.
///
/// Decoding `output()` rather than `downcast_ref::<PickerRow>()` also
/// sidesteps skim's cross-thread `TypeId` mismatch, which can make the
/// downcast fail when the item originates on the reader thread.
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,
}
}
/// skim's [`CommandCollector`] for the picker's `reload` actions. Only `alt-r`
/// (`reload(refresh)`) reaches it now — `alt-x` removal runs synchronously through
/// [`AltXRemover`] instead of a `reload` (see its docs). `invoke` re-runs the
/// collect pipeline for a refresh and otherwise re-streams the current rows.
struct PickerCollector {
/// The picker's row list (shared with the handler's `shared_items` and the
/// [`AltXRemover`]). `invoke` re-streams it when a `reload` isn't a refresh.
items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
/// Re-runs the collect pipeline for the `alt-r` refresh: `reload(refresh)`
/// routes here, and [`PipelineFactory::spawn`] streams a fresh item list
/// back. Shared (`Rc`) with `handle_picker`, which used it for the initial
/// spawn.
factory: Rc<PipelineFactory>,
}
/// What an `alt-x` press did to the selected row, so the keybinding callback knows
/// how to refresh skim's view (see [`AltXRemover::apply`] and
/// [`install_remove_keybinding`]).
enum RemovalEffect {
/// The row left the list (`items` shrank): the callback resyncs skim's pool
/// from the shrunk list ([`resync_pool`]).
Dropped,
/// The row stayed but its content changed (morphed to `/ branch` in place):
/// the callback repaints it and refreshes its preview.
Morphed,
/// The row stayed unchanged (the removal was declined or kept): the callback
/// just re-anchors and repaints.
Kept,
}
/// Runs `alt-x` removal for the selected picker row, **synchronously on skim's
/// event loop** rather than through skim's `reload`.
///
/// # Why not `reload`
///
/// `alt-x` used to be `reload(remove {})`. skim's `handle_reload` clears the item
/// pool and restarts the matcher *before* the new rows stream in, so the matcher
/// runs once against the empty pool, `Replace`s `item_list` with nothing, and
/// skim's render clamp resets the cursor to the top (`current = 0`). A
/// `reposition` action then snapped it back — but for the frames in between, the
/// `>` pointer flashed to the top row. The fix removes the `reload`: the keybinding
/// callback mutates the row list and rebuilds the pool itself ([`resync_pool`]) so
/// the matcher only ever sees the post-removal list (never empty) and the cursor
/// holds its slot. The row that slides into the removed row's place lands under the
/// cursor for free, with no flash.
///
/// # Send
///
/// The callback skim runs for a keybinding must be `Send`, so this holds only
/// `Send` state (every field is an `Arc`, or a `Repository`, which is `Send`) — it
/// can't carry the collector's `Rc<PipelineFactory>`. It owns the morph/keep
/// shared slots directly instead of reaching them through the factory.
///
/// Git operations (worktree removal, branch deletion) still run on a background
/// thread — `apply` is on skim's event loop and blocking it would freeze the TUI.
/// The row is mutated optimistically; if the background removal finds the target
/// survived ([`removal_target_still_present`]) it restores the row
/// ([`restore_failed_removal`] / [`revert_morph`]) and stashes why.
struct AltXRemover {
/// The picker's row list (shared with [`PickerCollector`] and the handler).
/// `apply` drops a row from it for the drop path; the callback then rebuilds
/// skim's pool from it.
items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
repo: Repository,
/// Approvals snapshot, loaded once at picker startup. A queued removal runs
/// its `pre-remove` / `post-remove` / `post-switch` hooks only when every
/// one is in here — the picker can't show an approval prompt mid-render, so
/// unapproved project commands are skipped, never run. See
/// [`approved_removal_plan`].
approvals: Arc<Approvals>,
/// skim's event sender, published once the TUI is initialized (same
/// `OnceLock` the progressive handler pushes `Event::Render` through). A
/// background removal that fails injects a [`resync_pool_action`] through it to
/// re-show the restored row. `None` until the TUI is up — but `alt-x` can only
/// fire after skim is showing rows, so it's always set by then.
render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
/// Same warning stash the progressive handler fills (drained to stderr once
/// skim releases the terminal). A failed background removal pushes a
/// `worktree kept` warning here so the user learns the row that flickered
/// back (or un-morphed) didn't actually go away. See [`restore_failed_removal`]
/// and [`revert_morph`].
stashed_warnings: Arc<Mutex<Vec<String>>>,
/// `alt-y` / `alt-o` lookup table (token → branch + URL). A morph re-keys the
/// row's entry from the worktree token to the branch token. Shared with the
/// handler (which fills it) and the shortcut keybindings (which read it).
shortcut_table: ShortcutTable,
/// The picker's full-width layout, handed over once the rows land. A morph
/// renders the `/ branch` row on this grid so it lines up with the worktree
/// rows. Shared with the handler (which fills it).
layout_slot: Arc<Mutex<Option<crate::commands::list::layout::LayoutConfig>>>,
/// The header's transient-message slot, shared with the header item. A
/// declined `alt-x` (current worktree, or an unmerged branch-only row) keeps
/// its row in place, so [`flash_header`](Self::flash_header) drops a short
/// "couldn't remove" line into the header for a beat — the *why* lands
/// immediately, not only when the stash drains on exit. See
/// [`items::HeaderFlash`].
header_flash: Arc<items::HeaderFlash>,
}
/// How long a declined-`alt-x` header flash stays up before it self-clears and
/// the column labels return — long enough to read, short enough not to linger.
const HEADER_FLASH_DURATION: std::time::Duration = std::time::Duration::from_millis(2500);
impl AltXRemover {
/// Build removal state from a fresh `Repository` so picker reloads after a
/// background removal do not reuse the startup worktree inventory cache.
///
/// `target` carries the exact worktree path or branch name decoded from
/// the row's `output()` token — no `git worktree list` lookup, so a
/// detached row can't be confused with another detached row.
fn prepare_removal(
&self,
target: &PickerRemovalTarget,
) -> anyhow::Result<(Repository, RemoveResult)> {
let repo = Repository::at(self.repo.discovery_path())?;
// Validate removal before touching the list. prepare_worktree_removal
// runs a few git commands (~15-20ms) — acceptable on skim's event loop.
// Only remove the item and spawn background deletion if this succeeds.
let caller_path = repo.current_worktree().root().ok();
let result = {
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,
caller_path,
None,
None,
)?
};
Ok((repo, result))
}
/// Execute a queued removal in the background.
///
/// A `RemovedWorktree` result goes through [`handle_remove_output`] in its
/// silent (TUI) mode — the git worktree removal with no `wt`-generated
/// messages, spinner, or `cd` directive (skim owns the terminal). Its
/// `pre-remove` / `post-remove` / `post-switch` hooks run only when they're
/// already approved ([`approved_removal_plan`] — a read-only `Approvals`
/// filter, no prompt): the picker can't prompt mid-render, so unapproved
/// project commands are dropped from the plan, never run. (A hook that
/// *does* run still
/// streams its own output to stderr, like any hook — a rough edge of
/// removing inside the picker.) A `BranchOnly` result just deletes the
/// branch if it's safe to.
///
/// Called from a background thread after the picker optimistically removes
/// the item from the list, so the whole operation runs off skim's event loop
/// and the TUI stays responsive. Only reached for a removal
/// [`removal_will_remove_target`] predicts will remove the target — a
/// predictably-kept unmerged branch never gets here. The caller does not infer
/// the outcome from this `Result` — a removal can fail before *or* after the
/// worktree is physically gone (rendering or spawning a
/// `post-remove`/`post-switch` hook can error during the announcer flush, which
/// runs after the dir is renamed into `.git/wt/trash/`), and a `BranchOnly`
/// delete that raced from integrated to unmerged returns `Ok` with the branch
/// surviving. Instead it
/// observes whether the target still exists ([`removal_target_still_present`])
/// and restores the row via [`restore_failed_removal`] only when it does, so
/// the list never shows a removal that didn't happen. The `Result` is for
/// logging.
///
/// `repo` is the worktree the picker is operating from — the config source
/// for the removal hooks (see [`approved_removal_plan`]) and the target of
/// a `BranchOnly` deletion. `RemovedWorktree` removal itself is rooted at
/// `main_path` (which may differ from the picker's startup repo in bare-repo
/// setups).
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,
/* foreground */ true,
&plan,
/* quiet */ true,
/* silent */ 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 Err(e) = delete_branch_if_safe(
repo,
&snapshot,
branch_name,
target,
deletion_mode.is_force(),
)
{
// A safe-delete refusal is `Ok(NotDeleted)`, not an error;
// this is a genuine `git branch -D` failure. The row is
// restored anyway because the branch still exists (see
// `removal_target_still_present`) — surface the cause.
tracing::warn!(branch = %branch_name, error = %e, "picker: failed to delete branch '{branch_name}': {e:#}");
}
}
}
}
Ok(())
}
/// Drop the selected row and remove its target on a background thread.
///
/// For a removal that will remove the row entirely — a worktree whose branch
/// is *also* deleted (integrated, or force), or a force-deleted branch-only
/// row. A worktree removal that *keeps* its branch never reaches here; that's
/// the in-place morph ([`morph_and_remove_in_background`](Self::morph_and_remove_in_background)).
/// The `output()` token is unique per row (a `worktree-path:` path for
/// worktrees), so this drops exactly the selected row even when several
/// detached rows share the `(detached)` branch label.
///
/// The row drops optimistically so the list stays snappy; the git work runs on
/// a background thread off skim's event loop. The dropped row is restored only
/// when the target survives — observed directly ([`removal_target_still_present`]),
/// not inferred from `do_removal`'s `Result`, which is `Err` after a successful
/// removal whose `post-remove` hook fails to render/spawn. This keeps the list
/// from showing a removal that didn't happen without ever resurrecting a row
/// for a target that's actually gone.
fn drop_and_remove_in_background(
&self,
selected_output: String,
planning_repo: Repository,
result: RemoveResult,
) {
// Capture the removed row (and its position) before dropping it: the
// position is handed to the background thread so it can put the row back
// at its slot if the removal fails (see `restore_failed_removal`). The
// cursor needs no separate repositioning — the caller rebuilds skim's pool
// from this shrunk list ([`resync_pool`]) without a `reload`, so `current`
// holds its index and the row that slides up into the removed slot lands
// under the cursor for free, query or no query.
let removed = {
let mut items = self.items.lock().unwrap();
let removed = items
.iter()
.position(|item| item.output().as_ref() == selected_output)
.map(|pos| (Arc::clone(&items[pos]), pos));
items.retain(|item| item.output().as_ref() != selected_output);
removed
};
// A user-facing (label, noun) for the `kept` message, taken from the result
// before it moves into the background thread.
let (removal_label, removal_noun) = removal_failure_subject(&result);
let repo = planning_repo.clone();
let approvals = Arc::clone(&self.approvals);
let items = Arc::clone(&self.items);
let render_tx = Arc::clone(&self.render_tx);
let stashed_warnings = Arc::clone(&self.stashed_warnings);
let header_flash = Arc::clone(&self.header_flash);
spawn_removal(format!("picker-remove-{selected_output}"), move || {
if let Err(e) = Self::do_removal(&repo, &result, &approvals) {
tracing::warn!(selected_output = %selected_output, error = %e, "picker: removal of '{selected_output}' errored: {e:#}");
}
// A removal that keeps its branch never reaches here — that's the
// morph path (`morph_and_remove_in_background`). So a surviving
// target means the removal itself failed: put the row back.
if removal_target_still_present(&repo, &result)
&& let Some((item, pos)) = removed
{
restore_failed_removal(
&items,
&header_flash,
&render_tx,
&stashed_warnings,
DroppedRow {
item,
pos,
label: removal_label,
noun: removal_noun,
},
);
}
});
}
/// Flash a one-line message in the header for a beat (see the free
/// [`flash_header`] for the mechanism and generation guard). The synchronous
/// keep/reject paths (on skim's event loop) reach it through this method; the
/// background failure paths ([`restore_failed_removal`], [`revert_morph`]) call
/// the free function directly, off the event loop.
fn flash_header(&self, message: String) {
flash_header(&self.header_flash, &self.render_tx, message);
}
/// Keep the selected row in place and explain why its target wasn't removed.
///
/// Called from [`apply`](Self::apply) when [`removal_will_remove_target`]
/// predicts the removal would keep the target — a branch-only row whose branch
/// is unmerged, which `SafeDelete` declines to delete (data safety). Deciding
/// this up front from `prepare_removal`'s already-computed integration check
/// means the row never drops (no flicker) and no background `do_removal` runs
/// for a no-op. The row stays in its slot under the (un-reset) cursor, so this
/// flashes a terse "branch is unmerged" line in the header (the *why*, where the
/// user is looking) and stashes the canonical "retained; unmerged" info + hint
/// pair `wt remove` itself prints (see `print_retained_unmerged_branch`), deduped
/// and drained to stderr when the picker exits. (This is a by-design retain, not
/// a failure — distinct from [`restore_failed_removal`]'s `kept … could not
/// remove it` warning.)
fn keep_unremovable_row(&self, branch_name: &str) {
// The canonical "retained; unmerged" info + hint `wt remove` prints,
// shared so the picker copy can't drift (see
// `stash_retained_unmerged_branch`). Taking the branch name (not the whole
// `RemoveResult`) makes it unrepresentable for this keep path to be handed
// a `RemovedWorktree`, which always removes — see the dispatch in
// [`apply`](Self::apply) and [`removal_will_remove_target`].
// A by-design retain, not a failure — info (○), matching the canonical
// `info_message` this path stashes (see `stash_retained_unmerged_branch`).
self.flash_header(
info_message(cformat!("Kept <bold>{branch_name}</> — branch is unmerged")).to_string(),
);
stash_retained_unmerged_branch(&self.stashed_warnings, branch_name);
}
/// Keep the current worktree's row in place and explain why the picker won't
/// remove it.
///
/// Called from [`apply`](Self::apply) when [`removal_targets_current_worktree`]
/// is true — alt-x on the worktree the picker was launched from. Removing it
/// would have to switch the shell elsewhere first (see
/// `removal_targets_current_worktree` for why that's disruptive mid-picker), so
/// the row stays put: this flashes a terse "current worktree" line in the header
/// and stashes the fuller hint to switch away first, drained to stderr when the
/// picker exits. The row never drops and no `do_removal` runs, so this is the
/// only removal path that never reaches a background thread.
fn keep_current_worktree_row(&self) {
// A by-design decline, not a failure — info (○), matching the canonical
// `info_message` this path stashes (see `stash_current_worktree_hint`).
self.flash_header(info_message("Can't remove the current worktree").to_string());
stash_current_worktree_hint(&self.stashed_warnings);
}
/// Morph the selected worktree row into a `/ branch` row in place, then remove
/// the worktree on a background thread.
///
/// For a `RemovedWorktree` removal that [`worktree_removal_keeps_branch`]
/// predicts will keep its (unmerged) branch. The row never leaves its slot:
/// the morph rewrites the row's shared `rendered` line to the branch line
/// (rendered on the live layout — gutter `+` → `/`, path blank), flips the
/// row's [`morphed`](items::LocalCheckout::morphed) flag (so `output()`
/// becomes the branch token), dims the `working_tree` preview tab (no worktree
/// left to diff), and re-keys the row's `alt-y`/`alt-o` shortcut entry to the
/// branch token. skim repaints just that row, and the (un-reset) cursor holds
/// the same slot — no teleport, no reset.
///
/// The morph is optimistic, like the drop path. The background thread runs the
/// git removal and, only if the worktree unexpectedly survives
/// ([`removal_target_still_present`] — a clean-check race, a locked dir, a
/// failing `pre-remove` hook), reverts the morph back to the worktree row via
/// [`revert_morph`] and surfaces why. (The branch can't flip integrated in the
/// millisecond between the prediction and the delete, so the only realistic
/// failure is the worktree removal itself.)
///
/// Returns [`RemovalEffect::Morphed`] on the in-place morph, or
/// [`RemovalEffect::Dropped`] when it falls back to
/// [`drop_and_remove_in_background`](Self::drop_and_remove_in_background) —
/// the row carries no [`MorphHandle`](items::MorphHandle) or the layout hasn't
/// landed, so the worktree still removes but the row drops instead of morphing.
fn morph_and_remove_in_background(
&self,
selected_output: String,
branch: String,
planning_repo: Repository,
result: RemoveResult,
) -> RemovalEffect {
// Gather the row's shared morph handles and render the branch line on the
// live layout. Any gap (row not morphable, layout not yet handed over)
// means no clean in-place morph — drop the row instead, same end state.
let default_branch = self.repo.default_branch();
let prepared = {
let table = self.shortcut_table.lock().unwrap();
let layout = self.layout_slot.lock().unwrap();
match (
table.get(&selected_output).and_then(|d| d.morph.as_ref()),
layout.as_ref(),
) {
(Some(handle), Some(layout)) => {
let (branch_line, branch_local) =
build_morph_branch_row(layout, &handle.item, default_branch.as_deref());
Some(MorphSlots {
rendered: Arc::clone(&handle.rendered),
morphed: Arc::clone(&handle.morphed),
local_content: Arc::clone(&handle.local_content),
branch_line,
branch_local,
})
}
_ => None,
}
};
let Some(slots) = prepared else {
self.drop_and_remove_in_background(selected_output, planning_repo, result);
return RemovalEffect::Dropped;
};
// Snapshot the pre-morph display for the revert, then apply the morph.
let original_rendered = slots.rendered.lock().unwrap().clone();
let original_local = *slots.local_content.lock().unwrap();
*slots.rendered.lock().unwrap() = slots.branch_line;
slots.morphed.store(true, Ordering::Relaxed);
*slots.local_content.lock().unwrap() = slots.branch_local;
// Re-key the `alt-y`/`alt-o` lookup to the branch token (the row's new
// `output()`); the revert moves it back.
{
let mut table = self.shortcut_table.lock().unwrap();
if let Some(data) = table.remove(&selected_output) {
table.insert(branch.clone(), data);
}
}
let repo = planning_repo.clone();
let approvals = Arc::clone(&self.approvals);
let render_tx = Arc::clone(&self.render_tx);
let stashed_warnings = Arc::clone(&self.stashed_warnings);
let shortcut_table = Arc::clone(&self.shortcut_table);
let header_flash = Arc::clone(&self.header_flash);
let revert = MorphRevert {
rendered: slots.rendered,
original_rendered,
morphed: slots.morphed,
local_content: slots.local_content,
original_local,
shortcut_table,
branch_token: branch.clone(),
worktree_token: selected_output.clone(),
};
spawn_removal(format!("picker-morph-{branch}"), move || {
if let Err(e) = Self::do_removal(&repo, &result, &approvals) {
tracing::warn!(branch = %branch, error = %e, "picker: removal of '{branch}' worktree errored: {e:#}");
}
// Only the worktree removal can realistically fail here; if it did,
// the worktree dir survives — undo the morph and say so.
if removal_target_still_present(&repo, &result) {
revert_morph(revert, &header_flash, &stashed_warnings, &render_tx);
}
});
RemovalEffect::Morphed
}
/// Run the `alt-x` removal dispatch for the selected row.
///
/// Decides up front, from `prepare_removal`'s already-computed result, what the
/// removal does to the row, mutates the picker's row list / shared row state
/// accordingly, and kicks off the background git work. Returns the
/// [`RemovalEffect`] so the keybinding callback can refresh skim's view:
/// - targets the current worktree → keep it (removing the worktree you're
/// standing in has to switch you away first, which the picker declines);
/// - keeps its (unmerged) branch → morph to `/ branch` in place;
/// - removes the target → drop the row;
/// - branch-only row whose branch is unmerged → stays put, explained.
///
/// Runs on skim's event loop (the `alt-x` keybinding callback), so the row
/// mutation and the caller's pool rebuild ([`resync_pool`]) are atomic from
/// skim's view — no `reload`, so the cursor never resets. The `~15-20ms`
/// `prepare_removal` git work is the same cost the old `reload`-time dispatch
/// paid; the actual worktree/branch deletion is deferred to a background thread.
fn apply(&self, selected_output: String) -> RemovalEffect {
let Some(removal_target) = PickerRemovalTarget::from_signal(&selected_output) else {
return RemovalEffect::Kept;
};
match self.prepare_removal(&removal_target) {
Ok((planning_repo, result)) => {
if removal_targets_current_worktree(&result) {
self.keep_current_worktree_row();
RemovalEffect::Kept
} else if let Some(branch) = worktree_removal_keeps_branch(&planning_repo, &result)
{
self.morph_and_remove_in_background(
selected_output,
branch,
planning_repo,
result,
)
} else if removal_will_remove_target(&result) {
self.drop_and_remove_in_background(selected_output, planning_repo, result);
RemovalEffect::Dropped
} else {
// The only non-removing outcome: `removal_will_remove_target`
// returns false solely for an unmerged `BranchOnly` row (a
// `RemovedWorktree` always removes, so it never reaches here), so
// this arm is always that row — keep it, explained.
// `keep_unremovable_row` taking the branch name — not the whole
// result — keeps that narrowing at the type level.
if let RemoveResult::BranchOnly { branch_name, .. } = &result {
self.keep_unremovable_row(branch_name);
}
RemovalEffect::Kept
}
}
Err(e) => {
tracing::info!(selected_output = %selected_output, error = %e, "picker: cannot remove '{selected_output}': {e:#}");
// The target can't be removed — the main worktree, a dirty
// worktree, a lock. Surface the *same* diagnostic `wt remove` prints
// (drained to stderr on exit) instead of swallowing it, so alt-x
// isn't a silent dead keypress. Nothing was removed, so the row
// stays under the (un-reset) cursor.
if let Some(diagnostic) = e.render_diagnostic() {
let mut stashed = self.stashed_warnings.lock().unwrap();
if !stashed.contains(&diagnostic) {
stashed.push(diagnostic);
}
}
// Flash the terse headline in the header too, so the *why* lands
// at alt-x time and not only when the stash drains on exit. Unlike
// the keep paths (by-design retains → info ○), this arm is a genuine
// rejection — error (✗), matching the `render_diagnostic` error this
// path stashes. `to_string()` is the typed error's short single-line
// label (ANSI-stripped); take its first line so a multi-line chain
// can't smear the header.
let headline = e.to_string();
let headline = headline.lines().next().unwrap_or(&headline);
self.flash_header(error_message(headline).to_string());
RemovalEffect::Kept
}
}
}
}
/// Flash a one-line message in the header for a beat, then clear it so the column
/// labels return. The slot is shared with the header item (see [`items::HeaderFlash`]),
/// so this sets it, repaints, and spawns a short-lived timer that clears it and
/// repaints again. The timer's `clear_if_current` is generation-guarded, so a second
/// flash arriving mid-beat replaces this one and isn't wiped early by this timer.
///
/// A no-op until skim's `render_tx` is published — but `alt-x` can only fire once the
/// TUI is showing rows, so it's always live by the time a flash path calls this.
///
/// Every alt-x "couldn't remove" reason reaches the header through here, so the
/// message surfaces immediately rather than only when the stash drains on exit:
/// [`AltXRemover::flash_header`] (the synchronous keep/reject paths, on skim's event
/// loop) and the background failure paths ([`restore_failed_removal`],
/// [`revert_morph`], off the event loop) share this one mechanism.
fn flash_header(
header_flash: &Arc<items::HeaderFlash>,
render_tx: &Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
message: String,
) {
let Some(tx) = render_tx.get() else {
return;
};
let generation = header_flash.set(message);
let _ = tx.try_send(Event::Render);
let header_flash = Arc::clone(header_flash);
let render_tx = Arc::clone(render_tx);
let _ = std::thread::Builder::new()
.name("picker-header-flash".into())
.spawn(move || {
std::thread::sleep(HEADER_FLASH_DURATION);
header_flash.clear_if_current(generation);
if let Some(tx) = render_tx.get() {
let _ = tx.try_send(Event::Render);
}
});
}
/// The row's shared display slots plus the pre-rendered branch line a morph
/// swaps in (see [`AltXRemover::morph_and_remove_in_background`]).
struct MorphSlots {
rendered: Arc<Mutex<String>>,
morphed: Arc<AtomicBool>,
local_content: LocalContentSlot,
branch_line: String,
branch_local: LocalContent,
}
/// Everything the background thread needs to undo a morph when the worktree
/// removal failed (see [`revert_morph`]).
struct MorphRevert {
rendered: Arc<Mutex<String>>,
original_rendered: String,
morphed: Arc<AtomicBool>,
local_content: LocalContentSlot,
original_local: LocalContent,
shortcut_table: ShortcutTable,
/// The branch token the morph re-keyed the shortcut entry to.
branch_token: String,
/// The worktree-path token the entry is keyed under before (and after) morph.
worktree_token: String,
}
/// Build the `/ branch` row a kept-branch `alt-x` morph swaps in — the rendered
/// line (on the picker's live `layout`, the same grid the worktree rows use) and
/// the diff-content signals for its preview tabs.
///
/// Clones the worktree row's model and demotes it to a local branch: `kind` →
/// `Branch` blanks the path and worktree-status columns and switches the gutter
/// to `/`, while counts / age / message carry over unchanged (the branch keeps
/// the worktree's HEAD). Status symbols are reset and recomputed for the branch
/// kind — `refresh_status_symbols` only fills empty slots, so the worktree's must
/// be cleared first. The [`LocalContent`] is read off the demoted item, so its
/// `working_tree` signal resolves empty (no worktree to diff) and the
/// `working_tree` preview tab dims. OSC 8 hyperlinks are stripped to match the
/// rows the handler builds (skim's pipeline mangles them).
fn build_morph_branch_row(
layout: &crate::commands::list::layout::LayoutConfig,
worktree_item: &ListItem,
default_branch: Option<&str>,
) -> (String, LocalContent) {
let mut branch_item = worktree_item.clone();
branch_item.kind = ItemKind::Branch(BranchScope::Local);
branch_item.status_symbols = Default::default();
branch_item.refresh_status_symbols(default_branch);
let line = strip_osc8_hyperlinks(
&layout
.render_list_item_line(&branch_item, PLACEHOLDER)
.render(),
);
(line, LocalContent::from_item(&branch_item))
}
/// Undo a morph after the worktree removal failed, restoring the worktree row in
/// place and explaining why it didn't go away.
///
/// The mirror of [`AltXRemover::morph_and_remove_in_background`]'s apply
/// step: restore the row's pre-morph display, clear the
/// [`morphed`](items::LocalCheckout::morphed) flag (so `output()` is the
/// worktree token again), restore the diff-content slot, and move the
/// `alt-y`/`alt-o` shortcut entry back to the worktree token. The row never left
/// its slot, so [`flash_header`]'s repaint re-shows it — no reload, no cursor move
/// (unlike [`restore_failed_removal`], which re-inserts a dropped row). The
/// `kept … could not remove it` reason lands twice: flashed in the header now, and
/// drained to stderr when the picker exits.
fn revert_morph(
revert: MorphRevert,
header_flash: &Arc<items::HeaderFlash>,
stashed_warnings: &Mutex<Vec<String>>,
render_tx: &Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
) {
let MorphRevert {
rendered,
original_rendered,
morphed,
local_content,
original_local,
shortcut_table,
branch_token,
worktree_token,
} = revert;
*rendered.lock().unwrap() = original_rendered;
morphed.store(false, Ordering::Relaxed);
*local_content.lock().unwrap() = original_local;
{
let mut table = shortcut_table.lock().unwrap();
if let Some(data) = table.remove(&branch_token) {
table.insert(worktree_token, data);
}
}
// Surface the "couldn't remove" reason two ways, like the drop path
// ([`restore_failed_removal`]): flash it in the header now (the row un-morphed
// under the cursor, so the *why* lands where the user is looking) and stash the
// same line to drain to stderr on exit. A genuine failure — the removal was
// attempted and the worktree survived — so warning (▲), not the keep paths'
// by-design info (○). `flash_header`'s repaint also re-shows the reverted row,
// so no separate `Event::Render` is needed.
let warning = warning_message(cformat!(
"Kept <bold>{branch_token}</> worktree — could not remove it"
))
.to_string();
flash_header(header_flash, render_tx, warning.clone());
stashed_warnings.lock().unwrap().push(warning);
}
/// Number of leading non-selectable header rows the picker streams (the single
/// `HeaderSkimItem`). The skim options pass this to `.header_lines(...)`: skim
/// reserves these from the item pool into its own Header widget, so `item_list`
/// — what the cursor moves over — holds data rows only, indexed from 0.
const PICKER_HEADER_ROWS: usize = 1;
/// Rebuild skim's item pool from the picker's row list and restart the matcher,
/// **synchronously**, so the cursor holds its slot across an `alt-x` removal.
///
/// This is the picker's replacement for skim's `reload`. `reload` clears the pool
/// and restarts the matcher *before* the reader streams the new rows in, so the
/// matcher runs once against the empty pool, `Replace`s `item_list` with nothing,
/// and skim's render clamp (`items.is_empty() → current = 0`) snaps the cursor to
/// the top — the flash. Filling the pool here, before `restart_matcher` runs the
/// matcher (which is async, on the matcher thread pool), means the matcher only
/// ever sees the post-removal list — never empty — so `current` is preserved
/// (clamped to the shrunk list) and the row that slid into the removed slot lands
/// under the cursor. No reposition, no flash. The active query still applies
/// (`restart_matcher` re-filters with the current input), so this is correct under
/// a fuzzy filter too: the cursor's filtered-list index holds.
///
/// `items` carries the leading `HeaderSkimItem`, which `append` re-reserves as
/// the non-selectable header (`header_lines(1)`), matching the initial stream.
fn resync_pool(app: &mut skim::tui::App, items: &Arc<Mutex<Vec<Arc<dyn SkimItem>>>>) {
let batch: Vec<Arc<dyn SkimItem>> = items.lock().unwrap().iter().map(Arc::clone).collect();
app.item_pool.clear();
app.item_pool.append(batch);
app.restart_matcher(true);
}
/// A skim `Custom` action that runs [`resync_pool`] on the event loop.
///
/// Both alt-x sites queue it. The keybinding callback returns it for the drop path
/// — skim processes a callback's returned events (then a Render) in order, so the
/// queued resync rebuilds the pool before any repaint, equivalent to an inline
/// rebuild. A background removal that fails ([`restore_failed_removal`]) re-inserts
/// the row from off the event loop and has no `App`, so it queues this through
/// skim's event sender to re-show the restored row. Sharing one action keeps the
/// pool-rebuild logic in a single place. The re-inserted row lands at the removed
/// row's old slot — which is exactly where `current` sits after the drop slid the
/// successor up — so the cursor lands back on it for free.
fn resync_pool_action(items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>) -> Action {
Action::Custom(ActionCallback::new_sync(
move |app| -> Result<Vec<Event>, Box<dyn std::error::Error + Send + Sync>> {
resync_pool(app, &items);
Ok(Vec::new())
},
))
}
/// Consecutive "matcher settled on the resynced pool" observations
/// [`run_preview_when_settled`] waits out before firing the preview. The matcher
/// writes its result, then a later render applies the `Replace` into `item_list`
/// and clamps the cursor — so `item_list` lags the matcher by a render. Each
/// re-arm queues a Render (skim appends one after every action), so three settled
/// observations guarantee the reloaded rows (and the cursor clamp) are in before
/// the preview fires.
const PREVIEW_SETTLED_RENDERS: usize = 3;
/// Hard backstop on [`run_preview_when_settled`] re-arms, far above the handful a
/// normal resync needs. The settled check is the real stop condition; this only
/// guards an unforeseen never-settles state (e.g. a resync that empties the pool).
const PREVIEW_MAX_ATTEMPTS: usize = 1000;
/// A skim `Custom` action that fires [`Event::RunPreview`] once the resynced pool's
/// matcher has settled, refreshing the preview for the row the cursor landed on
/// after an `alt-x` drop.
///
/// skim auto-refreshes the preview across a matcher `Replace` only when the
/// selected row's `text()` changes (`ItemList`'s `on_selection_changed`). That
/// covers a middle-row drop — a successor slides under the cursor — but not the
/// *last* row: `current` is briefly out of range at the `Replace` render, so the
/// selection reads empty and the clamp then lands it on the new last row with no
/// text change to detect, leaving the pane showing the removed row's preview. This
/// fires the missing `RunPreview` (no cursor move — the resync already landed it).
///
/// It re-arms until the matcher has settled on the resynced pool — stopped, the
/// pool non-empty, every item taken — for [`PREVIEW_SETTLED_RENDERS`] consecutive
/// checks; firing earlier would preview the pre-`Replace` (removed) row. A drop
/// that empties the filtered list settles the same way and previews nothing.
fn run_preview_when_settled(
attempts: Arc<AtomicUsize>,
settled_streak: Arc<AtomicUsize>,
) -> Action {
Action::Custom(ActionCallback::new_sync(
move |app| -> Result<Vec<Event>, Box<dyn std::error::Error + Send + Sync>> {
let matcher_settled = app.matcher_control.stopped()
&& !app.item_pool.is_empty()
&& app.item_pool.num_not_taken() == 0;
let streak = if matcher_settled {
settled_streak.fetch_add(1, Ordering::Relaxed) + 1
} else {
settled_streak.store(0, Ordering::Relaxed);
0
};
if streak < PREVIEW_SETTLED_RENDERS
&& attempts.fetch_add(1, Ordering::Relaxed) < PREVIEW_MAX_ATTEMPTS
{
return Ok(vec![Event::Action(run_preview_when_settled(
Arc::clone(&attempts),
Arc::clone(&settled_streak),
))]);
}
Ok(vec![Event::RunPreview])
},
))
}
/// A removal's user-facing subject for the `kept` warning: a `(label, noun)`
/// pair where `noun` is `worktree` or `branch` and `label` is the branch name
/// (or the worktree's display path for a detached worktree). Computed before the
/// `RemoveResult` moves into the background thread; surfaced by
/// [`restore_failed_removal`].
fn removal_failure_subject(result: &RemoveResult) -> (String, &'static str) {
match result {
RemoveResult::RemovedWorktree {
branch_name: Some(branch),
..
} => (branch.clone(), "worktree"),
RemoveResult::RemovedWorktree { worktree_path, .. } => {
(format_path_for_display(worktree_path), "worktree")
}
RemoveResult::BranchOnly { branch_name, .. } => (branch_name.clone(), "branch"),
}
}
/// Whether `do_removal` will actually remove the target — predicted up front from
/// `prepare_removal`'s already-computed [`RemoveResult`], before the row is
/// dropped. The dual of [`removal_target_still_present`]: this decides whether to
/// drop the row, that confirms the drop afterward.
///
/// A `RemovedWorktree` result has passed `ensure_clean` (Phase 5 of
/// `prepare_worktree_removal`), so the worktree removes — the only failures left
/// are async and rare (a clean-check race, a failing approved `pre-remove` hook),
/// which the background restore still catches. A `BranchOnly` result deletes only
/// when `delete_branch_if_safe` would: not `Keep` mode, and either force or an
/// integrated branch (the `integration_reason` here is computed from the *same*
/// `Repository::integration_reason` the later delete consults, so they can't
/// drift). An unmerged branch-only row is thus kept, and predicting it here means
/// it never drops (no flicker) — see [`AltXRemover::keep_unremovable_row`].
fn removal_will_remove_target(result: &RemoveResult) -> bool {
match result {
RemoveResult::RemovedWorktree { .. } => true,
RemoveResult::BranchOnly {
deletion_mode,
integration_reason,
..
} => {
!deletion_mode.should_keep()
&& (deletion_mode.is_force() || integration_reason.is_some())
}
}
}
/// Whether the row's target is the worktree the picker was launched from — the
/// `changed_directory` flag `prepare_worktree_removal` sets when the removed
/// worktree is the caller's own.
///
/// The picker declines this case (see [`AltXRemover::keep_current_worktree_row`]):
/// removing the current worktree would have to cd the shell elsewhere first, and
/// that switch drags in `post-switch` hooks streaming into the picker, an empty
/// placeholder directory swapped under the cursor mid-render, and a directory
/// change the picker can't cleanly reflect. Switching away (Enter) and then
/// removing the now-non-current row is the clean path, so alt-x on the current
/// worktree keeps the row and explains. `BranchOnly` rows have no worktree to be
/// standing in, so this is always `false` for them.
fn removal_targets_current_worktree(result: &RemoveResult) -> bool {
matches!(
result,
RemoveResult::RemovedWorktree {
changed_directory: true,
..
}
)
}
/// The branch a `RemovedWorktree` removal will **keep** — worktree gone, branch
/// retained — or `None` if the removal will delete the branch (or there's no
/// branch). Drives the `alt-x` in-place morph: a kept branch turns the row into
/// a `/ branch` row rather than dropping it.
///
/// Mirrors [`delete_branch_if_safe`] exactly so the prediction can't drift from
/// the deletion the background `do_removal` performs: force always deletes; a
/// `Keep` flag always retains; otherwise the branch is kept precisely when it is
/// **not** integrated into the same `target_branch.unwrap_or("HEAD")` the actual
/// delete checks (`Repository::integration_reason` → `None`). A `capture_refs`
/// or integration error yields `None` (fall back to the drop path) — never a
/// morph the removal won't back up. Runs a couple of git commands on skim's
/// event loop, like `prepare_removal`'s own validation.
fn worktree_removal_keeps_branch(repo: &Repository, result: &RemoveResult) -> Option<String> {
let RemoveResult::RemovedWorktree {
branch_name: Some(branch),
deletion_mode,
target_branch,
..
} = result
else {
return None;
};
if deletion_mode.is_force() {
return None; // `-D` deletes regardless of integration.
}
if deletion_mode.should_keep() {
return Some(branch.clone()); // `Keep` retains regardless of integration.
}
// SafeDelete: kept iff unmerged — the exact check `delete_branch_if_safe` runs.
let snapshot = repo.capture_refs().ok()?;
let target = target_branch.as_deref().unwrap_or("HEAD");
let (_, reason) = repo.integration_reason(&snapshot, branch, target).ok()?;
reason.is_none().then(|| branch.clone())
}
/// Whether the row's underlying target still exists after `do_removal` ran — the
/// primary evidence for "was this actually removed," used in place of inferring
/// from `do_removal`'s `Result`.
///
/// A `Result` is the wrong signal in two directions: a `RemovedWorktree` removal
/// can return `Err` *after* the worktree is already trashed (rendering or
/// spawning a `post-remove`/`post-switch` hook fails during the announcer flush),
/// and a `BranchOnly` safe-delete that raced from integrated to unmerged returns
/// `Ok` while leaving the branch in place. (The *predictable* unmerged case never
/// reaches here — [`removal_will_remove_target`] keeps that row without dropping
/// it.) Observing the target directly handles both: the worktree dir is gone once
/// removed (renamed into `.git/wt/trash/`), and the branch ref is gone once
/// deleted. The check runs on the background thread, off skim's event loop.
///
/// `worktree_path.exists()` is the right signal here because the picker only ever
/// removes *non-current* worktrees — [`removal_targets_current_worktree`] keeps the
/// current one in place rather than removing it. So no empty placeholder directory
/// is ever left at `worktree_path` (that placeholder, which keeps `$PWD` valid, is
/// created only when removing the worktree the shell is sitting in — see
/// [`crate::output::handlers`]). A successful removal renames the whole tree away;
/// a failed one leaves it intact.
fn removal_target_still_present(repo: &Repository, result: &RemoveResult) -> bool {
match result {
RemoveResult::RemovedWorktree { worktree_path, .. } => worktree_path.exists(),
RemoveResult::BranchOnly { branch_name, .. } => {
repo.branch(branch_name).exists_locally().unwrap_or(false)
}
}
}
/// Stash the canonical "retained; unmerged" info + hint pair (deduped), drained
/// to stderr once the picker releases the terminal. Used by
/// [`AltXRemover::keep_unremovable_row`] — a branch-only row whose unmerged
/// branch `SafeDelete` declines to delete stays put, and this explains the
/// no-op. (A worktree removal that keeps its branch instead transforms the row
/// to `/ branch` live — see [`AltXRemover::morph_and_remove_in_background`] —
/// so it needs no stashed message.) The pair is the one `wt remove` itself
/// prints — see [`crate::output::retained_unmerged_branch_messages`].
fn stash_retained_unmerged_branch(stashed: &Mutex<Vec<String>>, branch_name: &str) {
let (info, hint) = crate::output::retained_unmerged_branch_messages(branch_name);
let mut stashed = stashed.lock().unwrap();
if !stashed.contains(&info) {
stashed.push(info);
stashed.push(hint);
}
}
/// Stash the "can't remove the current worktree here" info + hint pair (deduped),
/// drained to stderr once the picker releases the terminal. Used by
/// [`AltXRemover::keep_current_worktree_row`] — alt-x on the worktree the
/// picker was launched from keeps the row and explains, since removing it would
/// have to switch the shell elsewhere first.
fn stash_current_worktree_hint(stashed: &Mutex<Vec<String>>) {
let info = info_message("Can't remove the current worktree from the picker").to_string();
let hint = hint_message("Switch to another worktree first").to_string();
let mut stashed = stashed.lock().unwrap();
if !stashed.contains(&info) {
stashed.push(info);
stashed.push(hint);
}
}
/// The optimistically-dropped row [`restore_failed_removal`] puts back, plus the
/// display subject (`label` + `noun`, from [`removal_failure_subject`]) for its
/// `kept … could not remove it` message.
struct DroppedRow {
item: Arc<dyn SkimItem>,
/// The row's slot before it was dropped (clamped on re-insert if the list
/// shrank in the meantime).
pos: usize,
label: String,
noun: &'static str,
}
/// Put a row back after its background removal didn't happen, closing the alt-x
/// loop so the list never shows a removal that didn't occur.
///
/// `invoke` drops a row optimistically once alt-x's validation passes, then
/// removes the target on a background thread. When the target unexpectedly
/// survives (data safety: a clean-check race against `ensure_clean`, a locked
/// directory, a failing `pre-remove` hook, or a `BranchOnly` delete that raced
/// from integrated to unmerged — see [`removal_target_still_present`]; the
/// predictably-kept unmerged branch is filtered earlier by
/// [`removal_will_remove_target`]), the row must reappear. This re-inserts it into
/// `shared_items` at its original slot, flashes the `kept` reason in the header and
/// stashes the same line (drained to stderr once skim releases the terminal; the
/// full error, if any, is in the `tracing::warn!` the caller emits), then queues a
/// [`resync_pool_action`] to re-show it.
///
/// Re-inserting at the removed row's old slot lands the cursor back on the row for
/// free: the drop slid the successor up into that slot under the cursor, so the
/// re-insert pushes the successor back down and the restored row takes the cursor's
/// position. Runs off the event loop (the background removal thread), so it can't
/// touch `App` directly — the queued action does the [`resync_pool`] on the loop.
fn restore_failed_removal(
items: &Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
header_flash: &Arc<items::HeaderFlash>,
render_tx: &Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
stashed_warnings: &Arc<Mutex<Vec<String>>>,
dropped: DroppedRow,
) {
let DroppedRow {
item,
pos,
label,
noun,
} = dropped;
{
let mut items = items.lock().unwrap();
let token = item.output().into_owned();
// A concurrent restore (rapid alt-x on the same row) may have already
// put it back; don't duplicate it.
if items.iter().any(|it| it.output().as_ref() == token) {
return;
}
// Another removal may have shrunk the list since the drop; clamp.
let insert_at = pos.min(items.len());
items.insert(insert_at, item);
}
// Surface the "couldn't remove" reason two ways, like the morph revert
// ([`revert_morph`]): flash it in the header now (the row is back under the
// cursor, so the *why* lands where the user is looking) and stash the same line
// to drain to stderr on exit. A genuine failure — the removal was attempted and
// the target survived — so warning (▲), not the keep paths' by-design info (○).
let warning = warning_message(cformat!(
"Kept <bold>{label}</> {noun} — could not remove it"
))
.to_string();
flash_header(header_flash, render_tx, warning.clone());
stashed_warnings.lock().unwrap().push(warning);
let Some(event_tx) = render_tx.get() else {
return;
};
// Re-show the restored row by rebuilding skim's pool from the list it's back in.
let _ = event_tx.try_send(Event::Action(resync_pool_action(Arc::clone(items))));
}
impl CommandCollector for PickerCollector {
fn invoke(
&mut self,
cmd: &str,
components_to_stop: Arc<AtomicUsize>,
) -> (SkimItemReceiver, Sender<i32>) {
let _ = components_to_stop;
// alt-r refresh: `reload(refresh)` re-runs collect and streams a fresh
// list. The new pipeline's threads feed `rx`; on completion their senders
// drop and skim's reload sees EOF. The returned handler and join handles
// are kept alive by those threads, so let them drop here. On a spawn
// failure we fall through and re-stream the current items unchanged.
// If the failure hit after `PreviewOrchestrator::refresh` ran (a
// thread-spawn error — resource-exhaustion territory), those rows'
// tokens are already superseded against a cleared cache, so their
// previews sit on placeholders until the next successful refresh.
// Accepted: un-bumping the generation would instead break a
// partially-started spawn's live producers (the collect thread can
// already be running when the `--prs` thread spawn is what failed).
//
// `alt-x` removal does NOT route here — it runs synchronously through
// [`AltXRemover`] / [`resync_pool`] instead of a `reload`, so `refresh` is
// the only command this collector now sees. The re-stream below stays as the
// fall-through for a failed `refresh` spawn.
if cmd.trim() == "refresh" {
match self.factory.spawn(true) {
Ok(SpawnedPipeline { rx, .. }) => {
let (tx_interrupt, _rx_interrupt) = bounded(1);
return (rx, tx_interrupt);
}
Err(e) => log::warn!("picker: refresh failed: {e:#}"),
}
}
// Stream the current items through a channel for skim to consume. skim
// 4.x's item channel carries Vec batches, so send the whole list as a
// single batch; unbounded means the send never blocks.
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);
// Dummy interrupt channel — no subprocess to kill.
// The reader's collect_item thread handles its own components_to_stop
// accounting; we just need a valid Sender to satisfy the trait signature.
let (tx_interrupt, _rx_interrupt) = bounded(1);
(rx, tx_interrupt)
}
}
/// Whether every `pre-remove` / `post-remove` / `post-switch` command this
/// removal would run is already approved — a read-only check, no prompt.
///
/// `repo` is the worktree the picker is operating from; its `.config/wt.toml`
/// is what every removal hook resolves against, matching `wt remove` /
/// `wt merge`. `main_path` is the post-removal destination (the `post-switch`
/// anchor); `worktree_path` is the worktree being removed (the `pre-remove` /
/// `post-remove` anchor). The picker can't prompt mid-render, so it runs the
/// removal's hooks only when they're already approved (e.g. from a prior
/// `wt remove` / `wt merge`) and skips them otherwise — unapproved project
/// commands must never run. See CLAUDE.md → "Project Commands Run Only After
/// Approval".
fn approved_removal_plan(
repo: &Repository,
main_path: &Path,
worktree_path: &Path,
approvals: &Approvals,
) -> anyhow::Result<ApprovedHookPlan> {
// Non-fatal: an unresolvable project identifier just means no project
// pipeline can be matched against approvals — `approve_readonly` then
// drops them (fail-closed), rather than aborting the picker removal.
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))
}
/// Everything needed to (re)spawn the picker's collect pipeline. Used once at
/// startup and again on every `alt-r` refresh — which re-runs `collect` so
/// worktrees and branches created outside the session (a teammate's push, a
/// parallel agent) appear without reopening the picker.
///
/// Each [`spawn`](Self::spawn) builds a *fresh* progressive handler (its
/// `OnceLock` slots can't be reset) and item channel, but shares the
/// session-long state — the orchestrator / preview cache (warm across row
/// navigation and the fall-through re-stream; a refresh clears it so previews
/// recompute — see [`spawn`](Self::spawn)), `shared_items` and `shortcut_table`
/// (which `on_skeleton` seeds and the
/// `--prs` thread extends), and skim's `render_tx`. Held by [`PickerCollector`]
/// so a refresh can re-enter the pipeline.
struct PipelineFactory {
repo: Repository,
render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
shared_items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
shortcut_table: ShortcutTable,
preview_cache: PreviewCache,
orchestrator: Arc<PreviewOrchestrator>,
stashed_warnings: Arc<Mutex<Vec<String>>>,
/// Handoff of the collect layout to the collector, for rendering a
/// `/ branch` row on the same grid at `alt-x` time. Filled by the handler's
/// `provide_layout`, read by [`PickerCollector`]. See [`items::LayoutSlot`].
layout_slot: items::LayoutSlot,
/// Shared picker-lifetime with the header item and [`AltXRemover`]: a declined
/// `alt-x` flashes a transient "couldn't remove this row" line in the header
/// (see [`items::HeaderFlash`]). One slot for the picker's life, re-shared into
/// each spawn's header item, so a reload reads the same (cleared) flash.
header_flash: Arc<items::HeaderFlash>,
preview_dims: (usize, usize),
skim_list_width: usize,
command_timeout: Option<std::time::Duration>,
llm_command: Option<String>,
summary_hint: Option<String>,
show_branches: bool,
show_remotes: bool,
show_prs: bool,
is_preview_bench: bool,
}
/// The product of one [`PipelineFactory::spawn`]: skim's item receiver plus the
/// handler and thread handles the caller manages (joined in the dry-run path,
/// dropped — detaching the threads — in the interactive and refresh paths).
struct SpawnedPipeline {
rx: SkimItemReceiver,
handler: Arc<progressive_handler::PickerHandler>,
collect_handle: std::thread::JoinHandle<()>,
prs_handle: Option<std::thread::JoinHandle<()>>,
}
impl PipelineFactory {
/// Build a fresh handler + item channel, start the `picker-collect` thread
/// (and the `picker-prs` thread when `--prs` is active), and hand back the
/// receiver skim reads. Every sender drops as soon as its batch is sent —
/// the handler's at the skeleton send, the `--prs` thread's when its rows
/// land — so skim's reader sees EOF once the last batch is in, which is
/// what ends a refresh's `reload`. Collect keeps grinding past that point
/// (CI fetches, summaries) through in-place row updates that never touch
/// the channel.
/// `rebuild_repo` controls the worktree/branch inventory source. A refresh
/// (`alt-r`) passes `true` to rebuild a fresh `Repository`, re-enumerating
/// after an in-picker removal, and to run `PreviewOrchestrator::refresh` —
/// which supersedes the prior spawn's preview producers, rebinds preview
/// compute to the fresh repo, and clears the in-memory preview cache so
/// previews recompute (see the `spawn_repo` binding, and the
/// `preview_orchestrator` spec for what a refresh does and doesn't refresh).
/// The initial spawn passes `false` to reuse the startup repo, whose cache
/// the prelude already primed — nothing has mutated yet, so reusing it is
/// correct and avoids re-paying `git worktree list` / `local_branches` on the
/// first-paint hot path (doubling them there slows the picker, worst on
/// Windows).
///
/// The rebuild is also what lets `alt-r` drop a worktree an in-picker `alt-x`
/// removed: re-enumerating from a fresh handle skips the gone worktree, where
/// the startup cache would still list it.
fn spawn(&self, rebuild_repo: bool) -> anyhow::Result<SpawnedPipeline> {
let (tx, rx): (SkimItemSender, SkimItemReceiver) = unbounded();
// Fresh per spawn: the header shows a "loading…" marker keyed to this
// flag while the forge call is in flight.
let prs_loading: Option<Arc<AtomicBool>> =
(self.show_prs && !self.is_preview_bench).then(|| Arc::new(AtomicBool::new(true)));
// Worktree/branch inventory source for this spawn. The factory's `repo`
// was primed with `git worktree list` / `local_branches` at picker
// startup, and those `RepoCache` cells are `OnceCell`s that are never
// invalidated. A refresh re-probing that shared cache would re-serve the
// startup list, so after an in-picker removal the removed worktrees would
// still appear and collect's per-worktree git ops would fail against the
// gone branches ("fatal: Needed a single revision"). So a refresh
// (`rebuild_repo`) builds a fresh `Repository::at` — the post-mutation
// discipline the `RepoCache` docs and `prepare_removal` already require.
// The initial spawn skips the rebuild: the primed cache is still valid,
// and rebuilding would re-pay both git calls on the first-paint path.
// The collect thread (`bg_repo`), the `--prs` thread (`prs_repo`), and
// the skeleton handler's inventory reads all share this one snapshot.
let spawn_repo = if rebuild_repo {
// A refresh recomputes previews too, not just the row inventory.
// The in-memory preview cache is keyed by `(branch, mode)` with no
// SHA — the working-tree diff has no stable hash to key on — so a
// warm entry outlives the branch's commits or working tree moving
// and would re-serve a stale diff / log / summary. `refresh`
// supersedes the prior spawn's still-in-flight producers, rebinds
// preview compute to this fresh repo, and clears the cache, so
// each rebuilt row recomputes against its current `item.head()`
// from the rebuilt inventory; the on-disk caches (SHA-keyed for
// log / branch-diff / upstream, diff-hash-keyed for the summary)
// make an unchanged branch a cheap re-read, so only genuinely
// changed content pays a recompute. The `pr` / `comments` tabs
// already self-invalidate on the CI path; clearing them here too
// just means a refresh also re-fetches their forge data. See the
// `preview_orchestrator` module spec ("Spawn generations").
let repo = Repository::at(self.repo.discovery_path())?;
self.orchestrator.refresh(repo.clone());
repo
} else {
self.repo.clone()
};
// This spawn's identity token, carried by everything the spawn
// starts and superseded by the next refresh (see `SpawnGeneration`).
let spawn_gen = self.orchestrator.generation();
// The skeleton→`--prs` handoff (column geometry + the branches already
// shown for dedup). Fresh per spawn so an alt-r reload's `--prs` thread
// reads *this* reload's branch set — a session-shared first-write-wins
// slot would feed it the original skeleton's stale set, double-listing or
// dropping a PR whose worktree was created/removed since (see
// `prs::Skeleton`). The grid is width-stable, so per-spawn grids are
// identical anyway.
let grid_slot = Arc::new(prs::GridSlot::new());
let handler: Arc<progressive_handler::PickerHandler> =
Arc::new(progressive_handler::PickerHandler {
tx: Mutex::new(Some(tx.clone())),
render_tx: Arc::clone(&self.render_tx),
last_render_poke: Mutex::new(Instant::now()),
shared_items: Arc::clone(&self.shared_items),
shortcut_table: Arc::clone(&self.shortcut_table),
rendered_slots: OnceLock::new(),
pr_status_slots: OnceLock::new(),
comments_fetched: OnceLock::new(),
local_content_slots: OnceLock::new(),
preview_cache: Arc::clone(&self.preview_cache),
repo: spawn_repo.clone(),
orchestrator: Arc::clone(&self.orchestrator),
spawn_gen: spawn_gen.clone(),
preview_dims: self.preview_dims,
llm_command: self.llm_command.clone(),
summary_hint: self.summary_hint.clone(),
stashed_warnings: Arc::clone(&self.stashed_warnings),
deferred_items: OnceLock::new(),
grid_slot: Arc::clone(&grid_slot),
layout_slot: Arc::clone(&self.layout_slot),
prs_loading: prs_loading.clone(),
header_flash: Arc::clone(&self.header_flash),
});
let bg_handler: Arc<dyn collect::PickerProgressHandler> = handler.clone();
let bg_repo = spawn_repo.clone();
let show_branches = self.show_branches;
let show_remotes = self.show_remotes;
let command_timeout = self.command_timeout;
let skim_list_width = self.skim_list_width;
let collect_handle = std::thread::Builder::new()
.name("picker-collect".into())
.spawn(move || {
let _ = collect::collect(
&bg_repo,
collect::ShowConfig::Resolved {
show_branches,
show_remotes,
command_timeout,
collect_deadline: None,
list_width: Some(skim_list_width),
progressive_handler: Some(bg_handler),
},
// Picker renders its own UI through `progressive_handler`;
// collect must not write to stdout.
RenderTarget::Json,
);
})
.context("Failed to spawn picker-collect thread")?;
// PR/MR streaming (`--prs`). One forge call on its own thread holding
// another `tx` clone, so the frame paints from local data immediately and
// PR rows stream in (~1s) when the call returns.
let prs_handle = if let Some(prs_loading) = prs_loading {
let prs_tx = tx.clone();
let prs_repo = spawn_repo.clone();
let prs_warnings = Arc::clone(&self.stashed_warnings);
let prs_orchestrator = Arc::clone(&self.orchestrator);
let prs_render_tx = Arc::clone(&self.render_tx);
let prs_shared = prs::PrsShared {
grid_slot: Arc::clone(&grid_slot),
shortcut_table: Arc::clone(&self.shortcut_table),
shared_items: Arc::clone(&self.shared_items),
// This spawn's token: the thread appends its rows (and fans
// out per-row fetches) only while it is still current, so an
// earlier spawn's still-in-flight forge call can't add rows
// to this (or a later) spawn's list. See `prs::PrsShared`.
spawn_gen: spawn_gen.clone(),
};
let prs_layout = prs::PrsLayout {
list_width: self.skim_list_width,
preview_dims: self.preview_dims,
};
Some(
std::thread::Builder::new()
.name("picker-prs".into())
.spawn(move || {
prs::stream_open_prs(
&prs_repo,
&prs_layout,
&prs_tx,
&prs_warnings,
&prs_orchestrator,
&prs_shared,
&prs::PrsStreamSignal {
pending: &prs_loading,
render_tx: &prs_render_tx,
},
);
})
.context("Failed to spawn picker-prs thread")?,
)
} else {
None
};
// Drop the local `tx` so the handler's clone (consumed at the skeleton
// send) and the `--prs` thread's clone are the only senders left —
// their release is what signals EOF to skim.
drop(tx);
Ok(SpawnedPipeline {
rx,
handler,
collect_handle,
prs_handle,
})
}
}
pub fn handle_picker(
cli_branches: bool,
cli_remotes: bool,
cli_prs: bool,
change_dir_flag: Option<bool>,
format: SwitchFormat,
execute: Option<&str>,
execute_args: &[String],
) -> anyhow::Result<()> {
// Interactive picker requires a terminal for the TUI. The dry-run and
// preview-bench paths bypass skim entirely, so no TTY is required —
// useful for tests, for diagnosing the pre-compute pipeline from scripts,
// and for benchmarking the preview workload headlessly.
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()?;
// Merge CLI flags with resolved config (project-specific config is now available)
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();
// Flag-only: listing PRs always reaches the forge, so it stays opt-in
// per invocation rather than defaulting on via config.
let show_prs = cli_prs;
worktrunk::trace::instant("Picker config resolved");
// Read the terminal size once, from the canonical reader that
// `crate::display::terminal_width` also projects (stderr first, then stdout,
// then `COLUMNS`). The skim list-column width (`skim_list_width` below)
// derives from the same snapshot, so the two can never observe different
// widths — whether across a resize or because stdout and stderr point to
// different terminals.
//
// The layout needs both dimensions, so it trusts the snapshot only when a
// real terminal was detected (width and height both present). A width-only
// `COLUMNS` reading — or no reading at all — falls back to 80x24, exactly as
// before. The picker requires a TTY, so that fallback only bites the
// headless dry-run / preview-bench paths; `skim_list_width` still uses the
// `COLUMNS` width there.
let term_dims = crate::display::terminal_dimensions();
let (term_width, term_height) = match term_dims {
Some((w, Some(h))) => (w, h),
_ => (80, 24),
};
// Reset the preview tab to working-tree and select the layout from the
// terminal size.
let state = PreviewState::new(PreviewLayout::for_dimensions(
term_width as f64,
term_height as f64,
));
worktrunk::trace::instant("Picker layout detected");
// Prime the current worktree's root / git-dir / branch caches with one
// batched `git rev-parse`. Subsumes the two standalone forks that the
// speculative preview block below would otherwise make via `branch()`
// and `root()`, and is also short-circuited when `collect::collect` calls
// `repo.url_template()` → `load_project_config()` → `project_config_path()`
// (which runs `prewarm_info` again — now a cache hit).
let _ = repo.current_worktree().prewarm_info();
// Preview cache is created up-front so the speculative first-item
// preview can run in parallel with `collect::collect` below. Tasks
// route to `COLLECT_POOL` (shared with the row pipeline).
// Wrapped in `Arc` because the progressive handler (running on the
// collect background thread) also calls `spawn_preview`.
//
// BranchDiff previews resolve the upstream-aware comparison base via
// `Repository::branch_diff_spec`, memoized on the shared `RepoCache` and
// primed by `collect::collect` from its ref scan, so N parallel preview
// tasks share one resolution instead of each capturing refs. Read-only for
// the picker session — see `branch_diff_spec` for the staleness contract.
//
// skim 4.x repaints on demand, so the orchestrator needs a handle to skim's
// event loop to surface a preview compute that lands after the keystroke that
// requested it. The picker fills this `OnceLock` once `Skim::init_tui` has run
// (inside `run_skim`); until then a fill simply doesn't poke (harmless — skim
// hasn't rendered a preview to strand yet). The progressive handler and a
// failed-removal restore share the same sender for their own `Event::Render` /
// resync pokes. See `preview_notify` and the `progressive_handler` module
// docstring.
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>> = Arc::new(OnceLock::new());
let orchestrator = Arc::new(PreviewOrchestrator::new(
repo.clone(),
Arc::clone(&render_tx),
));
let preview_cache: PreviewCache = Arc::clone(&orchestrator.cache);
// Speculative warm-up: the picker sorts the current worktree first, and
// the default tab (WorkingTree = `git diff HEAD` in that worktree) is
// what skim will render first. Kicking this off before `collect::collect`
// overlaps preview compute with list collection.
// The real spawn later skips this key via `contains_key`.
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()
}));
// num_items doesn't matter for Right (dims independent of it); for
// Down it only affects height, which doesn't alter pager wrapping.
let dims = state
.initial_layout
.dimensions_for(term_width, term_height, 0);
orchestrator.spawn_preview(
&orchestrator.generation(),
Arc::new(item),
PreviewMode::WorkingTree,
dims,
);
}
// The picker runs every task — it is `wt list --full` (`ShowConfig::Resolved`
// forces `show_full`, so `collect` plans the full task set from all columns).
// `main…±` (BranchDiff) is a default `wt list` column, so the picker surfaces
// it too; it's local git keyed by a persistent content-addressed cache, so
// warm rows are instant and a cold row computes once in the background (its
// merge-base walk streams in behind the frame, never blocking the picker).
// CiStatus is primed from the local cache so the first frame shows cached
// status (see `populate_from_cache`), then fetched live and streamed in — the
// same 3060s-TTL cache plus live fetch as `wt list --full`. The picker's
// lifetime is bounded by the user, so a slow forge call never blocks anything
// (see the "Network Access" notes in CLAUDE.md). The `pr` preview tab reads
// the same live status. `--prs` rows carry their own number from the explicit
// `--prs` forge call.
// Per-task command timeout (bounds any single git invocation) from
// shared `[list]` config. Still applies in progressive mode.
let command_timeout = config.list.task_timeout();
// Progressive rendering means the picker never blocks waiting for
// collect — so there's no UI-freeze budget to bound. The drain runs
// until its results channel closes or the fallback DRAIN_TIMEOUT
// (120s) fires.
// Lay the table out at full terminal width regardless of the preview
// layout. With the preview shown (Right), skim splits the screen and renders
// this full-width row into the left pane, clipping the overflow at the
// boundary; `no_hscroll` plus an empty ellipsis (set on the builder below)
// make that a clean left-anchored cut. Toggling the preview off with alt-p
// widens skim's list pane to full width and the SAME rows reveal their
// right-hand columns — no reload, no re-layout, so no column ever moves.
// (The Down layout already used full width, so this is a no-op there.)
//
// The width comes from the same `term_dims` snapshot as the layout above;
// its fully-headless fallback is `usize::MAX` (vs. the layout's 80 width) to
// keep the math total when no width is known at all — the picker requires a
// TTY, so that only applies to the headless paths. Skim prefixes every line
// with a 2-column cursor gutter ("> "), so the full width loses 2.
let list_width_source = term_dims.map(|(w, _)| w).unwrap_or(usize::MAX);
let skim_list_width = list_width_source.saturating_sub(2);
// Estimate item count for the preview window spec (only the Down
// layout depends on it). The Down layout caps visible rows at
// `max_visible_items(available)`; every row past that cap is a no-op
// for the height computation, so we short-circuit once the estimate
// reaches it.
let num_items_estimate = {
let cap = preview::max_visible_items(preview::available_height(term_height));
let mut estimate = repo.list_worktrees().map(|w| w.len()).unwrap_or(cap);
if estimate < cap && show_branches {
// Local branches are a superset of worktree branches (each
// linked worktree normally has one), so take the max rather
// than summing.
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");
// Compute the dimensions once; the skim preview-window spec is formatted
// from them rather than recomputed.
let preview_dims =
state
.initial_layout
.dimensions_for(term_width, term_height, num_items_estimate);
let preview_window_spec = state.initial_layout.spec_for(preview_dims);
// Summary hint: when summaries are disabled, prime the Summary cache
// with config guidance instead of showing a perpetual "Generating…"
// placeholder.
let (llm_command, summary_hint) =
if config.list.summary() && config.commit_generation.is_configured() {
(config.commit_generation.command.clone(), None)
} else {
// Point at the config file wt actually loads from, not a hardcoded
// default (resolution + fallback live in `config_path_for_display`).
let config_path = worktrunk::config::config_path_for_display();
// Keep every prose line short and put the resolved path on its own
// line. `render_summary` word-wraps prose to the preview width, and
// that width is a column narrower under Windows' PTY — so a sentence
// long enough to wrap lands its break on a different word there, and
// the single cross-platform snapshot can't match. A short lead line,
// the path alone (one unbreakable token, no wrap boundary to shift),
// and the fenced config block (code blocks are never wrapped) all
// render identically on every platform. The first line stays the bold
// H4 subject, which `render_summary` promotes and never wraps.
let hint = if !config.commit_generation.is_configured() {
format!(
r#"Summaries not configured
Add a [commit.generation] command in:
{config_path}
```toml
[commit.generation]
command = "llm -m haiku"
[list]
summary = true
```
"#
)
} else {
format!(
r#"Summaries off
Enable summaries in:
{config_path}
```toml
[list]
summary = true
```
"#
)
};
(None, Some(hint))
};
// The picker's full row list — header, worktree/branch rows, and (in `--prs`
// mode) PR/MR rows. `on_skeleton` fills it with the header + worktree/branch
// rows and the `--prs` thread appends its PR/MR rows; an `alt-x` removal
// mutates it (`AltXRemover`) and rebuilds skim's pool from it (`resync_pool`).
// Starts empty — those writers run only after skim is displaying rows.
let shared_items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>> = Arc::new(Mutex::new(Vec::new()));
// `alt-y` / `alt-o` lookup table (token → branch + URL). The collect handler
// fills it with worktree/branch rows and the `--prs` thread extends it; the
// shortcut keybinding callbacks read it. See `ShortcutTable`.
let shortcut_table: ShortcutTable = Arc::new(Mutex::new(std::collections::HashMap::new()));
// Approvals snapshot for the session: alt-x removals consult it read-only
// to filter the hook plan; see `approved_removal_plan`.
let approvals = Arc::new(Approvals::load().context("Failed to load approvals")?);
// Shared between the bg-thread collect handler and a failed alt-x removal
// (both push warnings while skim owns the terminal) and the main thread
// (which drains them after `Skim::run_with` returns and stderr is safe
// again).
let stashed_warnings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
// The collect pipeline, captured so the initial spawn below and every alt-r
// refresh build it the same way. See `PipelineFactory`.
let factory = Rc::new(PipelineFactory {
repo: repo.clone(),
render_tx: Arc::clone(&render_tx),
shared_items: Arc::clone(&shared_items),
shortcut_table: Arc::clone(&shortcut_table),
preview_cache: Arc::clone(&preview_cache),
orchestrator: Arc::clone(&orchestrator),
stashed_warnings: Arc::clone(&stashed_warnings),
// Full-layout handoff: the handler fills it in `provide_layout`, the
// collector reads it to render the `alt-x` `/ branch` row on this grid.
layout_slot: Arc::new(Mutex::new(None)),
header_flash: Arc::new(items::HeaderFlash::default()),
preview_dims,
skim_list_width,
command_timeout,
llm_command,
summary_hint,
show_branches,
show_remotes,
show_prs,
is_preview_bench,
});
// skim's pull-based reader side: only `alt-r` (`reload(refresh)`) reaches this
// now — `alt-x` removal runs synchronously through the `AltXRemover` below.
let collector = PickerCollector {
items: Arc::clone(&shared_items),
factory: Rc::clone(&factory),
};
// The `alt-x` removal handler. Holds only `Send` state (every field an `Arc`,
// or the `Send` `Repository`) so it can move into the keybinding's `Send`
// callback — it can't carry the collector's `Rc<PipelineFactory>`, so it owns
// the morph/keep shared slots directly. See `AltXRemover` and
// `install_remove_keybinding`.
let alt_x_remover = AltXRemover {
items: Arc::clone(&shared_items),
repo: repo.clone(),
approvals,
render_tx: Arc::clone(&render_tx),
stashed_warnings: Arc::clone(&stashed_warnings),
shortcut_table: Arc::clone(&shortcut_table),
layout_slot: Arc::clone(&factory.layout_slot),
header_flash: Arc::clone(&factory.header_flash),
};
// Half-page preview scroll: half of skim's usable height.
let half_page = (preview::available_height(term_height) / 2).max(5);
// Configure skim options with Rust-based preview and mode switching keybindings
let mut options = SkimOptionsBuilder::default()
.height("90%".to_string())
.reverse(true)
// skim 4.8's default tiebreak, kept explicit so a future edit can't
// quietly reintroduce `PathName` here. The empty-query view (no characters typed)
// is the picker's default frame, and it must show rows in the order
// `collect` produced them: current, main, newest-first worktrees, then
// branches, then the appended `--prs` rows. skim's empty-query engine
// (`MatchAllEngine`) scores every row `(score=0, begin=0)`, so with all
// scores tied the *whole* list is ordered by the second criterion. A
// `PathName` there degenerates: its key is `path_name_offset - begin`, so
// at `begin=0` it collapses to `path_name_offset` — sorting every row by
// where its last `/` sits. That pulls any slash-bearing name out of
// collect order regardless of row kind: a `feature/…` PR head branch, a
// `perf/…` worktree branch, and the `/`-gutter local-branch rows all sink
// together. `Begin`/`End` are `0` on the empty query, so they don't
// perturb it, leaving collect's input order intact.
//
// The cost of omitting `PathName` is confined to typed queries (scores
// only tie once a query matches): leaf-segment matches no longer win an
// exact tie. Two existing mechanisms already cover most of that — the
// shared `~/workspace/` prefix is stripped from each row's `search_text`
// in `progressive_handler::on_skeleton`, and frizbee penalizes
// non-boundary matches — so `feature/auth` still ranks well on `auth`.
.tiebreak(vec![
RankCriteria::Score,
RankCriteria::Begin,
RankCriteria::End,
])
// Fill the whole selected row with the `current` background (set via
// `current_bg` in `.color(...)` below). skim 4.x applies the current-row
// style at the line level only when this is on; without it the selection
// shows just the `>` pointer (the row's own `display()` ANSI spans carry
// no background). skim 0.20's tuikit backend highlighted the row for free.
.highlight_line(true)
// Each row's `display()` owns its layout: a leading gutter sigil
// (`+`/`@`/`^`/`/`/`|`), then columns, right-truncated to the list width
// with a trailing `…`. skim's horizontal scroll is a second, conflicting
// layout authority over the same row — on a query match it scrolls the row
// left to bring the matched char into view, deriving the offset from that
// char's *position* in the match text (`search_text` = branch + full path +
// glyph), which is far longer than the visible row, while clamping against
// the rendered line's own width. Any row whose rendered width exceeds skim's
// container (e.g. a long branch name, or a width-count disagreement on wide
// glyphs) then gets shifted left far enough to clip its leading gutter sigil
// — typing a few chars made the sigil vanish from every overflowing row.
// Disabling hscroll leaves worktrunk as the sole row-layout
// authority: overflow truncates on the right (gutter kept) instead of
// scrolling left. The picker doesn't reveal matches by scrolling anyway —
// `display()` ignores the match context and renders its own ANSI.
.no_hscroll(true)
// Draw a scrollbar thumb on the item list when it overflows the view.
// skim's `▐` default is the clap `default_value`, gated on skim's `cli`
// feature; with `default-features = false` the library `Default` for
// this `String` field is empty, which skim reads as "no scrollbar".
// Setting it explicitly restores the thumb — without it a long worktree
// (or `--prs`) list scrolls with no position cue, made worse by
// `no_info(true)` below hiding the matched/total counter.
.scrollbar("".to_string())
// First line (header) non-selectable; `PICKER_HEADER_ROWS` names the count.
.header_lines(PICKER_HEADER_ROWS)
.multi(false)
// The table is laid out at full terminal width (see `skim_list_width`
// above), so while the preview is shown the rows overflow skim's
// half-width list pane. Disable horizontal scroll so a fuzzy match deep
// in the search key can never shift the leading columns out of view — the
// row always clips left-anchored at the pane boundary. An empty ellipsis
// makes that a clean cut with no "..": it is the library default under
// `default-features = false` (the `..` default is gated on skim's `cli`
// feature, off here), pinned explicitly because the clean clip is
// load-bearing for the overflow.
.no_hscroll(true)
.ellipsis(String::new())
.no_info(true) // Hide info line (matched/total counter)
.preview("") // Enable preview (empty string means use SkimItem::preview())
.preview_window(preview_window_spec.as_str())
// Color scheme using fzf's --color=light values: dark text (237) on light gray bg (251)
//
// Terminal color compatibility is tricky:
// - current_bg:254 (original): too bright on dark terminals, washes out text
// - current_bg:236 (fzf dark): too dark on light terminals, jarring contrast
// - current_bg:251 + current:-1: light bg works on both, but unstyled text
// becomes unreadable on dark terminals (light-on-light)
// - current_bg:251 + current:237: fzf's light theme, best compromise
//
// The light theme works universally because:
// - On dark terminals: light gray highlight stands out clearly
// - On light terminals: light gray is subtle but visible
// - Dark text (237) ensures readability regardless of terminal theme
.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![
// Preview-tab switching (alt-1..alt-7 jump to a tab; tab / shift-tab
// cycle) is installed natively below via `install_preview_tab_keybindings`
// rather than here — those keys run Rust callbacks, not shell commands.
// Bare digits 1-7 stay unbound so they flow to the query input (a PR
// number, or digits within a branch name).
//
// Create new worktree with query as branch name (alt-c for "create")
"alt-c:accept(create)".to_string(),
// alt-x (remove) is installed natively below via
// `install_remove_keybinding` — a Custom callback that runs the removal
// synchronously and rebuilds skim's pool in place (no `reload`, so no
// cursor flash), which a string bind can't express.
// Refresh the list (alt-r for "refresh"): `reload(refresh)` re-runs
// collect through PickerCollector, picking up worktrees/branches
// created outside the session (a teammate's push, a parallel agent)
// without reopening the picker.
"alt-r:reload(refresh)".to_string(),
// Preview toggle (alt-p shows/hides preview)
// Note: skim doesn't support change-preview-window like fzf, only toggle
"alt-p:toggle-preview".to_string(),
// Suppress skim's default manual horizontal scroll (alt-h / alt-l map to
// ScrollLeft / ScrollRight in its built-in keymap). `no_hscroll(true)`
// above only zeros the *automatic* match-following shift; it doesn't gate
// the manual `manual_hscroll` offset these keys push, so they still slide
// each row's `display()` left under the fixed gutter — clipping the leading
// worktree-status sigil (`+`/`@`/`^`/`/`/`|`) and the branch name with no
// ellipsis. The row table is laid out to fit the pane, so there is nothing
// to scroll to; ignore both.
"alt-h:ignore".to_string(),
"alt-l:ignore".to_string(),
// Preview scrolling (half-page based on terminal height)
format!("ctrl-u:preview-up({half_page})"),
format!("ctrl-d:preview-down({half_page})"),
])
// Legend/controls moved to preview window tabs (render_preview_tabs)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build skim options: {}", e))?;
// `.build()` parsed the string binds above into `options.keymap`; layer the
// preview-tab switches on top as native `Action::Custom` callbacks (skim's
// string bind API can't express a custom action).
install_preview_tab_keybindings(&mut options.keymap);
// Row shortcuts (alt-y copy branch, alt-o open PR/MR URL) — native callbacks
// that read the selected row off skim's `App` and run the OS action on a
// background thread. Like the preview-tab keys, they can't be string binds.
install_shortcut_keybindings(&mut options.keymap, Arc::clone(&shortcut_table));
// alt-x (remove): a Custom callback that runs the removal synchronously and
// rebuilds skim's pool in place — no `reload`, so the cursor never flashes to
// the top. Moves the `AltXRemover` in (the callback must be `Send`).
install_remove_keybinding(&mut options.keymap, alt_x_remover);
worktrunk::trace::instant("Picker skim options built");
// Spawn the collect pipeline (and the `--prs` thread when active). Each
// sender drops with its one batch sent (skeleton, PR rows), so skim's
// reader sees EOF as soon as the last batch lands — see
// `PipelineFactory::spawn`. The initial spawn reuses the startup-primed
// inventory (`false`); every alt-r refresh re-runs `factory.spawn(true)`
// to re-enumerate.
let SpawnedPipeline {
rx,
handler,
collect_handle,
prs_handle,
} = factory.spawn(false)?;
worktrunk::trace::instant("Picker collect spawned");
// The dry run keeps the handler: skim never runs there, so the EOF contract
// doesn't apply, and the dump below reads its rendered rows.
let dry_run_handler = is_dry_run.then_some(handler);
// Dry-run / preview-bench: skim is bypassed. Wait for collect (which
// spawns previews via the handler) to finish, then for the orchestrator's
// pending tasks to drain on `COLLECT_POOL`. Dry-run additionally
// drains stashed warnings and dumps the cache inventory; preview-bench
// returns immediately so the measured wall clock is just "spawn → all
// preview tasks drained", with no JSON serialization or stderr I/O in
// the hot path.
if skip_tui {
drop(rx);
let _ = collect_handle.join();
// Join the `--prs` thread (present only for the dry-run, not the bench)
// so its forge fetch and row render run to completion before we dump
// and exit — this normal-exit path is what gives the streaming code its
// coverage. The PR rows it built went nowhere (`rx` is dropped); the
// dump is the worktree-preview cache, unchanged.
if let Some(handle) = prs_handle {
let _ = handle.join();
}
orchestrator.wait_for_idle();
if is_dry_run {
drain_stashed_warnings(&stashed_warnings);
// Final rendered rows (ANSI stripped) — lets tests assert on
// picker row content without a PTY.
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(());
}
// Run skim (single invocation — alt-r reloads and alt-x resyncs in place, not
// re-launch). Skim receives items as the bg thread's handler sends them, and the
// handler pushes repaints through `render_tx` (filled inside `run_skim`)
// as it mutates rows in place.
//
// Don't join `collect_handle` after skim exits: drain may still be running
// network tasks, and joining would block exit for up to DRAIN_TIMEOUT
// (120s). Process exit terminates the bg thread; everything it started is
// read-only, and the cancellation below stops the subprocesses it spawned,
// which process exit does not.
let output = run_skim(options, rx, &render_tx);
drop(collect_handle);
// Same rationale as `collect_handle`: don't join — the forge call may still be
// in flight, and process exit terminates the thread (its `gh`/`glab`
// subprocess is read-only).
drop(prs_handle);
// Skim has released the terminal — emit any warnings that collect's bg
// thread stashed during the run. Late warnings (e.g. drain timeouts)
// may still be in flight; we capture whatever has landed by now and let
// the rest fall on the floor with the bg thread.
drain_stashed_warnings(&stashed_warnings);
// The picker is over, so its background work has no consumer left: the
// preview cache dies with the process and skim will never repaint again.
// Uncancelled, a preview diff per worktree runs to completion against a
// repo the user has already left — `wt`'s exit ends the pool's threads,
// not the git children they spawned. Applies equally to accept and abort.
//
// After the drain, not before: a `--prs` forge call killed mid-flight
// fails like any other, and stashes that failure as a warning. Cancelling
// first would drain a spurious "couldn't fetch PRs" onto the user.
//
// Not everything running here is discardable — an `alt-x` removal is
// dispatched to its own thread and can still be mid-`git worktree remove`
// if the user pressed Enter straight after. Removal threads are spawned
// through `spawn_removal`, which marks them `uninterruptible` and lets
// them run to completion; this reaches only speculative work.
worktrunk::shell_exec::cancel_background_commands();
// `run_skim` returns Err only on a genuine TUI init / event-loop failure;
// a user cancel is `Ok` with `is_abort` set. Surface a real failure.
let out = output?;
// Handle selection
if !out.is_abort {
// Determine action: create (alt-c) or switch (enter)
// Remove (alt-x) is handled inline in its keybinding callback — it never
// reaches accept.
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);
// Get the switch identifier: the query if creating new, otherwise the
// selected item. `picker_item_identifier` yields a worktree path for
// any worktree-backed row and the branch name for a branch-only row
// (same as `wt switch` from CLI) — never the raw `worktree-path:` token.
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 = switch_pipeline_repo(&repo, is_recovered)?;
// Clone user config out — `SwitchPipeline` takes `&mut UserConfig` (the
// bare-repo path-fix offer and the shell-integration offer record onto
// it). Project config is loaded on demand inside the pipeline.
let mut config = repo.user_config().clone();
// Run the switch — the same `SwitchPipeline` as `wt switch <branch>`,
// so hooks, approval, and output cannot drift from the argument path.
// The picker offers no shell integration, but (like the argument path)
// the pipeline captures the pre-switch source worktree, so an existing
// switch's `{{ base }}` / `{{ base_worktree_path }}` resolve to the
// worktree the user came from. An `--execute` command (`wt switch -x
// <cmd>`) runs against the picked worktree, and its `{{ base }}` matches
// what `wt switch <branch> -x <cmd>` would produce.
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,
execute,
execute_args,
shell_integration_binary: None,
}
.run()?;
}
Ok(())
}
/// Install the preview-tab switches into skim's keymap: alt-1…alt-7 jump to a
/// tab, tab / shift-tab cycle forward / backward.
///
/// skim's string bind API only maps keys to its built-in actions, so these go
/// in as `Action::Custom` callbacks that set the process-wide
/// [`PreviewStateData`] mode and return `Event::RunPreview` to repaint. They're
/// native rather than `execute-silent` shell commands, so they behave
/// identically everywhere — the previous `echo`/`tr`/`mv` keybind bodies ran
/// through skim's shell, which on Windows is cmd.exe and has neither `tr` nor
/// `mv`. This is also what lets `wt switch` run its picker on Windows at all.
///
/// Keys are resolved with skim's own `parse_key` so they match exactly what its
/// keymap lookup expects (`KeyMap` is keyed by the crossterm `KeyEvent`
/// `parse_key` produces). Shift-Tab is bound under every spelling crossterm
/// might report (`btab` / `shift-btab` / `shift-tab`), mirroring skim's default
/// keymap, so the cycle-back override holds regardless of terminal.
fn install_preview_tab_keybindings(keymap: &mut skim::binds::KeyMap) {
use skim::binds::parse_key;
// alt-N jumps to tab N (1-indexed, matching PreviewMode's discriminant).
let switch_to = |mode: PreviewMode| {
Action::Custom(ActionCallback::new_sync(move |_app| {
PreviewStateData::set_mode(mode);
Ok(vec![Event::RunPreview])
}))
};
for digit in 1..=7u8 {
if let Ok(key) = parse_key(&format!("alt-{digit}")) {
keymap.insert(key, vec![switch_to(PreviewMode::from_u8(digit))]);
}
}
let cycle = |forward: bool| {
Action::Custom(ActionCallback::new_sync(move |_app| {
PreviewStateData::rotate(forward);
Ok(vec![Event::RunPreview])
}))
};
if let Ok(key) = parse_key("tab") {
keymap.insert(key, vec![cycle(true)]);
}
for back in ["btab", "shift-btab", "shift-tab"] {
if let Ok(key) = parse_key(back) {
keymap.insert(key, vec![cycle(false)]);
}
}
}
/// The branch name `alt-y` copies for the row whose `output()` token is `token`:
/// its `RowShortcutData.branch`. `None` when the token isn't in the table or the
/// row has no branch (a detached worktree), so `alt-y` no-ops. Pulled out of the
/// keybinding closure so the lookup — the part that doesn't need a live skim
/// `App` — is unit-testable.
fn resolve_shortcut_branch(table: &ShortcutTable, token: &str) -> Option<String> {
table
.lock()
.unwrap()
.get(token)
.and_then(|d| d.branch.clone())
}
/// The PR/MR URL `alt-o` opens for the row whose `output()` token is `token`.
/// `None` when the token isn't in the table or the row has no URL (a worktree
/// whose PR hasn't resolved, or has none), so `alt-o` no-ops. The counterpart to
/// [`resolve_shortcut_branch`].
fn resolve_shortcut_url(table: &ShortcutTable, token: &str) -> Option<String> {
table
.lock()
.unwrap()
.get(token)
.and_then(|d| d.url.resolve())
}
/// Install the `alt-y` (copy branch) and `alt-o` (open PR/MR URL) row shortcuts
/// as native callbacks, alongside the preview-tab keys.
///
/// Both read the selected row off skim's `App` — its `output()` token, looked up
/// in `shortcut_table` for the branch / URL — and run the OS action (clipboard,
/// browser) on a background thread, so skim's event loop never blocks and a slow
/// clipboard or opener can't freeze the frame. Neither touches the list, so
/// there's no reload and the cursor stays put. Both no-op when the row lacks the
/// thing they act on: `alt-y` on a detached worktree (no branch), `alt-o` on a
/// row with no URL (a worktree whose PR hasn't resolved, or has none). Failures
/// are logged, not surfaced — skim owns the terminal.
fn install_shortcut_keybindings(keymap: &mut skim::binds::KeyMap, shortcut_table: ShortcutTable) {
use skim::binds::parse_key;
// alt-y: copy the selected row's branch name to the system clipboard.
if let Ok(key) = parse_key("alt-y") {
let table = Arc::clone(&shortcut_table);
keymap.insert(
key,
vec![Action::Custom(ActionCallback::new_sync(move |app| {
let branch = app
.item_list
.selected()
.and_then(|m| resolve_shortcut_branch(&table, m.item.output().as_ref()));
if let Some(branch) = branch {
spawn_shortcut("picker-copy", move || os::copy_to_clipboard(&branch));
}
Ok(Vec::new())
}))],
);
}
// alt-o: open the selected row's PR/MR URL in the browser.
if let Ok(key) = parse_key("alt-o") {
let table = Arc::clone(&shortcut_table);
keymap.insert(
key,
vec![Action::Custom(ActionCallback::new_sync(move |app| {
let url = app
.item_list
.selected()
.and_then(|m| resolve_shortcut_url(&table, m.item.output().as_ref()));
if let Some(url) = url {
spawn_shortcut("picker-open", move || os::open_url(&url));
}
Ok(Vec::new())
}))],
);
}
}
/// Install `alt-x` (remove the selected row) as a native binding: a single Custom
/// callback that runs the removal synchronously through [`AltXRemover`].
///
/// `alt-x` no longer goes through skim's `reload`. A `reload` clears the item pool
/// and runs the matcher against it once *before* the new rows arrive, which resets
/// the cursor to the top (`current = 0`) for a frame — the flash this fixes. Here
/// the callback mutates the row list ([`AltXRemover::apply`]) and rebuilds skim's
/// pool itself ([`resync_pool`]) on the same event-loop tick, so the matcher only
/// ever sees the post-removal list and the cursor holds its slot. The
/// [`RemovalEffect`] says how to refresh skim's view: a drop resyncs the pool, a
/// morph repaints the row in place and refreshes its (now-dimmed) preview, a kept
/// row needs nothing.
///
/// The callback owns the `remover` (moved in) — skim requires a `Send` callback,
/// which is why [`AltXRemover`] carries only `Send` state and not the collector's
/// `Rc<PipelineFactory>`. A native keymap insert (not a string bind) is required
/// because a string bind can't express a Rust callback (like the preview-tab and
/// row shortcuts).
fn install_remove_keybinding(keymap: &mut skim::binds::KeyMap, remover: AltXRemover) {
use skim::binds::parse_key;
let Ok(key) = parse_key("alt-x") else {
return;
};
let cb = Action::Custom(ActionCallback::new_sync(move |app| {
// The selected row's `output()` token identifies what to remove. No
// selection (empty list) → nothing to do.
let Some(selected) = app.item_list.selected() else {
return Ok(Vec::new());
};
let selected_output = selected.item.output().into_owned();
match remover.apply(selected_output) {
RemovalEffect::Dropped => {
// The row left `items`; rebuild skim's pool from the shrunk list so
// the matcher re-filters it in place — the cursor holds its index and
// the row that slid up lands under it (no reset, no flash). skim
// processes a callback's returned events (then a Render) in order, so
// the queued resync runs before any repaint — same effect as an inline
// rebuild, and it shares the one `resync_pool_action` the failed-removal
// restore also queues. Then a settled-gated `RunPreview`: for a
// *last*-row drop skim's own preview-on-selection-change can't fire
// (`current` goes briefly out of range), so the pane would otherwise
// keep showing the removed row; for a middle-row drop skim already
// refreshes it, so this is a cheap cache-hit repaint.
Ok(vec![
Event::Action(resync_pool_action(Arc::clone(&remover.items))),
Event::Action(run_preview_when_settled(
Arc::new(AtomicUsize::new(0)),
Arc::new(AtomicUsize::new(0)),
)),
])
}
// The row's content changed in place (same item, no `Replace`), so the
// cursor doesn't move and skim's auto-preview doesn't fire — repaint the
// list row and request its (now working-tree-dimmed) preview explicitly.
RemovalEffect::Morphed => Ok(vec![Event::Render, Event::RunPreview]),
// The row is unchanged (declined / retained, with a stashed hint shown
// on exit); the cursor never moved, so nothing to repaint.
RemovalEffect::Kept => Ok(Vec::new()),
}
}));
keymap.insert(key, vec![cb]);
}
/// Run a removal's git work on a named background thread, exempt from
/// background-command cancellation: a removal is work the user asked for, and
/// a SIGTERM landing between `git worktree remove` and the branch delete would
/// leave them half-removed. Every removal dispatch goes through here so the
/// exemption is carried by the spawn path itself rather than remembered at
/// each call site.
fn spawn_removal<F>(name: String, work: F)
where
F: FnOnce() + Send + 'static,
{
let _ = std::thread::Builder::new()
.name(name)
.spawn(move || worktrunk::shell_exec::uninterruptible(work));
}
/// Run a row shortcut's OS action on a named background thread, logging any
/// failure — the picker owns the terminal, so an error can't be shown inline.
fn spawn_shortcut<F>(name: &str, action: F)
where
F: FnOnce() -> anyhow::Result<()> + Send + 'static,
{
let _ = std::thread::Builder::new()
.name(name.to_string())
.spawn(move || {
if let Err(e) = action() {
log::warn!("picker: {e:#}");
}
});
}
/// Run skim to completion, exposing its event sender for progressive repaints.
///
/// This inlines what `Skim::run_with` does, plus one addition: after the TUI is
/// initialized we publish `Skim::event_sender()` into `render_tx`. skim 4.x
/// renders on demand, so the background collect thread's in-place row mutations
/// stay invisible until something wakes the event loop — the handler pushes
/// `Event::Render` through that sender (see `progressive_handler`), and the
/// preview-tab keybindings return `Event::RunPreview` from their callbacks.
///
/// `wt` runs no outer tokio runtime, so skim's event loop runs on a fresh
/// multi-thread `Runtime` — the same one `run_with` builds in that case. A user
/// cancel is `Ok(SkimOutput)` with `is_abort` set; only a genuine init /
/// event-loop failure is an `Err`.
///
/// Injecting `Event::Render` / `Event::RunPreview` is safe against clobbering
/// the recorded selection: skim's `Accept` / `Abort` set `should_quit` in the
/// same `tick` that records them as `final_event`, and `run()` breaks before
/// the next `tick`, so a trailing injected event is never processed after the
/// terminal action.
fn run_skim(
options: SkimOptions,
rx: SkimItemReceiver,
render_tx: &Arc<OnceLock<tokio::sync::mpsc::Sender<Event>>>,
) -> anyhow::Result<SkimOutput> {
let mut skim: Skim = Skim::init(options, Some(rx))
.map_err(|e| anyhow::anyhow!("failed to initialize picker: {e}"))?;
skim.start();
// `should_enter` is false only for skim's filter / select-1 / exit-0 / sync
// modes — none of which the picker enables — so the TUI is always entered.
// The guard just keeps the fallback safe (an aborted output) rather than
// panicking on `event_sender()` if that ever changes.
if skim.should_enter() {
skim.init_tui()
.map_err(|e| anyhow::anyhow!("failed to initialize picker TUI: {e}"))?;
// event_sender() requires init_tui(); publish it before entering the
// loop so the handler's in-place updates can request repaints.
let _ = render_tx.set(skim.event_sender());
let runtime =
tokio::runtime::Runtime::new().context("failed to start picker event-loop runtime")?;
let result = runtime.block_on(async {
skim.enter().await?;
skim.run().await
});
result.map_err(|e| anyhow::anyhow!("interactive picker failed: {e}"))?;
}
Ok(skim.output())
}
/// Resolve the branch identifier from picker output.
///
/// Extracted from the picker's accept handler for testability.
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"
);
}
}
},
}
}
/// Select the `Repository` the accept path's `SwitchPipeline` runs against.
///
/// Extracted from the picker's accept handler for testability.
///
/// Rebuilds fresh rather than reuse `repo` (whose `OnceCell` worktree/branch
/// caches were primed at picker startup and never invalidated, same
/// discipline `spawn`'s `rebuild_repo` doc explains): an in-picker alt-x/alt-r
/// during this session can have removed or added worktrees/branches since.
/// `Repository::current()` re-discovers from cwd, which fails after a
/// deleted-CWD recovery, so the recovered arm rebuilds via `Repository::at`
/// on the already-recovered discovery path instead of reusing the stale
/// startup snapshot.
fn switch_pipeline_repo(repo: &Repository, is_recovered: bool) -> anyhow::Result<Repository> {
if is_recovered {
Repository::at(repo.discovery_path()).context("Failed to switch worktree")
} else {
Repository::current().context("Failed to switch worktree")
}
}
#[cfg(test)]
pub mod tests {
use super::items::{LocalCheckout, LocalContent, PickerRow, worktree_output_token};
use super::{
AltXRemover, PickerAction, PickerRemovalTarget, RemovalEffect, drain_stashed_warnings,
install_preview_tab_keybindings, install_shortcut_keybindings, picker_item_identifier,
resolve_identifier, resolve_shortcut_branch, resolve_shortcut_url, switch_pipeline_repo,
};
use crate::commands::list::model::{BranchScope, ItemKind, ListItem, WorktreeData};
use crate::commands::worktree::RemoveResult;
use skim::prelude::SkimItem;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use worktrunk::config::Approvals;
use worktrunk::git::BranchDeletionMode;
/// Empties the stash and emits each line. Verifies post-skim drain
/// semantics without standing up a real picker.
#[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());
}
/// A fresh stash with no warnings is a no-op — exercising the empty path
/// keeps the loop body covered when the picker exits cleanly.
#[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_install_preview_tab_keybindings() {
use skim::binds::{KeyMap, parse_key};
use skim::prelude::Action;
// The native preview-tab switches replace skim's default bindings for
// these keys with exactly one custom action each. This asserts the
// wiring (keyed via skim's own `parse_key` so the lookup matches its
// event loop). Which tab each callback selects can't be asserted here
// (`Action::Custom` has no `Eq`), but the callbacks are built by a
// uniform `from_u8` loop — `from_u8`/`next`/`prev` are unit-tested in
// `preview`, and the `switch_picker` PTY tests drive the keys end-to-end.
let mut keymap = KeyMap::default();
install_preview_tab_keybindings(&mut keymap);
let mut specs: Vec<String> = (1..=7).map(|d| format!("alt-{d}")).collect();
specs.extend(["tab", "btab", "shift-btab", "shift-tab"].map(String::from));
for spec in specs {
let key = parse_key(&spec).expect("known key spec parses");
let chain = keymap
.get(&key)
.unwrap_or_else(|| panic!("{spec} not bound"));
assert_eq!(chain.len(), 1, "{spec} should bind a single action");
assert!(
matches!(chain[0], Action::Custom(_)),
"{spec} should bind a native custom action, got {:?}",
chain[0]
);
}
}
#[test]
fn test_install_shortcut_keybindings() {
use skim::binds::{KeyMap, parse_key};
use skim::prelude::Action;
// alt-y (copy branch) and alt-o (open URL) bind native custom actions
// that read the selected row off skim's `App` and look it up in the
// shortcut table — no shell binds, so they work cross-platform. The
// callback behavior (clipboard / browser) is driven by the `switch_picker`
// PTY tests; here we just assert the wiring, mirroring the tab test above.
let mut keymap = KeyMap::default();
let table = Arc::new(Mutex::new(std::collections::HashMap::new()));
install_shortcut_keybindings(&mut keymap, table);
for spec in ["alt-y", "alt-o"] {
let key = parse_key(spec).expect("known key spec parses");
let chain = keymap
.get(&key)
.unwrap_or_else(|| panic!("{spec} not bound"));
assert_eq!(chain.len(), 1, "{spec} should bind a single action");
assert!(
matches!(chain[0], Action::Custom(_)),
"{spec} should bind a native custom action, got {:?}",
chain[0]
);
}
}
/// The lookup the `alt-y` / `alt-o` closures delegate to — pure table logic,
/// no live skim `App`. Covers a row with a branch + URL, a detached row (no
/// branch, no URL — both shortcuts no-op), and a token absent from the table.
#[test]
fn resolve_shortcut_branch_and_url() {
use super::items::{RowShortcutData, RowUrl, ShortcutTable};
let table: ShortcutTable = Arc::new(Mutex::new(std::collections::HashMap::new()));
{
let mut t = table.lock().unwrap();
t.insert(
"feat".into(),
RowShortcutData {
branch: Some("feat".into()),
url: RowUrl::Static(Some("https://example.test/pr/1".into())),
morph: None,
},
);
t.insert(
"wt".into(),
RowShortcutData {
branch: None,
url: RowUrl::Static(None),
morph: None,
},
);
}
assert_eq!(
resolve_shortcut_branch(&table, "feat").as_deref(),
Some("feat")
);
assert_eq!(resolve_shortcut_branch(&table, "wt"), None); // detached: no branch
assert_eq!(resolve_shortcut_branch(&table, "missing"), None);
assert_eq!(
resolve_shortcut_url(&table, "feat").as_deref(),
Some("https://example.test/pr/1")
);
assert_eq!(resolve_shortcut_url(&table, "wt"), None); // no URL
assert_eq!(resolve_shortcut_url(&table, "missing"), None);
}
#[test]
fn test_resolve_identifier() {
// Switch returns the selected name
let result = resolve_identifier(
&PickerAction::Switch,
String::new(),
Some("feature/foo".into()),
);
assert_eq!(result.unwrap(), "feature/foo");
// Switch with no selection and empty query
let result = resolve_identifier(&PickerAction::Switch, String::new(), None);
assert!(
result
.unwrap_err()
.to_string()
.contains("No worktree selected")
);
// Switch with no selection but a query — the panic from #1565
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"));
// Create returns the query
let result = resolve_identifier(&PickerAction::Create, "new-branch".into(), None);
assert_eq!(result.unwrap(), "new-branch");
// Create with empty query is an error
let result = resolve_identifier(&PickerAction::Create, String::new(), None);
assert!(result.unwrap_err().to_string().contains("no branch name"));
}
/// `is_recovered=true` must rebuild rather than reuse `repo`: a worktree
/// added after `repo`'s `OnceCell` list is primed must be visible through
/// the returned `Repository`, and not through the stale one — proving the
/// fix actually observes post-mutation state instead of just "not
/// panicking".
#[test]
fn test_switch_pipeline_repo_recovered_rebuilds_fresh() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let stale_repo = worktrunk::git::Repository::at(test.path()).unwrap();
// Prime the worktree-list cache before the mutation, same as the
// picker priming its startup repo.
assert!(stale_repo.worktree_for_branch("feature").unwrap().is_none());
stale_repo
.run_command(&[
"worktree",
"add",
"-b",
"feature",
test.path()
.parent()
.unwrap()
.join("feature")
.to_str()
.unwrap(),
])
.unwrap();
let fresh_repo = switch_pipeline_repo(&stale_repo, true).unwrap();
assert!(
fresh_repo.worktree_for_branch("feature").unwrap().is_some(),
"recovered arm must rebuild fresh so a worktree added after the \
startup snapshot was primed is visible"
);
assert!(
stale_repo.worktree_for_branch("feature").unwrap().is_none(),
"the startup repo's own cache stays stale, confirming the fix \
rebuilds rather than mutates in place"
);
}
/// `from_signal` rejects tokens that carry no usable target: a blank or
/// whitespace-only signal, and a bare `worktree-path:` prefix with no path
/// after it. A non-empty branch token and a prefixed path both parse.
#[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")
));
}
/// `picker_item_identifier` yields the worktree path for every
/// worktree-backed row — branched as well as detached — and the branch name
/// for a branch-only row, matching what each row's `output()` token carries.
#[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,
removed_commit: None,
};
AltXRemover::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();
// Create a branch at the same commit (fully integrated into main)
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,
};
AltXRemover::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();
// Create a branch with an unmerged commit
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,
};
AltXRemover::do_removal(&repo, &result, &Approvals::default()).unwrap();
// Branch should be retained — SafeDelete won't delete unmerged branches
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();
// Detach HEAD in the new worktree
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,
removed_commit: None,
};
AltXRemover::do_removal(&repo, &result, &Approvals::default()).unwrap();
assert!(!wt_path.exists(), "detached worktree should be removed");
}
/// A branch-only row's signal carries the bare branch name, which
/// `PickerRemovalTarget::from_signal` decodes to `Branch`; `prepare_removal`
/// then resolves it to the branch-only disposition.
#[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();
// A branch at the same commit as main, with no worktree.
repo.run_command(&["branch", "branch-only-feature"])
.unwrap();
let remover = test_remover(Arc::new(Mutex::new(Vec::new())), repo);
let target = PickerRemovalTarget::from_signal("branch-only-feature").unwrap();
let (_planning_repo, result) = remover.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"
);
}
/// A selection that names neither a worktree nor a local branch fails the
/// `prepare_worktree_removal` validation, so `prepare_removal` returns the
/// error rather than touching the picker list.
#[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 remover = test_remover(Arc::new(Mutex::new(Vec::new())), repo);
// `RemoveResult` isn't `Debug`; drop the Ok payload so `unwrap_err`
// (which needs `T: Debug`) can report a failure cleanly.
let target = PickerRemovalTarget::from_signal("no-such-branch").unwrap();
let err = remover
.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:#}"
);
}
/// A `pre-remove` hook the user hasn't approved must not run when the
/// picker removes the worktree — the picker can't prompt mid-render, so
/// unapproved project commands are skipped. The git removal still happens.
#[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();
// A `pre-remove` hook in the invoking worktree's `.config/wt.toml` —
// the config the picker removal resolves against — that would write a
// marker if it ever ran.
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,
removed_commit: None,
};
// Empty approvals → `approve_readonly` drops the unapproved project
// `pre-remove` pipeline from the plan, so it never runs.
let approvals = Approvals::default();
AltXRemover::do_removal(&repo, &result, &approvals).unwrap();
assert!(!wt_path.exists(), "worktree should be removed");
assert!(!marker.exists(), "unapproved pre-remove hook must not run");
}
/// Build a `PickerRow` from a snapshot `ListItem`.
fn picker_item(branch_name: &str, item: ListItem) -> Arc<dyn SkimItem> {
let item = Arc::new(item);
let pr_status = Arc::new(Mutex::new(item.pr_status.clone()));
let output_token = worktree_output_token(&item, branch_name);
Arc::new(PickerRow {
search_base: branch_name.to_string(),
gutter: '@',
rendered: Arc::new(Mutex::new(String::new())),
branch_name: branch_name.to_string(),
output_token,
preview_cache: Arc::new(dashmap::DashMap::new()),
pr_status,
notifier: super::preview_notify::PreviewNotifier::detached(),
local: Some(LocalCheckout {
item,
demand: super::preview_orchestrator::PreviewDemand::new(),
spawn_gen: super::preview_orchestrator::SpawnGeneration::default(),
has_upstream: false,
summaries_enabled: false,
local_content: Arc::new(Mutex::new(LocalContent::default())),
morphed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}),
}) as Arc<dyn SkimItem>
}
/// Build a `PickerRow` standing in for a detached-worktree row.
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)
}
/// Build a `PickerRow` standing in for a branched-worktree row.
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)
}
/// Build a `PickerRow` standing in for a branch-only row (no worktree).
fn branch_only_picker_item(branch: &str) -> Arc<dyn SkimItem> {
picker_item(
branch,
ListItem::new_branch("abc123".to_string(), branch.to_string()),
)
}
/// Build a morphable worktree row and register everything the morph path needs
/// — a [`MorphHandle`](items::MorphHandle) in the remover's shortcut table,
/// keyed by the row's `output()` token, and a real layout in its slot — so a
/// kept-branch removal morphs in place instead of falling back to a drop.
/// Returns the row, its token, and the shared `rendered` / `morphed` slots the
/// morph mutates (so a test can assert on them).
fn setup_morphable_row(
remover: &AltXRemover,
branch: &str,
path: &Path,
) -> (
Arc<dyn SkimItem>,
String,
Arc<Mutex<String>>,
Arc<std::sync::atomic::AtomicBool>,
) {
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()
}));
let item_arc = Arc::new(item);
let rendered = Arc::new(Mutex::new(format!("+ {branch}")));
let local_content = Arc::new(Mutex::new(LocalContent::default()));
let morphed = Arc::new(std::sync::atomic::AtomicBool::new(false));
let row: Arc<dyn SkimItem> = Arc::new(PickerRow {
search_base: branch.to_string(),
gutter: '+',
rendered: Arc::clone(&rendered),
branch_name: branch.to_string(),
output_token: worktree_output_token(&item_arc, branch),
preview_cache: Arc::new(dashmap::DashMap::new()),
pr_status: Arc::new(Mutex::new(None)),
notifier: super::preview_notify::PreviewNotifier::detached(),
local: Some(LocalCheckout {
item: Arc::clone(&item_arc),
demand: super::preview_orchestrator::PreviewDemand::new(),
spawn_gen: super::preview_orchestrator::SpawnGeneration::default(),
has_upstream: false,
summaries_enabled: false,
local_content: Arc::clone(&local_content),
morphed: Arc::clone(&morphed),
}),
});
let token = row.output().to_string();
remover.shortcut_table.lock().unwrap().insert(
token.clone(),
super::items::RowShortcutData {
branch: Some(branch.to_string()),
url: super::items::RowUrl::Static(None),
morph: Some(super::items::MorphHandle {
item: Arc::clone(&item_arc),
rendered: Arc::clone(&rendered),
local_content,
morphed: Arc::clone(&morphed),
}),
},
);
*remover.layout_slot.lock().unwrap() =
Some(crate::commands::list::layout::calculate_layout_with_width(
std::slice::from_ref(&*item_arc),
&crate::commands::list::columns::all_tasks(),
80,
Path::new("/test"),
None,
None,
crate::commands::list::layout::ColumnSelection {
custom: &[],
selected: None,
},
));
(row, token, rendered, morphed)
}
/// A real [`PipelineFactory`] with empty config for the removal / `invoke`
/// tests. Its `spawn()` is only reached by the refresh verb, which these
/// tests don't exercise, so the minimal field set is enough to satisfy the
/// type without standing up a full picker.
fn test_factory(repo: worktrunk::git::Repository) -> std::rc::Rc<super::PipelineFactory> {
let render_tx = Arc::new(OnceLock::new());
let orchestrator = Arc::new(super::preview_orchestrator::PreviewOrchestrator::new(
repo.clone(),
Arc::clone(&render_tx),
));
let preview_cache = Arc::clone(&orchestrator.cache);
std::rc::Rc::new(super::PipelineFactory {
repo,
render_tx,
shared_items: Arc::new(Mutex::new(Vec::new())),
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
preview_cache,
orchestrator,
stashed_warnings: Arc::new(Mutex::new(Vec::new())),
layout_slot: Arc::new(Mutex::new(None)),
header_flash: Arc::new(super::items::HeaderFlash::default()),
preview_dims: (80, 24),
skim_list_width: 80,
command_timeout: None,
llm_command: None,
summary_hint: None,
show_branches: false,
show_remotes: false,
show_prs: false,
is_preview_bench: false,
})
}
/// An [`AltXRemover`] for the removal tests, wrapping the given `items` and
/// `repo`. Its shortcut-table / layout / `stashed_warnings` come from a fresh
/// `test_factory` so [`setup_morphable_row`] can register a morph handle and a
/// test can assert on stashed warnings.
fn test_remover(
items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>>,
repo: worktrunk::git::Repository,
) -> AltXRemover {
let factory = test_factory(repo.clone());
AltXRemover {
items,
repo,
approvals: Arc::new(Approvals::default()),
render_tx: Arc::new(OnceLock::new()),
stashed_warnings: Arc::clone(&factory.stashed_warnings),
shortcut_table: Arc::clone(&factory.shortcut_table),
layout_slot: Arc::clone(&factory.layout_slot),
header_flash: Arc::clone(&factory.header_flash),
}
}
/// Poll `git branch --list <branch>` until it succeeds and the branch
/// reaches the expected presence, tolerating the transient `exit 128` a
/// concurrent background removal can trigger.
///
/// `apply` renames the worktree into the trash (so its path vanishes) and
/// then runs `git worktree prune`, which deletes the `.git/worktrees/<id>`
/// admin dir — all on a background thread. `git branch --list` enumerates
/// worktrees to mark checked-out branches, so a query that races the
/// in-flight prune can read a half-deleted admin dir and fail with
/// `exit 128`. A test that `.unwrap()`s such a query flakes; retry until the
/// prune settles and the branch has reached its steady state.
fn await_branch_presence(
repo: &worktrunk::git::Repository,
branch: &str,
expect_present: bool,
) {
worktrunk::testing::wait_for(
&format!(
"branch `{branch}` to become {}",
if expect_present {
"retained"
} else {
"deleted"
}
),
|| {
// A transient prune race surfaces as `Err`, not a successful
// empty read, so only a successful query is authoritative.
repo.run_command(&["branch", "--list", branch])
.map(|list| list.is_empty() != expect_present)
.unwrap_or(false)
},
);
}
/// Two detached worktrees both render the branch label `(detached)`, but
/// each row's `output()` token carries its unique path. alt-x on the
/// second row must remove exactly that worktree — not the first detached
/// one a branch-name match would resolve to — and drop only its row.
#[test]
fn test_apply_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 remover = test_remover(Arc::clone(&items), repo.clone());
// alt-x's callback hands `apply` the selected row's `output()` token.
remover.apply(second_output.clone());
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"
);
}
/// A refresh (`alt-r`) re-runs `factory.spawn()`. The factory carries the
/// `Repository` whose worktree-list cache was primed at picker startup and
/// is never invalidated, so after a worktree disappears `spawn` must rebuild
/// a fresh `Repository` rather than re-probe that stale cache — otherwise the
/// refresh streams a row for the gone worktree and collect's per-worktree git
/// ops fail against its deleted branch ("fatal: Needed a single revision").
#[test]
fn test_spawn_reenumerates_worktrees_after_removal() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
// The repo the factory carries; its cache is primed below, as the
// picker prelude primes it at startup.
let factory_repo = worktrunk::git::Repository::at(test.path()).unwrap();
let wt_dir = tempfile::tempdir().unwrap();
let doomed_path = wt_dir.path().join("doomed");
factory_repo
.run_command(&[
"worktree",
"add",
"-b",
"doomed",
doomed_path.to_str().unwrap(),
])
.unwrap();
let primed_has_doomed = factory_repo
.list_worktrees()
.unwrap()
.iter()
.any(|wt| wt.branch.as_deref() == Some("doomed"));
assert!(primed_has_doomed, "cache primed while doomed still present");
// Remove the worktree through a separate fresh `Repository`, exactly as
// the picker's background removal does. `factory_repo`'s cache is now
// stale: it still lists `doomed`.
let removal_repo = worktrunk::git::Repository::at(test.path()).unwrap();
removal_repo
.run_command(&[
"worktree",
"remove",
"--force",
doomed_path.to_str().unwrap(),
])
.unwrap();
removal_repo
.run_command(&["branch", "-D", "doomed"])
.unwrap();
let factory = test_factory(factory_repo);
// `true` models the refresh path (`alt-r`), which rebuilds a fresh repo;
// the initial spawn (`false`) deliberately reuses the startup inventory.
let super::SpawnedPipeline {
rx,
handler,
collect_handle,
..
} = factory.spawn(true).unwrap();
// Drop the returned handler's sender, then wait for the collect thread
// to finish (dropping its handler clone); the lone senders gone, `rx`
// hits EOF. The unbounded channel buffered every streamed row.
drop(handler);
collect_handle.join().unwrap();
let outputs: Vec<String> = std::iter::from_fn(|| rx.recv().ok())
.flatten()
.map(|item| item.output().into_owned())
.collect();
assert!(
!outputs.is_empty(),
"the surviving worktree should still stream a row"
);
assert!(
!outputs.iter().any(|out| out.contains("doomed")),
"refresh must not stream the removed worktree: {outputs:?}"
);
}
/// A refresh (`alt-r`, `spawn(true)`) drops the warm in-memory preview cache
/// so each rebuilt row recomputes against the fresh repo; the initial spawn
/// (`spawn(false)`) keeps it warm. The probe entry is keyed under a branch
/// with no row, so the background precompute never re-fills it — the entry's
/// fate is the clear alone, not a race with recompute.
#[test]
fn test_refresh_clears_preview_cache_initial_spawn_keeps_it() {
use super::preview::PreviewMode;
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let factory = test_factory(repo);
let ghost = ("ghost-branch".to_string(), PreviewMode::WorkingTree);
// Initial spawn (`false`) preserves warm previews.
factory
.preview_cache
.insert(ghost.clone(), "warm".to_string());
let super::SpawnedPipeline {
handler,
collect_handle,
..
} = factory.spawn(false).unwrap();
drop(handler);
collect_handle.join().unwrap();
assert!(
factory.preview_cache.contains_key(&ghost),
"initial spawn must keep the preview cache warm"
);
// Refresh (`true`) drops them.
let super::SpawnedPipeline {
handler,
collect_handle,
..
} = factory.spawn(true).unwrap();
drop(handler);
collect_handle.join().unwrap();
assert!(
!factory.preview_cache.contains_key(&ghost),
"refresh must clear the in-memory preview cache"
);
}
/// alt-x with nothing selectable under the cursor hands `apply` an empty token;
/// `apply` must treat it as a no-op and leave the list intact.
#[test]
fn test_apply_empty_token_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 remover = test_remover(Arc::clone(&items), repo);
remover.apply(String::new());
assert_eq!(
items.lock().unwrap().len(),
1,
"empty selection must not remove anything"
);
}
/// alt-x on a target that fails validation (a branch with no worktree and no
/// local ref) takes `apply`'s error arm: it logs and leaves the list intact —
/// no drop, no background work.
#[test]
fn test_apply_leaves_list_intact_when_prepare_fails() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let item = branch_only_picker_item("real-row");
let token = item.output().to_string();
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
let remover = test_remover(Arc::clone(&items), repo);
// `no-such-branch` parses as a branch target but has no worktree and no
// local ref, so `prepare_removal` errors before anything is dropped.
remover.apply("no-such-branch".to_string());
let outputs: Vec<String> = items
.lock()
.unwrap()
.iter()
.map(|item| item.output().into_owned())
.collect();
assert_eq!(
outputs,
vec![token],
"a target that fails validation leaves the row untouched"
);
}
/// `restore_failed_removal` puts a dropped row back at its original slot and
/// stashes a `worktree kept` warning — the correction path that keeps the
/// alt-x list from showing a removal that didn't happen.
#[test]
fn test_restore_failed_removal_reinserts_row_and_stashes_warning() {
// The list as it stands after `dropped-b` (originally shared_items
// index 2) was optimistically dropped: a header at 0, two surviving
// data rows.
let items: Arc<Mutex<Vec<Arc<dyn SkimItem>>>> = Arc::new(Mutex::new(vec![
branch_only_picker_item("header"),
branch_only_picker_item("keep-a"),
branch_only_picker_item("keep-c"),
]));
// A live sender so the restore queues its resync action rather
// than the early return.
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
Arc::new(OnceLock::new());
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
render_tx.set(tx).unwrap();
let stashed = Arc::new(Mutex::new(Vec::new()));
let header_flash = Arc::new(super::items::HeaderFlash::default());
super::restore_failed_removal(
&items,
&header_flash,
&render_tx,
&stashed,
super::DroppedRow {
item: branch_only_picker_item("dropped-b"),
pos: 2,
label: "dropped-b".to_string(),
noun: "worktree",
},
);
let outputs: Vec<String> = items
.lock()
.unwrap()
.iter()
.map(|item| item.output().into_owned())
.collect();
assert_eq!(
outputs,
vec!["header", "keep-a", "dropped-b", "keep-c"],
"row restored at its original slot"
);
let warnings = stashed.lock().unwrap();
assert_eq!(warnings.len(), 1, "one warning stashed");
assert!(
warnings[0].contains("dropped-b") && warnings[0].contains("Kept"),
"warning names the kept worktree: {}",
warnings[0]
);
// The same `could not remove` reason flashes in the header, styled as a
// warning (▲) — a genuine failure, not the keep paths' by-design info (○).
let flash = header_flash.current();
assert!(
flash.as_deref().is_some_and(|f| {
f.contains('▲') && f.contains("dropped-b") && f.contains("could not remove")
}),
"the failed removal flashes the reason in the header: {flash:?}"
);
// The flash queues a repaint first, then the restore re-shows the row by
// queuing a pool-resync Custom action.
assert!(
matches!(rx.try_recv(), Ok(skim::prelude::Event::Render)),
"the flash queues a repaint"
);
assert!(
matches!(rx.try_recv(), Ok(skim::prelude::Event::Action(_))),
"restore queues a resync action when the sender is live"
);
}
/// Restoring a row that's already back is a no-op — no duplicate, no extra
/// warning. Guards rapid repeated alt-x racing on the same row.
#[test]
fn test_restore_failed_removal_skips_when_already_present() {
let row = branch_only_picker_item("present");
let items = Arc::new(Mutex::new(vec![Arc::clone(&row)]));
let render_tx = Arc::new(OnceLock::new());
let stashed = Arc::new(Mutex::new(Vec::new()));
let header_flash = Arc::new(super::items::HeaderFlash::default());
super::restore_failed_removal(
&items,
&header_flash,
&render_tx,
&stashed,
super::DroppedRow {
item: row,
pos: 0,
label: "present".to_string(),
noun: "worktree",
},
);
assert_eq!(items.lock().unwrap().len(), 1, "no duplicate inserted");
assert!(
stashed.lock().unwrap().is_empty(),
"no warning when there's nothing to restore"
);
assert!(
header_flash.current().is_none(),
"the already-present early return skips the flash too"
);
}
/// `revert_morph` undoes an optimistic morph after the worktree removal failed:
/// it restores the row's pre-morph display, clears the `morphed` flag, moves the
/// shortcut entry back to the worktree token, stashes a `kept … could not remove`
/// warning, and flashes the same reason in the header. The in-place-morph mirror
/// of `restore_failed_removal`.
#[test]
fn test_revert_morph_restores_row_and_flashes() {
let rendered = Arc::new(Mutex::new("/ feature".to_string()));
let morphed = Arc::new(std::sync::atomic::AtomicBool::new(true));
let local_content = Arc::new(Mutex::new(LocalContent::default()));
// A shortcut entry keyed under the branch token, as the morph left it, so the
// revert can move it back to the worktree token.
let mut table_map = std::collections::HashMap::new();
table_map.insert(
"feature".to_string(),
super::items::RowShortcutData {
branch: Some("feature".to_string()),
url: super::items::RowUrl::Static(None),
morph: None,
},
);
let shortcut_table = Arc::new(Mutex::new(table_map));
let revert = super::MorphRevert {
rendered: Arc::clone(&rendered),
original_rendered: "+ feature".to_string(),
morphed: Arc::clone(&morphed),
local_content: Arc::clone(&local_content),
original_local: LocalContent::default(),
shortcut_table: Arc::clone(&shortcut_table),
branch_token: "feature".to_string(),
worktree_token: "worktree-path:/tmp/wt-feature".to_string(),
};
// A live sender so `flash_header` sets the flash rather than early-returning.
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
Arc::new(OnceLock::new());
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
render_tx.set(tx).unwrap();
let header_flash = Arc::new(super::items::HeaderFlash::default());
let stashed = Arc::new(Mutex::new(Vec::new()));
super::revert_morph(revert, &header_flash, &stashed, &render_tx);
// The row un-morphs in place: pre-morph line restored, flag cleared.
assert_eq!(
*rendered.lock().unwrap(),
"+ feature",
"the pre-morph display line is restored"
);
assert!(
!morphed.load(std::sync::atomic::Ordering::Relaxed),
"the morphed flag is cleared"
);
// The shortcut entry moves back from the branch token to the worktree token.
{
let table = shortcut_table.lock().unwrap();
assert!(
table.contains_key("worktree-path:/tmp/wt-feature")
&& !table.contains_key("feature"),
"the shortcut entry is re-keyed to the worktree token"
);
}
// The `could not remove` reason is stashed (drains to stderr on exit)...
{
let warnings = stashed.lock().unwrap();
assert_eq!(warnings.len(), 1, "one warning stashed");
assert!(
warnings[0].contains("feature") && warnings[0].contains("could not remove"),
"warning names the kept worktree: {}",
warnings[0]
);
}
// ...and flashes in the header now, styled as a warning (▲), not the keep
// paths' by-design info (○).
let flash = header_flash.current();
assert!(
flash.as_deref().is_some_and(|f| {
f.contains('▲') && f.contains("feature") && f.contains("could not remove")
}),
"the revert flashes the reason in the header: {flash:?}"
);
// `flash_header`'s repaint (which also re-shows the reverted row) is queued.
assert!(
matches!(rx.try_recv(), Ok(skim::prelude::Event::Render)),
"the revert queues a repaint"
);
}
/// `removal_failure_subject` prefers the branch name (falling back to the
/// worktree path for a detached worktree) and pairs it with the right noun:
/// `worktree` for a worktree removal, `branch` for a branch-only deletion.
#[test]
fn test_removal_failure_subject() {
let branched = RemoveResult::RemovedWorktree {
main_path: std::path::PathBuf::from("/tmp/main"),
worktree_path: std::path::PathBuf::from("/tmp/wt-feature"),
changed_directory: false,
branch_name: Some("feature".to_string()),
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".to_string()),
force_worktree: false,
removed_commit: None,
};
assert_eq!(
super::removal_failure_subject(&branched),
("feature".to_string(), "worktree")
);
let detached = RemoveResult::RemovedWorktree {
main_path: std::path::PathBuf::from("/tmp/main"),
worktree_path: std::path::PathBuf::from("/tmp/wt-detached"),
changed_directory: false,
branch_name: None,
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".to_string()),
force_worktree: false,
removed_commit: None,
};
assert_eq!(
super::removal_failure_subject(&detached),
("/tmp/wt-detached".to_string(), "worktree")
);
let branch_only = RemoveResult::BranchOnly {
branch_name: "orphan".to_string(),
deletion_mode: BranchDeletionMode::SafeDelete,
pruned: false,
target_branch: None,
integration_reason: None,
};
assert_eq!(
super::removal_failure_subject(&branch_only),
("orphan".to_string(), "branch")
);
}
/// End-to-end through `apply`: `prepare_removal` passes (the worktree is
/// clean and removable), but the background `do_removal` fails on an
/// approved-yet-failing `pre-remove` hook. The row is dropped optimistically,
/// then restored when the removal fails — the worktree is preserved and the
/// list reflects that, instead of leaving a phantom-removed row.
#[test]
fn test_apply_restores_row_when_removal_fails() {
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();
// A `pre-remove` hook that always fails, in the project config the
// picker removal resolves against.
fs::create_dir_all(test.path().join(".config")).unwrap();
fs::write(
test.path().join(".config/wt.toml"),
"pre-remove = \"false\"\n",
)
.unwrap();
// Approve `false` so the hook is selected into the read-only plan and
// actually runs; an isolated approvals path keeps real config untouched.
let pid = repo.project_identifier().unwrap();
let approvals_dir = tempfile::tempdir().unwrap();
let approvals_path = approvals_dir.path().join("approvals.toml");
let mut approvals = Approvals::default();
approvals
.approve_command(pid, "false".to_string(), &approvals_path)
.unwrap();
// Build the row from the git-reported worktree path, not the raw temp
// path: on macOS `git worktree list` resolves the `/var`→`/private/var`
// symlink, and `prepare_removal`'s path lookup matches that resolved
// form.
let reported_path = repo
.list_worktrees()
.unwrap()
.iter()
.find(|wt| wt.branch.as_deref() == Some("feature"))
.map(|wt| wt.path.clone())
.expect("feature worktree is listed");
let item = branched_picker_item("feature", &reported_path);
let token = item.output().to_string();
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
let stashed: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let remover = AltXRemover {
items: Arc::clone(&items),
repo: repo.clone(),
approvals: Arc::new(approvals),
render_tx: Arc::new(OnceLock::new()),
stashed_warnings: Arc::clone(&stashed),
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
layout_slot: Arc::new(Mutex::new(None)),
header_flash: Arc::new(super::items::HeaderFlash::default()),
};
remover.apply(token.clone());
// The background removal fails on the approved-yet-failing hook, so
// `restore_failed_removal` runs: only that path stashes a warning, so
// poll on it as the synchronization point.
let deadline = Instant::now() + Duration::from_secs(5);
while stashed.lock().unwrap().is_empty() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
let warnings = stashed.lock().unwrap().clone();
assert!(
warnings.iter().any(|w| w.contains("feature")),
"a failed removal stashes a `kept` warning: {warnings:?}"
);
let outputs: Vec<String> = items
.lock()
.unwrap()
.iter()
.map(|item| item.output().into_owned())
.collect();
assert_eq!(
outputs,
vec![token],
"the row is restored after the removal fails"
);
assert!(
reported_path.exists(),
"the worktree is preserved when removal fails"
);
}
/// End-to-end through `apply`: alt-x on a worktree whose branch is unmerged
/// morphs the row to `/ branch` in place. The worktree is removed but the
/// branch is kept (`SafeDelete` won't delete unmerged work), and the row
/// never leaves its slot — its `morphed` flag flips, its `output()` becomes
/// the bare branch token, and its display line is rewritten (no longer the
/// `+ worktree` line). The morph is applied synchronously in `apply`; only
/// the git removal runs on the background thread.
#[test]
fn test_apply_morphs_unmerged_worktree_to_branch_row() {
use std::sync::atomic::Ordering;
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();
// Make `feature` unmerged: a commit on it that main doesn't have, so
// SafeDelete retains the branch when the worktree is removed.
fs::write(wt_path.join("new.txt"), "unmerged work").unwrap();
worktrunk::shell_exec::Cmd::new("git")
.args(["add", "."])
.current_dir(&wt_path)
.run()
.unwrap();
worktrunk::shell_exec::Cmd::new("git")
.args(["commit", "-m", "unmerged work"])
.current_dir(&wt_path)
.run()
.unwrap();
// Build the row from the git-reported path (macOS resolves the
// `/var`→`/private/var` symlink, which `prepare_removal`'s lookup matches).
let reported_path = repo
.list_worktrees()
.unwrap()
.iter()
.find(|wt| wt.branch.as_deref() == Some("feature"))
.map(|wt| wt.path.clone())
.expect("feature worktree is listed");
let items = Arc::new(Mutex::new(Vec::new()));
let remover = test_remover(Arc::clone(&items), repo.clone());
let (row, token, rendered, morphed) =
setup_morphable_row(&remover, "feature", &reported_path);
items.lock().unwrap().push(Arc::clone(&row));
let original_line = rendered.lock().unwrap().clone();
remover.apply(token.clone());
// The morph is synchronous, so it's already applied when `apply` returns:
// the row is now a branch row in place — flag flipped, token rebranded,
// line rewritten.
assert!(
morphed.load(Ordering::Relaxed),
"the kept-branch worktree row is morphed to a branch row"
);
assert_eq!(
row.output().as_ref(),
"feature",
"the morphed row's selection token is the bare branch name"
);
assert_ne!(
*rendered.lock().unwrap(),
original_line,
"the morphed row's display line is rewritten to the `/ branch` line"
);
// The worktree removal itself runs in the background.
let deadline = Instant::now() + Duration::from_secs(5);
while reported_path.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert!(!reported_path.exists(), "the worktree is removed");
// The unmerged branch is retained after its worktree is removed.
await_branch_presence(&repo, "feature", true);
// The removal succeeded, so the morph stands (no revert).
assert!(
morphed.load(Ordering::Relaxed),
"a successful removal leaves the row morphed"
);
}
/// A kept-branch worktree removal whose row carries no `MorphHandle` (or whose
/// layout hasn't landed) can't morph in place, so `morph_and_remove_in_background`
/// falls back to the drop path: `apply` reports `Dropped` and the row leaves the
/// list (the worktree still removes, the branch is still kept). Same setup as
/// `test_apply_morphs_…` but without `setup_morphable_row`, so the shortcut table
/// has no morph handle for the row.
#[test]
fn test_apply_drops_unmorphable_kept_branch_row() {
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();
// Make `feature` unmerged so SafeDelete keeps the branch (the morph premise).
fs::write(wt_path.join("new.txt"), "unmerged work").unwrap();
worktrunk::shell_exec::Cmd::new("git")
.args(["add", "."])
.current_dir(&wt_path)
.run()
.unwrap();
worktrunk::shell_exec::Cmd::new("git")
.args(["commit", "-m", "unmerged work"])
.current_dir(&wt_path)
.run()
.unwrap();
let reported_path = repo
.list_worktrees()
.unwrap()
.iter()
.find(|wt| wt.branch.as_deref() == Some("feature"))
.map(|wt| wt.path.clone())
.expect("feature worktree is listed");
let item = branched_picker_item("feature", &reported_path);
let token = item.output().to_string();
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
// `test_remover` registers no morph handle, so the kept-branch removal falls
// back to a drop.
let remover = test_remover(Arc::clone(&items), repo.clone());
assert!(
matches!(remover.apply(token), RemovalEffect::Dropped),
"an unmorphable kept-branch removal falls back to the drop path"
);
assert!(
items.lock().unwrap().is_empty(),
"the row drops when it can't morph"
);
// The worktree is removed in the background; the unmerged branch is kept.
let deadline = Instant::now() + Duration::from_secs(5);
while reported_path.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(20));
}
assert!(!reported_path.exists(), "the worktree is removed");
// The unmerged branch is retained.
await_branch_presence(&repo, "feature", true);
}
/// The negative of the above, end-to-end through `apply`: alt-x on a worktree
/// whose branch is *integrated* deletes both the worktree and the branch, so
/// there's no branch to keep — the row drops (it's removed from the list)
/// rather than morphing. `worktree_removal_keeps_branch` returns `None`, so the
/// drop path runs, not the morph. Guards against morphing (and resurrecting) a
/// row whose branch is actually gone.
#[test]
fn test_apply_drops_integrated_worktree_row() {
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");
// No extra commit → `feature` sits at main's commit (integrated), so
// SafeDelete deletes the branch along with the worktree.
repo.run_command(&[
"worktree",
"add",
"-b",
"feature",
wt_path.to_str().unwrap(),
])
.unwrap();
let reported_path = repo
.list_worktrees()
.unwrap()
.iter()
.find(|wt| wt.branch.as_deref() == Some("feature"))
.map(|wt| wt.path.clone())
.expect("feature worktree is listed");
let item = branched_picker_item("feature", &reported_path);
let token = item.output().to_string();
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
let remover = test_remover(Arc::clone(&items), repo.clone());
remover.apply(token);
// The drop is synchronous (the row is removed before the background git
// work), so the list is already empty when `apply` returns.
assert!(
items.lock().unwrap().is_empty(),
"the integrated worktree row drops instead of morphing"
);
// The background removal deletes both the worktree and the branch
// (nothing to keep).
await_branch_presence(&repo, "feature", false);
assert!(!reported_path.exists(), "the worktree is removed");
}
/// `worktree_removal_keeps_branch` predicts the morph: a `RemovedWorktree`
/// whose `SafeDelete` would retain the branch (unmerged) yields the branch
/// name; an integrated one (deletes the branch) and a force-delete both yield
/// `None`. Built from real refs so the prediction runs the same
/// `integration_reason` the actual delete does.
#[test]
fn test_worktree_removal_keeps_branch() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
repo.run_command(&["branch", "integrated"]).unwrap();
// `unmerged` carries a commit main lacks.
repo.run_command(&["checkout", "-b", "unmerged"]).unwrap();
fs::write(test.path().join("new.txt"), "work").unwrap();
repo.run_command(&["add", "."]).unwrap();
repo.run_command(&["commit", "-m", "work"]).unwrap();
repo.run_command(&["checkout", "main"]).unwrap();
let result = |branch: &str, mode| RemoveResult::RemovedWorktree {
main_path: test.path().to_path_buf(),
worktree_path: test.path().join("gone"),
changed_directory: false,
branch_name: Some(branch.to_string()),
deletion_mode: mode,
target_branch: Some("main".to_string()),
force_worktree: false,
removed_commit: None,
};
assert_eq!(
super::worktree_removal_keeps_branch(
&repo,
&result("unmerged", BranchDeletionMode::SafeDelete)
)
.as_deref(),
Some("unmerged"),
"an unmerged branch is kept, so the row morphs"
);
assert_eq!(
super::worktree_removal_keeps_branch(
&repo,
&result("integrated", BranchDeletionMode::SafeDelete)
),
None,
"an integrated branch is deleted, so the row drops"
);
assert_eq!(
super::worktree_removal_keeps_branch(
&repo,
&result("unmerged", BranchDeletionMode::ForceDelete)
),
None,
"force-delete removes even an unmerged branch, so the row drops"
);
}
/// `build_morph_branch_row` renders the `/ branch` line a morph swaps in: the
/// worktree row's model demoted to a local branch on the live layout — gutter
/// `/`, no path — and a `LocalContent` whose `working_tree` reads empty (no
/// worktree to diff), which dims the `working_tree` preview tab.
#[test]
fn test_build_morph_branch_row() {
use ansi_str::AnsiStr;
let mut worktree_item = ListItem::new_branch("abc123".to_string(), "feature".to_string());
worktree_item.kind = ItemKind::Worktree(Box::new(WorktreeData {
path: Path::new("/tmp/wt.feature").to_path_buf(),
..Default::default()
}));
let layout = crate::commands::list::layout::calculate_layout_with_width(
std::slice::from_ref(&worktree_item),
&crate::commands::list::columns::all_tasks(),
80,
Path::new("/test"),
None,
None,
crate::commands::list::layout::ColumnSelection {
custom: &[],
selected: None,
},
);
let (line, local) = super::build_morph_branch_row(&layout, &worktree_item, Some("main"));
let plain = line.ansi_strip();
assert!(
plain.trim_start().starts_with('/'),
"the morphed line leads with the local-branch gutter `/`: {plain:?}"
);
assert!(
plain.contains("feature"),
"the morphed line shows the branch name: {plain:?}"
);
assert!(
!plain.contains("/tmp/wt.feature"),
"the morphed branch row has no worktree path: {plain:?}"
);
assert_eq!(
local,
LocalContent::from_item(&{
let mut b = ListItem::new_branch("abc123".to_string(), "feature".to_string());
b.kind = ItemKind::Branch(BranchScope::Local);
b
}),
"the morphed row's diff signals are the branch's (working_tree empty)"
);
}
/// `removal_target_still_present` observes reality: a worktree dir or a branch
/// ref that's gone reads as removed; one still on disk / in the ref store reads
/// as present (the restore trigger).
#[test]
fn test_removal_target_still_present() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let worktree_result = |path: std::path::PathBuf| RemoveResult::RemovedWorktree {
main_path: test.path().to_path_buf(),
worktree_path: path,
changed_directory: false,
branch_name: Some("x".to_string()),
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".to_string()),
force_worktree: false,
removed_commit: None,
};
assert!(super::removal_target_still_present(
&repo,
&worktree_result(test.path().to_path_buf()) // still on disk
));
assert!(!super::removal_target_still_present(
&repo,
&worktree_result(test.path().join("does-not-exist"))
));
repo.run_command(&["branch", "live-branch"]).unwrap();
let present_branch = RemoveResult::BranchOnly {
branch_name: "live-branch".to_string(),
deletion_mode: BranchDeletionMode::SafeDelete,
pruned: false,
target_branch: None,
integration_reason: None,
};
assert!(super::removal_target_still_present(&repo, &present_branch));
let gone_branch = RemoveResult::BranchOnly {
branch_name: "no-such-branch".to_string(),
deletion_mode: BranchDeletionMode::SafeDelete,
pruned: false,
target_branch: None,
integration_reason: None,
};
assert!(!super::removal_target_still_present(&repo, &gone_branch));
}
/// `removal_will_remove_target` predicts removal from the prepared result
/// alone: a worktree always removes (it passed `ensure_clean`); a branch-only
/// row removes only when the branch is integrated or force-deleted, and never
/// under `Keep` — mirroring `delete_branch_if_safe` so the up-front prediction
/// can't drift from what `do_removal` does.
#[test]
fn test_removal_will_remove_target() {
use worktrunk::git::IntegrationReason;
let branch_only = |mode: BranchDeletionMode, integration: Option<IntegrationReason>| {
RemoveResult::BranchOnly {
branch_name: "b".to_string(),
deletion_mode: mode,
pruned: false,
target_branch: None,
integration_reason: integration,
}
};
let worktree = RemoveResult::RemovedWorktree {
main_path: std::path::PathBuf::from("/repo"),
worktree_path: std::path::PathBuf::from("/repo.feature"),
changed_directory: false,
branch_name: Some("feature".to_string()),
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".to_string()),
force_worktree: false,
removed_commit: None,
};
assert!(
super::removal_will_remove_target(&worktree),
"a worktree removal always drops the row"
);
assert!(
super::removal_will_remove_target(&branch_only(
BranchDeletionMode::SafeDelete,
Some(IntegrationReason::SameCommit)
)),
"an integrated branch is safe-deleted"
);
assert!(
!super::removal_will_remove_target(&branch_only(BranchDeletionMode::SafeDelete, None)),
"an unmerged branch is kept, so the row stays"
);
assert!(
super::removal_will_remove_target(&branch_only(BranchDeletionMode::ForceDelete, None)),
"force-delete removes even an unmerged branch"
);
assert!(
!super::removal_will_remove_target(&branch_only(
BranchDeletionMode::Keep,
Some(IntegrationReason::SameCommit)
)),
"Keep never deletes, even when integrated"
);
}
/// `removal_targets_current_worktree` fires only for a `RemovedWorktree` whose
/// `changed_directory` flag is set (the worktree the picker was launched from);
/// a non-current worktree and any `BranchOnly` row read as `false`.
#[test]
fn test_removal_targets_current_worktree() {
let path = std::path::PathBuf::from("/repo.feature");
let worktree = |changed_directory| RemoveResult::RemovedWorktree {
main_path: std::path::PathBuf::from("/repo"),
worktree_path: path.clone(),
changed_directory,
branch_name: Some("feature".to_string()),
deletion_mode: BranchDeletionMode::SafeDelete,
target_branch: Some("main".to_string()),
force_worktree: false,
removed_commit: None,
};
assert!(
super::removal_targets_current_worktree(&worktree(true)),
"removing the worktree the picker was launched from"
);
assert!(
!super::removal_targets_current_worktree(&worktree(false)),
"removing some other worktree"
);
assert!(
!super::removal_targets_current_worktree(&RemoveResult::BranchOnly {
branch_name: "feature".to_string(),
deletion_mode: BranchDeletionMode::SafeDelete,
pruned: false,
target_branch: None,
integration_reason: None,
}),
"a branch-only row has no worktree to be standing in"
);
}
/// `keep_current_worktree_row` keeps the row in place and stashes the
/// can't-remove-current-worktree info + switch-away hint — alt-x on the current
/// worktree never removes it and never spawns a background removal.
#[test]
fn test_keep_current_worktree_row() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let item = branched_picker_item("current", &test.path().join("current"));
let token = item.output().to_string();
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
let remover = test_remover(Arc::clone(&items), repo.clone());
remover.keep_current_worktree_row();
assert_eq!(
items
.lock()
.unwrap()
.iter()
.map(|item| item.output().into_owned())
.collect::<Vec<_>>(),
vec![token.clone()],
"the current worktree row is kept, not removed"
);
let warnings = remover.stashed_warnings.lock().unwrap().clone();
assert!(
warnings.iter().any(|w| w.contains("current worktree")),
"stashes the can't-remove-current-worktree info: {warnings:?}"
);
assert!(
warnings
.iter()
.any(|w| w.contains("Switch to another worktree")),
"stashes the switch-away hint: {warnings:?}"
);
// A second alt-x on the same kept row dedups — the stash doesn't grow.
remover.keep_current_worktree_row();
assert_eq!(
remover.stashed_warnings.lock().unwrap().len(),
warnings.len(),
"repeated alt-x on the current worktree stashes the hint only once"
);
}
/// The header flash a declined alt-x sets self-clears after the beat: the timer
/// thread `flash_header` spawns runs `clear_if_current` + repaints once
/// `HEADER_FLASH_DURATION` elapses. Also pins the keep-path symbol — a by-design
/// decline flashes as info (○), not a warning. Polls for the clear (driving the
/// detached timer to completion) rather than racing a fixed sleep.
#[test]
fn test_header_flash_set_then_self_clears() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
let item = branched_picker_item("current", &test.path().join("current"));
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
// A live sender so `flash_header` sets the flash and its timer can repaint.
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
Arc::new(OnceLock::new());
let (tx, mut rx) = tokio::sync::mpsc::channel(8);
render_tx.set(tx).unwrap();
let header_flash = Arc::new(super::items::HeaderFlash::default());
let remover = AltXRemover {
items,
repo,
approvals: Arc::new(Approvals::default()),
render_tx: Arc::clone(&render_tx),
stashed_warnings: Arc::new(Mutex::new(Vec::new())),
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
layout_slot: Arc::new(Mutex::new(None)),
header_flash: Arc::clone(&header_flash),
};
remover.keep_current_worktree_row();
// The flash is up, styled as info (○ — a by-design decline, not a warning),
// and `flash_header` queued a repaint.
let flash = header_flash.current();
assert!(
flash
.as_deref()
.is_some_and(|f| f.contains('○') && f.contains("current worktree")),
"the decline flashes as info in the header: {flash:?}"
);
assert!(rx.try_recv().is_ok(), "flash_header queues a repaint");
// The timer clears it after the beat. Poll (don't fixed-sleep) so the test
// tracks the detached thread's completion causally; the deadline is a safety
// net well above `HEADER_FLASH_DURATION`.
let deadline = Instant::now() + Duration::from_secs(10);
while header_flash.current().is_some() {
assert!(Instant::now() < deadline, "flash never auto-cleared");
std::thread::sleep(Duration::from_millis(25));
}
// The auto-clear queues its own repaint (sent right after the clear); poll
// briefly so the assertion doesn't race the timer thread's final send.
let repaint_deadline = Instant::now() + Duration::from_secs(2);
let mut saw_repaint = false;
while Instant::now() < repaint_deadline {
if rx.try_recv().is_ok() {
saw_repaint = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(saw_repaint, "the auto-clear queues a repaint");
}
/// alt-x on an unremovable target surfaces the same diagnostic `wt remove`
/// prints rather than swallowing it: `prepare_removal` errors (here the main
/// worktree can't be removed), so the dispatch's `Err` arm stashes the rendered
/// reason and keeps the row in place — no silent dead keypress.
#[test]
fn test_apply_surfaces_unremovable_diagnostic() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
// The repo root is the main worktree — `prepare_worktree_removal` rejects it.
let item = branched_picker_item("main", test.path());
let token = item.output().to_string();
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
// A live sender so the Err arm's `flash_header` sets the header flash.
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
Arc::new(OnceLock::new());
let (tx, _rx) = tokio::sync::mpsc::channel(8);
render_tx.set(tx).unwrap();
let header_flash = Arc::new(super::items::HeaderFlash::default());
let remover = AltXRemover {
items: Arc::clone(&items),
repo: repo.clone(),
approvals: Arc::new(Approvals::default()),
render_tx: Arc::clone(&render_tx),
stashed_warnings: Arc::new(Mutex::new(Vec::new())),
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
layout_slot: Arc::new(Mutex::new(None)),
header_flash: Arc::clone(&header_flash),
};
remover.apply(token.clone());
// Nothing was removed, so the row stays...
assert_eq!(
items
.lock()
.unwrap()
.iter()
.map(|item| item.output().into_owned())
.collect::<Vec<_>>(),
vec![token],
"an unremovable row is never dropped"
);
// ...and the reason is surfaced, not swallowed.
let warnings = remover.stashed_warnings.lock().unwrap().clone();
assert!(
warnings
.iter()
.any(|w| w.contains("main worktree cannot be removed")),
"the unremovable diagnostic is stashed for the user: {warnings:?}"
);
// The terse headline also flashes in the header at alt-x time.
let flash = header_flash.current();
assert!(
flash
.as_deref()
.is_some_and(|f| f.contains("main worktree")),
"the unremovable reason flashes in the header: {flash:?}"
);
}
/// alt-x on an unmerged branch-only row never drops it (no flicker): an
/// unmerged branch with no worktree resolves to `BranchOnly` with no
/// integration reason, so `removal_will_remove_target` predicts `SafeDelete`
/// keeps it. Decided synchronously in `invoke` — no background removal — so the
/// row stays and a one-time `kept … branch` hint is stashed. Driven end-to-end.
#[test]
fn test_apply_keeps_unmerged_branch_only_row() {
let test = worktrunk::testing::TestRepo::with_initial_commit();
let repo = worktrunk::git::Repository::at(test.path()).unwrap();
// An unmerged branch (a commit not on main) with no worktree — SafeDelete
// keeps it.
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 item = branch_only_picker_item("unmerged");
let token = item.output().to_string();
let items = Arc::new(Mutex::new(vec![Arc::clone(&item)]));
let stashed: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
// A live sender so `flash_header` sets the header flash rather than
// taking its no-`render_tx` early return.
let render_tx: Arc<OnceLock<tokio::sync::mpsc::Sender<skim::prelude::Event>>> =
Arc::new(OnceLock::new());
let (tx, _rx) = tokio::sync::mpsc::channel(8);
render_tx.set(tx).unwrap();
let header_flash = Arc::new(super::items::HeaderFlash::default());
let remover = AltXRemover {
items: Arc::clone(&items),
repo: repo.clone(),
approvals: Arc::new(Approvals::default()),
render_tx: Arc::clone(&render_tx),
stashed_warnings: Arc::clone(&stashed),
shortcut_table: Arc::new(Mutex::new(std::collections::HashMap::new())),
layout_slot: Arc::new(Mutex::new(None)),
header_flash: Arc::clone(&header_flash),
};
remover.apply(token.clone());
// The keep path is synchronous (no background thread), so by the time
// `apply` returns the row is still present and the hint is stashed.
let outputs: Vec<String> = items
.lock()
.unwrap()
.iter()
.map(|item| item.output().into_owned())
.collect();
assert_eq!(
outputs,
vec![token.clone()],
"the unmerged branch-only row is never dropped"
);
let warnings = stashed.lock().unwrap().clone();
assert!(
warnings
.iter()
.any(|w| w.contains("unmerged") && w.contains("retained")),
"a kept unmerged branch stashes a `retained` info line: {warnings:?}"
);
assert!(
warnings.iter().any(|w| w.contains("wt remove -D unmerged")),
"a kept unmerged branch stashes the actionable `-D` hint: {warnings:?}"
);
// The *why* also flashes in the header immediately (the in-picker echo of
// the stash), not only on exit.
let flash = header_flash.current();
assert!(
flash
.as_deref()
.is_some_and(|f| f.contains("Kept") && f.contains("unmerged")),
"the keep path flashes the reason in the header: {flash:?}"
);
// A second alt-x on the same kept row dedups — the stash doesn't grow.
remover.apply(token);
assert_eq!(
stashed.lock().unwrap().clone(),
warnings,
"repeated alt-x on the same kept row stashes the hint only once"
);
let branch_list = repo.run_command(&["branch", "--list", "unmerged"]).unwrap();
assert!(!branch_list.is_empty(), "the unmerged branch is preserved");
}
// Note: skim's `as_any().downcast_ref::<PickerRow>()` can fail at
// runtime due to a TypeId mismatch between skim's reader thread and the main
// compilation unit. The invoke() code path uses output() matching instead.
// Full TUI tests require interactive skim — verified via tmux-cli during
// development.
}