aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent1c6397b1cddb288099ed212a5979732698bd72c2 (diff)
fix: move worktree lookups async (#4492)
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs1
-rw-r--r--src/app/api.rs22
-rw-r--r--src/app/api/worktrees.rs728
-rw-r--r--src/app/api/worktrees/deferred.rs6
-rw-r--r--src/app/api/worktrees/reads.rs339
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/events.rs23
-rw-r--r--src/server/headless.rs17
-rw-r--r--src/server/headless/client_views.rs27
-rw-r--r--src/server/headless/endpoint_requests.rs14
-rw-r--r--src/server/headless/notifications.rs57
-rw-r--r--src/server/headless/pane_graphics.rs2
-rw-r--r--src/server/headless/tests/mod.rs249
-rw-r--r--src/worktree.rs43
14 files changed, 1156 insertions, 374 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) => (
+ &params.workspace_id,
+ &params.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;
+ }
+ (
+ &params.workspace_id,
+ &params.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(),
diff --git a/src/events.rs b/src/events.rs
index 95638991..a614ee0c 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -52,6 +52,27 @@ pub struct WorktreeRemoveResult {
pub result: Result<(), String>,
}
+#[derive(Debug)]
+pub struct WorktreeReadResult {
+ // Keep the slot until completion is consumed, including time queued on the app loop.
+ pub(crate) _permit: tokio::sync::OwnedSemaphorePermit,
+ pub(crate) client_local: bool,
+ pub(crate) request: crate::api::schema::Request,
+ pub(crate) source_workspace_id: Option<String>,
+ pub(crate) source_cwd: Option<std::path::PathBuf>,
+ pub(crate) result: Result<WorktreeReadData, (String, String)>,
+ pub(crate) respond_to: std::sync::mpsc::Sender<String>,
+}
+
+#[derive(Debug)]
+pub(crate) struct WorktreeReadData {
+ pub source_checkout_path: std::path::PathBuf,
+ pub source_repo_root: std::path::PathBuf,
+ pub repo_key: String,
+ pub repo_name: String,
+ pub entries: Vec<crate::worktree::ExistingWorktree>,
+}
+
/// An event from a background task to the main loop.
#[derive(Debug)]
pub enum AppEvent {
@@ -173,4 +194,6 @@ pub enum AppEvent {
WorktreeAddFinished(Box<WorktreeAddResult>),
/// Background `git worktree remove` completed.
WorktreeRemoveFinished(Box<WorktreeRemoveResult>),
+ /// Background worktree discovery completed for an API list/open request.
+ WorktreeReadFinished(Box<WorktreeReadResult>),
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 0aed2a37..bba59498 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -2970,6 +2970,7 @@ impl HeadlessServer {
&mut self,
msg: api::ApiRequestMessage,
skip_default_workspace_for_request: bool,
+ client_local: bool,
) -> bool {
if self.shutting_down {
// During shutdown, respond with server_unavailable.
@@ -3124,12 +3125,18 @@ impl HeadlessServer {
}
if matches!(
&msg.request.method,
- api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_)
+ api::schema::Method::WorktreeCreate(_)
+ | api::schema::Method::WorktreeRemove(_)
+ | api::schema::Method::WorktreeList(_)
+ | api::schema::Method::WorktreeOpen(_)
) {
- let deferred_changed = self
- .app
- .handle_deferred_worktree_api_request(msg.request, msg.respond_to);
- return changed | deferred_changed;
+ let read_only = matches!(&msg.request.method, api::schema::Method::WorktreeList(_));
+ let deferred_changed = self.app.handle_deferred_worktree_api_request(
+ msg.request,
+ msg.respond_to,
+ client_local,
+ );
+ return changed | (deferred_changed && !read_only);
}
if self.foreground_client_id.is_some_and(|client_id| {
self.clients
diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs
index c2e3c2d5..31b39be2 100644
--- a/src/server/headless/client_views.rs
+++ b/src/server/headless/client_views.rs
@@ -313,7 +313,10 @@ impl HeadlessServer {
pub(super) fn deferred_endpoint_navigation_tab_id(response: &[u8]) -> Option<String> {
let response = serde_json::from_slice::<serde_json::Value>(response).ok()?;
- if response.pointer("/result/type")?.as_str()? != "worktree_created" {
+ if !matches!(
+ response.pointer("/result/type")?.as_str()?,
+ "worktree_created" | "worktree_opened"
+ ) {
return None;
}
response
@@ -851,24 +854,17 @@ impl HeadlessServer {
api::schema::Method::TabCreate(params) => params.focus,
_ => false,
};
- let inspect_worktree_open = matches!(
- &msg.request.method,
- api::schema::Method::WorktreeOpen(params) if params.focus
- );
let inspect_pane_move = matches!(
&msg.request.method,
api::schema::Method::PaneMove(params) if params.focus
);
- let response_proxy = (agent_focus_target.is_some()
- || inspect_worktree_open
- || inspect_pane_move)
- .then(|| {
- let (proxy_tx, proxy_rx) = std::sync::mpsc::channel();
- let original = std::mem::replace(&mut msg.respond_to, proxy_tx);
- (original, proxy_rx)
- });
+ let response_proxy = (agent_focus_target.is_some() || inspect_pane_move).then(|| {
+ let (proxy_tx, proxy_rx) = std::sync::mpsc::channel();
+ let original = std::mem::replace(&mut msg.respond_to, proxy_tx);
+ (original, proxy_rx)
+ });
let reconcile = Self::shell_locations_may_need_reconcile(&msg.request.method);
- let changed = self.handle_api_request_with_shutdown_check_inner(msg, false);
+ let changed = self.handle_api_request_with_shutdown_check_inner(msg, false, false);
let proxied_result = forward_proxied_api_response(response_proxy);
let proxied_request_succeeded = proxied_result.is_some();
// Same-tab and zoomed moves succeed without moving or requesting focus.
@@ -895,7 +891,6 @@ impl HeadlessServer {
.is_some_and(|target| self.default_shell_target() == Some(target));
let public_focus_succeeded = explicit_focus_succeeded
|| (create_focus_requested && target_changed)
- || (inspect_worktree_open && proxied_request_succeeded)
|| pane_move_focus_succeeded;
if public_focus_succeeded {
self.focus_all_shell_clients_on_default_target();
@@ -927,7 +922,7 @@ impl HeadlessServer {
self.set_default_shell_target_from_client(client_id);
let popup_before = self.app.state.popup_pane.is_some();
let popup_owner = self.shell_tab_id_for_client(client_id);
- let changed = self.handle_api_request_with_shutdown_check_inner(msg, false);
+ let changed = self.handle_api_request_with_shutdown_check_inner(msg, false, true);
self.focus_shell_client_on_default_target(client_id);
if !popup_before && self.app.state.popup_pane.is_some() {
self.popup_owner_tab_id = popup_owner;
diff --git a/src/server/headless/endpoint_requests.rs b/src/server/headless/endpoint_requests.rs
index 4f02b676..afdcc80a 100644
--- a/src/server/headless/endpoint_requests.rs
+++ b/src/server/headless/endpoint_requests.rs
@@ -105,12 +105,16 @@ impl HeadlessServer {
client.shell_endpoint_command_surface_revision = Some(client.shell_projection_revision);
let deferred_worktree = matches!(
&request.method,
- api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_)
- );
- let deferred_navigation = matches!(
- &request.method,
- api::schema::Method::WorktreeCreate(params) if params.focus
+ api::schema::Method::WorktreeCreate(_)
+ | api::schema::Method::WorktreeRemove(_)
+ | api::schema::Method::WorktreeList(_)
+ | api::schema::Method::WorktreeOpen(_)
);
+ let deferred_navigation = match &request.method {
+ api::schema::Method::WorktreeCreate(params) => params.focus,
+ api::schema::Method::WorktreeOpen(params) => params.focus,
+ _ => false,
+ };
client.shell_deferred_navigation_request_id =
deferred_worktree.then(|| api_request_id.clone());
client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new);
diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs
index a575cfe1..e6d217e2 100644
--- a/src/server/headless/notifications.rs
+++ b/src/server/headless/notifications.rs
@@ -302,19 +302,25 @@ impl HeadlessServer {
if self.host_shutdown_requested.load(Ordering::Acquire) {
return false;
}
- let mut focused_worktree_response = if let AppEvent::WorktreeAddFinished(result) = &mut ev {
- result
+ let focus_response = match &mut ev {
+ AppEvent::WorktreeAddFinished(result) => result
.api_request
.as_mut()
.filter(|request| request.focus)
- .map(|request| {
- let (proxy_tx, proxy_rx) = std::sync::mpsc::channel();
- let original = std::mem::replace(&mut request.respond_to, proxy_tx);
- (original, proxy_rx)
- })
- } else {
- None
+ .map(|request| &mut request.respond_to),
+ AppEvent::WorktreeReadFinished(result)
+ if matches!(&result.request.method,
+ api::schema::Method::WorktreeOpen(params) if params.focus) =>
+ {
+ Some(&mut result.respond_to)
+ }
+ _ => None,
};
+ let mut focused_worktree_response = focus_response.map(|respond_to| {
+ let (proxy_tx, proxy_rx) = std::sync::mpsc::channel();
+ let original = std::mem::replace(respond_to, proxy_tx);
+ (original, proxy_rx)
+ });
match &ev {
AppEvent::TerminalBell { pane_id, count } => {
if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) {
@@ -568,16 +574,29 @@ impl HeadlessServer {
true
}
- AppEvent::WorktreeAddFinished(result) => {
- let deferred_request_id = result
- .api_request
- .as_ref()
- .map(|request| request.id.as_str());
- let shell_navigation_pending = deferred_request_id.is_some_and(|request_id| {
- self.clients.values().any(|client| {
- client.shell_deferred_navigation_request_id.as_deref() == Some(request_id)
- })
- });
+ AppEvent::WorktreeReadFinished(result)
+ if matches!(&result.request.method, api::schema::Method::WorktreeList(_)) =>
+ {
+ self.app.handle_internal_event_with_render_impact(ev)
+ }
+ AppEvent::WorktreeAddFinished(_) | AppEvent::WorktreeReadFinished(_) => {
+ let deferred_request_id = match &ev {
+ AppEvent::WorktreeAddFinished(result) => result
+ .api_request
+ .as_ref()
+ .map(|request| request.id.as_str()),
+ AppEvent::WorktreeReadFinished(result) => Some(result.request.id.as_str()),
+ _ => None,
+ };
+ let client_local =
+ matches!(&ev, AppEvent::WorktreeReadFinished(result) if result.client_local);
+ let shell_navigation_pending = client_local
+ || deferred_request_id.is_some_and(|request_id| {
+ self.clients.values().any(|client| {
+ client.shell_deferred_navigation_request_id.as_deref()
+ == Some(request_id)
+ })
+ });
let changed = self.app.handle_internal_event_with_render_impact(ev);
let api_focus_succeeded = super::client_views::forward_proxied_api_response(
focused_worktree_response.take(),
diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs
index e1410be1..947d2cb8 100644
--- a/src/server/headless/pane_graphics.rs
+++ b/src/server/headless/pane_graphics.rs
@@ -13,7 +13,7 @@ impl HeadlessServer {
msg: api::ApiRequestMessage,
) -> RenderImpact {
if self.shutting_down {
- let _ = self.handle_api_request_with_shutdown_check_inner(msg, false);
+ let _ = self.handle_api_request_with_shutdown_check_inner(msg, false, false);
return RenderImpact::None;
}
diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs
index 6accdb09..5f54f4b6 100644
--- a/src/server/headless/tests/mod.rs
+++ b/src/server/headless/tests/mod.rs
@@ -1804,7 +1804,7 @@ async fn deferred_worktree_response_moves_only_its_source_client() {
.get_mut(&51)
.unwrap()
.shell_deferred_navigation_response = Some(Vec::new());
- let response = serde_json::json!({
+ let mut response = serde_json::json!({
"id": "create-worktree",
"result": {
"type": "worktree_created",
@@ -1833,6 +1833,40 @@ async fn deferred_worktree_response_moves_only_its_source_client() {
);
assert!(server.focus_shell_client_on_tab(51, &original_tab_id));
+ response["result"]["type"] = serde_json::json!("worktree_opened");
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
+ .shell_endpoint_command_surface_revision = Some(source_surface_revision);
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
+ .shell_endpoint_command_in_flight = true;
+ server
+ .clients
+ .get_mut(&51)
+ .unwrap()
+ .shell_deferred_navigation_response = Some(Vec::new());
+ assert!(
+ server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady {
+ client_id: 51,
+ boot_id: server.client_shell_boot_id.clone(),
+ request_id: "open-worktree".into(),
+ final_chunk: true,
+ data: serde_json::to_vec(&response).unwrap(),
+ })
+ );
+ assert_eq!(
+ server.shell_tab_id_for_client(51).as_deref(),
+ Some(created_tab_id.as_str())
+ );
+ assert_eq!(
+ server.shell_tab_id_for_client(52).as_deref(),
+ Some(original_tab_id.as_str())
+ );
+ assert!(server.focus_shell_client_on_tab(51, &original_tab_id));
// A source command begun in the old presentation epoch may finish after source-off and
// source-on rollback. Its response remains endpoint-local, but it must not apply deferred
// client navigation to the restored source.
@@ -3143,6 +3177,219 @@ async fn client_shell_release_under_popup_renders_when_it_resets_scrollback() {
}
#[tokio::test]
+async fn worktree_discovery_does_not_block_client_typing() {
+ use api::schema::{Method, WorktreeListParams, WorktreeOpenParams};
+
+ let mut server = test_headless_server();
+ let mut workspace = crate::workspace::Workspace::test_new("worktree-input");
+ let pane_id = workspace.tabs[0].root_pane;
+ let (runtime, mut input_rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"", 4);
+ workspace.insert_test_runtime(pane_id, runtime);
+ let repo = std::env::temp_dir().join(format!("herdr-blocked-git-{}", workspace.id));
+ workspace.identity_cwd = repo.clone();
+ workspace.cached_git_space = Some(crate::workspace::GitSpaceMetadata {
+ key: repo.display().to_string(),
+ checkout_key: repo.display().to_string(),
+ repo_name: "blocked-git".into(),
+ repo_root: repo.clone(),
+ is_linked_worktree: false,
+ });
+ let workspace_id = workspace.id.clone();
+ server.app.state.workspaces = vec![workspace];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap();
+ server.clients.insert(
+ 11,
+ ClientConnection::new_with_mode(
+ ClientConnectionMode::ClientShell,
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(11);
+
+ for (linked, method) in [
+ (
+ false,
+ Method::WorktreeList(WorktreeListParams {
+ workspace_id: Some(workspace_id.clone()),
+ ..Default::default()
+ }),
+ ),
+ (
+ true,
+ Method::WorktreeList(WorktreeListParams {
+ workspace_id: Some(workspace_id.clone()),
+ ..Default::default()
+ }),
+ ),
+ (
+ false,
+ Method::WorktreeOpen(WorktreeOpenParams {
+ workspace_id: Some(workspace_id),
+ path: Some(repo.display().to_string()),
+ focus: true,
+ ..Default::default()
+ }),
+ ),
+ ] {
+ server.app.state.workspaces[0]
+ .cached_git_space
+ .as_mut()
+ .unwrap()
+ .is_linked_worktree = linked;
+ let (entered, release) = crate::worktree::test_list_gate::block(&repo);
+ let (respond_to, response_rx) = std::sync::mpsc::channel();
+ server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
+ request: api::schema::Request {
+ id: "blocked-read".into(),
+ method,
+ },
+ respond_to,
+ response_write_complete: None,
+ stream_active: None,
+ });
+ entered
+ .recv_timeout(Duration::from_secs(5))
+ .expect("Git discovery started");
+ assert!(response_rx.try_recv().is_err(), "Git must still be blocked");
+ server.handle_server_event(ServerEvent::ClientShellPaneInput {
+ client_id: 11,
+ pane_id: public_pane_id.clone(),
+ events: vec![protocol::ClientPaneInputEvent::TextCommit("x".into())],
+ });
+ assert_eq!(
+ input_rx
+ .try_recv()
+ .expect("typing reaches PTY while Git is blocked"),
+ Bytes::from_static(b"x")
+ );
+ release.send(()).unwrap();
+ let event = tokio::time::timeout(Duration::from_secs(5), server.app.event_rx.recv())
+ .await
+ .unwrap()
+ .unwrap();
+ server.handle_internal_event_with_forwarding(event);
+ let response: api::schema::ErrorResponse =
+ serde_json::from_str(&response_rx.recv_timeout(Duration::from_secs(5)).unwrap())
+ .unwrap();
+ assert_eq!(response.error.code, "worktree_list_failed");
+ }
+ shutdown_test_runtimes(&mut server);
+}
+
+#[tokio::test]
+async fn deferred_worktree_open_disconnect_keeps_other_clients_focus() {
+ let mut server = test_headless_server();
+ let mut source = crate::workspace::Workspace::test_new("pending-open-source");
+ let repo = std::env::temp_dir().join(format!("herdr-disconnected-open-{}", source.id));
+ let checkout = repo.with_extension("checkout");
+ let git = |args: &[&str]| {
+ let output = std::process::Command::new("git")
+ .args(args)
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ };
+ git(&["init", "--quiet", repo.to_str().unwrap()]);
+ git(&[
+ "-C",
+ repo.to_str().unwrap(),
+ "-c",
+ "user.name=Herdr Test",
+ "-c",
+ "user.email=herdr@example.invalid",
+ "commit",
+ "--quiet",
+ "--allow-empty",
+ "-m",
+ "initial",
+ ]);
+ git(&[
+ "-C",
+ repo.to_str().unwrap(),
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "pending-open",
+ checkout.to_str().unwrap(),
+ ]);
+ source.identity_cwd = repo.clone();
+ let source_id = source.id.clone();
+ let mut target = crate::workspace::Workspace::test_new("pending-open-target");
+ target.identity_cwd = checkout.clone();
+ server.app.state.workspaces = vec![source, target];
+ server.app.state.ensure_test_terminals();
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ let (source_control, _) = connect_matching_test_shell(&mut server, 51);
+ let (other_control, _) = connect_matching_test_shell(&mut server, 52);
+ let _ = source_control.recv().unwrap();
+ let _ = other_control.recv().unwrap();
+ let original_tab = server.shell_tab_id_for_client(52);
+
+ let (entered, release) = crate::worktree::test_list_gate::block(&repo);
+ server.handle_server_event(ServerEvent::ClientShellEndpointRequest {
+ client_id: 51,
+ boot_id: server.client_shell_boot_id.clone(),
+ request: Box::new(api::schema::Request {
+ id: "open-before-disconnect".into(),
+ method: api::schema::Method::WorktreeOpen(api::schema::WorktreeOpenParams {
+ workspace_id: Some(source_id),
+ branch: Some("pending-open".into()),
+ focus: true,
+ ..Default::default()
+ }),
+ }),
+ });
+ entered
+ .recv_timeout(Duration::from_secs(5))
+ .expect("Git started");
+ server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 51 });
+ release.send(()).unwrap();
+ tokio::time::timeout(Duration::from_secs(5), async {
+ loop {
+ let event = server.app.event_rx.recv().await.unwrap();
+ let completed = matches!(&event, AppEvent::WorktreeReadFinished(_));
+ server.handle_internal_event_with_forwarding(event);
+ if completed {
+ break;
+ }
+ }
+ })
+ .await
+ .unwrap();
+ assert!(
+ server.app.state.workspaces[1].worktree_space().is_some(),
+ "open completed successfully"
+ );
+ assert_eq!(
+ server.shell_tab_id_for_client(52),
+ original_tab,
+ "disconnected endpoint must not turn into public navigation"
+ );
+ shutdown_test_runtimes(&mut server);
+ git(&[
+ "-C",
+ repo.to_str().unwrap(),
+ "worktree",
+ "remove",
+ checkout.to_str().unwrap(),
+ ]);
+ let _ = std::fs::remove_dir_all(repo);
+}
+
+#[tokio::test]
async fn client_shell_text_input_renders_only_when_resetting_scrollback() {
let mut server = test_headless_server();
let mut workspace = crate::workspace::Workspace::test_new("scrolled-input");
diff --git a/src/worktree.rs b/src/worktree.rs
index 72cdb3d2..17f560a4 100644
--- a/src/worktree.rs
+++ b/src/worktree.rs
@@ -495,6 +495,8 @@ pub(crate) fn list_existing_worktrees(
repo_root: &Path,
trust_repository: bool,
) -> Result<Vec<ExistingWorktree>, String> {
+ #[cfg(test)]
+ test_list_gate::wait(repo_root);
let output = repository_git_command(repo_root, trust_repository)
.args(["worktree", "list", "--porcelain"])
.output()
@@ -513,6 +515,47 @@ pub(crate) fn list_existing_worktrees(
})
}
+#[cfg(test)]
+pub(crate) mod test_list_gate {
+ use std::path::{Path, PathBuf};
+ use std::sync::{mpsc, Mutex};
+
+ struct Gate {
+ path: PathBuf,
+ entered: mpsc::Sender<()>,
+ release: mpsc::Receiver<()>,
+ }
+
+ static GATES: Mutex<Vec<Gate>> = Mutex::new(Vec::new());
+
+ pub(crate) fn block(path: &Path) -> (mpsc::Receiver<()>, mpsc::Sender<()>) {
+ let (entered, entry_rx) = mpsc::channel();
+ let (release_tx, release) = mpsc::channel();
+ GATES.lock().unwrap().push(Gate {
+ path: super::canonical_or_original(path),
+ entered,
+ release,
+ });
+ (entry_rx, release_tx)
+ }
+
+ pub(super) fn wait(path: &Path) {
+ let gate = {
+ let mut gates = GATES.lock().unwrap();
+ gates
+ .iter()
+ .position(|gate| gate.path == super::canonical_or_original(path))
+ .map(|index| gates.remove(index))
+ };
+ if let Some(gate) = gate {
+ gate.entered.send(()).unwrap();
+ gate.release
+ .recv_timeout(std::time::Duration::from_secs(10))
+ .expect("test must release worktree discovery without blocking the server loop");
+ }
+ }
+}
+
fn worktree_list_contains_path(
repo_root: &Path,
path: &Path,