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(
152 cx: &mut Cx,
153 opened_in: &Path,
154 primary_root: &Path,
155 chosen: Option<PathBuf>,
156) -> Result<Option<PathBuf>> {
157 if let Some(path) = chosen
158 && path.exists()
159 {
160 return Ok(Some(path));
161 }
162 if opened_in.exists() {
163 return Ok(None);
164 }
165 if primary_root.exists() {
166 cx.err.line(&format!(
167 "worktree {} was removed during this session; returning to the repository root at {}",
168 opened_in.display(),
169 primary_root.display(),
170 ))?;
171 Ok(Some(primary_root.to_path_buf()))
172 } else {
173 cx.err.line(&format!(
174 "worktree {} was removed during this session, and the repository root is no longer available",
175 opened_in.display(),
176 ))?;
177 Ok(None)
178 }
179}
180
181async fn run_loop(cx: &mut Cx, session: &Session, app: &mut App, initial: Effect) -> Result<()> {
185 install_panic_hook();
186 let mut tui = Tui::enter(app.mouse)?;
187 app.size = tui.size();
188 if app.size.1 < crate::tui::app::MIN_HEIGHT {
191 app.too_small = true;
192 return Ok(());
193 }
194 tui.draw(app)?;
195
196 let (job_tx, mut job_rx) = mpsc::channel::<(JobKey, JobOutcome)>(64);
200 let (pr_tx, mut pr_rx) = mpsc::channel::<PrFetch>(4);
201
202 if initial != Effect::None {
203 if dispatch_effect(cx, session, app, &mut tui, initial, &pr_tx)? {
204 return Ok(());
205 }
206 tui.draw(app)?;
207 }
208
209 let (tx, mut rx) = mpsc::channel::<Vec<Worktree>>(1);
211 spawn_enrichment(session.primary_root.clone(), cx.git.clone(), tx);
212
213 let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
214
215 let mut events = EventStream::new();
216 loop {
217 tokio::select! {
218 _ = ticker.tick(), if app.any_jobs() => {
221 app.tick_spinner();
222 tui.draw(app)?;
223 }
224 Some((key, outcome)) = job_rx.recv() => {
228 app.finish_job(&key);
229 apply_outcome(cx, session, app, outcome);
230 for effect in app.take_pending_jobs() {
231 spawn_job(cx, app, effect, &job_tx);
232 }
233 tui.draw(app)?;
234 if app.exit_now() {
235 break;
236 }
237 }
238 Some(fetch) = pr_rx.recv() => {
240 apply_prs(app, fetch);
241 tui.draw(app)?;
242 }
243 maybe = events.next() => {
244 let Some(Ok(event)) = maybe else { continue };
245 let effect = app.handle_event(event);
247 if is_background_action(&effect) {
248 spawn_job(cx, app, effect, &job_tx);
249 tui.draw(app)?;
250 } else if dispatch_effect(cx, session, app, &mut tui, effect, &pr_tx)? {
251 break;
252 } else {
253 tui.draw(app)?;
254 }
255 }
256 Some(worktrees) = rx.recv() => {
257 mark_all_loaded(app, worktrees);
258 tui.draw(app)?;
259 }
260 }
261 }
262 Ok(())
263}
264
265fn dispatch_effect(
268 cx: &mut Cx,
269 session: &Session,
270 app: &mut App,
271 tui: &mut Tui,
272 effect: Effect,
273 pr_tx: &mpsc::Sender<PrFetch>,
274) -> Result<bool> {
275 match effect {
276 Effect::None => Ok(false),
277 Effect::Switch(_) | Effect::Quit => Ok(true),
278 Effect::TooSmall => {
279 app.too_small = true;
280 Ok(true)
281 }
282 Effect::Refresh => {
283 do_refresh(cx, app, &session.primary_root);
284 Ok(false)
285 }
286 Effect::FetchPrs => {
287 spawn_fetch_prs(cx, session, pr_tx);
290 Ok(false)
291 }
292 Effect::OpenEditor(path) => {
293 tui.suspend()?;
294 run_editor(cx, session, &path);
295 tui.resume()?;
296 Ok(false)
297 }
298 Effect::Create { .. }
302 | Effect::Remove(_)
303 | Effect::DeleteBranch { .. }
304 | Effect::MaterializeBranch { .. }
305 | Effect::CheckoutPr(_)
306 | Effect::CheckoutBranch { .. }
307 | Effect::Sync { .. }
308 | Effect::InitSubmodules { .. } => Ok(false),
309 Effect::DraftPrAi | Effect::SubmitPr { .. } => Ok(false),
312 }
313}
314
315fn is_background_action(effect: &Effect) -> bool {
318 matches!(
319 effect,
320 Effect::Create { .. }
321 | Effect::Remove(_)
322 | Effect::DeleteBranch { .. }
323 | Effect::MaterializeBranch { .. }
324 | Effect::CheckoutPr(_)
325 | Effect::CheckoutBranch { .. }
326 | Effect::Sync { .. }
327 | Effect::InitSubmodules { .. }
328 )
329}
330
331struct JobCx {
337 env: crate::cx::Env,
338 cwd: PathBuf,
339 git: Arc<dyn GitCli + Send + Sync>,
340 gh: Arc<dyn crate::gh::GhClient + Send + Sync>,
341 agent: Arc<dyn crate::agent::AgentClient + Send + Sync>,
342}
343
344impl JobCx {
345 fn capture(cx: &Cx) -> Self {
349 JobCx {
350 env: cx.env.clone(),
351 cwd: cx.cwd.clone(),
352 git: cx.git.clone(),
353 gh: cx.gh.clone(),
354 agent: cx.agent.clone(),
355 }
356 }
357
358 fn into_cx(self) -> Cx {
362 let mut cx = Cx::new(
363 Stream::new(Box::new(Vec::<u8>::new()), false),
364 Stream::new(Box::new(Vec::<u8>::new()), false),
365 self.env,
366 self.cwd,
367 self.git,
368 self.gh,
369 self.agent,
370 Box::new(SilentInput),
371 );
372 cx.no_pager = true;
373 cx
374 }
375}
376
377enum Job {
380 Create {
383 branch: String,
385 base: Option<String>,
387 decision: Option<CreateDecision>,
389 },
390 Remove {
392 query: String,
394 },
395 DeleteBranch {
397 branch: String,
399 force: bool,
401 },
402 Materialize {
404 branch: String,
406 },
407 CheckoutPr {
409 number: u64,
411 },
412 CheckoutBranch {
414 worktree_dir: PathBuf,
416 branch: String,
418 },
419 Sync {
421 worktree_dir: PathBuf,
423 label: String,
425 },
426 SyncBranch {
429 branch: String,
431 label: String,
433 },
434 InitSubmodules {
436 dir: PathBuf,
438 count: usize,
440 },
441}
442
443enum JobOutcome {
447 Create {
450 branch: String,
452 base: Option<String>,
454 outcome: CreateOutcome,
456 },
457 Remove {
459 query: String,
461 result: std::result::Result<(), String>,
463 },
464 DeleteBranch {
466 branch: String,
468 force: bool,
470 result: std::result::Result<(), String>,
472 },
473 Materialize {
475 branch: String,
477 result: std::result::Result<(), String>,
479 },
480 CheckoutPr {
482 number: u64,
484 result: std::result::Result<(PathBuf, bool), String>,
486 },
487 CheckoutBranch {
489 branch: String,
491 result: std::result::Result<commands::checkout::SyncOutcome, String>,
493 },
494 Sync {
496 label: String,
498 result: std::result::Result<commands::sync::SyncOutcome, String>,
500 },
501 InitSubmodules {
503 count: usize,
505 result: std::result::Result<(), String>,
507 },
508}
509
510enum CreateOutcome {
515 Created,
517 CreatedNeedsSubmodules {
522 dir: PathBuf,
524 count: usize,
526 auto: bool,
529 },
530 NeedsStaleConfirm {
532 behind: u32,
534 upstream_display: String,
536 can_fast_forward: bool,
538 },
539 Failed(String),
541}
542
543fn remove_query_of(app: &App, index: usize) -> Option<String> {
546 let worktree = app.worktrees.get(index)?;
547 Some(worktree.branch.clone().unwrap_or_else(|| {
548 worktree
549 .path
550 .file_name()
551 .map(|n| n.to_string_lossy().into_owned())
552 .unwrap_or_default()
553 }))
554}
555
556fn resolve_job(app: &App, effect: Effect) -> Option<(Job, JobKey, String)> {
561 match effect {
562 Effect::Create {
563 branch,
564 base,
565 decision,
566 } => {
567 let label = format!("Creating {branch}");
568 let key = JobKey::New(branch.clone());
569 Some((
570 Job::Create {
571 branch,
572 base,
573 decision,
574 },
575 key,
576 label,
577 ))
578 }
579 Effect::Remove(index) => {
580 let worktree = app.worktrees.get(index)?;
581 let key = JobKey::Path(worktree.path.clone());
582 let query = remove_query_of(app, index)?;
583 let label = format!("Removing {query}");
584 Some((Job::Remove { query }, key, label))
585 }
586 Effect::DeleteBranch { branch, force } => {
587 let label = format!("Deleting branch {branch}");
588 let key = JobKey::Branch(branch.clone());
589 Some((Job::DeleteBranch { branch, force }, key, label))
590 }
591 Effect::MaterializeBranch { branch } => {
592 let label = format!("Creating worktree for {branch}");
593 let key = JobKey::Branch(branch.clone());
594 Some((Job::Materialize { branch }, key, label))
595 }
596 Effect::CheckoutPr(number) => {
597 let label = format!("Checking out PR #{number}");
598 let key = JobKey::New(format!("PR #{number}"));
599 Some((Job::CheckoutPr { number }, key, label))
600 }
601 Effect::CheckoutBranch {
602 worktree_index,
603 branch,
604 } => {
605 let worktree_dir = app.worktrees.get(worktree_index)?.path.clone();
606 let label = format!("Checking out {branch}");
607 let key = JobKey::Path(worktree_dir.clone());
608 Some((
609 Job::CheckoutBranch {
610 worktree_dir,
611 branch,
612 },
613 key,
614 label,
615 ))
616 }
617 Effect::Sync { worktree_index } => {
618 let worktree = app.worktrees.get(worktree_index)?;
619 let label = worktree
620 .branch
621 .clone()
622 .unwrap_or_else(|| "worktree".to_string());
623 let display = format!("Syncing {label}");
624 let (job, key) = if worktree.has_worktree {
625 (
626 Job::Sync {
627 worktree_dir: worktree.path.clone(),
628 label,
629 },
630 JobKey::Path(worktree.path.clone()),
631 )
632 } else {
633 let branch = worktree.branch.clone()?;
636 let key = JobKey::Branch(branch.clone());
637 (Job::SyncBranch { branch, label }, key)
638 };
639 Some((job, key, display))
640 }
641 Effect::InitSubmodules { dir, count } => {
642 let label = format!("Initializing {count} submodule(s)");
643 let key = JobKey::Path(dir.clone());
644 Some((Job::InitSubmodules { dir, count }, key, label))
645 }
646 _ => None,
647 }
648}
649
650fn spawn_job(cx: &Cx, app: &mut App, effect: Effect, tx: &mpsc::Sender<(JobKey, JobOutcome)>) {
655 let Some((job, key, label)) = resolve_job(app, effect) else {
656 return;
657 };
658 if app.has_job(&key) {
659 app.set_status(format!("{label} — already in progress"), StatusKind::Info);
660 return;
661 }
662 app.begin_job(key.clone(), label);
663 let jobcx = JobCx::capture(cx);
664 let tx = tx.clone();
665 tokio::task::spawn_blocking(move || {
666 let outcome = run_job(jobcx, job);
667 let _ = tx.blocking_send((key, outcome));
668 });
669}
670
671type PrFetch = std::result::Result<Vec<PrItem>, String>;
673
674fn spawn_fetch_prs(cx: &Cx, session: &Session, tx: &mpsc::Sender<PrFetch>) {
677 let gh = cx.gh.clone();
678 let dir = session
679 .repo
680 .current_workdir()
681 .unwrap_or_else(|| session.primary_root.clone());
682 let tx = tx.clone();
683 tokio::task::spawn_blocking(move || {
684 let _ = tx.blocking_send(fetch_prs_result(gh.as_ref(), &dir));
685 });
686}
687
688fn run_job(jobcx: JobCx, job: Job) -> JobOutcome {
691 let mut cx = jobcx.into_cx();
692 match job {
693 Job::Create {
694 branch,
695 base,
696 decision,
697 } => {
698 let outcome = run_create_command(&mut cx, &branch, base.clone(), decision);
699 JobOutcome::Create {
700 branch,
701 base,
702 outcome,
703 }
704 }
705 Job::Remove { query } => {
706 let result = run_remove_command(&mut cx, &query);
707 JobOutcome::Remove { query, result }
708 }
709 Job::DeleteBranch { branch, force } => {
710 let result = run_delete_branch_command(&mut cx, &branch, force);
711 JobOutcome::DeleteBranch {
712 branch,
713 force,
714 result,
715 }
716 }
717 Job::Materialize { branch } => {
718 let result = run_materialize_command(&mut cx, &branch);
719 JobOutcome::Materialize { branch, result }
720 }
721 Job::CheckoutPr { number } => {
722 let result = run_checkout_pr_command(&mut cx, number);
723 JobOutcome::CheckoutPr { number, result }
724 }
725 Job::CheckoutBranch {
726 worktree_dir,
727 branch,
728 } => {
729 let result = run_checkout_branch_command(&mut cx, &worktree_dir, &branch);
730 JobOutcome::CheckoutBranch { branch, result }
731 }
732 Job::Sync {
733 worktree_dir,
734 label,
735 } => {
736 let result = run_sync_command(&mut cx, &worktree_dir);
737 JobOutcome::Sync { label, result }
738 }
739 Job::SyncBranch { branch, label } => {
740 let result = run_sync_branch_command(&mut cx, &branch);
741 JobOutcome::Sync { label, result }
742 }
743 Job::InitSubmodules { dir, count } => {
744 let result = run_init_submodules_command(&mut cx, &dir);
745 JobOutcome::InitSubmodules { count, result }
746 }
747 }
748}
749
750fn apply_outcome(cx: &Cx, session: &Session, app: &mut App, outcome: JobOutcome) {
753 let root = &session.primary_root;
754 match outcome {
755 JobOutcome::Create {
756 branch,
757 base,
758 outcome,
759 } => apply_create(cx, app, &branch, base, outcome, root),
760 JobOutcome::Remove { query, result } => apply_remove(cx, app, &query, result, root),
761 JobOutcome::DeleteBranch {
762 branch,
763 force,
764 result,
765 } => apply_delete_branch(cx, app, &branch, force, result, root),
766 JobOutcome::Materialize { branch, result } => {
767 apply_materialize(cx, app, &branch, result, root)
768 }
769 JobOutcome::CheckoutPr { number, result } => {
770 apply_checkout_pr(cx, app, number, result, root)
771 }
772 JobOutcome::CheckoutBranch { branch, result } => {
773 apply_checkout_branch(cx, app, &branch, result, root)
774 }
775 JobOutcome::Sync { label, result } => apply_sync(cx, app, &label, result, root),
776 JobOutcome::InitSubmodules { count, result } => {
777 apply_init_submodules(cx, app, count, result, root)
778 }
779 }
780}
781
782#[derive(Debug, Clone, Default)]
784pub struct ComposeSeed {
785 pub title: String,
787 pub body: String,
789 pub draft: bool,
791 pub model: crate::agent::AgentModel,
793 pub effort: crate::agent::Effort,
795}
796
797pub(crate) fn run_pr_compose(
804 cx: &mut Cx,
805 session: &Session,
806 ctx: sendit::PrContext,
807 action: sendit::PrAction,
808 seed: ComposeSeed,
809 draft_ai: bool,
810) -> Result<Option<(sendit::PrOutcome, sendit::PrSpec)>> {
811 let git = cx.git.clone();
812 let mut app = build_app(cx, session, git.as_ref())?;
813 let action_label = match action {
814 sendit::PrAction::Create => "create".to_string(),
815 sendit::PrAction::Update { number } => format!("update #{number}"),
816 };
817 app.mode = Mode::PrCompose(PrComposeState {
818 title: seed.title,
819 body: seed.body,
820 draft: seed.draft,
821 branch: ctx.branch.clone(),
822 trunk: ctx.trunk.clone(),
823 action_label,
824 model: seed.model,
825 effort: seed.effort,
826 ..Default::default()
827 });
828
829 let initial = if draft_ai {
830 Effect::DraftPrAi
831 } else {
832 Effect::None
833 };
834 let mut outcome: Option<(sendit::PrOutcome, sendit::PrSpec)> = None;
835 let runtime = tokio::runtime::Runtime::new()?;
836 runtime.block_on(run_compose_loop(
837 cx,
838 session,
839 &mut app,
840 &ctx,
841 action,
842 initial,
843 &mut outcome,
844 ))?;
845
846 if app.too_small {
847 cx.err.line("terminal too small (need ≥5 rows)")?;
848 return Err(Error::operation("terminal too small"));
849 }
850 Ok(outcome)
851}
852
853async fn run_compose_loop(
857 cx: &mut Cx,
858 session: &Session,
859 app: &mut App,
860 ctx: &sendit::PrContext,
861 action: sendit::PrAction,
862 initial: Effect,
863 outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
864) -> Result<()> {
865 install_panic_hook();
866 let mut tui = Tui::enter(app.mouse)?;
867 app.size = tui.size();
868 if app.size.1 < crate::tui::app::MIN_HEIGHT {
869 app.too_small = true;
870 return Ok(());
871 }
872 tui.draw(app)?;
873
874 if initial != Effect::None
875 && compose_dispatch(cx, session, app, &mut tui, ctx, action, initial, outcome)?
876 {
877 return Ok(());
878 }
879 tui.draw(app)?;
880
881 let mut events = EventStream::new();
882 while let Some(maybe) = events.next().await {
883 let Ok(event) = maybe else { continue };
884 let effect = app.handle_event(event);
885 if compose_dispatch(cx, session, app, &mut tui, ctx, action, effect, outcome)? {
886 break;
887 }
888 tui.draw(app)?;
889 }
890 Ok(())
891}
892
893#[allow(clippy::too_many_arguments)]
897fn compose_dispatch(
898 cx: &mut Cx,
899 session: &Session,
900 app: &mut App,
901 tui: &mut Tui,
902 ctx: &sendit::PrContext,
903 action: sendit::PrAction,
904 effect: Effect,
905 outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
906) -> Result<bool> {
907 match effect {
908 Effect::Quit => Ok(true),
909 Effect::TooSmall => {
910 app.too_small = true;
911 Ok(true)
912 }
913 Effect::DraftPrAi => {
914 tui.suspend()?;
915 do_draft_pr_ai(cx, session, app, ctx);
916 tui.resume()?;
917 Ok(false)
918 }
919 Effect::SubmitPr { title, body, draft } => {
920 tui.suspend()?;
921 let done = do_submit_pr(cx, session, app, ctx, action, title, body, draft, outcome);
922 tui.resume()?;
923 Ok(done)
924 }
925 _ => Ok(!matches!(app.mode, Mode::PrCompose(_))),
928 }
929}
930
931fn run_editor(cx: &Cx, session: &Session, path: &Path) {
933 let Ok(editor) = resolve_editor(session.config.editor.as_deref(), &cx.env) else {
934 return;
935 };
936 let argv = editor_argv(&editor);
937 if let Some((program, rest)) = argv.split_first() {
938 let _ = std::process::Command::new(program)
939 .args(rest)
940 .arg(path)
941 .status();
942 }
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948 use crate::testutil::{FakeGh, TestRepo, test_cx};
949 use crate::tui::app::Mode;
950 use std::sync::Arc as StdArc;
951
952 fn setup(repo: &TestRepo) -> (crate::testutil::TestCx, Session, App) {
954 let t = test_cx(&[], repo.root().to_str().unwrap());
955 let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
956 let worktrees = build_rows(&session.repo, &crate::git::RealGit).unwrap();
957 let app = App::new(worktrees, app_config(&session.config, true), (100, 30));
958 (t, session, app)
959 }
960
961 #[test]
962 fn app_config_maps_settings() {
963 let config = Config {
964 ui_nerd_fonts: true,
965 ui_mouse: false,
966 ..Config::default()
967 };
968 let cfg = app_config(&config, false);
969 assert!(cfg.nerd_fonts);
970 assert!(!cfg.mouse);
971 assert!(!cfg.color);
972 assert!(app_config(&config, true).color);
973 }
974
975 #[test]
976 fn do_create_adds_a_worktree_and_refreshes() {
977 let repo = TestRepo::init();
978 let (mut t, session, mut app) = setup(&repo);
979 app.mode = Mode::Create(Default::default());
980 do_create(&mut t.cx, &session, &mut app, "feature/new".into(), None);
981 assert_eq!(app.mode, Mode::List);
982 assert!(
983 app.worktrees
984 .iter()
985 .any(|w| w.branch.as_deref() == Some("feature/new"))
986 );
987 assert!(app.status_message.as_deref().unwrap().contains("created"));
988 assert_eq!(
992 app.selected_worktree().unwrap().branch.as_deref(),
993 Some("feature/new")
994 );
995 }
996
997 #[test]
998 fn do_create_error_shows_in_modal() {
999 let repo = TestRepo::init();
1000 let (mut t, session, mut app) = setup(&repo);
1001 app.mode = Mode::Create(Default::default());
1002 do_create(
1004 &mut t.cx,
1005 &session,
1006 &mut app,
1007 "x".into(),
1008 Some("nope-ref".into()),
1009 );
1010 if let Mode::Create(state) = &app.mode {
1011 assert!(state.error.is_some());
1012 } else {
1013 panic!("expected create mode with error");
1014 }
1015 }
1016
1017 fn main_behind_origin(repo: &TestRepo) -> String {
1020 let c1 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1021 repo.write("u.txt", "1\n");
1022 repo.commit_all("ahead on origin");
1023 let c2 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1024 repo.git(&["update-ref", "refs/remotes/origin/main", &c2]);
1025 repo.git(&["reset", "-q", "--hard", &c1]);
1026 repo.git(&["config", "branch.main.remote", "origin"]);
1027 repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
1028 c2
1029 }
1030
1031 fn feat_branch_behind_origin(repo: &TestRepo) -> String {
1035 let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1036 repo.git(&["checkout", "-q", "-b", "feat"]);
1037 repo.write("u.txt", "1\n");
1038 repo.commit_all("ahead on origin/feat");
1039 let tip = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1040 repo.git(&["update-ref", "refs/remotes/origin/feat", &tip]);
1041 repo.git(&["checkout", "-q", "main"]);
1042 repo.git(&["branch", "-f", "feat", &base]);
1044 repo.git(&["config", "branch.feat.remote", "origin"]);
1045 repo.git(&["config", "branch.feat.merge", "refs/heads/feat"]);
1046 tip
1047 }
1048
1049 #[test]
1050 fn do_create_stale_base_opens_confirm_modal() {
1051 let repo = TestRepo::init();
1054 main_behind_origin(&repo);
1055 let (mut t, session, mut app) = setup(&repo);
1056 app.mode = Mode::Create(Default::default());
1057 do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
1058 match &app.mode {
1059 Mode::ConfirmStaleBase(s) => {
1060 assert_eq!(s.branch, "feature");
1061 assert_eq!(s.behind, 1);
1062 assert!(s.can_fast_forward);
1063 }
1064 other => panic!("expected ConfirmStaleBase, got {other:?}"),
1065 }
1066 assert!(
1068 !app.worktrees
1069 .iter()
1070 .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
1071 );
1072 }
1073
1074 #[test]
1075 fn do_create_with_submodules_opens_confirm_modal() {
1076 let repo = TestRepo::init();
1079 repo.add_submodule("libs/sub");
1080 let (mut t, session, mut app) = setup(&repo);
1081 app.mode = Mode::Create(Default::default());
1082 do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
1083 match &app.mode {
1084 Mode::ConfirmInitSubmodules(s) => {
1085 assert_eq!(s.branch, "feature");
1086 assert_eq!(s.count, 1);
1087 assert!(s.dir.exists());
1088 }
1089 other => panic!("expected ConfirmInitSubmodules, got {other:?}"),
1090 }
1091 assert!(
1093 app.worktrees
1094 .iter()
1095 .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
1096 );
1097 }
1098
1099 #[test]
1100 fn apply_init_submodules_reports_success_and_refreshes() {
1101 let repo = TestRepo::init();
1102 let (t, session, mut app) = setup(&repo);
1103 apply_init_submodules(&t.cx, &mut app, 2, Ok(()), &session.primary_root);
1106 assert_eq!(app.mode, Mode::List);
1107 assert!(
1108 app.status_message
1109 .as_deref()
1110 .unwrap()
1111 .contains("initialized 2 submodule")
1112 );
1113 }
1114
1115 #[test]
1116 fn apply_init_submodules_error_shows_in_status() {
1117 let repo = TestRepo::init();
1118 let (t, session, mut app) = setup(&repo);
1119 apply_init_submodules(
1120 &t.cx,
1121 &mut app,
1122 1,
1123 Err("boom".into()),
1124 &session.primary_root,
1125 );
1126 assert_eq!(app.mode, Mode::List);
1127 let msg = app.status_message.as_deref().unwrap();
1128 assert!(msg.contains("failed to initialize submodules"));
1129 assert!(msg.contains("boom"));
1130 }
1131
1132 #[test]
1133 fn create_update_decision_fast_forwards_then_creates() {
1134 let repo = TestRepo::init();
1137 let c2 = main_behind_origin(&repo);
1138 let (t, session, mut app) = setup(&repo);
1139 let outcome = run_job(
1140 JobCx::capture(&t.cx),
1141 Job::Create {
1142 branch: "feature".into(),
1143 base: None,
1144 decision: Some(CreateDecision::Update),
1145 },
1146 );
1147 apply_outcome(&t.cx, &session, &mut app, outcome);
1148 assert_eq!(app.mode, Mode::List);
1149 assert_eq!(repo.git(&["rev-parse", "refs/heads/main"]).trim(), c2);
1150 assert_eq!(repo.git(&["rev-parse", "refs/heads/feature"]).trim(), c2);
1151 }
1152
1153 #[test]
1154 fn do_remove_removes_selected() {
1155 let repo = TestRepo::init();
1156 repo.add_worktree("feature/x", "../wt-x");
1157 let (mut t, session, mut app) = setup(&repo);
1159 let index = app
1160 .worktrees
1161 .iter()
1162 .position(|w| w.branch.as_deref() == Some("feature/x"))
1163 .unwrap();
1164 do_remove(&mut t.cx, &session, &mut app, index);
1165 assert!(
1168 !app.worktrees
1169 .iter()
1170 .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature/x"))
1171 );
1172 assert!(
1173 app.worktrees
1174 .iter()
1175 .any(|w| !w.has_worktree && w.branch.as_deref() == Some("feature/x"))
1176 );
1177 }
1178
1179 #[test]
1180 fn do_delete_branch_removes_branch_row_and_refreshes() {
1181 let repo = TestRepo::init();
1184 repo.git(&["branch", "topic"]); let (mut t, session, mut app) = setup(&repo);
1186 assert!(
1187 app.worktrees
1188 .iter()
1189 .any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
1190 );
1191 do_delete_branch(&mut t.cx, &session, &mut app, "topic".into(), false);
1192 assert_eq!(app.mode, Mode::List);
1193 assert!(
1194 !app.worktrees
1195 .iter()
1196 .any(|w| w.branch.as_deref() == Some("topic"))
1197 );
1198 assert!(
1199 app.status_message
1200 .as_deref()
1201 .unwrap()
1202 .contains("deleted branch topic")
1203 );
1204 }
1205
1206 #[test]
1207 fn do_delete_branch_unmerged_reprompts_then_force_deletes() {
1208 let repo = TestRepo::init();
1211 repo.add_worktree("unmerged", "../wt-unmerged");
1214 let wt = repo.root().parent().unwrap().join("wt-unmerged");
1215 std::fs::write(wt.join("c.txt"), "x\n").unwrap();
1216 let dir = wt.to_string_lossy().into_owned();
1217 repo.git(&["-C", &dir, "add", "-A"]);
1218 repo.git(&["-C", &dir, "commit", "-q", "-m", "unmerged change"]);
1219 repo.git(&["worktree", "remove", "--force", &dir]);
1220 let (mut t, session, mut app) = setup(&repo);
1221 do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), false);
1223 assert!(matches!(
1224 app.mode,
1225 Mode::ConfirmDeleteBranch { force: true, .. }
1226 ));
1227 assert!(
1228 app.worktrees
1229 .iter()
1230 .any(|w| w.branch.as_deref() == Some("unmerged"))
1231 );
1232 app.mode = Mode::List;
1235 do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), true);
1236 assert_eq!(app.mode, Mode::List);
1237 assert!(
1238 !app.worktrees
1239 .iter()
1240 .any(|w| w.branch.as_deref() == Some("unmerged"))
1241 );
1242 }
1243
1244 #[test]
1245 fn do_materialize_branch_creates_worktree_and_stays_focused() {
1246 let repo = TestRepo::init();
1251 repo.git(&["branch", "topic"]);
1252 let (mut t, session, mut app) = setup(&repo);
1253 assert!(
1255 app.worktrees
1256 .iter()
1257 .any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
1258 );
1259 do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
1260 assert_eq!(app.mode, Mode::List);
1261 assert!(app.chosen.is_none());
1262 assert!(
1264 app.worktrees
1265 .iter()
1266 .any(|w| w.has_worktree && w.branch.as_deref() == Some("topic"))
1267 );
1268 let focused = app.selected_worktree().unwrap();
1269 assert!(focused.has_worktree && focused.branch.as_deref() == Some("topic"));
1270 assert!(
1271 app.status_message
1272 .as_deref()
1273 .unwrap()
1274 .contains("created topic")
1275 );
1276 }
1277
1278 #[test]
1279 fn do_materialize_branch_error_shows_in_status() {
1280 let repo = TestRepo::init();
1283 repo.add_worktree("dup", "../manual-dup");
1284 let (mut t, session, mut app) = setup(&repo);
1285 do_materialize_branch(&mut t.cx, &session, &mut app, "dup".into());
1286 assert!(app.chosen.is_none());
1287 assert_eq!(app.status_kind, StatusKind::Error);
1288 assert!(app.status_message.is_some());
1289 }
1290
1291 #[test]
1292 fn do_materialize_branch_queues_background_submodule_init() {
1293 let repo = TestRepo::init();
1297 repo.add_submodule("libs/sub");
1298 repo.git(&["branch", "topic"]); let (mut t, session, mut app) = setup(&repo);
1300 do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
1301 assert!(app.chosen.is_none());
1302 let queued = app.take_pending_jobs();
1304 assert!(
1305 queued
1306 .iter()
1307 .any(|e| matches!(e, Effect::InitSubmodules { .. })),
1308 "expected a queued InitSubmodules job, got {queued:?}"
1309 );
1310 }
1311
1312 #[test]
1313 fn apply_create_auto_policy_queues_submodule_job() {
1314 let repo = TestRepo::init();
1318 repo.add_submodule("libs/sub");
1319 let (t, session, mut app) = setup(&repo);
1320 apply_create(
1321 &t.cx,
1322 &mut app,
1323 "feature",
1324 None,
1325 CreateOutcome::CreatedNeedsSubmodules {
1326 dir: session.primary_root.clone(),
1327 count: 1,
1328 auto: true,
1329 },
1330 &session.primary_root,
1331 );
1332 assert_eq!(app.mode, Mode::List);
1333 let queued = app.take_pending_jobs();
1334 assert!(
1335 queued
1336 .iter()
1337 .any(|e| matches!(e, Effect::InitSubmodules { .. }))
1338 );
1339 }
1340
1341 #[test]
1342 fn apply_create_while_exit_blocked_queues_submodule_job_and_keeps_waiting() {
1343 use crate::tui::app::{ExitBlockedState, ExitIntent, JobKey};
1350 let repo = TestRepo::init();
1351 repo.add_submodule("libs/sub");
1352 let (t, session, mut app) = setup(&repo);
1353 app.mode = Mode::ExitBlocked(ExitBlockedState {
1356 intent: ExitIntent::Switch(session.primary_root.clone()),
1357 });
1358 apply_create(
1359 &t.cx,
1360 &mut app,
1361 "feature",
1362 None,
1363 CreateOutcome::CreatedNeedsSubmodules {
1364 dir: session.primary_root.clone(),
1365 count: 1,
1366 auto: false, },
1368 &session.primary_root,
1369 );
1370 assert!(matches!(app.mode, Mode::ExitBlocked(_)));
1372 let queued = app.take_pending_jobs();
1374 assert!(
1375 queued
1376 .iter()
1377 .any(|e| matches!(e, Effect::InitSubmodules { .. }))
1378 );
1379 let key = JobKey::Path(session.primary_root.clone());
1382 app.begin_job(key.clone(), "Initializing 1 submodule(s)");
1383 assert!(!app.exit_now());
1384 app.finish_job(&key);
1385 assert!(app.exit_now());
1386 assert_eq!(app.chosen, Some(session.primary_root.clone()));
1387 }
1388
1389 #[test]
1390 fn do_fetch_prs_populates_picker() {
1391 let repo = TestRepo::init();
1392 let (mut t, session, mut app) = setup(&repo);
1393 t.cx.gh = StdArc::new(FakeGh::with_list(vec![crate::gh::PrSummary {
1394 number: 5,
1395 title: "T".into(),
1396 author: crate::gh::Author {
1397 login: "alice".into(),
1398 },
1399 state: "OPEN".into(),
1400 is_draft: false,
1401 head_ref_name: "h".into(),
1402 created_at: String::new(),
1403 }]));
1404 app.mode = Mode::PrPicker(Default::default());
1405 do_fetch_prs(&t.cx, &session, &mut app);
1406 if let Mode::PrPicker(state) = &app.mode {
1407 assert!(!state.loading);
1408 assert_eq!(state.prs.len(), 1);
1409 assert_eq!(state.prs[0].number, 5);
1410 } else {
1411 panic!("expected pr picker");
1412 }
1413 }
1414
1415 #[test]
1416 fn do_fetch_prs_surfaces_gh_error() {
1417 let repo = TestRepo::init();
1418 let (mut t, session, mut app) = setup(&repo);
1419 t.cx.gh = StdArc::new(FakeGh::unavailable());
1420 app.mode = Mode::PrPicker(Default::default());
1421 do_fetch_prs(&t.cx, &session, &mut app);
1422 if let Mode::PrPicker(state) = &app.mode {
1423 assert!(state.error.is_some());
1424 } else {
1425 panic!("expected pr picker");
1426 }
1427 }
1428
1429 #[test]
1430 fn do_refresh_reloads_worktrees() {
1431 let repo = TestRepo::init();
1432 let (t, session, mut app) = setup(&repo);
1433 repo.add_worktree("added", "../wt-added");
1435 do_refresh(&t.cx, &mut app, &session.primary_root);
1436 assert!(
1437 app.worktrees
1438 .iter()
1439 .any(|w| w.branch.as_deref() == Some("added"))
1440 );
1441 }
1442
1443 fn repo_with_pr(number: u64) -> TestRepo {
1446 let repo = TestRepo::init();
1447 repo.write("pr.txt", "from pr\n");
1448 repo.commit_all("pr commit");
1449 let pr_oid = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1450 repo.git(&["update-ref", &format!("refs/pull/{number}/head"), &pr_oid]);
1451 repo.git(&["reset", "-q", "--hard", "HEAD~1"]);
1452 repo.git(&["remote", "add", "origin", repo.root().to_str().unwrap()]);
1453 repo
1454 }
1455
1456 fn pr_view(number: u64, head: &str, base: &str) -> crate::gh::PrView {
1457 crate::gh::PrView {
1458 number,
1459 title: "Add login".into(),
1460 state: "OPEN".into(),
1461 is_draft: false,
1462 head_ref_name: head.into(),
1463 base_ref_name: base.into(),
1464 url: format!("https://github.com/o/r/pull/{number}"),
1465 }
1466 }
1467
1468 #[test]
1469 fn do_checkout_pr_stays_in_list_and_focuses_new_worktree() {
1470 let repo = repo_with_pr(123);
1474 let (mut t, session, mut app) = setup(&repo);
1475 t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(123, "pr-feature", "main")));
1476 app.mode = Mode::PrPicker(Default::default());
1477 do_checkout_pr(&mut t.cx, &session, &mut app, 123);
1478 assert!(app.chosen.is_none());
1479 assert_eq!(app.mode, Mode::List);
1480 assert_eq!(
1482 app.selected_worktree().unwrap().branch.as_deref(),
1483 Some("pr-feature")
1484 );
1485 }
1486
1487 #[test]
1488 fn do_checkout_pr_stays_in_list_without_exit_flag() {
1489 let repo = repo_with_pr(55);
1492 let (mut t, session, mut app) = setup(&repo);
1493 t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(55, "pr-feature", "main")));
1494 app.mode = Mode::PrPicker(Default::default());
1495 do_checkout_pr(&mut t.cx, &session, &mut app, 55);
1496 assert!(app.chosen.is_none());
1497 assert_eq!(app.mode, Mode::List);
1498 assert!(
1499 app.status_message
1500 .as_deref()
1501 .unwrap()
1502 .contains("checked out")
1503 );
1504 assert!(
1505 app.worktrees
1506 .iter()
1507 .any(|w| w.branch.as_deref() == Some("pr-feature"))
1508 );
1509 }
1510
1511 #[test]
1512 fn do_checkout_branch_switches_and_stays_in_list() {
1513 let repo = TestRepo::init();
1514 repo.git(&["branch", "topic"]);
1515 let (mut t, session, mut app) = setup(&repo);
1516 app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
1517 worktree_index: 0,
1518 ..Default::default()
1519 });
1520 do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
1521 assert_eq!(app.mode, Mode::List);
1523 assert!(app.chosen.is_none());
1524 assert!(
1525 app.status_message
1526 .as_deref()
1527 .unwrap()
1528 .contains("checked out topic")
1529 );
1530 assert_eq!(
1532 repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(),
1533 "topic"
1534 );
1535 }
1536
1537 #[test]
1538 fn do_checkout_branch_dirty_shows_error_in_picker() {
1539 let repo = TestRepo::init();
1540 repo.git(&["branch", "topic"]);
1541 repo.write("README.md", "dirty\n"); let (mut t, session, mut app) = setup(&repo);
1543 app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
1544 worktree_index: 0,
1545 submitting: true,
1546 ..Default::default()
1547 });
1548 do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
1549 if let Mode::Checkout(state) = &app.mode {
1550 assert!(state.error.as_deref().unwrap().contains("uncommitted"));
1551 assert!(!state.submitting);
1552 } else {
1553 panic!("expected checkout picker with error");
1554 }
1555 }
1556
1557 #[test]
1558 fn do_sync_fast_forwards_and_refreshes() {
1559 let repo = TestRepo::init();
1562 let c2 = main_behind_origin(&repo);
1563 let (mut t, session, mut app) = setup(&repo);
1564 do_sync(&mut t.cx, &session, &mut app, 0);
1565 assert_eq!(app.mode, Mode::List);
1566 assert_eq!(app.status_kind, StatusKind::Success);
1567 assert!(
1568 app.status_message
1569 .as_deref()
1570 .unwrap()
1571 .contains("fast-forwarded")
1572 );
1573 assert_eq!(repo.git(&["rev-parse", "main"]).trim(), c2);
1574 }
1575
1576 #[test]
1577 fn do_sync_branch_row_fast_forwards() {
1578 let repo = TestRepo::init();
1581 let tip = feat_branch_behind_origin(&repo);
1582 let (mut t, session, mut app) = setup(&repo);
1583 let index = app
1584 .worktrees
1585 .iter()
1586 .position(|w| w.branch.as_deref() == Some("feat") && !w.has_worktree)
1587 .unwrap();
1588 do_sync(&mut t.cx, &session, &mut app, index);
1589 assert_eq!(app.mode, Mode::List);
1590 assert_eq!(app.status_kind, StatusKind::Success);
1591 assert!(
1592 app.status_message
1593 .as_deref()
1594 .unwrap()
1595 .contains("fast-forwarded")
1596 );
1597 assert_eq!(repo.git(&["rev-parse", "feat"]).trim(), tip);
1598 }
1599
1600 #[test]
1601 fn do_sync_no_upstream_shows_status() {
1602 let repo = TestRepo::init();
1603 let (mut t, session, mut app) = setup(&repo);
1604 do_sync(&mut t.cx, &session, &mut app, 0); assert_eq!(app.mode, Mode::List);
1606 assert!(
1607 app.status_message
1608 .as_deref()
1609 .unwrap()
1610 .contains("no upstream")
1611 );
1612 }
1613
1614 #[test]
1615 fn do_sync_dirty_shows_error_status() {
1616 let repo = TestRepo::init();
1617 main_behind_origin(&repo);
1618 repo.write("README.md", "dirty\n"); let (mut t, session, mut app) = setup(&repo);
1620 do_sync(&mut t.cx, &session, &mut app, 0);
1621 assert_eq!(app.status_kind, StatusKind::Error);
1622 assert!(app.status_message.as_deref().unwrap().contains("dirty"));
1623 }
1624
1625 #[test]
1626 fn do_sync_error_shows_in_status() {
1627 let repo = TestRepo::init();
1630 repo.add_worktree("feat", "../wt-feat");
1631 let (mut t, session, mut app) = setup(&repo);
1632 let index = app
1633 .worktrees
1634 .iter()
1635 .position(|w| w.branch.as_deref() == Some("feat"))
1636 .unwrap();
1637 std::fs::remove_dir_all(repo.root().parent().unwrap().join("wt-feat")).unwrap();
1638 do_sync(&mut t.cx, &session, &mut app, index);
1639 assert_eq!(app.status_kind, StatusKind::Error);
1640 assert!(app.status_message.is_some());
1641 }
1642
1643 fn sendit_ctx(branch: &str, trunk: &str, has_upstream: bool) -> sendit::PrContext {
1644 sendit::PrContext {
1645 branch: branch.into(),
1646 trunk: trunk.into(),
1647 merge_base: "abc".into(),
1648 has_upstream,
1649 commits_ahead: 1,
1650 commit_log: vec![],
1651 diffstat: sendit::DiffStat {
1652 files: 1,
1653 insertions: 1,
1654 deletions: 0,
1655 raw: String::new(),
1656 },
1657 existing_pr: None,
1658 }
1659 }
1660
1661 fn feature_repo_with_remote() -> (TestRepo, TestRepo) {
1663 let bare = TestRepo::init_bare();
1664 let repo = TestRepo::init();
1665 repo.git(&["checkout", "-q", "-b", "feat"]);
1666 repo.write("f.txt", "x\n");
1667 repo.commit_all("feat work");
1668 repo.git(&["remote", "add", "origin", bare.root().to_str().unwrap()]);
1669 (repo, bare)
1670 }
1671
1672 #[test]
1673 fn do_draft_pr_ai_seeds_form() {
1674 let repo = TestRepo::init();
1675 let (mut t, session, mut app) = setup(&repo);
1676 t.cx.agent = StdArc::new(crate::testutil::FakeAgent::drafting(
1677 "Add login\n\nBody here",
1678 ));
1679 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1680 do_draft_pr_ai(
1681 &mut t.cx,
1682 &session,
1683 &mut app,
1684 &sendit_ctx("feat", "main", false),
1685 );
1686 if let Mode::PrCompose(s) = &app.mode {
1687 assert_eq!(s.title, "Add login");
1688 assert_eq!(s.body, "Body here");
1689 assert!(s.error.is_none());
1690 } else {
1691 panic!("expected compose mode");
1692 }
1693 }
1694
1695 #[test]
1696 fn do_draft_pr_ai_shows_error_when_unavailable() {
1697 let repo = TestRepo::init();
1698 let (mut t, session, mut app) = setup(&repo);
1700 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1701 do_draft_pr_ai(
1702 &mut t.cx,
1703 &session,
1704 &mut app,
1705 &sendit_ctx("feat", "main", false),
1706 );
1707 if let Mode::PrCompose(s) = &app.mode {
1708 assert!(s.error.is_some());
1709 } else {
1710 panic!("expected compose mode");
1711 }
1712 }
1713
1714 #[test]
1715 fn do_draft_pr_ai_uses_form_model_and_effort() {
1716 let repo = TestRepo::init();
1717 let (mut t, session, mut app) = setup(&repo);
1718 let agent = StdArc::new(crate::testutil::FakeAgent::drafting("T\n\nB"));
1719 t.cx.agent = agent.clone();
1720 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
1721 model: crate::agent::AgentModel::Opus,
1722 effort: crate::agent::Effort::High,
1723 ..Default::default()
1724 });
1725 do_draft_pr_ai(
1726 &mut t.cx,
1727 &session,
1728 &mut app,
1729 &sendit_ctx("feat", "main", false),
1730 );
1731 assert_eq!(
1733 agent.last_opts(),
1734 Some(crate::agent::AgentOptions {
1735 model: crate::agent::AgentModel::Opus,
1736 effort: crate::agent::Effort::High,
1737 })
1738 );
1739 }
1740
1741 #[test]
1742 fn do_submit_pr_creates_records_and_exits() {
1743 let (repo, _bare) = feature_repo_with_remote();
1744 let (mut t, session, mut app) = setup(&repo);
1745 t.cx.gh = StdArc::new(FakeGh::sender("https://github.com/o/r/pull/77\n"));
1746 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1747 let mut outcome = None;
1748 let done = do_submit_pr(
1749 &mut t.cx,
1750 &session,
1751 &mut app,
1752 &sendit_ctx("feat", "main", false),
1753 sendit::PrAction::Create,
1754 "T".into(),
1755 "B".into(),
1756 false,
1757 &mut outcome,
1758 );
1759 assert!(done);
1760 assert_eq!(outcome.expect("outcome").0.number, Some(77));
1761 assert_eq!(
1762 repo.git(&["config", "--get", "wt.feat.prNumber"]).trim(),
1763 "77"
1764 );
1765 }
1766
1767 #[test]
1768 fn do_submit_pr_error_stays_in_form() {
1769 let (repo, _bare) = feature_repo_with_remote();
1770 let (mut t, session, mut app) = setup(&repo);
1771 t.cx.gh = StdArc::new(FakeGh::unavailable());
1772 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
1773 submitting: true,
1774 ..Default::default()
1775 });
1776 let mut outcome = None;
1777 let done = do_submit_pr(
1778 &mut t.cx,
1779 &session,
1780 &mut app,
1781 &sendit_ctx("feat", "main", false),
1782 sendit::PrAction::Create,
1783 "T".into(),
1784 "B".into(),
1785 false,
1786 &mut outcome,
1787 );
1788 assert!(!done);
1789 assert!(outcome.is_none());
1790 if let Mode::PrCompose(s) = &app.mode {
1791 assert!(s.error.is_some());
1792 assert!(!s.submitting);
1793 } else {
1794 panic!("expected compose mode");
1795 }
1796 }
1797
1798 #[test]
1799 fn do_checkout_pr_surfaces_gh_error_in_picker() {
1800 let repo = TestRepo::init();
1801 let (mut t, session, mut app) = setup(&repo);
1802 t.cx.gh = StdArc::new(FakeGh::unavailable());
1803 app.mode = Mode::PrPicker(Default::default());
1804 do_checkout_pr(&mut t.cx, &session, &mut app, 1);
1805 if let Mode::PrPicker(state) = &app.mode {
1806 assert!(state.error.is_some());
1807 } else {
1808 panic!("expected pr picker with error");
1809 }
1810 assert!(app.chosen.is_none());
1811 }
1812
1813 #[test]
1814 fn is_background_action_matches_mutations_only() {
1815 assert!(is_background_action(&Effect::Create {
1816 branch: "x".into(),
1817 base: None,
1818 decision: None,
1819 }));
1820 assert!(is_background_action(&Effect::Remove(0)));
1821 assert!(is_background_action(&Effect::MaterializeBranch {
1822 branch: "x".into()
1823 }));
1824 assert!(is_background_action(&Effect::CheckoutPr(1)));
1825 assert!(is_background_action(&Effect::CheckoutBranch {
1826 worktree_index: 0,
1827 branch: "x".into()
1828 }));
1829 assert!(is_background_action(&Effect::Sync { worktree_index: 0 }));
1830 assert!(!is_background_action(&Effect::Refresh));
1832 assert!(!is_background_action(&Effect::FetchPrs));
1833 assert!(!is_background_action(&Effect::None));
1834 assert!(!is_background_action(&Effect::OpenEditor("/tmp".into())));
1835 }
1836
1837 #[test]
1838 fn resolve_job_sets_label_key_and_args() {
1839 use crate::tui::app::testutil::app as make_app;
1840 let a = make_app(&[("main", true), ("feat/x", false)]);
1841
1842 let (job, key, label) = resolve_job(
1843 &a,
1844 Effect::Create {
1845 branch: "feat/new".into(),
1846 base: Some("main".into()),
1847 decision: None,
1848 },
1849 )
1850 .unwrap();
1851 assert!(matches!(job, Job::Create { .. }));
1852 assert_eq!(label, "Creating feat/new");
1853 assert_eq!(key, JobKey::New("feat/new".into()));
1854
1855 let (job, key, label) = resolve_job(&a, Effect::Remove(1)).unwrap();
1857 assert!(matches!(job, Job::Remove { query } if query == "feat/x"));
1858 assert_eq!(label, "Removing feat/x");
1859 assert_eq!(key, JobKey::Path(a.worktrees[1].path.clone()));
1860
1861 let (job, key, label) = resolve_job(
1863 &a,
1864 Effect::CheckoutBranch {
1865 worktree_index: 0,
1866 branch: "feat/x".into(),
1867 },
1868 )
1869 .unwrap();
1870 assert!(matches!(job, Job::CheckoutBranch { .. }));
1871 assert_eq!(label, "Checking out feat/x");
1872 assert_eq!(key, JobKey::Path(a.worktrees[0].path.clone()));
1873
1874 let (job, _key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
1876 assert!(matches!(job, Job::Sync { .. }));
1877 assert_eq!(label, "Syncing feat/x");
1878
1879 let (job, key, label) = resolve_job(&a, Effect::CheckoutPr(7)).unwrap();
1880 assert!(matches!(job, Job::CheckoutPr { number } if number == 7));
1881 assert_eq!(label, "Checking out PR #7");
1882 assert_eq!(key, JobKey::New("PR #7".into()));
1883 }
1884
1885 #[test]
1886 fn resolve_job_returns_none_for_missing_row() {
1887 use crate::tui::app::testutil::app as make_app;
1888 let a = make_app(&[("main", true)]);
1889 assert!(resolve_job(&a, Effect::Remove(99)).is_none());
1890 assert!(
1891 resolve_job(
1892 &a,
1893 Effect::CheckoutBranch {
1894 worktree_index: 99,
1895 branch: "x".into()
1896 }
1897 )
1898 .is_none()
1899 );
1900 assert!(resolve_job(&a, Effect::Sync { worktree_index: 99 }).is_none());
1901 assert!(resolve_job(&a, Effect::Refresh).is_none());
1903 }
1904
1905 #[test]
1906 fn spawn_job_refuses_conflicting_action_on_same_row() {
1907 use crate::tui::app::testutil::app as make_app;
1910 let mut a = make_app(&[("main", true), ("feat/x", false)]);
1911 let key = JobKey::Path(a.worktrees[1].path.clone());
1912 a.begin_job(key.clone(), "Removing feat/x");
1913 let (_, resolved_key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
1915 assert_eq!(resolved_key, key);
1916 assert!(a.has_job(&resolved_key));
1917 a.set_status(format!("{label} — already in progress"), StatusKind::Info);
1918 assert_eq!(a.jobs.len(), 1);
1919 assert!(a.status_message.as_deref().unwrap().contains("in progress"));
1920 }
1921
1922 #[test]
1923 fn anchor_at_root_repoints_cwd_and_returns_opened_worktree() {
1924 let repo = TestRepo::init();
1928 repo.add_worktree("feature/x", "../wt-x");
1929 let linked = repo.root().parent().unwrap().join("wt-x");
1930 let mut t = test_cx(&[], linked.to_str().unwrap());
1931 let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
1932 let opened_in = anchor_at_root(&mut t.cx, &session);
1933 assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
1934 assert_eq!(canon(&opened_in), canon(&linked));
1935 }
1936
1937 #[test]
1938 fn removing_opened_in_worktree_keeps_operations_working() {
1939 let repo = TestRepo::init();
1945 repo.add_worktree("feature/x", "../wt-x");
1946 let linked = repo.root().parent().unwrap().join("wt-x");
1947
1948 let mut t = test_cx(&[], linked.to_str().unwrap());
1950 let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
1951 let opened_in = anchor_at_root(&mut t.cx, &session);
1952 assert_eq!(canon(&opened_in), canon(&linked));
1953 assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
1954
1955 run_remove_command(&mut t.cx, "feature/x").unwrap();
1957 assert!(!linked.exists());
1958
1959 let again = open_session(&t.cx, &crate::git::RealGit).unwrap();
1961 assert_eq!(canon(&again.primary_root), canon(&session.primary_root));
1962
1963 let nav = finish_exit(&mut t.cx, &opened_in, &session.primary_root, None).unwrap();
1965 assert_eq!(canon(&nav.unwrap()), canon(&session.primary_root));
1966 assert!(t.err.contents().contains("was removed"));
1967 }
1968
1969 #[test]
1970 fn finish_exit_honors_explicit_switch() {
1971 let chosen = tempfile::tempdir().unwrap();
1974 let mut t = test_cx(&[], "/work");
1975 let out = finish_exit(
1976 &mut t.cx,
1977 Path::new("/deleted"),
1978 Path::new("/deleted-root"),
1979 Some(chosen.path().to_path_buf()),
1980 )
1981 .unwrap();
1982 assert_eq!(out.as_deref(), Some(chosen.path()));
1983 assert!(t.err.contents().is_empty());
1984 }
1985
1986 #[test]
1987 fn finish_exit_drops_chosen_that_was_removed() {
1988 let opened = tempfile::tempdir().unwrap();
1993 let gone_chosen = opened.path().join("wt-removed");
1994 let mut t = test_cx(&[], "/work");
1995 let out = finish_exit(&mut t.cx, opened.path(), opened.path(), Some(gone_chosen)).unwrap();
1996 assert_eq!(out, None);
1997 assert!(t.err.contents().is_empty());
1998 }
1999
2000 #[test]
2001 fn finish_exit_stays_put_when_opened_dir_survives() {
2002 let dir = tempfile::tempdir().unwrap();
2005 let mut t = test_cx(&[], "/work");
2006 let out = finish_exit(&mut t.cx, dir.path(), dir.path(), None).unwrap();
2007 assert_eq!(out, None);
2008 assert!(t.err.contents().is_empty());
2009 }
2010
2011 #[test]
2012 fn finish_exit_returns_to_root_when_opened_dir_deleted() {
2013 let root = tempfile::tempdir().unwrap();
2016 let gone = root.path().join("wt-x");
2017 let mut t = test_cx(&[], "/work");
2018 let out = finish_exit(&mut t.cx, &gone, root.path(), None).unwrap();
2019 assert_eq!(out.as_deref(), Some(root.path()));
2020 let err = t.err.contents();
2021 assert!(err.contains("was removed"));
2022 assert!(err.contains(&root.path().display().to_string()));
2023 }
2024
2025 #[test]
2026 fn finish_exit_reports_when_root_also_gone() {
2027 let scratch = tempfile::tempdir().unwrap();
2030 let gone = scratch.path().join("wt-x");
2031 let gone_root = scratch.path().join("root");
2032 let mut t = test_cx(&[], "/work");
2033 let out = finish_exit(&mut t.cx, &gone, &gone_root, None).unwrap();
2034 assert_eq!(out, None);
2035 assert!(t.err.contents().contains("no longer available"));
2036 }
2037
2038 fn canon(p: &Path) -> PathBuf {
2041 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
2042 }
2043}