Files
max-sixty__worktrunk/tests/integration_tests/switch_picker.rs
Maximilian Roos 6d09125b7b test: converge suite on semantic boundaries (#3663)
This follows the first test-simplification tranche by converging the
remaining suite around distinct semantic and pragmatic contracts rather
than raw case count. The branch removes false-confidence tests, invalid
setup variants, repetitive snapshots, and expensive PTY overlap while
strengthening the retained route, precondition, and interaction proofs.

## What changed

- Replace obsolete CI-status integration mocks and blank snapshots with
direct provider semantics, mixed-priority cases, and strict
GitHub/GitLab route assertions.
- Remove free-riding merge, push, remove, list, security, config, and
switch cases whose setup never reached the named behavior; consolidate
repetitive direct cases into labeled tables.
- Reduce the switch picker from 42 PTYs to 19 distinct terminal
contracts, using causal release gates for asynchronous loading and
repaint behavior.
- Add a cached main-only picker fixture, eliminating 138 unnecessary Git
subprocesses across the retained PTYs, and integrate it with main's
generated hermetic standard fixture.
- Tighten test guidance around proving setup preconditions and mock
invocation routes, and correct the comments-tab help text and generated
mirrors.

## Reviewer map

- `tests/integration_tests/ci_status.rs` and
`src/commands/list/ci_status/`: provider semantics and route coverage.
- `tests/integration_tests/switch_picker.rs`, `src/commands/picker/`,
and `src/testing/`: retained PTY contracts, causal mocks, and fixture
design.
- `tests/integration_tests/config_show.rs`, `src/config/deprecation.rs`,
`src/config/expansion.rs`, and worktree type/resolve tests:
direct-boundary consolidation.
- `tests/CLAUDE.md`: the testing rules extracted from the
false-confidence cases found during the survey.

The measured loop removed 98 tests, 65 snapshots, and 23 picker PTYs.
Controlled warm Nextest execution improved from a 79.593-second mean to
72.574 seconds (8.8%), while comparable production-line coverage moved
from 97.32% to 97.23%. The tracked PR diff is a net deletion of more
than 5,700 lines.

## Validation

- `cargo run -- hook pre-merge --yes` after syncing current `main`:
4,468 passed, one configured skip; docs, doctests, clippy, formatting,
policy checks, and snapshots green.
- `task coverage` on the completed change before the base sync: 4,465
passed, one configured skip; 97.23% comparable production-line coverage.
- Three independent final audits found no remaining lost beliefs,
fixture hazards, or safe PTY consolidations.

> _This was written by Claude Code on behalf of max_.
2026-07-30 00:01:32 -07:00

2221 lines
90 KiB
Rust

#![cfg(feature = "shell-integration-tests")]
//! TUI snapshot tests for `wt switch` interactive picker
//!
//! These tests use PTY execution combined with vt100 terminal emulation to capture
//! what the user actually sees on screen, enabling meaningful snapshot testing of
//! the skim-based TUI interface. They run on every platform — `portable_pty` uses a
//! ConPTY on Windows (see `tests/common/pty.rs`), and capturing the vt100-emulated
//! grid (not raw escape sequences) keeps the snapshots backend-agnostic.
//!
//! ## Capture-Before-Abort Pattern
//!
//! Abort tests snapshot the screen BEFORE sending Escape, not after. Skim's teardown
//! is asynchronous — sending Escape races with rendering, producing non-deterministic
//! output (variable border painting, incomplete rows). By capturing the stable pre-abort
//! state, we eliminate this entire class of flakiness. After capture, Escape is sent and
//! only the exit code is checked.
//!
//! ## Timing Strategy
//!
//! Instead of fixed delays (which are either too short on slow CI or wastefully
//! long on fast machines), we poll for screen stabilization:
//!
//! - **Long timeouts** (30s) ensure reliability on slow CI
//! - **Fast polling** (10ms) means tests complete quickly when things work
//! - **Content-based readiness** detects when skim has rendered ("> " prompt)
//! - **Stabilization detection** waits for screen to stop changing
//! - **Content expectations** wait for async preview content to load (e.g., "diff --git")
use crate::common::mock_commands::{MockConfig, MockResponse, mock_calls};
use crate::common::{TEST_EPOCH, TestRepo, wt_bin};
use insta::assert_snapshot;
use portable_pty::CommandBuilder;
use rstest::rstest;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Terminal dimensions for TUI tests
const TERM_ROWS: u16 = 30;
const TERM_COLS: u16 = 120;
/// Maximum time to wait for skim to become ready (show "> " prompt).
/// Long timeout ensures reliability on slow CI.
const READY_TIMEOUT: Duration = Duration::from_secs(30);
/// Maximum time to wait for screen to stabilize after input.
/// Long timeout ensures reliability on slow CI where skim's async item loading
/// and preview commands can be very slow under heavy load. Fast polling (10ms)
/// means tests complete quickly when things work — the long timeout only matters
/// in worst-case scenarios.
const STABILIZE_TIMEOUT: Duration = Duration::from_secs(30);
/// Maximum time to wait for the picker child to exit after the terminating
/// keystroke (Enter to switch, Escape to abort). A clean switch or abort exits in
/// well under a second, but under heavy CI parallelism the final git work (or
/// skim's Windows terminal teardown) can lag. Generous like
/// `READY_TIMEOUT`/`STABILIZE_TIMEOUT` for the same reason — fast polling means
/// the common case still returns at once. Reaching it is a hang, and
/// [`wait_for_exit`] panics rather than reporting an exit code for it.
const CHILD_EXIT_TIMEOUT: Duration = Duration::from_secs(30);
/// How long the PTY must stay silent after the child exits before its final
/// frame counts as complete. Long enough that a loaded runner's last flush lands
/// in the parser; short enough to add no meaningful cost per test.
const POST_EXIT_QUIET: Duration = Duration::from_millis(250);
/// Ceiling on the post-exit drain, in case something else on the PTY keeps
/// writing (a detached background hook shares the terminal).
const POST_EXIT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
/// How long screen must be unchanged to consider it "stable".
/// Must be long enough for preview content to load (preview commands run async).
/// 500ms balances reliability (allows preview to complete) with speed.
/// Panel switches trigger async git commands that may take time.
const STABLE_DURATION: Duration = Duration::from_millis(500);
/// Polling interval when waiting for output.
/// Fast polling ensures tests complete quickly when ready.
const POLL_INTERVAL: Duration = Duration::from_millis(10);
/// How often a cursor-arrow wait re-issues its (idempotent) arrow while the `>`
/// pointer has not yet settled on the target row. Long enough not to thrash the
/// picker; short enough to retry many times within [`STABILIZE_TIMEOUT`] after
/// an async item-list refresh resets the cursor to the top.
const CURSOR_REISSUE_INTERVAL: Duration = Duration::from_secs(1);
/// Picker tests shape their own linked-worktree topology. Starting from the
/// cached main-only variant avoids constructing and immediately removing the
/// standard fixture's three unrelated linked worktrees in every PTY test.
#[rstest::fixture]
fn repo() -> TestRepo {
TestRepo::standard_main_only()
}
/// Columns that split the list and preview panels in the 120-col test terminal.
/// skim 4.x draws the │ separator at col 59, with the list to its left (cols
/// 0..59) and the preview interior to its right (cols 60..120). Slicing around
/// col 59 drops the separator from both panels — the border glyph renders
/// inconsistently across platforms.
const LIST_WIDTH: u16 = 59;
const PREVIEW_START_COL: u16 = 60;
/// Full screen content as rows of text.
///
/// Trailing whitespace is trimmed from each row because `vt100::rows()` pads
/// rows to the full column width with spaces. This padding is terminal buffer
/// fill, not meaningful content, and varies across platforms. Trailing empty
/// lines are also removed (unwritten terminal rows become empty after trim).
fn screen_text(parser: &vt100::Parser) -> String {
parser
.screen()
.rows(0, TERM_COLS)
.map(|row| row.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_string()
}
/// Result of executing a command in a PTY, holding the parsed terminal state.
struct PtyResult {
parser: vt100::Parser,
exit_code: i32,
}
impl PtyResult {
/// Full screen content as rows of text — see [`screen_text`].
fn screen(&self) -> String {
screen_text(&self.parser)
}
/// List and preview panel content, split at the skim border column.
/// Avoids the │ border character that causes cross-platform rendering issues.
fn panels(&self) -> (String, String) {
let screen = self.parser.screen();
let list = list_pane_text(screen);
let preview = screen
.rows(PREVIEW_START_COL, TERM_COLS - PREVIEW_START_COL)
.map(|row| row.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_string();
(list, preview)
}
}
/// The list pane: screen columns left of the skim border, trailing whitespace
/// trimmed (vt100 pads rows to the full width; that padding is buffer fill, not
/// content, and varies across platforms).
fn list_pane_text(screen: &vt100::Screen) -> String {
screen
.rows(0, LIST_WIDTH)
.map(|row| row.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_string()
}
/// The preview pane: screen columns right of the skim border (the panel interior),
/// trailing whitespace trimmed. Mirrors the split [`PtyResult::panels`] uses.
fn preview_pane_text(screen: &vt100::Screen) -> String {
screen
.rows(PREVIEW_START_COL, TERM_COLS - PREVIEW_START_COL)
.map(|row| row.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
.trim_end()
.to_string()
}
/// Assert that exit code is valid for skim abort (0, 1, or 130)
fn assert_valid_abort_exit_code(exit_code: i32) {
// Skim exits with:
// - 0: successful selection or no items
// - 1: normal abort (escape key)
// - 130: abort via SIGINT (128 + signal 2)
assert!(
exit_code == 0 || exit_code == 1 || exit_code == 130,
"Unexpected exit code: {} (expected 0, 1, or 130 for skim abort)",
exit_code
);
}
/// Assert the exit code of a *successful* picker-create (alt-c).
///
/// A create that succeeds exits 0 — `run_picker` returns `Ok(())` after the
/// `SwitchPipeline` runs, and the "cannot cd — shell integration not installed"
/// line is a warning, not an error. Callers still prove the create succeeded the
/// deterministic way: the new branch and worktree exist in git afterward.
///
/// On Windows the picker process has been observed to *self-exit* with code 1
/// after a fully-correct create under the advisory `affected tests (windows)`
/// leg's load, while the required `test (windows)` leg passed 0 on the same SHA
/// (PR #3424 CI: branch + worktree created, pre-start hook ran, only the exit
/// code diverged; the test finished in ~5.6s, well under `CHILD_EXIT_TIMEOUT`,
/// so it was a genuine self-exit and not a harness kill). This is the same
/// "slow-but-successful exit reports 1 on Windows" class the abort helpers
/// already tolerate via [`assert_valid_abort_exit_code`]. Tolerate it here so a
/// correct create doesn't false-fail the advisory leg, while keeping the exit
/// code strict everywhere it is reliable — a create that genuinely *fails*
/// leaves no branch, so the git-state assertions remain the real guard.
fn assert_valid_create_exit_code(exit_code: i32) {
let valid = if cfg!(windows) {
exit_code == 0 || exit_code == 1
} else {
exit_code == 0
};
assert!(
valid,
"Unexpected create exit code: {} (expected 0{})",
exit_code,
if cfg!(windows) {
", or 1 for the Windows slow-exit quirk"
} else {
""
}
);
}
/// Check if skim is ready (shows "> " prompt indicating it's accepting input)
fn is_skim_ready(screen_content: &str) -> bool {
// Skim shows "> " at the start of the prompt line when accepting input.
screen_content.starts_with("> ") || screen_content.contains("\n> ")
}
/// Live handles for a booted picker PTY session.
///
/// `_master` is held only to keep the pseudo-terminal open for the session's
/// lifetime; it is never read. Dropping it tears down the Windows ConPTY, after
/// which every write to `writer` fails with `BrokenPipe`. On Unix `take_writer()`
/// hands back an independent fd, so the master's lifetime is irrelevant — which
/// is exactly why dropping it early passes locally and on Linux/macOS CI yet
/// wipes out every picker test on Windows.
struct PickerSession {
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
writer: crate::common::pty::SharedPtyWriter,
rx: mpsc::Receiver<Vec<u8>>,
parser: vt100::Parser,
}
/// Spawn `command` in an isolated PTY, wait until skim is ready and the initial
/// render has stabilized, and return the live session handles. Every picker PTY
/// helper shares this boot sequence; they differ only in how they drive the
/// session and capture its frames.
fn boot_picker_pty(
command: &str,
args: &[&str],
working_dir: &Path,
env_vars: &[(String, String)],
) -> PickerSession {
let pair = crate::common::open_pty_with_size(TERM_ROWS, TERM_COLS);
let mut cmd = CommandBuilder::new(command);
for arg in args {
cmd.arg(arg);
}
cmd.cwd(working_dir);
// Isolated environment with coverage passthrough
crate::common::configure_pty_command(&mut cmd);
cmd.env("TERM", "xterm-256color");
// Test-specific environment variables
for (key, value) in env_vars {
cmd.env(key, value);
}
let child = pair.slave.spawn_command(cmd).unwrap();
drop(pair.slave);
let reader = pair.master.try_clone_reader().unwrap();
let writer: crate::common::pty::SharedPtyWriter =
Arc::new(Mutex::new(pair.master.take_writer().unwrap()));
// Drain PTY output into a channel; the reader thread also answers skim's
// startup cursor-position query (see `spawn_pty_reader_answering_queries`).
let rx = crate::common::pty::spawn_pty_reader_answering_queries(reader, Arc::clone(&writer));
let mut parser = vt100::Parser::new(TERM_ROWS, TERM_COLS, 0);
// Wait for skim to be ready (show "> " prompt)
let start = Instant::now();
loop {
while let Ok(chunk) = rx.try_recv() {
parser.process(&chunk);
}
let screen_content = parser.screen().contents();
if is_skim_ready(&screen_content) {
break;
}
if start.elapsed() > READY_TIMEOUT {
eprintln!(
"Warning: Timed out waiting for skim ready state. Screen content:\n{}",
screen_content
);
break;
}
std::thread::sleep(POLL_INTERVAL);
}
// Wait for initial render to stabilize
wait_for_stable(&rx, &mut parser);
PickerSession {
child,
_master: pair.master,
writer,
rx,
parser,
}
}
/// Send Escape to abort the picker, then drain and discard remaining output —
/// the caller has already captured the frame it wants, so teardown bytes must
/// not reach its parser. Consumes the handles and returns the exit code.
fn abort_and_exit_code(
mut child: Box<dyn portable_pty::Child + Send + Sync>,
writer: crate::common::pty::SharedPtyWriter,
rx: mpsc::Receiver<Vec<u8>>,
) -> i32 {
{
let mut w = writer.lock().unwrap();
w.write_all(b"\x1b").unwrap();
w.flush().unwrap();
}
drop(writer);
// Teardown bytes are discarded rather than parsed, so this waits with a
// sink parser — the caller's frame was captured before the Escape.
let mut sink = vt100::Parser::new(TERM_ROWS, TERM_COLS, 0);
wait_for_exit(&mut child, &rx, &mut sink, "Escape")
}
/// Wait for the picker child to exit, then drain its final output into `parser`
/// and return its exit code.
///
/// A child still running after [`CHILD_EXIT_TIMEOUT`] is hung, and the harness
/// must not turn that into an exit code: `Child::kill` on Windows is
/// `TerminateProcess(proc, 1)`, so a killed hang and a wt that failed on its own
/// both report 1 — the ambiguity that had a *correct* picker-create reported as a
/// mysterious Windows "self-exit 1" (#3427). Panic on the hang instead, so every
/// exit code a caller asserts on is one the child chose.
///
/// `terminator` names the keystroke that should have ended the session, for the
/// panic message.
fn wait_for_exit(
child: &mut Box<dyn portable_pty::Child + Send + Sync>,
rx: &mpsc::Receiver<Vec<u8>>,
parser: &mut vt100::Parser,
terminator: &str,
) -> i32 {
let start = Instant::now();
let mut exited = false;
while start.elapsed() < CHILD_EXIT_TIMEOUT {
if child.try_wait().unwrap().is_some() {
exited = true;
break;
}
std::thread::sleep(POLL_INTERVAL);
}
if !exited {
let _ = child.kill();
let _ = child.wait();
drain_until_quiet(rx, parser);
panic!(
"Picker child did not exit within {CHILD_EXIT_TIMEOUT:?} of {terminator}.\n\
Screen content:\n{}",
screen_text(parser)
);
}
// Drain to quiet, not once: `try_wait` reports the child reaped, but its
// final writes can still be in the PTY (and in the reader thread) at that
// moment, so a single non-blocking sweep drops the tail — which is exactly
// where a failing run's explanation lives (wt's error line, the last
// warning). Without this the captured frame reads as a silent exit.
drain_until_quiet(rx, parser);
child.wait().unwrap().exit_code() as i32
}
/// Execute a command in a PTY with a sequence of inputs and optional content expectations.
///
/// Each input is `(input_bytes, expected_content)`:
/// - `expected_content`: a substring that must appear on screen before the input is considered
/// processed. Required for async preview content that lands later than the prompt update.
///
/// Example: `[("\x1b[B", None), ("\x1b3", Some("diff --git"))]`
/// - After Down (move cursor to the next worktree): just wait for the screen to settle.
/// - After Alt-3 (switch to the main…± diff panel): wait until "diff --git" appears.
fn exec_in_pty_with_input_expectations(
command: &str,
args: &[&str],
working_dir: &Path,
env_vars: &[(String, String)],
inputs: &[(&str, Option<&str>)],
) -> PtyResult {
let PickerSession {
mut child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(command, args, working_dir, env_vars);
// Send each input and wait for screen to stabilize after each
for (input, expected_content) in inputs {
send_input_awaiting_content(&writer, &rx, &mut parser, input, *expected_content);
}
// Release the main thread's writer handle. The reader thread holds the
// other Arc clone until the PTY drains, so this no longer drives stdin EOF.
// The picker exits on Accept/Escape.
drop(writer);
let exit_code = wait_for_exit(&mut child, &rx, &mut parser, "the last input");
PtyResult { parser, exit_code }
}
/// Drain the PTY into `parser` until the child's output goes quiet — nothing new
/// for [`POST_EXIT_QUIET`], or [`POST_EXIT_DRAIN_TIMEOUT`] elapsed.
///
/// Used after the child exits, where the goal is a complete final frame rather
/// than a stable one. The bound keeps a still-chatty PTY (a detached background
/// hook writing to the same terminal) from holding the test.
fn drain_until_quiet(rx: &mpsc::Receiver<Vec<u8>>, parser: &mut vt100::Parser) {
let start = Instant::now();
let mut last_chunk = Instant::now();
while start.elapsed() < POST_EXIT_DRAIN_TIMEOUT && last_chunk.elapsed() < POST_EXIT_QUIET {
match rx.recv_timeout(POLL_INTERVAL) {
Ok(chunk) => {
parser.process(&chunk);
last_chunk = Instant::now();
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
// Reader thread hit PTY EOF and dropped its sender: nothing more is
// coming, and the channel is already empty.
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
/// Execute a command in a PTY, capture screen state, then abort with Escape.
///
/// This is the key fix for flaky abort snapshot tests. The problem: snapshotting
/// screen state AFTER sending Escape races with skim's teardown, producing
/// non-deterministic output (variable border painting, incomplete rows, trailing
/// whitespace). The fix: capture the stable screen BEFORE aborting, then only
/// check exit code after abort.
///
/// `pre_abort_inputs` are sent before capturing (e.g., typing a filter or switching
/// preview panels). Each input can optionally specify content that must appear before
/// the screen is considered stable.
fn exec_in_pty_capture_before_abort(
command: &str,
args: &[&str],
working_dir: &Path,
env_vars: &[(String, String)],
pre_abort_inputs: &[(&str, Option<&str>)],
) -> PtyResult {
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(command, args, working_dir, env_vars);
// Send pre-abort inputs (filter text, panel switches, etc.)
for (input, expected_content) in pre_abort_inputs {
send_input_awaiting_content(&writer, &rx, &mut parser, input, *expected_content);
}
// === CAPTURE: screen state is now stable — snapshot BEFORE aborting ===
// The parser retains this state because we stop feeding output to it.
let exit_code = abort_and_exit_code(child, writer, rx);
PtyResult { parser, exit_code }
}
/// Drive the picker to a settled baseline, capture the list pane, send a
/// sequence of keys, capture the list pane again, then abort. Returns
/// `(baseline, after, exit_code)` so the caller can assert the keys left the
/// list byte-for-byte unchanged.
///
/// This is the invariant form of a "this key is a visual no-op" test. It
/// commits no frame, so picker column-layout changes never touch it, and there
/// is no frozen baseline that can capture a different async-render frame than a
/// sibling snapshot test — the failure mode the old committed snapshot hit.
/// Both captures bracket only the probe keys and are each taken once the screen
/// has settled, so a genuine no-op yields byte-identical frames.
fn exec_in_pty_capture_noop_probe(
command: &str,
args: &[&str],
working_dir: &Path,
env_vars: &[(String, String)],
baseline_inputs: &[(&str, Option<&str>)],
probe_inputs: &[(&str, Option<&str>)],
) -> (String, String, i32) {
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(command, args, working_dir, env_vars);
// Settle to the baseline, then capture it.
for (input, expected_content) in baseline_inputs {
send_input_awaiting_content(&writer, &rx, &mut parser, input, *expected_content);
}
let baseline = list_pane_text(parser.screen());
// Send the probe keys, then capture again.
for (input, expected_content) in probe_inputs {
send_input_awaiting_content(&writer, &rx, &mut parser, input, *expected_content);
}
let after = list_pane_text(parser.screen());
let exit_code = abort_and_exit_code(child, writer, rx);
(baseline, after, exit_code)
}
/// Wait for screen content to stabilize (no changes for STABLE_DURATION)
fn wait_for_stable(rx: &mpsc::Receiver<Vec<u8>>, parser: &mut vt100::Parser) {
wait_for_stable_with_content(rx, parser, None);
}
/// Wait for screen content to stabilize, optionally requiring specific content.
///
/// If `expected_content` is provided, waits until the screen contains that string
/// AND has stabilized. This is essential for async preview panels where the initial
/// render may show placeholder content before the actual data loads.
///
/// Tip: avoid including the panel border character (`│`) in `expected_content` —
/// its rendering varies by platform and terminal, causing flaky assertions.
fn wait_for_stable_with_content(
rx: &mpsc::Receiver<Vec<u8>>,
parser: &mut vt100::Parser,
expected_content: Option<&str>,
) {
let describe = expected_content.map(|c| format!("expected content {c:?}"));
wait_for_stable_until(
rx,
parser,
|screen| expected_content.is_none_or(|c| screen.contains(c)),
describe.as_deref(),
None,
);
}
/// Wait until the list-pane cursor pointer lands on the row for `name`, then
/// settles.
///
/// skim draws its `> ` pointer on the selected row on every render of the item
/// list, so the pointer is a race-free signal of cursor position. The preview
/// pane is not: skim only repaints it on a selection-*change* event
/// (`on_selection_changed` → `Event::RunPreview`), so a cursor move driven by a
/// `Custom` action — the alt-x sticky reposition — leaves the preview showing
/// the previous row until something else repaints it. Gating cursor-position
/// assertions on the preview text therefore races the picker's async render;
/// gating on the pointer does not.
///
/// The query line also starts with `> `, but these helpers navigate by cursor
/// and never type, so the query stays empty — only the selected row both starts
/// with `>` and carries a worktree `name`, which uniquely picks it out.
fn wait_for_cursor_on_row(rx: &mpsc::Receiver<Vec<u8>>, parser: &mut vt100::Parser, name: &str) {
let describe = format!("the cursor (> pointer) on row {name:?}");
wait_for_stable_until(
rx,
parser,
|screen| cursor_points_at(screen, name),
Some(&describe),
None,
);
}
/// True when the list-pane `>` pointer is on the row for `name`.
///
/// skim draws its pointer at the start of the selected row's line on every
/// item-list render. The query line also starts with `> `, but the helpers that
/// rely on this navigate by cursor and never type, so the query stays empty —
/// only the selected row both starts with `>` and carries a worktree `name`,
/// which uniquely picks it out.
///
/// The match is scoped to the list pane (cols `0..LIST_WIDTH`). The preview pane
/// shares each physical row to the right of the border, so `name` is sought only
/// in the row's own list text — otherwise a token that also renders in the
/// preview (e.g. a PR title carrying a branch word) could satisfy the check from
/// the wrong row.
fn cursor_points_at(screen: &str, name: &str) -> bool {
screen.lines().any(|line| {
let list: String = line.chars().take(LIST_WIDTH as usize).collect();
list.starts_with('>') && list.contains(name)
})
}
/// Drive the PTY reader until the screen satisfies `ready` and then settles, or
/// the stabilization timeout elapses.
///
/// `ready` is evaluated against the full screen contents. When `describe` is
/// `Some`, a timeout that never saw `ready` panics with diagnostics (naming the
/// awaited condition); when it is `None` the caller has no readiness condition
/// (stability only) and `ready` is ignored.
///
/// Handles a subtle race: skim may keep redrawing cosmetically (cursor
/// repositioning, border repaints) even after the meaningful content is on
/// screen, which keeps resetting the "no changes for STABLE_DURATION" timer. So
/// once `ready` holds, we track how long it has held continuously and accept
/// stability after STABLE_DURATION even if the screen keeps churning. With no
/// readiness condition there is nothing to find, so the screen must settle the
/// hard way (the cosmetic-redraw fallback never engages).
///
/// `nudge`, when `Some`, is invoked every [`CURSOR_REISSUE_INTERVAL`] while
/// `ready` is still unmet. It exists for the cursor-arrow caller: an idempotent
/// Up/Down arrow re-issued to drive the `>` pointer back onto its target row
/// after an async item-list refresh (CI status / PR markers landing) reset the
/// cursor to the top. Late *preview* content needs no nudge — the picker
/// repaints a preview on its own once its background compute lands (see
/// `PreviewNotifier`), so preview-content callers pass `None` and the poll just
/// waits for `ready`.
fn wait_for_stable_until(
rx: &mpsc::Receiver<Vec<u8>>,
parser: &mut vt100::Parser,
ready: impl Fn(&str) -> bool,
describe: Option<&str>,
nudge: Option<&dyn Fn()>,
) {
let start = Instant::now();
let mut last_change = Instant::now();
let mut last_content = parser.screen().contents();
// Tracks when `ready` first held continuously on screen. Used as a fallback
// stability signal when skim keeps redrawing cosmetically.
let mut ready_since: Option<Instant> = None;
let mut last_nudge = Instant::now();
let has_condition = describe.is_some();
while start.elapsed() < STABILIZE_TIMEOUT {
// Drain available output
while let Ok(chunk) = rx.try_recv() {
parser.process(&chunk);
}
let current_content = parser.screen().contents();
if current_content != last_content {
last_content = current_content.clone();
last_change = Instant::now();
}
let content_ready = if has_condition {
let found = ready(&current_content);
if found {
ready_since.get_or_insert(Instant::now());
} else {
// Condition lost (e.g., skim full redraw) — reset
ready_since = None;
}
found
} else {
true
};
// Primary: screen hasn't changed for STABLE_DURATION and content is ready
if last_change.elapsed() >= STABLE_DURATION && content_ready {
return;
}
// Fallback (only with a readiness condition): if it has held continuously
// for STABLE_DURATION, consider the screen stable even while skim keeps
// doing cosmetic redraws (cursor repositioning, border repaints).
if let Some(found_time) = ready_since
&& found_time.elapsed() >= STABLE_DURATION
{
return;
}
// While the readiness condition is still unmet, periodically re-issue the
// nudge (the cursor-arrow caller's idempotent arrow). An async item-list
// refresh can reset skim's cursor to the top after the first arrow, so a
// single keystroke would strand the pointer; re-issuing drives it back
// onto the target row until the list stops refreshing.
if !content_ready
&& let Some(nudge) = nudge
&& last_nudge.elapsed() >= CURSOR_REISSUE_INTERVAL
{
nudge();
last_nudge = Instant::now();
}
std::thread::sleep(POLL_INTERVAL);
}
// Timeout: if a condition was specified but never held, fail with diagnostics
// instead of proceeding to a guaranteed assertion mismatch.
if let Some(desc) = describe
&& !ready(&last_content)
{
panic!(
"Timed out after {:?} waiting for {desc} to appear on screen.\n\
Screen content:\n{}",
STABILIZE_TIMEOUT, last_content
);
}
// Stability-only timeout (no condition, or condition present but unstable) —
// warn but proceed (test may still pass with current screen state)
eprintln!(
"Warning: Screen did not fully stabilize within {:?}",
STABILIZE_TIMEOUT
);
}
/// True for a Up/Down cursor arrow (`ESC [ A` / `ESC [ B`). Arrow navigation
/// clamps at the list ends, so re-issuing one is idempotent there — safe to
/// repeat while waiting for the cursor to reach a target row.
fn is_cursor_arrow(input: &str) -> bool {
matches!(input.as_bytes(), [0x1b, b'[', b'A' | b'B'])
}
/// Send `input`, then wait for the screen to satisfy the per-input expectation
/// and settle.
///
/// For a Up/Down cursor arrow carrying `expected_content`, the content names the
/// target row and the wait re-issues the arrow every [`CURSOR_REISSUE_INTERVAL`]
/// until the list `>` pointer lands on it. A single arrow is unreliable on rows
/// that decorate asynchronously (CI status / PR markers): when the background
/// resolution lands it refreshes skim's item list, which resets the cursor to the
/// top, stranding the pointer on the primary worktree. That is a Windows-CI flake
/// observed with the cursor stuck on `main`, where the HEAD± tab showed the
/// primary's empty diff and the awaited `diff --git` never appeared. Re-issuing
/// the idempotent arrow drives the cursor back down after any reset; the wait
/// returns only once the pointer holds on the target through [`STABLE_DURATION`],
/// by which point the list has stopped refreshing.
///
/// Every other input — including an Alt-<digit> preview-tab switch — falls back
/// to a plain [`wait_for_stable_with_content`]. Late preview content needs no
/// re-issue: the picker repaints a preview on its own once its background compute
/// lands (see `PreviewNotifier`), so a diff or forge fetch that finishes after
/// the keystroke surfaces without one — the poll just waits for it. The
/// non-idempotent inputs (Tab, filter text, Enter) must not be repeated anyway.
fn send_input_awaiting_content(
writer: &crate::common::pty::SharedPtyWriter,
rx: &mpsc::Receiver<Vec<u8>>,
parser: &mut vt100::Parser,
input: &str,
expected_content: Option<&str>,
) {
let send = || {
let mut w = writer.lock().unwrap();
w.write_all(input.as_bytes()).unwrap();
w.flush().unwrap();
};
send();
match expected_content {
Some(name) if is_cursor_arrow(input) => {
let describe = format!("the cursor (> pointer) on row {name:?}");
wait_for_stable_until(
rx,
parser,
|screen| cursor_points_at(screen, name),
Some(&describe),
Some(&send),
);
}
_ => wait_for_stable_with_content(rx, parser, expected_content),
}
}
/// Create insta settings with filters for switch picker snapshot stability.
///
/// Replaces the manual `normalize_output()` approach with declarative insta filters.
/// Since `rows()` returns plain text (no ANSI codes, no OSC 8 hyperlinks),
/// `add_pty_filters()` and `strip_osc8_hyperlinks()` are not needed.
fn switch_picker_settings(repo: &TestRepo) -> insta::Settings {
let mut settings = crate::common::setup_snapshot_settings(repo);
// Query line has timing variations (shows typed chars at different rates).
// \A anchors to absolute start of string, matching only the first line.
settings.add_filter(r"\A> [^\n]*", "> [QUERY]");
// Skim's previewer overlays its vertical scroll indicator (`{vscroll_offset}/
// {content.len()}`) at the right edge of the preview pane's first line, in
// reverse video — see `skim::previewer::Previewer::draw`. We don't see the
// reverse-video attribute (vt100's `rows()` strips it), so it lands on screen
// as bare `N/M` overlapping the tab header text. content.len() varies with
// terminal width and preview content height, so it must be normalized.
//
// The previewer right-aligns the indicator at `screen_width - len - 1`, so
// it overwrites a variable-width chunk at the right edge of the tab bar. With
// all six numbered tabs the bar fills the 60-col preview pane, so the chunk
// covers tab 6 (`6: pr`), its ` | ` divider, and a few trailing chars of tab
// 5 — how many depends on the indicator's digit count (`5: summary1/46` vs
// `5: summar1/286`). Anchor on the always-visible left portion (through
// `5: summ`, well inside the pane) and rewrite the corrupted tail to the
// canonical full bar. The exact per-tab styling (bold/plain/dim) is asserted
// by the `items.rs` unit snapshots; here we only need a stable marker that
// the bar rendered with tab 6 present.
settings.add_filter(
r"(?m)^(1: HEAD± \| 2: log \| 3: main…± \| 4: remote⇅ \| 5: summ).*$",
"${1}ary | 6: pr [N/M]",
);
// Commit hashes (7-8 hex chars)
settings.add_filter(r"\b[0-9a-f]{7,8}\b", "[HASH]");
// Truncated commit hashes (6+ hex chars followed by ..) in narrow columns
settings.add_filter(r"\b[0-9a-f]{6,8}\.\.", "[HASH]..");
// Relative timestamps (1d, 16h, etc.)
settings.add_filter(r"\b\d+[dhms]\b", "[TIME]");
settings
}
#[rstest]
fn test_switch_picker_abort_with_escape(repo: TestRepo) {
// Remove origin so snapshots don't show origin/main
repo.run_git(&["remote", "remove", "origin"]);
let env_vars = repo.test_env_vars();
let result = exec_in_pty_capture_before_abort(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
&[], // No inputs before abort
);
assert_valid_abort_exit_code(result.exit_code);
let (list, preview) = result.panels();
let settings = switch_picker_settings(&repo);
settings.bind(|| {
assert_snapshot!("switch_picker_abort_escape_list", list);
assert_snapshot!("switch_picker_abort_escape_preview", preview);
});
}
/// A branch name containing `/` (`feature/auth`) must keep its place in collect
/// order on the empty-query view — it must not sink below plainer names. skim's
/// empty-query engine scores every row `(score=0, begin=0)`, so a `PathName`
/// tiebreak would collapse to `path_name_offset` there and demote every
/// slash-bearing row (a `feature/…` branch, a `/`-gutter row). The picker uses
/// the default `[Score, Begin, End]` tiebreak precisely so the empty query keeps
/// the order `collect` produced (current, main, newest-first). Created
/// `feature/auth` first so collect ranks it ahead of `plain-branch` (equal test
/// timestamps fall back to worktree-creation order). Asserts the relative order
/// directly rather than freezing a frame, so column-layout and async-render
/// timing can't drift it.
#[rstest]
fn test_switch_picker_slashed_branch_keeps_collect_order(mut repo: TestRepo) {
// Remove origin so the list doesn't show origin/main
repo.run_git(&["remote", "remove", "origin"]);
repo.add_worktree("feature/auth");
repo.add_worktree("plain-branch");
let env_vars = repo.test_env_vars();
let result = exec_in_pty_capture_before_abort(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
// Settle: wait for the last row to render before capturing.
&[("", Some("plain-branch"))],
);
assert_valid_abort_exit_code(result.exit_code);
let (list, _preview) = result.panels();
let auth = list
.find("feature/auth")
.unwrap_or_else(|| panic!("feature/auth row missing:\n{list}"));
let plain = list
.find("plain-branch")
.unwrap_or_else(|| panic!("plain-branch row missing:\n{list}"));
assert!(
auth < plain,
"slashed branch must keep collect order (feature/auth before plain-branch):\n{list}"
);
}
/// Alt-l / alt-h are skim's built-in horizontal-scroll keys (ScrollRight /
/// ScrollLeft). The picker binds both to `ignore` because each row's `display()`
/// owns its layout with a leading worktree-status sigil; an unbound alt-l slides
/// every row left, clipping that sigil gutter (`no_hscroll(true)` only gates the
/// automatic match-following shift, not the manual offset these keys push).
///
/// The belief is narrow — "alt-l/alt-h change nothing" — so the test asserts it
/// directly: capture the settled list, press the keys, capture again, require
/// the two byte-for-byte equal. No committed frame, so picker column-layout
/// changes never touch this test and the equality can't drift on async-column
/// render timing the way a frozen snapshot can.
#[rstest]
fn test_switch_picker_alt_l_does_not_hscroll(mut repo: TestRepo) {
// Remove origin so the list doesn't show origin/main
repo.run_git(&["remote", "remove", "origin"]);
repo.add_worktree("feature-one");
repo.add_worktree("feature-two");
let env_vars = repo.test_env_vars();
let (baseline, after, exit_code) = exec_in_pty_capture_noop_probe(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
&[("", Some("feature-two"))], // settle: wait for items to render
&[
("\x1bl", None), // Alt-l: ignored, must not scroll
("\x1bl", None), // a second press, still ignored
("\x1bh", None), // Alt-h: ignored too
],
);
assert_valid_abort_exit_code(exit_code);
assert_eq!(
baseline, after,
"alt-l/alt-h must leave the list unscrolled (left = before keys, right = after)"
);
}
/// A list taller than the viewport renders skim's scrollbar thumb (`▐`) down
/// the right edge of the item pane. The thumb only appears because the picker
/// sets `.scrollbar("▐")` explicitly: skim's `▐` default lives in its clap
/// `default_value`, gated on the `cli` feature we disable, so the library
/// `Default` for the field is the empty string ("no scrollbar"). Without the
/// explicit setting a long worktree/`--prs` list scrolls with no position cue.
/// `--branches` overflows the 30-row test terminal cheaply (one `git branch`
/// per row, no `git worktree add`).
#[rstest]
fn test_switch_picker_scrollbar_on_overflow(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
// Far more branches than the ~24 item rows the 30-row terminal can show, so
// the list is guaranteed to overflow and skim paints the scrollbar.
repo.create_branches(
&(0..50)
.map(|i| format!("scroll-{i:02}"))
.collect::<Vec<_>>(),
);
let env_vars = repo.test_env_vars();
let result = exec_in_pty_capture_before_abort(
wt_bin().to_str().unwrap(),
&["switch", "--branches"],
repo.root_path(),
&env_vars,
// `@ main` is the current worktree, always the top row of the list:
// gating on it confirms the item rows rendered before capture, and a
// regression fails fast with the list shown rather than via a 30s
// stabilize timeout (the role `orphan-branch` plays above).
&[("", Some("@ main"))],
);
assert_valid_abort_exit_code(result.exit_code);
let (list, _preview) = result.panels();
assert!(
list.contains('▐'),
"scrollbar thumb (▐) should render when the list overflows the viewport:\n{list}"
);
}
#[rstest]
fn test_switch_picker_preview_navigation_and_log_panel(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
let feature_path = repo.add_worktree("feature");
// One clean commit is enough to distinguish the log pane from HEAD±.
std::fs::write(feature_path.join("file.txt"), "content\n").unwrap();
repo.run_git_in(&feature_path, &["add", "file.txt"]);
repo.run_git_in(
&feature_path,
&["commit", "-m", "Commit for preview navigation"],
);
// Make the wrapped comments tab deterministic and local.
seed_ci_status(&repo, "feature", "null");
let env_vars = repo.test_env_vars();
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
);
// One real session covers both cyclic directions and direct Alt-N
// selection. The paired Shift-Tab then Tab transition distinguishes
// comments (7) from PR (6): both empty panes say "has no PR", but only
// comments advances to HEAD± (1).
send_input_awaiting_content(&writer, &rx, &mut parser, "\x1b[B", Some("feature"));
send_input_awaiting_content(&writer, &rx, &mut parser, "\x1b[Z", Some("has no PR"));
send_input_awaiting_content(
&writer,
&rx,
&mut parser,
"\t",
Some("has no uncommitted changes"),
);
send_input_awaiting_content(&writer, &rx, &mut parser, "\t", Some("* "));
send_input_awaiting_content(
&writer,
&rx,
&mut parser,
"\x1b1",
Some("has no uncommitted changes"),
);
send_input_awaiting_content(&writer, &rx, &mut parser, "\x1b2", Some("* "));
let list = list_pane_text(parser.screen());
let preview = preview_pane_text(parser.screen());
let exit_code = abort_and_exit_code(child, writer, rx);
assert_valid_abort_exit_code(exit_code);
let settings = switch_picker_settings(&repo);
settings.bind(|| {
assert_snapshot!("switch_picker_preview_log_list", list);
assert_snapshot!("switch_picker_preview_log_preview", preview);
});
}
/// Seed a fresh CI-status cache entry for `branch` so the picker primes the
/// row's `pr_status` at skeleton time (`populate_from_cache`) — making the `pr`
/// and `comments` tabs resolve deterministically with no dependence on the live
/// forge fetch's timing. `status_json` is the cached `status` value: `"null"`
/// for "CI checked, no PR", or a PR object (e.g.
/// `{"ci_status":"passed","source":"pr","is_stale":false,"number":{"number":42,"sigil":"#"}}`).
/// `branch` must be a checked-out worktree — its current HEAD is the cache key.
fn seed_ci_status(repo: &TestRepo, branch: &str, status_json: &str) {
let head = repo.git_output(&["rev-parse", branch]);
let cache_dir = repo.path().join(".git/wt/cache/ci-status");
std::fs::create_dir_all(&cache_dir).unwrap();
let entry = format!(
r#"{{"status":{status_json},"checked_at":{TEST_EPOCH},"head":"{head}","branch":"{branch}"}}"#,
head = head.trim(),
);
std::fs::write(cache_dir.join(format!("{branch}.json")), entry).unwrap();
}
/// Env vars (mock-bin on PATH + `WORKTRUNK_TEST_MOCK_CONFIG_DIR`) for a PTY
/// `wt` run that should resolve `gh`/`glab` to a mock written into `mock_bin`.
/// Shared by tests that build their own strict list, CI, and preview responses.
fn forge_mock_env_vars(repo: &TestRepo, mock_bin: &Path) -> Vec<(String, String)> {
let mut env_vars = repo.test_env_vars();
env_vars.push((
"WORKTRUNK_TEST_MOCK_CONFIG_DIR".to_string(),
mock_bin.display().to_string(),
));
// Prepend mock-bin to PATH using the OS separator (`;` on Windows, `:` on
// Unix) — a hardcoded `:` corrupts the PATH on Windows, so the mock
// `gh.exe`/`glab.exe` is never found and the `--prs` fetch silently no-ops.
// `configure_pty_command` sets `PATH` (uppercase), which this entry overrides.
let base_path = std::env::var_os("PATH").unwrap_or_default();
let mut paths = vec![mock_bin.to_path_buf()];
paths.extend(std::env::split_paths(&base_path));
let joined = std::env::join_paths(paths).expect("mock-bin joins into PATH");
env_vars.push(("PATH".to_string(), joined.to_string_lossy().into_owned()));
env_vars
}
/// A file-backed mock response gate that also releases on panic, so a failed
/// PTY assertion cannot leave the mock subprocess blocked until its timeout.
struct MockReleaseGate(PathBuf);
impl MockReleaseGate {
fn new(path: PathBuf) -> Self {
Self(path)
}
fn release(&self) {
std::fs::write(&self.0, "").expect("release gated mock response");
}
}
impl Drop for MockReleaseGate {
fn drop(&mut self) {
let _ = std::fs::write(&self.0, "");
}
}
/// Removing a worktree row with alt-x in `--prs` mode must keep the streamed
/// PR/MR rows on screen — only the removed worktree row leaves the list, no
/// alt-r refresh needed.
///
/// alt-x's drop path rebuilds skim's item pool from the picker's shared row list
/// (`resync_pool`). The `--prs` thread streams its PR rows straight to skim's
/// item channel, so unless they're also recorded in that shared list the rebuild
/// drops them. This first proves the complete mocked GitHub
/// fetch lifecycle from its in-flight marker through the loaded state, then
/// drives the drop path and asserts the `#42` PR row survives it.
#[rstest]
fn test_switch_picker_prs_rows_survive_alt_x_removal(mut repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
// A clean worktree at main's commit: its branch is integrated, so alt-x
// takes the drop path (row leaves the list → `resync_pool`), not the morph
// path (which keeps the row in place and never resyncs the pool).
repo.add_worktree("wt-drop");
// PR #42's head branch isn't a shown worktree/branch, so it survives the
// `--prs` dedup and streams in as its own `#42` row. The mock answers only
// the `--prs` list call (`gh pr list --state`); the per-worktree CI fetch
// (`gh pr list --head <branch>`) gets an empty list, so `#42` appears solely
// as the streamed `--prs` row — never folded into a worktree row's CI cell.
let pr_json = r#"[{"number":42,"title":"Retry the flaky network test","headRefName":"fix/flaky","author":{"login":"octocat"},"isDraft":false,"url":"https://github.com/owner/test-repo/pull/42","body":"Wraps the request in a retry so the suite stops flaking."}]"#;
let mock_bin = repo.root_path().join("mock-bin");
std::fs::create_dir_all(&mock_bin).unwrap();
std::fs::write(mock_bin.join("list.json"), pr_json).unwrap();
MockConfig::new("gh")
.version("gh version 1.0.0 (mock)")
.command(
"pr list --state",
MockResponse::file("list.json").wait_for_file("prs.release"),
)
.command("pr list --head", MockResponse::output("[]"))
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let release = MockReleaseGate::new(mock_bin.join("prs.release"));
let env_vars = forge_mock_env_vars(&repo, &mock_bin);
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(
wt_bin().to_str().unwrap(),
&["switch", "--prs"],
repo.root_path(),
&env_vars,
);
// The list fetch is causally held, so this is the real in-flight frame and
// no PR row can have streamed in yet.
wait_for_stable_with_content(&rx, &mut parser, Some("Loading open PRs"));
let loading_screen = parser.screen().contents();
assert!(
loading_screen.contains("Loading open PRs"),
"loading line on the header while --prs fetches:\n{loading_screen}"
);
assert!(
!loading_screen.contains("#42"),
"PR row must not render before the list response is released:\n{loading_screen}"
);
release.release();
// Preview off (alt-p) so the full-width list renders the CI column (`#42`),
// which the preview-shown pane otherwise clips past skim's split. The wait
// also blocks until the `--prs` row has streamed in.
send_input_awaiting_content(&writer, &rx, &mut parser, "\x1bp", Some("#42"));
// The worktree row is on screen too (one skeleton batch).
wait_for_stable_with_content(&rx, &mut parser, Some("wt-drop"));
let loaded_screen = parser.screen().contents();
assert!(
loaded_screen.contains("#42"),
"mocked GitHub PR row reached the picker:\n{loaded_screen}"
);
assert!(
!loaded_screen.contains("Retry the flaky network test"),
"PR title belongs in the hidden preview, not the list row:\n{loaded_screen}"
);
assert!(
!loaded_screen.contains("Loading open PRs"),
"loading marker clears when the PR row lands:\n{loaded_screen}"
);
// Cursor onto the removable worktree row (the row below the pinned current
// worktree), then alt-x removes it.
send_input_awaiting_content(&writer, &rx, &mut parser, "\x1b[B", Some("wt-drop"));
{
let mut w = writer.lock().unwrap();
w.write_all(b"\x1bx").unwrap();
w.flush().unwrap();
}
// The worktree row drops; gating on its disappearance proves the resync ran.
wait_for_stable_until(
&rx,
&mut parser,
|screen| !screen.contains("wt-drop"),
Some("the wt-drop row to leave the list"),
None,
);
let screen = parser.screen().contents();
let exit_code = abort_and_exit_code(child, writer, rx);
assert_valid_abort_exit_code(exit_code);
assert!(
screen.contains("#42"),
"PR row must survive the alt-x worktree removal (no alt-r needed).\nScreen:\n{screen}"
);
}
/// alt-x on a row that can't be removed explains the rejection immediately and
/// keeps the cursor on that row. Launching from `wt-a` puts the unremovable main
/// worktree between two real rows; that setup makes a one-row cursor drift
/// observable rather than letting bottom-of-list clamping hide it. One transient
/// frame must contain both the header reason and the pointer on `^ main`.
///
/// The flash clearing after the beat is covered by
/// `test_header_flash_set_then_self_clears`; this asserts the flash paints.
#[rstest]
fn test_switch_picker_alt_x_flashes_unremovable_reason(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
let wt_a = repo.add_worktree("wt-a");
repo.add_worktree("wt-b");
let env_vars = repo.test_env_vars();
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(wt_bin().to_str().unwrap(), &["switch"], &wt_a, &env_vars);
wait_for_stable_with_content(&rx, &mut parser, Some("wt-b"));
// Prove main is genuinely mid-list before relying on cursor preservation:
// row-specific gutter/name pairs keep the `main↕` column header from
// satisfying the main lookup.
let list = list_pane_text(parser.screen());
let row = |needle: &str| {
list.lines()
.position(|line| line.contains(needle))
.unwrap_or_else(|| panic!("missing picker row {needle:?}:\n{list}"))
};
let wt_a_row = row("@ wt-a");
let main_row = row("^ main");
let wt_b_row = row("+ wt-b");
assert!(
wt_a_row < main_row && main_row < wt_b_row,
"expected rendered row order wt-a < main < wt-b, got \
{wt_a_row} < {main_row} < {wt_b_row}:\n{list}"
);
// Down from the pinned current row onto the mid-list main worktree.
send_input_awaiting_content(&writer, &rx, &mut parser, "\x1b[B", Some("^ main"));
{
let mut w = writer.lock().unwrap();
w.write_all(b"\x1bx").unwrap();
w.flush().unwrap();
}
// Observe one frame that proves both effects of the rejected alt-x. Process
// queued output byte-by-byte and check after each parser state: even if this
// test thread is descheduled for the flash's full 2.5-second lifetime, a
// later clear repaint queued behind it cannot erase the transient frame
// before the predicate sees it.
let start = Instant::now();
let mut saw_rejected_frame = false;
'wait: while start.elapsed() < STABILIZE_TIMEOUT {
match rx.recv_timeout(POLL_INTERVAL) {
Ok(chunk) => {
for byte in chunk {
parser.process(&[byte]);
let screen = parser.screen().contents();
if screen.contains("main worktree cannot be removed")
&& cursor_points_at(&screen, "^ main")
{
saw_rejected_frame = true;
break 'wait;
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
assert!(
saw_rejected_frame,
"never observed one frame with both the unremovable reason and the cursor \
on the main row:\n{}",
parser.screen().contents()
);
let exit_code = abort_and_exit_code(child, writer, rx);
assert_valid_abort_exit_code(exit_code);
}
/// A preview pane fills in on its own once its background compute lands — no
/// keystroke needed. The deterministic vehicle is a `--prs` row's `comments`
/// tab: the comment fetch (`gh pr view <n> --json comments`) is held behind a
/// causal file gate. The test first observes the "Loading comments…"
/// placeholder, then releases the fetch; the comment must surface with no
/// further input once the orchestrator pokes a repaint (`PreviewNotifier`).
/// Before that product-side poke the placeholder would strand until the next
/// keystroke — the gap the picker's test harness used to paper over by
/// re-issuing the tab key.
///
/// The PR row is selected by driving the cursor with a re-issued Down, not by an
/// `!main` filter: a filter applied before the async `--prs` row streams in
/// empties the result set, and skim doesn't reselect the lone row when it arrives
/// — so the gate timed out under contention (#3269). Down is idempotent and
/// clamps on the bottom row, where the streamed PR row sits (below the worktree
/// row), so the cursor-arrow wait re-issues it until the `>` pointer holds on
/// `flaky`, outlasting both the stream-in and the cursor reset that the
/// item-list refresh triggers.
#[rstest]
fn test_switch_picker_preview_auto_refreshes_when_compute_lands(repo: TestRepo) {
repo.run_git(&[
"remote",
"set-url",
"origin",
"https://github.com/owner/test-repo.git",
]);
// `pr list` is instant so the row lands promptly. `pr view` cannot answer
// until the test creates `comments.release`, so boot speed cannot erase the
// loading-state precondition.
let mock_bin = repo.root_path().join("mock-bin");
std::fs::create_dir_all(&mock_bin).unwrap();
// A short head branch so the PR row isn't truncated in the narrow
// (preview-shown) list pane. A locally resolvable head OID keeps the log
// preview on its local fast path, so the gated `pr view` is exclusively the
// comments fetch whose repaint this test measures.
let head = repo.git_output(&["rev-parse", "HEAD"]);
let pr_json = format!(
r#"[{{"number":42,"title":"Retry the flaky network test","headRefName":"flaky","headRefOid":"{}","author":{{"login":"octocat"}},"isDraft":false,"url":"https://github.com/owner/test-repo/pull/42","body":"body"}}]"#,
head.trim()
);
std::fs::write(mock_bin.join("pr_list.json"), pr_json).unwrap();
let comments_json = r#"{"comments":[{"author":{"login":"octocat"},"body":"AUTOREFRESHMARK","createdAt":"2025-01-01T00:00:00Z"}]}"#;
MockConfig::new("gh")
.version("gh version 1.0.0 (mock)")
.command("pr list --state", MockResponse::file("pr_list.json"))
.command("pr list --head", MockResponse::output("[]"))
.command(
"pr view 42",
MockResponse::output(comments_json).wait_for_file("comments.release"),
)
.command("_default", MockResponse::exit(1))
.write(&mock_bin);
let release = MockReleaseGate::new(mock_bin.join("comments.release"));
let call_log = tempfile::tempdir().unwrap();
let mut env_vars = forge_mock_env_vars(&repo, &mock_bin);
env_vars.push((
"WORKTRUNK_TEST_MOCK_CALL_LOG_DIR".to_string(),
call_log.path().display().to_string(),
));
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(
wt_bin().to_str().unwrap(),
&["switch", "--prs"],
repo.root_path(),
&env_vars,
);
// Drive the cursor onto the PR row. Its narrow (preview-shown) list line
// shows the short head branch `flaky`; the worktree row shows `main`. A
// re-issued Down re-drives the pointer after a streamed-row refresh.
send_input_awaiting_content(&writer, &rx, &mut parser, "\x1b[B", Some("flaky"));
send_input_awaiting_content(&writer, &rx, &mut parser, "\x1b7", Some("Loading comments"));
let loading = preview_pane_text(parser.screen());
assert!(
loading.contains("Loading comments"),
"comments fetch must be visibly in flight before release:\n{loading}"
);
// Release the already-open fetch, then wait without sending another
// keystroke. Only the product's notifier can repaint the marker.
release.release();
wait_for_stable_with_content(&rx, &mut parser, Some("AUTOREFRESHMARK"));
let preview = preview_pane_text(parser.screen());
assert!(
preview.contains("AUTOREFRESHMARK"),
"comment surfaced on its own once the gated fetch landed:\n{preview}"
);
let exit_code = abort_and_exit_code(child, writer, rx);
assert_valid_abort_exit_code(exit_code);
let preview_calls: Vec<_> = mock_calls(call_log.path(), "gh")
.into_iter()
.filter(|call| call.starts_with("pr view"))
.collect();
assert_eq!(
preview_calls,
["pr view 42 --json comments"],
"only the intended comments fetch may drive this repaint"
);
}
/// Picker-create validates hook templates *before* `git worktree add`, mirroring
/// the pre-flight that `wt switch --create` already performs.
///
/// Without this, a broken `pre-start` template would let the worktree be
/// created, then fail at expansion time — leaving a half-state that blocks
/// re-running (the branch already exists). The test commits a syntax-broken
/// `pre-start` to the user config, fires picker-create, asserts that no branch
/// or worktree was created, then fixes the template and confirms re-running
/// succeeds — proving the pre-flight aborts cleanly rather than leaving a
/// half-created worktree behind.
#[rstest]
fn test_switch_picker_create_validates_templates_before_worktree(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
// Broken `pre-start` in user config: unbalanced `{{` is a minijinja parse
// error, so `validate_template` rejects it without needing approvals.
// Project config would also trigger the validation path, but it routes
// through the approval gate first and would prompt for a TTY response —
// user-config hooks are trusted and exercise validation directly.
repo.write_test_config(r#"pre-start = "echo {{ unclosed""#);
let env_vars = repo.test_env_vars();
let result = exec_in_pty_with_input_expectations(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
&[
("new-feature", None), // Type the branch name
("\x1bc", None), // Alt-C: create
],
);
assert_ne!(
result.exit_code,
0,
"Expected non-zero exit when pre-start template is broken.\nScreen:\n{}",
result.screen()
);
// Branch must not have been created — pre-flight runs before any
// `git worktree add` / `git branch`.
let branch_output = repo
.git_command()
.args(["branch", "--list", "new-feature"])
.run()
.unwrap();
assert!(
String::from_utf8_lossy(&branch_output.stdout)
.trim()
.is_empty(),
"Branch `new-feature` should NOT exist, got:\n{}",
String::from_utf8_lossy(&branch_output.stdout)
);
// Worktree directory must not exist either.
let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap();
let worktree_dir = repo
.root_path()
.parent()
.unwrap()
.join(format!("{repo_name}.new-feature"));
assert!(
!worktree_dir.exists(),
"Worktree dir {worktree_dir:?} should NOT have been created"
);
// Fix the template and re-run — proves no half-state was left behind.
repo.write_test_config(r#"pre-start = "true""#);
let result = exec_in_pty_with_input_expectations(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
&[("new-feature", None), ("\x1bc", None)],
);
// Re-running with the fixed template should succeed. Success is proven by the
// branch existing below (no half-state was left behind); the exit code
// tolerates the Windows self-exit-1 quirk (see assert_valid_create_exit_code)
// — the flake this test hit on the advisory `affected tests (windows)` leg,
// where a fully-correct create self-exited 1 (PR #3424 CI).
assert_valid_create_exit_code(result.exit_code);
let branch_output = repo
.git_command()
.args(["branch", "--list", "new-feature"])
.run()
.unwrap();
assert!(
String::from_utf8_lossy(&branch_output.stdout).contains("new-feature"),
"Branch `new-feature` should exist after fix.\nScreen:\n{}",
result.screen()
);
}
#[rstest]
fn test_switch_picker_emits_cd_directive_by_default(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
// Create a worktree to switch to
let target_path = repo.add_worktree("target-branch");
let (cd_path, exec_path, _guard) = worktrunk::testing::directive_files();
let mut env_vars = repo.test_env_vars();
env_vars.push((
"WORKTRUNK_DIRECTIVE_CD_FILE".to_string(),
cd_path.display().to_string(),
));
env_vars.push((
"WORKTRUNK_DIRECTIVE_EXEC_FILE".to_string(),
exec_path.display().to_string(),
));
// Run `wt switch` (without --no-cd), select "target-branch" via picker
let result = exec_in_pty_with_input_expectations(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
&[
// Cursor-navigation select, gated on the list-pane `>` pointer: the
// picker sorts the current worktree first, so one Down lands on
// `target-branch`. Typing a query instead would tie the selection to
// skim's matcher, whose filtered item list is swapped in during a
// *render* while `Accept` reads the cursor's slot directly — so a
// query gate can only ever assert what was painted, not what Enter
// will act on. The pointer comes from the same render state as the
// accept (see `wait_for_cursor_on_row`), and an async row refresh
// that resets the cursor is absorbed by the re-issued arrow.
("\x1b[B", Some("target-branch")),
("\r", None), // Enter to switch
],
);
assert_eq!(
result.exit_code,
0,
"Expected exit code 0 for successful switch.\nScreen:\n{}",
result.screen()
);
// The directive must name the row the picker actually selected, not merely
// contain some path (which could hide a stale/default-worktree write).
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
assert_eq!(
crate::common::canonicalize(Path::new(cd_content.trim())).unwrap(),
crate::common::canonicalize(&target_path).unwrap(),
"CD directive must point at the selected target-branch worktree; got {cd_content:?}"
);
}
#[rstest]
fn test_switch_picker_no_cd_switches_without_cd_directive(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
// Create a worktree to switch to
repo.add_worktree("target-branch");
let (cd_path, exec_path, _guard) = worktrunk::testing::directive_files();
let mut env_vars = repo.test_env_vars();
env_vars.push((
"WORKTRUNK_DIRECTIVE_CD_FILE".to_string(),
cd_path.display().to_string(),
));
env_vars.push((
"WORKTRUNK_DIRECTIVE_EXEC_FILE".to_string(),
exec_path.display().to_string(),
));
// `wt switch --no-cd` opens the picker and switches identically to
// `wt switch <branch> --no-cd` — it only suppresses the cd directive.
// `--format=json` is the observable proof the switch pipeline ran: the
// structured result reaches stdout only after `execute_switch`.
let result = exec_in_pty_with_input_expectations(
wt_bin().to_str().unwrap(),
&["switch", "--no-cd", "--format=json"],
repo.root_path(),
&env_vars,
&[
// Cursor-navigation select: see test_switch_picker_emits_cd_directive_by_default
// for why the `>` pointer is the gate rather than a typed query.
("\x1b[B", Some("target-branch")),
("\r", None), // Enter to switch
],
);
let screen = result.screen();
assert_eq!(
result.exit_code, 0,
"Expected exit code 0 for --no-cd switch.\nScreen:\n{screen}"
);
// The structured result reaches stdout only after execute_switch — the
// old print-only path emitted a bare branch name and never reached it.
assert!(
screen.contains("\"action\""),
"Expected --format=json switch result on screen.\nScreen:\n{}",
screen
);
// --no-cd suppresses only the cd directive; the switch still ran.
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
assert!(
cd_content.trim().is_empty(),
"CD file should be empty with --no-cd, got: {}",
cd_content
);
}
/// `{{ base }}` in a picker `--execute` resolves to the source worktree, just
/// as it does on the argument path (`wt switch <branch> -x …`). The picker now
/// captures pre-switch source identity, so the two paths no longer diverge:
/// before, the picker left `base` unset while pre-flight validation still
/// accepted the template, so `-x 'echo {{ base }}'` passed validation and then
/// errored on the undefined value *after* the switch had already landed.
/// Selecting from the `main` worktree, `{{ base }}` expands to `main`.
#[rstest]
fn test_switch_picker_execute_base_resolves_to_source(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.add_worktree("target-branch");
let (cd_path, exec_path, _guard) = worktrunk::testing::directive_files();
let mut env_vars = repo.test_env_vars();
env_vars.push((
"WORKTRUNK_DIRECTIVE_CD_FILE".to_string(),
cd_path.display().to_string(),
));
env_vars.push((
"WORKTRUNK_DIRECTIVE_EXEC_FILE".to_string(),
exec_path.display().to_string(),
));
// Run from the `main` worktree so the captured source branch is `main`.
let result = exec_in_pty_with_input_expectations(
wt_bin().to_str().unwrap(),
&["switch", "--execute", "echo {{ base }}"],
repo.root_path(),
&env_vars,
&[
// Cursor-navigation select: see test_switch_picker_emits_cd_directive_by_default.
("\x1b[B", Some("target-branch")),
("\r", None), // Enter to switch
],
);
assert_eq!(
result.exit_code,
0,
"picker `-x '{{{{ base }}}}'` should succeed, not error on an undefined \
value after the switch.\nScreen:\n{}",
result.screen()
);
let exec_contents = std::fs::read_to_string(&exec_path).unwrap_or_default();
assert!(
exec_contents.contains("echo main"),
"EXEC file should contain the expanded `{{{{ base }}}}` (the source \
branch `main`), got: {exec_contents}"
);
}
/// A project `pre-switch` hook must pass through the approval gate when the
/// picker switches — the picker has no `--yes`, so an unapproved project
/// command is shown for approval, never auto-run.
///
/// Regression: the picker previously passed `yes = true` to
/// `run_pre_switch_hooks`, silently executing project `pre-switch` commands
/// without a prompt — inconsistent with every other hook the picker gates, and
/// a hole in "Project Commands Run Only After Approval". Here the hook is
/// declined at the prompt; it must not run, and the switch must still succeed.
#[rstest]
fn test_switch_picker_pre_switch_hook_requires_approval(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.add_worktree("target-branch");
// Project `pre-switch` hook (in `.config/wt.toml`, so it routes through the
// approval gate) that touches a marker outside the worktree if it runs.
let marker_dir = tempfile::tempdir().unwrap();
let marker = marker_dir.path().join("pre-switch-ran");
repo.write_project_config(&format!(
"pre-switch = {:?}\n",
format!("touch {}", marker.display())
));
let env_vars = repo.test_env_vars();
// Select target-branch, press Enter, then decline the approval prompt.
let result = exec_in_pty_with_input_expectations(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
&[
// Cursor-navigation select: see test_switch_picker_emits_cd_directive_by_default.
("\x1b[B", Some("target-branch")),
("\r", Some("needs approval")), // Enter; wait for the approval prompt
// Decline. The line terminator is CR, not LF: once skim releases the
// terminal, the approval prompt's `read_line` runs in the OS line
// discipline (cooked mode). Windows' console terminates a line on CR
// (the Enter key) and never on a bare LF, so "n\n" would leave the
// read blocked until the harness kills the hung process (exit 1). CR
// terminates on both platforms — Windows reads it as Enter, and on
// Unix the PTY's ICRNL maps it to LF.
("n\r", None), // decline
],
);
let screen = result.screen();
assert_eq!(
result.exit_code, 0,
"switch should still succeed after declining the pre-switch hook.\nScreen:\n{screen}"
);
assert!(
screen.contains("needs approval"),
"picker must prompt before running a project pre-switch hook.\nScreen:\n{screen}"
);
assert!(!marker.exists(), "a declined pre-switch hook must not run");
}
/// alt-x lands the cursor on the row that slides up — the *immediate* next row —
/// not one past it, even when the removed row has several rows below it.
///
/// A two-row setup can't distinguish a correct landing from a one-row overshoot:
/// `scroll_by` clamps the cursor to the list's last row, so both land on the same
/// remaining row. This removes a *middle* row with two rows below it, where an
/// overshoot lands one row too far instead of being clamped — the exact "jumps
/// two down" a user sees with a long worktree list.
#[rstest]
fn test_switch_picker_alt_x_lands_on_immediate_next_row(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
// Four worktrees beneath the pinned current (main) row. All sit at main's
// commit, so each alt-x integrates-and-drops (no morph) — the drop path.
for branch in ["wt-a", "wt-b", "wt-c", "wt-d"] {
repo.add_worktree(branch);
}
let env_vars = repo.test_env_vars();
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(
wt_bin().to_str().unwrap(),
&["switch", "--no-cd", "--format=json"],
repo.root_path(),
&env_vars,
);
let send = |bytes: &[u8]| {
let mut w = writer.lock().unwrap();
w.write_all(bytes).unwrap();
w.flush().unwrap();
};
// One skeleton batch carries every worktree row, so waiting for one implies
// all are present.
wait_for_stable_with_content(&rx, &mut parser, Some("wt-a"));
// Learn the rendered order: the current worktree is pinned to the top, then the
// four worktrees by commit recency (a tie here, so insertion order). Read the
// four worktree rows top-to-bottom from the list pane.
let order: Vec<String> = {
let list = list_pane_text(parser.screen());
let mut rows: Vec<(usize, String)> = ["wt-a", "wt-b", "wt-c", "wt-d"]
.iter()
.filter_map(|name| {
list.lines()
.position(|l| l.contains(name))
.map(|line| (line, (*name).to_string()))
})
.collect();
rows.sort_by_key(|(line, _)| *line);
rows.into_iter().map(|(_, name)| name).collect()
};
assert_eq!(order.len(), 4, "all four worktree rows rendered");
// Remove the second worktree row (two rows still below it); the row directly
// below it must catch the cursor.
let remove_target = order[1].clone();
let expected_landing = order[2].clone();
let overshoot_row = order[3].clone();
// Down onto the second worktree row: one Down per row from the pinned current
// worktree at the top.
send(b"\x1b[B");
wait_for_cursor_on_row(&rx, &mut parser, &order[0]);
send(b"\x1b[B");
wait_for_cursor_on_row(&rx, &mut parser, &remove_target);
// alt-x drops it; the cursor must land on the row that slid up — the one
// directly below, not the one after it. A one-row overshoot lands on
// `overshoot_row` and times this out.
send(b"\x1bx");
wait_for_cursor_on_row(&rx, &mut parser, &expected_landing);
// Guard against the cursor having blown past to the next row: the pointer marks
// exactly one row, so a landing on `expected_landing` already excludes
// `overshoot_row`, but assert it explicitly for a clear failure message.
let pointer_line = list_pane_text(parser.screen())
.lines()
.find(|l| l.starts_with('>'))
.map(str::to_string)
.unwrap_or_default();
assert!(
!pointer_line.contains(&overshoot_row),
"alt-x overshot to `{overshoot_row}` instead of the immediate next row \
`{expected_landing}`.\nPointer line: {pointer_line:?}"
);
let _ = abort_and_exit_code(child, writer, rx);
}
/// Dropping the *last* row with alt-x refreshes the preview pane to the new last
/// row. skim auto-repaints the preview across the matcher's `Replace` only when the
/// selected row's text changes — which a last-row drop doesn't produce (`current`
/// goes briefly out of range, then clamps onto the new last row with no text change
/// to detect). The picker fires its own settled-gated `RunPreview`
/// (`run_preview_when_settled`) to cover that; without it the pane keeps showing the
/// removed row's preview until the next keystroke.
#[rstest]
fn test_switch_picker_alt_x_last_row_refreshes_preview(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
// Two clean worktrees at main's commit, so alt-x integrates-and-drops them.
for branch in ["wt-a", "wt-b"] {
repo.add_worktree(branch);
}
let env_vars = repo.test_env_vars();
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(
wt_bin().to_str().unwrap(),
&["switch", "--no-cd", "--format=json"],
repo.root_path(),
&env_vars,
);
let send = |bytes: &[u8]| {
let mut w = writer.lock().unwrap();
w.write_all(bytes).unwrap();
w.flush().unwrap();
};
wait_for_stable_with_content(&rx, &mut parser, Some("wt-b"));
// Rendered order: the pinned current (main) on top, then the two worktrees by
// recency. The bottom worktree row is the drop target; the one above it becomes
// the new last row the cursor lands on.
let order: Vec<String> = {
let list = list_pane_text(parser.screen());
let mut rows: Vec<(usize, String)> = ["wt-a", "wt-b"]
.iter()
.filter_map(|name| {
list.lines()
.position(|l| l.contains(name))
.map(|line| (line, (*name).to_string()))
})
.collect();
rows.sort_by_key(|(line, _)| *line);
rows.into_iter().map(|(_, name)| name).collect()
};
assert_eq!(order.len(), 2, "both worktree rows rendered");
let remove_target = order[1].clone(); // the bottom row
let new_last = order[0].clone(); // becomes the new last row after the drop
// Down onto the bottom worktree row, then confirm its preview is showing.
send(b"\x1b[B");
wait_for_cursor_on_row(&rx, &mut parser, &order[0]);
send(b"\x1b[B");
wait_for_cursor_on_row(&rx, &mut parser, &remove_target);
wait_for_stable_with_content(
&rx,
&mut parser,
Some(&format!("{remove_target} has no uncommitted changes")),
);
// alt-x drops the last row; the cursor lands on the new last row and its preview
// must refresh (the removed row's preview must not linger).
send(b"\x1bx");
wait_for_cursor_on_row(&rx, &mut parser, &new_last);
wait_for_stable_with_content(
&rx,
&mut parser,
Some(&format!("{new_last} has no uncommitted changes")),
);
let preview = preview_pane_text(parser.screen());
assert!(
preview.contains(&format!("{new_last} has no uncommitted changes")),
"the preview refreshed to the new last row `{new_last}`.\nPreview:\n{preview}"
);
assert!(
!preview.contains(&format!("{remove_target} has no uncommitted changes")),
"the preview must not keep showing the removed row `{remove_target}`.\nPreview:\n{preview}"
);
let _ = abort_and_exit_code(child, writer, rx);
}
/// alt-r refreshes the preview pane, not just the row list. The in-memory preview
/// cache is keyed by `(branch, mode)` with no SHA, so a warm working-tree diff
/// would otherwise survive an edit and re-serve stale content; the refresh clears
/// it so the pane recomputes against the current tree. Targets the pinned current
/// worktree (the top row), so the cursor sits on it before and after the reload
/// regardless of skim's reload cursor behavior — no navigation needed.
#[rstest]
fn test_switch_picker_alt_r_refreshes_preview(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
// A committed, tracked file in the current worktree so `git diff HEAD` has
// something to show once it's edited (untracked files don't appear in it).
let tracked = repo.root_path().join("tracked.txt");
std::fs::write(&tracked, "original\n").unwrap();
repo.run_git(&["add", "tracked.txt"]);
repo.run_git(&["commit", "-m", "add tracked file"]);
let env_vars = repo.test_env_vars();
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(
wt_bin().to_str().unwrap(),
&["switch", "--no-cd", "--format=json"],
repo.root_path(),
&env_vars,
);
let send = |bytes: &[u8]| {
let mut w = writer.lock().unwrap();
w.write_all(bytes).unwrap();
w.flush().unwrap();
};
// The picker opens on the working-tree tab with the cursor on the pinned
// current worktree (`main`), whose tree is clean.
wait_for_stable_with_content(&rx, &mut parser, Some("has no uncommitted changes"));
// Edit the tracked file *while the picker is open*. Without the refresh clearing
// the warm cache, alt-r would re-serve the cached "no uncommitted changes" pane.
std::fs::write(&tracked, "original\nedited\n").unwrap();
send(b"\x1br"); // alt-r: refresh
wait_for_stable_with_content(&rx, &mut parser, Some("diff --git"));
let preview = preview_pane_text(parser.screen());
assert!(
preview.contains("diff --git"),
"alt-r must recompute the working-tree preview to show the new edit.\nPreview:\n{preview}"
);
assert!(
!preview.contains("has no uncommitted changes"),
"the stale clean preview must not survive the refresh.\nPreview:\n{preview}"
);
let _ = abort_and_exit_code(child, writer, rx);
}
/// Removing the sole row matching an active query leaves the filtered list empty,
/// so the settled-gated preview refresh gives up once the matcher settles empty
/// rather than spinning the event loop. A second alt-x with nothing selected is a
/// no-op (the keybinding callback returns early on an empty selection). The picker
/// stays responsive — its screen stabilizes, then aborts cleanly.
#[rstest]
fn test_switch_picker_alt_x_no_match_stays_responsive(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
let wt_path = repo.add_worktree("solo-wt");
let env_vars = repo.test_env_vars();
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
);
send_input_awaiting_content(&writer, &rx, &mut parser, "solo-wt", Some("solo-wt"));
wait_for_cursor_on_row(&rx, &mut parser, "+ solo-wt");
// First alt-x must actually remove the selected worktree row. The query
// line still contains `solo-wt`, so look only for the linked-worktree row.
{
let mut w = writer.lock().unwrap();
w.write_all(b"\x1bx").unwrap();
w.flush().unwrap();
}
wait_for_stable_until(
&rx,
&mut parser,
|screen| {
!screen.lines().any(|line| {
let list: String = line.chars().take(LIST_WIDTH as usize).collect();
list.contains("+ solo-wt")
})
},
Some("the + solo-wt row to leave the filtered list"),
None,
);
worktrunk::testing::wait_for_worktree_removed(&wt_path);
worktrunk::testing::wait_for("integrated solo-wt branch deletion", || {
repo.git_output(&["branch", "--list", "solo-wt"]).is_empty()
});
worktrunk::testing::assert_worktree_removed(&wt_path);
assert!(
repo.git_output(&["branch", "--list", "solo-wt"]).is_empty(),
"integrated solo-wt branch should be deleted with its worktree"
);
// The filtered list is now empty. A second alt-x has no selection and must
// be a no-op; normal Escape teardown proves the event loop stayed live.
send_input_awaiting_content(&writer, &rx, &mut parser, "\x1bx", None);
let exit_code = abort_and_exit_code(child, writer, rx);
assert_valid_abort_exit_code(exit_code);
}
/// alt-x on a *worktree* row whose branch is unmerged morphs the row to
/// `/ branch` **in place**: the worktree is removed, the local branch stays, and
/// the row keeps its slot with the cursor on it — gutter `+` → `/`, no reload, no
/// teleport. The cursor staying put is the whole point of the morph (the old
/// re-collect re-sorted the row to the bottom and reset the cursor to the top).
/// End-to-end through real skim: after alt-x, the list-pane cursor pointer (`>`)
/// must land on the morphed `/ transform-me` row — proving both the in-place
/// gutter flip and the sticky cursor in one assertion.
#[rstest]
fn test_switch_picker_alt_x_morphs_removed_worktree_in_place(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
// A worktree on a branch with a commit the default branch lacks, so
// `SafeDelete` keeps the branch when the worktree is removed (→ morph, not drop).
let wt_path = repo.add_worktree("transform-me");
std::fs::write(wt_path.join("new.txt"), "unmerged work").unwrap();
repo.git_command()
.args(["-C", wt_path.to_str().unwrap(), "add", "new.txt"])
.run()
.unwrap();
repo.git_command()
.args([
"-C",
wt_path.to_str().unwrap(),
"commit",
"-m",
"unmerged work",
])
.run()
.unwrap();
let env_vars = repo.test_env_vars();
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(
wt_bin().to_str().unwrap(),
&["switch"],
repo.root_path(),
&env_vars,
);
let send = |bytes: &[u8]| {
let mut w = writer.lock().unwrap();
w.write_all(bytes).unwrap();
w.flush().unwrap();
};
wait_for_stable_with_content(&rx, &mut parser, Some("transform-me"));
// Filter to the single worktree row so the selection is deterministic
// regardless of commit-recency order, then confirm the cursor is on it. The
// row still reads `> + transform-me` — a linked worktree.
send(b"transform-me");
wait_for_cursor_on_row(&rx, &mut parser, "+ transform-me");
// alt-x morphs the row in place. The cursor must land back on the morphed
// row — `> / transform-me`, gutter flipped to the branch sigil. The morph
// leaves `search_text` untouched, so the row still matches the active filter;
// a drop would empty the list, and the old re-collect would reset the cursor.
send(b"\x1bx");
wait_for_cursor_on_row(&rx, &mut parser, "/ transform-me");
worktrunk::testing::wait_for_worktree_removed(&wt_path);
let exit_code = abort_and_exit_code(child, writer, rx);
assert_valid_abort_exit_code(exit_code);
// The worktree is gone but its branch survives — the morph's premise.
worktrunk::testing::assert_worktree_removed(&wt_path);
let branches = repo.git_output(&["branch", "--list", "transform-me"]);
assert!(
branches.contains("transform-me"),
"the unmerged branch is retained after its worktree is removed: {branches:?}"
);
}
/// alt-x under an active fuzzy query lands the cursor on the row displayed just
/// below the removed one — the *filtered display* order, not the removed row's
/// index in the full (unfiltered) `shared_items` list.
///
/// Typing a query both shrinks and reorders skim's `item_list` relative to
/// `shared_items`. A reposition that scrolled to the removed row's `shared_items`
/// index lands rows past the right one — the "+N down" jump a user sees when
/// removing rows after filtering, where N is the count of filtered-out rows above
/// the cursor. The other alt-x cursor tests type no query, so the two index spaces
/// coincide and this regression hides. Here decoy worktrees the query filters out
/// sit between the matching ones, inflating each keeper's `shared_items` index past
/// its displayed index: an index-based reposition overshoots (and `scroll_by`
/// clamps it to the last filtered row), an identity-based one lands on the neighbor.
#[rstest]
fn test_switch_picker_alt_x_lands_on_neighbor_under_filter(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
// Keepers (match the query `keep`) interleaved with decoys (don't), so each
// keeper carries decoys ahead of it in `shared_items` order. All sit at main's
// commit, so alt-x integrates-and-drops (the drop path).
for branch in [
"keep-1", "other-1", "keep-2", "other-2", "keep-3", "other-3", "keep-4",
] {
repo.add_worktree(branch);
}
let env_vars = repo.test_env_vars();
let PickerSession {
child,
_master,
writer,
rx,
mut parser,
} = boot_picker_pty(
wt_bin().to_str().unwrap(),
&["switch", "--no-cd", "--format=json"],
repo.root_path(),
&env_vars,
);
let send = |bytes: &[u8]| {
let mut w = writer.lock().unwrap();
w.write_all(bytes).unwrap();
w.flush().unwrap();
};
wait_for_stable_with_content(&rx, &mut parser, Some("keep-4"));
// Type the query: only the four keepers survive (the current/main row and the
// decoys filter out), so the cursor starts on the top keeper.
send(b"keep");
wait_for_stable_with_content(&rx, &mut parser, Some("keep-1"));
// Learn the filtered display order — skim ranks the equal-scoring keepers, so
// read the rows top-to-bottom rather than assume one.
let order: Vec<String> = {
let list = list_pane_text(parser.screen());
let mut rows: Vec<(usize, String)> = ["keep-1", "keep-2", "keep-3", "keep-4"]
.iter()
.filter_map(|name| {
list.lines()
.position(|l| l.contains(name))
.map(|line| (line, (*name).to_string()))
})
.collect();
rows.sort_by_key(|(line, _)| *line);
rows.into_iter().map(|(_, name)| name).collect()
};
assert_eq!(
order.len(),
4,
"all four keepers shown under the `keep` filter"
);
// Remove the second displayed keeper (two still below it): the row directly
// below must catch the cursor, not one further down.
let remove_target = order[1].clone();
let expected_landing = order[2].clone();
let overshoot_row = order[3].clone();
// Down from the top filtered row onto the second keeper.
send(b"\x1b[B");
wait_for_cursor_on_row(&rx, &mut parser, &remove_target);
// alt-x drops it; the cursor must land on the row that slid up. An index-based
// reposition overshoots toward `overshoot_row` (clamped to the last filtered
// row) and times this out.
send(b"\x1bx");
wait_for_cursor_on_row(&rx, &mut parser, &expected_landing);
let pointer_line = list_pane_text(parser.screen())
.lines()
.find(|l| l.starts_with('>'))
.map(str::to_string)
.unwrap_or_default();
assert!(
!pointer_line.contains(&overshoot_row),
"alt-x under a filter overshot to `{overshoot_row}` instead of the \
immediate next row `{expected_landing}`.\nPointer line: {pointer_line:?}"
);
let _ = abort_and_exit_code(child, writer, rx);
}