summaryrefslogtreecommitdiff
path: root/src/app/worktrees.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/app/worktrees.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/app/worktrees.rs')
-rw-r--r--src/app/worktrees.rs2345
1 files changed, 2 insertions, 2343 deletions
diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs
index 9993a70e..b4ac1482 100644
--- a/src/app/worktrees.rs
+++ b/src/app/worktrees.rs
@@ -1,972 +1,6 @@
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-
-use super::{
- state::{WorktreeCreateState, WorktreeOpenEntry, WorktreeOpenState, WorktreeRemoveState},
- App, Mode,
-};
-#[cfg(test)]
-use crate::events::AppEvent;
-use crate::events::{WorktreeAddResult, WorktreeRemoveResult};
+use super::App;
impl App {
- fn worktree_source_metadata(
- &self,
- ws_idx: usize,
- ) -> Result<
- (
- Option<crate::workspace::WorktreeSpaceMembership>,
- crate::workspace::GitSpaceMetadata,
- std::path::PathBuf,
- String,
- ),
- String,
- > {
- let Some(ws) = self.state.workspaces.get(ws_idx) else {
- return Err("Workspace not found.".into());
- };
- let existing_membership = ws.worktree_space().cloned();
- if existing_membership
- .as_ref()
- .is_some_and(|membership| membership.is_linked_worktree)
- {
- return Err(
- "New and open worktree actions start from the repo parent workspace.".into(),
- );
- }
-
- let git_space = ws.git_space().cloned().or_else(|| {
- ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
- .as_deref()
- .and_then(crate::workspace::git_space_metadata)
- });
- if git_space
- .as_ref()
- .is_some_and(|metadata| metadata.is_linked_worktree)
- {
- return Err(
- "New and open worktree actions start from the repo parent workspace.".into(),
- );
- }
-
- let space = existing_membership
- .as_ref()
- .map_or(git_space, |membership| {
- Some(crate::workspace::GitSpaceMetadata {
- key: membership.key.clone(),
- checkout_key: membership.checkout_path.display().to_string(),
- repo_name: membership.label.clone(),
- repo_root: membership.repo_root.clone(),
- is_linked_worktree: membership.is_linked_worktree,
- })
- })
- .ok_or_else(|| {
- "Herdr worktree actions require a workspace inside a Git work tree.".to_string()
- })?;
- let source_checkout_path = existing_membership
- .as_ref()
- .map(|membership| membership.checkout_path.clone())
- .unwrap_or_else(|| space.repo_root.clone());
- let source_workspace_id = self.state.workspaces[ws_idx].id.clone();
- Ok((
- existing_membership,
- space,
- source_checkout_path,
- source_workspace_id,
- ))
- }
-
- pub(crate) fn open_new_linked_worktree_dialog(&mut self, ws_idx: usize) {
- let (existing_membership, space, source_checkout_path, source_workspace_id) =
- match self.worktree_source_metadata(ws_idx) {
- Ok(metadata) => metadata,
- Err(err) => {
- self.state.config_diagnostic = Some(err);
- return;
- }
- };
-
- let repo_name = space.repo_name.clone();
- let seed = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64)
- .unwrap_or(0);
- let branch = crate::worktree::generated_branch_slug(seed);
- let checkout_path = crate::worktree::default_checkout_path(
- &self.state.worktree_directory,
- &repo_name,
- &branch,
- );
-
- tracing::info!(
- ws_idx,
- repo_root = %space.repo_root.display(),
- branch,
- checkout_path = %checkout_path.display(),
- "opening worktree dialog"
- );
- self.state.selected = ws_idx;
- self.state.name_input = branch.clone();
- self.state.name_input_replace_on_type = true;
- self.state.worktree_create = Some(WorktreeCreateState {
- source_workspace_id,
- source_checkout_path,
- source_existing_membership: existing_membership,
- source_repo_root: space.repo_root,
- repo_key: space.key,
- repo_name,
- branch,
- checkout_path,
- error: None,
- creating: false,
- });
- self.state.mode = Mode::NewLinkedWorktree;
- }
-
- pub(crate) fn open_remove_linked_worktree_confirmation(&mut self, ws_idx: usize) {
- let Some(ws) = self.state.workspaces.get(ws_idx) else {
- return;
- };
- if !ws
- .worktree_space()
- .is_some_and(|space| space.is_linked_worktree)
- {
- self.state.config_diagnostic =
- Some("This workspace is not a Herdr-managed worktree checkout.".into());
- return;
- }
- let Some(space) = ws.worktree_space().cloned() else {
- return;
- };
- self.state.selected = ws_idx;
- self.state.worktree_remove = Some(WorktreeRemoveState {
- workspace_id: ws.id.clone(),
- repo_root: space.repo_root,
- path: space.checkout_path,
- error: None,
- removing: false,
- force_confirmation: false,
- });
- self.state.mode = Mode::ConfirmRemoveWorktree;
- }
-
- pub(crate) fn open_existing_worktree_dialog(&mut self, ws_idx: usize) {
- let (existing_membership, space, source_checkout_path, source_workspace_id) =
- match self.worktree_source_metadata(ws_idx) {
- Ok(metadata) => metadata,
- Err(err) => {
- self.state.config_diagnostic = Some(err);
- return;
- }
- };
-
- let list = match crate::worktree::list_existing_worktrees(&space.repo_root, false) {
- Ok(list) => list,
- Err(err) => {
- self.state.config_diagnostic = Some(err);
- return;
- }
- };
- let entries = list
- .into_iter()
- .filter(|entry| !entry.is_bare && !entry.is_prunable)
- .map(|entry| {
- let entry_checkout_path = crate::worktree::canonical_or_original(&entry.path);
- let entry_checkout_key = entry_checkout_path.display().to_string();
- let repo_checkout_path = crate::worktree::canonical_or_original(&space.repo_root);
- let already_open_ws_idx = self.state.workspaces.iter().position(|ws| {
- if let Some(membership) = ws.worktree_space() {
- return crate::worktree::canonical_or_original(&membership.checkout_path)
- == entry_checkout_path;
- }
-
- let git_space = ws.git_space().cloned().or_else(|| {
- ws.resolved_identity_cwd_from(
- &self.state.terminals,
- &self.terminal_runtimes,
- )
- .as_deref()
- .and_then(crate::workspace::git_space_metadata)
- });
- if git_space
- .as_ref()
- .is_some_and(|metadata| metadata.checkout_key == entry_checkout_key)
- {
- return true;
- }
-
- ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
- .as_deref()
- .is_some_and(|cwd| {
- crate::worktree::canonical_or_original(cwd) == entry_checkout_path
- })
- });
- WorktreeOpenEntry {
- is_linked_worktree: entry_checkout_path != repo_checkout_path,
- path: entry.path,
- branch: entry.branch,
- already_open_ws_idx,
- }
- })
- .collect::<Vec<_>>();
-
- if entries.is_empty() {
- self.state.config_diagnostic = Some("No Git worktrees found for this repo.".into());
- return;
- }
-
- self.state.selected = ws_idx;
- self.state.worktree_open = Some(WorktreeOpenState {
- source_workspace_id,
- source_existing_membership: existing_membership,
- source_checkout_path,
- source_repo_root: space.repo_root,
- repo_key: space.key,
- repo_name: space.repo_name,
- entries,
- selected: 0,
- query: String::new(),
- search_focused: false,
- error: None,
- });
- self.state.mode = Mode::OpenExistingWorktree;
- }
-
- pub(crate) fn handle_worktree_create_key(&mut self, key: KeyEvent) {
- match key.code {
- KeyCode::Esc => {
- if self
- .state
- .worktree_create
- .as_ref()
- .is_some_and(|create| create.creating)
- {
- return;
- }
- self.close_worktree_create_dialog();
- }
- KeyCode::Enter => self.submit_worktree_create_via_api(),
- KeyCode::Backspace => {
- if self.state.name_input_replace_on_type {
- self.state.name_input.clear();
- self.state.name_input_replace_on_type = false;
- } else {
- self.state.name_input.pop();
- }
- self.sync_worktree_branch_from_input();
- }
- KeyCode::Char(c) => {
- self.insert_worktree_create_text(&c.to_string());
- }
- _ => {}
- }
- }
-
- pub(crate) fn insert_worktree_create_text(&mut self, text: &str) {
- if self.state.name_input_replace_on_type {
- self.state.name_input.clear();
- self.state.name_input_replace_on_type = false;
- }
- self.state.name_input.push_str(text);
- self.sync_worktree_branch_from_input();
- }
-
- pub(crate) fn handle_worktree_open_key(&mut self, key: KeyEvent) {
- match key.code {
- KeyCode::Esc => {
- self.state.worktree_open = None;
- self.state.mode = if self.state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
- KeyCode::Up => {
- if let Some(open) = &mut self.state.worktree_open {
- open.select_previous_filtered();
- }
- }
- KeyCode::Down => {
- if let Some(open) = &mut self.state.worktree_open {
- open.select_next_filtered();
- }
- }
- KeyCode::Char('/') => {
- if let Some(open) = &mut self.state.worktree_open {
- if open.search_focused {
- open.query.push('/');
- open.normalize_selection();
- } else {
- open.search_focused = true;
- }
- }
- }
- KeyCode::Char(ch)
- if self
- .state
- .worktree_open
- .as_ref()
- .is_some_and(|open| open.search_focused)
- && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
- && !ch.is_control() =>
- {
- self.insert_worktree_open_search_text(&ch.to_string());
- }
- KeyCode::Backspace
- if self
- .state
- .worktree_open
- .as_ref()
- .is_some_and(|open| open.search_focused) =>
- {
- if let Some(open) = &mut self.state.worktree_open {
- open.query.pop();
- open.normalize_selection();
- }
- }
- KeyCode::Enter => self.submit_worktree_open_via_api(),
- _ => {}
- }
- }
-
- pub(crate) fn insert_worktree_open_search_text(&mut self, text: &str) {
- let Some(open) = &mut self.state.worktree_open else {
- return;
- };
- if !open.search_focused {
- return;
- }
- open.query.push_str(text);
- open.normalize_selection();
- }
-
- #[cfg(test)]
- pub(crate) fn open_selected_existing_worktree(&mut self) {
- let Some(open) = self.state.worktree_open.as_ref() else {
- return;
- };
- let Some(entry_idx) = open.selected_entry_index() else {
- return;
- };
- let Some(entry) = open.entries.get(entry_idx).cloned() else {
- return;
- };
- let source_workspace_id = open.source_workspace_id.clone();
- let source_existing_membership = open.source_existing_membership.clone();
- let source_checkout_path = open.source_checkout_path.clone();
- let source_repo_root = open.source_repo_root.clone();
- let repo_key = open.repo_key.clone();
- let repo_name = open.repo_name.clone();
- self.state.worktree_open = None;
-
- if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&entry.path) {
- self.mark_opened_existing_worktree_membership(
- &source_workspace_id,
- source_existing_membership,
- source_checkout_path,
- source_repo_root,
- repo_key,
- repo_name,
- ws_idx,
- entry.path,
- entry.is_linked_worktree,
- );
- self.state.switch_workspace(ws_idx);
- self.state.mode = Mode::Terminal;
- self.emit_worktree_opened_for_workspace(ws_idx, true);
- return;
- }
-
- if let Some(source_ws_idx) = self
- .state
- .workspaces
- .iter()
- .position(|ws| ws.id == source_workspace_id)
- {
- let source_membership = source_existing_membership.clone().unwrap_or(
- crate::workspace::WorktreeSpaceMembership {
- key: repo_key.clone(),
- label: repo_name.clone(),
- repo_root: source_repo_root.clone(),
- checkout_path: source_checkout_path.clone(),
- is_linked_worktree: false,
- },
- );
- self.set_worktree_membership(source_ws_idx, source_membership, true);
- }
-
- match self.create_workspace_with_options(entry.path.clone(), true) {
- Ok(new_ws_idx) => {
- self.set_worktree_membership(
- new_ws_idx,
- crate::workspace::WorktreeSpaceMembership {
- key: repo_key,
- label: repo_name,
- repo_root: source_repo_root,
- checkout_path: entry.path,
- is_linked_worktree: entry.is_linked_worktree,
- },
- false,
- );
- self.emit_workspace_open_events(new_ws_idx);
- self.emit_worktree_opened_for_workspace(new_ws_idx, false);
- }
- Err(err) => {
- self.state.worktree_open = Some(WorktreeOpenState {
- source_workspace_id,
- source_existing_membership,
- source_checkout_path,
- source_repo_root,
- repo_key,
- repo_name,
- entries: vec![entry],
- selected: 0,
- query: String::new(),
- search_focused: false,
- error: Some(format!("failed to open worktree: {err}")),
- });
- self.state.mode = Mode::OpenExistingWorktree;
- }
- }
- }
-
- // The caller has already extracted the open-worktree dialog state; keeping the
- // membership fields explicit here avoids borrowing AppState across workspace creation.
- #[allow(clippy::too_many_arguments)]
- #[cfg(test)]
- fn mark_opened_existing_worktree_membership(
- &mut self,
- source_workspace_id: &str,
- source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>,
- source_checkout_path: std::path::PathBuf,
- source_repo_root: std::path::PathBuf,
- repo_key: String,
- repo_name: String,
- target_ws_idx: usize,
- target_path: std::path::PathBuf,
- target_is_linked_worktree: bool,
- ) {
- if let Some(source_ws_idx) = self
- .state
- .workspaces
- .iter()
- .position(|ws| ws.id == source_workspace_id)
- {
- let source_membership =
- source_existing_membership.unwrap_or(crate::workspace::WorktreeSpaceMembership {
- key: repo_key.clone(),
- label: repo_name.clone(),
- repo_root: source_repo_root.clone(),
- checkout_path: source_checkout_path,
- is_linked_worktree: false,
- });
- self.set_worktree_membership(source_ws_idx, source_membership, true);
- }
- self.set_worktree_membership(
- target_ws_idx,
- crate::workspace::WorktreeSpaceMembership {
- key: repo_key,
- label: repo_name,
- repo_root: source_repo_root,
- checkout_path: target_path,
- is_linked_worktree: target_is_linked_worktree,
- },
- true,
- );
- }
-
- fn close_worktree_create_dialog(&mut self) {
- self.state.worktree_create = None;
- self.state.name_input.clear();
- self.state.name_input_replace_on_type = false;
- self.state.mode = if self.state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
-
- fn sync_worktree_branch_from_input(&mut self) {
- let Some(create) = &mut self.state.worktree_create else {
- return;
- };
- create.branch = self.state.name_input.clone();
- create.checkout_path = crate::worktree::default_checkout_path(
- &self.state.worktree_directory,
- &create.repo_name,
- &create.branch,
- );
- create.error = None;
- }
-
- #[cfg(test)]
- pub(crate) fn start_worktree_add(&mut self) {
- self.sync_worktree_branch_from_input();
- let Some(create) = &mut self.state.worktree_create else {
- return;
- };
- let branch = create.branch.trim().to_string();
- if branch.is_empty() {
- create.error = Some("branch is required".into());
- return;
- }
- if create.creating {
- return;
- }
-
- create.branch = branch.clone();
- self.state.name_input = branch.clone();
- create.checkout_path = crate::worktree::default_checkout_path(
- &self.state.worktree_directory,
- &create.repo_name,
- &branch,
- );
- create.creating = true;
- create.error = None;
-
- let parent_dir = create
- .checkout_path
- .parent()
- .map(std::path::Path::to_path_buf);
- tracing::info!(
- repo_root = %create.source_repo_root.display(),
- branch = %create.branch,
- checkout_path = %create.checkout_path.display(),
- "starting git worktree add"
- );
- let path = create.checkout_path.clone();
- let source_checkout_path = create.source_checkout_path.clone();
- let branch = create.branch.clone();
- let event_tx = self.event_tx.clone();
- std::thread::spawn(move || {
- let result = if let Some(parent_dir) = parent_dir {
- std::fs::create_dir_all(&parent_dir).map_err(|err| err.to_string())
- } else {
- Ok(())
- }
- .and_then(|()| {
- crate::worktree::run_worktree_add_command(
- &source_checkout_path,
- &path,
- &branch,
- "HEAD",
- false,
- )
- });
- let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new(
- WorktreeAddResult {
- path,
- api_request: None,
- result,
- },
- )));
- });
- }
-
- pub(crate) fn submit_worktree_create_via_api(&mut self) {
- self.sync_worktree_branch_from_input();
- let Some(create) = &mut self.state.worktree_create else {
- return;
- };
- let branch = create.branch.trim().to_string();
- if branch.is_empty() {
- create.error = Some("branch is required".into());
- return;
- }
- if create.creating {
- return;
- }
-
- create.branch = branch.clone();
- self.state.name_input = branch.clone();
- create.checkout_path = crate::worktree::default_checkout_path(
- &self.state.worktree_directory,
- &create.repo_name,
- &branch,
- );
- create.creating = true;
- create.error = None;
- let workspace_id = create.source_workspace_id.clone();
- let checkout_path = create.checkout_path.display().to_string();
-
- let immediate_response = self.runtime_worktree_create_deferred(
- "tui.worktree.create",
- crate::api::schema::WorktreeCreateParams {
- workspace_id: Some(workspace_id),
- cwd: None,
- branch: Some(branch),
- path: Some(checkout_path),
- base: Some("HEAD".into()),
- focus: true,
- label: None,
- trust_repository: false,
- },
- );
- if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) {
- if let Some(create) = &mut self.state.worktree_create {
- create.creating = false;
- create.error = Some(message);
- }
- }
- }
-
- pub(crate) fn handle_worktree_remove_key(&mut self, key: KeyEvent) {
- match key.code {
- KeyCode::Esc => {
- if self
- .state
- .worktree_remove
- .as_ref()
- .is_some_and(|remove| remove.removing)
- {
- return;
- }
- self.state.worktree_remove = None;
- self.state.mode = if self.state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
- KeyCode::Enter => self.submit_worktree_remove_via_api(),
- _ => {}
- }
- }
-
- #[cfg(test)]
- pub(crate) fn start_worktree_remove(&mut self) {
- let Some((workspace_id, repo_root, path, force)) =
- self.state.worktree_remove.as_mut().and_then(|remove| {
- if remove.removing {
- return None;
- }
- #[cfg(windows)]
- if !remove.force_confirmation
- && crate::worktree::checkout_has_dirty_files(&remove.path, false)
- .unwrap_or(false)
- {
- remove.force_confirmation = true;
- remove.error = None;
- return None;
- }
- remove.removing = true;
- remove.error = None;
- Some((
- remove.workspace_id.clone(),
- remove.repo_root.clone(),
- remove.path.clone(),
- remove.force_confirmation,
- ))
- })
- else {
- return;
- };
-
- if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(force) {
- if let Some(ws_idx) = self
- .state
- .workspaces
- .iter()
- .position(|ws| ws.id == workspace_id)
- {
- self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx);
- }
- }
-
- let (workspace_snapshot, worktree_snapshot) = self
- .state
- .workspaces
- .iter()
- .position(|ws| ws.id == workspace_id)
- .map(|ws_idx| {
- let workspace = Box::new(self.workspace_info(ws_idx));
- let worktree = self.state.workspaces[ws_idx]
- .worktree_space()
- .cloned()
- .map(|space| Box::new(self.worktree_info_for_membership(&space, None)));
- (Some(workspace), worktree)
- })
- .unwrap_or((None, None));
-
- let command =
- crate::worktree::build_worktree_remove_command(&repo_root, &path, force, false);
- tracing::info!(workspace_id = %workspace_id, path = %path.display(), force, "starting git worktree remove");
- let event_tx = self.event_tx.clone();
- std::thread::spawn(move || {
- let result = crate::worktree::run_worktree_remove_command_with_recovery(
- &command, &repo_root, &path, force, false,
- );
- let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new(
- WorktreeRemoveResult {
- workspace_id,
- path,
- workspace: workspace_snapshot,
- worktree: worktree_snapshot,
- forced: force,
- api_request: None,
- result,
- },
- )));
- });
- }
-
- pub(crate) fn submit_worktree_open_via_api(&mut self) {
- let Some(open) = self.state.worktree_open.as_ref() else {
- return;
- };
- let Some(entry_idx) = open.selected_entry_index() else {
- return;
- };
- let Some(entry) = open.entries.get(entry_idx).cloned() else {
- return;
- };
- let source_workspace_id = open.source_workspace_id.clone();
-
- let response = self.runtime_worktree_open(
- "tui.worktree.open",
- crate::api::schema::WorktreeOpenParams {
- workspace_id: Some(source_workspace_id),
- cwd: None,
- path: Some(entry.path.display().to_string()),
- branch: None,
- focus: true,
- label: None,
- trust_repository: false,
- },
- );
- if serde_json::from_str::<crate::api::schema::SuccessResponse>(&response).is_ok() {
- self.state.worktree_open = None;
- self.state.mode = Mode::Terminal;
- } else if let Ok(error) =
- serde_json::from_str::<crate::api::schema::ErrorResponse>(&response)
- {
- if let Some(open) = &mut self.state.worktree_open {
- open.error = Some(error.error.message);
- }
- }
- }
-
- pub(crate) fn submit_worktree_remove_via_api(&mut self) {
- let Some(remove) = self.state.worktree_remove.as_mut() else {
- return;
- };
- if remove.removing {
- return;
- }
- #[cfg(windows)]
- if !remove.force_confirmation
- && crate::worktree::checkout_has_dirty_files(&remove.path, false).unwrap_or(false)
- {
- remove.force_confirmation = true;
- remove.error = None;
- return;
- }
-
- remove.removing = true;
- remove.error = None;
- let workspace_id = remove.workspace_id.clone();
- let force = remove.force_confirmation;
- let immediate_response = self.runtime_worktree_remove_deferred(
- "tui.worktree.remove",
- crate::api::schema::WorktreeRemoveParams {
- workspace_id,
- force,
- trust_repository: false,
- },
- );
- if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) {
- if let Some(remove) = &mut self.state.worktree_remove {
- remove.removing = false;
- remove.error = Some(message);
- }
- }
- }
-
- pub(crate) fn handle_worktree_add_finished(&mut self, result: WorktreeAddResult) {
- if result.api_request.is_some() {
- self.handle_api_worktree_add_finished(result);
- return;
- }
- let Some(create) = &mut self.state.worktree_create else {
- return;
- };
- if create.checkout_path != result.path {
- return;
- }
-
- match result.result {
- Ok(()) => {
- tracing::info!(checkout_path = %create.checkout_path.display(), "git worktree add completed");
- let path = create.checkout_path.clone();
- let source_workspace_id = create.source_workspace_id.clone();
- let source_checkout_path = create.source_checkout_path.clone();
- let source_existing_membership = create.source_existing_membership.clone();
- let repo_key = create.repo_key.clone();
- let repo_name = create.repo_name.clone();
- let source_repo_root = create.source_repo_root.clone();
- self.state.worktree_create = None;
- self.state.name_input.clear();
- self.state.name_input_replace_on_type = false;
- let source_membership = source_existing_membership.unwrap_or(
- crate::workspace::WorktreeSpaceMembership {
- key: repo_key.clone(),
- label: repo_name.clone(),
- repo_root: source_repo_root.clone(),
- checkout_path: source_checkout_path,
- is_linked_worktree: false,
- },
- );
- if let Some(source_ws_idx) = self
- .state
- .workspaces
- .iter()
- .position(|ws| ws.id == source_workspace_id)
- {
- self.set_worktree_membership(source_ws_idx, source_membership, true);
- }
- if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&path) {
- self.set_worktree_membership(
- ws_idx,
- crate::workspace::WorktreeSpaceMembership {
- key: repo_key,
- label: repo_name,
- repo_root: source_repo_root,
- checkout_path: path,
- is_linked_worktree: true,
- },
- true,
- );
- self.state.switch_workspace(ws_idx);
- self.state.mode = Mode::Terminal;
- if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) {
- self.emit_worktree_created_event(ws_idx, worktree);
- }
- } else {
- match self.create_workspace_with_options(path.clone(), true) {
- Ok(ws_idx) => {
- self.set_worktree_membership(
- ws_idx,
- crate::workspace::WorktreeSpaceMembership {
- key: repo_key,
- label: repo_name,
- repo_root: source_repo_root,
- checkout_path: path,
- is_linked_worktree: true,
- },
- false,
- );
- self.emit_workspace_open_events(ws_idx);
- if let Some(worktree) = self.worktree_info_for_workspace(ws_idx) {
- self.emit_worktree_created_event(ws_idx, worktree);
- }
- }
- Err(err) => {
- self.state.config_diagnostic = Some(format!(
- "created worktree but failed to open workspace: {err}"
- ));
- self.state.mode = Mode::Navigate;
- }
- }
- }
- self.render_dirty.request_generic();
- self.render_notify.notify_one();
- }
- Err(message) => {
- tracing::warn!(checkout_path = %create.checkout_path.display(), error = %message, "git worktree add failed");
- create.creating = false;
- create.error = Some(message);
- self.render_dirty.request_generic();
- self.render_notify.notify_one();
- }
- }
- }
- pub(crate) fn handle_worktree_remove_finished(&mut self, result: WorktreeRemoveResult) {
- if result.api_request.is_some() {
- self.handle_api_worktree_remove_finished(result);
- return;
- }
- let Some(remove) = &mut self.state.worktree_remove else {
- return;
- };
- if remove.workspace_id != result.workspace_id || remove.path != result.path {
- return;
- }
-
- match result.result {
- Ok(()) => {
- tracing::info!(workspace_id = %result.workspace_id, path = %result.path.display(), "git worktree remove completed");
- let forced = result.forced;
- self.state.worktree_remove = None;
- let mut workspace_id = result.workspace_id.clone();
- let mut workspace_snapshot = result.workspace.as_deref().cloned();
- let mut worktree = result.worktree.as_deref().cloned();
- if let Some(ws_idx) = self
- .state
- .workspaces
- .iter()
- .position(|ws| ws.id == result.workspace_id)
- {
- workspace_id = self.public_workspace_id(ws_idx);
- workspace_snapshot.get_or_insert_with(|| self.workspace_info(ws_idx));
- if worktree.is_none() {
- worktree = self.state.workspaces[ws_idx]
- .worktree_space()
- .cloned()
- .map(|space| self.worktree_info_for_membership(&space, None));
- }
- let still_same_linked_worktree = self.state.workspaces[ws_idx]
- .worktree_space()
- .is_some_and(|space| {
- space.is_linked_worktree && space.checkout_path == result.path
- });
- if still_same_linked_worktree {
- self.close_removed_linked_worktree_workspace(ws_idx);
- self.shutdown_detached_terminal_runtimes();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::WorkspaceClosed,
- data: crate::api::schema::EventData::WorkspaceClosed {
- workspace_id: workspace_id.clone(),
- workspace: workspace_snapshot.clone(),
- },
- });
- }
- } else if let Some(snapshot) = workspace_snapshot.as_ref() {
- workspace_id = snapshot.workspace_id.clone();
- }
- if let Some(worktree) = worktree {
- self.emit_worktree_removed_event(
- workspace_id,
- workspace_snapshot,
- worktree,
- forced,
- );
- }
- self.state.mode = if self.state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- self.render_dirty.request_generic();
- self.render_notify.notify_one();
- }
- Err(message) => {
- tracing::warn!(workspace_id = %result.workspace_id, path = %result.path.display(), error = %message, "git worktree remove failed");
- remove.removing = false;
- if !remove.force_confirmation
- && crate::worktree::is_dirty_worktree_remove_error(&message)
- {
- remove.force_confirmation = true;
- remove.error = None;
- } else {
- remove.error = Some(message);
- }
- self.render_dirty.request_generic();
- self.render_notify.notify_one();
- }
- }
- }
-
pub(crate) fn should_shutdown_workspace_terminal_runtimes_for_worktree_remove(
force: bool,
) -> bool {
@@ -1017,1384 +51,9 @@ impl App {
}
}
-fn immediate_api_error_message(response: Option<&str>) -> Option<String> {
- response
- .and_then(|response| {
- serde_json::from_str::<crate::api::schema::ErrorResponse>(response).ok()
- })
- .map(|response| response.error.message)
-}
-
#[cfg(test)]
mod tests {
- use super::*;
-
- fn unique_temp_path(name: &str) -> std::path::PathBuf {
- let nanos = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map(|d| d.as_nanos())
- .unwrap_or(0);
- std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
- }
-
- fn run_git(repo: &std::path::Path, args: &[&str]) {
- let status = std::process::Command::new("git")
- .arg("-C")
- .arg(repo)
- .args(args)
- .status()
- .unwrap();
- assert!(
- status.success(),
- "git command failed: git -C {} {}",
- repo.display(),
- args.join(" ")
- );
- }
-
- fn create_committed_repo(name: &str) -> std::path::PathBuf {
- let repo = unique_temp_path(name);
- std::fs::create_dir_all(&repo).unwrap();
- run_git(&repo, &["init", "--quiet"]);
- run_git(&repo, &["config", "user.email", "herdr@example.invalid"]);
- run_git(&repo, &["config", "user.name", "Herdr Test"]);
- std::fs::write(repo.join("README.md"), "test\n").unwrap();
- run_git(&repo, &["add", "README.md"]);
- run_git(&repo, &["commit", "--quiet", "-m", "initial"]);
- repo
- }
-
- fn wait_for_worktree_event(app: &mut App) -> AppEvent {
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
- while std::time::Instant::now() < deadline {
- if let Ok(event) = app.event_rx.try_recv() {
- return event;
- }
- std::thread::sleep(std::time::Duration::from_millis(10));
- }
- panic!("timed out waiting for worktree event");
- }
-
- fn app_for_worktree_tests() -> App {
- app_for_worktree_tests_with_event_hub(crate::api::EventHub::default())
- }
-
- fn app_for_worktree_tests_with_event_hub(event_hub: crate::api::EventHub) -> App {
- App::new(
- &crate::config::Config::default(),
- true,
- None,
- tokio::sync::mpsc::unbounded_channel().1,
- event_hub,
- )
- }
-
- fn event_kinds(event_hub: &crate::api::EventHub) -> Vec<crate::api::schema::EventKind> {
- event_hub
- .events_after(0)
- .into_iter()
- .map(|(_, event)| event.event)
- .collect()
- }
-
- fn shutdown_test_runtimes(app: &mut App) {
- for (_, runtime) in app.terminal_runtimes.drain() {
- runtime.shutdown();
- }
- }
-
- #[tokio::test]
- async fn ui_create_workspace_emits_initial_workspace_tab_and_pane_events() {
- let event_hub = crate::api::EventHub::default();
- let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone());
-
- app.create_workspace();
-
- assert_eq!(
- event_kinds(&event_hub),
- vec![
- crate::api::schema::EventKind::WorkspaceCreated,
- crate::api::schema::EventKind::TabCreated,
- crate::api::schema::EventKind::PaneCreated,
- crate::api::schema::EventKind::LayoutUpdated,
- ]
- );
- shutdown_test_runtimes(&mut app);
- }
-
- #[tokio::test]
- async fn ui_create_tab_emits_tab_and_pane_events() {
- let event_hub = crate::api::EventHub::default();
- let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone());
- app.create_workspace_with_options(std::env::temp_dir(), true)
- .unwrap();
-
- app.create_tab();
-
- assert_eq!(
- event_kinds(&event_hub),
- vec![
- crate::api::schema::EventKind::TabCreated,
- crate::api::schema::EventKind::PaneCreated,
- crate::api::schema::EventKind::LayoutUpdated,
- ]
- );
- shutdown_test_runtimes(&mut app);
- }
-
- #[test]
- fn worktree_create_replaces_prefilled_branch_on_paste_and_syncs_state() {
- let mut app = app_for_worktree_tests();
- app.state.name_input = "generated-branch".into();
- app.state.name_input_replace_on_type = true;
- app.state.worktree_create = Some(WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: "/repo/herdr".into(),
- source_existing_membership: None,
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: "generated-branch".into(),
- checkout_path: "/repo/herdr-generated-branch".into(),
- error: None,
- creating: false,
- });
-
- app.insert_worktree_create_text("feature/linear-302");
-
- assert_eq!(app.state.name_input, "feature/linear-302");
- assert!(!app.state.name_input_replace_on_type);
- assert_eq!(
- app.state
- .worktree_create
- .as_ref()
- .map(|create| create.branch.as_str()),
- Some("feature/linear-302")
- );
- }
-
- #[test]
- fn worktree_open_search_accepts_pasted_text_when_focused() {
- let mut app = app_for_worktree_tests();
- app.state.worktree_open = Some(WorktreeOpenState {
- source_workspace_id: "source".into(),
- source_existing_membership: None,
- source_checkout_path: "/repo/herdr".into(),
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- entries: vec![
- WorktreeOpenEntry {
- path: "/repo/herdr-main".into(),
- branch: Some("main".into()),
- is_linked_worktree: false,
- already_open_ws_idx: None,
- },
- WorktreeOpenEntry {
- path: "/repo/feature-linear-302".into(),
- branch: Some("feature/linear-302".into()),
- is_linked_worktree: true,
- already_open_ws_idx: None,
- },
- ],
- selected: 0,
- query: String::new(),
- search_focused: true,
- error: None,
- });
-
- app.insert_worktree_open_search_text("linear-302");
-
- let open = app.state.worktree_open.as_ref().unwrap();
- assert_eq!(open.query, "linear-302");
- assert_eq!(open.selected_entry_index(), Some(1));
- }
-
- #[test]
- fn worktree_open_search_ignores_paste_when_search_is_not_focused() {
- let mut app = app_for_worktree_tests();
- app.state.worktree_open = Some(WorktreeOpenState {
- source_workspace_id: "source".into(),
- source_existing_membership: None,
- source_checkout_path: "/repo/herdr".into(),
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- entries: Vec::new(),
- selected: 0,
- query: String::new(),
- search_focused: false,
- error: None,
- });
-
- app.insert_worktree_open_search_text("linear-302");
-
- assert_eq!(
- app.state
- .worktree_open
- .as_ref()
- .map(|open| open.query.as_str()),
- Some("")
- );
- }
-
- #[test]
- fn open_selected_existing_worktree_focuses_already_open_workspace() {
- let mut app = app_for_worktree_tests();
- app.state.workspaces = vec![
- crate::workspace::Workspace::test_new("main"),
- crate::workspace::Workspace::test_new("issue"),
- ];
- app.state.workspaces[1].identity_cwd = "/repo/herdr-issue".into();
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.worktree_open = Some(WorktreeOpenState {
- source_workspace_id: app.state.workspaces[0].id.clone(),
- source_existing_membership: None,
- source_checkout_path: "/repo/herdr".into(),
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- entries: vec![WorktreeOpenEntry {
- path: "/repo/herdr-issue".into(),
- branch: Some("worktree/issue".into()),
- is_linked_worktree: true,
- already_open_ws_idx: Some(1),
- }],
- selected: 0,
- query: String::new(),
- search_focused: false,
- error: None,
- });
-
- app.open_selected_existing_worktree();
-
- assert_eq!(app.state.active, Some(1));
- assert_eq!(app.state.selected, 1);
- assert!(app.state.worktree_open.is_none());
- assert!(app.state.workspaces[0].worktree_space().is_some());
- let target_membership = app.state.workspaces[1].worktree_space().unwrap();
- assert_eq!(target_membership.key, "repo-key");
- assert_eq!(
- target_membership.checkout_path,
- std::path::PathBuf::from("/repo/herdr-issue")
- );
- assert!(target_membership.is_linked_worktree);
- }
-
- #[tokio::test]
- async fn ui_worktree_open_new_workspace_emits_api_parity_events() {
- let checkout = unique_temp_path("app-worktree-open-event-checkout");
- std::fs::create_dir_all(&checkout).unwrap();
- let event_hub = crate::api::EventHub::default();
- let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone());
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")];
- let source_workspace_id = app.state.workspaces[0].id.clone();
- let source_membership = crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr".into(),
- is_linked_worktree: false,
- };
- app.state.workspaces[0].worktree_space = Some(source_membership.clone());
- app.state.worktree_open = Some(WorktreeOpenState {
- source_workspace_id,
- source_existing_membership: Some(source_membership),
- source_checkout_path: "/repo/herdr".into(),
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- entries: vec![WorktreeOpenEntry {
- path: checkout.clone(),
- branch: Some("worktree/open-event".into()),
- is_linked_worktree: true,
- already_open_ws_idx: None,
- }],
- selected: 0,
- query: String::new(),
- search_focused: false,
- error: None,
- });
-
- app.open_selected_existing_worktree();
-
- assert_eq!(
- event_kinds(&event_hub),
- vec![
- crate::api::schema::EventKind::WorkspaceCreated,
- crate::api::schema::EventKind::TabCreated,
- crate::api::schema::EventKind::PaneCreated,
- crate::api::schema::EventKind::LayoutUpdated,
- crate::api::schema::EventKind::WorktreeOpened,
- ]
- );
- shutdown_test_runtimes(&mut app);
- let _ = std::fs::remove_dir_all(checkout);
- }
-
- #[tokio::test]
- async fn open_selected_existing_worktree_recomputes_stale_already_open_state() {
- let checkout = unique_temp_path("app-worktree-stale-open-checkout");
- std::fs::create_dir_all(&checkout).unwrap();
- let event_hub = crate::api::EventHub::default();
- let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone());
- app.state.workspaces = vec![
- crate::workspace::Workspace::test_new("source"),
- crate::workspace::Workspace::test_new("other"),
- ];
- let source_workspace_id = app.state.workspaces[0].id.clone();
- app.state.worktree_open = Some(WorktreeOpenState {
- source_workspace_id,
- source_existing_membership: None,
- source_checkout_path: "/repo/herdr".into(),
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- entries: vec![WorktreeOpenEntry {
- path: checkout.clone(),
- branch: Some("worktree/stale-open".into()),
- is_linked_worktree: true,
- already_open_ws_idx: Some(1),
- }],
- selected: 0,
- query: String::new(),
- search_focused: false,
- error: None,
- });
-
- app.open_selected_existing_worktree();
-
- assert_eq!(app.state.workspaces.len(), 3);
- assert_eq!(
- event_kinds(&event_hub),
- vec![
- crate::api::schema::EventKind::WorkspaceUpdated,
- crate::api::schema::EventKind::WorkspaceCreated,
- crate::api::schema::EventKind::TabCreated,
- crate::api::schema::EventKind::PaneCreated,
- crate::api::schema::EventKind::LayoutUpdated,
- crate::api::schema::EventKind::WorktreeOpened,
- ]
- );
- let opened = event_hub
- .events_after(0)
- .into_iter()
- .find_map(|(_, event)| match event.data {
- crate::api::schema::EventData::WorktreeOpened { already_open, .. } => {
- Some(already_open)
- }
- _ => None,
- })
- .expect("worktree.opened should be emitted");
- assert!(!opened);
- shutdown_test_runtimes(&mut app);
- let _ = std::fs::remove_dir_all(checkout);
- }
-
- #[test]
- fn worktree_open_search_filters_entries() {
- let mut app = app_for_worktree_tests();
- app.state.worktree_open = Some(WorktreeOpenState {
- source_workspace_id: "source".into(),
- source_existing_membership: None,
- source_checkout_path: "/repo/herdr".into(),
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- entries: vec![
- WorktreeOpenEntry {
- path: "/repo/herdr".into(),
- branch: Some("main".into()),
- is_linked_worktree: false,
- already_open_ws_idx: Some(0),
- },
- WorktreeOpenEntry {
- path: "/repo/fd-cleanup".into(),
- branch: Some("fd-cleanup".into()),
- is_linked_worktree: true,
- already_open_ws_idx: None,
- },
- WorktreeOpenEntry {
- path: "/repo/bell-forward-macos-bounce".into(),
- branch: Some("bell-forward-macos-bounce".into()),
- is_linked_worktree: true,
- already_open_ws_idx: None,
- },
- ],
- selected: 0,
- query: String::new(),
- search_focused: false,
- error: None,
- });
-
- app.handle_worktree_open_key(crossterm::event::KeyEvent::new(
- crossterm::event::KeyCode::Char('/'),
- crossterm::event::KeyModifiers::empty(),
- ));
- app.handle_worktree_open_key(crossterm::event::KeyEvent::new(
- crossterm::event::KeyCode::Char('f'),
- crossterm::event::KeyModifiers::empty(),
- ));
- app.handle_worktree_open_key(crossterm::event::KeyEvent::new(
- crossterm::event::KeyCode::Char('d'),
- crossterm::event::KeyModifiers::empty(),
- ));
- app.handle_worktree_open_key(crossterm::event::KeyEvent::new(
- crossterm::event::KeyCode::Char('-'),
- crossterm::event::KeyModifiers::empty(),
- ));
- app.handle_worktree_open_key(crossterm::event::KeyEvent::new(
- crossterm::event::KeyCode::Char('c'),
- crossterm::event::KeyModifiers::empty(),
- ));
- app.handle_worktree_open_key(crossterm::event::KeyEvent::new(
- crossterm::event::KeyCode::Char('l'),
- crossterm::event::KeyModifiers::empty(),
- ));
-
- let open = app.state.worktree_open.as_ref().unwrap();
- assert_eq!(open.query, "fd-cl");
- assert_eq!(open.filtered_indices(), vec![1]);
- assert_eq!(open.selected_entry_index(), Some(1));
- }
-
- #[test]
- fn open_existing_worktree_detects_already_open_checkout_from_subdirectory() {
- let repo = create_committed_repo("app-worktree-open-existing-repo");
- let checkout = unique_temp_path("app-worktree-open-existing-checkout");
- run_git(
- &repo,
- &[
- "worktree",
- "add",
- "--quiet",
- "-b",
- "worktree/open-existing",
- checkout.to_str().unwrap(),
- "HEAD",
- ],
- );
- let subdir = checkout.join("nested");
- std::fs::create_dir_all(&subdir).unwrap();
-
- let mut app = app_for_worktree_tests();
- app.state.workspaces = vec![
- crate::workspace::Workspace::test_new("main"),
- crate::workspace::Workspace::test_new("nested"),
- ];
- app.state.workspaces[0].identity_cwd = repo;
- app.state.workspaces[1].identity_cwd = subdir;
-
- app.open_existing_worktree_dialog(0);
-
- let open = app.state.worktree_open.as_ref().unwrap();
- let checkout = crate::worktree::canonical_or_original(&checkout);
- let entry = open
- .entries
- .iter()
- .find(|entry| crate::worktree::canonical_or_original(&entry.path) == checkout)
- .unwrap_or_else(|| panic!("missing checkout in entries: {:?}", open.entries));
- assert_eq!(entry.already_open_ws_idx, Some(1));
- }
-
- #[test]
- fn worktree_create_and_open_dialogs_reject_linked_child_source() {
- let mut app = app_for_worktree_tests();
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
- app.state.mode = Mode::Navigate;
- app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr-issue".into(),
- is_linked_worktree: true,
- });
-
- app.open_new_linked_worktree_dialog(0);
-
- assert_eq!(app.state.mode, Mode::Navigate);
- assert!(app.state.worktree_create.is_none());
- assert_eq!(
- app.state.config_diagnostic.as_deref(),
- Some("New and open worktree actions start from the repo parent workspace.")
- );
-
- app.state.config_diagnostic = None;
- app.open_existing_worktree_dialog(0);
-
- assert!(app.state.worktree_open.is_none());
- assert_eq!(
- app.state.config_diagnostic.as_deref(),
- Some("New and open worktree actions start from the repo parent workspace.")
- );
- }
-
- #[test]
- fn sync_worktree_branch_updates_derived_path() {
- let mut app = app_for_worktree_tests();
- app.state.worktree_directory = std::path::PathBuf::from("/w");
- app.state.name_input = "issue/137".into();
- app.state.worktree_create = Some(WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: std::path::PathBuf::from("/repo/herdr"),
- source_existing_membership: None,
- source_repo_root: std::path::PathBuf::from("/repo/herdr"),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: "old".into(),
- checkout_path: std::path::PathBuf::from("/old"),
- error: Some("old error".into()),
- creating: false,
- });
-
- app.sync_worktree_branch_from_input();
-
- let create = app.state.worktree_create.unwrap();
- assert_eq!(create.branch, "issue/137");
- assert_eq!(
- create.checkout_path,
- std::path::PathBuf::from("/w/herdr/issue-137")
- );
- assert_eq!(create.error, None);
- }
-
- #[test]
- fn worktree_create_enter_submits_through_api_path() {
- let mut app = app_for_worktree_tests();
- app.state.worktree_directory = std::path::PathBuf::from("/w");
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")];
- let source_workspace_id = app.state.workspaces[0].id.clone();
- let source_membership = crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr".into(),
- is_linked_worktree: false,
- };
- let branch = "issue/195";
- let checkout_path =
- crate::worktree::default_checkout_path(&app.state.worktree_directory, "herdr", branch);
- let checkout_key = crate::worktree::canonical_or_original(&checkout_path);
- app.pending_api_worktree_creates.insert(checkout_key, 1);
- app.state.workspaces[0].worktree_space = Some(source_membership.clone());
- app.state.mode = Mode::NewLinkedWorktree;
- app.state.name_input = branch.into();
- app.state.worktree_create = Some(WorktreeCreateState {
- source_workspace_id,
- source_checkout_path: "/repo/herdr".into(),
- source_existing_membership: Some(source_membership),
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: branch.into(),
- checkout_path,
- error: None,
- creating: false,
- });
-
- app.handle_worktree_create_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
-
- let create = app.state.worktree_create.as_ref().unwrap();
- assert!(!create.creating);
- assert_eq!(
- create.error.as_deref(),
- Some("worktree operation is already in progress for this checkout")
- );
- }
-
- #[tokio::test]
- async fn worktree_open_enter_submits_through_api_path() {
- let repo = create_committed_repo("app-worktree-open-enter-repo");
- let checkout = unique_temp_path("app-worktree-open-enter-checkout");
- run_git(
- &repo,
- &[
- "worktree",
- "add",
- "--quiet",
- "-b",
- "worktree/open-enter",
- checkout.to_str().unwrap(),
- "HEAD",
- ],
- );
-
- let event_hub = crate::api::EventHub::default();
- let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone());
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")];
- let source_workspace_id = app.state.workspaces[0].id.clone();
- let source_membership = crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: repo.clone(),
- checkout_path: repo.clone(),
- is_linked_worktree: false,
- };
- app.state.workspaces[0].worktree_space = Some(source_membership.clone());
- app.state.mode = Mode::OpenExistingWorktree;
- app.state.worktree_open = Some(WorktreeOpenState {
- source_workspace_id,
- source_existing_membership: Some(source_membership),
- source_checkout_path: repo.clone(),
- source_repo_root: repo.clone(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- entries: vec![WorktreeOpenEntry {
- path: checkout.clone(),
- branch: Some("worktree/open-enter".into()),
- is_linked_worktree: true,
- already_open_ws_idx: None,
- }],
- selected: 0,
- query: String::new(),
- search_focused: false,
- error: None,
- });
-
- app.handle_worktree_open_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
-
- assert!(app.state.worktree_open.is_none());
- assert_eq!(
- event_kinds(&event_hub),
- vec![
- crate::api::schema::EventKind::WorkspaceCreated,
- crate::api::schema::EventKind::TabCreated,
- crate::api::schema::EventKind::PaneCreated,
- crate::api::schema::EventKind::LayoutUpdated,
- crate::api::schema::EventKind::WorktreeOpened,
- ]
- );
- shutdown_test_runtimes(&mut app);
- run_git(
- &repo,
- &["worktree", "remove", "--force", checkout.to_str().unwrap()],
- );
- let _ = std::fs::remove_dir_all(repo);
- }
-
- #[test]
- fn worktree_remove_enter_submits_through_api_path() {
- let mut app = app_for_worktree_tests();
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
- let workspace_id = app.state.workspaces[0].id.clone();
- app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr-issue".into(),
- is_linked_worktree: true,
- });
- app.open_remove_linked_worktree_confirmation(0);
- app.pending_api_worktree_removes
- .insert(workspace_id.clone(), 1);
-
- app.handle_worktree_remove_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
-
- let remove = app.state.worktree_remove.as_ref().unwrap();
- assert_eq!(remove.workspace_id, workspace_id);
- assert!(!remove.removing);
- assert_eq!(
- remove.error.as_deref(),
- Some("worktree operation is already in progress for this checkout")
- );
- }
-
- #[tokio::test]
- async fn ui_worktree_create_emits_api_parity_events_after_membership_is_committed() {
- let repo = create_committed_repo("app-worktree-create-event-repo");
- let worktree_root = unique_temp_path("app-worktree-create-event-root");
- let branch = "worktree/ui-create-event";
- let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch);
- run_git(
- &repo,
- &[
- "worktree",
- "add",
- "--quiet",
- "-b",
- branch,
- checkout.to_str().unwrap(),
- "HEAD",
- ],
- );
-
- let event_hub = crate::api::EventHub::default();
- let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone());
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")];
- let source_workspace_id = app.state.workspaces[0].id.clone();
- let source_membership = crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: repo.clone(),
- checkout_path: repo.clone(),
- is_linked_worktree: false,
- };
- app.state.workspaces[0].worktree_space = Some(source_membership.clone());
- app.state.worktree_create = Some(WorktreeCreateState {
- source_workspace_id,
- source_checkout_path: repo.clone(),
- source_existing_membership: Some(source_membership),
- source_repo_root: repo.clone(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: branch.into(),
- checkout_path: checkout.clone(),
- error: None,
- creating: true,
- });
- let plugin_root = unique_temp_path("app-worktree-create-plugin");
- std::fs::create_dir_all(&plugin_root).unwrap();
- let manifest_path = plugin_root.join("herdr-plugin.toml");
- std::fs::write(&manifest_path, "id = 'example.ui-worktree-create'\n").unwrap();
- app.state.installed_plugins.insert(
- "example.ui-worktree-create".into(),
- crate::api::schema::InstalledPluginInfo {
- plugin_id: "example.ui-worktree-create".into(),
- name: "UI Worktree Create".into(),
- version: "0.1.0".into(),
- min_herdr_version: "0.7.0".into(),
- description: None,
- manifest_path: manifest_path.display().to_string(),
- plugin_root: plugin_root.display().to_string(),
- enabled: true,
- platforms: None,
- build: Vec::new(),
- startup: Vec::new(),
- actions: Vec::new(),
- events: vec![crate::api::schema::PluginManifestEventHook {
- on: "worktree.created".into(),
- platforms: None,
- command: vec!["sh".into(), "-c".into(), "true".into()],
- }],
- panes: Vec::new(),
- link_handlers: Vec::new(),
- source: crate::api::schema::PluginSourceInfo::default(),
- warnings: Vec::new(),
- },
- );
-
- app.handle_worktree_add_finished(WorktreeAddResult {
- path: checkout.clone(),
- api_request: None,
- result: Ok(()),
- });
-
- assert_eq!(
- event_kinds(&event_hub),
- vec![
- crate::api::schema::EventKind::WorkspaceCreated,
- crate::api::schema::EventKind::TabCreated,
- crate::api::schema::EventKind::PaneCreated,
- crate::api::schema::EventKind::LayoutUpdated,
- crate::api::schema::EventKind::WorktreeCreated,
- ]
- );
- let events = event_hub.events_after(0);
- let workspace_created = events
- .iter()
- .find(|(_, event)| event.event == crate::api::schema::EventKind::WorkspaceCreated)
- .map(|(_, event)| event)
- .expect("workspace.created should be emitted");
- let crate::api::schema::EventData::WorkspaceCreated { workspace } = &workspace_created.data
- else {
- panic!("unexpected event data");
- };
- let checkout_path = checkout.display().to_string();
- assert_eq!(
- workspace
- .worktree
- .as_ref()
- .map(|worktree| worktree.checkout_path.as_str()),
- Some(checkout_path.as_str())
- );
- let worktree_created = events
- .iter()
- .find(|(_, event)| event.event == crate::api::schema::EventKind::WorktreeCreated)
- .map(|(_, event)| event)
- .expect("worktree.created should be emitted");
- let crate::api::schema::EventData::WorktreeCreated {
- workspace,
- worktree,
- } = &worktree_created.data
- else {
- panic!("unexpected event data");
- };
- assert_eq!(
- workspace
- .worktree
- .as_ref()
- .map(|worktree| worktree.checkout_path.as_str()),
- Some(checkout_path.as_str())
- );
- assert_eq!(
- worktree.open_workspace_id.as_deref(),
- Some(workspace.workspace_id.as_str())
- );
- assert!(app.state.plugin_command_logs.iter().any(|log| {
- log.event.as_deref() == Some("worktree.created")
- && log.status == crate::api::schema::PluginCommandStatus::Running
- }));
-
- shutdown_test_runtimes(&mut app);
- let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false);
- crate::worktree::run_worktree_command(&remove).unwrap();
- let _ = std::fs::remove_dir_all(worktree_root);
- let _ = std::fs::remove_dir_all(repo);
- let _ = std::fs::remove_dir_all(plugin_root);
- }
-
- #[test]
- fn worktree_create_finished_reuses_checkout_opened_before_result() {
- let checkout = unique_temp_path("app-worktree-create-race-checkout");
- std::fs::create_dir_all(&checkout).unwrap();
- let event_hub = crate::api::EventHub::default();
- let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone());
- app.state.workspaces = vec![
- crate::workspace::Workspace::test_new("source"),
- crate::workspace::Workspace::test_new("opened-by-race"),
- ];
- let source_workspace_id = app.state.workspaces[0].id.clone();
- app.state.workspaces[1].identity_cwd = checkout.clone();
- app.state.worktree_create = Some(WorktreeCreateState {
- source_workspace_id,
- source_checkout_path: "/repo/herdr".into(),
- source_existing_membership: None,
- source_repo_root: "/repo/herdr".into(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: "worktree/create-race".into(),
- checkout_path: checkout.clone(),
- error: None,
- creating: true,
- });
-
- app.handle_worktree_add_finished(WorktreeAddResult {
- path: checkout.clone(),
- api_request: None,
- result: Ok(()),
- });
-
- assert_eq!(app.state.workspaces.len(), 2);
- let kinds = event_kinds(&event_hub);
- assert!(!kinds.contains(&crate::api::schema::EventKind::WorkspaceCreated));
- assert_eq!(
- kinds
- .iter()
- .filter(|kind| **kind == crate::api::schema::EventKind::WorktreeCreated)
- .count(),
- 1
- );
- assert_eq!(
- app.state.workspaces[1]
- .worktree_space()
- .map(|membership| membership.checkout_path.as_path()),
- Some(checkout.as_path())
- );
- shutdown_test_runtimes(&mut app);
- let _ = std::fs::remove_dir_all(checkout);
- }
-
- #[test]
- fn start_worktree_add_runs_git_on_worker_and_emits_result() {
- let repo = create_committed_repo("app-worktree-add-repo");
- let worktree_root = unique_temp_path("app-worktree-add-root");
- let branch = "worktree/app-worker";
- let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch);
- let mut app = app_for_worktree_tests();
- app.state.worktree_directory = worktree_root.clone();
- app.state.name_input = branch.into();
- app.state.worktree_create = Some(WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: repo.clone(),
- source_existing_membership: None,
- source_repo_root: repo.clone(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: branch.into(),
- checkout_path: checkout.clone(),
- error: None,
- creating: false,
- });
-
- app.start_worktree_add();
-
- assert!(app
- .state
- .worktree_create
- .as_ref()
- .is_some_and(|create| create.creating));
- let event = wait_for_worktree_event(&mut app);
- match event {
- AppEvent::WorktreeAddFinished(result) => {
- let result = *result;
- assert_eq!(result.path, checkout);
- assert_eq!(result.result, Ok(()));
- }
- other => panic!("unexpected event: {other:?}"),
- }
- assert!(checkout.join("README.md").exists());
-
- let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false);
- crate::worktree::run_worktree_command(&remove).unwrap();
- let _ = std::fs::remove_dir_all(worktree_root);
- let _ = std::fs::remove_dir_all(repo);
- }
-
- #[test]
- fn start_worktree_add_existing_branch_checks_out_branch() {
- let repo = create_committed_repo("app-worktree-add-existing-branch-repo");
- let worktree_root = unique_temp_path("app-worktree-add-existing-branch-root");
- let branch = "foo";
- let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch);
- run_git(&repo, &["branch", branch]);
- let mut app = app_for_worktree_tests();
- app.state.worktree_directory = worktree_root.clone();
- app.state.name_input = branch.into();
- app.state.worktree_create = Some(WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: repo.clone(),
- source_existing_membership: None,
- source_repo_root: repo.clone(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: branch.into(),
- checkout_path: checkout.clone(),
- error: None,
- creating: false,
- });
-
- app.start_worktree_add();
-
- assert!(app
- .state
- .worktree_create
- .as_ref()
- .is_some_and(|create| create.creating));
- let event = wait_for_worktree_event(&mut app);
- match event {
- AppEvent::WorktreeAddFinished(result) => {
- let result = *result;
- assert_eq!(result.path, checkout);
- assert_eq!(result.result, Ok(()));
- }
- other => panic!("unexpected event: {other:?}"),
- }
-
- assert!(checkout.join("README.md").exists());
- let branch_name = std::process::Command::new("git")
- .arg("-C")
- .arg(&checkout)
- .args(["branch", "--show-current"])
- .output()
- .unwrap();
- assert!(branch_name.status.success());
- assert_eq!(
- String::from_utf8(branch_name.stdout).unwrap().trim(),
- branch
- );
-
- let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false);
- crate::worktree::run_worktree_command(&remove).unwrap();
- let _ = std::fs::remove_dir_all(worktree_root);
- let _ = std::fs::remove_dir_all(repo);
- }
-
- #[test]
- fn open_new_worktree_dialog_supports_standalone_bare_repo_source() {
- let repo = create_committed_repo("app-worktree-dialog-bare-origin");
- let bare = unique_temp_path("app-worktree-dialog-bare-repo");
- run_git(
- &repo,
- &["clone", "--quiet", "--bare", ".", bare.to_str().unwrap()],
- );
- let worktree_root = unique_temp_path("app-worktree-dialog-bare-root");
-
- let mut app = app_for_worktree_tests();
- app.state.worktree_directory = worktree_root.clone();
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")];
- app.state.workspaces[0].identity_cwd = bare.clone();
-
- app.open_new_linked_worktree_dialog(0);
-
- assert_eq!(app.state.mode, Mode::NewLinkedWorktree);
- assert!(app.state.config_diagnostic.is_none());
- let create = app.state.worktree_create.as_ref().unwrap();
- assert_eq!(create.source_checkout_path, bare);
- assert_eq!(create.source_repo_root, create.source_checkout_path);
- let source_checkout_path = create.source_checkout_path.clone();
-
- let branch = "worktree/from-bare-source";
- let repo_name = create.repo_name.clone();
- let checkout = crate::worktree::default_checkout_path(&worktree_root, &repo_name, branch);
- app.state.name_input = branch.into();
-
- app.start_worktree_add();
-
- let event = wait_for_worktree_event(&mut app);
- match event {
- AppEvent::WorktreeAddFinished(result) => {
- let result = *result;
- assert_eq!(result.path, checkout);
- assert_eq!(result.result, Ok(()));
- }
- other => panic!("unexpected event: {other:?}"),
- }
- assert!(checkout.join("README.md").exists());
-
- let remove_new = crate::worktree::build_worktree_remove_command(
- &source_checkout_path,
- &checkout,
- false,
- false,
- );
- crate::worktree::run_worktree_command(&remove_new).unwrap();
- let _ = std::fs::remove_dir_all(worktree_root);
- let _ = std::fs::remove_dir_all(source_checkout_path);
- let _ = std::fs::remove_dir_all(repo);
- }
-
- #[test]
- fn start_worktree_add_uses_source_checkout_head_as_base() {
- let repo = create_committed_repo("app-worktree-add-source-repo");
- let source_checkout = unique_temp_path("app-worktree-add-source-checkout");
- run_git(
- &repo,
- &[
- "worktree",
- "add",
- "--quiet",
- "-b",
- "worktree/source-base",
- source_checkout.to_str().unwrap(),
- "HEAD",
- ],
- );
- std::fs::write(source_checkout.join("SOURCE.md"), "source branch\n").unwrap();
- run_git(&source_checkout, &["add", "SOURCE.md"]);
- run_git(&source_checkout, &["commit", "--quiet", "-m", "source"]);
-
- let worktree_root = unique_temp_path("app-worktree-add-from-source-root");
- let branch = "worktree/from-source";
- let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch);
- let mut app = app_for_worktree_tests();
- app.state.worktree_directory = worktree_root.clone();
- app.state.name_input = branch.into();
- app.state.worktree_create = Some(WorktreeCreateState {
- source_workspace_id: "source".into(),
- source_checkout_path: source_checkout.clone(),
- source_existing_membership: None,
- source_repo_root: repo.clone(),
- repo_key: "repo-key".into(),
- repo_name: "herdr".into(),
- branch: branch.into(),
- checkout_path: checkout.clone(),
- error: None,
- creating: false,
- });
-
- app.start_worktree_add();
-
- let event = wait_for_worktree_event(&mut app);
- match event {
- AppEvent::WorktreeAddFinished(result) => {
- let result = *result;
- assert_eq!(result.path, checkout);
- assert_eq!(result.result, Ok(()));
- }
- other => panic!("unexpected event: {other:?}"),
- }
- assert!(checkout.join("SOURCE.md").exists());
-
- let remove_new =
- crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false);
- crate::worktree::run_worktree_command(&remove_new).unwrap();
- let remove_source =
- crate::worktree::build_worktree_remove_command(&repo, &source_checkout, false, false);
- crate::worktree::run_worktree_command(&remove_source).unwrap();
- let _ = std::fs::remove_dir_all(worktree_root);
- let _ = std::fs::remove_dir_all(repo);
- }
-
- #[test]
- fn dirty_worktree_remove_failure_requests_force_confirmation() {
- let path = std::path::PathBuf::from("/w/herdr/dirty");
- let mut app = app_for_worktree_tests();
- app.state.worktree_remove = Some(WorktreeRemoveState {
- workspace_id: "ws".into(),
- repo_root: std::path::PathBuf::from("/repo/herdr"),
- path: path.clone(),
- error: None,
- removing: true,
- force_confirmation: false,
- });
-
- app.handle_worktree_remove_finished(WorktreeRemoveResult {
- workspace_id: "ws".into(),
- path,
- workspace: None,
- worktree: None,
- forced: false,
- api_request: None,
- result: Err(
- "fatal: '/w/herdr/dirty' contains modified or untracked files, use --force to delete it"
- .into(),
- ),
- });
-
- let remove = app.state.worktree_remove.unwrap();
- assert!(!remove.removing);
- assert!(remove.force_confirmation);
- assert_eq!(remove.error, None);
- }
-
- #[test]
- fn non_dirty_worktree_remove_failure_keeps_error_message() {
- let path = std::path::PathBuf::from("/w/herdr/missing");
- let mut app = app_for_worktree_tests();
- app.state.worktree_remove = Some(WorktreeRemoveState {
- workspace_id: "ws".into(),
- repo_root: std::path::PathBuf::from("/repo/herdr"),
- path: path.clone(),
- error: None,
- removing: true,
- force_confirmation: false,
- });
-
- app.handle_worktree_remove_finished(WorktreeRemoveResult {
- workspace_id: "ws".into(),
- path,
- workspace: None,
- worktree: None,
- forced: false,
- api_request: None,
- result: Err("fatal: '/w/herdr/missing' is not a working tree".into()),
- });
-
- let remove = app.state.worktree_remove.unwrap();
- assert!(!remove.removing);
- assert!(!remove.force_confirmation);
- assert_eq!(
- remove.error,
- Some("fatal: '/w/herdr/missing' is not a working tree".into())
- );
- }
-
- #[test]
- fn worktree_remove_finished_focuses_parent_workspace() {
- let mut app = app_for_worktree_tests();
- let checkout = std::path::PathBuf::from("/repo/herdr-issue");
- app.state.workspaces = vec![
- crate::workspace::Workspace::test_new("parent"),
- crate::workspace::Workspace::test_new("issue"),
- crate::workspace::Workspace::test_new("sibling"),
- ];
- app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr".into(),
- is_linked_worktree: false,
- });
- app.state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: checkout.clone(),
- is_linked_worktree: true,
- });
- app.state.workspaces[2].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr-sibling".into(),
- is_linked_worktree: true,
- });
- let child_id = app.state.workspaces[1].id.clone();
- let parent_id = app.state.workspaces[0].id.clone();
- app.state.active = Some(1);
- app.state.selected = 1;
- app.state.worktree_remove = Some(WorktreeRemoveState {
- workspace_id: child_id.clone(),
- repo_root: std::path::PathBuf::from("/repo/herdr"),
- path: checkout.clone(),
- error: None,
- removing: true,
- force_confirmation: false,
- });
-
- app.handle_worktree_remove_finished(WorktreeRemoveResult {
- workspace_id: child_id,
- path: checkout,
- workspace: None,
- worktree: None,
- forced: false,
- api_request: None,
- result: Ok(()),
- });
-
- assert_eq!(app.state.workspaces.len(), 2);
- assert_eq!(app.state.active, Some(0));
- assert_eq!(app.state.selected, 0);
- assert_eq!(app.state.workspaces[0].id, parent_id);
- assert_eq!(app.state.workspaces[1].display_name(), "sibling");
- assert!(app.state.worktree_remove.is_none());
- }
-
- #[test]
- fn worktree_remove_finished_emits_removed_event_from_snapshot_after_workspace_closed() {
- let event_hub = crate::api::EventHub::default();
- let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone());
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
- let internal_workspace_id = app.state.workspaces[0].id.clone();
- let checkout = std::path::PathBuf::from("/repo/herdr-issue");
- app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: checkout.clone(),
- is_linked_worktree: true,
- });
- let workspace_snapshot = app.workspace_info(0);
- let worktree_snapshot = crate::api::schema::WorktreeInfo {
- path: checkout.display().to_string(),
- branch: Some("worktree/issue".into()),
- is_bare: false,
- is_detached: false,
- is_prunable: false,
- is_linked_worktree: true,
- open_workspace_id: None,
- label: "herdr".into(),
- };
- app.state.worktree_remove = Some(WorktreeRemoveState {
- workspace_id: internal_workspace_id.clone(),
- repo_root: "/repo/herdr".into(),
- path: checkout.clone(),
- error: None,
- removing: true,
- force_confirmation: true,
- });
- app.state.workspaces.clear();
-
- app.handle_worktree_remove_finished(WorktreeRemoveResult {
- workspace_id: internal_workspace_id,
- path: checkout,
- workspace: Some(Box::new(workspace_snapshot.clone())),
- worktree: Some(Box::new(worktree_snapshot)),
- forced: true,
- api_request: None,
- result: Ok(()),
- });
-
- assert_eq!(
- event_kinds(&event_hub),
- vec![crate::api::schema::EventKind::WorktreeRemoved]
- );
- assert!(event_hub.events_after(0).iter().any(|(_, event)| {
- matches!(
- &event.data,
- crate::api::schema::EventData::WorktreeRemoved {
- workspace_id,
- workspace: Some(workspace),
- worktree,
- forced,
- } if workspace_id == &workspace_snapshot.workspace_id
- && workspace.workspace_id == workspace_snapshot.workspace_id
- && worktree.branch.as_deref() == Some("worktree/issue")
- && *forced
- )
- }));
- }
-
- #[test]
- fn dirty_worktree_remove_retries_with_force_and_closes_workspace() {
- let repo = create_committed_repo("app-worktree-dirty-remove-repo");
- let checkout = unique_temp_path("app-worktree-dirty-remove-checkout");
- run_git(
- &repo,
- &[
- "worktree",
- "add",
- "--quiet",
- "-b",
- "worktree/dirty-remove",
- checkout.to_str().unwrap(),
- "HEAD",
- ],
- );
- std::fs::write(checkout.join("README.md"), "dirty\n").unwrap();
-
- let event_hub = crate::api::EventHub::default();
- let mut app = app_for_worktree_tests_with_event_hub(event_hub.clone());
- app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")];
- let workspace_id = app.state.workspaces[0].id.clone();
- app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: repo.clone(),
- checkout_path: checkout.clone(),
- is_linked_worktree: true,
- });
- app.state.active = Some(0);
- app.state.selected = 0;
- app.open_remove_linked_worktree_confirmation(0);
-
- app.start_worktree_remove();
-
- #[cfg(not(windows))]
- {
- let safe_event = wait_for_worktree_event(&mut app);
- match safe_event {
- AppEvent::WorktreeRemoveFinished(result) => {
- let result = *result;
- assert_eq!(result.workspace_id, workspace_id);
- assert_eq!(result.path, checkout);
- assert!(result.result.is_err());
- app.handle_worktree_remove_finished(result);
- }
- other => panic!("unexpected event: {other:?}"),
- }
- }
-
- let remove = app.state.worktree_remove.as_ref().unwrap();
- assert!(!remove.removing);
- assert!(remove.force_confirmation);
- assert!(checkout.exists());
-
- app.start_worktree_remove();
- let force_event = wait_for_worktree_event(&mut app);
- match force_event {
- AppEvent::WorktreeRemoveFinished(result) => {
- let result = *result;
- assert_eq!(result.workspace_id, workspace_id);
- assert_eq!(result.path, checkout);
- assert_eq!(result.result, Ok(()));
- app.handle_worktree_remove_finished(result);
- }
- other => panic!("unexpected event: {other:?}"),
- }
-
- assert!(!checkout.exists());
- assert!(app.state.worktree_remove.is_none());
- assert!(app.state.workspaces.is_empty());
- assert_eq!(
- event_kinds(&event_hub),
- vec![
- crate::api::schema::EventKind::WorkspaceClosed,
- crate::api::schema::EventKind::WorktreeRemoved,
- ]
- );
- assert!(event_hub.events_after(0).iter().any(|(_, event)| {
- matches!(
- &event.data,
- crate::api::schema::EventData::WorktreeRemoved { worktree, .. }
- if worktree.branch.as_deref() == Some("worktree/dirty-remove")
- && !worktree.is_detached
- )
- }));
-
- let _ = std::fs::remove_dir_all(repo);
- }
+ use super::App;
#[test]
fn worktree_remove_runtime_shutdown_policy_preserves_windows_safe_remove() {