fix: defer api worktree operations

refs #686

refs #657

refs #662

refs https://github.com/ogulcancelik/herdr/discussions/687
This commit is contained in:
Ogulcan Celik 2026-06-19 18:59:43 +03:00
parent 26c5f97a90
commit 46a2b259a7
11 changed files with 1764 additions and 291 deletions

View File

@ -91,12 +91,12 @@ impl App {
}
if let AppEvent::WorktreeAddFinished(result) = ev {
self.handle_worktree_add_finished(result);
self.handle_worktree_add_finished(*result);
return;
}
if let AppEvent::WorktreeRemoveFinished(result) = ev {
self.handle_worktree_remove_finished(result);
self.handle_worktree_remove_finished(*result);
return;
}
@ -778,11 +778,21 @@ impl App {
}
Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params),
Method::WorktreeCreate(params) => {
return self.handle_worktree_create(request.id, params);
let _ = params;
return responses::encode_error(
request.id,
"invalid_request",
"worktree.create is handled asynchronously by the app runtime",
);
}
Method::WorktreeOpen(params) => return self.handle_worktree_open(request.id, params),
Method::WorktreeRemove(params) => {
return self.handle_worktree_remove(request.id, params);
let _ = params;
return responses::encode_error(
request.id,
"invalid_request",
"worktree.remove is handled asynchronously by the app runtime",
);
}
Method::TabList(params) => return self.handle_tab_list(request.id, params),
Method::TabGet(target) => return self.handle_tab_get(request.id, target),

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,566 @@
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::api::schema::{
EventData, EventEnvelope, EventKind, Request, ResponseResult, WorktreeCreateParams,
WorktreeRemoveParams,
};
use crate::app::App;
use crate::events::{ApiWorktreeAddRequest, ApiWorktreeRemoveRequest, AppEvent};
use super::super::responses::{encode_error, encode_success};
use super::{absolute_user_path, WorktreeSource};
impl App {
pub(crate) fn handle_deferred_worktree_api_request(
&mut self,
request: Request,
respond_to: std::sync::mpsc::Sender<String>,
) -> bool {
match request.method {
crate::api::schema::Method::WorktreeCreate(params) => {
self.start_api_worktree_create(request.id, params, respond_to);
true
}
crate::api::schema::Method::WorktreeRemove(params) => {
self.start_api_worktree_remove(request.id, params, respond_to);
true
}
_ => false,
}
}
fn send_api_response(respond_to: std::sync::mpsc::Sender<String>, response: String) {
let _ = respond_to.send(response);
}
fn next_api_worktree_operation_id(&mut self) -> u64 {
let id = self.next_api_worktree_operation_id;
self.next_api_worktree_operation_id = self.next_api_worktree_operation_id.saturating_add(1);
id
}
fn api_create_source_workspace_idx(&self, api: &ApiWorktreeAddRequest) -> Option<usize> {
let Some(source_workspace_id) = api.source_workspace_id.as_ref() else {
return self.find_parent_workspace_by_key(&api.repo_key);
};
let Some(ws_idx) = self
.state
.workspaces
.iter()
.position(|ws| &ws.id == source_workspace_id)
else {
return self.find_parent_workspace_by_key(&api.repo_key);
};
let workspace = &self.state.workspaces[ws_idx];
if let Some(expected) = api.source_existing_membership.as_ref() {
if workspace.worktree_space() == Some(expected) {
return Some(ws_idx);
}
return self.find_parent_workspace_by_key(&api.repo_key);
}
if let Some(current) = workspace.worktree_space() {
let expected = crate::workspace::WorktreeSpaceMembership {
key: api.repo_key.clone(),
label: api.repo_name.clone(),
repo_root: api.source_repo_root.clone(),
checkout_path: api.source_checkout_path.clone(),
is_linked_worktree: false,
};
if current == &expected {
return Some(ws_idx);
}
return self.find_parent_workspace_by_key(&api.repo_key);
}
let git_space = workspace.git_space().cloned().or_else(|| {
workspace
.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
.as_deref()
.and_then(crate::workspace::git_space_metadata)
});
if git_space.is_some_and(|space| {
!space.is_linked_worktree
&& space.key == api.repo_key
&& crate::worktree::canonical_or_original(&space.repo_root)
== crate::worktree::canonical_or_original(&api.source_repo_root)
}) {
Some(ws_idx)
} else {
self.find_parent_workspace_by_key(&api.repo_key)
}
}
fn start_api_worktree_create(
&mut self,
id: String,
params: WorktreeCreateParams,
respond_to: std::sync::mpsc::Sender<String>,
) {
let branch = params
.branch
.unwrap_or_else(|| {
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0);
crate::worktree::generated_branch_slug(seed)
})
.trim()
.to_string();
if branch.is_empty() {
Self::send_api_response(
respond_to,
encode_error(id, "invalid_request", "branch is required"),
);
return;
}
let base = params.base.unwrap_or_else(|| "HEAD".into());
let source = match self.resolve_worktree_source(params.workspace_id, params.cwd) {
Ok(source) => source,
Err(err) => {
Self::send_api_response(respond_to, encode_error(id, err.code, err.message));
return;
}
};
let checkout_path = match params.path {
Some(path) => match absolute_user_path(&path) {
Ok(path) => path,
Err(err) => {
Self::send_api_response(respond_to, encode_error(id, err.code, err.message));
return;
}
},
None => crate::worktree::default_checkout_path(
&self.state.worktree_directory,
&source.repo_name,
&branch,
),
};
let checkout_key = crate::worktree::canonical_or_original(&checkout_path);
if self
.pending_api_worktree_creates
.contains_key(&checkout_key)
|| self
.pending_api_worktree_remove_paths
.contains_key(&checkout_key)
{
Self::send_api_response(
respond_to,
encode_error(
id,
"worktree_operation_in_progress",
"worktree operation is already in progress for this checkout",
),
);
return;
}
let operation_id = self.next_api_worktree_operation_id();
self.pending_api_worktree_creates
.insert(checkout_key.clone(), operation_id);
let command = crate::worktree::build_worktree_add_new_branch_command(
&source.source_checkout_path,
&checkout_path,
&branch,
&base,
);
let parent_dir = checkout_path.parent().map(Path::to_path_buf);
let source_workspace_id = source
.workspace_idx
.and_then(|idx| self.state.workspaces.get(idx).map(|ws| ws.id.clone()));
let source_existing_membership = source_workspace_id.as_ref().and_then(|workspace_id| {
self.state
.workspaces
.iter()
.find(|ws| &ws.id == workspace_id)
.and_then(|ws| ws.worktree_space().cloned())
});
let api_request = ApiWorktreeAddRequest {
id,
operation_id,
checkout_key,
source_workspace_id,
source_existing_membership,
source_checkout_path: source.source_checkout_path,
source_repo_root: source.source_repo_root,
repo_key: source.repo_key,
repo_name: source.repo_name,
label: params.label,
focus: params.focus,
respond_to,
};
let path = checkout_path;
let event_tx = self.event_tx.clone();
std::thread::spawn(move || {
let result = if let Some(parent_dir) = parent_dir {
std::fs::create_dir_all(&parent_dir)
.map_err(|err| err.to_string())
.and_then(|()| crate::worktree::run_worktree_command(&command))
} else {
crate::worktree::run_worktree_command(&command)
};
let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new(
crate::events::WorktreeAddResult {
path,
api_request: Some(api_request),
result,
},
)));
});
}
fn start_api_worktree_remove(
&mut self,
id: String,
params: WorktreeRemoveParams,
respond_to: std::sync::mpsc::Sender<String>,
) {
let Some(ws_idx) = self.parse_workspace_id(&params.workspace_id) else {
Self::send_api_response(
respond_to,
encode_error(
id,
"workspace_not_found",
format!("workspace {} not found", params.workspace_id),
),
);
return;
};
let Some(space) = self
.state
.workspaces
.get(ws_idx)
.and_then(|ws| ws.worktree_space().cloned())
else {
Self::send_api_response(
respond_to,
encode_error(
id,
"not_linked_worktree",
"workspace is not a Herdr-managed worktree checkout",
),
);
return;
};
if !space.is_linked_worktree {
Self::send_api_response(
respond_to,
encode_error(
id,
"not_linked_worktree",
"workspace is not a linked worktree checkout",
),
);
return;
}
#[cfg(windows)]
{
if !params.force
&& crate::worktree::checkout_has_dirty_files(&space.checkout_path).unwrap_or(false)
{
Self::send_api_response(
respond_to,
encode_error(
id,
"dirty_worktree_requires_force",
crate::worktree::worktree_dirty_remove_message(&space.checkout_path),
),
);
return;
}
}
let workspace_internal_id = self.state.workspaces[ws_idx].id.clone();
let checkout_key = crate::worktree::canonical_or_original(&space.checkout_path);
if self
.pending_api_worktree_removes
.contains_key(&workspace_internal_id)
|| self
.pending_api_worktree_remove_paths
.contains_key(&checkout_key)
|| self
.pending_api_worktree_creates
.contains_key(&checkout_key)
{
Self::send_api_response(
respond_to,
encode_error(
id,
"worktree_operation_in_progress",
"worktree operation is already in progress for this checkout",
),
);
return;
}
if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(params.force) {
self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx);
}
let operation_id = self.next_api_worktree_operation_id();
self.pending_api_worktree_removes
.insert(workspace_internal_id.clone(), operation_id);
self.pending_api_worktree_remove_paths
.insert(checkout_key.clone(), operation_id);
let workspace_snapshot = self.workspace_info(ws_idx);
let worktree = self.worktree_info_for_membership(&space, None);
let command = crate::worktree::build_worktree_remove_command(
&space.repo_root,
&space.checkout_path,
params.force,
);
let api_request = ApiWorktreeRemoveRequest {
id,
operation_id,
checkout_key,
respond_to,
};
let repo_root = space.repo_root;
let path = space.checkout_path;
let force = params.force;
let event_tx = self.event_tx.clone();
std::thread::spawn(move || {
let result = crate::worktree::run_worktree_remove_command_with_recovery(
&command, &repo_root, &path, force,
);
let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new(
crate::events::WorktreeRemoveResult {
workspace_id: workspace_internal_id,
path,
workspace: Some(Box::new(workspace_snapshot)),
worktree: Some(Box::new(worktree)),
forced: force,
api_request: Some(api_request),
result,
},
)));
});
}
pub(crate) fn handle_api_worktree_add_finished(
&mut self,
mut result: crate::events::WorktreeAddResult,
) {
let Some(api) = result.api_request.take() else {
return;
};
let checkout_key = api.checkout_key.clone();
let operation_matches = self
.pending_api_worktree_creates
.get(&checkout_key)
.is_some_and(|operation_id| *operation_id == api.operation_id);
if !operation_matches {
Self::send_api_response(
api.respond_to,
encode_error(
api.id,
"stale_worktree_operation",
"worktree create completed after the operation was superseded",
),
);
return;
}
self.pending_api_worktree_creates.remove(&checkout_key);
if let Err(err) = result.result {
Self::send_api_response(
api.respond_to,
encode_error(api.id, "worktree_create_failed", err),
);
return;
}
let source_workspace_idx = self.api_create_source_workspace_idx(&api);
let mut source = WorktreeSource {
workspace_idx: source_workspace_idx,
source_checkout_path: api.source_checkout_path,
source_repo_root: api.source_repo_root,
repo_key: api.repo_key,
repo_name: api.repo_name,
};
if let Err(err) = self.ensure_source_parent_membership(&mut source, true) {
Self::send_api_response(api.respond_to, encode_error(api.id, err.code, err.message));
return;
}
let (ws_idx, created_workspace) =
if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&result.path) {
if api.focus {
self.state.switch_workspace(ws_idx);
}
(ws_idx, false)
} else {
match self.create_workspace_with_options(result.path.clone(), api.focus) {
Ok(ws_idx) => (ws_idx, true),
Err(err) => {
Self::send_api_response(
api.respond_to,
encode_error(
api.id,
"worktree_open_failed",
format!("created worktree but failed to open workspace: {err}"),
),
);
return;
}
}
};
self.mark_worktree_membership(
&source,
ws_idx,
result.path.clone(),
true,
!created_workspace,
);
if let Some(label) = api.label {
if let Some(ws) = self.state.workspaces.get_mut(ws_idx) {
ws.set_custom_name(label);
}
}
self.state.mark_session_dirty();
if created_workspace {
self.emit_workspace_open_events(ws_idx);
}
let Some(worktree) = self.worktree_info_for_workspace(ws_idx) else {
Self::send_api_response(
api.respond_to,
encode_error(
api.id,
"worktree_open_failed",
"created worktree but failed to record workspace membership",
),
);
return;
};
self.emit_worktree_created_event(ws_idx, worktree.clone());
let tab_idx = self.state.workspaces[ws_idx].active_tab;
let response = encode_success(
api.id,
ResponseResult::WorktreeCreated {
workspace: self.workspace_info(ws_idx),
tab: self
.tab_info(ws_idx, tab_idx)
.expect("created worktree workspace should have an active tab"),
root_pane: self
.root_pane_info(ws_idx, tab_idx)
.expect("created worktree workspace should have an active root pane"),
worktree,
},
);
Self::send_api_response(api.respond_to, response);
}
pub(crate) fn handle_api_worktree_remove_finished(
&mut self,
mut result: crate::events::WorktreeRemoveResult,
) {
let Some(api) = result.api_request.take() else {
return;
};
let operation_matches = self
.pending_api_worktree_removes
.get(&result.workspace_id)
.is_some_and(|operation_id| *operation_id == api.operation_id)
&& self
.pending_api_worktree_remove_paths
.get(&api.checkout_key)
.is_some_and(|operation_id| *operation_id == api.operation_id);
if !operation_matches {
Self::send_api_response(
api.respond_to,
encode_error(
api.id,
"stale_worktree_operation",
"worktree remove completed after the operation was superseded",
),
);
return;
}
self.pending_api_worktree_removes
.remove(&result.workspace_id);
self.pending_api_worktree_remove_paths
.remove(&api.checkout_key);
if let Err(message) = result.result {
let code =
if !result.forced && crate::worktree::is_dirty_worktree_remove_error(&message) {
"dirty_worktree_requires_force"
} else {
"worktree_remove_failed"
};
Self::send_api_response(api.respond_to, encode_error(api.id, code, message));
return;
}
let mut workspace_id = result.workspace_id.clone();
let mut workspace_snapshot = result.workspace.as_deref().cloned();
let mut worktree = result.worktree.as_deref().cloned();
if let Some(ws_idx) = self
.state
.workspaces
.iter()
.position(|ws| ws.id == result.workspace_id)
{
let current_matches =
self.state.workspaces[ws_idx]
.worktree_space()
.is_some_and(|space| {
space.is_linked_worktree && space.checkout_path == result.path
});
if current_matches {
workspace_id = self.public_workspace_id(ws_idx);
workspace_snapshot.get_or_insert_with(|| self.workspace_info(ws_idx));
if worktree.is_none() {
worktree = self.state.workspaces[ws_idx]
.worktree_space()
.cloned()
.map(|space| self.worktree_info_for_membership(&space, None));
}
self.state.selected = ws_idx;
self.state.close_selected_workspace();
self.shutdown_detached_terminal_runtimes();
self.emit_event(EventEnvelope {
event: EventKind::WorkspaceClosed,
data: EventData::WorkspaceClosed {
workspace_id: workspace_id.clone(),
workspace: workspace_snapshot.clone(),
},
});
} else if let Some(snapshot) = workspace_snapshot.as_ref() {
workspace_id = snapshot.workspace_id.clone();
}
} else if let Some(snapshot) = workspace_snapshot.as_ref() {
workspace_id = snapshot.workspace_id.clone();
}
let Some(worktree) = worktree else {
Self::send_api_response(
api.respond_to,
encode_error(
api.id,
"worktree_remove_failed",
"removed worktree but lost worktree snapshot",
),
);
return;
};
self.emit_worktree_removed_event(
workspace_id.clone(),
workspace_snapshot,
worktree,
result.forced,
);
let response = encode_success(
api.id,
ResponseResult::WorktreeRemoved {
workspace_id,
path: result.path.display().to_string(),
forced: result.forced,
},
);
Self::send_api_response(api.respond_to, response);
}
}

