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