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, SubmoduleSeed};
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::{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        /// The worktree's path, to re-find its row for the locked re-prompt.
395        path: PathBuf,
396        /// Whether to override a lock (issue #126).
397        unlock: bool,
398    },
399    /// Delete the local branch `branch` of a worktree-less branch row (issue #53).
400    DeleteBranch {
401        /// The branch to delete.
402        branch: String,
403        /// Whether to force-delete an unmerged branch (`-D`).
404        force: bool,
405    },
406    /// Materialize a worktree for an existing worktree-less `branch`.
407    Materialize {
408        /// The branch to create a worktree for.
409        branch: String,
410    },
411    /// Check out the PR with the given number.
412    CheckoutPr {
413        /// The PR number.
414        number: u64,
415    },
416    /// Check out `branch` in the worktree at `worktree_dir` (in place).
417    CheckoutBranch {
418        /// The target worktree directory.
419        worktree_dir: PathBuf,
420        /// The branch to check out.
421        branch: String,
422    },
423    /// Sync (pull then push) the branch in the worktree at `worktree_dir`.
424    Sync {
425        /// The target worktree directory.
426        worktree_dir: PathBuf,
427        /// The branch label for the status text, resolved on the foreground.
428        label: String,
429    },
430    /// Sync a worktree-less `branch` by moving its ref from the repo root
431    /// (issue #47/#63).
432    SyncBranch {
433        /// The branch to sync.
434        branch: String,
435        /// The branch label for the status text (the branch name).
436        label: String,
437    },
438    /// Initialize the submodules in `dir` recursively (issue #50).
439    InitSubmodules {
440        /// The worktree directory whose submodules to initialize.
441        dir: PathBuf,
442        /// How many uninitialized submodules were detected (for the status text).
443        count: usize,
444    },
445}
446
447/// The result of a background [`Job`], carrying the minimum its `apply_*` needs.
448/// Errors are stringified inside the job (the typed `Error` is not `'static`-
449/// friendly to ferry across the task boundary, and the UI only needs the text).
450enum JobOutcome {
451    /// A finished create attempt. `branch`/`base` echo back so the stale-base
452    /// modal can re-issue the create with a decision (issue #56).
453    Create {
454        /// The branch that was (to be) created.
455        branch: String,
456        /// The base ref the create used (echoed for the modal's re-issue).
457        base: Option<String>,
458        /// Created, awaiting the stale-base confirmation, or failed.
459        outcome: CreateOutcome,
460    },
461    /// A finished remove.
462    Remove {
463        /// The query that was removed (for the status text).
464        query: String,
465        /// The worktree's path, to re-find its row for the locked re-prompt.
466        path: PathBuf,
467        /// Success, or why it failed (a lock re-prompts, issue #126).
468        result: std::result::Result<(), RemoveFailure>,
469    },
470    /// A finished branch deletion (issue #53).
471    DeleteBranch {
472        /// The branch that was deleted (for the status text).
473        branch: String,
474        /// Whether this was the force-delete attempt; gates the unmerged re-prompt.
475        force: bool,
476        /// Success, or the error message to surface.
477        result: std::result::Result<(), String>,
478    },
479    /// A finished materialize; the new worktree is located on apply.
480    Materialize {
481        /// The branch that was materialized.
482        branch: String,
483        /// Success, or the error message to surface.
484        result: std::result::Result<(), String>,
485    },
486    /// A finished PR checkout; the path is the new worktree to switch into.
487    CheckoutPr {
488        /// The PR number (for the status text).
489        number: u64,
490        /// The `(worktree path, already existed)` pair, or the error message.
491        result: std::result::Result<(PathBuf, bool), String>,
492    },
493    /// A finished in-place branch checkout.
494    CheckoutBranch {
495        /// The branch that was checked out.
496        branch: String,
497        /// The sync outcome, or the error message.
498        result: std::result::Result<commands::checkout::SyncOutcome, String>,
499    },
500    /// A finished sync (issue #63).
501    Sync {
502        /// The branch label for the status text.
503        label: String,
504        /// The sync outcome, or the error message.
505        result: std::result::Result<commands::sync::SyncOutcome, String>,
506    },
507    /// A finished submodule init (issue #50).
508    InitSubmodules {
509        /// How many submodules were initialized (for the status text).
510        count: usize,
511        /// Success, or the error message to surface.
512        result: std::result::Result<(), String>,
513    },
514}
515
516/// The result of a create job (issue #56). When the pre-flight finds the base
517/// behind its upstream it returns `NeedsStaleConfirm` *without* creating, so the
518/// loop can open the confirm modal; the modal then re-issues the create with a
519/// concrete decision.
520enum CreateOutcome {
521    /// The worktree was created.
522    Created,
523    /// The worktree was created and has uninitialized submodules (issue #50). The
524    /// TUI never initializes them inline (issue #46 overhaul): under the `always`
525    /// policy (`auto`) it starts a background init job on the new row; under the
526    /// `prompt` default it opens the confirm modal first.
527    CreatedNeedsSubmodules {
528        /// The new worktree directory whose submodules would be initialized.
529        dir: PathBuf,
530        /// How many uninitialized submodules were detected.
531        count: usize,
532        /// Whether to start the init automatically (`always` policy) rather than
533        /// prompting (`prompt` policy).
534        auto: bool,
535    },
536    /// The base is behind its upstream; the create paused for confirmation.
537    NeedsStaleConfirm {
538        /// How many commits the base is behind its upstream.
539        behind: u32,
540        /// The upstream display name, e.g. `origin/main`.
541        upstream_display: String,
542        /// Whether the base can be fast-forwarded (else "update" would fail).
543        can_fast_forward: bool,
544    },
545    /// The create failed; the message to surface.
546    Failed(String),
547}
548
549/// Resolves the worktree-identifying query for the row at `index` (its branch,
550/// or the directory name), mirroring the CLI's `remove` query.
551fn remove_query_of(app: &App, index: usize) -> Option<String> {
552    let worktree = app.worktrees.get(index)?;
553    Some(worktree.branch.clone().unwrap_or_else(|| {
554        worktree
555            .path
556            .file_name()
557            .map(|n| n.to_string_lossy().into_owned())
558            .unwrap_or_default()
559    }))
560}
561
562/// Resolves a background effect into a [`Job`] (owning its arguments), the
563/// [`JobKey`] that attaches its per-row spinner and guards against a conflicting
564/// action, and a display label. Pure — it does not touch the registry. Returns
565/// `None` when the effect's target row is gone or it is not a background action.
566fn resolve_job(app: &App, effect: Effect) -> Option<(Job, JobKey, String)> {
567    match effect {
568        Effect::Create {
569            branch,
570            base,
571            decision,
572        } => {
573            let label = format!("Creating {branch}");
574            let key = JobKey::New(branch.clone());
575            Some((
576                Job::Create {
577                    branch,
578                    base,
579                    decision,
580                },
581                key,
582                label,
583            ))
584        }
585        Effect::Remove { index, unlock } => {
586            let worktree = app.worktrees.get(index)?;
587            let path = worktree.path.clone();
588            let key = JobKey::Path(path.clone());
589            let query = remove_query_of(app, index)?;
590            let label = format!("Removing {query}");
591            Some((
592                Job::Remove {
593                    query,
594                    path,
595                    unlock,
596                },
597                key,
598                label,
599            ))
600        }
601        Effect::DeleteBranch { branch, force } => {
602            let label = format!("Deleting branch {branch}");
603            let key = JobKey::Branch(branch.clone());
604            Some((Job::DeleteBranch { branch, force }, key, label))
605        }
606        Effect::MaterializeBranch { branch } => {
607            let label = format!("Creating worktree for {branch}");
608            let key = JobKey::Branch(branch.clone());
609            Some((Job::Materialize { branch }, key, label))
610        }
611        Effect::CheckoutPr(number) => {
612            let label = format!("Checking out PR #{number}");
613            let key = JobKey::New(format!("PR #{number}"));
614            Some((Job::CheckoutPr { number }, key, label))
615        }
616        Effect::CheckoutBranch {
617            worktree_index,
618            branch,
619        } => {
620            let worktree_dir = app.worktrees.get(worktree_index)?.path.clone();
621            let label = format!("Checking out {branch}");
622            let key = JobKey::Path(worktree_dir.clone());
623            Some((
624                Job::CheckoutBranch {
625                    worktree_dir,
626                    branch,
627                },
628                key,
629                label,
630            ))
631        }
632        Effect::Sync { worktree_index } => {
633            let worktree = app.worktrees.get(worktree_index)?;
634            let label = worktree
635                .branch
636                .clone()
637                .unwrap_or_else(|| "worktree".to_string());
638            let display = format!("Syncing {label}");
639            let (job, key) = if worktree.has_worktree {
640                (
641                    Job::Sync {
642                        worktree_dir: worktree.path.clone(),
643                        label,
644                    },
645                    JobKey::Path(worktree.path.clone()),
646                )
647            } else {
648                // A branch row syncs by branch name from the repo root; a row with
649                // no branch (none exist today) has nothing to sync.
650                let branch = worktree.branch.clone()?;
651                let key = JobKey::Branch(branch.clone());
652                (Job::SyncBranch { branch, label }, key)
653            };
654            Some((job, key, display))
655        }
656        Effect::InitSubmodules { dir, count } => {
657            let label = format!("Initializing {count} submodule(s)");
658            let key = JobKey::Path(dir.clone());
659            Some((Job::InitSubmodules { dir, count }, key, label))
660        }
661        _ => None,
662    }
663}
664
665/// Spawns a background task to run `effect`'s shell action, registering it on its
666/// target row so a per-row spinner shows; the keyed outcome is sent to `tx` for
667/// the loop to apply (issue #46 overhaul). A second action on a row that already
668/// has a job in flight is refused with a status note so the two never race.
669fn spawn_job(cx: &Cx, app: &mut App, effect: Effect, tx: &mpsc::Sender<(JobKey, JobOutcome)>) {
670    let Some((job, key, label)) = resolve_job(app, effect) else {
671        return;
672    };
673    if app.has_job(&key) {
674        app.set_status(format!("{label} — already in progress"), StatusKind::Info);
675        return;
676    }
677    app.begin_job(key.clone(), label);
678    let jobcx = JobCx::capture(cx);
679    let tx = tx.clone();
680    tokio::task::spawn_blocking(move || {
681        let outcome = run_job(jobcx, job);
682        let _ = tx.blocking_send((key, outcome));
683    });
684}
685
686/// The payload of an async PR fetch: the picker items, or the error to surface.
687type PrFetch = std::result::Result<Vec<PrItem>, String>;
688
689/// Spawns a background task to fetch open PRs (a `gh` network call) and stream the
690/// result into the picker, so opening it never freezes the loop.
691fn spawn_fetch_prs(cx: &Cx, session: &Session, tx: &mpsc::Sender<PrFetch>) {
692    let gh = cx.gh.clone();
693    let dir = session
694        .repo
695        .current_workdir()
696        .unwrap_or_else(|| session.primary_root.clone());
697    let tx = tx.clone();
698    tokio::task::spawn_blocking(move || {
699        let _ = tx.blocking_send(fetch_prs_result(gh.as_ref(), &dir));
700    });
701}
702
703/// Runs a [`Job`] on the blocking thread, building its own `Cx` (and `Session`
704/// where needed) and capturing hook output. Returns the typed outcome.
705fn run_job(jobcx: JobCx, job: Job) -> JobOutcome {
706    let mut cx = jobcx.into_cx();
707    match job {
708        Job::Create {
709            branch,
710            base,
711            decision,
712        } => {
713            let outcome = run_create_command(&mut cx, &branch, base.clone(), decision);
714            JobOutcome::Create {
715                branch,
716                base,
717                outcome,
718            }
719        }
720        Job::Remove {
721            query,
722            path,
723            unlock,
724        } => {
725            let result = run_remove_command(&mut cx, &query, unlock);
726            JobOutcome::Remove {
727                query,
728                path,
729                result,
730            }
731        }
732        Job::DeleteBranch { branch, force } => {
733            let result = run_delete_branch_command(&mut cx, &branch, force);
734            JobOutcome::DeleteBranch {
735                branch,
736                force,
737                result,
738            }
739        }
740        Job::Materialize { branch } => {
741            let result = run_materialize_command(&mut cx, &branch);
742            JobOutcome::Materialize { branch, result }
743        }
744        Job::CheckoutPr { number } => {
745            let result = run_checkout_pr_command(&mut cx, number);
746            JobOutcome::CheckoutPr { number, result }
747        }
748        Job::CheckoutBranch {
749            worktree_dir,
750            branch,
751        } => {
752            let result = run_checkout_branch_command(&mut cx, &worktree_dir, &branch);
753            JobOutcome::CheckoutBranch { branch, result }
754        }
755        Job::Sync {
756            worktree_dir,
757            label,
758        } => {
759            let result = run_sync_command(&mut cx, &worktree_dir);
760            JobOutcome::Sync { label, result }
761        }
762        Job::SyncBranch { branch, label } => {
763            let result = run_sync_branch_command(&mut cx, &branch);
764            JobOutcome::Sync { label, result }
765        }
766        Job::InitSubmodules { dir, count } => {
767            let result = run_init_submodules_command(&mut cx, &dir);
768            JobOutcome::InitSubmodules { count, result }
769        }
770    }
771}
772
773/// Applies a finished [`JobOutcome`] to the app exactly as the inline handlers
774/// did before (issue #46): status text, mode, refresh, and `chosen`.
775fn apply_outcome(cx: &Cx, session: &Session, app: &mut App, outcome: JobOutcome) {
776    let root = &session.primary_root;
777    match outcome {
778        JobOutcome::Create {
779            branch,
780            base,
781            outcome,
782        } => apply_create(cx, app, &branch, base, outcome, root),
783        JobOutcome::Remove {
784            query,
785            path,
786            result,
787        } => apply_remove(cx, app, &query, &path, result, root),
788        JobOutcome::DeleteBranch {
789            branch,
790            force,
791            result,
792        } => apply_delete_branch(cx, app, &branch, force, result, root),
793        JobOutcome::Materialize { branch, result } => {
794            apply_materialize(cx, app, &branch, result, root)
795        }
796        JobOutcome::CheckoutPr { number, result } => {
797            apply_checkout_pr(cx, app, number, result, root)
798        }
799        JobOutcome::CheckoutBranch { branch, result } => {
800            apply_checkout_branch(cx, app, &branch, result, root)
801        }
802        JobOutcome::Sync { label, result } => apply_sync(cx, app, &label, result, root),
803        JobOutcome::InitSubmodules { count, result } => {
804            apply_init_submodules(cx, app, count, result, root)
805        }
806    }
807}
808
809/// The initial title/body/draft seed for the compose form (`wt pr open`).
810#[derive(Debug, Clone, Default)]
811pub struct ComposeSeed {
812    /// Seed title (empty when not provided).
813    pub title: String,
814    /// Seed body (empty when not provided).
815    pub body: String,
816    /// Whether the draft toggle starts on.
817    pub draft: bool,
818    /// The model used for AI auto-fill (resolved from `--model`/config).
819    pub model: crate::agent::AgentModel,
820    /// The effort used for AI auto-fill (resolved from `--effort`/config).
821    pub effort: crate::agent::Effort,
822}
823
824/// Runs the TUI directly in PR-compose mode (`wt pr open`). Seeds the form from
825/// `seed`, optionally drafting the title/body with the code agent first
826/// (`draft_ai`), then lets the user edit and submit. Returns the submit outcome,
827/// or `None` if the user cancels (Esc/quit). The compose form uses its own event
828/// loop so it can carry the gathered `ctx`, the resolved `action`, and the
829/// resulting outcome.
830pub(crate) fn run_pr_compose(
831    cx: &mut Cx,
832    session: &Session,
833    ctx: sendit::PrContext,
834    action: sendit::PrAction,
835    seed: ComposeSeed,
836    draft_ai: bool,
837) -> Result<Option<(sendit::PrOutcome, sendit::PrSpec)>> {
838    let git = cx.git.clone();
839    let mut app = build_app(cx, session, git.as_ref())?;
840    let action_label = match action {
841        sendit::PrAction::Create => "create".to_string(),
842        sendit::PrAction::Update { number } => format!("update #{number}"),
843    };
844    app.mode = Mode::PrCompose(PrComposeState {
845        title: seed.title,
846        body: seed.body,
847        draft: seed.draft,
848        branch: ctx.branch.clone(),
849        trunk: ctx.trunk.clone(),
850        action_label,
851        model: seed.model,
852        effort: seed.effort,
853        ..Default::default()
854    });
855
856    let initial = if draft_ai {
857        Effect::DraftPrAi
858    } else {
859        Effect::None
860    };
861    let mut outcome: Option<(sendit::PrOutcome, sendit::PrSpec)> = None;
862    let runtime = tokio::runtime::Runtime::new()?;
863    runtime.block_on(run_compose_loop(
864        cx,
865        session,
866        &mut app,
867        &ctx,
868        action,
869        initial,
870        &mut outcome,
871    ))?;
872
873    if app.too_small {
874        cx.err.line("terminal too small (need ≥5 rows)")?;
875        return Err(Error::operation("terminal too small"));
876    }
877    Ok(outcome)
878}
879
880/// The compose-mode event loop (terminal shell; not unit-tested). Mirrors
881/// [`run_loop`] but carries the PR `ctx`/`action`/`outcome` the compose effects
882/// need; the `do_*` helpers it delegates to are tested.
883async fn run_compose_loop(
884    cx: &mut Cx,
885    session: &Session,
886    app: &mut App,
887    ctx: &sendit::PrContext,
888    action: sendit::PrAction,
889    initial: Effect,
890    outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
891) -> Result<()> {
892    install_panic_hook();
893    let mut tui = Tui::enter(app.mouse)?;
894    app.size = tui.size();
895    if app.size.1 < crate::tui::app::MIN_HEIGHT {
896        app.too_small = true;
897        return Ok(());
898    }
899    tui.draw(app)?;
900
901    if initial != Effect::None
902        && compose_dispatch(cx, session, app, &mut tui, ctx, action, initial, outcome)?
903    {
904        return Ok(());
905    }
906    tui.draw(app)?;
907
908    let mut events = EventStream::new();
909    while let Some(maybe) = events.next().await {
910        let Ok(event) = maybe else { continue };
911        let effect = app.handle_event(event);
912        if compose_dispatch(cx, session, app, &mut tui, ctx, action, effect, outcome)? {
913            break;
914        }
915        tui.draw(app)?;
916    }
917    Ok(())
918}
919
920/// Executes a compose-mode effect, returning `true` when the loop should exit
921/// (a successful submit, a quit, or a cancel — the user leaving compose mode via
922/// Esc). Terminal shell; the `do_*` helpers it calls are tested.
923#[allow(clippy::too_many_arguments)]
924fn compose_dispatch(
925    cx: &mut Cx,
926    session: &Session,
927    app: &mut App,
928    tui: &mut Tui,
929    ctx: &sendit::PrContext,
930    action: sendit::PrAction,
931    effect: Effect,
932    outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
933) -> Result<bool> {
934    match effect {
935        Effect::Quit => Ok(true),
936        Effect::TooSmall => {
937            app.too_small = true;
938            Ok(true)
939        }
940        Effect::DraftPrAi => {
941            tui.suspend()?;
942            do_draft_pr_ai(cx, session, app, ctx);
943            tui.resume()?;
944            Ok(false)
945        }
946        Effect::SubmitPr { title, body, draft } => {
947            tui.suspend()?;
948            let done = do_submit_pr(cx, session, app, ctx, action, title, body, draft, outcome);
949            tui.resume()?;
950            Ok(done)
951        }
952        // Any other effect (typically `None`): exit only if the user left compose
953        // mode (Esc sets the mode back to List), which we treat as a cancel.
954        _ => Ok(!matches!(app.mode, Mode::PrCompose(_))),
955    }
956}
957
958/// Launches the editor in the foreground (terminal already suspended).
959fn run_editor(cx: &Cx, session: &Session, path: &Path) {
960    let Ok(editor) = resolve_editor(session.config.editor.as_deref(), &cx.env) else {
961        return;
962    };
963    let argv = editor_argv(&editor);
964    if let Some((program, rest)) = argv.split_first() {
965        let _ = std::process::Command::new(program)
966            .args(rest)
967            .arg(path)
968            .status();
969    }
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975    use crate::testutil::{FakeGh, TestRepo, test_cx};
976    use crate::tui::app::Mode;
977    use std::sync::Arc as StdArc;
978
979    /// Builds a session + app over a real repo for testing the `do_*` helpers.
980    fn setup(repo: &TestRepo) -> (crate::testutil::TestCx, Session, App) {
981        let t = test_cx(&[], repo.root().to_str().unwrap());
982        let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
983        let worktrees = build_rows(&session.repo, &crate::git::RealGit).unwrap();
984        let app = App::new(worktrees, app_config(&session.config, true), (100, 30));
985        (t, session, app)
986    }
987
988    #[test]
989    fn app_config_maps_settings() {
990        let config = Config {
991            ui_nerd_fonts: true,
992            ui_mouse: false,
993            ..Config::default()
994        };
995        let cfg = app_config(&config, false);
996        assert!(cfg.nerd_fonts);
997        assert!(!cfg.mouse);
998        assert!(!cfg.color);
999        assert!(app_config(&config, true).color);
1000    }
1001
1002    #[test]
1003    fn do_create_adds_a_worktree_and_refreshes() {
1004        let repo = TestRepo::init();
1005        let (mut t, session, mut app) = setup(&repo);
1006        app.mode = Mode::Create(Default::default());
1007        do_create(&mut t.cx, &session, &mut app, "feature/new".into(), None);
1008        assert_eq!(app.mode, Mode::List);
1009        assert!(
1010            app.worktrees
1011                .iter()
1012                .any(|w| w.branch.as_deref() == Some("feature/new"))
1013        );
1014        assert!(app.status_message.as_deref().unwrap().contains("created"));
1015        // The newly created worktree is focused, not the prior selection (issue
1016        // #52). `main` is the initial `is_current` row, so a non-focused create
1017        // would leave it selected.
1018        assert_eq!(
1019            app.selected_worktree().unwrap().branch.as_deref(),
1020            Some("feature/new")
1021        );
1022    }
1023
1024    #[test]
1025    fn do_create_error_shows_in_modal() {
1026        let repo = TestRepo::init();
1027        let (mut t, session, mut app) = setup(&repo);
1028        app.mode = Mode::Create(Default::default());
1029        // A base ref that does not exist -> error surfaced in the modal.
1030        do_create(
1031            &mut t.cx,
1032            &session,
1033            &mut app,
1034            "x".into(),
1035            Some("nope-ref".into()),
1036        );
1037        if let Mode::Create(state) = &app.mode {
1038            assert!(state.error.is_some());
1039        } else {
1040            panic!("expected create mode with error");
1041        }
1042    }
1043
1044    /// Leaves local `main` one commit behind `origin/main` (upstream configured,
1045    /// no fetchable remote so the check's fetch is skipped). Returns origin's tip.
1046    fn main_behind_origin(repo: &TestRepo) -> String {
1047        let c1 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1048        repo.write("u.txt", "1\n");
1049        repo.commit_all("ahead on origin");
1050        let c2 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1051        repo.git(&["update-ref", "refs/remotes/origin/main", &c2]);
1052        repo.git(&["reset", "-q", "--hard", &c1]);
1053        repo.git(&["config", "branch.main.remote", "origin"]);
1054        repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
1055        c2
1056    }
1057
1058    /// Creates a worktree-less `feat` branch one commit behind `origin/feat`
1059    /// (upstream configured, no fetchable remote so the sync's fetch is skipped),
1060    /// leaving the repo on `main`. Returns origin's tip.
1061    fn feat_branch_behind_origin(repo: &TestRepo) -> String {
1062        let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1063        repo.git(&["checkout", "-q", "-b", "feat"]);
1064        repo.write("u.txt", "1\n");
1065        repo.commit_all("ahead on origin/feat");
1066        let tip = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1067        repo.git(&["update-ref", "refs/remotes/origin/feat", &tip]);
1068        repo.git(&["checkout", "-q", "main"]);
1069        // Rewind local feat behind origin/feat; it is not checked out.
1070        repo.git(&["branch", "-f", "feat", &base]);
1071        repo.git(&["config", "branch.feat.remote", "origin"]);
1072        repo.git(&["config", "branch.feat.merge", "refs/heads/feat"]);
1073        tip
1074    }
1075
1076    #[test]
1077    fn do_create_stale_base_opens_confirm_modal() {
1078        // The default base (main) is behind origin/main, so the create pauses at
1079        // the stale-base confirm modal instead of creating (issue #56).
1080        let repo = TestRepo::init();
1081        main_behind_origin(&repo);
1082        let (mut t, session, mut app) = setup(&repo);
1083        app.mode = Mode::Create(Default::default());
1084        do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
1085        match &app.mode {
1086            Mode::ConfirmStaleBase(s) => {
1087                assert_eq!(s.branch, "feature");
1088                assert_eq!(s.behind, 1);
1089                assert!(s.can_fast_forward);
1090            }
1091            other => panic!("expected ConfirmStaleBase, got {other:?}"),
1092        }
1093        // Nothing was created.
1094        assert!(
1095            !app.worktrees
1096                .iter()
1097                .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
1098        );
1099    }
1100
1101    #[test]
1102    fn do_create_with_submodules_opens_confirm_modal() {
1103        // A new worktree with uninitialized submodules and the default `prompt`
1104        // policy pauses at the submodule confirm modal (issue #50).
1105        let repo = TestRepo::init();
1106        repo.add_submodule("libs/sub");
1107        let (mut t, session, mut app) = setup(&repo);
1108        app.mode = Mode::Create(Default::default());
1109        do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
1110        match &app.mode {
1111            Mode::ConfirmInitSubmodules(s) => {
1112                assert_eq!(s.branch, "feature");
1113                assert_eq!(s.count, 1);
1114                assert!(s.dir.exists());
1115            }
1116            other => panic!("expected ConfirmInitSubmodules, got {other:?}"),
1117        }
1118        // The worktree was created and is visible behind the modal.
1119        assert!(
1120            app.worktrees
1121                .iter()
1122                .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
1123        );
1124    }
1125
1126    #[test]
1127    fn apply_init_submodules_reports_success_and_refreshes() {
1128        let repo = TestRepo::init();
1129        let (t, session, mut app) = setup(&repo);
1130        // The confirm/loop already returned to the list before the job ran; the
1131        // init job runs concurrently and only reports its result on completion.
1132        apply_init_submodules(&t.cx, &mut app, 2, Ok(()), &session.primary_root);
1133        assert_eq!(app.mode, Mode::List);
1134        assert!(
1135            app.status_message
1136                .as_deref()
1137                .unwrap()
1138                .contains("initialized 2 submodule")
1139        );
1140    }
1141
1142    #[test]
1143    fn apply_init_submodules_error_shows_in_status() {
1144        let repo = TestRepo::init();
1145        let (t, session, mut app) = setup(&repo);
1146        apply_init_submodules(
1147            &t.cx,
1148            &mut app,
1149            1,
1150            Err("boom".into()),
1151            &session.primary_root,
1152        );
1153        assert_eq!(app.mode, Mode::List);
1154        let msg = app.status_message.as_deref().unwrap();
1155        assert!(msg.contains("failed to initialize submodules"));
1156        assert!(msg.contains("boom"));
1157    }
1158
1159    #[test]
1160    fn create_update_decision_fast_forwards_then_creates() {
1161        // Re-issuing the create with the Update decision (as the modal does)
1162        // fast-forwards the base and forks the new branch from it (issue #56).
1163        let repo = TestRepo::init();
1164        let c2 = main_behind_origin(&repo);
1165        let (t, session, mut app) = setup(&repo);
1166        let outcome = run_job(
1167            JobCx::capture(&t.cx),
1168            Job::Create {
1169                branch: "feature".into(),
1170                base: None,
1171                decision: Some(CreateDecision::Update),
1172            },
1173        );
1174        apply_outcome(&t.cx, &session, &mut app, outcome);
1175        assert_eq!(app.mode, Mode::List);
1176        assert_eq!(repo.git(&["rev-parse", "refs/heads/main"]).trim(), c2);
1177        assert_eq!(repo.git(&["rev-parse", "refs/heads/feature"]).trim(), c2);
1178    }
1179
1180    #[test]
1181    fn do_remove_removes_selected() {
1182        let repo = TestRepo::init();
1183        repo.add_worktree("feature/x", "../wt-x");
1184        // Give it an upstream so it is not "unpushed" (remove uses force anyway).
1185        let (mut t, session, mut app) = setup(&repo);
1186        let index = app
1187            .worktrees
1188            .iter()
1189            .position(|w| w.branch.as_deref() == Some("feature/x"))
1190            .unwrap();
1191        do_remove(&mut t.cx, &session, &mut app, index, false);
1192        // The worktree is gone; the branch itself survives (not wt-created) and now
1193        // shows as a worktree-less branch row, so assert on the worktree row only.
1194        assert!(
1195            !app.worktrees
1196                .iter()
1197                .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature/x"))
1198        );
1199        assert!(
1200            app.worktrees
1201                .iter()
1202                .any(|w| !w.has_worktree && w.branch.as_deref() == Some("feature/x"))
1203        );
1204    }
1205
1206    #[test]
1207    fn do_remove_locked_reprompts_then_overrides_the_lock() {
1208        // Issue #126: a locked worktree is refused and re-opens the confirm as
1209        // the locked re-prompt with its reason; confirming that removes it.
1210        let repo = TestRepo::init();
1211        repo.add_worktree("held", "../wt-held");
1212        repo.git(&["worktree", "lock", "--reason", "in use", "../wt-held"]);
1213        let held = repo.root().parent().unwrap().join("wt-held");
1214        let (mut t, session, mut app) = setup(&repo);
1215        let row = |app: &App| {
1216            app.worktrees
1217                .iter()
1218                .position(|w| w.has_worktree && w.branch.as_deref() == Some("held"))
1219        };
1220        let index = row(&app).unwrap();
1221        do_remove(&mut t.cx, &session, &mut app, index, false);
1222        assert_eq!(
1223            app.mode,
1224            Mode::ConfirmRemoveLocked {
1225                index,
1226                reason: Some("in use".into()),
1227            }
1228        );
1229        assert!(held.exists());
1230
1231        // The re-prompt's `y` returns to the list before the job runs.
1232        app.mode = Mode::List;
1233        do_remove(&mut t.cx, &session, &mut app, index, true);
1234        assert_eq!(app.mode, Mode::List);
1235        assert!(!held.exists());
1236        assert!(row(&app).is_none());
1237    }
1238
1239    #[test]
1240    fn do_remove_locked_in_another_modal_reports_instead() {
1241        // A finished job never clobbers a modal the user opened meanwhile.
1242        let repo = TestRepo::init();
1243        repo.add_worktree("held", "../wt-held");
1244        repo.git(&["worktree", "lock", "../wt-held"]);
1245        let (mut t, session, mut app) = setup(&repo);
1246        let index = app
1247            .worktrees
1248            .iter()
1249            .position(|w| w.has_worktree && w.branch.as_deref() == Some("held"))
1250            .unwrap();
1251        app.mode = Mode::Help;
1252        do_remove(&mut t.cx, &session, &mut app, index, false);
1253        assert_eq!(app.mode, Mode::Help);
1254        assert_eq!(app.status_message.as_deref(), Some("worktree is locked"));
1255    }
1256
1257    #[test]
1258    fn remove_failure_messages() {
1259        assert_eq!(
1260            RemoveFailure::Locked(Some("r".into())).message(),
1261            "worktree is locked (r)"
1262        );
1263        assert_eq!(RemoveFailure::Other("boom".into()).message(), "boom");
1264    }
1265
1266    #[test]
1267    fn do_delete_branch_removes_branch_row_and_refreshes() {
1268        // A worktree-less branch row (issue #53): deleting it removes the local
1269        // branch and refreshes so the row disappears.
1270        let repo = TestRepo::init();
1271        repo.git(&["branch", "topic"]); // a merged branch row, no worktree
1272        let (mut t, session, mut app) = setup(&repo);
1273        assert!(
1274            app.worktrees
1275                .iter()
1276                .any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
1277        );
1278        do_delete_branch(&mut t.cx, &session, &mut app, "topic".into(), false);
1279        assert_eq!(app.mode, Mode::List);
1280        assert!(
1281            !app.worktrees
1282                .iter()
1283                .any(|w| w.branch.as_deref() == Some("topic"))
1284        );
1285        assert!(
1286            app.status_message
1287                .as_deref()
1288                .unwrap()
1289                .contains("deleted branch topic")
1290        );
1291    }
1292
1293    #[test]
1294    fn do_delete_branch_unmerged_reprompts_then_force_deletes() {
1295        // Deleting an unmerged branch row is refused by the safe `-d` and re-opens
1296        // the confirm in force mode (issue #53); a forced delete then removes it.
1297        let repo = TestRepo::init();
1298        // An unmerged branch with no worktree: branch off in a temp worktree,
1299        // commit, then drop the worktree but keep the branch.
1300        repo.add_worktree("unmerged", "../wt-unmerged");
1301        let wt = repo.root().parent().unwrap().join("wt-unmerged");
1302        std::fs::write(wt.join("c.txt"), "x\n").unwrap();
1303        let dir = wt.to_string_lossy().into_owned();
1304        repo.git(&["-C", &dir, "add", "-A"]);
1305        repo.git(&["-C", &dir, "commit", "-q", "-m", "unmerged change"]);
1306        repo.git(&["worktree", "remove", "--force", &dir]);
1307        let (mut t, session, mut app) = setup(&repo);
1308        // A safe delete is refused -> re-prompt in force mode.
1309        do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), false);
1310        assert!(matches!(
1311            app.mode,
1312            Mode::ConfirmDeleteBranch { force: true, .. }
1313        ));
1314        assert!(
1315            app.worktrees
1316                .iter()
1317                .any(|w| w.branch.as_deref() == Some("unmerged"))
1318        );
1319        // The forced delete removes it. The re-prompt's `y` returns to the list
1320        // before the job runs (as the real key handler does), so apply sees List.
1321        app.mode = Mode::List;
1322        do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), true);
1323        assert_eq!(app.mode, Mode::List);
1324        assert!(
1325            !app.worktrees
1326                .iter()
1327                .any(|w| w.branch.as_deref() == Some("unmerged"))
1328        );
1329    }
1330
1331    #[test]
1332    fn do_materialize_branch_creates_worktree_and_stays_focused() {
1333        // A worktree-less branch (issue #47): materializing it creates a worktree
1334        // and refreshes so the row becomes a real worktree. Post-overhaul it stays
1335        // in the TUI (no `chosen`) and focuses the new worktree row so the user can
1336        // switch into it with Enter when ready (issue #46 overhaul).
1337        let repo = TestRepo::init();
1338        repo.git(&["branch", "topic"]);
1339        let (mut t, session, mut app) = setup(&repo);
1340        // Precondition: `topic` starts as a branch row with no worktree.
1341        assert!(
1342            app.worktrees
1343                .iter()
1344                .any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
1345        );
1346        do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
1347        assert_eq!(app.mode, Mode::List);
1348        assert!(app.chosen.is_none());
1349        // `topic` is now a real worktree row, not a branch row, and is focused.
1350        assert!(
1351            app.worktrees
1352                .iter()
1353                .any(|w| w.has_worktree && w.branch.as_deref() == Some("topic"))
1354        );
1355        let focused = app.selected_worktree().unwrap();
1356        assert!(focused.has_worktree && focused.branch.as_deref() == Some("topic"));
1357        assert!(
1358            app.status_message
1359                .as_deref()
1360                .unwrap()
1361                .contains("created topic")
1362        );
1363    }
1364
1365    #[test]
1366    fn do_materialize_branch_error_shows_in_status() {
1367        // Creating a worktree for a branch that is already checked out elsewhere
1368        // fails; the error surfaces in the status bar and nothing is chosen.
1369        let repo = TestRepo::init();
1370        repo.add_worktree("dup", "../manual-dup");
1371        let (mut t, session, mut app) = setup(&repo);
1372        do_materialize_branch(&mut t.cx, &session, &mut app, "dup".into());
1373        assert!(app.chosen.is_none());
1374        assert_eq!(app.status_kind, StatusKind::Error);
1375        assert!(app.status_message.is_some());
1376    }
1377
1378    #[test]
1379    fn do_materialize_branch_queues_background_submodule_init() {
1380        // Materializing a worktree that has uninitialized submodules queues a
1381        // background init job on the new row rather than blocking the switch
1382        // (issue #46 overhaul).
1383        let repo = TestRepo::init();
1384        repo.add_submodule("libs/sub");
1385        repo.git(&["branch", "topic"]); // branches from HEAD, which has the submodule
1386        let (mut t, session, mut app) = setup(&repo);
1387        do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
1388        assert!(app.chosen.is_none());
1389        // A follow-up submodule-init job is queued for the loop to spawn.
1390        let queued = app.take_pending_jobs();
1391        assert!(
1392            queued
1393                .iter()
1394                .any(|e| matches!(e, Effect::InitSubmodules { .. })),
1395            "expected a queued InitSubmodules job, got {queued:?}"
1396        );
1397    }
1398
1399    #[test]
1400    fn apply_create_auto_policy_queues_submodule_job() {
1401        // Under the `always` policy a created worktree with uninitialized
1402        // submodules starts a background init job (no modal) and returns to the
1403        // list (issue #46 overhaul).
1404        let repo = TestRepo::init();
1405        repo.add_submodule("libs/sub");
1406        let (t, session, mut app) = setup(&repo);
1407        apply_create(
1408            &t.cx,
1409            &mut app,
1410            "feature",
1411            None,
1412            CreateOutcome::CreatedNeedsSubmodules {
1413                dir: session.primary_root.clone(),
1414                count: 1,
1415                auto: true,
1416            },
1417            &session.primary_root,
1418        );
1419        assert_eq!(app.mode, Mode::List);
1420        let queued = app.take_pending_jobs();
1421        assert!(
1422            queued
1423                .iter()
1424                .any(|e| matches!(e, Effect::InitSubmodules { .. }))
1425        );
1426    }
1427
1428    #[test]
1429    fn apply_create_while_exit_blocked_queues_submodule_job_and_keeps_waiting() {
1430        // The premature-exit fix end to end: a create job finishes while the exit
1431        // is blocked and, under the `prompt` policy, its submodule-init follow-up
1432        // must be *queued* (not opened as a modal over the overlay) so the loop
1433        // keeps waiting for it to drain rather than switching prematurely
1434        // (issue #46 overhaul). This depends on `ExitBlocked` not being an idle
1435        // mode in `may_apply_mode`.
1436        use crate::tui::app::{ExitBlockedState, ExitIntent, JobKey};
1437        let repo = TestRepo::init();
1438        repo.add_submodule("libs/sub");
1439        let (t, session, mut app) = setup(&repo);
1440        // Stand in for the loop state after the create job finished while blocked:
1441        // the switch is pending, and the (now-finished) create job is cleared.
1442        app.mode = Mode::ExitBlocked(ExitBlockedState {
1443            intent: ExitIntent::Switch(session.primary_root.clone()),
1444        });
1445        apply_create(
1446            &t.cx,
1447            &mut app,
1448            "feature",
1449            None,
1450            CreateOutcome::CreatedNeedsSubmodules {
1451                dir: session.primary_root.clone(),
1452                count: 1,
1453                auto: false, // `prompt` policy — would normally open the modal
1454            },
1455            &session.primary_root,
1456        );
1457        // The overlay is preserved (no ConfirmInitSubmodules clobber)...
1458        assert!(matches!(app.mode, Mode::ExitBlocked(_)));
1459        // ...and the init was queued as a follow-up.
1460        let queued = app.take_pending_jobs();
1461        assert!(
1462            queued
1463                .iter()
1464                .any(|e| matches!(e, Effect::InitSubmodules { .. }))
1465        );
1466        // Simulate the loop spawning that follow-up: while it runs, the exit keeps
1467        // waiting; once it drains, the held switch commits.
1468        let key = JobKey::Path(session.primary_root.clone());
1469        app.begin_job(key.clone(), "Initializing 1 submodule(s)");
1470        assert!(!app.exit_now());
1471        app.finish_job(&key);
1472        assert!(app.exit_now());
1473        assert_eq!(app.chosen, Some(session.primary_root.clone()));
1474    }
1475
1476    #[test]
1477    fn do_fetch_prs_populates_picker() {
1478        let repo = TestRepo::init();
1479        let (mut t, session, mut app) = setup(&repo);
1480        t.cx.gh = StdArc::new(FakeGh::with_list(vec![crate::gh::PrSummary {
1481            number: 5,
1482            title: "T".into(),
1483            author: crate::gh::Author {
1484                login: "alice".into(),
1485            },
1486            state: "OPEN".into(),
1487            is_draft: false,
1488            head_ref_name: "h".into(),
1489            created_at: String::new(),
1490        }]));
1491        app.mode = Mode::PrPicker(Default::default());
1492        do_fetch_prs(&t.cx, &session, &mut app);
1493        if let Mode::PrPicker(state) = &app.mode {
1494            assert!(!state.loading);
1495            assert_eq!(state.prs.len(), 1);
1496            assert_eq!(state.prs[0].number, 5);
1497        } else {
1498            panic!("expected pr picker");
1499        }
1500    }
1501
1502    #[test]
1503    fn do_fetch_prs_surfaces_gh_error() {
1504        let repo = TestRepo::init();
1505        let (mut t, session, mut app) = setup(&repo);
1506        t.cx.gh = StdArc::new(FakeGh::unavailable());
1507        app.mode = Mode::PrPicker(Default::default());
1508        do_fetch_prs(&t.cx, &session, &mut app);
1509        if let Mode::PrPicker(state) = &app.mode {
1510            assert!(state.error.is_some());
1511        } else {
1512            panic!("expected pr picker");
1513        }
1514    }
1515
1516    #[test]
1517    fn do_refresh_reloads_worktrees() {
1518        let repo = TestRepo::init();
1519        let (t, session, mut app) = setup(&repo);
1520        // Create a worktree out-of-band, then refresh.
1521        repo.add_worktree("added", "../wt-added");
1522        do_refresh(&t.cx, &mut app, &session.primary_root);
1523        assert!(
1524            app.worktrees
1525                .iter()
1526                .any(|w| w.branch.as_deref() == Some("added"))
1527        );
1528    }
1529
1530    /// Sets up a fetchable `pull/<n>/head` ref served by an `origin` remote
1531    /// pointing at the repo itself, so `do_checkout_pr` can fetch a real head.
1532    fn repo_with_pr(number: u64) -> TestRepo {
1533        let repo = TestRepo::init();
1534        repo.write("pr.txt", "from pr\n");
1535        repo.commit_all("pr commit");
1536        let pr_oid = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1537        repo.git(&["update-ref", &format!("refs/pull/{number}/head"), &pr_oid]);
1538        repo.git(&["reset", "-q", "--hard", "HEAD~1"]);
1539        repo.git(&["remote", "add", "origin", repo.root().to_str().unwrap()]);
1540        repo
1541    }
1542
1543    fn pr_view(number: u64, head: &str, base: &str) -> crate::gh::PrView {
1544        crate::gh::PrView {
1545            number,
1546            title: "Add login".into(),
1547            state: "OPEN".into(),
1548            is_draft: false,
1549            head_ref_name: head.into(),
1550            base_ref_name: base.into(),
1551            url: format!("https://github.com/o/r/pull/{number}"),
1552        }
1553    }
1554
1555    #[test]
1556    fn do_checkout_pr_stays_in_list_and_focuses_new_worktree() {
1557        // Checking out a PR now always stays in the TUI (issue #46 overhaul): the
1558        // new worktree is checked out, the list refreshes and focuses it, and
1559        // `chosen` stays unset so the user switches into it with Enter when ready.
1560        let repo = repo_with_pr(123);
1561        let (mut t, session, mut app) = setup(&repo);
1562        t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(123, "pr-feature", "main")));
1563        app.mode = Mode::PrPicker(Default::default());
1564        do_checkout_pr(&mut t.cx, &session, &mut app, 123);
1565        assert!(app.chosen.is_none());
1566        assert_eq!(app.mode, Mode::List);
1567        // The picker closed to the list, focused on the new worktree row.
1568        assert_eq!(
1569            app.selected_worktree().unwrap().branch.as_deref(),
1570            Some("pr-feature")
1571        );
1572    }
1573
1574    #[test]
1575    fn do_checkout_pr_stays_in_list_without_exit_flag() {
1576        // The in-TUI `p`-key flow: checkout returns to the list and refreshes,
1577        // leaving `chosen` unset so the TUI keeps running.
1578        let repo = repo_with_pr(55);
1579        let (mut t, session, mut app) = setup(&repo);
1580        t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(55, "pr-feature", "main")));
1581        app.mode = Mode::PrPicker(Default::default());
1582        do_checkout_pr(&mut t.cx, &session, &mut app, 55);
1583        assert!(app.chosen.is_none());
1584        assert_eq!(app.mode, Mode::List);
1585        assert!(
1586            app.status_message
1587                .as_deref()
1588                .unwrap()
1589                .contains("checked out")
1590        );
1591        assert!(
1592            app.worktrees
1593                .iter()
1594                .any(|w| w.branch.as_deref() == Some("pr-feature"))
1595        );
1596    }
1597
1598    #[test]
1599    fn do_checkout_branch_switches_and_stays_in_list() {
1600        let repo = TestRepo::init();
1601        repo.git(&["branch", "topic"]);
1602        let (mut t, session, mut app) = setup(&repo);
1603        app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
1604            worktree_index: 0,
1605            ..Default::default()
1606        });
1607        do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
1608        // Stays in the list (no `cd`), refreshed, with a success status.
1609        assert_eq!(app.mode, Mode::List);
1610        assert!(app.chosen.is_none());
1611        assert!(
1612            app.status_message
1613                .as_deref()
1614                .unwrap()
1615                .contains("checked out topic")
1616        );
1617        // The (primary) worktree now has `topic` checked out.
1618        assert_eq!(
1619            repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(),
1620            "topic"
1621        );
1622    }
1623
1624    #[test]
1625    fn do_checkout_branch_dirty_shows_error_in_picker() {
1626        let repo = TestRepo::init();
1627        repo.git(&["branch", "topic"]);
1628        repo.write("README.md", "dirty\n"); // a tracked modification
1629        let (mut t, session, mut app) = setup(&repo);
1630        app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
1631            worktree_index: 0,
1632            submitting: true,
1633            ..Default::default()
1634        });
1635        do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
1636        if let Mode::Checkout(state) = &app.mode {
1637            assert!(state.error.as_deref().unwrap().contains("uncommitted"));
1638            assert!(!state.submitting);
1639        } else {
1640            panic!("expected checkout picker with error");
1641        }
1642    }
1643
1644    #[test]
1645    fn do_sync_fast_forwards_and_refreshes() {
1646        // The primary worktree's `main` is behind `origin/main` (no fetchable
1647        // remote, so the fetch is skipped): sync fast-forwards it in place.
1648        let repo = TestRepo::init();
1649        let c2 = main_behind_origin(&repo);
1650        let (mut t, session, mut app) = setup(&repo);
1651        do_sync(&mut t.cx, &session, &mut app, 0);
1652        assert_eq!(app.mode, Mode::List);
1653        assert_eq!(app.status_kind, StatusKind::Success);
1654        assert!(
1655            app.status_message
1656                .as_deref()
1657                .unwrap()
1658                .contains("fast-forwarded")
1659        );
1660        assert_eq!(repo.git(&["rev-parse", "main"]).trim(), c2);
1661    }
1662
1663    #[test]
1664    fn do_sync_branch_row_fast_forwards() {
1665        // A worktree-less `feat` branch row that is behind `origin/feat` syncs by
1666        // moving its ref (no checkout involved) — issue #47/#63.
1667        let repo = TestRepo::init();
1668        let tip = feat_branch_behind_origin(&repo);
1669        let (mut t, session, mut app) = setup(&repo);
1670        let index = app
1671            .worktrees
1672            .iter()
1673            .position(|w| w.branch.as_deref() == Some("feat") && !w.has_worktree)
1674            .unwrap();
1675        do_sync(&mut t.cx, &session, &mut app, index);
1676        assert_eq!(app.mode, Mode::List);
1677        assert_eq!(app.status_kind, StatusKind::Success);
1678        assert!(
1679            app.status_message
1680                .as_deref()
1681                .unwrap()
1682                .contains("fast-forwarded")
1683        );
1684        assert_eq!(repo.git(&["rev-parse", "feat"]).trim(), tip);
1685    }
1686
1687    #[test]
1688    fn do_sync_no_upstream_shows_status() {
1689        let repo = TestRepo::init();
1690        let (mut t, session, mut app) = setup(&repo);
1691        do_sync(&mut t.cx, &session, &mut app, 0); // `main`, no upstream
1692        assert_eq!(app.mode, Mode::List);
1693        assert!(
1694            app.status_message
1695                .as_deref()
1696                .unwrap()
1697                .contains("no upstream")
1698        );
1699    }
1700
1701    #[test]
1702    fn do_sync_dirty_shows_error_status() {
1703        let repo = TestRepo::init();
1704        main_behind_origin(&repo);
1705        repo.write("README.md", "dirty\n"); // blocks the fast-forward
1706        let (mut t, session, mut app) = setup(&repo);
1707        do_sync(&mut t.cx, &session, &mut app, 0);
1708        assert_eq!(app.status_kind, StatusKind::Error);
1709        assert!(app.status_message.as_deref().unwrap().contains("dirty"));
1710    }
1711
1712    #[test]
1713    fn do_sync_error_shows_in_status() {
1714        // A worktree whose directory is gone makes the sync core error out; the
1715        // message surfaces in the status bar.
1716        let repo = TestRepo::init();
1717        repo.add_worktree("feat", "../wt-feat");
1718        let (mut t, session, mut app) = setup(&repo);
1719        let index = app
1720            .worktrees
1721            .iter()
1722            .position(|w| w.branch.as_deref() == Some("feat"))
1723            .unwrap();
1724        std::fs::remove_dir_all(repo.root().parent().unwrap().join("wt-feat")).unwrap();
1725        do_sync(&mut t.cx, &session, &mut app, index);
1726        assert_eq!(app.status_kind, StatusKind::Error);
1727        assert!(app.status_message.is_some());
1728    }
1729
1730    fn sendit_ctx(branch: &str, trunk: &str, has_upstream: bool) -> sendit::PrContext {
1731        sendit::PrContext {
1732            branch: branch.into(),
1733            trunk: trunk.into(),
1734            merge_base: "abc".into(),
1735            has_upstream,
1736            commits_ahead: 1,
1737            commit_log: vec![],
1738            diffstat: sendit::DiffStat {
1739                files: 1,
1740                insertions: 1,
1741                deletions: 0,
1742                raw: String::new(),
1743            },
1744            existing_pr: None,
1745        }
1746    }
1747
1748    /// A feature repo (`feat`, one commit ahead of `main`) with a bare `origin`.
1749    fn feature_repo_with_remote() -> (TestRepo, TestRepo) {
1750        let bare = TestRepo::init_bare();
1751        let repo = TestRepo::init();
1752        repo.git(&["checkout", "-q", "-b", "feat"]);
1753        repo.write("f.txt", "x\n");
1754        repo.commit_all("feat work");
1755        repo.git(&["remote", "add", "origin", bare.root().to_str().unwrap()]);
1756        (repo, bare)
1757    }
1758
1759    #[test]
1760    fn do_draft_pr_ai_seeds_form() {
1761        let repo = TestRepo::init();
1762        let (mut t, session, mut app) = setup(&repo);
1763        t.cx.agent = StdArc::new(crate::testutil::FakeAgent::drafting(
1764            "Add login\n\nBody here",
1765        ));
1766        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1767        do_draft_pr_ai(
1768            &mut t.cx,
1769            &session,
1770            &mut app,
1771            &sendit_ctx("feat", "main", false),
1772        );
1773        if let Mode::PrCompose(s) = &app.mode {
1774            assert_eq!(s.title, "Add login");
1775            assert_eq!(s.body, "Body here");
1776            assert!(s.error.is_none());
1777        } else {
1778            panic!("expected compose mode");
1779        }
1780    }
1781
1782    #[test]
1783    fn do_draft_pr_ai_shows_error_when_unavailable() {
1784        let repo = TestRepo::init();
1785        // The default test agent is `FakeAgent::unavailable()`.
1786        let (mut t, session, mut app) = setup(&repo);
1787        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1788        do_draft_pr_ai(
1789            &mut t.cx,
1790            &session,
1791            &mut app,
1792            &sendit_ctx("feat", "main", false),
1793        );
1794        if let Mode::PrCompose(s) = &app.mode {
1795            assert!(s.error.is_some());
1796        } else {
1797            panic!("expected compose mode");
1798        }
1799    }
1800
1801    #[test]
1802    fn do_draft_pr_ai_uses_form_model_and_effort() {
1803        let repo = TestRepo::init();
1804        let (mut t, session, mut app) = setup(&repo);
1805        let agent = StdArc::new(crate::testutil::FakeAgent::drafting("T\n\nB"));
1806        t.cx.agent = agent.clone();
1807        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
1808            model: crate::agent::AgentModel::Opus,
1809            effort: crate::agent::Effort::High,
1810            ..Default::default()
1811        });
1812        do_draft_pr_ai(
1813            &mut t.cx,
1814            &session,
1815            &mut app,
1816            &sendit_ctx("feat", "main", false),
1817        );
1818        // The model/effort selected in the form were passed to the agent.
1819        assert_eq!(
1820            agent.last_opts(),
1821            Some(crate::agent::AgentOptions {
1822                model: crate::agent::AgentModel::Opus,
1823                effort: crate::agent::Effort::High,
1824                timeout: None,
1825            })
1826        );
1827    }
1828
1829    #[test]
1830    fn do_submit_pr_creates_records_and_exits() {
1831        let (repo, _bare) = feature_repo_with_remote();
1832        let (mut t, session, mut app) = setup(&repo);
1833        t.cx.gh = StdArc::new(FakeGh::sender("https://github.com/o/r/pull/77\n"));
1834        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1835        let mut outcome = None;
1836        let done = do_submit_pr(
1837            &mut t.cx,
1838            &session,
1839            &mut app,
1840            &sendit_ctx("feat", "main", false),
1841            sendit::PrAction::Create,
1842            "T".into(),
1843            "B".into(),
1844            false,
1845            &mut outcome,
1846        );
1847        assert!(done);
1848        assert_eq!(outcome.expect("outcome").0.number, Some(77));
1849        assert_eq!(
1850            repo.git(&["config", "--get", "wt.feat.prNumber"]).trim(),
1851            "77"
1852        );
1853    }
1854
1855    #[test]
1856    fn do_submit_pr_error_stays_in_form() {
1857        let (repo, _bare) = feature_repo_with_remote();
1858        let (mut t, session, mut app) = setup(&repo);
1859        t.cx.gh = StdArc::new(FakeGh::unavailable());
1860        app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
1861            submitting: true,
1862            ..Default::default()
1863        });
1864        let mut outcome = None;
1865        let done = do_submit_pr(
1866            &mut t.cx,
1867            &session,
1868            &mut app,
1869            &sendit_ctx("feat", "main", false),
1870            sendit::PrAction::Create,
1871            "T".into(),
1872            "B".into(),
1873            false,
1874            &mut outcome,
1875        );
1876        assert!(!done);
1877        assert!(outcome.is_none());
1878        if let Mode::PrCompose(s) = &app.mode {
1879            assert!(s.error.is_some());
1880            assert!(!s.submitting);
1881        } else {
1882            panic!("expected compose mode");
1883        }
1884    }
1885
1886    #[test]
1887    fn do_checkout_pr_surfaces_gh_error_in_picker() {
1888        let repo = TestRepo::init();
1889        let (mut t, session, mut app) = setup(&repo);
1890        t.cx.gh = StdArc::new(FakeGh::unavailable());
1891        app.mode = Mode::PrPicker(Default::default());
1892        do_checkout_pr(&mut t.cx, &session, &mut app, 1);
1893        if let Mode::PrPicker(state) = &app.mode {
1894            assert!(state.error.is_some());
1895        } else {
1896            panic!("expected pr picker with error");
1897        }
1898        assert!(app.chosen.is_none());
1899    }
1900
1901    #[test]
1902    fn is_background_action_matches_mutations_only() {
1903        assert!(is_background_action(&Effect::Create {
1904            branch: "x".into(),
1905            base: None,
1906            decision: None,
1907        }));
1908        assert!(is_background_action(&Effect::Remove {
1909            index: 0,
1910            unlock: false
1911        }));
1912        assert!(is_background_action(&Effect::MaterializeBranch {
1913            branch: "x".into()
1914        }));
1915        assert!(is_background_action(&Effect::CheckoutPr(1)));
1916        assert!(is_background_action(&Effect::CheckoutBranch {
1917            worktree_index: 0,
1918            branch: "x".into()
1919        }));
1920        assert!(is_background_action(&Effect::Sync { worktree_index: 0 }));
1921        // Non-mutating effects run inline, not on a background task.
1922        assert!(!is_background_action(&Effect::Refresh));
1923        assert!(!is_background_action(&Effect::FetchPrs));
1924        assert!(!is_background_action(&Effect::None));
1925        assert!(!is_background_action(&Effect::OpenEditor("/tmp".into())));
1926    }
1927
1928    #[test]
1929    fn resolve_job_sets_label_key_and_args() {
1930        use crate::tui::app::testutil::app as make_app;
1931        let a = make_app(&[("main", true), ("feat/x", false)]);
1932
1933        let (job, key, label) = resolve_job(
1934            &a,
1935            Effect::Create {
1936                branch: "feat/new".into(),
1937                base: Some("main".into()),
1938                decision: None,
1939            },
1940        )
1941        .unwrap();
1942        assert!(matches!(job, Job::Create { .. }));
1943        assert_eq!(label, "Creating feat/new");
1944        assert_eq!(key, JobKey::New("feat/new".into()));
1945
1946        // Remove resolves the query and keys on the target row's path.
1947        let (job, key, label) = resolve_job(
1948            &a,
1949            Effect::Remove {
1950                index: 1,
1951                unlock: true,
1952            },
1953        )
1954        .unwrap();
1955        assert!(matches!(
1956            job,
1957            Job::Remove { query, path, unlock: true }
1958                if query == "feat/x" && path == a.worktrees[1].path
1959        ));
1960        assert_eq!(label, "Removing feat/x");
1961        assert_eq!(key, JobKey::Path(a.worktrees[1].path.clone()));
1962
1963        // CheckoutBranch resolves the worktree directory from the row.
1964        let (job, key, label) = resolve_job(
1965            &a,
1966            Effect::CheckoutBranch {
1967                worktree_index: 0,
1968                branch: "feat/x".into(),
1969            },
1970        )
1971        .unwrap();
1972        assert!(matches!(job, Job::CheckoutBranch { .. }));
1973        assert_eq!(label, "Checking out feat/x");
1974        assert_eq!(key, JobKey::Path(a.worktrees[0].path.clone()));
1975
1976        // Sync resolves the worktree directory and labels with the branch.
1977        let (job, _key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
1978        assert!(matches!(job, Job::Sync { .. }));
1979        assert_eq!(label, "Syncing feat/x");
1980
1981        let (job, key, label) = resolve_job(&a, Effect::CheckoutPr(7)).unwrap();
1982        assert!(matches!(job, Job::CheckoutPr { number } if number == 7));
1983        assert_eq!(label, "Checking out PR #7");
1984        assert_eq!(key, JobKey::New("PR #7".into()));
1985    }
1986
1987    #[test]
1988    fn resolve_job_returns_none_for_missing_row() {
1989        use crate::tui::app::testutil::app as make_app;
1990        let a = make_app(&[("main", true)]);
1991        assert!(
1992            resolve_job(
1993                &a,
1994                Effect::Remove {
1995                    index: 99,
1996                    unlock: false
1997                }
1998            )
1999            .is_none()
2000        );
2001        assert!(
2002            resolve_job(
2003                &a,
2004                Effect::CheckoutBranch {
2005                    worktree_index: 99,
2006                    branch: "x".into()
2007                }
2008            )
2009            .is_none()
2010        );
2011        assert!(resolve_job(&a, Effect::Sync { worktree_index: 99 }).is_none());
2012        // A non-background effect also yields no job.
2013        assert!(resolve_job(&a, Effect::Refresh).is_none());
2014    }
2015
2016    #[test]
2017    fn spawn_job_refuses_conflicting_action_on_same_row() {
2018        // Two jobs on the same row must not race: the second is refused with a
2019        // status note and the registry keeps just the first (issue #46 overhaul).
2020        use crate::tui::app::testutil::app as make_app;
2021        let mut a = make_app(&[("main", true), ("feat/x", false)]);
2022        let key = JobKey::Path(a.worktrees[1].path.clone());
2023        a.begin_job(key.clone(), "Removing feat/x");
2024        // Simulate the guard the loop's `spawn_job` applies before spawning.
2025        let (_, resolved_key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
2026        assert_eq!(resolved_key, key);
2027        assert!(a.has_job(&resolved_key));
2028        a.set_status(format!("{label} — already in progress"), StatusKind::Info);
2029        assert_eq!(a.jobs.len(), 1);
2030        assert!(a.status_message.as_deref().unwrap().contains("in progress"));
2031    }
2032
2033    #[test]
2034    fn anchor_at_root_repoints_cwd_and_returns_opened_worktree() {
2035        // The TUI opened in a linked worktree: git operations should re-anchor at
2036        // the primary root, while the opened-in worktree path is returned so a
2037        // later removal can be detected (issue #68).
2038        let repo = TestRepo::init();
2039        repo.add_worktree("feature/x", "../wt-x");
2040        let linked = repo.root().parent().unwrap().join("wt-x");
2041        let mut t = test_cx(&[], linked.to_str().unwrap());
2042        let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
2043        let opened_in = anchor_at_root(&mut t.cx, &session);
2044        assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
2045        assert_eq!(canon(&opened_in), canon(&linked));
2046    }
2047
2048    #[test]
2049    fn removing_opened_in_worktree_keeps_operations_working() {
2050        // Issue #68: the TUI is opened inside a linked worktree which is then
2051        // removed during the session. Because git operations are re-anchored at
2052        // the root, the removal succeeds and a later session-open still works
2053        // (instead of failing with the worktree's directory gone), and on exit
2054        // the shell is steered back to the root with a friendly note.
2055        let repo = TestRepo::init();
2056        repo.add_worktree("feature/x", "../wt-x");
2057        let linked = repo.root().parent().unwrap().join("wt-x");
2058
2059        // Open as if the TUI launched inside the linked worktree.
2060        let mut t = test_cx(&[], linked.to_str().unwrap());
2061        let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
2062        let opened_in = anchor_at_root(&mut t.cx, &session);
2063        assert_eq!(canon(&opened_in), canon(&linked));
2064        assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
2065
2066        // Remove the worktree the TUI was opened in (the background-job path).
2067        run_remove_command(&mut t.cx, "feature/x", false).unwrap();
2068        assert!(!linked.exists());
2069
2070        // The session still opens: operations anchor at the surviving root.
2071        let again = open_session(&t.cx, &crate::git::RealGit).unwrap();
2072        assert_eq!(canon(&again.primary_root), canon(&session.primary_root));
2073
2074        // On graceful exit (no explicit switch), navigate back to the root.
2075        let nav = finish_exit(&mut t.cx, &opened_in, &session.primary_root, None).unwrap();
2076        assert_eq!(canon(&nav.unwrap()), canon(&session.primary_root));
2077        assert!(t.err.contents().contains("was removed"));
2078    }
2079
2080    #[test]
2081    fn finish_exit_honors_explicit_switch() {
2082        // An explicit switch into a directory that still exists is passed through
2083        // untouched, even if the opened-in directory is gone.
2084        let chosen = tempfile::tempdir().unwrap();
2085        let mut t = test_cx(&[], "/work");
2086        let out = finish_exit(
2087            &mut t.cx,
2088            Path::new("/deleted"),
2089            Path::new("/deleted-root"),
2090            Some(chosen.path().to_path_buf()),
2091        )
2092        .unwrap();
2093        assert_eq!(out.as_deref(), Some(chosen.path()));
2094        assert!(t.err.contents().is_empty());
2095    }
2096
2097    #[test]
2098    fn finish_exit_drops_chosen_that_was_removed() {
2099        // A blocked exit that switched into a worktree a *different* finished job
2100        // then removed must not land the shell in the deleted directory: the dead
2101        // `chosen` is dropped and it falls through to the normal recovery — here the
2102        // opened-in dir still exists, so stay put (issue #46 overhaul).
2103        let opened = tempfile::tempdir().unwrap();
2104        let gone_chosen = opened.path().join("wt-removed");
2105        let mut t = test_cx(&[], "/work");
2106        let out = finish_exit(&mut t.cx, opened.path(), opened.path(), Some(gone_chosen)).unwrap();
2107        assert_eq!(out, None);
2108        assert!(t.err.contents().is_empty());
2109    }
2110
2111    #[test]
2112    fn finish_exit_stays_put_when_opened_dir_survives() {
2113        // No switch and the opened-in directory still exists: nothing is printed
2114        // and the shell stays where it is.
2115        let dir = tempfile::tempdir().unwrap();
2116        let mut t = test_cx(&[], "/work");
2117        let out = finish_exit(&mut t.cx, dir.path(), dir.path(), None).unwrap();
2118        assert_eq!(out, None);
2119        assert!(t.err.contents().is_empty());
2120    }
2121
2122    #[test]
2123    fn finish_exit_returns_to_root_when_opened_dir_deleted() {
2124        // The opened-in worktree was removed during the session but the root
2125        // survives: navigate to the root and explain the move on stderr.
2126        let root = tempfile::tempdir().unwrap();
2127        let gone = root.path().join("wt-x");
2128        let mut t = test_cx(&[], "/work");
2129        let out = finish_exit(&mut t.cx, &gone, root.path(), None).unwrap();
2130        assert_eq!(out.as_deref(), Some(root.path()));
2131        let err = t.err.contents();
2132        assert!(err.contains("was removed"));
2133        assert!(err.contains(&root.path().display().to_string()));
2134    }
2135
2136    #[test]
2137    fn finish_exit_reports_when_root_also_gone() {
2138        // Both the opened-in worktree and the root are gone: explain the
2139        // situation and navigate nowhere.
2140        let scratch = tempfile::tempdir().unwrap();
2141        let gone = scratch.path().join("wt-x");
2142        let gone_root = scratch.path().join("root");
2143        let mut t = test_cx(&[], "/work");
2144        let out = finish_exit(&mut t.cx, &gone, &gone_root, None).unwrap();
2145        assert_eq!(out, None);
2146        assert!(t.err.contents().contains("no longer available"));
2147    }
2148
2149    /// Canonicalizes a path so comparisons ignore `/private` symlink prefixes on
2150    /// macOS temp dirs.
2151    fn canon(p: &Path) -> PathBuf {
2152        std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
2153    }
2154}