View File

@ -109,6 +109,10 @@ pub struct App {
pub(crate) git_refresh_in_flight: bool,
pub(crate) git_refresh_due_after_in_flight: 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) pending_api_worktree_removes: HashMap<String, u64>,
pub(crate) pending_api_worktree_remove_paths: HashMap<std::path::PathBuf, u64>,
pub(crate) next_api_worktree_operation_id: u64,
pub(crate) last_sidebar_divider_click: Option<Instant>,
pub(crate) last_pane_click: Option<PaneClickState>,
pub(crate) next_resize_poll: Instant,
@ -687,6 +691,10 @@ impl App {
git_refresh_in_flight: false,
git_refresh_due_after_in_flight: false,
git_status_cache: HashMap::new(),
pending_api_worktree_creates: HashMap::new(),
pending_api_worktree_removes: HashMap::new(),
pending_api_worktree_remove_paths: HashMap::new(),
next_api_worktree_operation_id: 1,
last_sidebar_divider_click: None,
last_pane_click: None,
next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL,

View File

@ -68,6 +68,20 @@ impl App {
crate::api::schema::Method::ServerStop(_)
| crate::api::schema::Method::ServerLiveHandoff(_)
);
if matches!(
&msg.request.method,
crate::api::schema::Method::WorktreeCreate(_)
| crate::api::schema::Method::WorktreeRemove(_)
) {
self.drain_all_internal_events();
let deferred_changed =
self.handle_deferred_worktree_api_request(msg.request, msg.respond_to);
if !skip_default_workspace {
changed |= self.ensure_default_workspace();
}
self.sync_prefix_input_source(previous_mode);
return changed | deferred_changed;
}
let response = self.handle_api_request(msg.request);
if !skip_default_workspace {
changed |= self.ensure_default_workspace();

View File

@ -547,10 +547,13 @@ impl App {
} else {
crate::worktree::run_worktree_command(&command)
};
let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(WorktreeAddResult {
path,
result,
}));
let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new(
WorktreeAddResult {
path,
api_request: None,
result,
},
)));
});
}
@ -604,14 +607,15 @@ impl App {
return;
};
#[cfg(windows)]
if let Some(ws_idx) = self
.state
.workspaces
.iter()
.position(|ws| ws.id == workspace_id)
{
self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx);
if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(force) {
if let Some(ws_idx) = self
.state
.workspaces
.iter()
.position(|ws| ws.id == workspace_id)
{
self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx);
}
}
let (workspace_snapshot, worktree_snapshot) = self
@ -633,20 +637,28 @@ impl App {
tracing::info!(workspace_id = %workspace_id, path = %path.display(), force, "starting git worktree remove");
let event_tx = self.event_tx.clone();
std::thread::spawn(move || {
let result = crate::worktree::run_worktree_command(&command);
let _ =
event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(WorktreeRemoveResult {
let result = crate::worktree::run_worktree_remove_command_with_recovery(
&command, &repo_root, &path, force,
);
let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new(
WorktreeRemoveResult {
workspace_id,
path,
workspace: workspace_snapshot,
worktree: worktree_snapshot,
forced: force,
api_request: None,
result,
}));
},
)));
});
}
pub(crate) fn handle_worktree_add_finished(&mut self, result: WorktreeAddResult) {
if result.api_request.is_some() {
self.handle_api_worktree_add_finished(result);
return;
}
let Some(create) = &mut self.state.worktree_create else {
return;
};
@ -741,6 +753,10 @@ impl App {
}
}
pub(crate) fn handle_worktree_remove_finished(&mut self, result: WorktreeRemoveResult) {
if result.api_request.is_some() {
self.handle_api_worktree_remove_finished(result);
return;
}
let Some(remove) = &mut self.state.worktree_remove else {
return;
};
@ -823,7 +839,12 @@ impl App {
}
}
#[cfg(windows)]
pub(crate) fn should_shutdown_workspace_terminal_runtimes_for_worktree_remove(
force: bool,
) -> bool {
force || cfg!(windows)
}
pub(crate) fn shutdown_workspace_terminal_runtimes_for_worktree_remove(
&mut self,
ws_idx: usize,
@ -833,7 +854,7 @@ impl App {
tracing::debug!(
workspace_index = ws_idx,
terminal_id = %terminal_id,
"shutting down terminal runtime before Windows worktree removal"
"shutting down terminal runtime before worktree removal"
);
runtime.shutdown();
}
@ -1446,6 +1467,7 @@ mod tests {
app.handle_worktree_add_finished(WorktreeAddResult {
path: checkout.clone(),
api_request: None,
result: Ok(()),
});
@ -1539,6 +1561,7 @@ mod tests {
app.handle_worktree_add_finished(WorktreeAddResult {
path: checkout.clone(),
api_request: None,
result: Ok(()),
});
@ -1594,6 +1617,7 @@ mod tests {
let event = wait_for_worktree_event(&mut app);
match event {
AppEvent::WorktreeAddFinished(result) => {
let result = *result;
assert_eq!(result.path, checkout);
assert_eq!(result.result, Ok(()));
}
@ -1607,6 +1631,55 @@ mod tests {
let _ = std::fs::remove_dir_all(repo);
}
#[test]
fn start_worktree_add_existing_branch_clears_creating_and_shows_fatal_error() {
let repo = create_committed_repo("app-worktree-add-existing-branch-repo");
let worktree_root = unique_temp_path("app-worktree-add-existing-branch-root");
let branch = "foo";
let checkout = crate::worktree::default_checkout_path(&worktree_root, "herdr", branch);
run_git(&repo, &["branch", branch]);
let mut app = app_for_worktree_tests();
app.state.worktree_directory = worktree_root.clone();
app.state.name_input = branch.into();
app.state.worktree_create = Some(WorktreeCreateState {
source_workspace_id: "source".into(),
source_checkout_path: repo.clone(),
source_existing_membership: None,
source_repo_root: repo.clone(),
repo_key: "repo-key".into(),
repo_name: "herdr".into(),
branch: branch.into(),
checkout_path: checkout.clone(),
error: None,
creating: false,
});
app.start_worktree_add();
assert!(app
.state
.worktree_create
.as_ref()
.is_some_and(|create| create.creating));
let event = wait_for_worktree_event(&mut app);
match event {
AppEvent::WorktreeAddFinished(result) => {
app.handle_worktree_add_finished(*result);
}
other => panic!("unexpected event: {other:?}"),
}
let create = app.state.worktree_create.as_ref().unwrap();
assert!(!create.creating);
let error = create.error.as_deref().unwrap();
assert!(error.contains("Preparing worktree"));
assert!(error.contains("fatal: a branch named 'foo' already exists"));
assert!(!checkout.exists());
let _ = std::fs::remove_dir_all(worktree_root);
let _ = std::fs::remove_dir_all(repo);
}
#[test]
fn open_new_worktree_dialog_supports_standalone_bare_repo_source() {
let repo = create_committed_repo("app-worktree-dialog-bare-origin");
@ -1641,6 +1714,7 @@ mod tests {
let event = wait_for_worktree_event(&mut app);
match event {
AppEvent::WorktreeAddFinished(result) => {
let result = *result;
assert_eq!(result.path, checkout);
assert_eq!(result.result, Ok(()));
}
@ -1700,6 +1774,7 @@ mod tests {
let event = wait_for_worktree_event(&mut app);
match event {
AppEvent::WorktreeAddFinished(result) => {
let result = *result;
assert_eq!(result.path, checkout);
assert_eq!(result.result, Ok(()));
}
@ -1735,6 +1810,7 @@ mod tests {
workspace: None,
worktree: None,
forced: false,
api_request: None,
result: Err(
"fatal: '/w/herdr/dirty' contains modified or untracked files, use --force to delete it"
.into(),
@ -1766,6 +1842,7 @@ mod tests {
workspace: None,
worktree: None,
forced: false,
api_request: None,
result: Err("fatal: '/w/herdr/missing' is not a working tree".into()),
});
@ -1819,6 +1896,7 @@ mod tests {
workspace: Some(Box::new(workspace_snapshot.clone())),
worktree: Some(Box::new(worktree_snapshot)),
forced: true,
api_request: None,
result: Ok(()),
});
@ -1882,6 +1960,7 @@ mod tests {
let safe_event = wait_for_worktree_event(&mut app);
match safe_event {
AppEvent::WorktreeRemoveFinished(result) => {
let result = *result;
assert_eq!(result.workspace_id, workspace_id);
assert_eq!(result.path, checkout);
assert!(result.result.is_err());
@ -1900,6 +1979,7 @@ mod tests {
let force_event = wait_for_worktree_event(&mut app);
match force_event {
AppEvent::WorktreeRemoveFinished(result) => {
let result = *result;
assert_eq!(result.workspace_id, workspace_id);
assert_eq!(result.path, checkout);
assert_eq!(result.result, Ok(()));
@ -1929,4 +2009,13 @@ mod tests {
let _ = std::fs::remove_dir_all(repo);
}
#[test]
fn worktree_remove_runtime_shutdown_policy_preserves_windows_safe_remove() {
assert_eq!(
App::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(false),
cfg!(windows)
);
assert!(App::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(true));
}
}

View File

@ -9,12 +9,37 @@ use crate::detect::{Agent, AgentState};
use crate::layout::PaneId;
use crate::workspace::{GitStatusCacheEntry, WorkspaceGitStatus};
#[derive(Debug)]
pub struct ApiWorktreeAddRequest {
pub id: String,
pub operation_id: u64,
pub checkout_key: std::path::PathBuf,
pub source_workspace_id: Option<String>,
pub source_existing_membership: Option<crate::workspace::WorktreeSpaceMembership>,
pub source_checkout_path: std::path::PathBuf,
pub source_repo_root: std::path::PathBuf,
pub repo_key: String,
pub repo_name: String,
pub label: Option<String>,
pub focus: bool,
pub respond_to: std::sync::mpsc::Sender<String>,
}
#[derive(Debug)]
pub struct WorktreeAddResult {
pub path: std::path::PathBuf,
pub api_request: Option<ApiWorktreeAddRequest>,
pub result: Result<(), String>,
}
#[derive(Debug)]
pub struct ApiWorktreeRemoveRequest {
pub id: String,
pub operation_id: u64,
pub checkout_key: std::path::PathBuf,
pub respond_to: std::sync::mpsc::Sender<String>,
}
#[derive(Debug)]
pub struct WorktreeRemoveResult {
pub workspace_id: String,
@ -22,6 +47,7 @@ pub struct WorktreeRemoveResult {
pub workspace: Option<Box<crate::api::schema::WorkspaceInfo>>,
pub worktree: Option<Box<crate::api::schema::WorktreeInfo>>,
pub forced: bool,
pub api_request: Option<ApiWorktreeRemoveRequest>,
pub result: Result<(), String>,
}
@ -125,7 +151,7 @@ pub enum AppEvent {
error: Option<String>,
},
/// Background `git worktree add` completed.
WorktreeAddFinished(WorktreeAddResult),
WorktreeAddFinished(Box<WorktreeAddResult>),
/// Background `git worktree remove` completed.
WorktreeRemoveFinished(WorktreeRemoveResult),
WorktreeRemoveFinished(Box<WorktreeRemoveResult>),
}

View File

@ -2611,6 +2611,15 @@ impl HeadlessServer {
};
self.sync_foreground_client_state();
if matches!(
&msg.request.method,
api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_)
) {
let deferred_changed = self
.app
.handle_deferred_worktree_api_request(msg.request, msg.respond_to);
return changed | deferred_changed;
}
let response = if matches!(
&msg.request.method,
api::schema::Method::ServerReloadConfig(_)

View File

@ -2,7 +2,7 @@ use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Clear, Paragraph},
widgets::{Clear, Paragraph, Wrap},
Frame,
};
@ -12,6 +12,9 @@ use super::widgets::{
};
use crate::app::{state::WorktreeOpenState, AppState, Mode};
const NEW_LINKED_WORKTREE_POPUP_WIDTH: u16 = 68;
const NEW_LINKED_WORKTREE_POPUP_HEIGHT: u16 = 12;
fn truncate_text(text: &str, max_width: usize) -> String {
let len = text.chars().count();
if len <= max_width {
@ -126,7 +129,12 @@ pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rec
}
pub(crate) fn new_linked_worktree_inner_rect(area: Rect) -> Option<Rect> {
centered_popup_rect(area, 68, 10).map(|popup| {
centered_popup_rect(
area,
NEW_LINKED_WORKTREE_POPUP_WIDTH,
NEW_LINKED_WORKTREE_POPUP_HEIGHT,
)
.map(|popup| {
Rect::new(
popup.x + 1,
popup.y + 1,
@ -240,10 +248,16 @@ pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Fra
};
super::dim_background(frame, area);
let Some(inner) = render_modal_shell(frame, area, 68, 10, &app.palette) else {
let Some(inner) = render_modal_shell(
frame,
area,
NEW_LINKED_WORKTREE_POPUP_WIDTH,
NEW_LINKED_WORKTREE_POPUP_HEIGHT,
&app.palette,
) else {
return;
};
if inner.height < 7 {
if inner.height < 9 {
return;
}
@ -253,7 +267,7 @@ pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Fra
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(1),
Constraint::Min(0),
])
@ -293,7 +307,9 @@ pub(super) fn render_new_linked_worktree_overlay(app: &AppState, frame: &mut Fra
);
} else if let Some(error) = &create.error {
frame.render_widget(
Paragraph::new(format!(" {error}")).style(Style::default().fg(app.palette.red)),
Paragraph::new(format!(" {error}"))
.style(Style::default().fg(app.palette.red))
.wrap(Wrap { trim: false }),
rows[5],
);
}
@ -755,9 +771,13 @@ pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) {
#[cfg(test)]
mod tests {
use crate::{app::AppState, workspace::Workspace};
use crate::{
app::{state::WorktreeCreateState, AppState},
workspace::Workspace,
};
use ratatui::{backend::TestBackend, layout::Rect, Terminal};
use super::confirm_close_overlay_text;
use super::{confirm_close_overlay_text, render_new_linked_worktree_overlay};
#[test]
fn confirm_close_text_reports_parent_group_scope() {
@ -786,4 +806,52 @@ mod tests {
assert_eq!(title, "Close worktree group?");
assert_eq!(detail, "main — 2 workspaces, 2 panes");
}
#[test]
fn new_worktree_error_renders_fatal_stderr_line() {
let mut app = AppState::test_new();
app.name_input = "foo".into();
app.worktree_create = Some(WorktreeCreateState {
source_workspace_id: "source".into(),
source_checkout_path: "/repo/herdr".into(),
source_existing_membership: None,
source_repo_root: "/repo/herdr".into(),
repo_key: "repo-key".into(),
repo_name: "herdr".into(),
branch: "foo".into(),
checkout_path: "/repo/.worktrees/herdr/foo".into(),
error: Some(
"Preparing worktree (new branch 'foo')\nfatal: a branch named 'foo' already exists"
.into(),
),
creating: false,
});
let mut terminal =
Terminal::new(TestBackend::new(100, 30)).expect("test terminal should initialize");
terminal
.draw(|frame| render_new_linked_worktree_overlay(&app, frame, Rect::new(0, 0, 100, 30)))
.expect("new worktree overlay should render");
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("fatal: a branch named 'foo' already exists"));
}
#[test]
fn new_worktree_hit_test_geometry_matches_modal_size() {
let area = Rect::new(0, 0, 100, 30);
let inner = super::new_linked_worktree_inner_rect(area).unwrap();
let (create, cancel) = super::new_linked_worktree_button_rects(inner);
assert_eq!(inner.width, super::NEW_LINKED_WORKTREE_POPUP_WIDTH - 2);
assert_eq!(inner.height, super::NEW_LINKED_WORKTREE_POPUP_HEIGHT - 2);
assert_eq!(create.y, inner.y + inner.height - 1);
assert_eq!(cancel.y, inner.y + inner.height - 1);
}
}

View File

@ -183,6 +183,11 @@ pub(crate) fn is_dirty_worktree_remove_error(message: &str) -> bool {
&& lower.contains("use --force to delete it")
}
pub(crate) fn is_not_working_tree_remove_error(message: &str) -> bool {
let lower = message.to_ascii_lowercase();
lower.contains("is not a working tree") || lower.contains("is not a worktree")
}
#[cfg(windows)]
pub(crate) fn worktree_dirty_remove_message(path: &Path) -> String {
format!(
@ -261,6 +266,82 @@ pub(crate) fn run_worktree_command(command: &WorktreeCommand) -> Result<(), Stri
})
}
pub(crate) fn run_worktree_remove_command_with_recovery(
command: &WorktreeCommand,
repo_root: &Path,
path: &Path,
force: bool,
) -> Result<(), String> {
match run_worktree_command(command) {
Ok(()) => Ok(()),
Err(err) if force && is_not_working_tree_remove_error(&err) => {
if worktree_list_contains_path(repo_root, path)? {
return Err(err);
}
if path.exists() {
if !leftover_worktree_checkout_matches_repo(repo_root, path) {
return Err(err);
}
std::fs::remove_dir_all(path).map_err(|remove_err| {
format!(
"{err}; failed to remove leftover checkout {}: {remove_err}",
path.display()
)
})?;
}
Ok(())
}
Err(err) => Err(err),
}
}
fn leftover_worktree_checkout_matches_repo(repo_root: &Path, path: &Path) -> bool {
let git_file = path.join(".git");
let Ok(content) = std::fs::read_to_string(&git_file) else {
return false;
};
let Some(gitdir) = content.trim().strip_prefix("gitdir:") else {
return false;
};
let gitdir = PathBuf::from(gitdir.trim());
let gitdir = if gitdir.is_absolute() {
gitdir
} else {
path.join(gitdir)
};
let Some(worktrees_dir) = git_common_worktrees_dir(repo_root) else {
return false;
};
canonical_or_original(&gitdir).starts_with(canonical_or_original(&worktrees_dir))
}
fn git_common_worktrees_dir(repo_root: &Path) -> Option<PathBuf> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["rev-parse", "--git-common-dir"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let common_dir = stdout.trim();
if common_dir.is_empty() {
None
} else {
let common_dir = PathBuf::from(common_dir);
let common_dir = if common_dir.is_absolute() {
common_dir
} else {
repo_root.join(common_dir)
};
Some(common_dir.join("worktrees"))
}
}
pub(crate) fn parse_worktree_list_porcelain(output: &str) -> Vec<ExistingWorktree> {
let mut entries = Vec::new();
let mut path: Option<PathBuf> = None;
@ -351,6 +432,13 @@ pub(crate) fn list_existing_worktrees(repo_root: &Path) -> Result<Vec<ExistingWo
})
}
pub(crate) fn worktree_list_contains_path(repo_root: &Path, path: &Path) -> Result<bool, String> {
let expected = canonical_or_original(path);
Ok(list_existing_worktrees(repo_root)?
.into_iter()
.any(|entry| canonical_or_original(&entry.path) == expected))
}
#[cfg(test)]
mod tests {
use super::*;
@ -695,4 +783,51 @@ prunable stale
let _ = std::fs::remove_dir_all(repo);
}
#[test]
fn forced_worktree_remove_recovers_leftover_unregistered_checkout() {
let repo = create_committed_repo("worktree-recovery-repo");
let checkout = unique_temp_path("worktree-recovery-checkout");
let branch = "worktree/recovery";
let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD");
run_worktree_command(&add).unwrap();
let remove = build_worktree_remove_command(&repo, &checkout, true);
run_worktree_command(&remove).unwrap();
std::fs::create_dir_all(&checkout).unwrap();
let stale_admin_dir = git_common_worktrees_dir(&repo).unwrap().join("stale");
std::fs::write(
checkout.join(".git"),
format!("gitdir: {}\n", stale_admin_dir.display()),
)
.unwrap();
std::fs::write(checkout.join("leftover"), "leftover\n").unwrap();
run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true).unwrap();
assert!(!checkout.exists());
let _ = std::fs::remove_dir_all(repo);
}
#[test]
fn forced_worktree_remove_recovery_keeps_unrelated_replacement_directory() {
let repo = create_committed_repo("worktree-recovery-unrelated-repo");
let checkout = unique_temp_path("worktree-recovery-unrelated-checkout");
let branch = "worktree/recovery-unrelated";
let add = build_worktree_add_new_branch_command(&repo, &checkout, branch, "HEAD");
run_worktree_command(&add).unwrap();
let remove = build_worktree_remove_command(&repo, &checkout, true);
run_worktree_command(&remove).unwrap();
std::fs::create_dir_all(&checkout).unwrap();
std::fs::write(checkout.join("unrelated"), "do not delete\n").unwrap();
let err = run_worktree_remove_command_with_recovery(&remove, &repo, &checkout, true)
.expect_err("unrelated replacement directory should not be removed");
assert!(is_not_working_tree_remove_error(&err));
assert!(checkout.join("unrelated").exists());
let _ = std::fs::remove_dir_all(checkout);
let _ = std::fs::remove_dir_all(repo);
}
}

View File

@ -2007,6 +2007,76 @@ fn worktree_management_commands_work() {
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn forced_worktree_remove_terminates_processes_inside_checkout() {
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let repo = base.join("repo");
let checkout = base.join("checkout-with-process");
create_committed_repo(&repo);
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli_json(
&socket_path,
&[
"worktree",
"create",
"--cwd",
repo.to_str().unwrap(),
"--branch",
"worktree/force-process",
"--path",
checkout.to_str().unwrap(),
"--json",
],
);
let child_workspace_id = created["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();
let pane_id = created["result"]["root_pane"]["pane_id"]
.as_str()
.unwrap()
.to_string();
let pid_file = base.join("worktree-remove-force.pid");
let command = format!(
"python3 -c 'import os,time,pathlib; pathlib.Path(r\"{}\").write_text(str(os.getpid())); time.sleep(1000)'",
pid_file.display()
);
let ran = run_cli(&socket_path, &["pane", "run", &pane_id, &command]);
assert!(
ran.status.success(),
"stderr: {}",
String::from_utf8_lossy(&ran.stderr)
);
let pid = wait_for_pid_file(&pid_file, Duration::from_secs(5)).unwrap_or_else(|err| {
panic!("failed to read pane child pid: {err}");
});
assert!(process_exists(pid), "child process was not running");
let removed = run_cli_json(
&socket_path,
&[
"worktree",
"remove",
"--workspace",
&child_workspace_id,
"--force",
"--json",
],
);
assert_eq!(removed["result"]["type"], "worktree_removed");
assert!(wait_for_pid_exit(pid, Duration::from_secs(3)));
assert!(!checkout.exists());
cleanup_spawned_herdr(herdr, base);
}
#[test]
fn worktree_open_existing_checkout_by_path_and_branch() {
let base = unique_test_dir();