aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/worktrees.rs
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-22 18:43:12 +0200
committerGitHub <noreply@github.com>2026-09-22 19:43:12 +0300
commitdd33ddf28d693b2c64ed91f75656c61416ee2538 (patch)
tree229769fa9e2535b72dc39ba0f64593ef5fdc93a2 /src/app/api/worktrees.rs
parent1c6397b1cddb288099ed212a5979732698bd72c2 (diff)
fix: move worktree lookups async (#4492)
Diffstat (limited to 'src/app/api/worktrees.rs')
-rw-r--r--src/app/api/worktrees.rs728
1 files changed, 403 insertions, 325 deletions
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