1use 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
36pub(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
52pub 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
66pub 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
82fn 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
98fn 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 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
113fn 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 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
137fn 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
175async 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 if app.size.1 < crate::tui::app::MIN_HEIGHT {
185 app.too_small = true;
186 return Ok(());
187 }
188 tui.draw(app)?;
189
190 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 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 _ = ticker.tick(), if app.any_jobs() => {
215 app.tick_spinner();
216 tui.draw(app)?;
217 }
218 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 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 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
259fn 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 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 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 Effect::DraftPrAi | Effect::SubmitPr { .. } => Ok(false),
306 }
307}
308
309fn 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
325struct 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 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 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
371enum Job {
374 Create {
377 branch: String,
379 base: Option<String>,
381 decision: Option<CreateDecision>,
383 },
384 Remove {
386 query: String,
388 },
389 DeleteBranch {
391 branch: String,
393 force: bool,
395 },
396 Materialize {
398 branch: String,
400 },
401 CheckoutPr {
403 number: u64,
405 },
406 CheckoutBranch {
408 worktree_dir: PathBuf,
410 branch: String,
412 },
413 Sync {
415 worktree_dir: PathBuf,
417 label: String,
419 },
420 SyncBranch {
423 branch: String,
425 label: String,
427 },
428 InitSubmodules {
430 dir: PathBuf,
432 count: usize,
434 },
435}
436
437enum JobOutcome {
441 Create {
444 branch: String,
446 base: Option<String>,
448 outcome: CreateOutcome,
450 },
451 Remove {
453 query: String,
455 result: std::result::Result<(), String>,
457 },
458 DeleteBranch {
460 branch: String,
462 force: bool,
464 result: std::result::Result<(), String>,
466 },
467 Materialize {
469 branch: String,
471 result: std::result::Result<(), String>,
473 },
474 CheckoutPr {
476 number: u64,
478 result: std::result::Result<(PathBuf, bool), String>,
480 },
481 CheckoutBranch {
483 branch: String,
485 result: std::result::Result<commands::checkout::SyncOutcome, String>,
487 },
488 Sync {
490 label: String,
492 result: std::result::Result<commands::sync::SyncOutcome, String>,
494 },
495 InitSubmodules {
497 count: usize,
499 result: std::result::Result<(), String>,
501 },
502}
503
504enum CreateOutcome {
509 Created,
511 CreatedNeedsSubmodules {
516 dir: PathBuf,
518 count: usize,
520 auto: bool,
523 },
524 NeedsStaleConfirm {
526 behind: u32,
528 upstream_display: String,
530 can_fast_forward: bool,
532 },
533 Failed(String),
535}
536
537fn 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
550fn 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 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
644fn 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
665type PrFetch = std::result::Result<Vec<PrItem>, String>;
667
668fn 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
682fn 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
744fn 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#[derive(Debug, Clone, Default)]
778pub struct ComposeSeed {
779 pub title: String,
781 pub body: String,
783 pub draft: bool,
785 pub model: crate::agent::AgentModel,
787 pub effort: crate::agent::Effort,
789}
790
791pub(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
847async 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#[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 _ => Ok(!matches!(app.mode, Mode::PrCompose(_))),
922 }
923}
924
925fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let repo = TestRepo::init();
1178 repo.git(&["branch", "topic"]); 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 let repo = TestRepo::init();
1205 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 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 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 let repo = TestRepo::init();
1245 repo.git(&["branch", "topic"]);
1246 let (mut t, session, mut app) = setup(&repo);
1247 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 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 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 let repo = TestRepo::init();
1291 repo.add_submodule("libs/sub");
1292 repo.git(&["branch", "topic"]); 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 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 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 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 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 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 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 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 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 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"); 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 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 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); 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"); 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 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 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 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 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 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 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 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 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 assert!(resolve_job(&a, Effect::Refresh).is_none());
1849 }
1850
1851 #[test]
1852 fn spawn_job_refuses_conflicting_action_on_same_row() {
1853 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 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 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 let repo = TestRepo::init();
1891 repo.add_worktree("feature/x", "../wt-x");
1892 let linked = repo.root().parent().unwrap().join("wt-x");
1893
1894 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 run_remove_command(&mut t.cx, "feature/x").unwrap();
1903 assert!(!linked.exists());
1904
1905 let again = open_session(&t.cx, &crate::git::RealGit).unwrap();
1907 assert_eq!(canon(&again.primary_root), canon(&session.primary_root));
1908
1909 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 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 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 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 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 fn canon(p: &Path) -> PathBuf {
1973 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
1974 }
1975}