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