diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 1 | ||||
| -rw-r--r-- | src/app/api.rs | 22 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 728 | ||||
| -rw-r--r-- | src/app/api/worktrees/deferred.rs | 6 | ||||
| -rw-r--r-- | src/app/api/worktrees/reads.rs | 339 | ||||
| -rw-r--r-- | src/app/mod.rs | 2 |
6 files changed, 771 insertions, 327 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 953bcd94..e1bedd26 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1604,6 +1604,7 @@ impl AppState { } AppEvent::WorktreeAddFinished(_) => Vec::new(), AppEvent::WorktreeRemoveFinished(_) => Vec::new(), + AppEvent::WorktreeReadFinished(_) => Vec::new(), AppEvent::TabBarCommandFinished { .. } => Vec::new(), AppEvent::PluginCommandFinished { .. } => Vec::new(), } diff --git a/src/app/api.rs b/src/app/api.rs index 1b5bad71..c56a07a1 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -39,6 +39,14 @@ impl App { segment_index, result, } => self.handle_tab_bar_command_finished(generation, segment_index, result), + AppEvent::WorktreeReadFinished(result) => { + let changes_workspace = matches!( + &result.request.method, + crate::api::schema::Method::WorktreeOpen(_) + ); + self.handle_api_worktree_read_finished(*result); + changes_workspace + } ev @ AppEvent::TerminalBell { .. } => { self.handle_internal_event(ev); false @@ -157,6 +165,11 @@ impl App { return Vec::new(); } + if let AppEvent::WorktreeReadFinished(result) = ev { + self.handle_api_worktree_read_finished(*result); + return Vec::new(); + } + if let AppEvent::WorktreeAddFinished(result) = ev { self.handle_api_worktree_add_finished(*result); return Vec::new(); @@ -1035,7 +1048,13 @@ impl App { Method::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target); } - Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params), + Method::WorktreeList(_) | Method::WorktreeOpen(_) => { + return responses::encode_error( + request.id, + "invalid_request", + "worktree discovery is handled asynchronously by the app runtime", + ); + } Method::WorktreeCreate(params) => { let _ = params; return responses::encode_error( @@ -1044,7 +1063,6 @@ impl App { "worktree.create is handled asynchronously by the app runtime", ); } - Method::WorktreeOpen(params) => return self.handle_worktree_open(request.id, params), Method::WorktreeRemove(params) => { let _ = params; return responses::encode_error( diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index f578f101..b2fc0330 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -1,14 +1,15 @@ use std::path::{Path, PathBuf}; use crate::api::schema::{ - EventData, EventEnvelope, EventKind, ResponseResult, WorktreeInfo, WorktreeListParams, - WorktreeOpenParams, WorktreeSourceInfo, + EventData, EventEnvelope, EventKind, ResponseResult, WorktreeInfo, WorktreeOpenParams, + WorktreeSourceInfo, }; use crate::app::App; use super::responses::{encode_error, encode_success}; mod deferred; +mod reads; struct ApiFailure { code: &'static str, @@ -45,26 +46,12 @@ struct WorktreeSource { } impl App { - pub(super) fn handle_worktree_list( - &mut self, + fn finish_worktree_list( + &self, id: String, - params: WorktreeListParams, + source: WorktreeSource, + entries: Vec<crate::worktree::ExistingWorktree>, ) -> String { - let source = match self.resolve_worktree_list_source( - params.workspace_id, - params.cwd, - params.trust_repository, - ) { - Ok(source) => source, - Err(err) => return encode_error(id, err.code, err.message), - }; - let entries = match crate::worktree::list_existing_worktrees( - &source.source_repo_root, - params.trust_repository, - ) { - Ok(entries) => entries, - Err(err) => return encode_error(id, "worktree_list_failed", err), - }; let worktrees = entries .into_iter() .map(|entry| self.worktree_info_for_entry(&source, entry)) @@ -79,35 +66,29 @@ impl App { ) } - pub(super) fn handle_worktree_open( + fn finish_worktree_open( &mut self, id: String, params: WorktreeOpenParams, + mut source: WorktreeSource, + entry: crate::worktree::ExistingWorktree, ) -> String { - if params.path.is_some() == params.branch.is_some() { - return encode_error( - id, - "invalid_request", - "exactly one of path or branch is required", - ); - } - let mut source = match self.resolve_worktree_source(params.workspace_id, params.cwd) { - Ok(source) => source, - Err(err) => return encode_error(id, err.code, err.message), - }; - let entry = match self.find_worktree_entry( - &source, - params.path, - params.branch, - params.trust_repository, - ) { - Ok(entry) => entry, - Err(err) => return encode_error(id, err.code, err.message), - }; if entry.is_bare || entry.is_prunable { return encode_error(id, "worktree_not_found", "worktree cannot be opened"); } let canonical_path = crate::worktree::canonical_or_original(&entry.path); + let target_matches = + crate::workspace::git_space_metadata(&entry.path).is_some_and(|space| { + space.key == source.repo_key + && space.checkout_key == canonical_path.display().to_string() + }); + if !target_matches { + return encode_error( + id, + "worktree_not_found", + "worktree checkout changed during discovery; retry the request", + ); + } let canonical_source = crate::worktree::canonical_or_original(&source.source_checkout_path); let target_is_source = canonical_path == canonical_source; let already_open = self.open_workspace_idx_for_checkout(&canonical_path); @@ -243,60 +224,6 @@ impl App { self.worktree_source_from_workspace(ws_idx) } - fn resolve_worktree_list_source( - &mut self, - workspace_id: Option<String>, - cwd: Option<String>, - trust_repository: bool, - ) -> Result<WorktreeSource, ApiFailure> { - if workspace_id.is_some() && cwd.is_some() { - return Err(ApiFailure::new( - "invalid_request", - "only one of workspace_id or cwd may be supplied", - )); - } - - if let Some(workspace_id) = workspace_id { - let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { - return Err(ApiFailure::new( - "workspace_not_found", - format!("workspace {workspace_id} not found"), - )); - }; - return self.worktree_list_source_from_workspace(ws_idx, trust_repository); - } - - if let Some(cwd) = cwd { - let path = absolute_user_path(&cwd)?; - let space = crate::workspace::git_space_metadata(&path).ok_or_else(|| { - ApiFailure::new( - "not_git_worktree", - "Herdr worktree actions require a path inside a Git work tree", - ) - })?; - let workspace_idx = self.list_source_workspace_idx_for_space(&space, trust_repository); - return Ok(worktree_source_from_space( - space, - workspace_idx, - true, - trust_repository, - )); - } - - let Some(ws_idx) = self.state.active.or_else(|| { - self.state - .workspaces - .get(self.state.selected) - .map(|_| self.state.selected) - }) else { - return Err(ApiFailure::new( - "invalid_request", - "workspace_id or cwd is required when no workspace is active", - )); - }; - self.worktree_list_source_from_workspace(ws_idx, trust_repository) - } - fn worktree_source_from_workspace(&self, ws_idx: usize) -> Result<WorktreeSource, ApiFailure> { let Some(ws) = self.state.workspaces.get(ws_idx) else { return Err(ApiFailure::new( @@ -346,61 +273,6 @@ impl App { }) } - fn worktree_list_source_from_workspace( - &self, - ws_idx: usize, - trust_repository: bool, - ) -> Result<WorktreeSource, ApiFailure> { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return Err(ApiFailure::new( - "workspace_not_found", - "workspace not found", - )); - }; - if let Some(membership) = ws.worktree_space() { - let source_checkout_path = if membership.is_linked_worktree { - membership.repo_root.clone() - } else { - membership.checkout_path.clone() - }; - let workspace_idx = if membership.is_linked_worktree { - self.open_workspace_idx_for_checkout(&membership.repo_root) - } else { - Some(ws_idx) - }; - return Ok(WorktreeSource { - workspace_idx, - source_checkout_path, - source_repo_root: membership.repo_root.clone(), - repo_key: membership.key.clone(), - repo_name: membership.label.clone(), - }); - } - - 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) - }); - let Some(space) = git_space else { - return Err(ApiFailure::new( - "not_git_worktree", - "Herdr worktree actions require a workspace inside a Git work tree", - )); - }; - let workspace_idx = if space.is_linked_worktree { - self.list_source_workspace_idx_for_space(&space, trust_repository) - } else { - Some(ws_idx) - }; - Ok(worktree_source_from_space( - space, - workspace_idx, - true, - trust_repository, - )) - } - fn ensure_source_parent_membership( &mut self, source: &mut WorktreeSource, @@ -436,19 +308,6 @@ impl App { .or_else(|| self.open_workspace_idx_for_checkout(&space.repo_root)) } - fn list_source_workspace_idx_for_space( - &self, - space: &crate::workspace::GitSpaceMetadata, - trust_repository: bool, - ) -> Option<usize> { - if space.is_linked_worktree { - let parent_checkout = parent_checkout_path_for_space(space, trust_repository); - self.open_workspace_idx_for_checkout(&parent_checkout) - } else { - self.find_parent_workspace_for_space(space) - } - } - fn find_parent_workspace_by_key(&self, repo_key: &str) -> Option<usize> { self.state.workspaces.iter().position(|ws| { ws.worktree_space() @@ -495,52 +354,6 @@ impl App { } } - fn find_worktree_entry( - &self, - source: &WorktreeSource, - path: Option<String>, - branch: Option<String>, - trust_repository: bool, - ) -> Result<crate::worktree::ExistingWorktree, ApiFailure> { - let entries = - crate::worktree::list_existing_worktrees(&source.source_repo_root, trust_repository) - .map_err(|err| ApiFailure::new("worktree_list_failed", err))?; - if let Some(path) = path { - let expected = absolute_user_path(&path)?; - let expected = crate::worktree::canonical_or_original(&expected); - entries - .into_iter() - .find(|entry| crate::worktree::canonical_or_original(&entry.path) == expected) - .ok_or_else(|| ApiFailure::new("worktree_not_found", "worktree path not found")) - } else if let Some(branch) = branch { - let matches = entries - .into_iter() - .filter(|entry| { - !entry.is_bare - && !entry.is_prunable - && !entry.is_detached - && entry.branch.as_deref() == Some(branch.as_str()) - }) - .collect::<Vec<_>>(); - match matches.len() { - 0 => Err(ApiFailure::new( - "worktree_not_found", - "worktree branch not found", - )), - 1 => Ok(matches.into_iter().next().expect("one match should exist")), - _ => Err(ApiFailure::new( - "ambiguous_worktree_branch", - "multiple worktrees matched branch", - )), - } - } else { - Err(ApiFailure::new( - "invalid_request", - "exactly one of path or branch is required", - )) - } - } - fn worktree_source_info(&self, source: &WorktreeSource) -> WorktreeSourceInfo { WorktreeSourceInfo { repo_key: source.repo_key.clone(), @@ -682,49 +495,47 @@ impl App { } } -fn worktree_source_from_space( - space: crate::workspace::GitSpaceMetadata, - workspace_idx: Option<usize>, - allow_linked: bool, - trust_repository: bool, -) -> WorktreeSource { - let source_checkout_path = if allow_linked { - parent_checkout_path_for_space(&space, trust_repository) +fn find_worktree_entry( + entries: Vec<crate::worktree::ExistingWorktree>, + path: Option<String>, + branch: Option<String>, +) -> Result<crate::worktree::ExistingWorktree, ApiFailure> { + if let Some(path) = path { + let expected = absolute_user_path(&path)?; + let expected = crate::worktree::canonical_or_original(&expected); + entries + .into_iter() + .find(|entry| crate::worktree::canonical_or_original(&entry.path) == expected) + .ok_or_else(|| ApiFailure::new("worktree_not_found", "worktree path not found")) + } else if let Some(branch) = branch { + let matches = entries + .into_iter() + .filter(|entry| { + !entry.is_bare + && !entry.is_prunable + && !entry.is_detached + && entry.branch.as_deref() == Some(branch.as_str()) + }) + .collect::<Vec<_>>(); + match matches.len() { + 0 => Err(ApiFailure::new( + "worktree_not_found", + "worktree branch not found", + )), + 1 => Ok(matches.into_iter().next().expect("one match should exist")), + _ => Err(ApiFailure::new( + "ambiguous_worktree_branch", + "multiple worktrees matched branch", + )), + } } else { - space.repo_root.clone() - }; - WorktreeSource { - workspace_idx, - source_checkout_path: source_checkout_path.clone(), - source_repo_root: source_checkout_path, - repo_key: space.key, - repo_name: space.repo_name, + Err(ApiFailure::new( + "invalid_request", + "exactly one of path or branch is required", + )) } } -fn parent_checkout_path_for_space( - space: &crate::workspace::GitSpaceMetadata, - trust_repository: bool, -) -> PathBuf { - if !space.is_linked_worktree { - return space.repo_root.clone(); - } - - crate::worktree::list_existing_worktrees(&space.repo_root, trust_repository) - .ok() - .and_then(|entries| { - entries.into_iter().find_map(|entry| { - let entry_space = crate::workspace::git_space_metadata(&entry.path)?; - if entry_space.key == space.key && !entry_space.is_linked_worktree { - Some(entry_space.repo_root) - } else { - None - } - }) - }) - .unwrap_or_else(|| space.repo_root.clone()) -} - fn worktree_membership( source: &WorktreeSource, checkout_path: PathBuf, @@ -745,7 +556,8 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use crate::api::schema::{ - ErrorResponse, Request, SuccessResponse, WorktreeCreateParams, WorktreeRemoveParams, + ErrorResponse, Request, SuccessResponse, WorktreeCreateParams, WorktreeListParams, + WorktreeRemoveParams, }; use crate::events::{ ApiWorktreeAddRequest, ApiWorktreeRemoveRequest, AppEvent, WorktreeAddResult, @@ -883,7 +695,7 @@ mod tests { fn run_deferred_api_request(app: &mut App, request: Request) -> String { let (respond_to, response_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request, respond_to)); + assert!(app.handle_deferred_worktree_api_request(request, respond_to, false)); if let Ok(response) = response_rx.recv_timeout(std::time::Duration::from_millis(50)) { return response; } @@ -1025,6 +837,7 @@ mod tests { }), }, respond_to, + false, )); assert!(response_rx.try_recv().is_err()); @@ -1100,7 +913,7 @@ mod tests { }; let (response_tx, response_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request(), response_tx)); + assert!(app.handle_deferred_worktree_api_request(request(), response_tx, false)); let event = wait_for_app_event(&mut app); app.handle_internal_event(event); let response = response_rx @@ -1165,7 +978,7 @@ mod tests { }; let (first_tx, first_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request(), first_tx)); + assert!(app.handle_deferred_worktree_api_request(request(), first_tx, false)); let event = wait_for_app_event(&mut app); app.handle_internal_event(event); let response = first_rx @@ -1176,7 +989,7 @@ mod tests { assert!(app.pending_api_worktree_creates.is_empty()); let (second_tx, second_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request(), second_tx)); + assert!(app.handle_deferred_worktree_api_request(request(), second_tx, false)); assert!(second_rx.try_recv().is_err()); let event = wait_for_app_event(&mut app); app.handle_internal_event(event); @@ -1345,15 +1158,18 @@ mod tests { let repo = create_committed_repo("api-worktree-open-invalid-cwd-repo"); let mut app = test_app(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - cwd: Some(repo.display().to_string()), - path: Some("/tmp/one".into()), - branch: Some("worktree/one".into()), - ..WorktreeOpenParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + cwd: Some(repo.display().to_string()), + path: Some("/tmp/one".into()), + branch: Some("worktree/one".into()), + ..WorktreeOpenParams::default() + }), + }, + ); let error: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "invalid_request"); @@ -1436,15 +1252,19 @@ mod tests { app.state.workspaces.push(child); app.state.ensure_test_terminals(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - branch: Some("worktree/api-open".into()), - focus: true, - ..WorktreeOpenParams::default() - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(workspace_id), + branch: Some("worktree/api-open".into()), + focus: true, + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeOpened { @@ -1532,13 +1352,16 @@ mod tests { app.mark_worktree_membership(&source, 1, checkout.clone(), true, false); let child_membership = app.state.workspaces[1].worktree_space().cloned(); - let response = app.handle_api_request(Request { - id: "list".into(), - method: crate::api::schema::Method::WorktreeList(WorktreeListParams { - workspace_id: Some(parent_id.clone()), - ..WorktreeListParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "list".into(), + method: crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(parent_id.clone()), + ..WorktreeListParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { worktrees, .. } = success.result else { panic!("expected worktree_list response"); @@ -1549,15 +1372,18 @@ mod tests { .unwrap(); assert_eq!(entry.open_workspace_id.as_deref(), Some(child_id.as_str())); - let response = app.handle_api_request(Request { - id: "open".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - workspace_id: Some(parent_id), - branch: Some("worktree/membership".into()), - focus: false, - ..WorktreeOpenParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "open".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(parent_id), + branch: Some("worktree/membership".into()), + focus: false, + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeOpened { workspace, @@ -1616,15 +1442,19 @@ mod tests { app.state.active = Some(0); app.state.selected = 0; - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - branch: Some("worktree/api-open-label".into()), - label: Some("review".into()), - ..WorktreeOpenParams::default() - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(workspace_id), + branch: Some("worktree/api-open-label".into()), + label: Some("review".into()), + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeOpened { @@ -1665,21 +1495,251 @@ mod tests { } #[tokio::test] + async fn deferred_worktree_open_rejects_removed_or_replaced_target() { + for replacement in ["missing", "directory", "repository"] { + let repo = create_committed_repo("api-stale-target-source"); + let checkout = unique_temp_path("api-stale-target"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "stale-target", + checkout.to_str().unwrap(), + ], + ); + let mut app = app_with_parent(&repo); + let (respond_to, response_rx) = response_channel(); + let source_id = app.state.workspaces[0].id.clone(); + app.handle_deferred_worktree_api_request( + Request { + id: "stale-target".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(source_id), + branch: Some("stale-target".into()), + label: Some("must not rename".into()), + focus: true, + ..Default::default() + }), + }, + respond_to, + false, + ); + let completion = wait_for_app_event(&mut app); + run_git(&repo, &["worktree", "remove", checkout.to_str().unwrap()]); + match replacement { + "directory" => std::fs::create_dir_all(&checkout).unwrap(), + "repository" => { + let other_repo = create_committed_repo("api-stale-target-replacement"); + std::fs::rename(other_repo, &checkout).unwrap(); + } + _ => {} + } + let mut unrelated = Workspace::test_new("unrelated"); + unrelated.identity_cwd = checkout.clone(); + app.state.workspaces.push(unrelated); + app.state.ensure_test_terminals(); + app.handle_internal_event(completion); + let response: ErrorResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + assert_eq!(response.error.code, "worktree_not_found", "{replacement}"); + assert_eq!(app.state.workspaces.len(), 2); + assert_eq!(app.state.active, Some(0)); + assert!(app + .state + .workspaces + .iter() + .all(|ws| ws.worktree_space().is_none())); + app.state.assert_invariants_for_test(); + let _ = std::fs::remove_dir_all(checkout); + let _ = std::fs::remove_dir_all(repo); + } + } + + #[tokio::test] + async fn deferred_worktree_reads_bound_running_and_queued_discovery() { + let repo = create_committed_repo("api-read-limit"); + let mut app = app_with_parent(&repo); + app.worktree_read_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1)); + let source_id = app.state.workspaces[0].id.clone(); + let request = |open| Request { + id: "read-limit".into(), + method: if open { + crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(source_id.clone()), + path: Some(repo.display().to_string()), + ..Default::default() + }) + } else { + crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(source_id.clone()), + ..Default::default() + }) + }, + }; + let (entered, release) = crate::worktree::test_list_gate::block(&repo); + let (respond_to, response_rx) = response_channel(); + app.handle_deferred_worktree_api_request(request(false), respond_to, false); + entered + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + let assert_busy = |app: &mut App| { + for open in [false, true] { + let (respond_to, response_rx) = response_channel(); + app.handle_deferred_worktree_api_request(request(open), respond_to, false); + let response: ErrorResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + assert_eq!(response.error.code, "worktree_busy"); + } + }; + assert_busy(&mut app); + release.send(()).unwrap(); + let completion = wait_for_app_event(&mut app); + assert_busy(&mut app); + app.handle_internal_event(completion); + let _: SuccessResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + assert_eq!(app.worktree_read_slots.available_permits(), 1); + let response = run_deferred_api_request(&mut app, request(false)); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(app.worktree_read_slots.available_permits(), 1); + let _ = std::fs::remove_dir_all(repo); + } + + #[tokio::test] + async fn deferred_worktree_open_rechecks_source_identity_and_existing_target() { + let repo = create_committed_repo("api-worktree-open-pending-source"); + let other_repo = create_committed_repo("api-worktree-open-unrelated-source"); + let checkout = unique_temp_path("api-worktree-open-pending-target"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "pending-open", + checkout.to_str().unwrap(), + "HEAD", + ], + ); + + for source_change in ["reordered", "removed", "repurposed"] { + let mut app = app_with_parent(&repo); + let source_id = app.state.workspaces[0].id.clone(); + let (respond_to, response_rx) = response_channel(); + assert!(app.handle_deferred_worktree_api_request( + Request { + id: "pending-open".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(source_id.clone()), + branch: Some("pending-open".into()), + focus: false, + ..Default::default() + }), + }, + respond_to, + false + )); + let completed_discovery = wait_for_app_event(&mut app); + assert!(response_rx.try_recv().is_err()); + assert!(app.state.workspaces[0].worktree_space().is_none()); + + let original_source = app.state.workspaces.remove(0); + let mut unrelated = Workspace::test_new("unrelated"); + unrelated.identity_cwd = other_repo.clone(); + if source_change == "repurposed" { + unrelated.id = source_id.clone(); + } + let mut opened_target = Workspace::test_new("opened-during-discovery"); + opened_target.identity_cwd = checkout.clone(); + let target_id = opened_target.id.clone(); + app.state.workspaces = vec![unrelated, opened_target]; + if source_change == "reordered" { + app.state.workspaces.push(original_source); + } + app.state.terminals.clear(); + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.handle_internal_event(completed_discovery); + + let response: SuccessResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + let ResponseResult::WorktreeOpened { + workspace, + already_open, + .. + } = response.result + else { + panic!("expected opened response"); + }; + assert!( + already_open, + "target opened during discovery must be reused" + ); + assert_eq!(workspace.workspace_id, target_id); + assert!( + app.state.workspaces[0].worktree_space().is_none(), + "{source_change} source must not mark an unrelated workspace" + ); + assert_eq!( + app.state.active, + Some(0), + "background open must preserve focus" + ); + assert_eq!(app.state.workspaces.len(), 3); + assert_eq!( + crate::worktree::canonical_or_original( + &app.state.workspaces[2].worktree_space().unwrap().repo_root + ), + crate::worktree::canonical_or_original(&repo) + ); + app.state.assert_invariants_for_test(); + } + 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(repo); + let _ = std::fs::remove_dir_all(other_repo); + } + + #[tokio::test] async fn api_worktree_open_source_checkout_created_by_request_is_not_already_open() { let repo = create_committed_repo("api-worktree-open-source-repo"); let event_hub = crate::api::EventHub::default(); let mut app = test_app_with_event_hub(event_hub.clone()); app.state.default_shell = test_shell().into(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - cwd: Some(repo.display().to_string()), - path: Some(repo.display().to_string()), - label: Some("source checkout".into()), - ..WorktreeOpenParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + cwd: Some(repo.display().to_string()), + path: Some(repo.display().to_string()), + label: Some("source checkout".into()), + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap_or_else(|err| { panic!("expected success response, got {response}: {err}"); @@ -1739,14 +1799,18 @@ mod tests { app.state.workspaces.push(child); app.state.ensure_test_terminals(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeList(WorktreeListParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - cwd: None, - trust_repository: false, - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id), + cwd: None, + trust_repository: false, + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { worktrees, .. } = success.result else { @@ -1803,10 +1867,13 @@ mod tests { trust_repository: false, }), ] { - let response = app.handle_api_request(Request { - id: "req".into(), - method, - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method, + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { source, worktrees } = success.result else { panic!("expected worktree_list response"); @@ -1849,14 +1916,18 @@ mod tests { std::fs::remove_dir_all(&checkout).unwrap(); let mut app = app_with_parent(&repo); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeList(WorktreeListParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - cwd: None, - trust_repository: false, - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id), + cwd: None, + trust_repository: false, + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { worktrees, .. } = success.result else { @@ -2075,6 +2146,7 @@ mod tests { }), }, respond_to, + false, )); assert!(response_rx.try_recv().is_err()); @@ -2149,6 +2221,7 @@ mod tests { }), }, first_tx, + false, )); assert!(app.handle_deferred_worktree_api_request( Request { @@ -2160,6 +2233,7 @@ mod tests { }), }, second_tx, + false, )); let response = second_rx .recv_timeout(std::time::Duration::from_secs(2)) @@ -2220,6 +2294,7 @@ mod tests { }), }, first_tx, + false, )); assert!(app.handle_deferred_worktree_api_request( Request { @@ -2231,6 +2306,7 @@ mod tests { }), }, second_tx, + false, )); let response = second_rx .recv_timeout(std::time::Duration::from_secs(2)) @@ -2267,6 +2343,7 @@ mod tests { }), }, respond_to, + false, )); let response = response_rx @@ -2322,6 +2399,7 @@ mod tests { }), }, respond_to, + false, )); let response = response_rx diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 9adbd70c..0f5ed547 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -16,8 +16,14 @@ impl App { &mut self, request: Request, respond_to: std::sync::mpsc::Sender<String>, + client_local: bool, ) -> bool { match request.method { + crate::api::schema::Method::WorktreeList(_) + | crate::api::schema::Method::WorktreeOpen(_) => { + self.start_api_worktree_read(request, respond_to, client_local); + true + } crate::api::schema::Method::WorktreeCreate(params) => { self.start_api_worktree_create(request.id, params, respond_to); true diff --git a/src/app/api/worktrees/reads.rs b/src/app/api/worktrees/reads.rs new file mode 100644 index 00000000..5024e7f0 --- /dev/null +++ b/src/app/api/worktrees/reads.rs @@ -0,0 +1,339 @@ +use std::path::PathBuf; + +use crate::api::schema::{Method, Request}; +use crate::app::App; +use crate::events::{AppEvent, WorktreeReadData, WorktreeReadResult}; +use crate::workspace::{GitSpaceMetadata, WorktreeSpaceMembership}; + +use super::super::responses::encode_error; +use super::{absolute_user_path, find_worktree_entry, ApiFailure, WorktreeSource}; + +// Capture only source provenance. Filesystem discovery and Git run on the worker; +// workspace indices and the target's open state are resolved again on completion. +struct SourceInput { + workspace_id: Option<String>, + membership: Option<WorktreeSpaceMembership>, + git_space: Option<GitSpaceMetadata>, + cwd: Option<PathBuf>, +} + +impl SourceInput { + fn resolve( + mut self, + allow_linked: bool, + trust_repository: bool, + ) -> Result<(Option<String>, WorktreeSource), ApiFailure> { + let source = if let Some(membership) = self.membership { + if membership.is_linked_worktree && !allow_linked { + return Err(linked_source_error()); + } + let source_checkout_path = if membership.is_linked_worktree { + self.workspace_id = None; + membership.repo_root.clone() + } else { + membership.checkout_path + }; + WorktreeSource { + workspace_idx: None, + source_checkout_path, + source_repo_root: membership.repo_root, + repo_key: membership.key, + repo_name: membership.label, + } + } else { + let space = self + .git_space + .or_else(|| { + self.cwd + .as_deref() + .and_then(crate::workspace::git_space_metadata) + }) + .ok_or_else(|| { + ApiFailure::new( + "not_git_worktree", + if self.workspace_id.is_some() { + "Herdr worktree actions require a workspace inside a Git work tree" + } else { + "Herdr worktree actions require a path inside a Git work tree" + }, + ) + })?; + if space.is_linked_worktree { + if !allow_linked { + return Err(linked_source_error()); + } + self.workspace_id = None; + } + worktree_source_from_space(space, allow_linked, trust_repository) + }; + Ok((self.workspace_id, source)) + } +} + +fn linked_source_error() -> ApiFailure { + ApiFailure::new( + "linked_worktree_source", + "New and open worktree actions start from the repo parent workspace.", + ) +} + +impl App { + fn capture_worktree_read_source( + &self, + workspace_id: &Option<String>, + cwd: &Option<String>, + ) -> Result<SourceInput, ApiFailure> { + if workspace_id.is_some() && cwd.is_some() { + return Err(ApiFailure::new( + "invalid_request", + "only one of workspace_id or cwd may be supplied", + )); + } + if let Some(cwd) = cwd { + return Ok(SourceInput { + workspace_id: None, + membership: None, + git_space: None, + cwd: Some(absolute_user_path(cwd)?), + }); + } + let ws_idx = if let Some(workspace_id) = workspace_id { + self.parse_workspace_id(workspace_id).ok_or_else(|| { + ApiFailure::new( + "workspace_not_found", + format!("workspace {workspace_id} not found"), + ) + })? + } else { + self.state + .active + .or_else(|| { + self.state + .workspaces + .get(self.state.selected) + .map(|_| self.state.selected) + }) + .ok_or_else(|| { + ApiFailure::new( + "invalid_request", + "workspace_id or cwd is required when no workspace is active", + ) + })? + }; + let ws = &self.state.workspaces[ws_idx]; + Ok(SourceInput { + workspace_id: Some(ws.id.clone()), + membership: ws.worktree_space().cloned(), + git_space: ws.git_space().cloned(), + cwd: if ws.worktree_space().is_none() && ws.git_space().is_none() { + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) + } else { + None + }, + }) + } + + pub(super) fn start_api_worktree_read( + &mut self, + request: Request, + respond_to: std::sync::mpsc::Sender<String>, + client_local: bool, + ) { + let (workspace_id, cwd, allow_linked, trust_repository) = match &request.method { + Method::WorktreeList(params) => ( + ¶ms.workspace_id, + ¶ms.cwd, + true, + params.trust_repository, + ), + Method::WorktreeOpen(params) => { + if params.path.is_some() == params.branch.is_some() { + let _ = respond_to.send(encode_error( + request.id, + "invalid_request", + "exactly one of path or branch is required", + )); + return; + } + ( + ¶ms.workspace_id, + ¶ms.cwd, + false, + params.trust_repository, + ) + } + _ => unreachable!("only worktree list/open use background discovery"), + }; + let input = match self.capture_worktree_read_source(workspace_id, cwd) { + Ok(input) => input, + Err(err) => { + let _ = respond_to.send(encode_error(request.id, err.code, err.message)); + return; + } + }; + let Ok(permit) = self.worktree_read_slots.clone().try_acquire_owned() else { + let _ = respond_to.send(encode_error( + request.id, + "worktree_busy", + "too many worktree checks are pending; retry shortly", + )); + return; + }; + let event_tx = self.event_tx.clone(); + let spawn_error_response = respond_to.clone(); + let request_id = request.id.clone(); + let spawned = std::thread::Builder::new() + .name("worktree-read".into()) + .spawn(move || { + let source_cwd = input.cwd.clone(); + let mut source_workspace_id = None; + let result = input + .resolve(allow_linked, trust_repository) + .and_then(|(workspace_id, source)| { + source_workspace_id = workspace_id; + let mut entries = crate::worktree::list_existing_worktrees( + &source.source_repo_root, + trust_repository, + ) + .map_err(|err| ApiFailure::new("worktree_list_failed", err))?; + if let Method::WorktreeOpen(params) = &request.method { + entries = vec![find_worktree_entry( + entries, + params.path.clone(), + params.branch.clone(), + )?]; + } + Ok(WorktreeReadData { + source_checkout_path: source.source_checkout_path, + source_repo_root: source.source_repo_root, + repo_key: source.repo_key, + repo_name: source.repo_name, + entries, + }) + }) + .map_err(|err| (err.code.to_string(), err.message)); + let _ = event_tx.blocking_send(AppEvent::WorktreeReadFinished(Box::new( + WorktreeReadResult { + _permit: permit, + request, + client_local, + source_workspace_id, + source_cwd, + result, + respond_to, + }, + ))); + }); + if let Err(err) = spawned { + let _ = spawn_error_response.send(encode_error( + request_id, + "worktree_list_failed", + format!("could not start worktree discovery: {err}"), + )); + } + } + + pub(crate) fn handle_api_worktree_read_finished(&mut self, result: WorktreeReadResult) { + let response = match result.result { + Err((code, message)) => encode_error(result.request.id, &code, message), + Ok(data) => { + let mut source = WorktreeSource { + workspace_idx: None, + source_checkout_path: data.source_checkout_path, + source_repo_root: data.source_repo_root, + repo_key: data.repo_key, + repo_name: data.repo_name, + }; + // An index captured before Git ran could now name a different workspace. + source.workspace_idx = result + .source_workspace_id + .as_ref() + .and_then(|id| self.state.workspaces.iter().position(|ws| &ws.id == id)) + .filter(|&idx| { + let ws = &self.state.workspaces[idx]; + if let Some(current) = ws.worktree_space() { + !current.is_linked_worktree + && current.key == source.repo_key + && current.repo_root == source.source_repo_root + && current.checkout_path == source.source_checkout_path + } else if let Some(current) = ws.git_space() { + !current.is_linked_worktree + && current.key == source.repo_key + && current.repo_root == source.source_repo_root + } else { + // Git was already discovered on the worker. A cache miss here + // must only compare the captured source path, not rediscover it. + result.source_cwd.as_ref().is_some_and(|cwd| { + ws.resolved_identity_cwd_from( + &self.state.terminals, + &self.terminal_runtimes, + ) + .as_ref() + == Some(cwd) + }) + } + }) + .or_else(|| self.find_parent_workspace_by_key(&source.repo_key)) + .or_else(|| self.open_workspace_idx_for_checkout(&source.source_checkout_path)); + match result.request.method { + Method::WorktreeList(_) => { + self.finish_worktree_list(result.request.id, source, data.entries) + } + Method::WorktreeOpen(params) => self.finish_worktree_open( + result.request.id, + params, + source, + data.entries + .into_iter() + .next() + .expect("open discovery selects one worktree"), + ), + _ => unreachable!("only worktree list/open use background discovery"), + } + } + }; + let _ = result.respond_to.send(response); + } +} + +fn worktree_source_from_space( + space: crate::workspace::GitSpaceMetadata, + allow_linked: bool, + trust_repository: bool, +) -> WorktreeSource { + let source_checkout_path = if allow_linked { + parent_checkout_path_for_space(&space, trust_repository) + } else { + space.repo_root.clone() + }; + WorktreeSource { + workspace_idx: None, + source_checkout_path: source_checkout_path.clone(), + source_repo_root: source_checkout_path, + repo_key: space.key, + repo_name: space.repo_name, + } +} + +fn parent_checkout_path_for_space( + space: &crate::workspace::GitSpaceMetadata, + trust_repository: bool, +) -> PathBuf { + if !space.is_linked_worktree { + return space.repo_root.clone(); + } + + crate::worktree::list_existing_worktrees(&space.repo_root, trust_repository) + .ok() + .and_then(|entries| { + entries.into_iter().find_map(|entry| { + let entry_space = crate::workspace::git_space_metadata(&entry.path)?; + if entry_space.key == space.key && !entry_space.is_linked_worktree { + Some(entry_space.repo_root) + } else { + None + } + }) + }) + .unwrap_or_else(|| space.repo_root.clone()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 7ff3040b..03e89026 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -124,6 +124,7 @@ pub struct App { pub(crate) git_identity_refresh_requested: bool, pub(crate) git_status_cache: HashMap<std::path::PathBuf, crate::workspace::GitStatusCacheEntry>, pub(crate) pending_api_worktree_creates: HashMap<std::path::PathBuf, u64>, + pub(crate) worktree_read_slots: std::sync::Arc<tokio::sync::Semaphore>, pub(crate) pending_api_worktree_removes: HashMap<String, u64>, pub(crate) pending_api_worktree_remove_paths: HashMap<std::path::PathBuf, u64>, pub(crate) pending_worktree_remove_runtime_exits: HashMap<crate::layout::PaneId, usize>, @@ -588,6 +589,7 @@ impl App { git_identity_refresh_requested: false, git_status_cache: HashMap::new(), pending_api_worktree_creates: HashMap::new(), + worktree_read_slots: std::sync::Arc::new(tokio::sync::Semaphore::new(8)), pending_api_worktree_removes: HashMap::new(), pending_api_worktree_remove_paths: HashMap::new(), pending_worktree_remove_runtime_exits: HashMap::new(), |
