From cc7de6c7fecfeed151a1aacfb0be227f233ac600 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sun, 7 Jun 2026 02:01:44 +0300 Subject: [PATCH] feat: add worktree lifecycle events --- .../website/src/content/docs/socket-api.mdx | 8 +- src/api/schema/events.rs | 26 +++++ src/api/schema/tests.rs | 88 ++++++++++++++ src/api/subscriptions.rs | 12 ++ src/app/api/panes.rs | 6 +- src/app/api/workspaces.rs | 39 ++++++- src/app/api/worktrees.rs | 110 +++++++++++++++++- 7 files changed, 281 insertions(+), 8 deletions(-) diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index ff3f5ab8..7d1f95c4 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -213,6 +213,8 @@ Remove a linked checkout: Use at most one of `workspace_id` or `cwd` for `worktree.list`, `worktree.create`, and `worktree.open`; omit both to use the active workspace. Use exactly one of `path` or `branch` for `worktree.open`. Raw socket `cwd` and `path` values must be absolute; the CLI expands relative `--cwd` and `--path` values before sending requests. Workspace responses include optional `worktree` provenance when a workspace belongs to a Herdr worktree group. Worktree commands can emit `workspace.updated` when an existing workspace gains or changes worktree provenance. +Worktree commands also emit lifecycle events. `worktree.create` emits `workspace.created`, `tab.created`, `pane.created`, and `worktree.created`. `worktree.open` emits `worktree.opened`, and it also emits workspace/tab/pane creation events when it opens a new Herdr workspace. `worktree.remove` emits `worktree.removed`; if the linked workspace is still open, it also emits `workspace.closed`. + ## Plugin APIs The plugin API is an early host surface for local workflow tools. It does not require a manifest yet. A plugin process can register actions, store namespaced JSON records, and ask Herdr to open a managed terminal pane for its UI. @@ -392,12 +394,14 @@ Subscribe to events when you need a long-lived stream: The first response acknowledges the subscription. Later lines are pushed events. -Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.renamed`, `workspace.closed`, and `workspace.focused`. +Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.renamed`, `workspace.closed`, and `workspace.focused`. Workspace events describe Herdr UI/runtime lifecycle. `workspace.created` includes optional `workspace.worktree` provenance when the workspace belongs to a worktree group. `workspace.closed` includes a final `workspace` snapshot when Herdr can still identify it before removal. Pane event subscriptions include `pane.created`, `pane.closed`, `pane.focused`, `pane.moved`, `pane.exited`, `pane.agent_detected`, `pane.output_matched`, and `pane.agent_status_changed`. -Use `events.wait` when you want one matching event and then a response. +Worktree event subscriptions include `worktree.created`, `worktree.opened`, and `worktree.removed`. Worktree events describe Git checkout lifecycle. `worktree.created` includes the opened `workspace` and created `worktree`. `worktree.opened` includes the target `workspace`, opened `worktree`, and `already_open`. `worktree.removed` includes the `workspace_id`, removed `worktree`, and `forced`. + +Use `events.subscribe` for lifecycle events. Dedicated wait helpers are documented separately when a one-shot wait is supported. ## Reading panes diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs index 946c7531..af73e215 100644 --- a/src/api/schema/events.rs +++ b/src/api/schema/events.rs @@ -6,6 +6,7 @@ use super::common::{AgentStatus, ReadSource}; use super::panes::{PaneInfo, PaneReadResult}; use super::tabs::TabInfo; use super::workspaces::WorkspaceInfo; +use super::worktrees::WorktreeInfo; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EventsSubscribeParams { @@ -25,6 +26,12 @@ pub enum Subscription { WorkspaceClosed {}, #[serde(rename = "workspace.focused")] WorkspaceFocused {}, + #[serde(rename = "worktree.created")] + WorktreeCreated {}, + #[serde(rename = "worktree.opened")] + WorktreeOpened {}, + #[serde(rename = "worktree.removed")] + WorktreeRemoved {}, #[serde(rename = "tab.created")] TabCreated {}, #[serde(rename = "tab.closed")] @@ -165,6 +172,9 @@ pub enum EventKind { WorkspaceClosed, WorkspaceRenamed, WorkspaceFocused, + WorktreeCreated, + WorktreeOpened, + WorktreeRemoved, TabCreated, TabClosed, TabRenamed, @@ -240,6 +250,8 @@ pub enum EventData { }, WorkspaceClosed { workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace: Option, }, WorkspaceRenamed { workspace_id: String, @@ -248,6 +260,20 @@ pub enum EventData { WorkspaceFocused { workspace_id: String, }, + WorktreeCreated { + workspace: WorkspaceInfo, + worktree: WorktreeInfo, + }, + WorktreeOpened { + workspace: WorkspaceInfo, + worktree: WorktreeInfo, + already_open: bool, + }, + WorktreeRemoved { + workspace_id: String, + worktree: WorktreeInfo, + forced: bool, + }, TabCreated { tab: TabInfo, }, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index 1b2be6ee..eaca580d 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -516,6 +516,94 @@ fn worktree_request_and_response_round_trip() { assert_eq!(restored, response); } +#[test] +fn worktree_lifecycle_events_round_trip() { + let subscription = Request { + id: "sub_worktrees".into(), + method: Method::EventsSubscribe(EventsSubscribeParams { + subscriptions: vec![ + Subscription::WorktreeCreated {}, + Subscription::WorktreeOpened {}, + Subscription::WorktreeRemoved {}, + ], + }), + }; + let json = serde_json::to_string(&subscription).unwrap(); + assert!(json.contains("\"type\":\"worktree.created\"")); + assert!(json.contains("\"type\":\"worktree.opened\"")); + assert!(json.contains("\"type\":\"worktree.removed\"")); + let restored: Request = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, subscription); + + let workspace = WorkspaceInfo { + workspace_id: "w_2".into(), + number: 2, + label: "herdr".into(), + focused: true, + pane_count: 1, + tab_count: 1, + active_tab_id: "w_2: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, + }), + }; + let 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_2".into()), + label: "herdr".into(), + }; + + for event in [ + EventEnvelope { + event: EventKind::WorktreeCreated, + data: EventData::WorktreeCreated { + workspace: workspace.clone(), + worktree: worktree.clone(), + }, + }, + EventEnvelope { + event: EventKind::WorktreeOpened, + data: EventData::WorktreeOpened { + workspace: workspace.clone(), + worktree: worktree.clone(), + already_open: false, + }, + }, + EventEnvelope { + event: EventKind::WorktreeRemoved, + data: EventData::WorktreeRemoved { + workspace_id: "w_2".into(), + worktree: WorktreeInfo { + open_workspace_id: None, + ..worktree.clone() + }, + forced: false, + }, + }, + EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { + workspace_id: "w_2".into(), + workspace: Some(workspace.clone()), + }, + }, + ] { + let json = serde_json::to_string(&event).unwrap(); + let restored: EventEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, event); + } +} + #[test] fn create_response_round_trips_with_root_pane() { let response = SuccessResponse { diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs index e8e362fe..d71ea24d 100644 --- a/src/api/subscriptions.rs +++ b/src/api/subscriptions.rs @@ -128,6 +128,18 @@ impl ActiveSubscription { event_kind: crate::api::schema::EventKind::WorkspaceFocused, last_sequence: 0, })), + Subscription::WorktreeCreated {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorktreeCreated, + last_sequence: 0, + })), + Subscription::WorktreeOpened {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorktreeOpened, + last_sequence: 0, + })), + Subscription::WorktreeRemoved {} => Ok(Self::Event(ActiveEventSubscription { + event_kind: crate::api::schema::EventKind::WorktreeRemoved, + last_sequence: 0, + })), Subscription::TabCreated {} => Ok(Self::Event(ActiveEventSubscription { event_kind: crate::api::schema::EventKind::TabCreated, last_sequence: 0, diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index f913f670..4c227e34 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1392,6 +1392,7 @@ impl App { "closing this pane would close a worktree group", ); } + let workspace_snapshot = self.workspace_info(ws_idx); let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); let should_close_workspace = { let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { @@ -1412,7 +1413,10 @@ impl App { }); self.emit_event(EventEnvelope { event: EventKind::WorkspaceClosed, - data: EventData::WorkspaceClosed { workspace_id }, + data: EventData::WorkspaceClosed { + workspace_id, + workspace: Some(workspace_snapshot), + }, }); } else { self.state.remove_unattached_terminal_ids(terminal_id); diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index 03557062..8ce60af1 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -151,12 +151,16 @@ impl App { return workspace_not_found(id, &target.workspace_id); } let workspace_id = self.public_workspace_id(index); + let workspace = self.workspace_info(index); self.state.selected = index; self.state.close_selected_workspace(); self.shutdown_detached_terminal_runtimes(); self.emit_event(EventEnvelope { event: EventKind::WorkspaceClosed, - data: EventData::WorkspaceClosed { workspace_id }, + data: EventData::WorkspaceClosed { + workspace_id, + workspace: Some(workspace), + }, }); encode_success(id, ResponseResult::Ok {}) @@ -212,4 +216,37 @@ mod tests { assert_eq!(app.state.request_remove_linked_worktree, None); assert!(app.state.workspaces.is_empty()); } + + #[test] + fn api_workspace_close_event_includes_final_worktree_snapshot() { + let event_hub = crate::api::EventHub::default(); + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + app.state.workspaces = app_with_linked_worktree().state.workspaces; + let workspace_id = app.state.workspaces[0].id.clone(); + + let response = app.handle_workspace_close( + "req".into(), + WorkspaceTarget { + workspace_id: workspace_id.clone(), + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(success.id, "req"); + let events = event_hub.events_after(0); + assert!(events.iter().any(|(_, event)| { + matches!( + &event.data, + EventData::WorkspaceClosed { + workspace_id: closed_id, + workspace: Some(workspace), + } if closed_id == &workspace_id + && workspace + .worktree + .as_ref() + .is_some_and(|worktree| worktree.is_linked_worktree) + ) + })); + } } diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 573771e2..605378bc 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -148,6 +148,13 @@ impl App { let worktree = self .worktree_info_for_checkout(&source, ws_idx) .expect("created worktree workspace should have worktree info"); + self.emit_event(EventEnvelope { + event: EventKind::WorktreeCreated, + data: EventData::WorktreeCreated { + workspace: self.workspace_info(ws_idx), + worktree: worktree.clone(), + }, + }); encode_success( id, ResponseResult::WorktreeCreated { @@ -244,6 +251,15 @@ impl App { } let tab_idx = self.state.workspaces[ws_idx].active_tab; + let worktree = self.worktree_info_for_entry(&source, entry); + self.emit_event(EventEnvelope { + event: EventKind::WorktreeOpened, + data: EventData::WorktreeOpened { + workspace: self.workspace_info(ws_idx), + worktree: worktree.clone(), + already_open: already_open.is_some(), + }, + }); encode_success( id, ResponseResult::WorktreeOpened { @@ -254,7 +270,7 @@ impl App { 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), + worktree, already_open: already_open.is_some(), }, ) @@ -313,6 +329,8 @@ impl App { &space.checkout_path, params.force, ); + let workspace_snapshot = self.workspace_info(ws_idx); + let worktree = self.worktree_info_for_membership(&space, None); 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" @@ -337,9 +355,18 @@ impl App { event: EventKind::WorkspaceClosed, data: EventData::WorkspaceClosed { workspace_id: workspace_id.clone(), + workspace: Some(workspace_snapshot), }, }); } + self.emit_event(EventEnvelope { + event: EventKind::WorktreeRemoved, + data: EventData::WorktreeRemoved { + workspace_id: workspace_id.clone(), + worktree, + forced: params.force, + }, + }); encode_success( id, @@ -747,6 +774,25 @@ impl App { }) } + fn worktree_info_for_membership( + &self, + membership: &crate::workspace::WorktreeSpaceMembership, + open_workspace_id: Option, + ) -> WorktreeInfo { + let branch = crate::workspace::git_branch(&membership.checkout_path); + let is_detached = branch.is_none(); + 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, + label: membership.label.clone(), + } + } + fn open_workspace_idx_for_checkout(&self, checkout_path: &Path) -> Option { let canonical_checkout = crate::worktree::canonical_or_original(checkout_path); let checkout_key = canonical_checkout.display().to_string(); @@ -940,7 +986,14 @@ mod tests { 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); + 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]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; app.state.worktree_directory = worktree_root.clone(); let response = app.handle_api_request(Request { @@ -980,6 +1033,17 @@ mod tests { .is_linked_worktree ); assert!(workspace.worktree.unwrap().is_linked_worktree); + assert!(event_hub.events_after(0).iter().any(|(_, event)| { + matches!( + &event.data, + EventData::WorktreeCreated { + workspace: event_workspace, + worktree: event_worktree, + } if event_workspace.workspace_id == workspace.workspace_id + && event_worktree.branch.as_deref() == Some("worktree/api-create") + && event_worktree.is_linked_worktree + ) + })); for (_, runtime) in app.terminal_runtimes.drain() { runtime.shutdown(); @@ -1140,7 +1204,11 @@ mod tests { let subdir = checkout.join("nested"); std::fs::create_dir_all(&subdir).unwrap(); - let mut app = app_with_parent(&repo); + 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 = subdir; app.state.workspaces.push(child); @@ -1175,6 +1243,19 @@ mod tests { .unwrap() .is_linked_worktree ); + assert!(event_hub.events_after(0).iter().any(|(_, event)| { + matches!( + &event.data, + EventData::WorktreeOpened { + workspace: event_workspace, + worktree: event_worktree, + already_open, + } if event_workspace.workspace_id == workspace.workspace_id + && event_worktree.branch.as_deref() == Some("worktree/api-open") + && event_worktree.is_linked_worktree + && *already_open + ) + })); let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false); crate::worktree::run_worktree_command(&remove).unwrap(); @@ -1578,7 +1659,28 @@ mod tests { assert!(event_hub.events_after(0).iter().any(|(_, event)| { matches!( &event.data, - EventData::WorkspaceClosed { workspace_id } if workspace_id == &child_id + EventData::WorkspaceClosed { + workspace_id, + workspace: Some(workspace), + } if 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::WorktreeRemoved { + workspace_id, + worktree, + forced, + } if workspace_id == &child_id + && worktree.branch.as_deref() == Some("worktree/api-remove-event") + && worktree.is_linked_worktree + && worktree.open_workspace_id.is_none() + && !forced ) }));