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, 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
35pub(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
51pub 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
65pub 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
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 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
132fn 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
170async 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 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 let (tx, mut rx) = mpsc::channel::<Vec<Worktree>>(1);
194 spawn_enrichment(session.primary_root.clone(), cx.git.clone(), tx);
195
196 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 _ = ticker.tick(), if app.is_busy() => {
208 app.tick_busy();
209 tui.draw(app)?;
210 }
211 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 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 if !app.is_busy() {
242 tui.draw(app)?;
243 }
244 }
245 }
246 }
247 Ok(())
248}
249
250fn 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 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 Effect::DraftPrAi | Effect::SubmitPr { .. } => Ok(false),
294 }
295}
296
297fn 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
313struct 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 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 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
359enum Job {
362 Create {
365 branch: String,
367 base: Option<String>,
369 decision: Option<CreateDecision>,
371 },
372 Remove {
374 query: String,
376 },
377 DeleteBranch {
379 branch: String,
381 force: bool,
383 },
384 Materialize {
386 branch: String,
388 },
389 CheckoutPr {
391 number: u64,
393 },
394 CheckoutBranch {
396 worktree_dir: PathBuf,
398 branch: String,
400 },
401 Sync {
403 worktree_dir: PathBuf,
405 label: String,
407 },
408 SyncBranch {
411 branch: String,
413 label: String,
415 },
416 InitSubmodules {
418 dir: PathBuf,
420 count: usize,
422 },
423}
424
425enum JobOutcome {
429 Create {
432 branch: String,
434 base: Option<String>,
436 outcome: CreateOutcome,
438 },
439 Remove {
441 query: String,
443 result: std::result::Result<(), String>,
445 },
446 DeleteBranch {
448 branch: String,
450 force: bool,
452 result: std::result::Result<(), String>,
454 },
455 Materialize {
457 branch: String,
459 result: std::result::Result<(), String>,
461 },
462 CheckoutPr {
464 number: u64,
466 result: std::result::Result<(PathBuf, bool), String>,
468 },
469 CheckoutBranch {
471 branch: String,
473 result: std::result::Result<commands::checkout::SyncOutcome, String>,
475 },
476 Sync {
478 label: String,
480 result: std::result::Result<commands::sync::SyncOutcome, String>,
482 },
483 InitSubmodules {
485 count: usize,
487 result: std::result::Result<(), String>,
489 },
490}
491
492enum CreateOutcome {
497 Created,
499 CreatedNeedsSubmodules {
502 dir: PathBuf,
504 count: usize,
506 },
507 NeedsStaleConfirm {
509 behind: u32,
511 upstream_display: String,
513 can_fast_forward: bool,
515 },
516 Failed(String),
518}
519
520fn 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
533fn 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 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
614fn 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
628fn 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
690fn 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#[derive(Debug, Clone, Default)]
724pub struct ComposeSeed {
725 pub title: String,
727 pub body: String,
729 pub draft: bool,
731 pub model: crate::agent::AgentModel,
733 pub effort: crate::agent::Effort,
735}
736
737pub(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
793async 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#[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 _ => Ok(!matches!(app.mode, Mode::PrCompose(_))),
868 }
869}
870
871fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let repo = TestRepo::init();
1127 repo.git(&["branch", "topic"]); 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 let repo = TestRepo::init();
1154 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 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 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 let repo = TestRepo::init();
1190 repo.git(&["branch", "topic"]);
1191 let (mut t, session, mut app) = setup(&repo);
1192 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 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 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 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 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 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 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 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 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"); 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 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 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); 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"); 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 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 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 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 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 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 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 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 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 assert!(begin_job(&mut a, Effect::Refresh).is_none());
1736 assert!(!a.is_busy());
1738 }
1739
1740 #[test]
1741 fn anchor_at_root_repoints_cwd_and_returns_opened_worktree() {
1742 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 let repo = TestRepo::init();
1763 repo.add_worktree("feature/x", "../wt-x");
1764 let linked = repo.root().parent().unwrap().join("wt-x");
1765
1766 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 run_remove_command(&mut t.cx, "feature/x").unwrap();
1775 assert!(!linked.exists());
1776
1777 let again = open_session(&t.cx, &crate::git::RealGit).unwrap();
1779 assert_eq!(canon(&again.primary_root), canon(&session.primary_root));
1780
1781 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 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 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 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 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 fn canon(p: &Path) -> PathBuf {
1845 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
1846 }
1847}