diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-23 15:11:48 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-23 15:19:36 +0300 |
| commit | 9817820af1f0468402934122538f658299ff71de (patch) | |
| tree | 1bf668cf866213d10eec30a85401aedc54fb714f /src | |
| parent | ed11779fcaebea7cc3223c4e221ea43674057891 (diff) | |
feat: add worktree cli and socket api
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 3 | ||||
| -rw-r--r-- | src/api/schema.rs | 202 | ||||
| -rw-r--r-- | src/api/server.rs | 4 | ||||
| -rw-r--r-- | src/api/subscriptions.rs | 8 | ||||
| -rw-r--r-- | src/app/api.rs | 9 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 1548 | ||||
| -rw-r--r-- | src/app/creation.rs | 9 | ||||
| -rw-r--r-- | src/app/mod.rs | 19 | ||||
| -rw-r--r-- | src/cli.rs | 2 | ||||
| -rw-r--r-- | src/cli/worktree.rs | 318 | ||||
| -rw-r--r-- | src/main.rs | 6 | ||||
| -rw-r--r-- | src/protocol/wire.rs | 2 | ||||
| -rw-r--r-- | src/worktree.rs | 11 |
13 files changed, 2138 insertions, 3 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index 34b37138..e3ad58c4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -26,6 +26,9 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::WorkspaceFocus(_) | Method::WorkspaceRename(_) | Method::WorkspaceClose(_) + | Method::WorktreeCreate(_) + | Method::WorktreeOpen(_) + | Method::WorktreeRemove(_) | Method::TabCreate(_) | Method::TabFocus(_) | Method::TabRename(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 7224d419..17a5d35e 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -28,6 +28,14 @@ pub enum Method { WorkspaceRename(WorkspaceRenameParams), #[serde(rename = "workspace.close")] WorkspaceClose(WorkspaceTarget), + #[serde(rename = "worktree.list")] + WorktreeList(WorktreeListParams), + #[serde(rename = "worktree.create")] + WorktreeCreate(WorktreeCreateParams), + #[serde(rename = "worktree.open")] + WorktreeOpen(WorktreeOpenParams), + #[serde(rename = "worktree.remove")] + WorktreeRemove(WorktreeRemoveParams), #[serde(rename = "tab.create")] TabCreate(TabCreateParams), #[serde(rename = "tab.list")] @@ -127,6 +135,55 @@ pub struct WorkspaceRenameParams { pub label: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WorktreeListParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WorktreeCreateParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct WorktreeOpenParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default)] + pub focus: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeRemoveParams { + pub workspace_id: String, + #[serde(default)] + pub force: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TabCreateParams { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -320,6 +377,10 @@ pub struct EventsSubscribeParams { pub enum Subscription { #[serde(rename = "workspace.created")] WorkspaceCreated {}, + #[serde(rename = "workspace.updated")] + WorkspaceUpdated {}, + #[serde(rename = "workspace.renamed")] + WorkspaceRenamed {}, #[serde(rename = "workspace.closed")] WorkspaceClosed {}, #[serde(rename = "workspace.focused")] @@ -415,6 +476,9 @@ pub enum EventMatch { #[serde(default, skip_serializing_if = "Option::is_none")] workspace_id: Option<String>, }, + WorkspaceUpdated { + workspace_id: String, + }, WorkspaceClosed { workspace_id: String, }, @@ -478,6 +542,7 @@ pub enum EventMatch { #[serde(rename_all = "snake_case")] pub enum EventKind { WorkspaceCreated, + WorkspaceUpdated, WorkspaceClosed, WorkspaceRenamed, WorkspaceFocused, @@ -530,6 +595,28 @@ pub enum ResponseResult { WorkspaceList { workspaces: Vec<WorkspaceInfo>, }, + WorktreeList { + source: WorktreeSourceInfo, + worktrees: Vec<WorktreeInfo>, + }, + WorktreeCreated { + workspace: WorkspaceInfo, + tab: TabInfo, + root_pane: PaneInfo, + worktree: WorktreeInfo, + }, + WorktreeOpened { + workspace: WorkspaceInfo, + tab: TabInfo, + root_pane: PaneInfo, + worktree: WorktreeInfo, + already_open: bool, + }, + WorktreeRemoved { + workspace_id: String, + path: String, + forced: bool, + }, TabInfo { tab: TabInfo, }, @@ -594,6 +681,41 @@ pub struct WorkspaceInfo { pub tab_count: usize, pub active_tab_id: String, pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option<WorkspaceWorktreeInfo>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceWorktreeInfo { + pub repo_key: String, + pub repo_name: String, + pub repo_root: String, + pub checkout_path: String, + pub is_linked_worktree: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeSourceInfo { + pub repo_key: String, + pub repo_name: String, + pub repo_root: String, + pub source_checkout_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_workspace_id: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorktreeInfo { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option<String>, + pub is_bare: bool, + pub is_detached: bool, + pub is_prunable: bool, + pub is_linked_worktree: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_workspace_id: Option<String>, + pub label: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -716,6 +838,9 @@ pub enum EventData { WorkspaceCreated { workspace: WorkspaceInfo, }, + WorkspaceUpdated { + workspace: WorkspaceInfo, + }, WorkspaceClosed { workspace_id: String, }, @@ -1103,6 +1228,83 @@ mod tests { } #[test] + fn worktree_request_and_response_round_trip() { + let request = Request { + id: "req_worktree".into(), + method: Method::WorktreeCreate(WorktreeCreateParams { + workspace_id: Some("1".into()), + branch: Some("worktree/api".into()), + base: Some("HEAD".into()), + focus: true, + ..WorktreeCreateParams::default() + }), + }; + let json = serde_json::to_string(&request).unwrap(); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, request); + + let response = SuccessResponse { + id: "req_worktree".into(), + result: ResponseResult::WorktreeCreated { + workspace: WorkspaceInfo { + workspace_id: "w_1".into(), + number: 2, + label: "herdr".into(), + focused: true, + pane_count: 1, + tab_count: 1, + active_tab_id: "w_1:1".into(), + agent_status: AgentStatus::Unknown, + worktree: Some(WorkspaceWorktreeInfo { + repo_key: "/repo/herdr/.git".into(), + repo_name: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/worktrees/herdr/worktree-api".into(), + is_linked_worktree: true, + }), + }, + tab: TabInfo { + tab_id: "w_1:1".into(), + workspace_id: "w_1".into(), + number: 1, + label: "herdr".into(), + focused: true, + pane_count: 1, + agent_status: AgentStatus::Unknown, + }, + root_pane: PaneInfo { + pane_id: "w_1-1".into(), + terminal_id: "term_1".into(), + workspace_id: "w_1".into(), + tab_id: "w_1:1".into(), + focused: true, + cwd: Some("/worktrees/herdr/worktree-api".into()), + label: None, + agent: None, + agent_status: AgentStatus::Unknown, + custom_status: None, + revision: 0, + }, + worktree: WorktreeInfo { + path: "/worktrees/herdr/worktree-api".into(), + branch: Some("worktree/api".into()), + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + open_workspace_id: Some("w_1".into()), + label: "herdr".into(), + }, + }, + }; + let json = serde_json::to_string(&response).unwrap(); + assert!(json.contains("\"type\":\"worktree_created\"")); + assert!(json.contains("\"worktree\"")); + let restored: SuccessResponse = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, response); + } + + #[test] fn create_response_round_trips_with_root_pane() { let response = SuccessResponse { id: "req_2".into(), diff --git a/src/api/server.rs b/src/api/server.rs index 2f9a423e..513262bc 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -245,6 +245,10 @@ fn api_method_name(method: &Method) -> &'static str { Method::WorkspaceFocus(_) => "workspace.focus", Method::WorkspaceRename(_) => "workspace.rename", Method::WorkspaceClose(_) => "workspace.close", + Method::WorktreeList(_) => "worktree.list", + Method::WorktreeCreate(_) => "worktree.create", + Method::WorktreeOpen(_) => "worktree.open", + Method::WorktreeRemove(_) => "worktree.remove", Method::TabCreate(_) => "tab.create", Method::TabList(_) => "tab.list", Method::TabGet(_) => "tab.get", diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index 891b743a..c5c43e20 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -76,6 +76,14 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::WorkspaceCreated, last_sequence: 0, })), + Subscription::WorkspaceUpdated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceUpdated, + last_sequence: 0, + })), + Subscription::WorkspaceRenamed {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorkspaceRenamed, + last_sequence: 0, + })), Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription { event_kind: crate::api::schema::EventKind::WorkspaceClosed, last_sequence: 0, diff --git a/src/app/api.rs b/src/app/api.rs index 877a9fee..a867f4d7 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -7,6 +7,7 @@ mod panes; mod responses; mod tabs; mod workspaces; +mod worktrees; use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind}; use crate::events::AppEvent; @@ -356,6 +357,14 @@ impl App { Method::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target) } + Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params), + Method::WorktreeCreate(params) => { + return self.handle_worktree_create(request.id, params); + } + Method::WorktreeOpen(params) => return self.handle_worktree_open(request.id, params), + Method::WorktreeRemove(params) => { + return self.handle_worktree_remove(request.id, params); + } Method::TabList(params) => return self.handle_tab_list(request.id, params), Method::TabGet(target) => return self.handle_tab_get(request.id, target), Method::TabCreate(params) => return self.handle_tab_create(request.id, params), 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(¶ms.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); + } +} diff --git a/src/app/creation.rs b/src/app/creation.rs index 2bfe389b..4d91e250 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -325,6 +325,15 @@ impl App { .public_tab_id(index, ws.active_tab) .unwrap_or_else(|| format!("{}:{}", ws.id, ws.active_tab + 1)), agent_status: pane_agent_status(agg_state, seen), + worktree: ws + .worktree_space() + .map(|space| crate::api::schema::WorkspaceWorktreeInfo { + repo_key: space.key.clone(), + repo_name: space.label.clone(), + repo_root: space.repo_root.display().to_string(), + checkout_path: space.checkout_path.display().to_string(), + is_linked_worktree: space.is_linked_worktree, + }), } } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 16b98193..6124abc1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -327,7 +327,8 @@ impl App { (18, 36) }); - let worktree_directory = crate::worktree::expand_tilde_path(&config.worktrees.directory); + let worktree_directory = + crate::worktree::expand_tilde_absolute_path(&config.worktrees.directory); info!( pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes, @@ -1019,7 +1020,7 @@ impl App { if !invalid_section("worktrees") { self.state.worktree_directory = - crate::worktree::expand_tilde_path(&config.worktrees.directory); + crate::worktree::expand_tilde_absolute_path(&config.worktrees.directory); } if !invalid_section("theme") { @@ -1996,10 +1997,24 @@ mod tests { label: Some("logs".into()), }), }; + let worktree_list = crate::api::schema::Request { + id: "req_4".into(), + method: crate::api::schema::Method::WorktreeList( + crate::api::schema::WorktreeListParams::default(), + ), + }; + let worktree_create = crate::api::schema::Request { + id: "req_5".into(), + method: crate::api::schema::Method::WorktreeCreate( + crate::api::schema::WorktreeCreateParams::default(), + ), + }; assert!(!crate::api::request_changes_ui(&read_only)); + assert!(!crate::api::request_changes_ui(&worktree_list)); assert!(crate::api::request_changes_ui(&mutating)); assert!(crate::api::request_changes_ui(&pane_rename)); + assert!(crate::api::request_changes_ui(&worktree_create)); } #[test] @@ -15,6 +15,7 @@ mod server; mod status; mod tab; mod workspace; +mod worktree; pub enum CommandOutcome { Handled(i32), @@ -36,6 +37,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> { "status" => status::run_status_command(&args[2..])?, "config" => run_config_command(&args[2..])?, "workspace" => workspace::run_workspace_command(&args[2..])?, + "worktree" => worktree::run_worktree_command(&args[2..])?, "tab" => tab::run_tab_command(&args[2..])?, "agent" => agent::run_agent_command(&args[2..])?, "terminal" => run_terminal_command(&args[2..])?, diff --git a/src/cli/worktree.rs b/src/cli/worktree.rs new file mode 100644 index 00000000..53ad685b --- /dev/null +++ b/src/cli/worktree.rs @@ -0,0 +1,318 @@ +use crate::api::schema::{ + Method, Request, WorktreeCreateParams, WorktreeListParams, WorktreeOpenParams, + WorktreeRemoveParams, +}; + +pub(super) fn run_worktree_command(args: &[String]) -> std::io::Result<i32> { + let Some(subcommand) = args.first().map(|arg| arg.as_str()) else { + print_worktree_help(); + return Ok(2); + }; + + match subcommand { + "list" => worktree_list(&args[1..]), + "create" => worktree_create(&args[1..]), + "open" => worktree_open(&args[1..]), + "remove" => worktree_remove(&args[1..]), + "help" | "--help" | "-h" => { + print_worktree_help(); + Ok(0) + } + _ => { + print_worktree_help(); + Ok(2) + } + } +} + +fn worktree_list(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + let mut cwd = None; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + "--cwd" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(normalize_path_arg(value)?); + index += 2; + } + "--json" => index += 1, + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + if workspace_id.is_some() && cwd.is_some() { + eprintln!("usage: herdr worktree list [--workspace ID | --cwd PATH] [--json]"); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:worktree:list".into(), + method: Method::WorktreeList(WorktreeListParams { workspace_id, cwd }), + })?) +} + +fn worktree_create(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + let mut cwd = None; + let mut branch = None; + let mut base = None; + let mut path = None; + let mut label = None; + let mut focus = false; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + "--cwd" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(normalize_path_arg(value)?); + index += 2; + } + "--branch" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --branch"); + return Ok(2); + }; + branch = Some(value.clone()); + index += 2; + } + "--base" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --base"); + return Ok(2); + }; + base = Some(value.clone()); + index += 2; + } + "--path" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --path"); + return Ok(2); + }; + path = Some(normalize_path_arg(value)?); + index += 2; + } + "--label" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --label"); + return Ok(2); + }; + label = Some(value.clone()); + index += 2; + } + "--focus" => { + focus = true; + index += 1; + } + "--no-focus" => { + focus = false; + index += 1; + } + "--json" => index += 1, + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + if workspace_id.is_some() && cwd.is_some() { + eprintln!( + "usage: herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus] [--json]" + ); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:worktree:create".into(), + method: Method::WorktreeCreate(WorktreeCreateParams { + workspace_id, + cwd, + branch, + base, + path, + label, + focus, + }), + })?) +} + +fn worktree_open(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + let mut cwd = None; + let mut path = None; + let mut branch = None; + let mut label = None; + let mut focus = false; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + "--cwd" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --cwd"); + return Ok(2); + }; + cwd = Some(normalize_path_arg(value)?); + index += 2; + } + "--path" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --path"); + return Ok(2); + }; + path = Some(normalize_path_arg(value)?); + index += 2; + } + "--branch" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --branch"); + return Ok(2); + }; + branch = Some(value.clone()); + index += 2; + } + "--label" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --label"); + return Ok(2); + }; + label = Some(value.clone()); + index += 2; + } + "--focus" => { + focus = true; + index += 1; + } + "--no-focus" => { + focus = false; + index += 1; + } + "--json" => index += 1, + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + if workspace_id.is_some() && cwd.is_some() { + eprintln!( + "usage: herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--json]" + ); + return Ok(2); + } + if path.is_some() == branch.is_some() { + eprintln!( + "usage: herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--json]" + ); + return Ok(2); + } + + super::print_response(&super::send_request(&Request { + id: "cli:worktree:open".into(), + method: Method::WorktreeOpen(WorktreeOpenParams { + workspace_id, + cwd, + path, + branch, + label, + focus, + }), + })?) +} + +fn worktree_remove(args: &[String]) -> std::io::Result<i32> { + let mut workspace_id = None; + let mut force = false; + + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --workspace"); + return Ok(2); + }; + workspace_id = Some(super::normalize_workspace_id(value)); + index += 2; + } + "--force" => { + force = true; + index += 1; + } + "--json" => index += 1, + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(workspace_id) = workspace_id else { + eprintln!("usage: herdr worktree remove --workspace ID [--force] [--json]"); + return Ok(2); + }; + + super::print_response(&super::send_request(&Request { + id: "cli:worktree:remove".into(), + method: Method::WorktreeRemove(WorktreeRemoveParams { + workspace_id, + force, + }), + })?) +} + +fn print_worktree_help() { + eprintln!("herdr worktree commands:"); + eprintln!(" herdr worktree list [--workspace ID | --cwd PATH] [--json]"); + eprintln!( + " herdr worktree create [--workspace ID | --cwd PATH] [--branch NAME] [--base REF] [--path PATH] [--label TEXT] [--focus] [--no-focus] [--json]" + ); + eprintln!( + " herdr worktree open [--workspace ID | --cwd PATH] (--path PATH | --branch NAME) [--label TEXT] [--focus] [--no-focus] [--json]" + ); + eprintln!(" herdr worktree remove --workspace ID [--force] [--json]"); +} + +fn normalize_path_arg(value: &str) -> std::io::Result<String> { + let path = crate::worktree::expand_tilde_path(value); + let absolute = if path.is_absolute() { + path + } else { + std::env::current_dir()?.join(path) + }; + Ok(absolute.display().to_string()) +} diff --git a/src/main.rs b/src/main.rs index cbe584ef..e2b9a14a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -347,6 +347,7 @@ fn main() -> io::Result<()> { println!(" herdr server reload-config"); println!(" herdr config <subcommand> ..."); println!(" herdr workspace <subcommand> ..."); + println!(" herdr worktree <subcommand> ..."); println!(" herdr tab <subcommand> ..."); println!(" herdr agent <subcommand> ..."); println!(" herdr pane <subcommand> ..."); @@ -378,6 +379,10 @@ fn main() -> io::Result<()> { "herdr workspace <subcommand>", "Workspace helpers over the socket API", ), + ( + "herdr worktree <subcommand>", + "Git worktree helpers over the socket API", + ), ("herdr tab <subcommand>", "Tab helpers over the socket API"), ( "herdr agent <subcommand>", @@ -461,6 +466,7 @@ fn main() -> io::Result<()> { "status", "config", "workspace", + "worktree", "pane", "wait", "session", diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 33a5f513..ea81b36e 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 9; +pub const PROTOCOL_VERSION: u32 = 10; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. diff --git a/src/worktree.rs b/src/worktree.rs index 058ee2b8..8c1d40bb 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -68,6 +68,17 @@ pub(crate) fn expand_tilde_path(path: &str) -> PathBuf { PathBuf::from(path) } +pub(crate) fn expand_tilde_absolute_path(path: &str) -> PathBuf { + let path = expand_tilde_path(path); + if path.is_absolute() { + path + } else { + std::env::current_dir() + .map(|cwd| cwd.join(&path)) + .unwrap_or(path) + } +} + pub(crate) fn canonical_or_original(path: &Path) -> PathBuf { std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } |
