aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 15:11:48 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-23 15:19:36 +0300
commit9817820af1f0468402934122538f658299ff71de (patch)
tree1bf668cf866213d10eec30a85401aedc54fb714f /src/app/api
parented11779fcaebea7cc3223c4e221ea43674057891 (diff)
feat: add worktree cli and socket api
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/worktrees.rs1548
1 files changed, 1548 insertions, 0 deletions
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs
new file mode 100644
index 00000000..e11e69ce
--- /dev/null
+++ b/src/app/api/worktrees.rs
@@ -0,0 +1,1548 @@
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use crate::api::schema::{
+ EventData, EventEnvelope, EventKind, ResponseResult, WorktreeCreateParams, WorktreeInfo,
+ WorktreeListParams, WorktreeOpenParams, WorktreeRemoveParams, WorktreeSourceInfo,
+};
+use crate::app::App;
+
+use super::responses::{encode_error, encode_success};
+
+struct ApiFailure {
+ code: &'static str,
+ message: String,
+}
+
+impl ApiFailure {
+ fn new(code: &'static str, message: impl Into<String>) -> Self {
+ Self {
+ code,
+ message: message.into(),
+ }
+ }
+}
+
+fn absolute_user_path(path: &str) -> Result<PathBuf, ApiFailure> {
+ let path = crate::worktree::expand_tilde_path(path);
+ if path.is_absolute() {
+ Ok(path)
+ } else {
+ Err(ApiFailure::new(
+ "invalid_request",
+ "worktree path must be absolute",
+ ))
+ }
+}
+
+struct WorktreeSource {
+ workspace_idx: Option<usize>,
+ source_checkout_path: PathBuf,
+ source_repo_root: PathBuf,
+ repo_key: String,
+ repo_name: String,
+}
+
+impl App {
+ pub(super) fn handle_worktree_list(
+ &mut self,
+ id: String,
+ params: WorktreeListParams,
+ ) -> String {
+ let source = match self.resolve_worktree_list_source(params.workspace_id, params.cwd) {
+ Ok(source) => source,
+ Err(err) => return encode_error(id, err.code, err.message),
+ };
+ let entries = match crate::worktree::list_existing_worktrees(&source.source_repo_root) {
+ Ok(entries) => entries,
+ Err(err) => return encode_error(id, "worktree_list_failed", err),
+ };
+ let worktrees = entries
+ .into_iter()
+ .map(|entry| self.worktree_info_for_entry(&source, entry))
+ .collect();
+
+ encode_success(
+ id,
+ ResponseResult::WorktreeList {
+ source: self.worktree_source_info(&source),
+ worktrees,
+ },
+ )
+ }
+
+ pub(super) fn handle_worktree_create(
+ &mut self,
+ id: String,
+ params: WorktreeCreateParams,
+ ) -> 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() {
+ return encode_error(id, "invalid_request", "branch is required");
+ }
+ let base = params.base.unwrap_or_else(|| "HEAD".into());
+ let mut source = match self.resolve_worktree_source(params.workspace_id, params.cwd) {
+ Ok(source) => source,
+ Err(err) => return encode_error(id, err.code, err.message),
+ };
+ let checkout_path = match params.path {
+ Some(path) => match absolute_user_path(&path) {
+ Ok(path) => path,
+ Err(err) => return encode_error(id, err.code, err.message),
+ },
+ None => crate::worktree::default_checkout_path(
+ &self.state.worktree_directory,
+ &source.repo_name,
+ &branch,
+ ),
+ };
+
+ if let Some(parent_dir) = checkout_path.parent() {
+ if let Err(err) = std::fs::create_dir_all(parent_dir) {
+ return encode_error(id, "worktree_create_failed", err.to_string());
+ }
+ }
+
+ let command = crate::worktree::build_worktree_add_new_branch_command(
+ &source.source_checkout_path,
+ &checkout_path,
+ &branch,
+ &base,
+ );
+ if let Err(err) = crate::worktree::run_worktree_command(&command) {
+ return encode_error(id, "worktree_create_failed", err);
+ }
+ if let Err(err) = self.ensure_source_parent_membership(&mut source, true) {
+ return encode_error(id, err.code, err.message);
+ }
+
+ let ws_idx = match self.create_workspace_with_options(checkout_path.clone(), params.focus) {
+ Ok(ws_idx) => ws_idx,
+ Err(err) => {
+ return encode_error(
+ id,
+ "worktree_open_failed",
+ format!("created worktree but failed to open workspace: {err}"),
+ );
+ }
+ };
+ self.mark_worktree_membership(&source, ws_idx, checkout_path, true, false);
+ if let Some(label) = params.label {
+ if let Some(ws) = self.state.workspaces.get_mut(ws_idx) {
+ ws.set_custom_name(label);
+ }
+ }
+ self.state.mark_session_dirty();
+ self.emit_workspace_open_events(ws_idx);
+
+ let worktree = self
+ .worktree_info_for_checkout(&source, ws_idx)
+ .expect("created worktree workspace should have worktree info");
+ encode_success(
+ id,
+ ResponseResult::WorktreeCreated {
+ workspace: self.workspace_info(ws_idx),
+ tab: self
+ .tab_info(ws_idx, 0)
+ .expect("new worktree workspace should have an initial tab"),
+ root_pane: self
+ .root_pane_info(ws_idx, 0)
+ .expect("new worktree workspace should have an initial root pane"),
+ worktree,
+ },
+ )
+ }
+
+ pub(super) fn handle_worktree_open(
+ &mut self,
+ id: String,
+ params: WorktreeOpenParams,
+ ) -> String {
+ if params.path.is_some() == params.branch.is_some() {
+ return encode_error(
+ id,
+ "invalid_request",
+ "exactly one of path or branch is required",
+ );
+ }
+ let mut source = match self.resolve_worktree_source(params.workspace_id, params.cwd) {
+ Ok(source) => source,
+ Err(err) => return encode_error(id, err.code, err.message),
+ };
+ let entry = match self.find_worktree_entry(&source, params.path, params.branch) {
+ Ok(entry) => entry,
+ Err(err) => return encode_error(id, err.code, err.message),
+ };
+ if entry.is_bare || entry.is_prunable {
+ return encode_error(id, "worktree_not_found", "worktree cannot be opened");
+ }
+ let canonical_path = crate::worktree::canonical_or_original(&entry.path);
+ let canonical_source = crate::worktree::canonical_or_original(&source.source_checkout_path);
+ let target_is_source = canonical_path == canonical_source;
+ let already_open = self.open_workspace_idx_for_checkout(&canonical_path);
+ let defer_source_created_event = target_is_source && already_open.is_none();
+ let created_source_workspace =
+ match self.ensure_source_parent_membership(&mut source, !defer_source_created_event) {
+ Ok(created) => created,
+ Err(err) => return encode_error(id, err.code, err.message),
+ };
+ let (ws_idx, created_workspace) = if let Some(ws_idx) = already_open {
+ if params.focus {
+ self.state.switch_workspace(ws_idx);
+ }
+ (ws_idx, false)
+ } else if target_is_source {
+ let ws_idx = source
+ .workspace_idx
+ .expect("source workspace should exist after membership ensure");
+ if params.focus {
+ self.state.switch_workspace(ws_idx);
+ }
+ (ws_idx, created_source_workspace)
+ } else {
+ match self.create_workspace_with_options(entry.path.clone(), params.focus) {
+ Ok(ws_idx) => (ws_idx, true),
+ Err(err) => return encode_error(id, "worktree_open_failed", err.to_string()),
+ }
+ };
+ self.mark_worktree_membership(
+ &source,
+ ws_idx,
+ entry.path.clone(),
+ canonical_path != crate::worktree::canonical_or_original(&source.source_repo_root),
+ !created_workspace,
+ );
+ if let Some(label) = params.label {
+ let workspace_id = self.public_workspace_id(ws_idx);
+ if let Some(ws) = self.state.workspaces.get_mut(ws_idx) {
+ ws.set_custom_name(label.clone());
+ crate::logging::workspace_renamed(&ws.id);
+ }
+ if !created_workspace {
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceRenamed,
+ data: EventData::WorkspaceRenamed {
+ workspace_id,
+ label,
+ },
+ });
+ }
+ }
+ self.state.mark_session_dirty();
+ if created_workspace {
+ self.emit_workspace_open_events(ws_idx);
+ }
+
+ let tab_idx = self.state.workspaces[ws_idx].active_tab;
+ encode_success(
+ id,
+ ResponseResult::WorktreeOpened {
+ workspace: self.workspace_info(ws_idx),
+ tab: self
+ .tab_info(ws_idx, tab_idx)
+ .expect("opened worktree workspace should have an active tab"),
+ root_pane: self
+ .root_pane_info(ws_idx, tab_idx)
+ .expect("opened worktree workspace should have an active root pane"),
+ worktree: self.worktree_info_for_entry(&source, entry),
+ already_open: already_open.is_some(),
+ },
+ )
+ }
+
+ pub(super) fn handle_worktree_remove(
+ &mut self,
+ id: String,
+ params: WorktreeRemoveParams,
+ ) -> String {
+ let Some(ws_idx) = self.parse_workspace_id(&params.workspace_id) else {
+ return encode_error(
+ id,
+ "workspace_not_found",
+ format!("workspace {} not found", params.workspace_id),
+ );
+ };
+ let Some(space) = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.worktree_space().cloned())
+ else {
+ return encode_error(
+ id,
+ "not_linked_worktree",
+ "workspace is not a Herdr-managed worktree checkout",
+ );
+ };
+ if !space.is_linked_worktree {
+ return encode_error(
+ id,
+ "not_linked_worktree",
+ "workspace is not a linked worktree checkout",
+ );
+ }
+
+ let command = crate::worktree::build_worktree_remove_command(
+ &space.repo_root,
+ &space.checkout_path,
+ params.force,
+ );
+ if let Err(err) = crate::worktree::run_worktree_command(&command) {
+ let code = if !params.force && crate::worktree::is_dirty_worktree_remove_error(&err) {
+ "dirty_worktree_requires_force"
+ } else {
+ "worktree_remove_failed"
+ };
+ return encode_error(id, code, err);
+ }
+
+ let workspace_id = self.public_workspace_id(ws_idx);
+ let path = space.checkout_path.display().to_string();
+ let still_same_linked_worktree = self.state.workspaces[ws_idx]
+ .worktree_space()
+ .is_some_and(|current| {
+ current.is_linked_worktree && current.checkout_path == space.checkout_path
+ });
+ if still_same_linked_worktree {
+ 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(),
+ },
+ });
+ }
+
+ encode_success(
+ id,
+ ResponseResult::WorktreeRemoved {
+ workspace_id,
+ path,
+ forced: params.force,
+ },
+ )
+ }
+
+ fn resolve_worktree_source(
+ &mut self,
+ workspace_id: Option<String>,
+ cwd: Option<String>,
+ ) -> Result<WorktreeSource, ApiFailure> {
+ if workspace_id.is_some() && cwd.is_some() {
+ return Err(ApiFailure::new(
+ "invalid_request",
+ "only one of workspace_id or cwd may be supplied",
+ ));
+ }
+
+ if let Some(workspace_id) = workspace_id {
+ let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
+ return Err(ApiFailure::new(
+ "workspace_not_found",
+ format!("workspace {workspace_id} not found"),
+ ));
+ };
+ return self.worktree_source_from_workspace(ws_idx);
+ }
+
+ if let Some(cwd) = cwd {
+ let path = absolute_user_path(&cwd)?;
+ let space = crate::workspace::git_space_metadata(&path).ok_or_else(|| {
+ ApiFailure::new(
+ "not_git_worktree",
+ "Herdr worktree actions require a path inside a Git work tree",
+ )
+ })?;
+ if space.is_linked_worktree {
+ return Err(ApiFailure::new(
+ "linked_worktree_source",
+ "New and open worktree actions start from the repo parent workspace.",
+ ));
+ }
+ let source = WorktreeSource {
+ workspace_idx: self.find_parent_workspace_for_space(&space),
+ source_checkout_path: space.repo_root.clone(),
+ source_repo_root: space.repo_root,
+ repo_key: space.key,
+ repo_name: space.label,
+ };
+ return Ok(source);
+ }
+
+ let Some(ws_idx) = self.state.active.or_else(|| {
+ self.state
+ .workspaces
+ .get(self.state.selected)
+ .map(|_| self.state.selected)
+ }) else {
+ return Err(ApiFailure::new(
+ "invalid_request",
+ "workspace_id or cwd is required when no workspace is active",
+ ));
+ };
+ self.worktree_source_from_workspace(ws_idx)
+ }
+
+ fn resolve_worktree_list_source(
+ &mut self,
+ workspace_id: Option<String>,
+ cwd: Option<String>,
+ ) -> Result<WorktreeSource, ApiFailure> {
+ if workspace_id.is_some() && cwd.is_some() {
+ return Err(ApiFailure::new(
+ "invalid_request",
+ "only one of workspace_id or cwd may be supplied",
+ ));
+ }
+
+ if let Some(workspace_id) = workspace_id {
+ let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
+ return Err(ApiFailure::new(
+ "workspace_not_found",
+ format!("workspace {workspace_id} not found"),
+ ));
+ };
+ return self.worktree_list_source_from_workspace(ws_idx);
+ }
+
+ if let Some(cwd) = cwd {
+ let path = absolute_user_path(&cwd)?;
+ let space = crate::workspace::git_space_metadata(&path).ok_or_else(|| {
+ ApiFailure::new(
+ "not_git_worktree",
+ "Herdr worktree actions require a path inside a Git work tree",
+ )
+ })?;
+ let workspace_idx = self.list_source_workspace_idx_for_space(&space);
+ return Ok(worktree_source_from_space(space, workspace_idx, true));
+ }
+
+ let Some(ws_idx) = self.state.active.or_else(|| {
+ self.state
+ .workspaces
+ .get(self.state.selected)
+ .map(|_| self.state.selected)
+ }) else {
+ return Err(ApiFailure::new(
+ "invalid_request",
+ "workspace_id or cwd is required when no workspace is active",
+ ));
+ };
+ self.worktree_list_source_from_workspace(ws_idx)
+ }
+
+ fn worktree_source_from_workspace(&self, ws_idx: usize) -> Result<WorktreeSource, ApiFailure> {
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return Err(ApiFailure::new(
+ "workspace_not_found",
+ "workspace not found",
+ ));
+ };
+ if let Some(membership) = ws.worktree_space() {
+ if membership.is_linked_worktree {
+ return Err(ApiFailure::new(
+ "linked_worktree_source",
+ "New and open worktree actions start from the repo parent workspace.",
+ ));
+ }
+ return Ok(WorktreeSource {
+ workspace_idx: Some(ws_idx),
+ source_checkout_path: membership.checkout_path.clone(),
+ source_repo_root: membership.repo_root.clone(),
+ repo_key: membership.key.clone(),
+ repo_name: membership.label.clone(),
+ });
+ }
+
+ let git_space = ws.git_space().cloned().or_else(|| {
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ .as_deref()
+ .and_then(crate::workspace::git_space_metadata)
+ });
+ let Some(space) = git_space else {
+ return Err(ApiFailure::new(
+ "not_git_worktree",
+ "Herdr worktree actions require a workspace inside a Git work tree",
+ ));
+ };
+ if space.is_linked_worktree {
+ return Err(ApiFailure::new(
+ "linked_worktree_source",
+ "New and open worktree actions start from the repo parent workspace.",
+ ));
+ }
+ Ok(WorktreeSource {
+ workspace_idx: Some(ws_idx),
+ source_checkout_path: space.repo_root.clone(),
+ source_repo_root: space.repo_root,
+ repo_key: space.key,
+ repo_name: space.label,
+ })
+ }
+
+ fn worktree_list_source_from_workspace(
+ &self,
+ ws_idx: usize,
+ ) -> Result<WorktreeSource, ApiFailure> {
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return Err(ApiFailure::new(
+ "workspace_not_found",
+ "workspace not found",
+ ));
+ };
+ if let Some(membership) = ws.worktree_space() {
+ let source_checkout_path = if membership.is_linked_worktree {
+ membership.repo_root.clone()
+ } else {
+ membership.checkout_path.clone()
+ };
+ let workspace_idx = if membership.is_linked_worktree {
+ self.open_workspace_idx_for_checkout(&membership.repo_root)
+ } else {
+ Some(ws_idx)
+ };
+ return Ok(WorktreeSource {
+ workspace_idx,
+ source_checkout_path,
+ source_repo_root: membership.repo_root.clone(),
+ repo_key: membership.key.clone(),
+ repo_name: membership.label.clone(),
+ });
+ }
+
+ let git_space = ws.git_space().cloned().or_else(|| {
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ .as_deref()
+ .and_then(crate::workspace::git_space_metadata)
+ });
+ let Some(space) = git_space else {
+ return Err(ApiFailure::new(
+ "not_git_worktree",
+ "Herdr worktree actions require a workspace inside a Git work tree",
+ ));
+ };
+ let workspace_idx = if space.is_linked_worktree {
+ self.list_source_workspace_idx_for_space(&space)
+ } else {
+ Some(ws_idx)
+ };
+ Ok(worktree_source_from_space(space, workspace_idx, true))
+ }
+
+ fn ensure_source_parent_membership(
+ &mut self,
+ source: &mut WorktreeSource,
+ emit_created_event: bool,
+ ) -> Result<bool, ApiFailure> {
+ if source.workspace_idx.is_none() {
+ source.workspace_idx = self.find_parent_workspace_by_key(&source.repo_key);
+ }
+ let mut created_parent = false;
+ if source.workspace_idx.is_none() {
+ let ws_idx = self
+ .create_workspace_with_options(source.source_checkout_path.clone(), false)
+ .map_err(|err| ApiFailure::new("worktree_open_failed", err.to_string()))?;
+ source.workspace_idx = Some(ws_idx);
+ created_parent = true;
+ }
+ if let Some(ws_idx) = source.workspace_idx {
+ let membership =
+ worktree_membership(source, source.source_checkout_path.clone(), false);
+ self.set_worktree_membership(ws_idx, membership, !created_parent);
+ if created_parent && emit_created_event {
+ self.emit_workspace_open_events(ws_idx);
+ }
+ }
+ Ok(created_parent)
+ }
+
+ fn find_parent_workspace_for_space(
+ &self,
+ space: &crate::workspace::GitSpaceMetadata,
+ ) -> Option<usize> {
+ self.find_parent_workspace_by_key(&space.key)
+ .or_else(|| self.open_workspace_idx_for_checkout(&space.repo_root))
+ }
+
+ fn list_source_workspace_idx_for_space(
+ &self,
+ space: &crate::workspace::GitSpaceMetadata,
+ ) -> Option<usize> {
+ if space.is_linked_worktree {
+ let parent_checkout = parent_checkout_path_for_space(space);
+ self.open_workspace_idx_for_checkout(&parent_checkout)
+ } else {
+ self.find_parent_workspace_for_space(space)
+ }
+ }
+
+ fn find_parent_workspace_by_key(&self, repo_key: &str) -> Option<usize> {
+ self.state.workspaces.iter().position(|ws| {
+ ws.worktree_space()
+ .is_some_and(|space| space.key == repo_key && !space.is_linked_worktree)
+ || ws
+ .git_space()
+ .is_some_and(|space| space.key == repo_key && !space.is_linked_worktree)
+ })
+ }
+
+ fn mark_worktree_membership(
+ &mut self,
+ source: &WorktreeSource,
+ target_ws_idx: usize,
+ target_path: PathBuf,
+ target_is_linked_worktree: bool,
+ emit_update: bool,
+ ) {
+ let membership = worktree_membership(source, target_path, target_is_linked_worktree);
+ self.set_worktree_membership(target_ws_idx, membership, emit_update);
+ }
+
+ fn set_worktree_membership(
+ &mut self,
+ ws_idx: usize,
+ membership: crate::workspace::WorktreeSpaceMembership,
+ emit_update: bool,
+ ) {
+ let changed = if let Some(workspace) = self.state.workspaces.get_mut(ws_idx) {
+ if workspace.worktree_space.as_ref() == Some(&membership) {
+ false
+ } else {
+ workspace.worktree_space = Some(membership);
+ true
+ }
+ } else {
+ false
+ };
+ if changed {
+ self.state.mark_session_dirty();
+ if emit_update {
+ self.emit_workspace_updated(ws_idx);
+ }
+ }
+ }
+
+ fn find_worktree_entry(
+ &self,
+ source: &WorktreeSource,
+ path: Option<String>,
+ branch: Option<String>,
+ ) -> Result<crate::worktree::ExistingWorktree, ApiFailure> {
+ let entries = crate::worktree::list_existing_worktrees(&source.source_repo_root)
+ .map_err(|err| ApiFailure::new("worktree_list_failed", err))?;
+ if let Some(path) = path {
+ let expected = absolute_user_path(&path)?;
+ let expected = crate::worktree::canonical_or_original(&expected);
+ entries
+ .into_iter()
+ .find(|entry| crate::worktree::canonical_or_original(&entry.path) == expected)
+ .ok_or_else(|| ApiFailure::new("worktree_not_found", "worktree path not found"))
+ } else if let Some(branch) = branch {
+ let matches = entries
+ .into_iter()
+ .filter(|entry| {
+ !entry.is_bare
+ && !entry.is_prunable
+ && !entry.is_detached
+ && entry.branch.as_deref() == Some(branch.as_str())
+ })
+ .collect::<Vec<_>>();
+ match matches.len() {
+ 0 => Err(ApiFailure::new(
+ "worktree_not_found",
+ "worktree branch not found",
+ )),
+ 1 => Ok(matches.into_iter().next().expect("one match should exist")),
+ _ => Err(ApiFailure::new(
+ "ambiguous_worktree_branch",
+ "multiple worktrees matched branch",
+ )),
+ }
+ } else {
+ Err(ApiFailure::new(
+ "invalid_request",
+ "exactly one of path or branch is required",
+ ))
+ }
+ }
+
+ fn worktree_source_info(&self, source: &WorktreeSource) -> WorktreeSourceInfo {
+ WorktreeSourceInfo {
+ repo_key: source.repo_key.clone(),
+ repo_name: source.repo_name.clone(),
+ repo_root: source.source_repo_root.display().to_string(),
+ source_checkout_path: source.source_checkout_path.display().to_string(),
+ source_workspace_id: source
+ .workspace_idx
+ .map(|idx| self.public_workspace_id(idx)),
+ }
+ }
+
+ fn worktree_info_for_entry(
+ &self,
+ source: &WorktreeSource,
+ entry: crate::worktree::ExistingWorktree,
+ ) -> WorktreeInfo {
+ let canonical_path = crate::worktree::canonical_or_original(&entry.path);
+ let repo_root = crate::worktree::canonical_or_original(&source.source_repo_root);
+ WorktreeInfo {
+ path: entry.path.display().to_string(),
+ branch: entry.branch,
+ is_bare: entry.is_bare,
+ is_detached: entry.is_detached,
+ is_prunable: entry.is_prunable,
+ is_linked_worktree: canonical_path != repo_root,
+ open_workspace_id: self
+ .open_workspace_idx_for_checkout(&canonical_path)
+ .map(|idx| self.public_workspace_id(idx)),
+ label: source.repo_name.clone(),
+ }
+ }
+
+ fn worktree_info_for_checkout(
+ &self,
+ source: &WorktreeSource,
+ ws_idx: usize,
+ ) -> Option<WorktreeInfo> {
+ let membership = self.state.workspaces.get(ws_idx)?.worktree_space()?;
+ let branch = crate::workspace::git_branch(&membership.checkout_path);
+ let is_detached = branch.is_none();
+ Some(WorktreeInfo {
+ path: membership.checkout_path.display().to_string(),
+ branch,
+ is_bare: false,
+ is_detached,
+ is_prunable: false,
+ is_linked_worktree: membership.is_linked_worktree,
+ open_workspace_id: Some(self.public_workspace_id(ws_idx)),
+ label: source.repo_name.clone(),
+ })
+ }
+
+ fn open_workspace_idx_for_checkout(&self, checkout_path: &Path) -> Option<usize> {
+ let canonical_checkout = crate::worktree::canonical_or_original(checkout_path);
+ let checkout_key = canonical_checkout.display().to_string();
+ self.state.workspaces.iter().position(|ws| {
+ if ws.worktree_space().is_some_and(|space| {
+ crate::worktree::canonical_or_original(&space.checkout_path) == canonical_checkout
+ }) {
+ return true;
+ }
+
+ let git_space = ws.git_space().cloned().or_else(|| {
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ .as_deref()
+ .and_then(crate::workspace::git_space_metadata)
+ });
+ if git_space
+ .as_ref()
+ .is_some_and(|metadata| metadata.checkout_key == checkout_key)
+ {
+ return true;
+ }
+
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
+ .as_deref()
+ .is_some_and(|cwd| {
+ crate::worktree::canonical_or_original(cwd) == canonical_checkout
+ })
+ })
+ }
+
+ fn emit_workspace_open_events(&self, ws_idx: usize) {
+ let workspace_info = self.workspace_info(ws_idx);
+ let Some(tab) = self.tab_info(ws_idx, 0) else {
+ return;
+ };
+ let Some(root_pane) = self.root_pane_info(ws_idx, 0) else {
+ return;
+ };
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceCreated,
+ data: EventData::WorkspaceCreated {
+ workspace: workspace_info,
+ },
+ });
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabCreated,
+ data: EventData::TabCreated { tab },
+ });
+ self.emit_event(EventEnvelope {
+ event: EventKind::PaneCreated,
+ data: EventData::PaneCreated { pane: root_pane },
+ });
+ }
+
+ fn emit_workspace_updated(&self, ws_idx: usize) {
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceUpdated,
+ data: EventData::WorkspaceUpdated {
+ workspace: self.workspace_info(ws_idx),
+ },
+ });
+ }
+}
+
+fn worktree_source_from_space(
+ space: crate::workspace::GitSpaceMetadata,
+ workspace_idx: Option<usize>,
+ allow_linked: bool,
+) -> WorktreeSource {
+ let source_checkout_path = if allow_linked {
+ parent_checkout_path_for_space(&space)
+ } else {
+ space.repo_root.clone()
+ };
+ WorktreeSource {
+ workspace_idx,
+ source_checkout_path: source_checkout_path.clone(),
+ source_repo_root: source_checkout_path,
+ repo_key: space.key,
+ repo_name: space.label,
+ }
+}
+
+fn parent_checkout_path_for_space(space: &crate::workspace::GitSpaceMetadata) -> PathBuf {
+ if !space.is_linked_worktree {
+ return space.repo_root.clone();
+ }
+
+ crate::worktree::list_existing_worktrees(&space.repo_root)
+ .ok()
+ .and_then(|entries| {
+ entries.into_iter().find_map(|entry| {
+ let entry_space = crate::workspace::git_space_metadata(&entry.path)?;
+ if entry_space.key == space.key && !entry_space.is_linked_worktree {
+ Some(entry_space.repo_root)
+ } else {
+ None
+ }
+ })
+ })
+ .unwrap_or_else(|| space.repo_root.clone())
+}
+
+fn worktree_membership(
+ source: &WorktreeSource,
+ checkout_path: PathBuf,
+ is_linked_worktree: bool,
+) -> crate::workspace::WorktreeSpaceMembership {
+ crate::workspace::WorktreeSpaceMembership {
+ key: source.repo_key.clone(),
+ label: source.repo_name.clone(),
+ repo_root: source.source_repo_root.clone(),
+ checkout_path,
+ is_linked_worktree,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::api::schema::{ErrorResponse, Request, SuccessResponse};
+ use crate::{config::Config, workspace::Workspace};
+
+ fn unique_temp_path(name: &str) -> PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
+ }
+
+ fn run_git(repo: &Path, args: &[&str]) {
+ let status = std::process::Command::new("git")
+ .arg("-C")
+ .arg(repo)
+ .args(args)
+ .status()
+ .unwrap();
+ assert!(
+ status.success(),
+ "git command failed: git -C {} {}",
+ repo.display(),
+ args.join(" ")
+ );
+ }
+
+ fn create_committed_repo(name: &str) -> PathBuf {
+ let repo = unique_temp_path(name);
+ std::fs::create_dir_all(&repo).unwrap();
+ run_git(&repo, &["init", "--quiet"]);
+ run_git(&repo, &["config", "user.email", "herdr@example.invalid"]);
+ run_git(&repo, &["config", "user.name", "Herdr Test"]);
+ std::fs::write(repo.join("README.md"), "test\n").unwrap();
+ run_git(&repo, &["add", "README.md"]);
+ run_git(&repo, &["commit", "--quiet", "-m", "initial"]);
+ repo
+ }
+
+ fn test_app() -> App {
+ test_app_with_event_hub(crate::api::EventHub::default())
+ }
+
+ fn test_app_with_event_hub(event_hub: crate::api::EventHub) -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ App::new(&Config::default(), true, None, api_rx, event_hub)
+ }
+
+ fn app_with_parent(repo: &Path) -> App {
+ let mut app = test_app();
+ let mut parent = Workspace::test_new("main");
+ parent.identity_cwd = repo.to_path_buf();
+ app.state.workspaces = vec![parent];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app
+ }
+
+ #[tokio::test]
+ async fn api_worktree_create_opens_workspace_and_marks_membership() {
+ let repo = create_committed_repo("api-worktree-create-repo");
+ let worktree_root = unique_temp_path("api-worktree-create-root");
+ let mut app = app_with_parent(&repo);
+ app.state.worktree_directory = worktree_root.clone();
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeCreate(WorktreeCreateParams {
+ workspace_id: Some(app.state.workspaces[0].id.clone()),
+ branch: Some("worktree/api-create".into()),
+ ..WorktreeCreateParams::default()
+ }),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorktreeCreated {
+ workspace,
+ tab,
+ root_pane,
+ worktree,
+ } = success.result
+ else {
+ panic!("expected worktree_created response");
+ };
+ assert_eq!(tab.workspace_id, workspace.workspace_id);
+ assert_eq!(root_pane.workspace_id, workspace.workspace_id);
+ assert_eq!(worktree.branch.as_deref(), Some("worktree/api-create"));
+ assert!(Path::new(&worktree.path).join("README.md").exists());
+ assert_eq!(app.state.workspaces.len(), 2);
+ assert!(
+ !app.state.workspaces[0]
+ .worktree_space()
+ .unwrap()
+ .is_linked_worktree
+ );
+ assert!(
+ app.state.workspaces[1]
+ .worktree_space()
+ .unwrap()
+ .is_linked_worktree
+ );
+ assert!(workspace.worktree.unwrap().is_linked_worktree);
+
+ let remove =
+ crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false);
+ crate::worktree::run_worktree_command(&remove).unwrap();
+ let _ = std::fs::remove_dir_all(worktree_root);
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[tokio::test]
+ async fn api_worktree_create_from_cwd_emits_parent_with_membership() {
+ let repo = create_committed_repo("api-worktree-create-cwd-repo");
+ let worktree_root = unique_temp_path("api-worktree-create-cwd-root");
+ let event_hub = crate::api::EventHub::default();
+ let mut app = test_app_with_event_hub(event_hub.clone());
+ app.state.worktree_directory = worktree_root.clone();
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeCreate(WorktreeCreateParams {
+ cwd: Some(repo.display().to_string()),
+ branch: Some("worktree/api-create-cwd".into()),
+ ..WorktreeCreateParams::default()
+ }),
+ });
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorktreeCreated { worktree, .. } = success.result else {
+ panic!("expected worktree_created response");
+ };
+
+ let events = event_hub.events_after(0);
+ let parent_created = events
+ .iter()
+ .filter_map(|(_, event)| match &event.data {
+ EventData::WorkspaceCreated { workspace } => Some(workspace),
+ _ => None,
+ })
+ .find(|workspace| {
+ workspace
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| !worktree.is_linked_worktree)
+ });
+ assert!(
+ parent_created.is_some(),
+ "auto-created parent workspace event should include parent worktree membership"
+ );
+
+ let remove =
+ crate::worktree::build_worktree_remove_command(&repo, Path::new(&worktree.path), false);
+ crate::worktree::run_worktree_command(&remove).unwrap();
+ let _ = std::fs::remove_dir_all(worktree_root);
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn invalid_worktree_create_from_cwd_does_not_create_parent_workspace() {
+ let repo = create_committed_repo("api-worktree-create-invalid-cwd-repo");
+ let mut app = test_app();
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeCreate(WorktreeCreateParams {
+ cwd: Some(repo.display().to_string()),
+ branch: Some(" ".into()),
+ ..WorktreeCreateParams::default()
+ }),
+ });
+
+ let error: ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "invalid_request");
+ assert!(app.state.workspaces.is_empty());
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn invalid_worktree_open_from_cwd_does_not_create_parent_workspace() {
+ let repo = create_committed_repo("api-worktree-open-invalid-cwd-repo");
+ let mut app = test_app();
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams {
+ cwd: Some(repo.display().to_string()),
+ path: Some("/tmp/one".into()),
+ branch: Some("worktree/one".into()),
+ ..WorktreeOpenParams::default()
+ }),
+ });
+
+ let error: ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "invalid_request");
+ assert!(app.state.workspaces.is_empty());
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn raw_api_worktree_create_rejects_relative_path_override() {
+ let repo = create_committed_repo("api-worktree-relative-path-repo");
+ let mut app = app_with_parent(&repo);
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeCreate(WorktreeCreateParams {
+ workspace_id: Some(app.state.workspaces[0].id.clone()),
+ branch: Some("worktree/relative".into()),
+ path: Some("relative-checkout".into()),
+ ..WorktreeCreateParams::default()
+ }),
+ });
+
+ let error: ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "invalid_request");
+ assert_eq!(app.state.workspaces.len(), 1);
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn raw_api_worktree_create_rejects_relative_cwd() {
+ let mut app = test_app();
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeCreate(WorktreeCreateParams {
+ cwd: Some("relative-repo".into()),
+ branch: Some("worktree/relative-cwd".into()),
+ ..WorktreeCreateParams::default()
+ }),
+ });
+
+ let error: ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "invalid_request");
+ assert!(app.state.workspaces.is_empty());
+ }
+
+ #[test]
+ fn api_worktree_open_reuses_already_open_checkout_from_subdirectory() {
+ let repo = create_committed_repo("api-worktree-open-repo");
+ let checkout = unique_temp_path("api-worktree-open-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/api-open",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ let subdir = checkout.join("nested");
+ std::fs::create_dir_all(&subdir).unwrap();
+
+ let mut app = app_with_parent(&repo);
+ let mut child = Workspace::test_new("child");
+ child.identity_cwd = subdir;
+ app.state.workspaces.push(child);
+ app.state.ensure_test_terminals();
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams {
+ workspace_id: Some(app.state.workspaces[0].id.clone()),
+ branch: Some("worktree/api-open".into()),
+ focus: true,
+ ..WorktreeOpenParams::default()
+ }),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorktreeOpened {
+ workspace,
+ already_open,
+ ..
+ } = success.result
+ else {
+ panic!("expected worktree_opened response");
+ };
+ assert!(already_open);
+ assert_eq!(app.state.workspaces.len(), 2);
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(workspace.workspace_id, app.state.workspaces[1].id);
+ assert!(
+ app.state.workspaces[1]
+ .worktree_space()
+ .unwrap()
+ .is_linked_worktree
+ );
+
+ let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
+ crate::worktree::run_worktree_command(&remove).unwrap();
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn api_worktree_open_label_on_already_open_checkout_emits_rename_event() {
+ let repo = create_committed_repo("api-worktree-open-label-repo");
+ let checkout = unique_temp_path("api-worktree-open-label-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/api-open-label",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+
+ let event_hub = crate::api::EventHub::default();
+ let mut app = test_app_with_event_hub(event_hub.clone());
+ let mut parent = Workspace::test_new("main");
+ parent.identity_cwd = repo.clone();
+ app.state.workspaces = vec![parent];
+ let mut child = Workspace::test_new("child");
+ child.identity_cwd = checkout.clone();
+ let child_id = child.id.clone();
+ app.state.workspaces.push(child);
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams {
+ workspace_id: Some(app.state.workspaces[0].id.clone()),
+ branch: Some("worktree/api-open-label".into()),
+ label: Some("review".into()),
+ ..WorktreeOpenParams::default()
+ }),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorktreeOpened {
+ workspace,
+ already_open,
+ ..
+ } = success.result
+ else {
+ panic!("expected worktree_opened response");
+ };
+ assert!(already_open);
+ assert_eq!(workspace.workspace_id, child_id);
+ assert_eq!(workspace.label, "review");
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorkspaceUpdated { workspace }
+ if workspace.workspace_id == child_id
+ && workspace
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| worktree.is_linked_worktree)
+ )
+ }));
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorkspaceRenamed {
+ workspace_id,
+ label
+ } if workspace_id == &child_id && label == "review"
+ )
+ }));
+
+ let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
+ crate::worktree::run_worktree_command(&remove).unwrap();
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[tokio::test]
+ async fn api_worktree_open_source_checkout_created_by_request_is_not_already_open() {
+ let repo = create_committed_repo("api-worktree-open-source-repo");
+ let event_hub = crate::api::EventHub::default();
+ let mut app = test_app_with_event_hub(event_hub.clone());
+ app.state.default_shell = "/bin/true".into();
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams {
+ cwd: Some(repo.display().to_string()),
+ path: Some(repo.display().to_string()),
+ label: Some("source checkout".into()),
+ ..WorktreeOpenParams::default()
+ }),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorktreeOpened {
+ workspace,
+ already_open,
+ ..
+ } = success.result
+ else {
+ panic!("expected worktree_opened response");
+ };
+ assert!(!already_open);
+ assert_eq!(workspace.label, "source checkout");
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorkspaceCreated { workspace }
+ if workspace.label == "source checkout"
+ && workspace
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| !worktree.is_linked_worktree)
+ )
+ }));
+ assert!(!event_hub
+ .events_after(0)
+ .iter()
+ .any(|(_, event)| { matches!(&event.data, EventData::WorkspaceRenamed { .. }) }));
+
+ app.state.selected = 0;
+ app.state.close_selected_workspace();
+ app.shutdown_detached_terminal_runtimes();
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn api_worktree_list_reports_open_workspace_ids() {
+ let repo = create_committed_repo("api-worktree-list-repo");
+ let checkout = unique_temp_path("api-worktree-list-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/api-list",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ let mut app = app_with_parent(&repo);
+ let mut child = Workspace::test_new("child");
+ child.identity_cwd = checkout.clone();
+ app.state.workspaces.push(child);
+ app.state.ensure_test_terminals();
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeList(WorktreeListParams {
+ workspace_id: Some(app.state.workspaces[0].id.clone()),
+ cwd: None,
+ }),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorktreeList { worktrees, .. } = success.result else {
+ panic!("expected worktree_list response");
+ };
+ let entry = worktrees
+ .iter()
+ .find(|entry| entry.branch.as_deref() == Some("worktree/api-list"))
+ .unwrap();
+ assert_eq!(
+ entry.open_workspace_id.as_deref(),
+ Some(app.state.workspaces[1].id.as_str())
+ );
+ assert!(entry.is_linked_worktree);
+
+ let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
+ crate::worktree::run_worktree_command(&remove).unwrap();
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn api_worktree_list_accepts_linked_checkout_sources() {
+ let repo = create_committed_repo("api-worktree-list-linked-repo");
+ let checkout = unique_temp_path("api-worktree-list-linked-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/api-list-linked",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ let mut app = app_with_parent(&repo);
+ let parent_id = app.state.workspaces[0].id.clone();
+ let mut child = Workspace::test_new("child");
+ child.identity_cwd = checkout.clone();
+ let child_id = child.id.clone();
+ app.state.workspaces.push(child);
+ app.state.ensure_test_terminals();
+
+ for method in [
+ crate::api::schema::Method::WorktreeList(WorktreeListParams {
+ workspace_id: Some(child_id),
+ cwd: None,
+ }),
+ crate::api::schema::Method::WorktreeList(WorktreeListParams {
+ workspace_id: None,
+ cwd: Some(checkout.display().to_string()),
+ }),
+ ] {
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method,
+ });
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorktreeList { source, worktrees } = success.result else {
+ panic!("expected worktree_list response");
+ };
+ assert_eq!(source.repo_root, repo.display().to_string());
+ assert_eq!(
+ source.source_workspace_id.as_deref(),
+ Some(parent_id.as_str())
+ );
+ assert!(worktrees.iter().any(|entry| {
+ entry.branch.as_deref() == Some("worktree/api-list-linked")
+ && entry.is_linked_worktree
+ }));
+ }
+
+ let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false);
+ crate::worktree::run_worktree_command(&remove).unwrap();
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn api_worktree_list_preserves_prunable_entries() {
+ let repo = create_committed_repo("api-worktree-list-prunable-repo");
+ let checkout = unique_temp_path("api-worktree-list-prunable-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/api-list-prunable",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ std::fs::remove_dir_all(&checkout).unwrap();
+ let mut app = app_with_parent(&repo);
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeList(WorktreeListParams {
+ workspace_id: Some(app.state.workspaces[0].id.clone()),
+ cwd: None,
+ }),
+ });
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorktreeList { worktrees, .. } = success.result else {
+ panic!("expected worktree_list response");
+ };
+ let entry = worktrees
+ .iter()
+ .find(|entry| entry.branch.as_deref() == Some("worktree/api-list-prunable"))
+ .unwrap();
+ assert!(entry.is_prunable);
+ assert!(entry.is_linked_worktree);
+
+ run_git(&repo, &["worktree", "prune"]);
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn api_worktree_remove_requires_force_for_dirty_checkout() {
+ let repo = create_committed_repo("api-worktree-remove-repo");
+ let checkout = unique_temp_path("api-worktree-remove-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/api-remove",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+ std::fs::write(checkout.join("README.md"), "dirty\n").unwrap();
+
+ let mut app = app_with_parent(&repo);
+ let mut child = Workspace::test_new("child");
+ child.identity_cwd = checkout.clone();
+ child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: crate::workspace::git_space_metadata(&repo).unwrap().key,
+ label: "api-worktree-remove-repo".into(),
+ repo_root: repo.clone(),
+ checkout_path: checkout.clone(),
+ is_linked_worktree: true,
+ });
+ let child_id = child.id.clone();
+ app.state.workspaces.push(child);
+ app.state.ensure_test_terminals();
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams {
+ workspace_id: child_id.clone(),
+ force: false,
+ }),
+ });
+ let error: ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "dirty_worktree_requires_force");
+ assert!(checkout.exists());
+ assert_eq!(app.state.workspaces.len(), 2);
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams {
+ workspace_id: child_id,
+ force: true,
+ }),
+ });
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorktreeRemoved { forced, path, .. } = success.result else {
+ panic!("expected worktree_removed response");
+ };
+ assert!(forced);
+ assert_eq!(path, checkout.display().to_string());
+ assert!(!checkout.exists());
+ assert_eq!(app.state.workspaces.len(), 1);
+
+ let _ = std::fs::remove_dir_all(repo);
+ }
+
+ #[test]
+ fn api_worktree_remove_emits_close_event_and_drains_runtime_shutdowns() {
+ let repo = create_committed_repo("api-worktree-remove-event-repo");
+ let checkout = unique_temp_path("api-worktree-remove-event-checkout");
+ run_git(
+ &repo,
+ &[
+ "worktree",
+ "add",
+ "--quiet",
+ "-b",
+ "worktree/api-remove-event",
+ checkout.to_str().unwrap(),
+ "HEAD",
+ ],
+ );
+
+ let event_hub = crate::api::EventHub::default();
+ let mut app = test_app_with_event_hub(event_hub.clone());
+ let mut child = Workspace::test_new("child");
+ child.identity_cwd = checkout.clone();
+ child.worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
+ key: crate::workspace::git_space_metadata(&repo).unwrap().key,
+ label: "api-worktree-remove-event-repo".into(),
+ repo_root: repo.clone(),
+ checkout_path: checkout.clone(),
+ is_linked_worktree: true,
+ });
+ let child_id = child.id.clone();
+ app.state.workspaces.push(child);
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+
+ let response = app.handle_api_request(Request {
+ id: "req".into(),
+ method: crate::api::schema::Method::WorktreeRemove(WorktreeRemoveParams {
+ workspace_id: child_id.clone(),
+ force: false,
+ }),
+ });
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert!(matches!(
+ success.result,
+ ResponseResult::WorktreeRemoved { .. }
+ ));
+ assert!(app.state.workspaces.is_empty());
+ assert!(app.state.terminal_runtime_shutdowns.is_empty());
+ assert!(event_hub.events_after(0).iter().any(|(_, event)| {
+ matches!(
+ &event.data,
+ EventData::WorkspaceClosed { workspace_id } if workspace_id == &child_id
+ )
+ }));
+
+ let _ = std::fs::remove_dir_all(repo);
+ }
+}