aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/worktrees.rs
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-02 18:08:10 +0200
committerGitHub <noreply@github.com>2026-09-02 18:08:10 +0200
commit5a2dee700eeeea68267a4d16777307632f77172f (patch)
tree3a7b2b0ead7af9cb1bfc069e5f61ce56a5f4909f /src/app/api/worktrees.rs
parent987b070fbfa187e85009b45cd7e208fc6175ff6a (diff)
fix(windows): remove worktrees with active agent panes (#3532)
* fix(windows): remove worktrees with active agent panes * fix(windows): defer pane restore until shutdown exits
Diffstat (limited to 'src/app/api/worktrees.rs')
-rw-r--r--src/app/api/worktrees.rs133
1 files changed, 129 insertions, 4 deletions
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs
index cf4ca3f9..fd7bb0fb 100644
--- a/src/app/api/worktrees.rs
+++ b/src/app/api/worktrees.rs
@@ -2232,6 +2232,112 @@ mod tests {
let _ = std::fs::remove_dir_all(repo);
}
+ #[tokio::test]
+ async fn failed_worktree_remove_preserves_and_restores_shutdown_pane() {
+ let checkout = unique_temp_path("api-worktree-remove-failure-checkout");
+ std::fs::create_dir_all(&checkout).unwrap();
+ let mut app = test_app();
+ app.state.default_shell = test_shell().into();
+ let mut workspace = Workspace::test_new("worktree");
+ workspace.identity_cwd = checkout.clone();
+ workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: "repo-key".into(),
+ label: "repo".into(),
+ repo_root: checkout.clone(),
+ checkout_path: checkout.clone(),
+ is_linked_worktree: true,
+ });
+ let workspace_id = workspace.id.clone();
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
+ let foreground = Workspace::test_new("foreground");
+ let foreground_id = foreground.id.clone();
+ app.state.workspaces = vec![workspace, foreground];
+ app.state.active = Some(1);
+ app.state.selected = 1;
+ app.state.ensure_test_terminals();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .set_detected_state(
+ Some(crate::detect::Agent::Codex),
+ crate::detect::AgentState::Working,
+ );
+ app.state.toast_config.delay_seconds = 1;
+ app.handle_internal_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(crate::detect::Agent::Codex),
+ state: crate::detect::AgentState::Blocked,
+ visible_blocker: true,
+ visible_working: false,
+ process_exited: false,
+ observed_at: std::time::Instant::now(),
+ });
+ assert!(app.state.pending_agent_notifications.contains_key(&pane_id));
+ let (runtime, _input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
+ app.terminal_runtimes.insert(terminal_id.clone(), runtime);
+
+ let shutdown_panes = app.shutdown_workspace_terminal_runtimes_for_worktree_remove(0);
+ assert_eq!(shutdown_panes, vec![pane_id]);
+
+ let checkout_key = crate::worktree::canonical_or_original(&checkout);
+ app.pending_api_worktree_removes
+ .insert(workspace_id.clone(), 7);
+ app.pending_api_worktree_remove_paths
+ .insert(checkout_key.clone(), 7);
+ let workspace_snapshot = app.workspace_info(0);
+ let worktree_snapshot = app
+ .worktree_info_for_membership(app.state.workspaces[0].worktree_space().unwrap(), None);
+ let (respond_to, response_rx) = response_channel();
+ let pane_updates = app.handle_api_worktree_remove_finished(WorktreeRemoveResult {
+ workspace_id,
+ path: checkout.clone(),
+ workspace: Some(Box::new(workspace_snapshot)),
+ worktree: Some(Box::new(worktree_snapshot)),
+ forced: false,
+ api_request: Some(ApiWorktreeRemoveRequest {
+ id: "req".into(),
+ operation_id: 7,
+ checkout_key,
+ shutdown_panes,
+ respond_to,
+ }),
+ result: Err("simulated remove failure".into()),
+ });
+ assert!(pane_updates.is_empty());
+
+ let response = response_rx
+ .recv_timeout(std::time::Duration::from_secs(2))
+ .unwrap();
+ let error: ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "worktree_remove_failed");
+ assert!(app.find_pane(pane_id).is_some());
+ assert!(app.terminal_runtimes.get(&terminal_id).is_none());
+ assert!(app
+ .pending_worktree_remove_runtime_restores
+ .contains_key(&pane_id));
+
+ let pane_updates =
+ app.handle_internal_event_with_pane_updates(AppEvent::PaneDied { pane_id });
+ assert!(matches!(
+ pane_updates.as_slice(),
+ [update] if update.agent_released && update.suppress_completion
+ ));
+ assert!(app.pending_worktree_remove_runtime_exits.is_empty());
+ assert!(app.pending_worktree_remove_runtime_restores.is_empty());
+ assert!(app.state.pending_agent_notifications.is_empty());
+ assert!(app.terminal_runtimes.get(&terminal_id).is_some());
+ assert_eq!(
+ app.state.active.map(|idx| &app.state.workspaces[idx].id),
+ Some(&foreground_id)
+ );
+ assert_eq!(app.state.workspaces[app.state.selected].id, foreground_id);
+
+ crate::app::api::test_support::shutdown_test_runtimes(&mut app);
+ let _ = std::fs::remove_dir_all(checkout);
+ }
+
#[test]
fn deferred_api_background_worktree_remove_preserves_focused_workspace() {
let mut app = test_app();
@@ -2253,10 +2359,15 @@ mod tests {
child.worktree_space = Some(membership.clone());
let foreground = Workspace::test_new("foreground");
let child_id = child.id.clone();
+ let child_pane_id = child.tabs[0].root_pane;
let foreground_id = foreground.id.clone();
app.state.workspaces = vec![parent, child, foreground];
app.state.active = Some(2);
app.state.selected = 2;
+ app.pending_worktree_remove_runtime_exits
+ .insert(child_pane_id, 1);
+ app.pending_worktree_remove_runtime_restores
+ .insert(child_pane_id, 7);
let workspace_snapshot = app.workspace_info(1);
let worktree_snapshot = app.worktree_info_for_membership(&membership, None);
app.pending_api_worktree_removes.insert(child_id.clone(), 7);
@@ -2264,7 +2375,7 @@ mod tests {
.insert(crate::worktree::canonical_or_original(&checkout), 7);
let (respond_to, response_rx) = response_channel();
- app.handle_api_worktree_remove_finished(WorktreeRemoveResult {
+ let _ = app.handle_api_worktree_remove_finished(WorktreeRemoveResult {
workspace_id: child_id,
path: checkout.clone(),
workspace: Some(Box::new(workspace_snapshot)),
@@ -2274,6 +2385,7 @@ mod tests {
id: "req".into(),
operation_id: 7,
checkout_key: crate::worktree::canonical_or_original(&checkout),
+ shutdown_panes: vec![child_pane_id],
respond_to,
}),
result: Ok(()),
@@ -2293,10 +2405,12 @@ mod tests {
Some(&foreground_id)
);
assert_eq!(app.state.workspaces[app.state.selected].id, foreground_id);
+ assert!(app.pending_worktree_remove_runtime_exits.is_empty());
+ assert!(app.pending_worktree_remove_runtime_restores.is_empty());
}
- #[test]
- fn deferred_api_worktree_remove_emits_removed_after_workspace_changes() {
+ #[tokio::test]
+ async fn deferred_api_worktree_remove_emits_removed_after_workspace_changes() {
let event_hub = crate::api::EventHub::default();
let mut app = test_app_with_event_hub(event_hub.clone());
let checkout = PathBuf::from("/repo/herdr-issue");
@@ -2309,13 +2423,19 @@ mod tests {
is_linked_worktree: true,
});
let child_id = child.id.clone();
+ let child_pane_id = child.tabs[0].root_pane;
+ let child_terminal_id = child.terminal_id(child_pane_id).cloned().unwrap();
app.state.workspaces.push(child);
+ app.state.ensure_test_terminals();
+ app.state.terminals.get_mut(&child_terminal_id).unwrap().cwd = checkout.clone();
let workspace_snapshot = app.workspace_info(0);
let worktree_snapshot = app
.worktree_info_for_membership(app.state.workspaces[0].worktree_space().unwrap(), None);
app.pending_api_worktree_removes.insert(child_id.clone(), 7);
app.pending_api_worktree_remove_paths
.insert(crate::worktree::canonical_or_original(&checkout), 7);
+ app.pending_worktree_remove_runtime_exits
+ .insert(child_pane_id, 1);
app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
key: "repo-key".into(),
label: "herdr".into(),
@@ -2325,7 +2445,7 @@ mod tests {
});
let (respond_to, response_rx) = response_channel();
- app.handle_api_worktree_remove_finished(WorktreeRemoveResult {
+ let _ = app.handle_api_worktree_remove_finished(WorktreeRemoveResult {
workspace_id: child_id,
path: checkout.clone(),
workspace: Some(Box::new(workspace_snapshot)),
@@ -2335,6 +2455,7 @@ mod tests {
id: "req".into(),
operation_id: 7,
checkout_key: crate::worktree::canonical_or_original(&checkout),
+ shutdown_panes: vec![child_pane_id],
respond_to,
}),
result: Ok(()),
@@ -2363,5 +2484,9 @@ mod tests {
.map(|membership| membership.checkout_path.as_path()),
Some(Path::new("/repo/other"))
);
+ assert_eq!(
+ app.state.terminals[&child_terminal_id].cwd,
+ PathBuf::from("/repo/other")
+ );
}
}