feat: add worktree lifecycle events

This commit is contained in:
Ogulcan Celik
2026-06-13 02:11:24 +03:00
parent f5658c54c1
commit cc7de6c7fe
7 changed files with 281 additions and 8 deletions
@@ -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
+26
View File
@@ -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<WorkspaceInfo>,
},
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,
},
+88
View File
@@ -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 {
+12
View File
@@ -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,
+5 -1
View File
@@ -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);
+38 -1
View File
@@ -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)
)
}));
}
}
+106 -4
View File
@@ -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<String>,
) -> 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<usize> {
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
)
}));