From cc8af94254d01c0a59adea0e5afd187820e19f08 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:48:12 +0100 Subject: [PATCH] feat(maestro): worktree routes and retained-worktree payload in protocol --- crates/maestro-daemon/src/retained_store.rs | 25 ++--- crates/maestro-protocol/src/lib.rs | 106 ++++++++++++++++++++ crates/maestro-protocol/src/routes.rs | 18 ++++ 3 files changed, 131 insertions(+), 18 deletions(-) diff --git a/crates/maestro-daemon/src/retained_store.rs b/crates/maestro-daemon/src/retained_store.rs index 064ba15..1d8528c 100644 --- a/crates/maestro-daemon/src/retained_store.rs +++ b/crates/maestro-daemon/src/retained_store.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; +use maestro_protocol::RetainedReason; use uuid::Uuid; #[derive(Debug, thiserror::Error)] @@ -24,20 +25,8 @@ pub enum RetainedStoreError { Json(#[from] serde_json::Error), } -/// Why a worktree outlived its session. Kebab-case on the wire; new variants -/// are additive but clients should render unknown reasons generically. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum RetainedReason { - /// The session was killed with `--force` while its worktree held unsaved - /// work. - ForceKilled, - /// The session ended by itself and its worktree held unsaved work. - ExitedWithUnsavedWork, -} - #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct RetainedWorktree { +pub struct RetainedWorktreeRecord { pub id: Uuid, pub path: PathBuf, pub base_repo: PathBuf, @@ -71,8 +60,8 @@ impl RetainedStore { base_repo: &Path, branch: &str, reason: RetainedReason, - ) -> Result { - let entry = RetainedWorktree { + ) -> Result { + let entry = RetainedWorktreeRecord { id: Uuid::new_v4(), path: path.to_path_buf(), base_repo: base_repo.to_path_buf(), @@ -85,7 +74,7 @@ impl RetainedStore { } /// Atomically write `entry`. Creates the directory if it does not exist. - pub fn overwrite(&self, entry: &RetainedWorktree) -> Result<(), RetainedStoreError> { + pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> { std::fs::create_dir_all(&self.dir)?; let final_path = self.path_for(entry.id); let tmp = self.dir.join(format!("{}.json.tmp", entry.id)); @@ -108,7 +97,7 @@ impl RetainedStore { /// Read every entry. Files that are missing or malformed are skipped /// silently: a corrupt record must not hide the others, and an empty or /// absent directory simply means nothing is retained. - pub fn load_all(&self) -> Result, RetainedStoreError> { + pub fn load_all(&self) -> Result, RetainedStoreError> { let mut out = Vec::new(); let entries = match std::fs::read_dir(&self.dir) { Ok(e) => e, @@ -124,7 +113,7 @@ impl RetainedStore { let Ok(bytes) = std::fs::read(&path) else { continue; }; - if let Ok(worktree) = serde_json::from_slice::(&bytes) { + if let Ok(worktree) = serde_json::from_slice::(&bytes) { out.push(worktree); } } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 77979d8..87a118c 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -143,6 +143,58 @@ pub struct SessionWorktree { pub base_repo: PathBuf, } +/// Why a retained worktree outlived its session (#80). Same values the +/// daemon's retained store persists; new variants are additive but clients +/// should render unknown reasons generically. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RetainedReason { + /// The session was killed with `--force` while its worktree held unsaved + /// work. + ForceKilled, + /// The session ended by itself and its worktree held unsaved work. + ExitedWithUnsavedWork, +} + +/// What a kill refusal would report about a worktree right now (#80) — the +/// same breakdown that blocks an unforced kill, recomputed on every read of +/// [`RetainedWorktree`] so it reflects the tree now rather than when it was +/// retained. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct InTheWay { + /// Tracked files with uncommitted modifications. + #[serde(default)] + pub uncommitted_files: usize, + /// Untracked files that are not ignored (ignored files never block). + #[serde(default)] + pub untracked_files: usize, + /// Commits on the branch reachable from no remote-tracking ref. + #[serde(default)] + pub unpushed_commits: usize, +} + +impl InTheWay { + /// Nothing blocks any more — the work has since been committed and pushed. + pub fn is_clear(&self) -> bool { + *self == Self::default() + } +} + +/// One entry of `GET /api/worktrees` (#80): a retained worktree with its +/// in-the-way state as of this read. Reclaiming it via +/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetainedWorktree { + pub id: Uuid, + pub path: PathBuf, + pub base_repo: PathBuf, + pub branch: String, + pub reason: RetainedReason, + pub retained_at: DateTime, + #[serde(default)] + pub in_the_way: InTheWay, +} + /// HTTP `POST /api/sessions` body. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CreateSessionRequest { @@ -539,6 +591,60 @@ mod tests { assert_eq!(spec, round_trip(&spec)); } + fn sample_retained_worktree() -> RetainedWorktree { + RetainedWorktree { + id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), + path: PathBuf::from("/work/maestro/worktrees/feat/login"), + base_repo: PathBuf::from("/work/maestro"), + branch: "feat/login".into(), + reason: RetainedReason::ForceKilled, + retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z") + .unwrap() + .with_timezone(&Utc), + in_the_way: InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }, + } + } + + #[test] + fn retained_worktree_round_trips() { + let wt = sample_retained_worktree(); + assert_eq!(wt, round_trip(&wt)); + } + + /// A payload from a daemon predating `in_the_way` still decodes; the + /// breakdown defaults to "nothing in the way". + #[test] + fn retained_worktree_defaults_in_the_way_on_legacy_payload() { + let legacy = serde_json::json!({ + "id": Uuid::from_u128(1), + "path": "/w/t", + "base_repo": "/w", + "branch": "feat/login", + "reason": "force-killed", + "retained_at": "2026-08-24T10:00:00Z", + }); + let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap(); + assert_eq!(wt.reason, RetainedReason::ForceKilled); + assert_eq!(wt.in_the_way, InTheWay::default()); + assert!(wt.in_the_way.is_clear()); + } + + #[test] + fn retained_reason_serializes_kebab_case() { + assert_eq!( + serde_json::to_value(RetainedReason::ForceKilled).unwrap(), + serde_json::json!("force-killed") + ); + assert_eq!( + serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(), + serde_json::json!("exited-with-unsaved-work") + ); + } + #[test] fn catalog_event_round_trips_and_tags() { let session = sample_session(); diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 3f50b62..7878813 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -26,6 +26,9 @@ pub const BRANCHES: &str = "/api/branches"; /// `GET` a batch of generated session handles (#53). Takes a `count` query /// param (default 1, capped at [`maestro_names`]' batch cap). pub const NAMES: &str = "/api/names"; +/// `GET` retained worktrees (#80): what the store holds, with the in-the-way +/// breakdown recomputed on read. +pub const WORKTREES: &str = "/api/worktrees"; /// `WS` catalog snapshot + session deltas. pub const EVENTS: &str = "/api/events"; @@ -40,6 +43,11 @@ pub fn session(id: Uuid) -> String { format!("{SESSIONS}/{id}") } +/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). +pub fn worktree(id: Uuid) -> String { + format!("{WORKTREES}/{id}") +} + /// `WS /api/attach/:id` — raw PTY bytes. pub fn attach(id: Uuid) -> String { format!("{API_PREFIX}/attach/{id}") @@ -50,6 +58,7 @@ pub fn attach(id: Uuid) -> String { pub mod patterns { pub const SESSION: &str = "/api/sessions/:id"; pub const ATTACH: &str = "/api/attach/:id"; + pub const WORKTREE: &str = "/api/worktrees/:id"; } /// A daemon base URL that could not be turned into a WebSocket base. @@ -160,6 +169,10 @@ mod tests { attach(id()), "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" ); + assert_eq!( + worktree(id()), + "/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" + ); assert_eq!( attach_url("https://laptop.lan:7070", id()).unwrap(), "wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" @@ -178,6 +191,10 @@ mod tests { patterns::ATTACH.trim_end_matches(":id"), attach(id()).trim_end_matches(&id().to_string()) ); + assert_eq!( + patterns::WORKTREE.trim_end_matches(":id"), + worktree(id()).trim_end_matches(&id().to_string()) + ); } #[test] @@ -191,6 +208,7 @@ mod tests { GITROOT, BRANCHES, NAMES, + WORKTREES, EVENTS, ] { assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");