Skip to main content

wt/tui/runtime/
mod.rs

1//! TUI runtime (spec §10): the async event loop that drives [`App`], executes
2//! [`Effect`]s, and loads async data. The loop and terminal handling are the
3//! thin, untestable shell; the effect-executing helpers are pure of the terminal
4//! and are unit-tested. Shell-based mutating actions run on a background task as
5//! a `Job` and apply their `JobOutcome` to the app, so the loop can animate a
6//! spinner overlay instead of freezing (issue #46).
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use crossterm::event::EventStream;
12use futures_util::StreamExt;
13use tokio::sync::mpsc;
14
15use crate::cli::NewArgs;
16use crate::commands::{self, Session, open_session};
17use crate::config::{Config, SubmoduleInit};
18use crate::cx::{Cx, SilentInput, Stream};
19use crate::error::{Error, Result};
20use crate::git::cli::GitCli;
21use crate::git::discover::Repo;
22use crate::hooks::CapturingHookRunner;
23use crate::model::{SortSpec, Worktree};
24use crate::tui::app::{
25    App, AppConfig, InitSubmodulesState, JobHome, JobKey, Mode, PrComposeState, PrItem,
26    StaleBaseState, StatusKind,
27};
28use crate::tui::event::{CreateDecision, Effect};
29use crate::tui::terminal::{Tui, install_panic_hook};
30use crate::util::editor::{editor_argv, resolve_editor};
31use crate::worktree_service::{build_rows, enumerate_rows, enumerate_worktrees};
32
33mod effects;
34use effects::*;
35
36/// Builds the [`AppConfig`] from the resolved configuration and the resolved
37/// color decision (spec §11 precedence).
38pub(crate) fn app_config(config: &Config, color: bool) -> AppConfig {
39    AppConfig {
40        keymap: config.keymap(),
41        sort: SortSpec::default(),
42        columns: config.list_columns.clone(),
43        show_untracked: config.list_show_untracked,
44        remove_untracked_blocks: config.remove_untracked_blocks,
45        nerd_fonts: config.ui_nerd_fonts,
46        mouse: config.ui_mouse,
47        color,
48        palette: config.palette(),
49    }
50}
51
52/// Runs the TUI, returning the chosen worktree path (if the user switched).
53/// When `initial_filter` is set, the picker opens pre-filtered to that query
54/// (the ambiguous-query fallback uses this to surface the candidates).
55pub fn run_tui(cx: &mut Cx, initial_filter: Option<&str>) -> Result<Option<PathBuf>> {
56    let git = cx.git.clone();
57    let session = open_session(cx, git.as_ref())?;
58    let opened_in = anchor_at_root(cx, &session);
59    let mut app = build_app(cx, &session, git.as_ref())?;
60    if let Some(filter) = initial_filter.filter(|f| !f.is_empty()) {
61        app.apply_filter(filter.to_string());
62    }
63    drive_tui(cx, &session, app, Effect::None, &opened_in)
64}
65
66/// Runs the TUI directly in PR-picker mode (the `wt pr` no-argument entry).
67/// Returns the chosen worktree path once a PR is checked out, or `None` if the
68/// user cancels. The picker loads its PRs on open (via an initial `FetchPrs`),
69/// and selecting a PR switches into the new worktree (spec §7).
70pub fn run_pr_picker(cx: &mut Cx) -> Result<Option<PathBuf>> {
71    let git = cx.git.clone();
72    let session = open_session(cx, git.as_ref())?;
73    let opened_in = anchor_at_root(cx, &session);
74    let mut app = build_app(cx, &session, git.as_ref())?;
75    app.mode = Mode::PrPicker(crate::tui::app::PrPickerState {
76        loading: true,
77        ..Default::default()
78    });
79    drive_tui(cx, &session, app, Effect::FetchPrs, &opened_in)
80}
81
82/// Centres the session's git operations on the primary worktree root (issue #68):
83/// records the worktree the TUI was opened in, then repoints `cx.cwd` at the root
84/// so every subsequent operation — background jobs, refreshes, session rebuilds —
85/// anchors at the root rather than the opened-in worktree, which the user may
86/// remove during the session (deleting its directory out from under us). The
87/// returned path is the opened-in worktree root (the invocation directory for a
88/// bare repo), used on exit to detect whether it survived ([`finish_exit`]).
89fn anchor_at_root(cx: &mut Cx, session: &Session) -> PathBuf {
90    let opened_in = session
91        .repo
92        .current_workdir()
93        .unwrap_or_else(|| cx.cwd.clone());
94    cx.cwd = session.primary_root.clone();
95    opened_in
96}
97
98/// Builds the [`App`] over the session's worktrees plus worktree-less branch
99/// rows (issue #47), seeding the branch list.
100fn build_app(cx: &Cx, session: &Session, git: &dyn GitCli) -> Result<App> {
101    let sync_worktrees = enumerate_rows(&session.repo, git)?;
102    let size = crossterm::terminal::size().unwrap_or((100, 30));
103    // The TUI draws to the alternate screen on stderr, so resolve color against
104    // stderr (stdout is reserved for the chosen path and is usually piped).
105    let color = cx.color_enabled_err(session.config.ui_color);
106    let mut app = App::new(sync_worktrees, app_config(&session.config, color), size);
107    app.branches = crate::git::all_branches(session.repo.gix()).unwrap_or_default();
108    app.default_base = crate::git::default_base_ref(session.repo.gix());
109    app.mark_loading();
110    Ok(app)
111}
112
113/// Drives the prepared app through the event loop and returns the chosen path
114/// (terminal shell; not unit-tested).
115fn drive_tui(
116    cx: &mut Cx,
117    session: &Session,
118    mut app: App,
119    initial: Effect,
120    opened_in: &Path,
121) -> Result<Option<PathBuf>> {
122    let runtime = tokio::runtime::Runtime::new()?;
123    let outcome = runtime.block_on(run_loop(cx, session, &mut app, initial));
124    // Abandon any still-running background jobs (e.g. a submodule clone the user
125    // quit past) rather than blocking on runtime drop (issue #46 overhaul): the
126    // confirm-quit prompt already warned they may be left partial.
127    runtime.shutdown_background();
128    outcome?;
129
130    if app.too_small {
131        cx.err.line("terminal too small (need ≥5 rows)")?;
132        return Err(Error::operation("terminal too small"));
133    }
134    finish_exit(cx, opened_in, &session.primary_root, app.chosen.clone())
135}
136
137/// Resolves where the shell lands after a graceful TUI exit (issue #68).
138///
139/// An explicit switch (`chosen`) into a directory that still exists is always
140/// honoured. A blocked exit that waited for its jobs may have switched into a
141/// worktree a *different* finished job then removed (issue #46 overhaul), so a
142/// `chosen` path that no longer exists falls through to the same recovery as a
143/// plain quit rather than landing the shell in a deleted directory. Otherwise,
144/// when the directory the TUI was opened in was deleted during the session —
145/// typically by removing the current worktree from the dashboard — the user must
146/// not be left in a directory that no longer exists. In that case the shell is
147/// steered back to the repository root (the returned path, printed to stdout) with
148/// a friendly note on stderr; if the root is also gone, only the note is emitted
149/// and no navigation occurs. When the opened-in directory survives, nothing is
150/// printed and the user stays put.
151fn finish_exit(
152    cx: &mut Cx,
153    opened_in: &Path,
154    primary_root: &Path,
155    chosen: Option<PathBuf>,
156) -> Result<Option<PathBuf>> {
157    if let Some(path) = chosen
158        && path.exists()
159    {
160        return Ok(Some(path));
161    }
162    if opened_in.exists() {
163        return Ok(None);
164    }
165    if primary_root.exists() {
166        cx.err.line(&format!(
167            "worktree {} was removed during this session; returning to the repository root at {}",
168            opened_in.display(),
169            primary_root.display(),
170        ))?;
171        Ok(Some(primary_root.to_path_buf()))
172    } else {
173        cx.err.line(&format!(
174            "worktree {} was removed during this session, and the repository root is no longer available",
175            opened_in.display(),
176        ))?;
177        Ok(None)
178    }
179}
180
181/// The async event loop (terminal shell; not unit-tested). `initial` is an
182/// effect dispatched once after the first paint (e.g. `FetchPrs` to populate the
183/// PR picker on open); pass `Effect::None` for no initial action.
184async fn run_loop(cx: &mut Cx, session: &Session, app: &mut App, initial: Effect) -> Result<()> {
185    install_panic_hook();
186    let mut tui = Tui::enter(app.mouse)?;
187    app.size = tui.size();
188    // Refuse to drive a terminal that is already too short, before the first
189    // paint (spec §10); the `Tui` guard restores the terminal on drop.
190    if app.size.1 < crate::tui::app::MIN_HEIGHT {
191        app.too_small = true;
192        return Ok(());
193    }
194    tui.draw(app)?;
195
196    // Background shell-based actions run on blocking tasks and send their keyed
197    // outcome here; PR fetches stream into the picker; a ticker animates the
198    // per-row spinners while any job is in flight (issue #46 overhaul).
199    let (job_tx, mut job_rx) = mpsc::channel::<(JobKey, JobOutcome)>(64);
200    let (pr_tx, mut pr_rx) = mpsc::channel::<PrFetch>(4);
201
202    if initial != Effect::None {
203        if dispatch_effect(cx, session, app, &mut tui, initial, &pr_tx)? {
204            return Ok(());
205        }
206        tui.draw(app)?;
207    }
208
209    // Load async data in the background and stream the result in.
210    let (tx, mut rx) = mpsc::channel::<Vec<Worktree>>(1);
211    spawn_enrichment(session.primary_root.clone(), cx.git.clone(), tx);
212
213    let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
214
215    let mut events = EventStream::new();
216    loop {
217        tokio::select! {
218            // Animate the per-row spinners while any background job runs (the guard
219            // disables this branch — and the timer wakeups — when idle).
220            _ = ticker.tick(), if app.any_jobs() => {
221                app.tick_spinner();
222                tui.draw(app)?;
223            }
224            // A background action finished: clear its per-row spinner, apply its
225            // result, spawn any queued follow-up jobs (e.g. submodule init), and
226            // exit only if it set a worktree to switch into.
227            Some((key, outcome)) = job_rx.recv() => {
228                app.finish_job(&key);
229                apply_outcome(cx, session, app, outcome);
230                for effect in app.take_pending_jobs() {
231                    spawn_job(cx, app, effect, &job_tx);
232                }
233                tui.draw(app)?;
234                if app.exit_now() {
235                    break;
236                }
237            }
238            // A PR fetch finished: fold it into the picker (if still open).
239            Some(fetch) = pr_rx.recv() => {
240                apply_prs(app, fetch);
241                tui.draw(app)?;
242            }
243            maybe = events.next() => {
244                let Some(Ok(event)) = maybe else { continue };
245                // Input is never gated: the user can act while jobs run.
246                let effect = app.handle_event(event);
247                if is_background_action(&effect) {
248                    spawn_job(cx, app, effect, &job_tx);
249                    tui.draw(app)?;
250                } else if dispatch_effect(cx, session, app, &mut tui, effect, &pr_tx)? {
251                    break;
252                } else {
253                    tui.draw(app)?;
254                }
255            }
256            Some(worktrees) = rx.recv() => {
257                mark_all_loaded(app, worktrees);
258                tui.draw(app)?;
259            }
260        }
261    }
262    Ok(())
263}
264
265/// Executes an effect, returning `true` when the loop should exit (terminal
266/// shell; the operations it delegates to are tested).
267fn dispatch_effect(
268    cx: &mut Cx,
269    session: &Session,
270    app: &mut App,
271    tui: &mut Tui,
272    effect: Effect,
273    pr_tx: &mpsc::Sender<PrFetch>,
274) -> Result<bool> {
275    match effect {
276        Effect::None => Ok(false),
277        Effect::Switch(_) | Effect::Quit => Ok(true),
278        Effect::TooSmall => {
279            app.too_small = true;
280            Ok(true)
281        }
282        Effect::Refresh => {
283            do_refresh(cx, app, &session.primary_root);
284            Ok(false)
285        }
286        Effect::FetchPrs => {
287            // Fetch off-thread so opening the picker never freezes on the gh
288            // network call; the picker shows its loading state until it arrives.
289            spawn_fetch_prs(cx, session, pr_tx);
290            Ok(false)
291        }
292        Effect::OpenEditor(path) => {
293            tui.suspend()?;
294            run_editor(cx, session, &path);
295            tui.resume()?;
296            Ok(false)
297        }
298        // Shell-based mutating actions run on a background task with a spinner
299        // overlay (issue #46), dispatched by [`spawn_job`] from the event loop;
300        // they never reach `dispatch_effect`.
301        Effect::Create { .. }
302        | Effect::Remove(_)
303        | Effect::DeleteBranch { .. }
304        | Effect::MaterializeBranch { .. }
305        | Effect::CheckoutPr(_)
306        | Effect::CheckoutBranch { .. }
307        | Effect::Sync { .. }
308        | Effect::InitSubmodules { .. } => Ok(false),
309        // Compose-only effects are driven by the dedicated compose loop
310        // ([`run_pr_compose`]) and never reach the main loop.
311        Effect::DraftPrAi | Effect::SubmitPr { .. } => Ok(false),
312    }
313}
314
315/// Whether an effect is a shell-based mutating action that the event loop runs
316/// on a background task (with a spinner overlay) rather than inline (issue #46).
317fn is_background_action(effect: &Effect) -> bool {
318    matches!(
319        effect,
320        Effect::Create { .. }
321            | Effect::Remove(_)
322            | Effect::DeleteBranch { .. }
323            | Effect::MaterializeBranch { .. }
324            | Effect::CheckoutPr(_)
325            | Effect::CheckoutBranch { .. }
326            | Effect::Sync { .. }
327            | Effect::InitSubmodules { .. }
328    )
329}
330
331/// The owned, `Send + 'static` pieces a background job needs to build its own
332/// [`Cx`] (issue #46): discarded output buffers, a silent input, cloned handles,
333/// the captured environment, and the working directory. Rebuilding the `Cx`
334/// inside the job avoids moving the loop's borrowed `Cx`/`Session` into a
335/// `'static` task (mirrors [`spawn_enrichment`]).
336struct JobCx {
337    env: crate::cx::Env,
338    cwd: PathBuf,
339    git: Arc<dyn GitCli + Send + Sync>,
340    gh: Arc<dyn crate::gh::GhClient + Send + Sync>,
341    agent: Arc<dyn crate::agent::AgentClient + Send + Sync>,
342}
343
344impl JobCx {
345    /// Captures the handles needed to rebuild a `Cx` on a background thread. The
346    /// working directory is the loop's `cx.cwd`, so the rebuilt session matches
347    /// the one the foreground built at startup.
348    fn capture(cx: &Cx) -> Self {
349        JobCx {
350            env: cx.env.clone(),
351            cwd: cx.cwd.clone(),
352            git: cx.git.clone(),
353            gh: cx.gh.clone(),
354            agent: cx.agent.clone(),
355        }
356    }
357
358    /// Builds a fresh owned `Cx` inside the job: stdout/stderr are discarded
359    /// buffers (subprocess/hook output is captured, not shown — the TUI stays on
360    /// the alternate screen), and prompts are auto-declined.
361    fn into_cx(self) -> Cx {
362        let mut cx = Cx::new(
363            Stream::new(Box::new(Vec::<u8>::new()), false),
364            Stream::new(Box::new(Vec::<u8>::new()), false),
365            self.env,
366            self.cwd,
367            self.git,
368            self.gh,
369            self.agent,
370            Box::new(SilentInput),
371        );
372        cx.no_pager = true;
373        cx
374    }
375}
376
377/// A shell-based action to run on a background task, with its arguments already
378/// resolved to owned values on the foreground thread (issue #46).
379enum Job {
380    /// Create a worktree for a new `branch` based on `base`. `decision` is `None`
381    /// to pre-flight the base for staleness (issue #56), else the user's choice.
382    Create {
383        /// The new branch name.
384        branch: String,
385        /// The base ref (or `None` for the default).
386        base: Option<String>,
387        /// The stale-base decision (`None` pre-flights, else update/proceed).
388        decision: Option<CreateDecision>,
389    },
390    /// Remove the worktree matched by `query` (force semantics).
391    Remove {
392        /// The branch (or directory name) identifying the worktree to remove.
393        query: String,
394    },
395    /// Delete the local branch `branch` of a worktree-less branch row (issue #53).
396    DeleteBranch {
397        /// The branch to delete.
398        branch: String,
399        /// Whether to force-delete an unmerged branch (`-D`).
400        force: bool,
401    },
402    /// Materialize a worktree for an existing worktree-less `branch`.
403    Materialize {
404        /// The branch to create a worktree for.
405        branch: String,
406    },
407    /// Check out the PR with the given number.
408    CheckoutPr {
409        /// The PR number.
410        number: u64,
411    },
412    /// Check out `branch` in the worktree at `worktree_dir` (in place).
413    CheckoutBranch {
414        /// The target worktree directory.
415        worktree_dir: PathBuf,
416        /// The branch to check out.
417        branch: String,
418    },
419    /// Sync (pull then push) the branch in the worktree at `worktree_dir`.
420    Sync {
421        /// The target worktree directory.
422        worktree_dir: PathBuf,
423        /// The branch label for the status text, resolved on the foreground.
424        label: String,
425    },
426    /// Sync a worktree-less `branch` by moving its ref from the repo root
427    /// (issue #47/#63).
428    SyncBranch {
429        /// The branch to sync.
430        branch: String,
431        /// The branch label for the status text (the branch name).
432        label: String,
433    },
434    /// Initialize the submodules in `dir` recursively (issue #50).
435    InitSubmodules {
436        /// The worktree directory whose submodules to initialize.
437        dir: PathBuf,
438        /// How many uninitialized submodules were detected (for the status text).
439        count: usize,
440    },
441}
442
443/// The result of a background [`Job`], carrying the minimum its `apply_*` needs.
444/// Errors are stringified inside the job (the typed `Error` is not `'static`-
445/// friendly to ferry across the task boundary, and the UI only needs the text).
446enum JobOutcome {
447    /// A finished create attempt. `branch`/`base` echo back so the stale-base
448    /// modal can re-issue the create with a decision (issue #56).
449    Create {
450        /// The branch that was (to be) created.
451        branch: String,
452        /// The base ref the create used (echoed for the modal's re-issue).
453        base: Option<String>,
454        /// Created, awaiting the stale-base confirmation, or failed.
455        outcome: CreateOutcome,
456    },
457    /// A finished remove.
458    Remove {
459        /// The query that was removed (for the status text).
460        query: String,
461        /// Success, or the error message to surface.
462        result: std::result::Result<(), String>,
463    },
464    /// A finished branch deletion (issue #53).
465    DeleteBranch {
466        /// The branch that was deleted (for the status text).
467        branch: String,
468        /// Whether this was the force-delete attempt; gates the unmerged re-prompt.
469        force: bool,
470        /// Success, or the error message to surface.
471        result: std::result::Result<(), String>,
472    },
473    /// A finished materialize; the new worktree is located on apply.
474    Materialize {
475        /// The branch that was materialized.
476        branch: String,
477        /// Success, or the error message to surface.
478        result: std::result::Result<(), String>,
479    },
480    /// A finished PR checkout; the path is the new worktree to switch into.
481    CheckoutPr {
482        /// The PR number (for the status text).
483        number: u64,
484        /// The `(worktree path, already existed)` pair, or the error message.
485        result: std::result::Result<(PathBuf, bool), String>,
486    },
487    /// A finished in-place branch checkout.
488    CheckoutBranch {
489        /// The branch that was checked out.
490        branch: String,
491        /// The sync outcome, or the error message.
492        result: std::result::Result<commands::checkout::SyncOutcome, String>,
493    },
494    /// A finished sync (issue #63).
495    Sync {
496        /// The branch label for the status text.
497        label: String,
498        /// The sync outcome, or the error message.
499        result: std::result::Result<commands::sync::SyncOutcome, String>,
500    },
501    /// A finished submodule init (issue #50).
502    InitSubmodules {
503        /// How many submodules were initialized (for the status text).
504        count: usize,
505        /// Success, or the error message to surface.
506        result: std::result::Result<(), String>,
507    },
508}
509
510/// The result of a create job (issue #56). When the pre-flight finds the base
511/// behind its upstream it returns `NeedsStaleConfirm` *without* creating, so the
512/// loop can open the confirm modal; the modal then re-issues the create with a
513/// concrete decision.
514enum CreateOutcome {
515    /// The worktree was created.
516    Created,
517    /// The worktree was created and has uninitialized submodules (issue #50). The
518    /// TUI never initializes them inline (issue #46 overhaul): under the `always`
519    /// policy (`auto`) it starts a background init job on the new row; under the
520    /// `prompt` default it opens the confirm modal first.
521    CreatedNeedsSubmodules {
522        /// The new worktree directory whose submodules would be initialized.
523        dir: PathBuf,
524        /// How many uninitialized submodules were detected.
525        count: usize,
526        /// Whether to start the init automatically (`always` policy) rather than
527        /// prompting (`prompt` policy).
528        auto: bool,
529    },
530    /// The base is behind its upstream; the create paused for confirmation.
531    NeedsStaleConfirm {
532        /// How many commits the base is behind its upstream.
533        behind: u32,
534        /// The upstream display name, e.g. `origin/main`.
535        upstream_display: String,
536        /// Whether the base can be fast-forwarded (else "update" would fail).
537        can_fast_forward: bool,
538    },
539    /// The create failed; the message to surface.
540    Failed(String),
541}
542
543/// Resolves the worktree-identifying query for the row at `index` (its branch,
544/// or the directory name), mirroring the CLI's `remove` query.
545fn remove_query_of(app: &App, index: usize) -> Option<String> {
546    let worktree = app.worktrees.get(index)?;
547    Some(worktree.branch.clone().unwrap_or_else(|| {
548        worktree
549            .path
550            .file_name()
551            .map(|n| n.to_string_lossy().into_owned())
552            .unwrap_or_default()
553    }))
554}
555
556/// Resolves a background effect into a [`Job`] (owning its arguments), the
557/// [`JobKey`] that attaches its per-row spinner and guards against a conflicting
558/// action, and a display label. Pure — it does not touch the registry. Returns
559/// `None` when the effect's target row is gone or it is not a background action.
560fn resolve_job(app: &App, effect: Effect) -> Option<(Job, JobKey, String)> {
561    match effect {
562        Effect::Create {
563            branch,
564            base,
565            decision,
566        } => {
567            let label = format!("Creating {branch}");
568            let key = JobKey::New(branch.clone());
569            Some((
570                Job::Create {
571                    branch,
572                    base,
573                    decision,
574                },
575                key,
576                label,
577            ))
578        }
579        Effect::Remove(index) => {
580            let worktree = app.worktrees.get(index)?;
581            let key = JobKey::Path(worktree.path.clone());
582            let query = remove_query_of(app, index)?;
583            let label = format!("Removing {query}");
584            Some((Job::Remove { query }, key, label))
585        }
586        Effect::DeleteBranch { branch, force } => {
587            let label = format!("Deleting branch {branch}");
588            let key = JobKey::Branch(branch.clone());
589            Some((Job::DeleteBranch { branch, force }, key, label))
590        }
591        Effect::MaterializeBranch { branch } => {
592            let label = format!("Creating worktree for {branch}");
593            let key = JobKey::Branch(branch.clone());
594            Some((Job::Materialize { branch }, key, label))
595        }
596        Effect::CheckoutPr(number) => {
597            let label = format!("Checking out PR #{number}");
598            let key = JobKey::New(format!("PR #{number}"));
599            Some((Job::CheckoutPr { number }, key, label))
600        }
601        Effect::CheckoutBranch {
602            worktree_index,
603            branch,
604        } => {
605            let worktree_dir = app.worktrees.get(worktree_index)?.path.clone();
606            let label = format!("Checking out {branch}");
607            let key = JobKey::Path(worktree_dir.clone());
608            Some((
609                Job::CheckoutBranch {
610                    worktree_dir,
611                    branch,
612                },
613                key,
614                label,
615            ))
616        }
617        Effect::Sync { worktree_index } => {
618            let worktree = app.worktrees.get(worktree_index)?;
619            let label = worktree
620                .branch
621                .clone()
622                .unwrap_or_else(|| "worktree".to_string());
623            let display = format!("Syncing {label}");
624            let (job, key) = if worktree.has_worktree {
625                (
626                    Job::Sync {
627                        worktree_dir: worktree.path.clone(),
628                        label,
629                    },
630                    JobKey::Path(worktree.path.clone()),
631                )
632            } else {
633                // A branch row syncs by branch name from the repo root; a row with
634                // no branch (none exist today) has nothing to sync.
635                let branch = worktree.branch.clone()?;
636                let key = JobKey::Branch(branch.clone());
637                (Job::SyncBranch { branch, label }, key)
638            };
639            Some((job, key, display))
640        }
641        Effect::InitSubmodules { dir, count } => {
642            let label = format!("Initializing {count} submodule(s)");
643            let key = JobKey::Path(dir.clone());
644            Some((Job::InitSubmodules { dir, count }, key, label))
645        }
646        _ => None,
647    }
648}
649
650/// Spawns a background task to run `effect`'s shell action, registering it on its
651/// target row so a per-row spinner shows; the keyed outcome is sent to `tx` for
652/// the loop to apply (issue #46 overhaul). A second action on a row that already
653/// has a job in flight is refused with a status note so the two never race.
654fn spawn_job(cx: &Cx, app: &mut App, effect: Effect, tx: &mpsc::Sender<(JobKey, JobOutcome)>) {
655    let Some((job, key, label)) = resolve_job(app, effect) else {
656        return;
657    };
658    if app.has_job(&key) {
659        app.set_status(format!("{label} — already in progress"), StatusKind::Info);
660        return;
661    }
662    app.begin_job(key.clone(), label);
663    let jobcx = JobCx::capture(cx);
664    let tx = tx.clone();
665    tokio::task::spawn_blocking(move || {
666        let outcome = run_job(jobcx, job);
667        let _ = tx.blocking_send((key, outcome));
668    });
669}
670
671/// The payload of an async PR fetch: the picker items, or the error to surface.
672type PrFetch = std::result::Result<Vec<PrItem>, String>;
673
674/// Spawns a background task to fetch open PRs (a `gh` network call) and stream the
675/// result into the picker, so opening it never freezes the loop.
676fn spawn_fetch_prs(cx: &Cx, session: &Session, tx: &mpsc::Sender<PrFetch>) {
677    let gh = cx.gh.clone();
678    let dir = session
679        .repo
680        .current_workdir()
681        .unwrap_or_else(|| session.primary_root.clone());
682    let tx = tx.clone();
683    tokio::task::spawn_blocking(move || {
684        let _ = tx.blocking_send(fetch_prs_result(gh.as_ref(), &dir));
685    });
686}
687
688/// Runs a [`Job`] on the blocking thread, building its own `Cx` (and `Session`
689/// where needed) and capturing hook output. Returns the typed outcome.
690fn run_job(jobcx: JobCx, job: Job) -> JobOutcome {
691    let mut cx = jobcx.into_cx();
692    match job {
693        Job::Create {
694            branch,
695            base,
696            decision,
697        } => {
698            let outcome = run_create_command(&mut cx, &branch, base.clone(), decision);
699            JobOutcome::Create {
700                branch,
701                base,
702                outcome,
703            }
704        }
705        Job::Remove { query } => {
706            let result = run_remove_command(&mut cx, &query);
707            JobOutcome::Remove { query, result }
708        }
709        Job::DeleteBranch { branch, force } => {
710            let result = run_delete_branch_command(&mut cx, &branch, force);
711            JobOutcome::DeleteBranch {
712                branch,
713                force,
714                result,
715            }
716        }
717        Job::Materialize { branch } => {
718            let result = run_materialize_command(&mut cx, &branch);
719            JobOutcome::Materialize { branch, result }
720        }
721        Job::CheckoutPr { number } => {
722            let result = run_checkout_pr_command(&mut cx, number);
723            JobOutcome::CheckoutPr { number, result }
724        }
725        Job::CheckoutBranch {
726            worktree_dir,
727            branch,
728        } => {
729            let result = run_checkout_branch_command(&mut cx, &worktree_dir, &branch);
730            JobOutcome::CheckoutBranch { branch, result }
731        }
732        Job::Sync {
733            worktree_dir,
734            label,
735        } => {
736            let result = run_sync_command(&mut cx, &worktree_dir);
737            JobOutcome::Sync { label, result }
738        }
739        Job::SyncBranch { branch, label } => {
740            let result = run_sync_branch_command(&mut cx, &branch);
741            JobOutcome::Sync { label, result }
742        }
743        Job::InitSubmodules { dir, count } => {
744            let result = run_init_submodules_command(&mut cx, &dir);
745            JobOutcome::InitSubmodules { count, result }
746        }
747    }
748}
749
750/// Applies a finished [`JobOutcome`] to the app exactly as the inline handlers
751/// did before (issue #46): status text, mode, refresh, and `chosen`.
752fn apply_outcome(cx: &Cx, session: &Session, app: &mut App, outcome: JobOutcome) {
753    let root = &session.primary_root;
754    match outcome {
755        JobOutcome::Create {
756            branch,
757            base,
758            outcome,
759        } => apply_create(cx, app, &branch, base, outcome, root),
760        JobOutcome::Remove { query, result } => apply_remove(cx, app, &query, result, root),
761        JobOutcome::DeleteBranch {
762            branch,
763            force,
764            result,
765        } => apply_delete_branch(cx, app, &branch, force, result, root),
766        JobOutcome::Materialize { branch, result } => {
767            apply_materialize(cx, app, &branch, result, root)
768        }
769        JobOutcome::CheckoutPr { number, result } => {
770            apply_checkout_pr(cx, app, number, result, root)
771        }
772        JobOutcome::CheckoutBranch { branch, result } => {
773            apply_checkout_branch(cx, app, &branch, result, root)
774        }
775        JobOutcome::Sync { label, result } => apply_sync(cx, app, &label, result, root),
776        JobOutcome::InitSubmodules { count, result } => {
777            apply_init_submodules(cx, app, count, result, root)
778        }
779    }
780}
781
782/// The initial title/body/draft seed for the compose form (`wt pr open`).
783#[derive(Debug, Clone, Default)]
784pub struct ComposeSeed {
785    /// Seed title (empty when not provided).
786    pub title: String,
787    /// Seed body (empty when not provided).
788    pub body: String,
789    /// Whether the draft toggle starts on.
790    pub draft: bool,
791    /// The model used for AI auto-fill (resolved from `--model`/config).
792    pub model: crate::agent::AgentModel,
793    /// The effort used for AI auto-fill (resolved from `--effort`/config).
794    pub effort: crate::agent::Effort,
795}
796
797/// Runs the TUI directly in PR-compose mode (`wt pr open`). Seeds the form from
798/// `seed`, optionally drafting the title/body with the code agent first
799/// (`draft_ai`), then lets the user edit and submit. Returns the submit outcome,
800/// or `None` if the user cancels (Esc/quit). The compose form uses its own event
801/// loop so it can carry the gathered `ctx`, the resolved `action`, and the
802/// resulting outcome.
803pub(crate) fn run_pr_compose(
804    cx: &mut Cx,
805    session: &Session,
806    ctx: sendit::PrContext,
807    action: sendit::PrAction,
808    seed: ComposeSeed,
809    draft_ai: bool,
810) -> Result<Option<(sendit::PrOutcome, sendit::PrSpec)>> {
811    let git = cx.git.clone();
812    let mut app = build_app(cx, session, git.as_ref())?;
813    let action_label = match action {
814        sendit::PrAction::Create => "create".to_string(),
815        sendit::PrAction::Update { number } => format!("update #{number}"),
816    };
817    app.mode = Mode::PrCompose(PrComposeState {
818        title: seed.title,
819        body: seed.body,
820        draft: seed.draft,
821        branch: ctx.branch.clone(),
822        trunk: ctx.trunk.clone(),
823        action_label,
824        model: seed.model,
825        effort: seed.effort,
826        ..Default::default()
827    });
828
829    let initial = if draft_ai {
830        Effect::DraftPrAi
831    } else {
832        Effect::None
833    };
834    let mut outcome: Option<(sendit::PrOutcome, sendit::PrSpec)> = None;
835    let runtime = tokio::runtime::Runtime::new()?;
836    runtime.block_on(run_compose_loop(
837        cx,
838        session,
839        &mut app,
840        &ctx,
841        action,
842        initial,
843        &mut outcome,
844    ))?;
845
846    if app.too_small {
847        cx.err.line("terminal too small (need ≥5 rows)")?;
848        return Err(Error::operation("terminal too small"));
849    }
850    Ok(outcome)
851}
852
853/// The compose-mode event loop (terminal shell; not unit-tested). Mirrors
854/// [`run_loop`] but carries the PR `ctx`/`action`/`outcome` the compose effects
855/// need; the `do_*` helpers it delegates to are tested.
856async fn run_compose_loop(
857    cx: &mut Cx,
858    session: &Session,
859    app: &mut App,
860    ctx: &sendit::PrContext,
861    action: sendit::PrAction,
862    initial: Effect,
863    outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
864) -> Result<()> {
865    install_panic_hook();
866    let mut tui = Tui::enter(app.mouse)?;
867    app.size = tui.size();
868    if app.size.1 < crate::tui::app::MIN_HEIGHT {
869        app.too_small = true;
870        return Ok(());
871    }
872    tui.draw(app)?;
873
874    if initial != Effect::None
875        && compose_dispatch(cx, session, app, &mut tui, ctx, action, initial, outcome)?
876    {
877        return Ok(());
878    }
879    tui.draw(app)?;
880
881    let mut events = EventStream::new();
882    while let Some(maybe) = events.next().await {
883        let Ok(event) = maybe else { continue };
884        let effect = app.handle_event(event);
885        if compose_dispatch(cx, session, app, &mut tui, ctx, action, effect, outcome)? {
886            break;
887        }
888        tui.draw(app)?;
889    }
890    Ok(())
891}
892
893/// Executes a compose-mode effect, returning `true` when the loop should exit
894/// (a successful submit, a quit, or a cancel — the user leaving compose mode via
895/// Esc). Terminal shell; the `do_*` helpers it calls are tested.
896#[allow(clippy::too_many_arguments)]
897fn compose_dispatch(
898    cx: &mut Cx,
899    session: &Session,
900    app: &mut App,
901    tui: &mut Tui,
902    ctx: &sendit::PrContext,
903    action: sendit::PrAction,
904    effect: Effect,
905    outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
906) -> Result<bool> {
907    match effect {
908        Effect::Quit => Ok(true),
909        Effect::TooSmall => {
910            app.too_small = true;
911            Ok(true)
912        }
913        Effect::DraftPrAi => {
914            tui.suspend()?;
915            do_draft_pr_ai(cx, session, app, ctx);
916            tui.resume()?;
917            Ok(false)
918        }
919        Effect::SubmitPr { title, body, draft } => {
920            tui.suspend()?;
921            let done = do_submit_pr(cx, session, app, ctx, action, title, body, draft, outcome);
922            tui.resume()?;
923            Ok(done)
924        }
925        // Any other effect (typically `None`): exit only if the user left compose
926        // mode (Esc sets the mode back to List), which we treat as a cancel.
927        _ => Ok(!matches!(app.mode, Mode::PrCompose(_))),
928    }
929}
930
931/// Launches the editor in the foreground (terminal already suspended).
932fn run_editor(cx: &Cx, session: &Session, path: &Path) {
933    let Ok(editor) = resolve_editor(session.config.editor.as_deref(), &cx.env) else {
934        return;
935    };
936    let argv = editor_argv(&editor);
937    if let Some((program, rest)) = argv.split_first() {
938        let _ = std::process::Command::new(program)
939            .args(rest)
940            .arg(path)
941            .status();
942    }
943}
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948    use crate::testutil::{FakeGh, TestRepo, test_cx};
949    use crate::tui::app::Mode;
950    use std::sync::Arc as StdArc;
951
952    /// Builds a session + app over a real repo for testing the `do_*` helpers.
953    fn setup(repo: &TestRepo) -> (crate::testutil::TestCx, Session, App) {
954        let t = test_cx(&[], repo.root().to_str().unwrap());
955        let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
956        let worktrees = build_rows(&session.repo, &crate::git::RealGit).unwrap();
957        let app = App::new(worktrees, app_config(&session.config, true), (100, 30));
958        (t, session, app)
959    }
960
961    #[test]
962    fn app_config_maps_settings() {
963        let config = Config {
964            ui_nerd_fonts: true,
965            ui_mouse: false,
966            ..Config::default()
967        };
968        let cfg = app_config(&config, false);
969        assert!(cfg.nerd_fonts);
970        assert!(!cfg.mouse);
971        assert!(!cfg.color);
972        assert!(app_config(&config, true).color);
973    }
974
975    #[test]
976    fn do_create_adds_a_worktree_and_refreshes() {
977        let repo = TestRepo::init();
978        let (mut t, session, mut app) = setup(&repo);
979        app.mode = Mode::Create(Default::default());
980        do_create(&mut t.cx, &session, &mut app, "feature/new".into(), None);
981        assert_eq!(app.mode, Mode::List);
982        assert!(
983            app.worktrees
984                .iter()
985                .any(|w| w.branch.as_deref() == Some("feature/new"))
986        );
987        assert!(app.status_message.as_deref().unwrap().contains("created"));
988        // The newly created worktree is focused, not the prior selection (issue
989        // #52). `main` is the initial `is_current` row, so a non-focused create
990        // would leave it selected.
991        assert_eq!(
992            app.selected_worktree().unwrap().branch.as_deref(),
993            Some("feature/new")
994        );
995    }
996
997    #[test]
998    fn do_create_error_shows_in_modal() {
999        let repo = TestRepo::init();
1000        let (mut t, session, mut app) = setup(&repo);
1001        app.mode = Mode::Create(Default::default());
1002        // A base ref that does not exist -> error surfaced in the modal.
1003        do_create(
1004            &mut t.cx,
1005            &session,
1006            &mut app,
1007            "x".into(),
1008            Some("nope-ref".into()),
1009        );
1010        if let Mode::Create(state) = &app.mode {
1011            assert!(state.error.is_some());
1012        } else {
1013            panic!("expected create mode with error");
1014        }
1015    }
1016
1017    /// Leaves local `main` one commit behind `origin/main` (upstream configured,
1018    /// no fetchable remote so the check's fetch is skipped). Returns origin's tip.
1019    fn main_behind_origin(repo: &TestRepo) -> String {
1020        let c1 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1021        repo.write("u.txt", "1\n");
1022        repo.commit_all("ahead on origin");
1023        let c2 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1024        repo.git(&["update-ref", "refs/remotes/origin/main", &c2]);
1025        repo.git(&["reset", "-q", "--hard", &c1]);
1026        repo.git(&["config", "branch.main.remote", "origin"]);
1027        repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
1028        c2
1029    }
1030
1031    /// Creates a worktree-less `feat` branch one commit behind `origin/feat`
1032    /// (upstream configured, no fetchable remote so the sync's fetch is skipped),
1033    /// leaving the repo on `main`. Returns origin's tip.
1034    fn feat_branch_behind_origin(repo: &TestRepo) -> String {
1035        let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1036        repo.git(&["checkout", "-q", "-b", "feat"]);
1037        repo.write("u.txt", "1\n");
1038        repo.commit_all("ahead on origin/feat");
1039        let tip = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1040        repo.git(&["update-ref", "refs/remotes/origin/feat", &tip]);
1041        repo.git(&["checkout", "-q", "main"]);
1042        // Rewind local feat behind origin/feat; it is not checked out.
1043        repo.git(&["branch", "-f", "feat", &base]);
1044        repo.git(&["config", "branch.feat.remote", "origin"]);
1045        repo.git(&["config", "branch.feat.merge", "refs/heads/feat"]);
1046        tip
1047    }
1048
1049    #[test]
1050    fn do_create_stale_base_opens_confirm_modal() {
1051        // The default base (main) is behind origin/main, so the create pauses at
1052        // the stale-base confirm modal instead of creating (issue #56).
1053        let repo = TestRepo::init();
1054        main_behind_origin(&repo);
1055        let (mut t, session, mut app) = setup(&repo);
1056        app.mode = Mode::Create(Default::default());
1057        do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
1058        match &app.mode {
1059            Mode::ConfirmStaleBase(s) => {
1060                assert_eq!(s.branch, "feature");
1061                assert_eq!(s.behind, 1);
1062                assert!(s.can_fast_forward);
1063            }
1064            other => panic!("expected ConfirmStaleBase, got {other:?}"),
1065        }
1066        // Nothing was created.
1067        assert!(
1068            !app.worktrees
1069                .iter()
1070                .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
1071        );
1072    }
1073
1074    #[test]
1075    fn do_create_with_submodules_opens_confirm_modal() {
1076        // A new worktree with uninitialized submodules and the default `prompt`
1077        // policy pauses at the submodule confirm modal (issue #50).
1078        let repo = TestRepo::init();
1079        repo.add_submodule("libs/sub");
1080        let (mut t, session, mut app) = setup(&repo);
1081        app.mode = Mode::Create(Default::default());
1082        do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
1083        match &app.mode {
1084            Mode::ConfirmInitSubmodules(s) => {
1085                assert_eq!(s.branch, "feature");
1086                assert_eq!(s.count, 1);
1087                assert!(s.dir.exists());
1088            }
1089            other => panic!("expected ConfirmInitSubmodules, got {other:?}"),
1090        }
1091        // The worktree was created and is visible behind the modal.
1092        assert!(
1093            app.worktrees
1094                .iter()
1095                .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
1096        );
1097    }
1098
1099    #[test]
1100    fn apply_init_submodules_reports_success_and_refreshes() {
1101        let repo = TestRepo::init();
1102        let (t, session, mut app) = setup(&repo);
1103        // The confirm/loop already returned to the list before the job ran; the
1104        // init job runs concurrently and only reports its result on completion.
1105        apply_init_submodules(&t.cx, &mut app, 2, Ok(()), &session.primary_root);
1106        assert_eq!(app.mode, Mode::List);
1107        assert!(
1108            app.status_message
1109                .as_deref()
1110                .unwrap()
1111                .contains("initialized 2 submodule")
1112        );
1113    }
1114
1115    #[test]
1116    fn apply_init_submodules_error_shows_in_status() {
1117        let repo = TestRepo::init();
1118        let (t, session, mut app) = setup(&repo);
1119        apply_init_submodules(
1120            &t.cx,
1121            &mut app,
1122            1,
1123            Err("boom".into()),
1124            &session.primary_root,
1125        );
1126        assert_eq!(app.mode, Mode::List);
1127        let msg = app.status_message.as_deref().unwrap();
1128        assert!(msg.contains("failed to initialize submodules"));
1129        assert!(msg.contains("boom"));
1130    }
1131
1132    #[test]
1133    fn create_update_decision_fast_forwards_then_creates() {
1134        // Re-issuing the create with the Update decision (as the modal does)
1135        // fast-forwards the base and forks the new branch from it (issue #56).
1136        let repo = TestRepo::init();
1137        let c2 = main_behind_origin(&repo);
1138        let (t, session, mut app) = setup(&repo);
1139        let outcome = run_job(
1140            JobCx::capture(&t.cx),
1141            Job::Create {
1142                branch: "feature".into(),
1143                base: None,
1144                decision: Some(CreateDecision::Update),
1145            },
1146        );
1147        apply_outcome(&t.cx, &session, &mut app, outcome);
1148        assert_eq!(app.mode, Mode::List);
1149        assert_eq!(repo.git(&["rev-parse", "refs/heads/main"]).trim(), c2);
1150        assert_eq!(repo.git(&["rev-parse", "refs/heads/feature"]).trim(), c2);
1151    }
1152
1153    #[test]
1154    fn do_remove_removes_selected() {
1155        let repo = TestRepo::init();
1156        repo.add_worktree("feature/x", "../wt-x");
1157        // Give it an upstream so it is not "unpushed" (remove uses force anyway).
1158        let (mut t, session, mut app) = setup(&repo);
1159        let index = app
1160            .worktrees
1161            .iter()
1162            .position(|w| w.branch.as_deref() == Some("feature/x"))
1163            .unwrap();
1164        do_remove(&mut t.cx, &session, &mut app, index);
1165        // The worktree is gone; the branch itself survives (not wt-created) and now
1166        // shows as a worktree-less branch row, so assert on the worktree row only.
1167        assert!(
1168            !app.worktrees
1169                .iter()
1170                .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature/x"))
1171        );
1172        assert!(
1173            app.worktrees
1174                .iter()
1175                .any(|w| !w.has_worktree && w.branch.as_deref() == Some("feature/x"))
1176        );
1177    }
1178
1179    #[test]
1180    fn do_delete_branch_removes_branch_row_and_refreshes() {
1181        // A worktree-less branch row (issue #53): deleting it removes the local
1182        // branch and refreshes so the row disappears.
1183        let repo = TestRepo::init();
1184        repo.git(&["branch", "topic"]); // a merged branch row, no worktree
1185        let (mut t, session, mut app) = setup(&repo);
1186        assert!(
1187            app.worktrees
1188                .iter()
1189                .any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
1190        );
1191        do_delete_branch(&mut t.cx, &session, &mut app, "topic".into(), false);
1192        assert_eq!(app.mode, Mode::List);
1193        assert!(
1194            !app.worktrees
1195                .iter()
1196                .any(|w| w.branch.as_deref() == Some("topic"))
1197        );
1198        assert!(
1199            app.status_message
1200                .as_deref()
1201                .unwrap()
1202                .contains("deleted branch topic")
1203        );
1204    }
1205
1206    #[test]
1207    fn do_delete_branch_unmerged_reprompts_then_force_deletes() {
1208        // Deleting an unmerged branch row is refused by the safe `-d` and re-opens
1209        // the confirm in force mode (issue #53); a forced delete then removes it.
1210        let repo = TestRepo::init();
1211        // An unmerged branch with no worktree: branch off in a temp worktree,
1212        // commit, then drop the worktree but keep the branch.
1213        repo.add_worktree("unmerged", "../wt-unmerged");
1214        let wt = repo.root().parent().unwrap().join("wt-unmerged");
1215        std::fs::write(wt.join("c.txt"), "x\n").unwrap();
1216        let dir = wt.to_string_lossy().into_owned();
1217        repo.git(&["-C", &dir, "add", "-A"]);
1218        repo.git(&["-C", &dir, "commit", "-q", "-m", "unmerged change"]);
1219        repo.git(&["worktree", "remove", "--force", &dir]);
1220        let (mut t, session, mut app) = setup(&repo);
1221        // A safe delete is refused -> re-prompt in force mode.
1222        do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), false);
1223        assert!(matches!(
1224            app.mode,
1225            Mode::ConfirmDeleteBranch { force: true, .. }
1226        ));
1227        assert!(
1228            app.worktrees
1229                .iter()
1230                .any(|w| w.branch.as_deref() == Some("unmerged"))
1231        );
1232        // The forced delete removes it. The re-prompt's `y` returns to the list
1233        // before the job runs (as the real key handler does), so apply sees List.
1234        app.mode = Mode::List;
1235        do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), true);
1236        assert_eq!(app.mode, Mode::List);
1237        assert!(
1238            !app.worktrees
1239                .iter()
1240                .any(|w| w.branch.as_deref() == Some("unmerged"))
1241        );
1242    }
1243
1244    #[test]
1245    fn do_materialize_branch_creates_worktree_and_stays_focused() {
1246        // A worktree-less branch (issue #47): materializing it creates a worktree
1247        // and refreshes so the row becomes a real worktree. Post-overhaul it stays
1248        // in the TUI (no `chosen`) and focuses the new worktree row so the user can
1249        // switch into it with Enter when ready (issue #46 overhaul).
1250        let repo = TestRepo::init();
1251        repo.git(&["branch", "topic"]);
1252        let (mut t, session, mut app) = setup(&repo);
1253        // Precondition: `topic` starts as a branch row with no worktree.
1254        assert!(
1255            app.worktrees
1256                .iter()
1257                .any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
1258        );
1259        do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
1260        assert_eq!(app.mode, Mode::List);
1261        assert!(app.chosen.is_none());
1262        // `topic` is now a real worktree row, not a branch row, and is focused.
1263        assert!(
1264            app.worktrees
1265                .iter()
1266                .any(|w| w.has_worktree && w.branch.as_deref() == Some("topic"))
1267        );
1268        let focused = app.selected_worktree().unwrap();
1269        assert!(focused.has_worktree && focused.branch.as_deref() == Some("topic"));
1270        assert!(
1271            app.status_message
1272                .as_deref()
1273                .unwrap()
1274                .contains("created topic")
1275        );
1276    }
1277
1278    #[test]
1279    fn do_materialize_branch_error_shows_in_status() {
1280        // Creating a worktree for a branch that is already checked out elsewhere
1281        // fails; the error surfaces in the status bar and nothing is chosen.
1282        let repo = TestRepo::init();
1283        repo.add_worktree("dup", "../manual-dup");
1284        let (mut t, session, mut app) = setup(&repo);
1285        do_materialize_branch(&mut t.cx, &session, &mut app, "dup".into());
1286        assert!(app.chosen.is_none());
1287        assert_eq!(app.status_kind, StatusKind::Error);
1288        assert!(app.status_message.is_some());
1289    }
1290
1291    #[test]
1292    fn do_materialize_branch_queues_background_submodule_init() {
1293        // Materializing a worktree that has uninitialized submodules queues a
1294        // background init job on the new row rather than blocking the switch
1295        // (issue #46 overhaul).
1296        let repo = TestRepo::init();
1297        repo.add_submodule("libs/sub");
1298        repo.git(&["branch", "topic"]); // branches from HEAD, which has the submodule
1299        let (mut t, session, mut app) = setup(&repo);
1300        do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
1301        assert!(app.chosen.is_none());
1302        // A follow-up submodule-init job is queued for the loop to spawn.
1303        let queued = app.take_pending_jobs();
1304        assert!(
1305            queued
1306                .iter()
1307                .any(|e| matches!(e, Effect::InitSubmodules { .. })),
1308            "expected a queued InitSubmodules job, got {queued:?}"
1309        );
1310    }
1311
1312    #[test]
1313    fn apply_create_auto_policy_queues_submodule_job() {
1314        // Under the `always` policy a created worktree with uninitialized
1315        // submodules starts a background init job (no modal) and returns to the
1316        // list (issue #46 overhaul).
1317        let repo = TestRepo::init();
1318        repo.add_submodule("libs/sub");
1319        let (t, session, mut app) = setup(&repo);
1320        apply_create(
1321            &t.cx,
1322            &mut app,
1323            "feature",
1324            None,
1325            CreateOutcome::CreatedNeedsSubmodules {
1326                dir: session.primary_root.clone(),
1327                count: 1,
1328                auto: true,
1329            },
1330            &session.primary_root,
1331        );
1332        assert_eq!(app.mode, Mode::List);
1333        let queued = app.take_pending_jobs();
1334        assert!(
1335            queued
1336                .iter()
1337                .any(|e| matches!(e, Effect::InitSubmodules { .. }))
1338        );
1339    }
1340
1341    #[test]
1342    fn apply_create_while_exit_blocked_queues_submodule_job_and_keeps_waiting() {
1343        // The premature-exit fix end to end: a create job finishes while the exit
1344        // is blocked and, under the `prompt` policy, its submodule-init follow-up
1345        // must be *queued* (not opened as a modal over the overlay) so the loop
1346        // keeps waiting for it to drain rather than switching prematurely
1347        // (issue #46 overhaul). This depends on `ExitBlocked` not being an idle
1348        // mode in `may_apply_mode`.
1349        use crate::tui::app::{ExitBlockedState, ExitIntent, JobKey};
1350        let repo = TestRepo::init();
1351        repo.add_submodule("libs/sub");
1352        let (t, session, mut app) = setup(&repo);
1353        // Stand in for the loop state after the create job finished while blocked:
1354        // the switch is pending, and the (now-finished) create job is cleared.
1355        app.mode = Mode::ExitBlocked(ExitBlockedState {
1356            intent: ExitIntent::Switch(session.primary_root.clone()),
1357        });
1358        apply_create(
1359            &t.cx,
1360            &mut app,
1361            "feature",
1362            None,
1363            CreateOutcome::CreatedNeedsSubmodules {
1364                dir: session.primary_root.clone(),
1365                count: 1,
1366                auto: false, // `prompt` policy — would normally open the modal
1367            },
1368            &session.primary_root,
1369        );
1370        // The overlay is preserved (no ConfirmInitSubmodules clobber)...
1371        assert!(matches!(app.mode, Mode::ExitBlocked(_)));
1372        // ...and the init was queued as a follow-up.
1373        let queued = app.take_pending_jobs();
1374        assert!(
1375            queued
1376                .iter()
1377                .any(|e| matches!(e, Effect::InitSubmodules { .. }))
1378        );
1379        // Simulate the loop spawning that follow-up: while it runs, the exit keeps
1380        // waiting; once it drains, the held switch commits.
1381        let key = JobKey::Path(session.primary_root.clone());
1382        app.begin_job(key.clone(), "Initializing 1 submodule(s)");
1383        assert!(!app.exit_now());
1384        app.finish_job(&key);
1385        assert!(app.exit_now());
1386        assert_eq!(app.chosen, Some(session.primary_root.clone()));
1387    }
1388
1389    #[test]
1390    fn do_fetch_prs_populates_picker() {
1391        let repo = TestRepo::init();
1392        let (mut t, session, mut app) = setup(&repo);
1393        t.cx.gh = StdArc::new(FakeGh::with_list(vec![crate::gh::PrSummary {
1394            number: 5,
1395            title: "T".into(),
1396            author: crate::gh::Author {
1397                login: "alice".into(),
1398            },
1399            state: "OPEN".into(),
1400            is_draft: false,
1401            head_ref_name: "h".into(),
1402            created_at: String::new(),
1403        }]));
1404        app.mode = Mode::PrPicker(Default::default());
1405        do_fetch_prs(&t.cx, &session, &mut app);
1406        if let Mode::PrPicker(state) = &app.mode {
1407            assert!(!state.loading);
1408            assert_eq!(state.prs.len(), 1);
1409            assert_eq!(state.prs[0].number, 5);
1410        } else {
1411            panic!("expected pr picker");
1412        }
1413    }
1414
1415    #[test]
1416    fn do_fetch_prs_surfaces_gh_error() {
1417        let repo = TestRepo::init();
1418        let (mut t, session, mut app) = setup(&repo);
1419        t.cx.gh = StdArc::new(FakeGh::unavailable());
1420        app.mode = Mode::PrPicker(Default::default());
1421        do_fetch_prs(&t.cx, &session, &mut app);
1422        if let Mode::PrPicker(state) = &app.mode {
1423            assert!(state.error.is_some());
1424        } else {
1425            panic!("expected pr picker");
1426        }
1427    }
1428
1429    #[test]
1430    fn do_refresh_reloads_worktrees() {
1431        let repo = TestRepo::init();
1432        let (t, session, mut app) = setup(&repo);
1433        // Create a worktree out-of-band, then refresh.
1434        repo.add_worktree("added", "../wt-added");
1435        do_refresh(&t.cx, &mut app, &session.primary_root);
1436        assert!(
1437            app.worktrees
1438                .iter()
1439                .any(|w| w.branch.as_deref() == Some("added"))
1440        );
1441    }
1442
1443    /// Sets up a fetchable `pull/<n>/head` ref served by an `origin` remote
1444    /// pointing at the repo itself, so `do_checkout_pr` can fetch a real head.
1445    fn repo_with_pr(number: u64) -> TestRepo {
1446        let repo = TestRepo::init();
1447        repo.write("pr.txt", "from pr\n");
1448        repo.commit_all("pr commit");
1449        let pr_oid = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1450        repo.git(&["update-ref", &format!("refs/pull/{number}/head"), &pr_oid]);
1451        repo.git(&["reset", "-q", "--hard", "HEAD~1"]);
1452        repo.git(&["remote", "add", "origin", repo.root().to_str().unwrap()]);
1453        repo
1454    }
1455
1456    fn pr_view(number: u64, head: &str, base: &str) -> crate::gh::PrView {
1457        crate::gh::PrView {
1458            number,
1459            title: "Add login".into(),
1460            state: "OPEN".into(),
1461            is_draft: false,
1462            head_ref_name: head.into(),
1463            base_ref_name: base.into(),
1464            url: format!("https://github.com/o/r/pull/{number}"),
1465        }
1466    }
1467
1468    #[test]
1469    fn do_checkout_pr_stays_in_list_and_focuses_new_worktree() {
1470        // Checking out a PR now always stays in the TUI (issue #46 overhaul): the
1471        // new worktree is checked out, the list refreshes and focuses it, and
1472        // `chosen` stays unset so the user switches into it with Enter when ready.
1473        let repo = repo_with_pr(123);
1474        let (mut t, session, mut app) = setup(&repo);
1475        t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(123, "pr-feature", "main")));
1476        app.mode = Mode::PrPicker(Default::default());
1477        do_checkout_pr(&mut t.cx, &session, &mut app, 123);
1478        assert!(app.chosen.is_none());
1479        assert_eq!(app.mode, Mode::List);
1480        // The picker closed to the list, focused on the new worktree row.
1481        assert_eq!(
1482            app.selected_worktree().unwrap().branch.as_deref(),
1483            Some("pr-feature")
1484        );
1485    }
1486
1487    #[test]
1488    fn do_checkout_pr_stays_in_list_without_exit_flag() {
1489        // The in-TUI `p`-key flow: checkout returns to the list and refreshes,
1490        // leaving `chosen` unset so the TUI keeps running.
1491        let repo = repo_with_pr(55);
1492        let (mut t, session, mut app) = setup(&repo);
1493        t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(55, "pr-feature", "main")));
1494        app.mode = Mode::PrPicker(Default::default());
1495        do_checkout_pr(&mut t.cx, &session, &mut app, 55);
1496        assert!(app.chosen.is_none());
1497        assert_eq!(app.mode, Mode::List);
1498        assert!(
1499            app.status_message
1500                .as_deref()
1501                .unwrap()
1502                .contains("checked out")
1503        );
1504        assert!(
1505            app.worktrees
1506                .iter()
1507                .any(|w| w.branch.as_deref() == Some("pr-feature"))
1508        );
1509    }
1510
1511    #[test]
1512    fn do_checkout_branch_switches_and_stays_in_list() {
1513        let repo = TestRepo::init();
1514        repo.git(&["branch", "topic"]);
1515        let (mut t, session, mut app) = setup(&repo);
1516        app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
1517            worktree_index: 0,
1518            ..Default::default()
1519        });
1520        do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
1521        // Stays in the list (no `cd`), refreshed, with a success status.
1522        assert_eq!(app.mode, Mode::List);
1523        assert!(app.chosen.is_none());
1524        assert!(
1525            app.status_message
1526                .as_deref()
1527                .unwrap()
1528                .contains("checked out topic")
1529        );
1530        // The (primary) worktree now has `topic` checked out.
1531        assert_eq!(
1532            repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(),
1533            "topic"
1534        );
1535    }
1536
1537    #[test]
1538    fn do_checkout_branch_dirty_shows_error_in_picker() {
1539        let repo = TestRepo::init();
1540        repo.git(&["branch", "topic"]);
1541        repo.write("README.md", "dirty\n"); // a tracked modification
1542        let (mut t, session, mut app) = setup(&repo);
1543        app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
1544            worktree_index: 0,
1545            submitting: true,
1546            ..Default::default()
1547        });
1548        do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
1549        if let Mode::Checkout(state) = &app.mode {
1550            assert!(state.error.as_deref().unwrap().contains("uncommitted"));
1551            assert!(!state.submitting);
1552        } else {
1553            panic!("expected checkout picker with error");
1554        }
1555    }
1556
1557    #[test]
1558    fn do_sync_fast_forwards_and_refreshes() {
1559        // The primary worktree's `main` is behind `origin/main` (no fetchable
1560        // remote, so the fetch is skipped): sync fast-forwards it in place.
1561        let repo = TestRepo::init();
1562        let c2 = main_behind_origin(&repo);
1563        let (mut t, session, mut app) = setup(&repo);
1564        do_sync(&mut t.cx, &session, &mut app, 0);
1565        assert_eq!(app.mode, Mode::List);
1566        assert_eq!(app.status_kind, StatusKind::Success);
1567        assert!(
1568            app.status_message
1569                .as_deref()
1570                .unwrap()
1571                .contains("fast-forwarded")
1572        );
1573        assert_eq!(repo.git(&["rev-parse", "main"]).trim(), c2);
1574    }
1575
1576    #[test]
1577    fn do_sync_branch_row_fast_forwards() {
1578        // A worktree-less `feat` branch row that is behind `origin/feat` syncs by
1579        // moving its ref (no checkout involved) — issue #47/#63.
1580        let repo = TestRepo::init();
1581        let tip = feat_branch_behind_origin(&repo);
1582        let (mut t, session, mut app) = setup(&repo);
1583        let index = app
1584            .worktrees
1585            .iter()
1586            .position(|w| w.branch.as_deref() == Some("feat") && !w.has_worktree)
1587            .unwrap();
1588        do_sync(&mut t.cx, &session, &mut app, index);
1589        assert_eq!(app.mode, Mode::List);
1590        assert_eq!(app.status_kind, StatusKind::Success);
1591        assert!(
1592            app.status_message
1593                .as_deref()
1594                .unwrap()
1595                .contains("fast-forwarded")
1596        );
1597        assert_eq!(repo.git(&["rev-parse", "feat"]).trim(), tip);
1598    }
1599
1600    #[test]
1601    fn do_sync_no_upstream_shows_status() {
1602        let repo = TestRepo::init();
1603        let (mut t, session, mut app) = setup(&repo);
1604        do_sync(&mut t.cx, &session, &mut app, 0); // `main`, no upstream
1605        assert_eq!(app.mode, Mode::List);
1606        assert!(
1607            app.status_message
1608                .as_deref()
1609                .unwrap()
1610                .contains("no upstream")
1611        );
1612    }
1613
1614    #[test]
1615    fn do_sync_dirty_shows_error_status() {
1616        let repo = TestRepo::init();
1617        main_behind_origin(&repo);
1618        repo.write("README.md", "dirty\n"); // blocks the fast-forward
1619        let (mut t, session, mut app) = setup(&repo);
1620        do_sync(&mut t.cx, &session, &mut app, 0);
1621        assert_eq!(app.status_kind, StatusKind::Error);
1622        assert!(app.status_message.as_deref().unwrap().contains("dirty"));
1623    }
1624
1625    #[test]
1626    fn do_sync_error_shows_in_status() {
1627        // A worktree whose directory is gone makes the sync core error out; the
1628        // message surfaces in the status bar.
1629        let repo = TestRepo::init();
1630        repo.add_worktree("feat", "../wt-feat");
1631        let (mut t, session, mut app) = setup(&repo);
1632        let index = app
1633            .worktrees
1634            .iter()
1635            .position(|w| w.branch.as_deref() == Some("feat"))
1636            .unwrap();
1637        std::fs::remove_dir_all(repo.root().parent().unwrap().join("wt-feat")).unwrap();
1638        do_sync(&mut t.cx, &session, &mut app, index);
1639        assert_eq!(app.status_kind, StatusKind::Error);
1640        assert!(app.status_message.is_some());
1641    }
1642
1643    fn sendit_ctx(branch: &str, trunk: &str, has_upstream: bool) -> sendit::PrContext {
1644        sendit::PrContext {
1645            branch: branch.into(),
1646            trunk: trunk.into(),
1647            merge_base: "abc".into(),
1648            has_upstream,
1649            commits_ahead: 1,
1650            commit_log: vec![],
1651            diffstat: sendit::DiffStat {
1652                files: 1,
1653                insertions: 1,
1654                deletions: 0,
1655                raw: String::new(),
1656            },
1657            existing_pr: None,
1658        }
1659    }
1660
1661    /// A feature repo (`feat`, one commit ahead of `main`) with a bare `origin`.
1662    fn feature_repo_with_remote() -> (TestRepo, TestRepo) {
1663        let bare = TestRepo::init_bare();
1664        let repo = TestRepo::init();
1665        repo.git(&["checkout", "-q", "-b", "feat"]);
1666        repo.write("f.txt", "x\n");
1667        repo.commit_all("feat work");
1668        repo.git(&["remote", "add", "origin", bare.root().to_str().unwrap()]);
1669        (repo, bare)
1670    }
1671
1672    #[test]
1673    fn do_draft_pr_ai_seeds_form() {
1674        let repo = TestRepo::init();
1675        let (mut t, session, mut app) = setup(&repo);
1676        t.cx.agent = StdArc::new(crate::testutil::FakeAgent::drafting(
1677            "Add login\n\nBody here",
1678        ));
1679        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1680        do_draft_pr_ai(
1681            &mut t.cx,
1682            &session,
1683            &mut app,
1684            &sendit_ctx("feat", "main", false),
1685        );
1686        if let Mode::PrCompose(s) = &app.mode {
1687            assert_eq!(s.title, "Add login");
1688            assert_eq!(s.body, "Body here");
1689            assert!(s.error.is_none());
1690        } else {
1691            panic!("expected compose mode");
1692        }
1693    }
1694
1695    #[test]
1696    fn do_draft_pr_ai_shows_error_when_unavailable() {
1697        let repo = TestRepo::init();
1698        // The default test agent is `FakeAgent::unavailable()`.
1699        let (mut t, session, mut app) = setup(&repo);
1700        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1701        do_draft_pr_ai(
1702            &mut t.cx,
1703            &session,
1704            &mut app,
1705            &sendit_ctx("feat", "main", false),
1706        );
1707        if let Mode::PrCompose(s) = &app.mode {
1708            assert!(s.error.is_some());
1709        } else {
1710            panic!("expected compose mode");
1711        }
1712    }
1713
1714    #[test]
1715    fn do_draft_pr_ai_uses_form_model_and_effort() {
1716        let repo = TestRepo::init();
1717        let (mut t, session, mut app) = setup(&repo);
1718        let agent = StdArc::new(crate::testutil::FakeAgent::drafting("T\n\nB"));
1719        t.cx.agent = agent.clone();
1720        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
1721            model: crate::agent::AgentModel::Opus,
1722            effort: crate::agent::Effort::High,
1723            ..Default::default()
1724        });
1725        do_draft_pr_ai(
1726            &mut t.cx,
1727            &session,
1728            &mut app,
1729            &sendit_ctx("feat", "main", false),
1730        );
1731        // The model/effort selected in the form were passed to the agent.
1732        assert_eq!(
1733            agent.last_opts(),
1734            Some(crate::agent::AgentOptions {
1735                model: crate::agent::AgentModel::Opus,
1736                effort: crate::agent::Effort::High,
1737            })
1738        );
1739    }
1740
1741    #[test]
1742    fn do_submit_pr_creates_records_and_exits() {
1743        let (repo, _bare) = feature_repo_with_remote();
1744        let (mut t, session, mut app) = setup(&repo);
1745        t.cx.gh = StdArc::new(FakeGh::sender("https://github.com/o/r/pull/77\n"));
1746        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1747        let mut outcome = None;
1748        let done = do_submit_pr(
1749            &mut t.cx,
1750            &session,
1751            &mut app,
1752            &sendit_ctx("feat", "main", false),
1753            sendit::PrAction::Create,
1754            "T".into(),
1755            "B".into(),
1756            false,
1757            &mut outcome,
1758        );
1759        assert!(done);
1760        assert_eq!(outcome.expect("outcome").0.number, Some(77));
1761        assert_eq!(
1762            repo.git(&["config", "--get", "wt.feat.prNumber"]).trim(),
1763            "77"
1764        );
1765    }
1766
1767    #[test]
1768    fn do_submit_pr_error_stays_in_form() {
1769        let (repo, _bare) = feature_repo_with_remote();
1770        let (mut t, session, mut app) = setup(&repo);
1771        t.cx.gh = StdArc::new(FakeGh::unavailable());
1772        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
1773            submitting: true,
1774            ..Default::default()
1775        });
1776        let mut outcome = None;
1777        let done = do_submit_pr(
1778            &mut t.cx,
1779            &session,
1780            &mut app,
1781            &sendit_ctx("feat", "main", false),
1782            sendit::PrAction::Create,
1783            "T".into(),
1784            "B".into(),
1785            false,
1786            &mut outcome,
1787        );
1788        assert!(!done);
1789        assert!(outcome.is_none());
1790        if let Mode::PrCompose(s) = &app.mode {
1791            assert!(s.error.is_some());
1792            assert!(!s.submitting);
1793        } else {
1794            panic!("expected compose mode");
1795        }
1796    }
1797
1798    #[test]
1799    fn do_checkout_pr_surfaces_gh_error_in_picker() {
1800        let repo = TestRepo::init();
1801        let (mut t, session, mut app) = setup(&repo);
1802        t.cx.gh = StdArc::new(FakeGh::unavailable());
1803        app.mode = Mode::PrPicker(Default::default());
1804        do_checkout_pr(&mut t.cx, &session, &mut app, 1);
1805        if let Mode::PrPicker(state) = &app.mode {
1806            assert!(state.error.is_some());
1807        } else {
1808            panic!("expected pr picker with error");
1809        }
1810        assert!(app.chosen.is_none());
1811    }
1812
1813    #[test]
1814    fn is_background_action_matches_mutations_only() {
1815        assert!(is_background_action(&Effect::Create {
1816            branch: "x".into(),
1817            base: None,
1818            decision: None,
1819        }));
1820        assert!(is_background_action(&Effect::Remove(0)));
1821        assert!(is_background_action(&Effect::MaterializeBranch {
1822            branch: "x".into()
1823        }));
1824        assert!(is_background_action(&Effect::CheckoutPr(1)));
1825        assert!(is_background_action(&Effect::CheckoutBranch {
1826            worktree_index: 0,
1827            branch: "x".into()
1828        }));
1829        assert!(is_background_action(&Effect::Sync { worktree_index: 0 }));
1830        // Non-mutating effects run inline, not on a background task.
1831        assert!(!is_background_action(&Effect::Refresh));
1832        assert!(!is_background_action(&Effect::FetchPrs));
1833        assert!(!is_background_action(&Effect::None));
1834        assert!(!is_background_action(&Effect::OpenEditor("/tmp".into())));
1835    }
1836
1837    #[test]
1838    fn resolve_job_sets_label_key_and_args() {
1839        use crate::tui::app::testutil::app as make_app;
1840        let a = make_app(&[("main", true), ("feat/x", false)]);
1841
1842        let (job, key, label) = resolve_job(
1843            &a,
1844            Effect::Create {
1845                branch: "feat/new".into(),
1846                base: Some("main".into()),
1847                decision: None,
1848            },
1849        )
1850        .unwrap();
1851        assert!(matches!(job, Job::Create { .. }));
1852        assert_eq!(label, "Creating feat/new");
1853        assert_eq!(key, JobKey::New("feat/new".into()));
1854
1855        // Remove resolves the query and keys on the target row's path.
1856        let (job, key, label) = resolve_job(&a, Effect::Remove(1)).unwrap();
1857        assert!(matches!(job, Job::Remove { query } if query == "feat/x"));
1858        assert_eq!(label, "Removing feat/x");
1859        assert_eq!(key, JobKey::Path(a.worktrees[1].path.clone()));
1860
1861        // CheckoutBranch resolves the worktree directory from the row.
1862        let (job, key, label) = resolve_job(
1863            &a,
1864            Effect::CheckoutBranch {
1865                worktree_index: 0,
1866                branch: "feat/x".into(),
1867            },
1868        )
1869        .unwrap();
1870        assert!(matches!(job, Job::CheckoutBranch { .. }));
1871        assert_eq!(label, "Checking out feat/x");
1872        assert_eq!(key, JobKey::Path(a.worktrees[0].path.clone()));
1873
1874        // Sync resolves the worktree directory and labels with the branch.
1875        let (job, _key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
1876        assert!(matches!(job, Job::Sync { .. }));
1877        assert_eq!(label, "Syncing feat/x");
1878
1879        let (job, key, label) = resolve_job(&a, Effect::CheckoutPr(7)).unwrap();
1880        assert!(matches!(job, Job::CheckoutPr { number } if number == 7));
1881        assert_eq!(label, "Checking out PR #7");
1882        assert_eq!(key, JobKey::New("PR #7".into()));
1883    }
1884
1885    #[test]
1886    fn resolve_job_returns_none_for_missing_row() {
1887        use crate::tui::app::testutil::app as make_app;
1888        let a = make_app(&[("main", true)]);
1889        assert!(resolve_job(&a, Effect::Remove(99)).is_none());
1890        assert!(
1891            resolve_job(
1892                &a,
1893                Effect::CheckoutBranch {
1894                    worktree_index: 99,
1895                    branch: "x".into()
1896                }
1897            )
1898            .is_none()
1899        );
1900        assert!(resolve_job(&a, Effect::Sync { worktree_index: 99 }).is_none());
1901        // A non-background effect also yields no job.
1902        assert!(resolve_job(&a, Effect::Refresh).is_none());
1903    }
1904
1905    #[test]
1906    fn spawn_job_refuses_conflicting_action_on_same_row() {
1907        // Two jobs on the same row must not race: the second is refused with a
1908        // status note and the registry keeps just the first (issue #46 overhaul).
1909        use crate::tui::app::testutil::app as make_app;
1910        let mut a = make_app(&[("main", true), ("feat/x", false)]);
1911        let key = JobKey::Path(a.worktrees[1].path.clone());
1912        a.begin_job(key.clone(), "Removing feat/x");
1913        // Simulate the guard the loop's `spawn_job` applies before spawning.
1914        let (_, resolved_key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
1915        assert_eq!(resolved_key, key);
1916        assert!(a.has_job(&resolved_key));
1917        a.set_status(format!("{label} — already in progress"), StatusKind::Info);
1918        assert_eq!(a.jobs.len(), 1);
1919        assert!(a.status_message.as_deref().unwrap().contains("in progress"));
1920    }
1921
1922    #[test]
1923    fn anchor_at_root_repoints_cwd_and_returns_opened_worktree() {
1924        // The TUI opened in a linked worktree: git operations should re-anchor at
1925        // the primary root, while the opened-in worktree path is returned so a
1926        // later removal can be detected (issue #68).
1927        let repo = TestRepo::init();
1928        repo.add_worktree("feature/x", "../wt-x");
1929        let linked = repo.root().parent().unwrap().join("wt-x");
1930        let mut t = test_cx(&[], linked.to_str().unwrap());
1931        let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
1932        let opened_in = anchor_at_root(&mut t.cx, &session);
1933        assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
1934        assert_eq!(canon(&opened_in), canon(&linked));
1935    }
1936
1937    #[test]
1938    fn removing_opened_in_worktree_keeps_operations_working() {
1939        // Issue #68: the TUI is opened inside a linked worktree which is then
1940        // removed during the session. Because git operations are re-anchored at
1941        // the root, the removal succeeds and a later session-open still works
1942        // (instead of failing with the worktree's directory gone), and on exit
1943        // the shell is steered back to the root with a friendly note.
1944        let repo = TestRepo::init();
1945        repo.add_worktree("feature/x", "../wt-x");
1946        let linked = repo.root().parent().unwrap().join("wt-x");
1947
1948        // Open as if the TUI launched inside the linked worktree.
1949        let mut t = test_cx(&[], linked.to_str().unwrap());
1950        let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
1951        let opened_in = anchor_at_root(&mut t.cx, &session);
1952        assert_eq!(canon(&opened_in), canon(&linked));
1953        assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
1954
1955        // Remove the worktree the TUI was opened in (the background-job path).
1956        run_remove_command(&mut t.cx, "feature/x").unwrap();
1957        assert!(!linked.exists());
1958
1959        // The session still opens: operations anchor at the surviving root.
1960        let again = open_session(&t.cx, &crate::git::RealGit).unwrap();
1961        assert_eq!(canon(&again.primary_root), canon(&session.primary_root));
1962
1963        // On graceful exit (no explicit switch), navigate back to the root.
1964        let nav = finish_exit(&mut t.cx, &opened_in, &session.primary_root, None).unwrap();
1965        assert_eq!(canon(&nav.unwrap()), canon(&session.primary_root));
1966        assert!(t.err.contents().contains("was removed"));
1967    }
1968
1969    #[test]
1970    fn finish_exit_honors_explicit_switch() {
1971        // An explicit switch into a directory that still exists is passed through
1972        // untouched, even if the opened-in directory is gone.
1973        let chosen = tempfile::tempdir().unwrap();
1974        let mut t = test_cx(&[], "/work");
1975        let out = finish_exit(
1976            &mut t.cx,
1977            Path::new("/deleted"),
1978            Path::new("/deleted-root"),
1979            Some(chosen.path().to_path_buf()),
1980        )
1981        .unwrap();
1982        assert_eq!(out.as_deref(), Some(chosen.path()));
1983        assert!(t.err.contents().is_empty());
1984    }
1985
1986    #[test]
1987    fn finish_exit_drops_chosen_that_was_removed() {
1988        // A blocked exit that switched into a worktree a *different* finished job
1989        // then removed must not land the shell in the deleted directory: the dead
1990        // `chosen` is dropped and it falls through to the normal recovery — here the
1991        // opened-in dir still exists, so stay put (issue #46 overhaul).
1992        let opened = tempfile::tempdir().unwrap();
1993        let gone_chosen = opened.path().join("wt-removed");
1994        let mut t = test_cx(&[], "/work");
1995        let out = finish_exit(&mut t.cx, opened.path(), opened.path(), Some(gone_chosen)).unwrap();
1996        assert_eq!(out, None);
1997        assert!(t.err.contents().is_empty());
1998    }
1999
2000    #[test]
2001    fn finish_exit_stays_put_when_opened_dir_survives() {
2002        // No switch and the opened-in directory still exists: nothing is printed
2003        // and the shell stays where it is.
2004        let dir = tempfile::tempdir().unwrap();
2005        let mut t = test_cx(&[], "/work");
2006        let out = finish_exit(&mut t.cx, dir.path(), dir.path(), None).unwrap();
2007        assert_eq!(out, None);
2008        assert!(t.err.contents().is_empty());
2009    }
2010
2011    #[test]
2012    fn finish_exit_returns_to_root_when_opened_dir_deleted() {
2013        // The opened-in worktree was removed during the session but the root
2014        // survives: navigate to the root and explain the move on stderr.
2015        let root = tempfile::tempdir().unwrap();
2016        let gone = root.path().join("wt-x");
2017        let mut t = test_cx(&[], "/work");
2018        let out = finish_exit(&mut t.cx, &gone, root.path(), None).unwrap();
2019        assert_eq!(out.as_deref(), Some(root.path()));
2020        let err = t.err.contents();
2021        assert!(err.contains("was removed"));
2022        assert!(err.contains(&root.path().display().to_string()));
2023    }
2024
2025    #[test]
2026    fn finish_exit_reports_when_root_also_gone() {
2027        // Both the opened-in worktree and the root are gone: explain the
2028        // situation and navigate nowhere.
2029        let scratch = tempfile::tempdir().unwrap();
2030        let gone = scratch.path().join("wt-x");
2031        let gone_root = scratch.path().join("root");
2032        let mut t = test_cx(&[], "/work");
2033        let out = finish_exit(&mut t.cx, &gone, &gone_root, None).unwrap();
2034        assert_eq!(out, None);
2035        assert!(t.err.contents().contains("no longer available"));
2036    }
2037
2038    /// Canonicalizes a path so comparisons ignore `/private` symlink prefixes on
2039    /// macOS temp dirs.
2040    fn canon(p: &Path) -> PathBuf {
2041        std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
2042    }
2043}