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, SubmoduleSeed};
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::{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 path: PathBuf,
396 unlock: bool,
398 },
399 DeleteBranch {
401 branch: String,
403 force: bool,
405 },
406 Materialize {
408 branch: String,
410 },
411 CheckoutPr {
413 number: u64,
415 },
416 CheckoutBranch {
418 worktree_dir: PathBuf,
420 branch: String,
422 },
423 Sync {
425 worktree_dir: PathBuf,
427 label: String,
429 },
430 SyncBranch {
433 branch: String,
435 label: String,
437 },
438 InitSubmodules {
440 dir: PathBuf,
442 count: usize,
444 },
445}
446
447enum JobOutcome {
451 Create {
454 branch: String,
456 base: Option<String>,
458 outcome: CreateOutcome,
460 },
461 Remove {
463 query: String,
465 path: PathBuf,
467 result: std::result::Result<(), RemoveFailure>,
469 },
470 DeleteBranch {
472 branch: String,
474 force: bool,
476 result: std::result::Result<(), String>,
478 },
479 Materialize {
481 branch: String,
483 result: std::result::Result<(), String>,
485 },
486 CheckoutPr {
488 number: u64,
490 result: std::result::Result<(PathBuf, bool), String>,
492 },
493 CheckoutBranch {
495 branch: String,
497 result: std::result::Result<commands::checkout::SyncOutcome, String>,
499 },
500 Sync {
502 label: String,
504 result: std::result::Result<commands::sync::SyncOutcome, String>,
506 },
507 InitSubmodules {
509 count: usize,
511 result: std::result::Result<(), String>,
513 },
514}
515
516enum CreateOutcome {
521 Created,
523 CreatedNeedsSubmodules {
528 dir: PathBuf,
530 count: usize,
532 auto: bool,
535 },
536 NeedsStaleConfirm {
538 behind: u32,
540 upstream_display: String,
542 can_fast_forward: bool,
544 },
545 Failed(String),
547}
548
549fn remove_query_of(app: &App, index: usize) -> Option<String> {
552 let worktree = app.worktrees.get(index)?;
553 Some(worktree.branch.clone().unwrap_or_else(|| {
554 worktree
555 .path
556 .file_name()
557 .map(|n| n.to_string_lossy().into_owned())
558 .unwrap_or_default()
559 }))
560}
561
562fn resolve_job(app: &App, effect: Effect) -> Option<(Job, JobKey, String)> {
567 match effect {
568 Effect::Create {
569 branch,
570 base,
571 decision,
572 } => {
573 let label = format!("Creating {branch}");
574 let key = JobKey::New(branch.clone());
575 Some((
576 Job::Create {
577 branch,
578 base,
579 decision,
580 },
581 key,
582 label,
583 ))
584 }
585 Effect::Remove { index, unlock } => {
586 let worktree = app.worktrees.get(index)?;
587 let path = worktree.path.clone();
588 let key = JobKey::Path(path.clone());
589 let query = remove_query_of(app, index)?;
590 let label = format!("Removing {query}");
591 Some((
592 Job::Remove {
593 query,
594 path,
595 unlock,
596 },
597 key,
598 label,
599 ))
600 }
601 Effect::DeleteBranch { branch, force } => {
602 let label = format!("Deleting branch {branch}");
603 let key = JobKey::Branch(branch.clone());
604 Some((Job::DeleteBranch { branch, force }, key, label))
605 }
606 Effect::MaterializeBranch { branch } => {
607 let label = format!("Creating worktree for {branch}");
608 let key = JobKey::Branch(branch.clone());
609 Some((Job::Materialize { branch }, key, label))
610 }
611 Effect::CheckoutPr(number) => {
612 let label = format!("Checking out PR #{number}");
613 let key = JobKey::New(format!("PR #{number}"));
614 Some((Job::CheckoutPr { number }, key, label))
615 }
616 Effect::CheckoutBranch {
617 worktree_index,
618 branch,
619 } => {
620 let worktree_dir = app.worktrees.get(worktree_index)?.path.clone();
621 let label = format!("Checking out {branch}");
622 let key = JobKey::Path(worktree_dir.clone());
623 Some((
624 Job::CheckoutBranch {
625 worktree_dir,
626 branch,
627 },
628 key,
629 label,
630 ))
631 }
632 Effect::Sync { worktree_index } => {
633 let worktree = app.worktrees.get(worktree_index)?;
634 let label = worktree
635 .branch
636 .clone()
637 .unwrap_or_else(|| "worktree".to_string());
638 let display = format!("Syncing {label}");
639 let (job, key) = if worktree.has_worktree {
640 (
641 Job::Sync {
642 worktree_dir: worktree.path.clone(),
643 label,
644 },
645 JobKey::Path(worktree.path.clone()),
646 )
647 } else {
648 let branch = worktree.branch.clone()?;
651 let key = JobKey::Branch(branch.clone());
652 (Job::SyncBranch { branch, label }, key)
653 };
654 Some((job, key, display))
655 }
656 Effect::InitSubmodules { dir, count } => {
657 let label = format!("Initializing {count} submodule(s)");
658 let key = JobKey::Path(dir.clone());
659 Some((Job::InitSubmodules { dir, count }, key, label))
660 }
661 _ => None,
662 }
663}
664
665fn spawn_job(cx: &Cx, app: &mut App, effect: Effect, tx: &mpsc::Sender<(JobKey, JobOutcome)>) {
670 let Some((job, key, label)) = resolve_job(app, effect) else {
671 return;
672 };
673 if app.has_job(&key) {
674 app.set_status(format!("{label} — already in progress"), StatusKind::Info);
675 return;
676 }
677 app.begin_job(key.clone(), label);
678 let jobcx = JobCx::capture(cx);
679 let tx = tx.clone();
680 tokio::task::spawn_blocking(move || {
681 let outcome = run_job(jobcx, job);
682 let _ = tx.blocking_send((key, outcome));
683 });
684}
685
686type PrFetch = std::result::Result<Vec<PrItem>, String>;
688
689fn spawn_fetch_prs(cx: &Cx, session: &Session, tx: &mpsc::Sender<PrFetch>) {
692 let gh = cx.gh.clone();
693 let dir = session
694 .repo
695 .current_workdir()
696 .unwrap_or_else(|| session.primary_root.clone());
697 let tx = tx.clone();
698 tokio::task::spawn_blocking(move || {
699 let _ = tx.blocking_send(fetch_prs_result(gh.as_ref(), &dir));
700 });
701}
702
703fn run_job(jobcx: JobCx, job: Job) -> JobOutcome {
706 let mut cx = jobcx.into_cx();
707 match job {
708 Job::Create {
709 branch,
710 base,
711 decision,
712 } => {
713 let outcome = run_create_command(&mut cx, &branch, base.clone(), decision);
714 JobOutcome::Create {
715 branch,
716 base,
717 outcome,
718 }
719 }
720 Job::Remove {
721 query,
722 path,
723 unlock,
724 } => {
725 let result = run_remove_command(&mut cx, &query, unlock);
726 JobOutcome::Remove {
727 query,
728 path,
729 result,
730 }
731 }
732 Job::DeleteBranch { branch, force } => {
733 let result = run_delete_branch_command(&mut cx, &branch, force);
734 JobOutcome::DeleteBranch {
735 branch,
736 force,
737 result,
738 }
739 }
740 Job::Materialize { branch } => {
741 let result = run_materialize_command(&mut cx, &branch);
742 JobOutcome::Materialize { branch, result }
743 }
744 Job::CheckoutPr { number } => {
745 let result = run_checkout_pr_command(&mut cx, number);
746 JobOutcome::CheckoutPr { number, result }
747 }
748 Job::CheckoutBranch {
749 worktree_dir,
750 branch,
751 } => {
752 let result = run_checkout_branch_command(&mut cx, &worktree_dir, &branch);
753 JobOutcome::CheckoutBranch { branch, result }
754 }
755 Job::Sync {
756 worktree_dir,
757 label,
758 } => {
759 let result = run_sync_command(&mut cx, &worktree_dir);
760 JobOutcome::Sync { label, result }
761 }
762 Job::SyncBranch { branch, label } => {
763 let result = run_sync_branch_command(&mut cx, &branch);
764 JobOutcome::Sync { label, result }
765 }
766 Job::InitSubmodules { dir, count } => {
767 let result = run_init_submodules_command(&mut cx, &dir);
768 JobOutcome::InitSubmodules { count, result }
769 }
770 }
771}
772
773fn apply_outcome(cx: &Cx, session: &Session, app: &mut App, outcome: JobOutcome) {
776 let root = &session.primary_root;
777 match outcome {
778 JobOutcome::Create {
779 branch,
780 base,
781 outcome,
782 } => apply_create(cx, app, &branch, base, outcome, root),
783 JobOutcome::Remove {
784 query,
785 path,
786 result,
787 } => apply_remove(cx, app, &query, &path, result, root),
788 JobOutcome::DeleteBranch {
789 branch,
790 force,
791 result,
792 } => apply_delete_branch(cx, app, &branch, force, result, root),
793 JobOutcome::Materialize { branch, result } => {
794 apply_materialize(cx, app, &branch, result, root)
795 }
796 JobOutcome::CheckoutPr { number, result } => {
797 apply_checkout_pr(cx, app, number, result, root)
798 }
799 JobOutcome::CheckoutBranch { branch, result } => {
800 apply_checkout_branch(cx, app, &branch, result, root)
801 }
802 JobOutcome::Sync { label, result } => apply_sync(cx, app, &label, result, root),
803 JobOutcome::InitSubmodules { count, result } => {
804 apply_init_submodules(cx, app, count, result, root)
805 }
806 }
807}
808
809#[derive(Debug, Clone, Default)]
811pub struct ComposeSeed {
812 pub title: String,
814 pub body: String,
816 pub draft: bool,
818 pub model: crate::agent::AgentModel,
820 pub effort: crate::agent::Effort,
822}
823
824pub(crate) fn run_pr_compose(
831 cx: &mut Cx,
832 session: &Session,
833 ctx: sendit::PrContext,
834 action: sendit::PrAction,
835 seed: ComposeSeed,
836 draft_ai: bool,
837) -> Result<Option<(sendit::PrOutcome, sendit::PrSpec)>> {
838 let git = cx.git.clone();
839 let mut app = build_app(cx, session, git.as_ref())?;
840 let action_label = match action {
841 sendit::PrAction::Create => "create".to_string(),
842 sendit::PrAction::Update { number } => format!("update #{number}"),
843 };
844 app.mode = Mode::PrCompose(PrComposeState {
845 title: seed.title,
846 body: seed.body,
847 draft: seed.draft,
848 branch: ctx.branch.clone(),
849 trunk: ctx.trunk.clone(),
850 action_label,
851 model: seed.model,
852 effort: seed.effort,
853 ..Default::default()
854 });
855
856 let initial = if draft_ai {
857 Effect::DraftPrAi
858 } else {
859 Effect::None
860 };
861 let mut outcome: Option<(sendit::PrOutcome, sendit::PrSpec)> = None;
862 let runtime = tokio::runtime::Runtime::new()?;
863 runtime.block_on(run_compose_loop(
864 cx,
865 session,
866 &mut app,
867 &ctx,
868 action,
869 initial,
870 &mut outcome,
871 ))?;
872
873 if app.too_small {
874 cx.err.line("terminal too small (need ≥5 rows)")?;
875 return Err(Error::operation("terminal too small"));
876 }
877 Ok(outcome)
878}
879
880async fn run_compose_loop(
884 cx: &mut Cx,
885 session: &Session,
886 app: &mut App,
887 ctx: &sendit::PrContext,
888 action: sendit::PrAction,
889 initial: Effect,
890 outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
891) -> Result<()> {
892 install_panic_hook();
893 let mut tui = Tui::enter(app.mouse)?;
894 app.size = tui.size();
895 if app.size.1 < crate::tui::app::MIN_HEIGHT {
896 app.too_small = true;
897 return Ok(());
898 }
899 tui.draw(app)?;
900
901 if initial != Effect::None
902 && compose_dispatch(cx, session, app, &mut tui, ctx, action, initial, outcome)?
903 {
904 return Ok(());
905 }
906 tui.draw(app)?;
907
908 let mut events = EventStream::new();
909 while let Some(maybe) = events.next().await {
910 let Ok(event) = maybe else { continue };
911 let effect = app.handle_event(event);
912 if compose_dispatch(cx, session, app, &mut tui, ctx, action, effect, outcome)? {
913 break;
914 }
915 tui.draw(app)?;
916 }
917 Ok(())
918}
919
920#[allow(clippy::too_many_arguments)]
924fn compose_dispatch(
925 cx: &mut Cx,
926 session: &Session,
927 app: &mut App,
928 tui: &mut Tui,
929 ctx: &sendit::PrContext,
930 action: sendit::PrAction,
931 effect: Effect,
932 outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
933) -> Result<bool> {
934 match effect {
935 Effect::Quit => Ok(true),
936 Effect::TooSmall => {
937 app.too_small = true;
938 Ok(true)
939 }
940 Effect::DraftPrAi => {
941 tui.suspend()?;
942 do_draft_pr_ai(cx, session, app, ctx);
943 tui.resume()?;
944 Ok(false)
945 }
946 Effect::SubmitPr { title, body, draft } => {
947 tui.suspend()?;
948 let done = do_submit_pr(cx, session, app, ctx, action, title, body, draft, outcome);
949 tui.resume()?;
950 Ok(done)
951 }
952 _ => Ok(!matches!(app.mode, Mode::PrCompose(_))),
955 }
956}
957
958fn run_editor(cx: &Cx, session: &Session, path: &Path) {
960 let Ok(editor) = resolve_editor(session.config.editor.as_deref(), &cx.env) else {
961 return;
962 };
963 let argv = editor_argv(&editor);
964 if let Some((program, rest)) = argv.split_first() {
965 let _ = std::process::Command::new(program)
966 .args(rest)
967 .arg(path)
968 .status();
969 }
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975 use crate::testutil::{FakeGh, TestRepo, test_cx};
976 use crate::tui::app::Mode;
977 use std::sync::Arc as StdArc;
978
979 fn setup(repo: &TestRepo) -> (crate::testutil::TestCx, Session, App) {
981 let t = test_cx(&[], repo.root().to_str().unwrap());
982 let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
983 let worktrees = build_rows(&session.repo, &crate::git::RealGit).unwrap();
984 let app = App::new(worktrees, app_config(&session.config, true), (100, 30));
985 (t, session, app)
986 }
987
988 #[test]
989 fn app_config_maps_settings() {
990 let config = Config {
991 ui_nerd_fonts: true,
992 ui_mouse: false,
993 ..Config::default()
994 };
995 let cfg = app_config(&config, false);
996 assert!(cfg.nerd_fonts);
997 assert!(!cfg.mouse);
998 assert!(!cfg.color);
999 assert!(app_config(&config, true).color);
1000 }
1001
1002 #[test]
1003 fn do_create_adds_a_worktree_and_refreshes() {
1004 let repo = TestRepo::init();
1005 let (mut t, session, mut app) = setup(&repo);
1006 app.mode = Mode::Create(Default::default());
1007 do_create(&mut t.cx, &session, &mut app, "feature/new".into(), None);
1008 assert_eq!(app.mode, Mode::List);
1009 assert!(
1010 app.worktrees
1011 .iter()
1012 .any(|w| w.branch.as_deref() == Some("feature/new"))
1013 );
1014 assert!(app.status_message.as_deref().unwrap().contains("created"));
1015 assert_eq!(
1019 app.selected_worktree().unwrap().branch.as_deref(),
1020 Some("feature/new")
1021 );
1022 }
1023
1024 #[test]
1025 fn do_create_error_shows_in_modal() {
1026 let repo = TestRepo::init();
1027 let (mut t, session, mut app) = setup(&repo);
1028 app.mode = Mode::Create(Default::default());
1029 do_create(
1031 &mut t.cx,
1032 &session,
1033 &mut app,
1034 "x".into(),
1035 Some("nope-ref".into()),
1036 );
1037 if let Mode::Create(state) = &app.mode {
1038 assert!(state.error.is_some());
1039 } else {
1040 panic!("expected create mode with error");
1041 }
1042 }
1043
1044 fn main_behind_origin(repo: &TestRepo) -> String {
1047 let c1 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1048 repo.write("u.txt", "1\n");
1049 repo.commit_all("ahead on origin");
1050 let c2 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1051 repo.git(&["update-ref", "refs/remotes/origin/main", &c2]);
1052 repo.git(&["reset", "-q", "--hard", &c1]);
1053 repo.git(&["config", "branch.main.remote", "origin"]);
1054 repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
1055 c2
1056 }
1057
1058 fn feat_branch_behind_origin(repo: &TestRepo) -> String {
1062 let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1063 repo.git(&["checkout", "-q", "-b", "feat"]);
1064 repo.write("u.txt", "1\n");
1065 repo.commit_all("ahead on origin/feat");
1066 let tip = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1067 repo.git(&["update-ref", "refs/remotes/origin/feat", &tip]);
1068 repo.git(&["checkout", "-q", "main"]);
1069 repo.git(&["branch", "-f", "feat", &base]);
1071 repo.git(&["config", "branch.feat.remote", "origin"]);
1072 repo.git(&["config", "branch.feat.merge", "refs/heads/feat"]);
1073 tip
1074 }
1075
1076 #[test]
1077 fn do_create_stale_base_opens_confirm_modal() {
1078 let repo = TestRepo::init();
1081 main_behind_origin(&repo);
1082 let (mut t, session, mut app) = setup(&repo);
1083 app.mode = Mode::Create(Default::default());
1084 do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
1085 match &app.mode {
1086 Mode::ConfirmStaleBase(s) => {
1087 assert_eq!(s.branch, "feature");
1088 assert_eq!(s.behind, 1);
1089 assert!(s.can_fast_forward);
1090 }
1091 other => panic!("expected ConfirmStaleBase, got {other:?}"),
1092 }
1093 assert!(
1095 !app.worktrees
1096 .iter()
1097 .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
1098 );
1099 }
1100
1101 #[test]
1102 fn do_create_with_submodules_opens_confirm_modal() {
1103 let repo = TestRepo::init();
1106 repo.add_submodule("libs/sub");
1107 let (mut t, session, mut app) = setup(&repo);
1108 app.mode = Mode::Create(Default::default());
1109 do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
1110 match &app.mode {
1111 Mode::ConfirmInitSubmodules(s) => {
1112 assert_eq!(s.branch, "feature");
1113 assert_eq!(s.count, 1);
1114 assert!(s.dir.exists());
1115 }
1116 other => panic!("expected ConfirmInitSubmodules, got {other:?}"),
1117 }
1118 assert!(
1120 app.worktrees
1121 .iter()
1122 .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
1123 );
1124 }
1125
1126 #[test]
1127 fn apply_init_submodules_reports_success_and_refreshes() {
1128 let repo = TestRepo::init();
1129 let (t, session, mut app) = setup(&repo);
1130 apply_init_submodules(&t.cx, &mut app, 2, Ok(()), &session.primary_root);
1133 assert_eq!(app.mode, Mode::List);
1134 assert!(
1135 app.status_message
1136 .as_deref()
1137 .unwrap()
1138 .contains("initialized 2 submodule")
1139 );
1140 }
1141
1142 #[test]
1143 fn apply_init_submodules_error_shows_in_status() {
1144 let repo = TestRepo::init();
1145 let (t, session, mut app) = setup(&repo);
1146 apply_init_submodules(
1147 &t.cx,
1148 &mut app,
1149 1,
1150 Err("boom".into()),
1151 &session.primary_root,
1152 );
1153 assert_eq!(app.mode, Mode::List);
1154 let msg = app.status_message.as_deref().unwrap();
1155 assert!(msg.contains("failed to initialize submodules"));
1156 assert!(msg.contains("boom"));
1157 }
1158
1159 #[test]
1160 fn create_update_decision_fast_forwards_then_creates() {
1161 let repo = TestRepo::init();
1164 let c2 = main_behind_origin(&repo);
1165 let (t, session, mut app) = setup(&repo);
1166 let outcome = run_job(
1167 JobCx::capture(&t.cx),
1168 Job::Create {
1169 branch: "feature".into(),
1170 base: None,
1171 decision: Some(CreateDecision::Update),
1172 },
1173 );
1174 apply_outcome(&t.cx, &session, &mut app, outcome);
1175 assert_eq!(app.mode, Mode::List);
1176 assert_eq!(repo.git(&["rev-parse", "refs/heads/main"]).trim(), c2);
1177 assert_eq!(repo.git(&["rev-parse", "refs/heads/feature"]).trim(), c2);
1178 }
1179
1180 #[test]
1181 fn do_remove_removes_selected() {
1182 let repo = TestRepo::init();
1183 repo.add_worktree("feature/x", "../wt-x");
1184 let (mut t, session, mut app) = setup(&repo);
1186 let index = app
1187 .worktrees
1188 .iter()
1189 .position(|w| w.branch.as_deref() == Some("feature/x"))
1190 .unwrap();
1191 do_remove(&mut t.cx, &session, &mut app, index, false);
1192 assert!(
1195 !app.worktrees
1196 .iter()
1197 .any(|w| w.has_worktree && w.branch.as_deref() == Some("feature/x"))
1198 );
1199 assert!(
1200 app.worktrees
1201 .iter()
1202 .any(|w| !w.has_worktree && w.branch.as_deref() == Some("feature/x"))
1203 );
1204 }
1205
1206 #[test]
1207 fn do_remove_locked_reprompts_then_overrides_the_lock() {
1208 let repo = TestRepo::init();
1211 repo.add_worktree("held", "../wt-held");
1212 repo.git(&["worktree", "lock", "--reason", "in use", "../wt-held"]);
1213 let held = repo.root().parent().unwrap().join("wt-held");
1214 let (mut t, session, mut app) = setup(&repo);
1215 let row = |app: &App| {
1216 app.worktrees
1217 .iter()
1218 .position(|w| w.has_worktree && w.branch.as_deref() == Some("held"))
1219 };
1220 let index = row(&app).unwrap();
1221 do_remove(&mut t.cx, &session, &mut app, index, false);
1222 assert_eq!(
1223 app.mode,
1224 Mode::ConfirmRemoveLocked {
1225 index,
1226 reason: Some("in use".into()),
1227 }
1228 );
1229 assert!(held.exists());
1230
1231 app.mode = Mode::List;
1233 do_remove(&mut t.cx, &session, &mut app, index, true);
1234 assert_eq!(app.mode, Mode::List);
1235 assert!(!held.exists());
1236 assert!(row(&app).is_none());
1237 }
1238
1239 #[test]
1240 fn do_remove_locked_in_another_modal_reports_instead() {
1241 let repo = TestRepo::init();
1243 repo.add_worktree("held", "../wt-held");
1244 repo.git(&["worktree", "lock", "../wt-held"]);
1245 let (mut t, session, mut app) = setup(&repo);
1246 let index = app
1247 .worktrees
1248 .iter()
1249 .position(|w| w.has_worktree && w.branch.as_deref() == Some("held"))
1250 .unwrap();
1251 app.mode = Mode::Help;
1252 do_remove(&mut t.cx, &session, &mut app, index, false);
1253 assert_eq!(app.mode, Mode::Help);
1254 assert_eq!(app.status_message.as_deref(), Some("worktree is locked"));
1255 }
1256
1257 #[test]
1258 fn remove_failure_messages() {
1259 assert_eq!(
1260 RemoveFailure::Locked(Some("r".into())).message(),
1261 "worktree is locked (r)"
1262 );
1263 assert_eq!(RemoveFailure::Other("boom".into()).message(), "boom");
1264 }
1265
1266 #[test]
1267 fn do_delete_branch_removes_branch_row_and_refreshes() {
1268 let repo = TestRepo::init();
1271 repo.git(&["branch", "topic"]); let (mut t, session, mut app) = setup(&repo);
1273 assert!(
1274 app.worktrees
1275 .iter()
1276 .any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
1277 );
1278 do_delete_branch(&mut t.cx, &session, &mut app, "topic".into(), false);
1279 assert_eq!(app.mode, Mode::List);
1280 assert!(
1281 !app.worktrees
1282 .iter()
1283 .any(|w| w.branch.as_deref() == Some("topic"))
1284 );
1285 assert!(
1286 app.status_message
1287 .as_deref()
1288 .unwrap()
1289 .contains("deleted branch topic")
1290 );
1291 }
1292
1293 #[test]
1294 fn do_delete_branch_unmerged_reprompts_then_force_deletes() {
1295 let repo = TestRepo::init();
1298 repo.add_worktree("unmerged", "../wt-unmerged");
1301 let wt = repo.root().parent().unwrap().join("wt-unmerged");
1302 std::fs::write(wt.join("c.txt"), "x\n").unwrap();
1303 let dir = wt.to_string_lossy().into_owned();
1304 repo.git(&["-C", &dir, "add", "-A"]);
1305 repo.git(&["-C", &dir, "commit", "-q", "-m", "unmerged change"]);
1306 repo.git(&["worktree", "remove", "--force", &dir]);
1307 let (mut t, session, mut app) = setup(&repo);
1308 do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), false);
1310 assert!(matches!(
1311 app.mode,
1312 Mode::ConfirmDeleteBranch { force: true, .. }
1313 ));
1314 assert!(
1315 app.worktrees
1316 .iter()
1317 .any(|w| w.branch.as_deref() == Some("unmerged"))
1318 );
1319 app.mode = Mode::List;
1322 do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), true);
1323 assert_eq!(app.mode, Mode::List);
1324 assert!(
1325 !app.worktrees
1326 .iter()
1327 .any(|w| w.branch.as_deref() == Some("unmerged"))
1328 );
1329 }
1330
1331 #[test]
1332 fn do_materialize_branch_creates_worktree_and_stays_focused() {
1333 let repo = TestRepo::init();
1338 repo.git(&["branch", "topic"]);
1339 let (mut t, session, mut app) = setup(&repo);
1340 assert!(
1342 app.worktrees
1343 .iter()
1344 .any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
1345 );
1346 do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
1347 assert_eq!(app.mode, Mode::List);
1348 assert!(app.chosen.is_none());
1349 assert!(
1351 app.worktrees
1352 .iter()
1353 .any(|w| w.has_worktree && w.branch.as_deref() == Some("topic"))
1354 );
1355 let focused = app.selected_worktree().unwrap();
1356 assert!(focused.has_worktree && focused.branch.as_deref() == Some("topic"));
1357 assert!(
1358 app.status_message
1359 .as_deref()
1360 .unwrap()
1361 .contains("created topic")
1362 );
1363 }
1364
1365 #[test]
1366 fn do_materialize_branch_error_shows_in_status() {
1367 let repo = TestRepo::init();
1370 repo.add_worktree("dup", "../manual-dup");
1371 let (mut t, session, mut app) = setup(&repo);
1372 do_materialize_branch(&mut t.cx, &session, &mut app, "dup".into());
1373 assert!(app.chosen.is_none());
1374 assert_eq!(app.status_kind, StatusKind::Error);
1375 assert!(app.status_message.is_some());
1376 }
1377
1378 #[test]
1379 fn do_materialize_branch_queues_background_submodule_init() {
1380 let repo = TestRepo::init();
1384 repo.add_submodule("libs/sub");
1385 repo.git(&["branch", "topic"]); let (mut t, session, mut app) = setup(&repo);
1387 do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
1388 assert!(app.chosen.is_none());
1389 let queued = app.take_pending_jobs();
1391 assert!(
1392 queued
1393 .iter()
1394 .any(|e| matches!(e, Effect::InitSubmodules { .. })),
1395 "expected a queued InitSubmodules job, got {queued:?}"
1396 );
1397 }
1398
1399 #[test]
1400 fn apply_create_auto_policy_queues_submodule_job() {
1401 let repo = TestRepo::init();
1405 repo.add_submodule("libs/sub");
1406 let (t, session, mut app) = setup(&repo);
1407 apply_create(
1408 &t.cx,
1409 &mut app,
1410 "feature",
1411 None,
1412 CreateOutcome::CreatedNeedsSubmodules {
1413 dir: session.primary_root.clone(),
1414 count: 1,
1415 auto: true,
1416 },
1417 &session.primary_root,
1418 );
1419 assert_eq!(app.mode, Mode::List);
1420 let queued = app.take_pending_jobs();
1421 assert!(
1422 queued
1423 .iter()
1424 .any(|e| matches!(e, Effect::InitSubmodules { .. }))
1425 );
1426 }
1427
1428 #[test]
1429 fn apply_create_while_exit_blocked_queues_submodule_job_and_keeps_waiting() {
1430 use crate::tui::app::{ExitBlockedState, ExitIntent, JobKey};
1437 let repo = TestRepo::init();
1438 repo.add_submodule("libs/sub");
1439 let (t, session, mut app) = setup(&repo);
1440 app.mode = Mode::ExitBlocked(ExitBlockedState {
1443 intent: ExitIntent::Switch(session.primary_root.clone()),
1444 });
1445 apply_create(
1446 &t.cx,
1447 &mut app,
1448 "feature",
1449 None,
1450 CreateOutcome::CreatedNeedsSubmodules {
1451 dir: session.primary_root.clone(),
1452 count: 1,
1453 auto: false, },
1455 &session.primary_root,
1456 );
1457 assert!(matches!(app.mode, Mode::ExitBlocked(_)));
1459 let queued = app.take_pending_jobs();
1461 assert!(
1462 queued
1463 .iter()
1464 .any(|e| matches!(e, Effect::InitSubmodules { .. }))
1465 );
1466 let key = JobKey::Path(session.primary_root.clone());
1469 app.begin_job(key.clone(), "Initializing 1 submodule(s)");
1470 assert!(!app.exit_now());
1471 app.finish_job(&key);
1472 assert!(app.exit_now());
1473 assert_eq!(app.chosen, Some(session.primary_root.clone()));
1474 }
1475
1476 #[test]
1477 fn do_fetch_prs_populates_picker() {
1478 let repo = TestRepo::init();
1479 let (mut t, session, mut app) = setup(&repo);
1480 t.cx.gh = StdArc::new(FakeGh::with_list(vec![crate::gh::PrSummary {
1481 number: 5,
1482 title: "T".into(),
1483 author: crate::gh::Author {
1484 login: "alice".into(),
1485 },
1486 state: "OPEN".into(),
1487 is_draft: false,
1488 head_ref_name: "h".into(),
1489 created_at: String::new(),
1490 }]));
1491 app.mode = Mode::PrPicker(Default::default());
1492 do_fetch_prs(&t.cx, &session, &mut app);
1493 if let Mode::PrPicker(state) = &app.mode {
1494 assert!(!state.loading);
1495 assert_eq!(state.prs.len(), 1);
1496 assert_eq!(state.prs[0].number, 5);
1497 } else {
1498 panic!("expected pr picker");
1499 }
1500 }
1501
1502 #[test]
1503 fn do_fetch_prs_surfaces_gh_error() {
1504 let repo = TestRepo::init();
1505 let (mut t, session, mut app) = setup(&repo);
1506 t.cx.gh = StdArc::new(FakeGh::unavailable());
1507 app.mode = Mode::PrPicker(Default::default());
1508 do_fetch_prs(&t.cx, &session, &mut app);
1509 if let Mode::PrPicker(state) = &app.mode {
1510 assert!(state.error.is_some());
1511 } else {
1512 panic!("expected pr picker");
1513 }
1514 }
1515
1516 #[test]
1517 fn do_refresh_reloads_worktrees() {
1518 let repo = TestRepo::init();
1519 let (t, session, mut app) = setup(&repo);
1520 repo.add_worktree("added", "../wt-added");
1522 do_refresh(&t.cx, &mut app, &session.primary_root);
1523 assert!(
1524 app.worktrees
1525 .iter()
1526 .any(|w| w.branch.as_deref() == Some("added"))
1527 );
1528 }
1529
1530 fn repo_with_pr(number: u64) -> TestRepo {
1533 let repo = TestRepo::init();
1534 repo.write("pr.txt", "from pr\n");
1535 repo.commit_all("pr commit");
1536 let pr_oid = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
1537 repo.git(&["update-ref", &format!("refs/pull/{number}/head"), &pr_oid]);
1538 repo.git(&["reset", "-q", "--hard", "HEAD~1"]);
1539 repo.git(&["remote", "add", "origin", repo.root().to_str().unwrap()]);
1540 repo
1541 }
1542
1543 fn pr_view(number: u64, head: &str, base: &str) -> crate::gh::PrView {
1544 crate::gh::PrView {
1545 number,
1546 title: "Add login".into(),
1547 state: "OPEN".into(),
1548 is_draft: false,
1549 head_ref_name: head.into(),
1550 base_ref_name: base.into(),
1551 url: format!("https://github.com/o/r/pull/{number}"),
1552 }
1553 }
1554
1555 #[test]
1556 fn do_checkout_pr_stays_in_list_and_focuses_new_worktree() {
1557 let repo = repo_with_pr(123);
1561 let (mut t, session, mut app) = setup(&repo);
1562 t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(123, "pr-feature", "main")));
1563 app.mode = Mode::PrPicker(Default::default());
1564 do_checkout_pr(&mut t.cx, &session, &mut app, 123);
1565 assert!(app.chosen.is_none());
1566 assert_eq!(app.mode, Mode::List);
1567 assert_eq!(
1569 app.selected_worktree().unwrap().branch.as_deref(),
1570 Some("pr-feature")
1571 );
1572 }
1573
1574 #[test]
1575 fn do_checkout_pr_stays_in_list_without_exit_flag() {
1576 let repo = repo_with_pr(55);
1579 let (mut t, session, mut app) = setup(&repo);
1580 t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(55, "pr-feature", "main")));
1581 app.mode = Mode::PrPicker(Default::default());
1582 do_checkout_pr(&mut t.cx, &session, &mut app, 55);
1583 assert!(app.chosen.is_none());
1584 assert_eq!(app.mode, Mode::List);
1585 assert!(
1586 app.status_message
1587 .as_deref()
1588 .unwrap()
1589 .contains("checked out")
1590 );
1591 assert!(
1592 app.worktrees
1593 .iter()
1594 .any(|w| w.branch.as_deref() == Some("pr-feature"))
1595 );
1596 }
1597
1598 #[test]
1599 fn do_checkout_branch_switches_and_stays_in_list() {
1600 let repo = TestRepo::init();
1601 repo.git(&["branch", "topic"]);
1602 let (mut t, session, mut app) = setup(&repo);
1603 app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
1604 worktree_index: 0,
1605 ..Default::default()
1606 });
1607 do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
1608 assert_eq!(app.mode, Mode::List);
1610 assert!(app.chosen.is_none());
1611 assert!(
1612 app.status_message
1613 .as_deref()
1614 .unwrap()
1615 .contains("checked out topic")
1616 );
1617 assert_eq!(
1619 repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(),
1620 "topic"
1621 );
1622 }
1623
1624 #[test]
1625 fn do_checkout_branch_dirty_shows_error_in_picker() {
1626 let repo = TestRepo::init();
1627 repo.git(&["branch", "topic"]);
1628 repo.write("README.md", "dirty\n"); let (mut t, session, mut app) = setup(&repo);
1630 app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
1631 worktree_index: 0,
1632 submitting: true,
1633 ..Default::default()
1634 });
1635 do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
1636 if let Mode::Checkout(state) = &app.mode {
1637 assert!(state.error.as_deref().unwrap().contains("uncommitted"));
1638 assert!(!state.submitting);
1639 } else {
1640 panic!("expected checkout picker with error");
1641 }
1642 }
1643
1644 #[test]
1645 fn do_sync_fast_forwards_and_refreshes() {
1646 let repo = TestRepo::init();
1649 let c2 = main_behind_origin(&repo);
1650 let (mut t, session, mut app) = setup(&repo);
1651 do_sync(&mut t.cx, &session, &mut app, 0);
1652 assert_eq!(app.mode, Mode::List);
1653 assert_eq!(app.status_kind, StatusKind::Success);
1654 assert!(
1655 app.status_message
1656 .as_deref()
1657 .unwrap()
1658 .contains("fast-forwarded")
1659 );
1660 assert_eq!(repo.git(&["rev-parse", "main"]).trim(), c2);
1661 }
1662
1663 #[test]
1664 fn do_sync_branch_row_fast_forwards() {
1665 let repo = TestRepo::init();
1668 let tip = feat_branch_behind_origin(&repo);
1669 let (mut t, session, mut app) = setup(&repo);
1670 let index = app
1671 .worktrees
1672 .iter()
1673 .position(|w| w.branch.as_deref() == Some("feat") && !w.has_worktree)
1674 .unwrap();
1675 do_sync(&mut t.cx, &session, &mut app, index);
1676 assert_eq!(app.mode, Mode::List);
1677 assert_eq!(app.status_kind, StatusKind::Success);
1678 assert!(
1679 app.status_message
1680 .as_deref()
1681 .unwrap()
1682 .contains("fast-forwarded")
1683 );
1684 assert_eq!(repo.git(&["rev-parse", "feat"]).trim(), tip);
1685 }
1686
1687 #[test]
1688 fn do_sync_no_upstream_shows_status() {
1689 let repo = TestRepo::init();
1690 let (mut t, session, mut app) = setup(&repo);
1691 do_sync(&mut t.cx, &session, &mut app, 0); assert_eq!(app.mode, Mode::List);
1693 assert!(
1694 app.status_message
1695 .as_deref()
1696 .unwrap()
1697 .contains("no upstream")
1698 );
1699 }
1700
1701 #[test]
1702 fn do_sync_dirty_shows_error_status() {
1703 let repo = TestRepo::init();
1704 main_behind_origin(&repo);
1705 repo.write("README.md", "dirty\n"); let (mut t, session, mut app) = setup(&repo);
1707 do_sync(&mut t.cx, &session, &mut app, 0);
1708 assert_eq!(app.status_kind, StatusKind::Error);
1709 assert!(app.status_message.as_deref().unwrap().contains("dirty"));
1710 }
1711
1712 #[test]
1713 fn do_sync_error_shows_in_status() {
1714 let repo = TestRepo::init();
1717 repo.add_worktree("feat", "../wt-feat");
1718 let (mut t, session, mut app) = setup(&repo);
1719 let index = app
1720 .worktrees
1721 .iter()
1722 .position(|w| w.branch.as_deref() == Some("feat"))
1723 .unwrap();
1724 std::fs::remove_dir_all(repo.root().parent().unwrap().join("wt-feat")).unwrap();
1725 do_sync(&mut t.cx, &session, &mut app, index);
1726 assert_eq!(app.status_kind, StatusKind::Error);
1727 assert!(app.status_message.is_some());
1728 }
1729
1730 fn sendit_ctx(branch: &str, trunk: &str, has_upstream: bool) -> sendit::PrContext {
1731 sendit::PrContext {
1732 branch: branch.into(),
1733 trunk: trunk.into(),
1734 merge_base: "abc".into(),
1735 has_upstream,
1736 commits_ahead: 1,
1737 commit_log: vec![],
1738 diffstat: sendit::DiffStat {
1739 files: 1,
1740 insertions: 1,
1741 deletions: 0,
1742 raw: String::new(),
1743 },
1744 existing_pr: None,
1745 }
1746 }
1747
1748 fn feature_repo_with_remote() -> (TestRepo, TestRepo) {
1750 let bare = TestRepo::init_bare();
1751 let repo = TestRepo::init();
1752 repo.git(&["checkout", "-q", "-b", "feat"]);
1753 repo.write("f.txt", "x\n");
1754 repo.commit_all("feat work");
1755 repo.git(&["remote", "add", "origin", bare.root().to_str().unwrap()]);
1756 (repo, bare)
1757 }
1758
1759 #[test]
1760 fn do_draft_pr_ai_seeds_form() {
1761 let repo = TestRepo::init();
1762 let (mut t, session, mut app) = setup(&repo);
1763 t.cx.agent = StdArc::new(crate::testutil::FakeAgent::drafting(
1764 "Add login\n\nBody here",
1765 ));
1766 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1767 do_draft_pr_ai(
1768 &mut t.cx,
1769 &session,
1770 &mut app,
1771 &sendit_ctx("feat", "main", false),
1772 );
1773 if let Mode::PrCompose(s) = &app.mode {
1774 assert_eq!(s.title, "Add login");
1775 assert_eq!(s.body, "Body here");
1776 assert!(s.error.is_none());
1777 } else {
1778 panic!("expected compose mode");
1779 }
1780 }
1781
1782 #[test]
1783 fn do_draft_pr_ai_shows_error_when_unavailable() {
1784 let repo = TestRepo::init();
1785 let (mut t, session, mut app) = setup(&repo);
1787 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1788 do_draft_pr_ai(
1789 &mut t.cx,
1790 &session,
1791 &mut app,
1792 &sendit_ctx("feat", "main", false),
1793 );
1794 if let Mode::PrCompose(s) = &app.mode {
1795 assert!(s.error.is_some());
1796 } else {
1797 panic!("expected compose mode");
1798 }
1799 }
1800
1801 #[test]
1802 fn do_draft_pr_ai_uses_form_model_and_effort() {
1803 let repo = TestRepo::init();
1804 let (mut t, session, mut app) = setup(&repo);
1805 let agent = StdArc::new(crate::testutil::FakeAgent::drafting("T\n\nB"));
1806 t.cx.agent = agent.clone();
1807 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
1808 model: crate::agent::AgentModel::Opus,
1809 effort: crate::agent::Effort::High,
1810 ..Default::default()
1811 });
1812 do_draft_pr_ai(
1813 &mut t.cx,
1814 &session,
1815 &mut app,
1816 &sendit_ctx("feat", "main", false),
1817 );
1818 assert_eq!(
1820 agent.last_opts(),
1821 Some(crate::agent::AgentOptions {
1822 model: crate::agent::AgentModel::Opus,
1823 effort: crate::agent::Effort::High,
1824 timeout: None,
1825 })
1826 );
1827 }
1828
1829 #[test]
1830 fn do_submit_pr_creates_records_and_exits() {
1831 let (repo, _bare) = feature_repo_with_remote();
1832 let (mut t, session, mut app) = setup(&repo);
1833 t.cx.gh = StdArc::new(FakeGh::sender("https://github.com/o/r/pull/77\n"));
1834 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
1835 let mut outcome = None;
1836 let done = do_submit_pr(
1837 &mut t.cx,
1838 &session,
1839 &mut app,
1840 &sendit_ctx("feat", "main", false),
1841 sendit::PrAction::Create,
1842 "T".into(),
1843 "B".into(),
1844 false,
1845 &mut outcome,
1846 );
1847 assert!(done);
1848 assert_eq!(outcome.expect("outcome").0.number, Some(77));
1849 assert_eq!(
1850 repo.git(&["config", "--get", "wt.feat.prNumber"]).trim(),
1851 "77"
1852 );
1853 }
1854
1855 #[test]
1856 fn do_submit_pr_error_stays_in_form() {
1857 let (repo, _bare) = feature_repo_with_remote();
1858 let (mut t, session, mut app) = setup(&repo);
1859 t.cx.gh = StdArc::new(FakeGh::unavailable());
1860 app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
1861 submitting: true,
1862 ..Default::default()
1863 });
1864 let mut outcome = None;
1865 let done = do_submit_pr(
1866 &mut t.cx,
1867 &session,
1868 &mut app,
1869 &sendit_ctx("feat", "main", false),
1870 sendit::PrAction::Create,
1871 "T".into(),
1872 "B".into(),
1873 false,
1874 &mut outcome,
1875 );
1876 assert!(!done);
1877 assert!(outcome.is_none());
1878 if let Mode::PrCompose(s) = &app.mode {
1879 assert!(s.error.is_some());
1880 assert!(!s.submitting);
1881 } else {
1882 panic!("expected compose mode");
1883 }
1884 }
1885
1886 #[test]
1887 fn do_checkout_pr_surfaces_gh_error_in_picker() {
1888 let repo = TestRepo::init();
1889 let (mut t, session, mut app) = setup(&repo);
1890 t.cx.gh = StdArc::new(FakeGh::unavailable());
1891 app.mode = Mode::PrPicker(Default::default());
1892 do_checkout_pr(&mut t.cx, &session, &mut app, 1);
1893 if let Mode::PrPicker(state) = &app.mode {
1894 assert!(state.error.is_some());
1895 } else {
1896 panic!("expected pr picker with error");
1897 }
1898 assert!(app.chosen.is_none());
1899 }
1900
1901 #[test]
1902 fn is_background_action_matches_mutations_only() {
1903 assert!(is_background_action(&Effect::Create {
1904 branch: "x".into(),
1905 base: None,
1906 decision: None,
1907 }));
1908 assert!(is_background_action(&Effect::Remove {
1909 index: 0,
1910 unlock: false
1911 }));
1912 assert!(is_background_action(&Effect::MaterializeBranch {
1913 branch: "x".into()
1914 }));
1915 assert!(is_background_action(&Effect::CheckoutPr(1)));
1916 assert!(is_background_action(&Effect::CheckoutBranch {
1917 worktree_index: 0,
1918 branch: "x".into()
1919 }));
1920 assert!(is_background_action(&Effect::Sync { worktree_index: 0 }));
1921 assert!(!is_background_action(&Effect::Refresh));
1923 assert!(!is_background_action(&Effect::FetchPrs));
1924 assert!(!is_background_action(&Effect::None));
1925 assert!(!is_background_action(&Effect::OpenEditor("/tmp".into())));
1926 }
1927
1928 #[test]
1929 fn resolve_job_sets_label_key_and_args() {
1930 use crate::tui::app::testutil::app as make_app;
1931 let a = make_app(&[("main", true), ("feat/x", false)]);
1932
1933 let (job, key, label) = resolve_job(
1934 &a,
1935 Effect::Create {
1936 branch: "feat/new".into(),
1937 base: Some("main".into()),
1938 decision: None,
1939 },
1940 )
1941 .unwrap();
1942 assert!(matches!(job, Job::Create { .. }));
1943 assert_eq!(label, "Creating feat/new");
1944 assert_eq!(key, JobKey::New("feat/new".into()));
1945
1946 let (job, key, label) = resolve_job(
1948 &a,
1949 Effect::Remove {
1950 index: 1,
1951 unlock: true,
1952 },
1953 )
1954 .unwrap();
1955 assert!(matches!(
1956 job,
1957 Job::Remove { query, path, unlock: true }
1958 if query == "feat/x" && path == a.worktrees[1].path
1959 ));
1960 assert_eq!(label, "Removing feat/x");
1961 assert_eq!(key, JobKey::Path(a.worktrees[1].path.clone()));
1962
1963 let (job, key, label) = resolve_job(
1965 &a,
1966 Effect::CheckoutBranch {
1967 worktree_index: 0,
1968 branch: "feat/x".into(),
1969 },
1970 )
1971 .unwrap();
1972 assert!(matches!(job, Job::CheckoutBranch { .. }));
1973 assert_eq!(label, "Checking out feat/x");
1974 assert_eq!(key, JobKey::Path(a.worktrees[0].path.clone()));
1975
1976 let (job, _key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
1978 assert!(matches!(job, Job::Sync { .. }));
1979 assert_eq!(label, "Syncing feat/x");
1980
1981 let (job, key, label) = resolve_job(&a, Effect::CheckoutPr(7)).unwrap();
1982 assert!(matches!(job, Job::CheckoutPr { number } if number == 7));
1983 assert_eq!(label, "Checking out PR #7");
1984 assert_eq!(key, JobKey::New("PR #7".into()));
1985 }
1986
1987 #[test]
1988 fn resolve_job_returns_none_for_missing_row() {
1989 use crate::tui::app::testutil::app as make_app;
1990 let a = make_app(&[("main", true)]);
1991 assert!(
1992 resolve_job(
1993 &a,
1994 Effect::Remove {
1995 index: 99,
1996 unlock: false
1997 }
1998 )
1999 .is_none()
2000 );
2001 assert!(
2002 resolve_job(
2003 &a,
2004 Effect::CheckoutBranch {
2005 worktree_index: 99,
2006 branch: "x".into()
2007 }
2008 )
2009 .is_none()
2010 );
2011 assert!(resolve_job(&a, Effect::Sync { worktree_index: 99 }).is_none());
2012 assert!(resolve_job(&a, Effect::Refresh).is_none());
2014 }
2015
2016 #[test]
2017 fn spawn_job_refuses_conflicting_action_on_same_row() {
2018 use crate::tui::app::testutil::app as make_app;
2021 let mut a = make_app(&[("main", true), ("feat/x", false)]);
2022 let key = JobKey::Path(a.worktrees[1].path.clone());
2023 a.begin_job(key.clone(), "Removing feat/x");
2024 let (_, resolved_key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
2026 assert_eq!(resolved_key, key);
2027 assert!(a.has_job(&resolved_key));
2028 a.set_status(format!("{label} — already in progress"), StatusKind::Info);
2029 assert_eq!(a.jobs.len(), 1);
2030 assert!(a.status_message.as_deref().unwrap().contains("in progress"));
2031 }
2032
2033 #[test]
2034 fn anchor_at_root_repoints_cwd_and_returns_opened_worktree() {
2035 let repo = TestRepo::init();
2039 repo.add_worktree("feature/x", "../wt-x");
2040 let linked = repo.root().parent().unwrap().join("wt-x");
2041 let mut t = test_cx(&[], linked.to_str().unwrap());
2042 let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
2043 let opened_in = anchor_at_root(&mut t.cx, &session);
2044 assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
2045 assert_eq!(canon(&opened_in), canon(&linked));
2046 }
2047
2048 #[test]
2049 fn removing_opened_in_worktree_keeps_operations_working() {
2050 let repo = TestRepo::init();
2056 repo.add_worktree("feature/x", "../wt-x");
2057 let linked = repo.root().parent().unwrap().join("wt-x");
2058
2059 let mut t = test_cx(&[], linked.to_str().unwrap());
2061 let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
2062 let opened_in = anchor_at_root(&mut t.cx, &session);
2063 assert_eq!(canon(&opened_in), canon(&linked));
2064 assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
2065
2066 run_remove_command(&mut t.cx, "feature/x", false).unwrap();
2068 assert!(!linked.exists());
2069
2070 let again = open_session(&t.cx, &crate::git::RealGit).unwrap();
2072 assert_eq!(canon(&again.primary_root), canon(&session.primary_root));
2073
2074 let nav = finish_exit(&mut t.cx, &opened_in, &session.primary_root, None).unwrap();
2076 assert_eq!(canon(&nav.unwrap()), canon(&session.primary_root));
2077 assert!(t.err.contents().contains("was removed"));
2078 }
2079
2080 #[test]
2081 fn finish_exit_honors_explicit_switch() {
2082 let chosen = tempfile::tempdir().unwrap();
2085 let mut t = test_cx(&[], "/work");
2086 let out = finish_exit(
2087 &mut t.cx,
2088 Path::new("/deleted"),
2089 Path::new("/deleted-root"),
2090 Some(chosen.path().to_path_buf()),
2091 )
2092 .unwrap();
2093 assert_eq!(out.as_deref(), Some(chosen.path()));
2094 assert!(t.err.contents().is_empty());
2095 }
2096
2097 #[test]
2098 fn finish_exit_drops_chosen_that_was_removed() {
2099 let opened = tempfile::tempdir().unwrap();
2104 let gone_chosen = opened.path().join("wt-removed");
2105 let mut t = test_cx(&[], "/work");
2106 let out = finish_exit(&mut t.cx, opened.path(), opened.path(), Some(gone_chosen)).unwrap();
2107 assert_eq!(out, None);
2108 assert!(t.err.contents().is_empty());
2109 }
2110
2111 #[test]
2112 fn finish_exit_stays_put_when_opened_dir_survives() {
2113 let dir = tempfile::tempdir().unwrap();
2116 let mut t = test_cx(&[], "/work");
2117 let out = finish_exit(&mut t.cx, dir.path(), dir.path(), None).unwrap();
2118 assert_eq!(out, None);
2119 assert!(t.err.contents().is_empty());
2120 }
2121
2122 #[test]
2123 fn finish_exit_returns_to_root_when_opened_dir_deleted() {
2124 let root = tempfile::tempdir().unwrap();
2127 let gone = root.path().join("wt-x");
2128 let mut t = test_cx(&[], "/work");
2129 let out = finish_exit(&mut t.cx, &gone, root.path(), None).unwrap();
2130 assert_eq!(out.as_deref(), Some(root.path()));
2131 let err = t.err.contents();
2132 assert!(err.contains("was removed"));
2133 assert!(err.contains(&root.path().display().to_string()));
2134 }
2135
2136 #[test]
2137 fn finish_exit_reports_when_root_also_gone() {
2138 let scratch = tempfile::tempdir().unwrap();
2141 let gone = scratch.path().join("wt-x");
2142 let gone_root = scratch.path().join("root");
2143 let mut t = test_cx(&[], "/work");
2144 let out = finish_exit(&mut t.cx, &gone, &gone_root, None).unwrap();
2145 assert_eq!(out, None);
2146 assert!(t.err.contents().contains("no longer available"));
2147 }
2148
2149 fn canon(p: &Path) -> PathBuf {
2152 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
2153 }
2154}