use std::path::{Path, PathBuf};
use std::sync::Arc;
use crossterm::event::EventStream;
use futures_util::StreamExt;
use tokio::sync::mpsc;
use crate::cli::NewArgs;
use crate::commands::{self, Session, open_session};
use crate::config::{Config, SubmoduleInit};
use crate::cx::{Cx, SilentInput, Stream};
use crate::error::{Error, Result};
use crate::git::cli::GitCli;
use crate::git::discover::Repo;
use crate::hooks::CapturingHookRunner;
use crate::model::{SortSpec, Worktree};
use crate::tui::app::{
App, AppConfig, InitSubmodulesState, JobHome, JobKey, Mode, PrComposeState, PrItem,
StaleBaseState, StatusKind,
};
use crate::tui::event::{CreateDecision, Effect};
use crate::tui::terminal::{Tui, install_panic_hook};
use crate::util::editor::{editor_argv, resolve_editor};
use crate::worktree_service::{build_rows, enumerate_rows, enumerate_worktrees};
mod effects;
use effects::*;
pub(crate) fn app_config(config: &Config, color: bool) -> AppConfig {
AppConfig {
keymap: config.keymap(),
sort: SortSpec::default(),
columns: config.list_columns.clone(),
show_untracked: config.list_show_untracked,
remove_untracked_blocks: config.remove_untracked_blocks,
nerd_fonts: config.ui_nerd_fonts,
mouse: config.ui_mouse,
color,
palette: config.palette(),
}
}
pub fn run_tui(cx: &mut Cx, initial_filter: Option<&str>) -> Result<Option<PathBuf>> {
let git = cx.git.clone();
let session = open_session(cx, git.as_ref())?;
let opened_in = anchor_at_root(cx, &session);
let mut app = build_app(cx, &session, git.as_ref())?;
if let Some(filter) = initial_filter.filter(|f| !f.is_empty()) {
app.apply_filter(filter.to_string());
}
drive_tui(cx, &session, app, Effect::None, &opened_in)
}
pub fn run_pr_picker(cx: &mut Cx) -> Result<Option<PathBuf>> {
let git = cx.git.clone();
let session = open_session(cx, git.as_ref())?;
let opened_in = anchor_at_root(cx, &session);
let mut app = build_app(cx, &session, git.as_ref())?;
app.mode = Mode::PrPicker(crate::tui::app::PrPickerState {
loading: true,
..Default::default()
});
drive_tui(cx, &session, app, Effect::FetchPrs, &opened_in)
}
fn anchor_at_root(cx: &mut Cx, session: &Session) -> PathBuf {
let opened_in = session
.repo
.current_workdir()
.unwrap_or_else(|| cx.cwd.clone());
cx.cwd = session.primary_root.clone();
opened_in
}
fn build_app(cx: &Cx, session: &Session, git: &dyn GitCli) -> Result<App> {
let sync_worktrees = enumerate_rows(&session.repo, git)?;
let size = crossterm::terminal::size().unwrap_or((100, 30));
let color = cx.color_enabled_err(session.config.ui_color);
let mut app = App::new(sync_worktrees, app_config(&session.config, color), size);
app.branches = crate::git::all_branches(session.repo.gix()).unwrap_or_default();
app.default_base = crate::git::default_base_ref(session.repo.gix());
app.mark_loading();
Ok(app)
}
fn drive_tui(
cx: &mut Cx,
session: &Session,
mut app: App,
initial: Effect,
opened_in: &Path,
) -> Result<Option<PathBuf>> {
let runtime = tokio::runtime::Runtime::new()?;
let outcome = runtime.block_on(run_loop(cx, session, &mut app, initial));
runtime.shutdown_background();
outcome?;
if app.too_small {
cx.err.line("terminal too small (need ≥5 rows)")?;
return Err(Error::operation("terminal too small"));
}
finish_exit(cx, opened_in, &session.primary_root, app.chosen.clone())
}
fn finish_exit(
cx: &mut Cx,
opened_in: &Path,
primary_root: &Path,
chosen: Option<PathBuf>,
) -> Result<Option<PathBuf>> {
if let Some(path) = chosen
&& path.exists()
{
return Ok(Some(path));
}
if opened_in.exists() {
return Ok(None);
}
if primary_root.exists() {
cx.err.line(&format!(
"worktree {} was removed during this session; returning to the repository root at {}",
opened_in.display(),
primary_root.display(),
))?;
Ok(Some(primary_root.to_path_buf()))
} else {
cx.err.line(&format!(
"worktree {} was removed during this session, and the repository root is no longer available",
opened_in.display(),
))?;
Ok(None)
}
}
async fn run_loop(cx: &mut Cx, session: &Session, app: &mut App, initial: Effect) -> Result<()> {
install_panic_hook();
let mut tui = Tui::enter(app.mouse)?;
app.size = tui.size();
if app.size.1 < crate::tui::app::MIN_HEIGHT {
app.too_small = true;
return Ok(());
}
tui.draw(app)?;
let (job_tx, mut job_rx) = mpsc::channel::<(JobKey, JobOutcome)>(64);
let (pr_tx, mut pr_rx) = mpsc::channel::<PrFetch>(4);
if initial != Effect::None {
if dispatch_effect(cx, session, app, &mut tui, initial, &pr_tx)? {
return Ok(());
}
tui.draw(app)?;
}
let (tx, mut rx) = mpsc::channel::<Vec<Worktree>>(1);
spawn_enrichment(session.primary_root.clone(), cx.git.clone(), tx);
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(100));
let mut events = EventStream::new();
loop {
tokio::select! {
_ = ticker.tick(), if app.any_jobs() => {
app.tick_spinner();
tui.draw(app)?;
}
Some((key, outcome)) = job_rx.recv() => {
app.finish_job(&key);
apply_outcome(cx, session, app, outcome);
for effect in app.take_pending_jobs() {
spawn_job(cx, app, effect, &job_tx);
}
tui.draw(app)?;
if app.exit_now() {
break;
}
}
Some(fetch) = pr_rx.recv() => {
apply_prs(app, fetch);
tui.draw(app)?;
}
maybe = events.next() => {
let Some(Ok(event)) = maybe else { continue };
let effect = app.handle_event(event);
if is_background_action(&effect) {
spawn_job(cx, app, effect, &job_tx);
tui.draw(app)?;
} else if dispatch_effect(cx, session, app, &mut tui, effect, &pr_tx)? {
break;
} else {
tui.draw(app)?;
}
}
Some(worktrees) = rx.recv() => {
mark_all_loaded(app, worktrees);
tui.draw(app)?;
}
}
}
Ok(())
}
fn dispatch_effect(
cx: &mut Cx,
session: &Session,
app: &mut App,
tui: &mut Tui,
effect: Effect,
pr_tx: &mpsc::Sender<PrFetch>,
) -> Result<bool> {
match effect {
Effect::None => Ok(false),
Effect::Switch(_) | Effect::Quit => Ok(true),
Effect::TooSmall => {
app.too_small = true;
Ok(true)
}
Effect::Refresh => {
do_refresh(cx, app, &session.primary_root);
Ok(false)
}
Effect::FetchPrs => {
spawn_fetch_prs(cx, session, pr_tx);
Ok(false)
}
Effect::OpenEditor(path) => {
tui.suspend()?;
run_editor(cx, session, &path);
tui.resume()?;
Ok(false)
}
Effect::Create { .. }
| Effect::Remove(_)
| Effect::DeleteBranch { .. }
| Effect::MaterializeBranch { .. }
| Effect::CheckoutPr(_)
| Effect::CheckoutBranch { .. }
| Effect::Sync { .. }
| Effect::InitSubmodules { .. } => Ok(false),
Effect::DraftPrAi | Effect::SubmitPr { .. } => Ok(false),
}
}
fn is_background_action(effect: &Effect) -> bool {
matches!(
effect,
Effect::Create { .. }
| Effect::Remove(_)
| Effect::DeleteBranch { .. }
| Effect::MaterializeBranch { .. }
| Effect::CheckoutPr(_)
| Effect::CheckoutBranch { .. }
| Effect::Sync { .. }
| Effect::InitSubmodules { .. }
)
}
struct JobCx {
env: crate::cx::Env,
cwd: PathBuf,
git: Arc<dyn GitCli + Send + Sync>,
gh: Arc<dyn crate::gh::GhClient + Send + Sync>,
agent: Arc<dyn crate::agent::AgentClient + Send + Sync>,
}
impl JobCx {
fn capture(cx: &Cx) -> Self {
JobCx {
env: cx.env.clone(),
cwd: cx.cwd.clone(),
git: cx.git.clone(),
gh: cx.gh.clone(),
agent: cx.agent.clone(),
}
}
fn into_cx(self) -> Cx {
let mut cx = Cx::new(
Stream::new(Box::new(Vec::<u8>::new()), false),
Stream::new(Box::new(Vec::<u8>::new()), false),
self.env,
self.cwd,
self.git,
self.gh,
self.agent,
Box::new(SilentInput),
);
cx.no_pager = true;
cx
}
}
enum Job {
Create {
branch: String,
base: Option<String>,
decision: Option<CreateDecision>,
},
Remove {
query: String,
},
DeleteBranch {
branch: String,
force: bool,
},
Materialize {
branch: String,
},
CheckoutPr {
number: u64,
},
CheckoutBranch {
worktree_dir: PathBuf,
branch: String,
},
Sync {
worktree_dir: PathBuf,
label: String,
},
SyncBranch {
branch: String,
label: String,
},
InitSubmodules {
dir: PathBuf,
count: usize,
},
}
enum JobOutcome {
Create {
branch: String,
base: Option<String>,
outcome: CreateOutcome,
},
Remove {
query: String,
result: std::result::Result<(), String>,
},
DeleteBranch {
branch: String,
force: bool,
result: std::result::Result<(), String>,
},
Materialize {
branch: String,
result: std::result::Result<(), String>,
},
CheckoutPr {
number: u64,
result: std::result::Result<(PathBuf, bool), String>,
},
CheckoutBranch {
branch: String,
result: std::result::Result<commands::checkout::SyncOutcome, String>,
},
Sync {
label: String,
result: std::result::Result<commands::sync::SyncOutcome, String>,
},
InitSubmodules {
count: usize,
result: std::result::Result<(), String>,
},
}
enum CreateOutcome {
Created,
CreatedNeedsSubmodules {
dir: PathBuf,
count: usize,
auto: bool,
},
NeedsStaleConfirm {
behind: u32,
upstream_display: String,
can_fast_forward: bool,
},
Failed(String),
}
fn remove_query_of(app: &App, index: usize) -> Option<String> {
let worktree = app.worktrees.get(index)?;
Some(worktree.branch.clone().unwrap_or_else(|| {
worktree
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}))
}
fn resolve_job(app: &App, effect: Effect) -> Option<(Job, JobKey, String)> {
match effect {
Effect::Create {
branch,
base,
decision,
} => {
let label = format!("Creating {branch}");
let key = JobKey::New(branch.clone());
Some((
Job::Create {
branch,
base,
decision,
},
key,
label,
))
}
Effect::Remove(index) => {
let worktree = app.worktrees.get(index)?;
let key = JobKey::Path(worktree.path.clone());
let query = remove_query_of(app, index)?;
let label = format!("Removing {query}");
Some((Job::Remove { query }, key, label))
}
Effect::DeleteBranch { branch, force } => {
let label = format!("Deleting branch {branch}");
let key = JobKey::Branch(branch.clone());
Some((Job::DeleteBranch { branch, force }, key, label))
}
Effect::MaterializeBranch { branch } => {
let label = format!("Creating worktree for {branch}");
let key = JobKey::Branch(branch.clone());
Some((Job::Materialize { branch }, key, label))
}
Effect::CheckoutPr(number) => {
let label = format!("Checking out PR #{number}");
let key = JobKey::New(format!("PR #{number}"));
Some((Job::CheckoutPr { number }, key, label))
}
Effect::CheckoutBranch {
worktree_index,
branch,
} => {
let worktree_dir = app.worktrees.get(worktree_index)?.path.clone();
let label = format!("Checking out {branch}");
let key = JobKey::Path(worktree_dir.clone());
Some((
Job::CheckoutBranch {
worktree_dir,
branch,
},
key,
label,
))
}
Effect::Sync { worktree_index } => {
let worktree = app.worktrees.get(worktree_index)?;
let label = worktree
.branch
.clone()
.unwrap_or_else(|| "worktree".to_string());
let display = format!("Syncing {label}");
let (job, key) = if worktree.has_worktree {
(
Job::Sync {
worktree_dir: worktree.path.clone(),
label,
},
JobKey::Path(worktree.path.clone()),
)
} else {
let branch = worktree.branch.clone()?;
let key = JobKey::Branch(branch.clone());
(Job::SyncBranch { branch, label }, key)
};
Some((job, key, display))
}
Effect::InitSubmodules { dir, count } => {
let label = format!("Initializing {count} submodule(s)");
let key = JobKey::Path(dir.clone());
Some((Job::InitSubmodules { dir, count }, key, label))
}
_ => None,
}
}
fn spawn_job(cx: &Cx, app: &mut App, effect: Effect, tx: &mpsc::Sender<(JobKey, JobOutcome)>) {
let Some((job, key, label)) = resolve_job(app, effect) else {
return;
};
if app.has_job(&key) {
app.set_status(format!("{label} — already in progress"), StatusKind::Info);
return;
}
app.begin_job(key.clone(), label);
let jobcx = JobCx::capture(cx);
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
let outcome = run_job(jobcx, job);
let _ = tx.blocking_send((key, outcome));
});
}
type PrFetch = std::result::Result<Vec<PrItem>, String>;
fn spawn_fetch_prs(cx: &Cx, session: &Session, tx: &mpsc::Sender<PrFetch>) {
let gh = cx.gh.clone();
let dir = session
.repo
.current_workdir()
.unwrap_or_else(|| session.primary_root.clone());
let tx = tx.clone();
tokio::task::spawn_blocking(move || {
let _ = tx.blocking_send(fetch_prs_result(gh.as_ref(), &dir));
});
}
fn run_job(jobcx: JobCx, job: Job) -> JobOutcome {
let mut cx = jobcx.into_cx();
match job {
Job::Create {
branch,
base,
decision,
} => {
let outcome = run_create_command(&mut cx, &branch, base.clone(), decision);
JobOutcome::Create {
branch,
base,
outcome,
}
}
Job::Remove { query } => {
let result = run_remove_command(&mut cx, &query);
JobOutcome::Remove { query, result }
}
Job::DeleteBranch { branch, force } => {
let result = run_delete_branch_command(&mut cx, &branch, force);
JobOutcome::DeleteBranch {
branch,
force,
result,
}
}
Job::Materialize { branch } => {
let result = run_materialize_command(&mut cx, &branch);
JobOutcome::Materialize { branch, result }
}
Job::CheckoutPr { number } => {
let result = run_checkout_pr_command(&mut cx, number);
JobOutcome::CheckoutPr { number, result }
}
Job::CheckoutBranch {
worktree_dir,
branch,
} => {
let result = run_checkout_branch_command(&mut cx, &worktree_dir, &branch);
JobOutcome::CheckoutBranch { branch, result }
}
Job::Sync {
worktree_dir,
label,
} => {
let result = run_sync_command(&mut cx, &worktree_dir);
JobOutcome::Sync { label, result }
}
Job::SyncBranch { branch, label } => {
let result = run_sync_branch_command(&mut cx, &branch);
JobOutcome::Sync { label, result }
}
Job::InitSubmodules { dir, count } => {
let result = run_init_submodules_command(&mut cx, &dir);
JobOutcome::InitSubmodules { count, result }
}
}
}
fn apply_outcome(cx: &Cx, session: &Session, app: &mut App, outcome: JobOutcome) {
let root = &session.primary_root;
match outcome {
JobOutcome::Create {
branch,
base,
outcome,
} => apply_create(cx, app, &branch, base, outcome, root),
JobOutcome::Remove { query, result } => apply_remove(cx, app, &query, result, root),
JobOutcome::DeleteBranch {
branch,
force,
result,
} => apply_delete_branch(cx, app, &branch, force, result, root),
JobOutcome::Materialize { branch, result } => {
apply_materialize(cx, app, &branch, result, root)
}
JobOutcome::CheckoutPr { number, result } => {
apply_checkout_pr(cx, app, number, result, root)
}
JobOutcome::CheckoutBranch { branch, result } => {
apply_checkout_branch(cx, app, &branch, result, root)
}
JobOutcome::Sync { label, result } => apply_sync(cx, app, &label, result, root),
JobOutcome::InitSubmodules { count, result } => {
apply_init_submodules(cx, app, count, result, root)
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ComposeSeed {
pub title: String,
pub body: String,
pub draft: bool,
pub model: crate::agent::AgentModel,
pub effort: crate::agent::Effort,
}
pub(crate) fn run_pr_compose(
cx: &mut Cx,
session: &Session,
ctx: sendit::PrContext,
action: sendit::PrAction,
seed: ComposeSeed,
draft_ai: bool,
) -> Result<Option<(sendit::PrOutcome, sendit::PrSpec)>> {
let git = cx.git.clone();
let mut app = build_app(cx, session, git.as_ref())?;
let action_label = match action {
sendit::PrAction::Create => "create".to_string(),
sendit::PrAction::Update { number } => format!("update #{number}"),
};
app.mode = Mode::PrCompose(PrComposeState {
title: seed.title,
body: seed.body,
draft: seed.draft,
branch: ctx.branch.clone(),
trunk: ctx.trunk.clone(),
action_label,
model: seed.model,
effort: seed.effort,
..Default::default()
});
let initial = if draft_ai {
Effect::DraftPrAi
} else {
Effect::None
};
let mut outcome: Option<(sendit::PrOutcome, sendit::PrSpec)> = None;
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(run_compose_loop(
cx,
session,
&mut app,
&ctx,
action,
initial,
&mut outcome,
))?;
if app.too_small {
cx.err.line("terminal too small (need ≥5 rows)")?;
return Err(Error::operation("terminal too small"));
}
Ok(outcome)
}
async fn run_compose_loop(
cx: &mut Cx,
session: &Session,
app: &mut App,
ctx: &sendit::PrContext,
action: sendit::PrAction,
initial: Effect,
outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
) -> Result<()> {
install_panic_hook();
let mut tui = Tui::enter(app.mouse)?;
app.size = tui.size();
if app.size.1 < crate::tui::app::MIN_HEIGHT {
app.too_small = true;
return Ok(());
}
tui.draw(app)?;
if initial != Effect::None
&& compose_dispatch(cx, session, app, &mut tui, ctx, action, initial, outcome)?
{
return Ok(());
}
tui.draw(app)?;
let mut events = EventStream::new();
while let Some(maybe) = events.next().await {
let Ok(event) = maybe else { continue };
let effect = app.handle_event(event);
if compose_dispatch(cx, session, app, &mut tui, ctx, action, effect, outcome)? {
break;
}
tui.draw(app)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn compose_dispatch(
cx: &mut Cx,
session: &Session,
app: &mut App,
tui: &mut Tui,
ctx: &sendit::PrContext,
action: sendit::PrAction,
effect: Effect,
outcome: &mut Option<(sendit::PrOutcome, sendit::PrSpec)>,
) -> Result<bool> {
match effect {
Effect::Quit => Ok(true),
Effect::TooSmall => {
app.too_small = true;
Ok(true)
}
Effect::DraftPrAi => {
tui.suspend()?;
do_draft_pr_ai(cx, session, app, ctx);
tui.resume()?;
Ok(false)
}
Effect::SubmitPr { title, body, draft } => {
tui.suspend()?;
let done = do_submit_pr(cx, session, app, ctx, action, title, body, draft, outcome);
tui.resume()?;
Ok(done)
}
_ => Ok(!matches!(app.mode, Mode::PrCompose(_))),
}
}
fn run_editor(cx: &Cx, session: &Session, path: &Path) {
let Ok(editor) = resolve_editor(session.config.editor.as_deref(), &cx.env) else {
return;
};
let argv = editor_argv(&editor);
if let Some((program, rest)) = argv.split_first() {
let _ = std::process::Command::new(program)
.args(rest)
.arg(path)
.status();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{FakeGh, TestRepo, test_cx};
use crate::tui::app::Mode;
use std::sync::Arc as StdArc;
fn setup(repo: &TestRepo) -> (crate::testutil::TestCx, Session, App) {
let t = test_cx(&[], repo.root().to_str().unwrap());
let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
let worktrees = build_rows(&session.repo, &crate::git::RealGit).unwrap();
let app = App::new(worktrees, app_config(&session.config, true), (100, 30));
(t, session, app)
}
#[test]
fn app_config_maps_settings() {
let config = Config {
ui_nerd_fonts: true,
ui_mouse: false,
..Config::default()
};
let cfg = app_config(&config, false);
assert!(cfg.nerd_fonts);
assert!(!cfg.mouse);
assert!(!cfg.color);
assert!(app_config(&config, true).color);
}
#[test]
fn do_create_adds_a_worktree_and_refreshes() {
let repo = TestRepo::init();
let (mut t, session, mut app) = setup(&repo);
app.mode = Mode::Create(Default::default());
do_create(&mut t.cx, &session, &mut app, "feature/new".into(), None);
assert_eq!(app.mode, Mode::List);
assert!(
app.worktrees
.iter()
.any(|w| w.branch.as_deref() == Some("feature/new"))
);
assert!(app.status_message.as_deref().unwrap().contains("created"));
assert_eq!(
app.selected_worktree().unwrap().branch.as_deref(),
Some("feature/new")
);
}
#[test]
fn do_create_error_shows_in_modal() {
let repo = TestRepo::init();
let (mut t, session, mut app) = setup(&repo);
app.mode = Mode::Create(Default::default());
do_create(
&mut t.cx,
&session,
&mut app,
"x".into(),
Some("nope-ref".into()),
);
if let Mode::Create(state) = &app.mode {
assert!(state.error.is_some());
} else {
panic!("expected create mode with error");
}
}
fn main_behind_origin(repo: &TestRepo) -> String {
let c1 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.write("u.txt", "1\n");
repo.commit_all("ahead on origin");
let c2 = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/main", &c2]);
repo.git(&["reset", "-q", "--hard", &c1]);
repo.git(&["config", "branch.main.remote", "origin"]);
repo.git(&["config", "branch.main.merge", "refs/heads/main"]);
c2
}
fn feat_branch_behind_origin(repo: &TestRepo) -> String {
let base = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["checkout", "-q", "-b", "feat"]);
repo.write("u.txt", "1\n");
repo.commit_all("ahead on origin/feat");
let tip = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", "refs/remotes/origin/feat", &tip]);
repo.git(&["checkout", "-q", "main"]);
repo.git(&["branch", "-f", "feat", &base]);
repo.git(&["config", "branch.feat.remote", "origin"]);
repo.git(&["config", "branch.feat.merge", "refs/heads/feat"]);
tip
}
#[test]
fn do_create_stale_base_opens_confirm_modal() {
let repo = TestRepo::init();
main_behind_origin(&repo);
let (mut t, session, mut app) = setup(&repo);
app.mode = Mode::Create(Default::default());
do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
match &app.mode {
Mode::ConfirmStaleBase(s) => {
assert_eq!(s.branch, "feature");
assert_eq!(s.behind, 1);
assert!(s.can_fast_forward);
}
other => panic!("expected ConfirmStaleBase, got {other:?}"),
}
assert!(
!app.worktrees
.iter()
.any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
);
}
#[test]
fn do_create_with_submodules_opens_confirm_modal() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let (mut t, session, mut app) = setup(&repo);
app.mode = Mode::Create(Default::default());
do_create(&mut t.cx, &session, &mut app, "feature".into(), None);
match &app.mode {
Mode::ConfirmInitSubmodules(s) => {
assert_eq!(s.branch, "feature");
assert_eq!(s.count, 1);
assert!(s.dir.exists());
}
other => panic!("expected ConfirmInitSubmodules, got {other:?}"),
}
assert!(
app.worktrees
.iter()
.any(|w| w.has_worktree && w.branch.as_deref() == Some("feature"))
);
}
#[test]
fn apply_init_submodules_reports_success_and_refreshes() {
let repo = TestRepo::init();
let (t, session, mut app) = setup(&repo);
apply_init_submodules(&t.cx, &mut app, 2, Ok(()), &session.primary_root);
assert_eq!(app.mode, Mode::List);
assert!(
app.status_message
.as_deref()
.unwrap()
.contains("initialized 2 submodule")
);
}
#[test]
fn apply_init_submodules_error_shows_in_status() {
let repo = TestRepo::init();
let (t, session, mut app) = setup(&repo);
apply_init_submodules(
&t.cx,
&mut app,
1,
Err("boom".into()),
&session.primary_root,
);
assert_eq!(app.mode, Mode::List);
let msg = app.status_message.as_deref().unwrap();
assert!(msg.contains("failed to initialize submodules"));
assert!(msg.contains("boom"));
}
#[test]
fn create_update_decision_fast_forwards_then_creates() {
let repo = TestRepo::init();
let c2 = main_behind_origin(&repo);
let (t, session, mut app) = setup(&repo);
let outcome = run_job(
JobCx::capture(&t.cx),
Job::Create {
branch: "feature".into(),
base: None,
decision: Some(CreateDecision::Update),
},
);
apply_outcome(&t.cx, &session, &mut app, outcome);
assert_eq!(app.mode, Mode::List);
assert_eq!(repo.git(&["rev-parse", "refs/heads/main"]).trim(), c2);
assert_eq!(repo.git(&["rev-parse", "refs/heads/feature"]).trim(), c2);
}
#[test]
fn do_remove_removes_selected() {
let repo = TestRepo::init();
repo.add_worktree("feature/x", "../wt-x");
let (mut t, session, mut app) = setup(&repo);
let index = app
.worktrees
.iter()
.position(|w| w.branch.as_deref() == Some("feature/x"))
.unwrap();
do_remove(&mut t.cx, &session, &mut app, index);
assert!(
!app.worktrees
.iter()
.any(|w| w.has_worktree && w.branch.as_deref() == Some("feature/x"))
);
assert!(
app.worktrees
.iter()
.any(|w| !w.has_worktree && w.branch.as_deref() == Some("feature/x"))
);
}
#[test]
fn do_delete_branch_removes_branch_row_and_refreshes() {
let repo = TestRepo::init();
repo.git(&["branch", "topic"]); let (mut t, session, mut app) = setup(&repo);
assert!(
app.worktrees
.iter()
.any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
);
do_delete_branch(&mut t.cx, &session, &mut app, "topic".into(), false);
assert_eq!(app.mode, Mode::List);
assert!(
!app.worktrees
.iter()
.any(|w| w.branch.as_deref() == Some("topic"))
);
assert!(
app.status_message
.as_deref()
.unwrap()
.contains("deleted branch topic")
);
}
#[test]
fn do_delete_branch_unmerged_reprompts_then_force_deletes() {
let repo = TestRepo::init();
repo.add_worktree("unmerged", "../wt-unmerged");
let wt = repo.root().parent().unwrap().join("wt-unmerged");
std::fs::write(wt.join("c.txt"), "x\n").unwrap();
let dir = wt.to_string_lossy().into_owned();
repo.git(&["-C", &dir, "add", "-A"]);
repo.git(&["-C", &dir, "commit", "-q", "-m", "unmerged change"]);
repo.git(&["worktree", "remove", "--force", &dir]);
let (mut t, session, mut app) = setup(&repo);
do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), false);
assert!(matches!(
app.mode,
Mode::ConfirmDeleteBranch { force: true, .. }
));
assert!(
app.worktrees
.iter()
.any(|w| w.branch.as_deref() == Some("unmerged"))
);
app.mode = Mode::List;
do_delete_branch(&mut t.cx, &session, &mut app, "unmerged".into(), true);
assert_eq!(app.mode, Mode::List);
assert!(
!app.worktrees
.iter()
.any(|w| w.branch.as_deref() == Some("unmerged"))
);
}
#[test]
fn do_materialize_branch_creates_worktree_and_stays_focused() {
let repo = TestRepo::init();
repo.git(&["branch", "topic"]);
let (mut t, session, mut app) = setup(&repo);
assert!(
app.worktrees
.iter()
.any(|w| !w.has_worktree && w.branch.as_deref() == Some("topic"))
);
do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
assert_eq!(app.mode, Mode::List);
assert!(app.chosen.is_none());
assert!(
app.worktrees
.iter()
.any(|w| w.has_worktree && w.branch.as_deref() == Some("topic"))
);
let focused = app.selected_worktree().unwrap();
assert!(focused.has_worktree && focused.branch.as_deref() == Some("topic"));
assert!(
app.status_message
.as_deref()
.unwrap()
.contains("created topic")
);
}
#[test]
fn do_materialize_branch_error_shows_in_status() {
let repo = TestRepo::init();
repo.add_worktree("dup", "../manual-dup");
let (mut t, session, mut app) = setup(&repo);
do_materialize_branch(&mut t.cx, &session, &mut app, "dup".into());
assert!(app.chosen.is_none());
assert_eq!(app.status_kind, StatusKind::Error);
assert!(app.status_message.is_some());
}
#[test]
fn do_materialize_branch_queues_background_submodule_init() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
repo.git(&["branch", "topic"]); let (mut t, session, mut app) = setup(&repo);
do_materialize_branch(&mut t.cx, &session, &mut app, "topic".into());
assert!(app.chosen.is_none());
let queued = app.take_pending_jobs();
assert!(
queued
.iter()
.any(|e| matches!(e, Effect::InitSubmodules { .. })),
"expected a queued InitSubmodules job, got {queued:?}"
);
}
#[test]
fn apply_create_auto_policy_queues_submodule_job() {
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let (t, session, mut app) = setup(&repo);
apply_create(
&t.cx,
&mut app,
"feature",
None,
CreateOutcome::CreatedNeedsSubmodules {
dir: session.primary_root.clone(),
count: 1,
auto: true,
},
&session.primary_root,
);
assert_eq!(app.mode, Mode::List);
let queued = app.take_pending_jobs();
assert!(
queued
.iter()
.any(|e| matches!(e, Effect::InitSubmodules { .. }))
);
}
#[test]
fn apply_create_while_exit_blocked_queues_submodule_job_and_keeps_waiting() {
use crate::tui::app::{ExitBlockedState, ExitIntent, JobKey};
let repo = TestRepo::init();
repo.add_submodule("libs/sub");
let (t, session, mut app) = setup(&repo);
app.mode = Mode::ExitBlocked(ExitBlockedState {
intent: ExitIntent::Switch(session.primary_root.clone()),
});
apply_create(
&t.cx,
&mut app,
"feature",
None,
CreateOutcome::CreatedNeedsSubmodules {
dir: session.primary_root.clone(),
count: 1,
auto: false, },
&session.primary_root,
);
assert!(matches!(app.mode, Mode::ExitBlocked(_)));
let queued = app.take_pending_jobs();
assert!(
queued
.iter()
.any(|e| matches!(e, Effect::InitSubmodules { .. }))
);
let key = JobKey::Path(session.primary_root.clone());
app.begin_job(key.clone(), "Initializing 1 submodule(s)");
assert!(!app.exit_now());
app.finish_job(&key);
assert!(app.exit_now());
assert_eq!(app.chosen, Some(session.primary_root.clone()));
}
#[test]
fn do_fetch_prs_populates_picker() {
let repo = TestRepo::init();
let (mut t, session, mut app) = setup(&repo);
t.cx.gh = StdArc::new(FakeGh::with_list(vec![crate::gh::PrSummary {
number: 5,
title: "T".into(),
author: crate::gh::Author {
login: "alice".into(),
},
state: "OPEN".into(),
is_draft: false,
head_ref_name: "h".into(),
created_at: String::new(),
}]));
app.mode = Mode::PrPicker(Default::default());
do_fetch_prs(&t.cx, &session, &mut app);
if let Mode::PrPicker(state) = &app.mode {
assert!(!state.loading);
assert_eq!(state.prs.len(), 1);
assert_eq!(state.prs[0].number, 5);
} else {
panic!("expected pr picker");
}
}
#[test]
fn do_fetch_prs_surfaces_gh_error() {
let repo = TestRepo::init();
let (mut t, session, mut app) = setup(&repo);
t.cx.gh = StdArc::new(FakeGh::unavailable());
app.mode = Mode::PrPicker(Default::default());
do_fetch_prs(&t.cx, &session, &mut app);
if let Mode::PrPicker(state) = &app.mode {
assert!(state.error.is_some());
} else {
panic!("expected pr picker");
}
}
#[test]
fn do_refresh_reloads_worktrees() {
let repo = TestRepo::init();
let (t, session, mut app) = setup(&repo);
repo.add_worktree("added", "../wt-added");
do_refresh(&t.cx, &mut app, &session.primary_root);
assert!(
app.worktrees
.iter()
.any(|w| w.branch.as_deref() == Some("added"))
);
}
fn repo_with_pr(number: u64) -> TestRepo {
let repo = TestRepo::init();
repo.write("pr.txt", "from pr\n");
repo.commit_all("pr commit");
let pr_oid = repo.git(&["rev-parse", "HEAD"]).trim().to_string();
repo.git(&["update-ref", &format!("refs/pull/{number}/head"), &pr_oid]);
repo.git(&["reset", "-q", "--hard", "HEAD~1"]);
repo.git(&["remote", "add", "origin", repo.root().to_str().unwrap()]);
repo
}
fn pr_view(number: u64, head: &str, base: &str) -> crate::gh::PrView {
crate::gh::PrView {
number,
title: "Add login".into(),
state: "OPEN".into(),
is_draft: false,
head_ref_name: head.into(),
base_ref_name: base.into(),
url: format!("https://github.com/o/r/pull/{number}"),
}
}
#[test]
fn do_checkout_pr_stays_in_list_and_focuses_new_worktree() {
let repo = repo_with_pr(123);
let (mut t, session, mut app) = setup(&repo);
t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(123, "pr-feature", "main")));
app.mode = Mode::PrPicker(Default::default());
do_checkout_pr(&mut t.cx, &session, &mut app, 123);
assert!(app.chosen.is_none());
assert_eq!(app.mode, Mode::List);
assert_eq!(
app.selected_worktree().unwrap().branch.as_deref(),
Some("pr-feature")
);
}
#[test]
fn do_checkout_pr_stays_in_list_without_exit_flag() {
let repo = repo_with_pr(55);
let (mut t, session, mut app) = setup(&repo);
t.cx.gh = StdArc::new(FakeGh::with_view(pr_view(55, "pr-feature", "main")));
app.mode = Mode::PrPicker(Default::default());
do_checkout_pr(&mut t.cx, &session, &mut app, 55);
assert!(app.chosen.is_none());
assert_eq!(app.mode, Mode::List);
assert!(
app.status_message
.as_deref()
.unwrap()
.contains("checked out")
);
assert!(
app.worktrees
.iter()
.any(|w| w.branch.as_deref() == Some("pr-feature"))
);
}
#[test]
fn do_checkout_branch_switches_and_stays_in_list() {
let repo = TestRepo::init();
repo.git(&["branch", "topic"]);
let (mut t, session, mut app) = setup(&repo);
app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
worktree_index: 0,
..Default::default()
});
do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
assert_eq!(app.mode, Mode::List);
assert!(app.chosen.is_none());
assert!(
app.status_message
.as_deref()
.unwrap()
.contains("checked out topic")
);
assert_eq!(
repo.git(&["rev-parse", "--abbrev-ref", "HEAD"]).trim(),
"topic"
);
}
#[test]
fn do_checkout_branch_dirty_shows_error_in_picker() {
let repo = TestRepo::init();
repo.git(&["branch", "topic"]);
repo.write("README.md", "dirty\n"); let (mut t, session, mut app) = setup(&repo);
app.mode = Mode::Checkout(crate::tui::app::CheckoutState {
worktree_index: 0,
submitting: true,
..Default::default()
});
do_checkout_branch(&mut t.cx, &session, &mut app, 0, "topic".into());
if let Mode::Checkout(state) = &app.mode {
assert!(state.error.as_deref().unwrap().contains("uncommitted"));
assert!(!state.submitting);
} else {
panic!("expected checkout picker with error");
}
}
#[test]
fn do_sync_fast_forwards_and_refreshes() {
let repo = TestRepo::init();
let c2 = main_behind_origin(&repo);
let (mut t, session, mut app) = setup(&repo);
do_sync(&mut t.cx, &session, &mut app, 0);
assert_eq!(app.mode, Mode::List);
assert_eq!(app.status_kind, StatusKind::Success);
assert!(
app.status_message
.as_deref()
.unwrap()
.contains("fast-forwarded")
);
assert_eq!(repo.git(&["rev-parse", "main"]).trim(), c2);
}
#[test]
fn do_sync_branch_row_fast_forwards() {
let repo = TestRepo::init();
let tip = feat_branch_behind_origin(&repo);
let (mut t, session, mut app) = setup(&repo);
let index = app
.worktrees
.iter()
.position(|w| w.branch.as_deref() == Some("feat") && !w.has_worktree)
.unwrap();
do_sync(&mut t.cx, &session, &mut app, index);
assert_eq!(app.mode, Mode::List);
assert_eq!(app.status_kind, StatusKind::Success);
assert!(
app.status_message
.as_deref()
.unwrap()
.contains("fast-forwarded")
);
assert_eq!(repo.git(&["rev-parse", "feat"]).trim(), tip);
}
#[test]
fn do_sync_no_upstream_shows_status() {
let repo = TestRepo::init();
let (mut t, session, mut app) = setup(&repo);
do_sync(&mut t.cx, &session, &mut app, 0); assert_eq!(app.mode, Mode::List);
assert!(
app.status_message
.as_deref()
.unwrap()
.contains("no upstream")
);
}
#[test]
fn do_sync_dirty_shows_error_status() {
let repo = TestRepo::init();
main_behind_origin(&repo);
repo.write("README.md", "dirty\n"); let (mut t, session, mut app) = setup(&repo);
do_sync(&mut t.cx, &session, &mut app, 0);
assert_eq!(app.status_kind, StatusKind::Error);
assert!(app.status_message.as_deref().unwrap().contains("dirty"));
}
#[test]
fn do_sync_error_shows_in_status() {
let repo = TestRepo::init();
repo.add_worktree("feat", "../wt-feat");
let (mut t, session, mut app) = setup(&repo);
let index = app
.worktrees
.iter()
.position(|w| w.branch.as_deref() == Some("feat"))
.unwrap();
std::fs::remove_dir_all(repo.root().parent().unwrap().join("wt-feat")).unwrap();
do_sync(&mut t.cx, &session, &mut app, index);
assert_eq!(app.status_kind, StatusKind::Error);
assert!(app.status_message.is_some());
}
fn sendit_ctx(branch: &str, trunk: &str, has_upstream: bool) -> sendit::PrContext {
sendit::PrContext {
branch: branch.into(),
trunk: trunk.into(),
merge_base: "abc".into(),
has_upstream,
commits_ahead: 1,
commit_log: vec![],
diffstat: sendit::DiffStat {
files: 1,
insertions: 1,
deletions: 0,
raw: String::new(),
},
existing_pr: None,
}
}
fn feature_repo_with_remote() -> (TestRepo, TestRepo) {
let bare = TestRepo::init_bare();
let repo = TestRepo::init();
repo.git(&["checkout", "-q", "-b", "feat"]);
repo.write("f.txt", "x\n");
repo.commit_all("feat work");
repo.git(&["remote", "add", "origin", bare.root().to_str().unwrap()]);
(repo, bare)
}
#[test]
fn do_draft_pr_ai_seeds_form() {
let repo = TestRepo::init();
let (mut t, session, mut app) = setup(&repo);
t.cx.agent = StdArc::new(crate::testutil::FakeAgent::drafting(
"Add login\n\nBody here",
));
app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
do_draft_pr_ai(
&mut t.cx,
&session,
&mut app,
&sendit_ctx("feat", "main", false),
);
if let Mode::PrCompose(s) = &app.mode {
assert_eq!(s.title, "Add login");
assert_eq!(s.body, "Body here");
assert!(s.error.is_none());
} else {
panic!("expected compose mode");
}
}
#[test]
fn do_draft_pr_ai_shows_error_when_unavailable() {
let repo = TestRepo::init();
let (mut t, session, mut app) = setup(&repo);
app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
do_draft_pr_ai(
&mut t.cx,
&session,
&mut app,
&sendit_ctx("feat", "main", false),
);
if let Mode::PrCompose(s) = &app.mode {
assert!(s.error.is_some());
} else {
panic!("expected compose mode");
}
}
#[test]
fn do_draft_pr_ai_uses_form_model_and_effort() {
let repo = TestRepo::init();
let (mut t, session, mut app) = setup(&repo);
let agent = StdArc::new(crate::testutil::FakeAgent::drafting("T\n\nB"));
t.cx.agent = agent.clone();
app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
model: crate::agent::AgentModel::Opus,
effort: crate::agent::Effort::High,
..Default::default()
});
do_draft_pr_ai(
&mut t.cx,
&session,
&mut app,
&sendit_ctx("feat", "main", false),
);
assert_eq!(
agent.last_opts(),
Some(crate::agent::AgentOptions {
model: crate::agent::AgentModel::Opus,
effort: crate::agent::Effort::High,
})
);
}
#[test]
fn do_submit_pr_creates_records_and_exits() {
let (repo, _bare) = feature_repo_with_remote();
let (mut t, session, mut app) = setup(&repo);
t.cx.gh = StdArc::new(FakeGh::sender("https://github.com/o/r/pull/77\n"));
app.mode = Mode::PrCompose(crate::tui::app::PrComposeState::default());
let mut outcome = None;
let done = do_submit_pr(
&mut t.cx,
&session,
&mut app,
&sendit_ctx("feat", "main", false),
sendit::PrAction::Create,
"T".into(),
"B".into(),
false,
&mut outcome,
);
assert!(done);
assert_eq!(outcome.expect("outcome").0.number, Some(77));
assert_eq!(
repo.git(&["config", "--get", "wt.feat.prNumber"]).trim(),
"77"
);
}
#[test]
fn do_submit_pr_error_stays_in_form() {
let (repo, _bare) = feature_repo_with_remote();
let (mut t, session, mut app) = setup(&repo);
t.cx.gh = StdArc::new(FakeGh::unavailable());
app.mode = Mode::PrCompose(crate::tui::app::PrComposeState {
submitting: true,
..Default::default()
});
let mut outcome = None;
let done = do_submit_pr(
&mut t.cx,
&session,
&mut app,
&sendit_ctx("feat", "main", false),
sendit::PrAction::Create,
"T".into(),
"B".into(),
false,
&mut outcome,
);
assert!(!done);
assert!(outcome.is_none());
if let Mode::PrCompose(s) = &app.mode {
assert!(s.error.is_some());
assert!(!s.submitting);
} else {
panic!("expected compose mode");
}
}
#[test]
fn do_checkout_pr_surfaces_gh_error_in_picker() {
let repo = TestRepo::init();
let (mut t, session, mut app) = setup(&repo);
t.cx.gh = StdArc::new(FakeGh::unavailable());
app.mode = Mode::PrPicker(Default::default());
do_checkout_pr(&mut t.cx, &session, &mut app, 1);
if let Mode::PrPicker(state) = &app.mode {
assert!(state.error.is_some());
} else {
panic!("expected pr picker with error");
}
assert!(app.chosen.is_none());
}
#[test]
fn is_background_action_matches_mutations_only() {
assert!(is_background_action(&Effect::Create {
branch: "x".into(),
base: None,
decision: None,
}));
assert!(is_background_action(&Effect::Remove(0)));
assert!(is_background_action(&Effect::MaterializeBranch {
branch: "x".into()
}));
assert!(is_background_action(&Effect::CheckoutPr(1)));
assert!(is_background_action(&Effect::CheckoutBranch {
worktree_index: 0,
branch: "x".into()
}));
assert!(is_background_action(&Effect::Sync { worktree_index: 0 }));
assert!(!is_background_action(&Effect::Refresh));
assert!(!is_background_action(&Effect::FetchPrs));
assert!(!is_background_action(&Effect::None));
assert!(!is_background_action(&Effect::OpenEditor("/tmp".into())));
}
#[test]
fn resolve_job_sets_label_key_and_args() {
use crate::tui::app::testutil::app as make_app;
let a = make_app(&[("main", true), ("feat/x", false)]);
let (job, key, label) = resolve_job(
&a,
Effect::Create {
branch: "feat/new".into(),
base: Some("main".into()),
decision: None,
},
)
.unwrap();
assert!(matches!(job, Job::Create { .. }));
assert_eq!(label, "Creating feat/new");
assert_eq!(key, JobKey::New("feat/new".into()));
let (job, key, label) = resolve_job(&a, Effect::Remove(1)).unwrap();
assert!(matches!(job, Job::Remove { query } if query == "feat/x"));
assert_eq!(label, "Removing feat/x");
assert_eq!(key, JobKey::Path(a.worktrees[1].path.clone()));
let (job, key, label) = resolve_job(
&a,
Effect::CheckoutBranch {
worktree_index: 0,
branch: "feat/x".into(),
},
)
.unwrap();
assert!(matches!(job, Job::CheckoutBranch { .. }));
assert_eq!(label, "Checking out feat/x");
assert_eq!(key, JobKey::Path(a.worktrees[0].path.clone()));
let (job, _key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
assert!(matches!(job, Job::Sync { .. }));
assert_eq!(label, "Syncing feat/x");
let (job, key, label) = resolve_job(&a, Effect::CheckoutPr(7)).unwrap();
assert!(matches!(job, Job::CheckoutPr { number } if number == 7));
assert_eq!(label, "Checking out PR #7");
assert_eq!(key, JobKey::New("PR #7".into()));
}
#[test]
fn resolve_job_returns_none_for_missing_row() {
use crate::tui::app::testutil::app as make_app;
let a = make_app(&[("main", true)]);
assert!(resolve_job(&a, Effect::Remove(99)).is_none());
assert!(
resolve_job(
&a,
Effect::CheckoutBranch {
worktree_index: 99,
branch: "x".into()
}
)
.is_none()
);
assert!(resolve_job(&a, Effect::Sync { worktree_index: 99 }).is_none());
assert!(resolve_job(&a, Effect::Refresh).is_none());
}
#[test]
fn spawn_job_refuses_conflicting_action_on_same_row() {
use crate::tui::app::testutil::app as make_app;
let mut a = make_app(&[("main", true), ("feat/x", false)]);
let key = JobKey::Path(a.worktrees[1].path.clone());
a.begin_job(key.clone(), "Removing feat/x");
let (_, resolved_key, label) = resolve_job(&a, Effect::Sync { worktree_index: 1 }).unwrap();
assert_eq!(resolved_key, key);
assert!(a.has_job(&resolved_key));
a.set_status(format!("{label} — already in progress"), StatusKind::Info);
assert_eq!(a.jobs.len(), 1);
assert!(a.status_message.as_deref().unwrap().contains("in progress"));
}
#[test]
fn anchor_at_root_repoints_cwd_and_returns_opened_worktree() {
let repo = TestRepo::init();
repo.add_worktree("feature/x", "../wt-x");
let linked = repo.root().parent().unwrap().join("wt-x");
let mut t = test_cx(&[], linked.to_str().unwrap());
let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
let opened_in = anchor_at_root(&mut t.cx, &session);
assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
assert_eq!(canon(&opened_in), canon(&linked));
}
#[test]
fn removing_opened_in_worktree_keeps_operations_working() {
let repo = TestRepo::init();
repo.add_worktree("feature/x", "../wt-x");
let linked = repo.root().parent().unwrap().join("wt-x");
let mut t = test_cx(&[], linked.to_str().unwrap());
let session = open_session(&t.cx, &crate::git::RealGit).unwrap();
let opened_in = anchor_at_root(&mut t.cx, &session);
assert_eq!(canon(&opened_in), canon(&linked));
assert_eq!(canon(&t.cx.cwd), canon(&session.primary_root));
run_remove_command(&mut t.cx, "feature/x").unwrap();
assert!(!linked.exists());
let again = open_session(&t.cx, &crate::git::RealGit).unwrap();
assert_eq!(canon(&again.primary_root), canon(&session.primary_root));
let nav = finish_exit(&mut t.cx, &opened_in, &session.primary_root, None).unwrap();
assert_eq!(canon(&nav.unwrap()), canon(&session.primary_root));
assert!(t.err.contents().contains("was removed"));
}
#[test]
fn finish_exit_honors_explicit_switch() {
let chosen = tempfile::tempdir().unwrap();
let mut t = test_cx(&[], "/work");
let out = finish_exit(
&mut t.cx,
Path::new("/deleted"),
Path::new("/deleted-root"),
Some(chosen.path().to_path_buf()),
)
.unwrap();
assert_eq!(out.as_deref(), Some(chosen.path()));
assert!(t.err.contents().is_empty());
}
#[test]
fn finish_exit_drops_chosen_that_was_removed() {
let opened = tempfile::tempdir().unwrap();
let gone_chosen = opened.path().join("wt-removed");
let mut t = test_cx(&[], "/work");
let out = finish_exit(&mut t.cx, opened.path(), opened.path(), Some(gone_chosen)).unwrap();
assert_eq!(out, None);
assert!(t.err.contents().is_empty());
}
#[test]
fn finish_exit_stays_put_when_opened_dir_survives() {
let dir = tempfile::tempdir().unwrap();
let mut t = test_cx(&[], "/work");
let out = finish_exit(&mut t.cx, dir.path(), dir.path(), None).unwrap();
assert_eq!(out, None);
assert!(t.err.contents().is_empty());
}
#[test]
fn finish_exit_returns_to_root_when_opened_dir_deleted() {
let root = tempfile::tempdir().unwrap();
let gone = root.path().join("wt-x");
let mut t = test_cx(&[], "/work");
let out = finish_exit(&mut t.cx, &gone, root.path(), None).unwrap();
assert_eq!(out.as_deref(), Some(root.path()));
let err = t.err.contents();
assert!(err.contains("was removed"));
assert!(err.contains(&root.path().display().to_string()));
}
#[test]
fn finish_exit_reports_when_root_also_gone() {
let scratch = tempfile::tempdir().unwrap();
let gone = scratch.path().join("wt-x");
let gone_root = scratch.path().join("root");
let mut t = test_cx(&[], "/work");
let out = finish_exit(&mut t.cx, &gone, &gone_root, None).unwrap();
assert_eq!(out, None);
assert!(t.err.contents().contains("no longer available"));
}
fn canon(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
}