fix: defer api worktree operations
refs #686 refs #657 refs #662 refs https://github.com/ogulcancelik/herdr/discussions/687
This commit is contained in:
parent
26c5f97a90
commit
46a2b259a7
|
|
@ -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
|
|
@ -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(¶ms.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(_)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
135
src/worktree.rs
135
src/worktree.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue