From d5485b8f5da317af6a5b5205c40155ba13f58ae7 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:44:14 +0100 Subject: [PATCH 01/71] feat(maestro): add home/end/delete and bracketed-paste InputKey variants --- crates/maestro-tui/src/app.rs | 5 ++++- crates/maestro-tui/src/input.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 25b438e..82a46f7 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -348,7 +348,10 @@ impl App { InputKey::Down => b"\x1b[B".to_vec(), InputKey::Right => b"\x1b[C".to_vec(), InputKey::Left => b"\x1b[D".to_vec(), - InputKey::Mouse { .. } => return None, + InputKey::Home => b"\x1b[H".to_vec(), + InputKey::End => b"\x1b[F".to_vec(), + InputKey::Delete => b"\x1b[3~".to_vec(), + InputKey::Paste(_) | InputKey::Mouse { .. } => return None, }; Some(Effect::Forward { id: sid, bytes }) } diff --git a/crates/maestro-tui/src/input.rs b/crates/maestro-tui/src/input.rs index 4a2736d..34c4d4a 100644 --- a/crates/maestro-tui/src/input.rs +++ b/crates/maestro-tui/src/input.rs @@ -34,6 +34,12 @@ pub enum InputKey { Down, Left, Right, + Home, + End, + Delete, + /// Bracketed paste: crossterm delivers the whole payload as one event, so + /// text fields can insert it as a single operation. + Paste(String), Mouse { kind: MouseKind, region: Region, @@ -126,6 +132,12 @@ impl InputKey { InputKey::Down => "down".into(), InputKey::Left => "left".into(), InputKey::Right => "right".into(), + InputKey::Home => "home".into(), + InputKey::End => "end".into(), + InputKey::Delete => "delete".into(), + // Pastes are not bindable; the canonical form exists only to + // satisfy exhaustiveness and never round-trips through `parse`. + InputKey::Paste(_) => "paste".into(), InputKey::Mouse { kind, region, .. } => { let k = match kind { MouseKind::Click => "click", @@ -191,6 +203,9 @@ impl InputKey { "down" => return Ok(InputKey::Down), "left" => return Ok(InputKey::Left), "right" => return Ok(InputKey::Right), + "home" => return Ok(InputKey::Home), + "end" => return Ok(InputKey::End), + "delete" => return Ok(InputKey::Delete), _ => {} } // Modifier chain: ctrl-, alt-, shift- prefixes, then a single char. @@ -262,9 +277,13 @@ where KeyCode::Down => InputKey::Down, KeyCode::Left => InputKey::Left, KeyCode::Right => InputKey::Right, + KeyCode::Home => InputKey::Home, + KeyCode::End => InputKey::End, + KeyCode::Delete => InputKey::Delete, _ => return None, }) } + CtEvent::Paste(text) => Some(InputKey::Paste(text.clone())), CtEvent::Mouse(MouseEvent { kind, column, row, .. }) => { @@ -578,12 +597,25 @@ mod tests { "down", "left", "right", + "home", + "end", + "delete", ] { let k = InputKey::parse(s).unwrap(); assert_eq!(k.canonical(), s, "round-trip {s:?}"); } } + /// A bracketed paste must arrive as one key carrying the whole payload, + /// not a burst of per-character keys. + #[test] + fn canonicalise_paste_event_carries_the_whole_payload() { + use crossterm::event::Event as CtEvent; + let ev = CtEvent::Paste("/srv/work/maestro".into()); + let k = canonicalise(&ev, |_, _| Region::Unknown).unwrap(); + assert_eq!(k, InputKey::Paste("/srv/work/maestro".into())); + } + #[test] fn canonical_round_trips_mouse() { let s = "mouse:click(pane)"; From dfc4ee91e108484e2ab89e8fba218e79757bdf69 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:50:37 +0100 Subject: [PATCH 02/71] feat(maestro): add shared text-input widget with cursor motion and word ops --- crates/maestro-tui/src/lib.rs | 1 + crates/maestro-tui/src/text_input.rs | 457 +++++++++++++++++++++++++++ 2 files changed, 458 insertions(+) create mode 100644 crates/maestro-tui/src/text_input.rs diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index bc9ed0c..b76bd84 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -18,6 +18,7 @@ pub mod sidebar; pub mod state; pub mod tabs; pub mod terminal; +pub mod text_input; use std::path::PathBuf; use std::time::Duration; diff --git a/crates/maestro-tui/src/text_input.rs b/crates/maestro-tui/src/text_input.rs new file mode 100644 index 0000000..402b7ce --- /dev/null +++ b/crates/maestro-tui/src/text_input.rs @@ -0,0 +1,457 @@ +//! Single-line text-input primitive shared by every TUI form field. +//! +//! Owns a `String`, a character-indexed cursor, and horizontal scroll state. +//! All editing goes through [`TextInput::handle_key`] so callers feed the same +//! canonical [`InputKey`] stream the rest of the app uses; bracketed paste +//! arrives as one [`InputKey::Paste`] and is inserted as a single operation. +//! +//! This is the one piece of the TUI form layer that unit-tests without a +//! terminal: drive it with `InputKey` sequences and inspect value/cursor. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; + +use crate::input::InputKey; + +#[derive(Debug, Default, Clone)] +pub struct TextInput { + value: String, + /// Cursor position as a char offset into `value`. + cursor: usize, + /// Char offset of the leftmost visible cell. + scroll: usize, +} + +impl TextInput { + pub fn with_value(value: impl Into) -> Self { + let mut input = Self { + value: value.into(), + ..Self::default() + }; + input.cursor = input.value.chars().count(); + input + } + + pub fn value(&self) -> &str { + &self.value + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + /// Char offset of the leftmost visible cell; only meaningful after + /// [`TextInput::render`] has clamped it to the field width. + pub fn scroll_offset(&self) -> usize { + self.scroll + } + + /// Insert `text` at the cursor as one operation. Used both for typed + /// characters and for whole bracketed-paste payloads. + pub fn insert(&mut self, text: &str) { + let at = self.byte_offset(self.cursor); + self.value.insert_str(at, text); + self.cursor += text.chars().count(); + } + + pub fn handle_key(&mut self, key: &InputKey) { + match key { + InputKey::Char { + c, + ctrl: false, + alt: false, + .. + } => self.insert(&c.to_string()), + InputKey::Char { + c: 'w', ctrl: true, .. + } => self.delete_word_backwards(), + InputKey::Char { + c: 'k', ctrl: true, .. + } => self.kill_to_end(), + // Emacs-style word motion; ctrl-arrow reaches us as a plain + // Left/Right today, so these are the dependable binding. + InputKey::Char { + c: 'b', ctrl: true, .. + } => self.cursor = self.word_left(), + InputKey::Char { + c: 'f', ctrl: true, .. + } => self.cursor = self.word_right(), + InputKey::Left => { + self.cursor = self.cursor.saturating_sub(1); + } + InputKey::Right => { + if self.cursor < self.value.chars().count() { + self.cursor += 1; + } + } + InputKey::Home => self.cursor = 0, + InputKey::End => self.cursor = self.value.chars().count(), + InputKey::Backspace => { + if self.cursor > 0 { + let at = self.byte_offset(self.cursor - 1); + let end = self.byte_offset(self.cursor); + self.value.replace_range(at..end, ""); + self.cursor -= 1; + } + } + InputKey::Delete => { + if self.cursor < self.value.chars().count() { + let at = self.byte_offset(self.cursor); + let end = self.byte_offset(self.cursor + 1); + self.value.replace_range(at..end, ""); + } + } + InputKey::Paste(text) => self.insert(text), + _ => {} + } + } + + /// Draw into `area` (expected one row tall), scrolling horizontally so the + /// cursor stays visible, and return the screen column where the caller + /// should place the terminal cursor. + pub fn render(&mut self, area: Rect, buf: &mut Buffer, style: Style) -> u16 { + let width = area.width as usize; + let len = self.value.chars().count(); + self.scroll = self.scroll.min(len); + if self.cursor < self.scroll { + self.scroll = self.cursor; + } else if width > 0 && self.cursor.saturating_sub(self.scroll) >= width { + self.scroll = self.cursor + 1 - width; + } + for (col, c) in self.value.chars().skip(self.scroll).take(width).enumerate() { + buf[(area.x + col as u16, area.y)] + .set_symbol(&c.to_string()) + .set_style(style); + } + area.x + self.cursor.saturating_sub(self.scroll) as u16 + } + + fn byte_offset(&self, char_index: usize) -> usize { + self.value + .char_indices() + .nth(char_index) + .map(|(b, _)| b) + .unwrap_or(self.value.len()) + } + + fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' + } + + /// Start of the word before the cursor, skipping any separators first — + /// emacs `backward-word`. + fn word_left(&self) -> usize { + let chars: Vec = self.value.chars().collect(); + let mut i = self.cursor; + while i > 0 && !Self::is_word_char(chars[i - 1]) { + i -= 1; + } + while i > 0 && Self::is_word_char(chars[i - 1]) { + i -= 1; + } + i + } + + /// Start of the word after the cursor, skipping the rest of the current + /// word first — emacs `forward-word`. + fn word_right(&self) -> usize { + let chars: Vec = self.value.chars().collect(); + let len = chars.len(); + let mut i = self.cursor; + while i < len && !Self::is_word_char(chars[i]) { + i += 1; + } + while i < len && Self::is_word_char(chars[i]) { + i += 1; + } + i + } + + fn kill_to_end(&mut self) { + let at = self.byte_offset(self.cursor); + self.value.truncate(at); + } + + /// Shell-style ctrl-w: kill back to the previous whitespace, taking any + /// trailing whitespace run with it — so `cd /tmp/foo` loses the whole + /// path segment in one keystroke. + fn delete_word_backwards(&mut self) { + let chars: Vec = self.value.chars().collect(); + let mut i = self.cursor; + while i > 0 && chars[i - 1].is_whitespace() { + i -= 1; + } + while i > 0 && !chars[i - 1].is_whitespace() { + i -= 1; + } + let at = self.byte_offset(i); + let end = self.byte_offset(self.cursor); + self.value.replace_range(at..end, ""); + self.cursor = i; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use InputKey::{Backspace, Delete, End, Home, Left, Right}; + + fn typed(s: &str) -> Vec { + s.chars().map(InputKey::char).collect() + } + + fn fed(keys: &[InputKey]) -> TextInput { + let mut input = TextInput::default(); + for k in keys { + input.handle_key(k); + } + input + } + + fn ctrl(c: char) -> InputKey { + InputKey::ctrl_char(c) + } + + #[test] + fn typing_lands_in_order() { + let input = fed(&typed("hello")); + assert_eq!(input.value(), "hello"); + assert_eq!(input.cursor(), 5); + } + + #[test] + fn backspace_removes_the_last_typed_char() { + let mut input = fed(&typed("hello")); + input.handle_key(&Backspace); + assert_eq!(input.value(), "hell"); + assert_eq!(input.cursor(), 4); + } + + #[test] + fn left_then_insert_fixes_a_middle_typo() { + let mut input = fed(&typed("helo")); + input.handle_key(&Left); + input.handle_key(&Left); + input.handle_key(&InputKey::char('l')); + assert_eq!(input.value(), "hello"); + assert_eq!(input.cursor(), 3); + } + + #[test] + fn motion_keys_stop_at_the_edges() { + let mut input = fed(&typed("abc")); + for _ in 0..5 { + input.handle_key(&Left); + } + assert_eq!(input.cursor(), 0); + input.handle_key(&Left); + assert_eq!(input.cursor(), 0); + for _ in 0..5 { + input.handle_key(&Right); + } + assert_eq!(input.cursor(), 3); + } + + #[test] + fn home_and_end_jump_to_the_ends() { + let mut input = fed(&typed("abcdef")); + input.handle_key(&Home); + assert_eq!(input.cursor(), 0); + input.handle_key(&InputKey::char('x')); + assert_eq!(input.value(), "xabcdef"); + input.handle_key(&End); + input.handle_key(&InputKey::char('!')); + assert_eq!(input.value(), "xabcdef!"); + } + + /// Cursor starts after the final `/`; word-left lands on word starts, + /// never inside a separator run. + #[test] + fn ctrl_b_walks_left_one_word_at_a_time() { + let mut input = TextInput::with_value("/tmp/foo bar-baz/"); + assert_eq!(input.cursor(), 17); + for expected in [13, 9, 5, 1, 0, 0] { + input.handle_key(&ctrl('b')); + assert_eq!(input.cursor(), expected); + } + } + + #[test] + fn ctrl_f_walks_right_one_word_at_a_time() { + let mut input = TextInput::with_value("/tmp/foo bar"); + input.handle_key(&Home); + for expected in [4, 8, 12, 12] { + input.handle_key(&ctrl('f')); + assert_eq!(input.cursor(), expected); + } + } + + #[test] + fn backspace_deletes_before_the_cursor() { + let mut input = fed(&typed("abcdef")); + input.handle_key(&Left); + input.handle_key(&Left); + input.handle_key(&Left); + input.handle_key(&Backspace); + assert_eq!(input.value(), "abdef"); + assert_eq!(input.cursor(), 2); + } + + #[test] + fn backspace_on_empty_input_is_a_noop() { + let mut input = TextInput::default(); + input.handle_key(&Backspace); + assert_eq!(input.value(), ""); + assert_eq!(input.cursor(), 0); + } + + #[test] + fn delete_removes_the_char_under_the_cursor() { + let mut input = fed(&typed("abcdef")); + input.handle_key(&Home); + input.handle_key(&Delete); + assert_eq!(input.value(), "bcdef"); + assert_eq!(input.cursor(), 0); + } + + #[test] + fn delete_past_the_end_is_a_noop() { + let mut input = TextInput::with_value("abc"); + input.handle_key(&End); + input.handle_key(&Delete); + assert_eq!(input.value(), "abc"); + } + + /// `cd /tmp/foo` with ctrl-w leaves `cd ` — the whole path segment is one + /// word to a shell (readline kills back to whitespace, spaces included). + #[test] + fn ctrl_w_deletes_the_word_behind_the_cursor() { + let mut input = fed(&[typed("cd ").as_slice(), typed("/tmp/foo").as_slice()].concat()); + input.handle_key(&ctrl('w')); + assert_eq!(input.value(), "cd "); + assert_eq!(input.cursor(), 3); + } + + #[test] + fn ctrl_w_stops_before_the_separator_run() { + let mut input = TextInput::with_value("a b"); + input.cursor = 5; + input.handle_key(&ctrl('w')); + assert_eq!(input.value(), "a "); + assert_eq!(input.cursor(), 4); + } + + #[test] + fn ctrl_k_kills_everything_after_the_cursor() { + let mut input = fed(&typed("spawn --debug extra")); + for _ in 0..14 { + input.handle_key(&Left); + } + input.handle_key(&ctrl('k')); + assert_eq!(input.value(), "spawn"); + assert_eq!(input.cursor(), 5); + } + + /// A paste arrives as one event carrying the whole payload and must land + /// at the cursor as a single insertion, not per-character. + #[test] + fn paste_inserts_whole_payload_at_the_cursor() { + let mut input = fed(&typed("cd ")); + input.handle_key(&InputKey::Paste("/srv/work".into())); + assert_eq!(input.value(), "cd /srv/work"); + assert_eq!(input.cursor(), 12); + // What follows still behaves like ordinary typed text. + input.handle_key(&InputKey::char('/')); + assert_eq!(input.value(), "cd /srv/work/"); + } + + #[test] + fn paste_between_existing_text_keeps_both_halves() { + let mut input = TextInput::with_value("ac"); + input.handle_key(&Left); + input.handle_key(&InputKey::Paste("b×".into())); + assert_eq!(input.value(), "ab×c"); + assert_eq!(input.cursor(), 3, "cursor counts chars, not bytes"); + } + + #[test] + fn rendering_an_empty_field_points_at_its_first_column() { + let mut input = TextInput::default(); + let area = Rect::new(10, 5, 20, 1); + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(col, 10); + } + + #[test] + fn rendering_reports_the_visible_cursor_column() { + let mut input = TextInput::with_value("hello"); + input.handle_key(&Left); + input.handle_key(&Left); + let area = Rect::new(10, 5, 20, 1); + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(col, 13); + for (i, expected) in ["h", "e", "l", "l", "o"].iter().enumerate() { + assert_eq!(buf[(10 + i as u16, 5)].symbol(), *expected); + } + } + + #[test] + fn rendering_a_value_wider_than_the_field_shows_its_tail() { + let value = "0123456789abcdefghij"; + let mut input = TextInput::with_value(value); + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(col, 9, "cursor sits on the last visible column"); + for (i, expected) in value.chars().skip(input.scroll_offset()).enumerate() { + assert_eq!( + buf[(i as u16, 0)].symbol(), + expected.to_string(), + "cell {i}" + ); + } + } + + /// Moving left into the hidden region must scroll the window back so the + /// cursor never disappears off the left edge. + #[test] + fn moving_left_into_the_hidden_region_scrolls_back() { + let value = "0123456789abcdefghij"; + let mut input = TextInput::with_value(value); + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + input.render(area, &mut buf, Style::default()); + assert_eq!(input.cursor(), 20); + + input.handle_key(&Home); + for _ in 0..5 { + input.handle_key(&Right); + } + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(input.cursor(), 5); + assert_eq!( + input.value().chars().nth(input.scroll), + Some('5'), + "window scrolled back to the cursor" + ); + assert_eq!(col, 0); + assert_eq!(buf[(0, 0)].symbol(), "5"); + } + + #[test] + fn shrinking_the_value_clamps_scroll_and_cursor() { + let mut input = TextInput::with_value("0123456789abcdefghij"); + input.handle_key(&Home); + input.handle_key(&ctrl('k')); + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + let col = input.render(area, &mut buf, Style::default()); + assert_eq!(input.value(), ""); + assert_eq!(col, 0); + } +} From 10cb47c50b81556c81b8ecea7f5604ab0623d6c3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:52:18 +0100 Subject: [PATCH 03/71] feat(maestro): hang draft state off layout leaves, never persisted (#57) --- web/src/layout.ts | 19 +++++++++++++++-- web/src/persistence.test.ts | 42 +++++++++++++++++++++++++++++++++++-- web/src/persistence.ts | 20 +++++++++++++++++- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/web/src/layout.ts b/web/src/layout.ts index 51a2ec5..bf98155 100644 --- a/web/src/layout.ts +++ b/web/src/layout.ts @@ -11,8 +11,23 @@ import type { Uuid } from "./types.js"; export type Dir = "row" | "col"; +/** Half-filled spawn-form state for a draft pane (issue #57): an empty leaf + * renders the spawn form instead of a placeholder, and each pane's values + * hang off its own leaf so two drafts stay independent. Never persisted — + * a reload gives back an empty form (persistence strips it). */ +export interface Draft { + command: string; + cwd: string; + name: string; + worktreeEnabled?: boolean; + worktreeBranch?: string; + worktreeCleanup?: boolean; + /** Whether the cwd lives in the raw text input (vs a picked recent row). */ + cwdRawVisible?: boolean; +} + export type Node = - | { kind: "leaf"; id: string; sessionId: Uuid | null } + | { kind: "leaf"; id: string; sessionId: Uuid | null; draft?: Draft | null } | { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number }; export type Leaf = Extract; @@ -89,7 +104,7 @@ export function splitLeaf( dir: Dir, after: boolean, ): string { - const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId }; + const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId, draft: null }; const rec = (n: Node): Node => { if (n.kind === "leaf") { if (n.id !== targetId) return n; diff --git a/web/src/persistence.test.ts b/web/src/persistence.test.ts index ffc00e5..c038d46 100644 --- a/web/src/persistence.test.ts +++ b/web/src/persistence.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { hydrate } from "./persistence.js"; -import { leaves } from "./layout.js"; +import { hydrate, serializeLayout } from "./persistence.js"; +import { leaves, type Layout } from "./layout.js"; describe("hydrate", () => { it("rejects anything that is not a tab-bearing object", () => { @@ -61,4 +61,42 @@ describe("hydrate", () => { }); expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]); }); + + it("never restores draft state, even if a stale blob carries it", () => { + const layout = hydrate({ + tabs: [ + { id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } }, + ], + activeTabId: "t", + }); + expect(leaves(layout!.tabs[0].root)[0]).not.toHaveProperty("draft"); + }); +}); + +describe("serializeLayout", () => { + it("strips draft state so a reload gives back an empty form", () => { + const layout = hydrate({ + tabs: [ + { + id: "t", + root: { + kind: "split", + dir: "row", + a: { kind: "leaf", id: "l1", sessionId: "s1" }, + b: { kind: "leaf", id: "l2", sessionId: null }, + }, + }, + ], + activeTabId: "t", + }) as Layout; + const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!; + draftLeaf.draft = { command: "claude", cwd: "/home/user/project", name: "amber-fox" }; + + const restored = hydrate(JSON.parse(serializeLayout(layout)).layout); + expect(restored).not.toBeNull(); + for (const leaf of leaves(restored!.tabs[0].root)) { + expect(leaf.draft ?? null).toBeNull(); + } + expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]); + }); }); diff --git a/web/src/persistence.ts b/web/src/persistence.ts index 4ac03b9..708e36b 100644 --- a/web/src/persistence.ts +++ b/web/src/persistence.ts @@ -14,12 +14,30 @@ const VERSION = 1; export function saveLayout(layout: Layout): void { try { - localStorage.setItem(KEY, JSON.stringify({ v: VERSION, layout })); + localStorage.setItem(KEY, serializeLayout(layout)); } catch { // Storage full or unavailable (private mode) — persistence is best-effort. } } +/** Serialise the layout for storage with draft state stripped (issue #57): a + * reload gives back an empty form, never a stale half-filled one. */ +export function serializeLayout(layout: Layout): string { + const clean: Layout = { + ...layout, + tabs: layout.tabs.map((t) => ({ + ...t, + root: stripDrafts(t.root), + })), + }; + return JSON.stringify({ v: VERSION, layout: clean }); +} + +function stripDrafts(node: Node | null): Node | null { + if (!node || node.kind !== "leaf") return node; + return { ...node, draft: null }; +} + export function loadLayout(): Layout | null { let raw: string | null; try { From cbfd5eee12b207dd631a659eb5f4e066b31da111 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:52:18 +0100 Subject: [PATCH 04/71] refactor(maestro): extract the spawn form so it can live outside the modal --- web/src/spawn.ts | 418 +++----------------------------- web/src/spawnForm.test.ts | 59 +++++ web/src/spawnForm.ts | 495 ++++++++++++++++++++++++++++++++++++++ web/src/styles.css | 43 ++-- 4 files changed, 612 insertions(+), 403 deletions(-) create mode 100644 web/src/spawnForm.test.ts create mode 100644 web/src/spawnForm.ts diff --git a/web/src/spawn.ts b/web/src/spawn.ts index a3c7040..34683c8 100644 --- a/web/src/spawn.ts +++ b/web/src/spawn.ts @@ -1,26 +1,12 @@ -import { escapeHtml } from "./html.js"; -import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js"; -import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js"; -import { generateName } from "./names.js"; -import type { CreateSessionRequest, RecentDir, SpawnPreset } from "./types.js"; -import { abbreviateCwd, abbreviateFolderName } from "./types.js"; +// The spawn modal (issue #27): bottom-sheet/dialog chrome around the shared +// spawn form (spawnForm.ts). The modal coexists with the inline draft panes +// (#57) until a later issue deletes it. + +import { createSpawnForm, type SpawnFormSeed } from "./spawnForm.js"; /** Seed values for the spawn form — used by "clone" to reopen the dialog with * the same cwd, command, and name as an existing session. */ -export interface SpawnPrefill { - cwd?: string; - command?: string; - name?: string; -} - -/** Built-ins used when the daemon's `/api/info` presets can't be fetched - * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */ -const FALLBACK_PRESETS: SpawnPreset[] = [ - { id: "claude", label: "Claude", command: "claude" }, - { id: "opencode", label: "opencode", command: "opencode" }, - { id: "zsh", label: "Shell", command: "zsh" }, - { id: "custom", label: "Custom…", command: "" }, -]; +export type SpawnPrefill = SpawnFormSeed; export function openSpawnModal(prefill?: SpawnPrefill): void { const existing = document.getElementById("modal-host"); @@ -35,331 +21,35 @@ export function openSpawnModal(prefill?: SpawnPrefill): void {

Spawn session

- +
`; document.body.appendChild(host); - const form = host.querySelector("#spawn-form") as HTMLFormElement; - const errEl = form.querySelector(".form-error") as HTMLElement; - const cmdTiles = host.querySelector("#cmd-tiles") as HTMLElement; - const cmdRaw = host.querySelector("#cmd-raw") as HTMLInputElement; - const recentsEl = host.querySelector("#recents") as HTMLElement; - const cwdRawWrap = host.querySelector("#cwd-raw-wrap") as HTMLElement; - const cwdRaw = host.querySelector("#cwd-raw") as HTMLInputElement; - const wtEnabled = host.querySelector("#wt-enabled") as HTMLInputElement; - const wtHint = host.querySelector("#wt-hint") as HTMLElement; - const wtBody = host.querySelector("#wt-body") as HTMLElement; - const wtRepo = host.querySelector("#wt-repo") as HTMLElement; - const branchInput = host.querySelector("input[name=worktree_branch]") as HTMLInputElement; - const nameInput = host.querySelector("input[name=name]") as HTMLInputElement; - - // ---- form state ------------------------------------------------------ - let homeDir = ""; - let presets: SpawnPreset[] = FALLBACK_PRESETS; - let selectedPresetId = ""; - let command = ""; // resolved command (preset's, or the raw custom input) - let cwd = ""; // chosen working directory - let inferredBaseRepo: string | null = null; // from the gitroot probe - let probeToken = 0; // guards async gitroot probes against races - // A clone's cwd, applied once the (async) recents load for the initial host - // settles — so the fetch can't race past and clobber the seeded directory. - let cloneCwd: string | null = prefill?.cwd ?? null; - - // ---- name (also seeds the worktree branch) --------------------------- - // The worktree branch defaults to the session name — including a clone's - // prefilled name — and tracks it on reroll/edit until the user types their - // own branch. - let branchEdited = false; - const syncBranchToName = () => { - if (!branchEdited) branchInput.value = nameInput.value; - }; - nameInput.value = prefill?.name ?? generateName(); - nameInput.addEventListener("input", syncBranchToName); - branchInput.addEventListener("input", () => { - branchEdited = true; + const formHandle = createSpawnForm({ + seed: prefill, + class: "modal-body", + onSuccess(session) { + close(); + location.hash = `#/attach/${encodeURIComponent(session.id)}`; + }, }); - (host.querySelector("#reroll") as HTMLButtonElement).addEventListener("click", () => { - nameInput.value = generateName(); - syncBranchToName(); - }); - syncBranchToName(); // seed the branch from the initial name + (host.querySelector(".form-slot") as HTMLElement).replaceWith(formHandle.el); - // ---- command tiles --------------------------------------------------- - function selectPreset(id: string): void { - selectedPresetId = id; - const preset = presets.find((p) => p.id === id); - const isCustom = !preset || preset.command === ""; - cmdRaw.hidden = !isCustom; - if (isCustom) { - command = cmdRaw.value.trim(); - cmdRaw.focus(); - } else { - command = preset!.command; - } - for (const tile of cmdTiles.querySelectorAll(".tile")) { - tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id); - } - } - - function renderCommandTiles(): void { - cmdTiles.replaceChildren(); - for (const p of presets) { - const tile = document.createElement("button"); - tile.type = "button"; - tile.className = "tile"; - tile.dataset.id = p.id; - tile.innerHTML = `${escapeHtml(p.label)}`; - tile.addEventListener("click", () => selectPreset(p.id)); - cmdTiles.appendChild(tile); - } - } - - cmdRaw.addEventListener("input", () => { - if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") { - command = cmdRaw.value.trim(); - } - }); - - // Presets ride `/api/info`; render fallbacks immediately, then refine. - renderCommandTiles(); - applyInitialCommand(); - void getDaemonInfo() - .then((info) => { - homeDir = info.home_dir ?? ""; - if (info.presets?.length) { - presets = info.presets; - renderCommandTiles(); - applyInitialCommand(); - } - }) - .catch(() => { - /* keep fallbacks */ - }); - - // Pick a sensible starting command: a clone's command (matched to a preset or - // dropped into custom), else the first non-custom preset. - function applyInitialCommand(): void { - const wanted = prefill?.command; - if (wanted) { - const match = presets.find((p) => p.command === wanted && p.command !== ""); - if (match) { - selectPreset(match.id); - } else { - const custom = presets.find((p) => p.command === ""); - cmdRaw.value = wanted; - selectPreset(custom ? custom.id : "custom"); - command = wanted; - } - return; - } - const first = presets.find((p) => p.command !== ""); - if (first) selectPreset(first.id); - } - - // ---- working-directory picker --------------------------------------- - function showRawCwd(prefillValue?: string): void { - cwdRawWrap.hidden = false; - if (prefillValue != null) cwd = prefillValue; - cwdRaw.value = cwd; - markActiveRecent(null); - cwdRaw.focus(); - cwdRaw.select(); - } - - cwdRaw.addEventListener("input", () => { - cwd = cwdRaw.value.trim(); - if (wtEnabled.checked) probeGitRoot(); - }); - - function markActiveRecent(activeCwd: string | null): void { - for (const row of recentsEl.querySelectorAll(".recent-row")) { - row.classList.toggle( - "is-active", - (row as HTMLElement).dataset.cwd === activeCwd, - ); - } - } - - function selectRecent(dir: RecentDir): void { - cwd = dir.cwd; - cwdRawWrap.hidden = true; - markActiveRecent(dir.cwd); - // Picking a recent dir can pre-select the command it was last used with. - const match = presets.find((p) => p.command === dir.last_command && p.command !== ""); - if (match) selectPreset(match.id); - if (wtEnabled.checked) probeGitRoot(); - } - - async function loadRecents(): Promise { - recentsEl.replaceChildren(loadingRow()); - let dirs: RecentDir[] = []; - try { - dirs = (await recentDirs()).dirs ?? []; - } catch { - dirs = []; - } - recentsEl.replaceChildren(); - for (const dir of dirs) { - recentsEl.appendChild(recentRow(dir)); - } - // Persistent escape hatch + empty-history fallback. - recentsEl.appendChild(otherDirRow()); - if (cloneCwd) { - // A clone seeds the directory verbatim into the raw input, regardless of - // the recorded history. - showRawCwd(cloneCwd); - cloneCwd = null; - } else if (dirs.length === 0) { - // No history: default to raw input prefilled with the daemon's home dir. - showRawCwd(homeDir); - } else { - cwdRawWrap.hidden = true; - } - } - - function recentRow(dir: RecentDir): HTMLElement { - const row = document.createElement("button"); - row.type = "button"; - row.className = "recent-row"; - row.dataset.cwd = dir.cwd; - // Abbreviate exactly like the sidebar: home → `~`, then collapse leading - // segments to initials (`~/g/n/maestro`). The full path stays in the tooltip - // and `dataset.cwd` (what's actually submitted). - const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir)); - row.innerHTML = ` - ${display} - ${escapeHtml(dir.last_command)}`; - row.addEventListener("click", () => selectRecent(dir)); - return row; - } - - function otherDirRow(): HTMLElement { - const row = document.createElement("button"); - row.type = "button"; - row.className = "recent-row recent-other"; - row.innerHTML = `Other directory…`; - row.addEventListener("click", () => showRawCwd(cwd || homeDir)); - return row; - } - - function loadingRow(): HTMLElement { - const p = document.createElement("p"); - p.className = "picker-loading"; - p.textContent = "Loading recent directories…"; - return p; - } - - // ---- worktree (branch-only; base_repo inferred via /gitroot) ---------- - async function probeGitRoot(): Promise { - const token = ++probeToken; - if (!cwd) { - inferredBaseRepo = null; - wtBody.hidden = true; - wtHint.hidden = false; - wtHint.textContent = "Choose a working directory first."; - return; - } - wtHint.hidden = false; - wtHint.textContent = "Checking for a git repository…"; - wtBody.hidden = true; - let root: string | undefined; - try { - root = (await gitRoot(cwd)).root; - } catch { - root = undefined; - } - if (token !== probeToken) return; // a newer probe superseded us - if (root) { - inferredBaseRepo = root; - wtHint.hidden = true; - wtBody.hidden = false; - wtRepo.textContent = `repo: ${root} (inferred)`; - } else { - inferredBaseRepo = null; - wtEnabled.checked = false; - wtBody.hidden = true; - wtHint.hidden = false; - wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree."; - } - } - - wtEnabled.addEventListener("change", () => { - if (wtEnabled.checked) { - probeGitRoot(); - } else { - inferredBaseRepo = null; - wtBody.hidden = true; - wtHint.hidden = true; - } - }); - - void loadRecents(); - - // ---- clone seed ------------------------------------------------------ - if (prefill) { - (host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session"; - // The cwd itself is applied by loadRecents once the initial host's recents - // settle (see `cloneCwd`), so a slow fetch can't clobber it. - } - - // ---- live dir autocomplete on the raw cwd input (NAP-329) ------------ - const autocompletes: DirAutocompleteHandle[] = [attachDirAutocomplete(cwdRaw)]; - - // ---- lifecycle ------------------------------------------------------- const close = () => { - autocompletes.forEach((a) => a.destroy()); + formHandle.destroy(); host.remove(); }; + // The modal keeps its Cancel affordance; the shared form ships only Submit. + const actions = formHandle.el.querySelector(".form-actions") as HTMLElement; + const cancel = document.createElement("button"); + cancel.type = "button"; + cancel.className = "btn btn-ghost cancel"; + cancel.textContent = "Cancel"; + cancel.addEventListener("click", close); + actions.prepend(cancel); + host.querySelector(".close")?.addEventListener("click", close); - host.querySelector(".cancel")?.addEventListener("click", close); host.addEventListener("click", (e) => { if (e.target === host) close(); }); @@ -371,58 +61,8 @@ export function openSpawnModal(prefill?: SpawnPrefill): void { { once: true }, ); - // ---- submit ---------------------------------------------------------- - form.addEventListener("submit", async (e) => { - e.preventDefault(); - errEl.textContent = ""; - if (!cwd) { - errEl.textContent = "Pick a working directory."; - return; - } - if (!command) { - errEl.textContent = "Pick a command (or fill the custom command)."; - return; - } - - const metadata: Record = {}; - const name = nameInput.value.trim(); - if (name) metadata.name = name; - - const req: CreateSessionRequest = { - cwd, - command, - metadata, - }; - - if (wtEnabled.checked) { - const branch = branchInput.value.trim(); - if (!inferredBaseRepo) { - errEl.textContent = "Worktree needs a directory inside a git repository."; - return; - } - if (!branch) { - errEl.textContent = "Worktree requires a branch name."; - return; - } - req.worktree = { - base_repo: inferredBaseRepo, - branch, - cleanup_on_kill: Boolean( - (host.querySelector("input[name=worktree_cleanup_on_kill]") as HTMLInputElement).checked, - ), - }; - } - - const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement; - submitBtn.disabled = true; - try { - const session = await createSession(req); - close(); - location.hash = `#/attach/${encodeURIComponent(session.id)}`; - } catch (err) { - errEl.textContent = (err as Error).message; - submitBtn.disabled = false; - } - }); + // ---- clone seed ------------------------------------------------------ + if (prefill) { + (host.querySelector("#spawn-title") as HTMLElement).textContent = "Clone session"; + } } - diff --git a/web/src/spawnForm.test.ts b/web/src/spawnForm.test.ts new file mode 100644 index 0000000..4b7dd1b --- /dev/null +++ b/web/src/spawnForm.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { buildSpawnRequest, type SpawnFormValues } from "./spawnForm.js"; + +const base: SpawnFormValues = { + command: "claude", + cwd: "/home/user/project", + name: "amber-fox", + worktreeEnabled: false, + worktreeBranch: "", + worktreeCleanup: false, +}; + +describe("buildSpawnRequest", () => { + it("builds a plain request with the picked cwd and command", () => { + const res = buildSpawnRequest(base, null); + expect(res).toEqual({ + req: { cwd: "/home/user/project", command: "claude", metadata: { name: "amber-fox" } }, + }); + }); + + it("omits metadata.name when the name is blank", () => { + const res = buildSpawnRequest({ ...base, name: " " }, null); + expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } }); + }); + + it("attaches a trimmed branch and cleanup flag when worktree is enabled", () => { + const res = buildSpawnRequest( + { ...base, worktreeEnabled: true, worktreeBranch: " feat/foo ", worktreeCleanup: true }, + "/home/user/project", + ); + expect(res).toEqual({ + req: { + cwd: base.cwd, + command: base.command, + metadata: { name: "amber-fox" }, + worktree: { base_repo: "/home/user/project", branch: "feat/foo", cleanup_on_kill: true }, + }, + }); + }); + + it("rejects a missing working directory", () => { + expect(buildSpawnRequest({ ...base, cwd: "" }, null)).toHaveProperty("error"); + }); + + it("rejects a missing command", () => { + expect(buildSpawnRequest({ ...base, command: "" }, null)).toHaveProperty("error"); + }); + + it("rejects a worktree without an inferred base repo", () => { + expect(buildSpawnRequest({ ...base, worktreeEnabled: true, worktreeBranch: "b" }, null)) + .toHaveProperty("error"); + }); + + it("rejects a worktree without a branch name", () => { + expect(buildSpawnRequest({ ...base, worktreeEnabled: true, worktreeBranch: " " }, "/repo")) + .toHaveProperty("error"); + }); +}); diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts new file mode 100644 index 0000000..26e1a3b --- /dev/null +++ b/web/src/spawnForm.ts @@ -0,0 +1,495 @@ +import { escapeHtml } from "./html.js"; +import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js"; +import { attachDirAutocomplete } from "./dirAutocomplete.js"; +import { generateName } from "./names.js"; +import type { CreateSessionRequest, RecentDir, Session, SpawnPreset } from "./types.js"; +import { abbreviateCwd, abbreviateFolderName } from "./types.js"; + +/** Seed values for the spawn form — used by "clone" to reopen with the same + * cwd, command, and name as an existing session, and by draft panes to restore + * their own half-filled state after a re-render. */ +export interface SpawnFormSeed { + command?: string; + cwd?: string; + name?: string; + worktreeEnabled?: boolean; + worktreeBranch?: string; + worktreeCleanup?: boolean; + cwdRawVisible?: boolean; +} + +/** The form's current values, reported on every change so a caller (the draft + * pane) can hang them off its layout leaf. */ +export interface SpawnFormValues { + command: string; + cwd: string; + name: string; + worktreeEnabled: boolean; + worktreeBranch: string; + worktreeCleanup: boolean; + /** Whether the cwd lives in the raw text input (vs a picked recent row). */ + cwdRawVisible?: boolean; +} + +export function buildSpawnRequest( + values: SpawnFormValues, + baseRepo: string | null, +): { req: CreateSessionRequest } | { error: string } { + if (!values.cwd) return { error: "Pick a working directory." }; + if (!values.command) return { error: "Pick a command (or fill the custom command)." }; + + const metadata: Record = {}; + const name = values.name.trim(); + if (name) metadata.name = name; + + const req: CreateSessionRequest = { + cwd: values.cwd, + command: values.command, + metadata, + }; + + if (values.worktreeEnabled) { + const branch = values.worktreeBranch.trim(); + if (!baseRepo) return { error: "Worktree needs a directory inside a git repository." }; + if (!branch) return { error: "Worktree requires a branch name." }; + req.worktree = { + base_repo: baseRepo, + branch, + cleanup_on_kill: values.worktreeCleanup, + }; + } + + return { req }; +} + +/** Built-ins used when the daemon's `/api/info` presets can't be fetched + * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */ +const FALLBACK_PRESETS: SpawnPreset[] = [ + { id: "claude", label: "Claude", command: "claude" }, + { id: "opencode", label: "opencode", command: "opencode" }, + { id: "zsh", label: "Shell", command: "zsh" }, + { id: "custom", label: "Custom…", command: "" }, +]; + +export interface SpawnFormHandle { + el: HTMLFormElement; + /** Remove global listeners (dir autocomplete) — call when unmounting. */ + destroy(): void; +} + +export interface SpawnFormOptions { + seed?: SpawnFormSeed; + /** Extra class(es) for the
root (e.g. `modal-body` in the modal). */ + class?: string; + /** Fired whenever any value changes (input, tile pick, checkbox). */ + onChange?: (values: SpawnFormValues) => void; + /** Called with the created session after a successful submit. */ + onSuccess: (session: Session) => void; +} + +/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree. + * Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */ +export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { + const prefill = opts.seed ?? {}; + + const form = document.createElement("form"); + form.className = `spawn-form${opts.class ? ` ${opts.class}` : ""}`; + form.innerHTML = ` +
+ Command +
+ +
+
+ Working directory +
+ +
+
+ Name +
+ + +
+
+
+ + + + + +
+ +
+ +
+ `; + // Field queries are scoped to this form instance — several forms can be + // mounted at once (modal + draft panes), so ids would collide. + + const errEl = form.querySelector(".form-error") as HTMLElement; + const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement; + const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement; + const recentsEl = form.querySelector(".recents") as HTMLElement; + const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement; + const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement; + const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement; + const wtHint = form.querySelector(".wt-hint") as HTMLElement; + const wtBody = form.querySelector(".wt-body") as HTMLElement; + const wtRepo = form.querySelector(".wt-repo") as HTMLElement; + const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement; + const cleanupInput = form.querySelector( + "input[name=worktree_cleanup_on_kill]", + ) as HTMLInputElement; + const nameInput = form.querySelector("input[name=name]") as HTMLInputElement; + + // ---- form state ------------------------------------------------------ + let homeDir = ""; + let presets: SpawnPreset[] = FALLBACK_PRESETS; + let selectedPresetId = ""; + let command = ""; // resolved command (preset's, or the raw custom input) + let cwd = ""; // chosen working directory + let inferredBaseRepo: string | null = null; // from the gitroot probe + let probeToken = 0; // guards async gitroot probes against races + // A seed's cwd, applied once the (async) recents load for the initial host + // settles — so the fetch can't race past and clobber the seeded directory. + let seedCwd: string | null = prefill.cwd ?? null; + + const emit = () => { + opts.onChange?.({ + command, + cwd, + name: nameInput.value, + worktreeEnabled: wtEnabled.checked, + worktreeBranch: branchInput.value, + worktreeCleanup: cleanupInput.checked, + cwdRawVisible: !cwdRawWrap.hidden, + }); + }; + + // ---- name (also seeds the worktree branch) --------------------------- + // The worktree branch defaults to the session name — including a clone's + // prefilled name — and tracks it on reroll/edit until the user types their + // own branch. + let branchEdited = Boolean(prefill.worktreeBranch); + const syncBranchToName = () => { + if (!branchEdited) branchInput.value = nameInput.value; + }; + nameInput.value = prefill.name ?? generateName(); + nameInput.addEventListener("input", () => { + syncBranchToName(); + emit(); + }); + branchInput.addEventListener("input", () => { + branchEdited = true; + emit(); + }); + (form.querySelector(".reroll") as HTMLButtonElement).addEventListener("click", () => { + nameInput.value = generateName(); + syncBranchToName(); + emit(); + }); + syncBranchToName(); // seed the branch from the initial name + + // ---- command tiles --------------------------------------------------- + function selectPreset(id: string): void { + selectedPresetId = id; + const preset = presets.find((p) => p.id === id); + const isCustom = !preset || preset.command === ""; + cmdRaw.hidden = !isCustom; + if (isCustom) { + command = cmdRaw.value.trim(); + cmdRaw.focus(); + } else { + command = preset!.command; + } + for (const tile of cmdTiles.querySelectorAll(".tile")) { + tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id); + } + emit(); + } + + function renderCommandTiles(): void { + cmdTiles.replaceChildren(); + for (const p of presets) { + const tile = document.createElement("button"); + tile.type = "button"; + tile.className = "tile"; + tile.dataset.id = p.id; + tile.innerHTML = `${escapeHtml(p.label)}`; + tile.addEventListener("click", () => selectPreset(p.id)); + cmdTiles.appendChild(tile); + } + } + + cmdRaw.addEventListener("input", () => { + if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") { + command = cmdRaw.value.trim(); + emit(); + } + }); + + // Presets ride `/api/info`; render fallbacks immediately, then refine. + renderCommandTiles(); + applyInitialCommand(); + + const infoLoad = getDaemonInfo() + .then((info) => { + homeDir = info.home_dir ?? ""; + if (info.presets?.length) { + presets = info.presets; + renderCommandTiles(); + applyInitialCommand(); + } + }) + .catch(() => { + /* keep fallbacks */ + }); + + // Pick a sensible starting command: a seed's command (matched to a preset or + // dropped into custom), else the first non-custom preset. + function applyInitialCommand(): void { + const wanted = prefill.command; + if (wanted) { + const match = presets.find((p) => p.command === wanted && p.command !== ""); + if (match) { + selectPreset(match.id); + } else { + const custom = presets.find((p) => p.command === ""); + cmdRaw.value = wanted; + selectPreset(custom ? custom.id : "custom"); + command = wanted; + } + return; + } + const first = presets.find((p) => p.command !== ""); + if (first) selectPreset(first.id); + } + + // ---- working-directory picker --------------------------------------- + function showRawCwd(prefillValue?: string, focus = true): void { + cwdRawWrap.hidden = false; + if (prefillValue != null) cwd = prefillValue; + cwdRaw.value = cwd; + markActiveRecent(null); + if (focus) { + cwdRaw.focus(); + cwdRaw.select(); + } + } + + cwdRaw.addEventListener("input", () => { + cwd = cwdRaw.value.trim(); + emit(); + if (wtEnabled.checked) probeGitRoot(); + }); + + function markActiveRecent(activeCwd: string | null): void { + for (const row of recentsEl.querySelectorAll(".recent-row")) { + row.classList.toggle( + "is-active", + (row as HTMLElement).dataset.cwd === activeCwd, + ); + } + } + + function selectRecent(dir: RecentDir): void { + cwd = dir.cwd; + cwdRawWrap.hidden = true; + markActiveRecent(dir.cwd); + emit(); + // Picking a recent dir can pre-select the command it was last used with. + const match = presets.find((p) => p.command === dir.last_command && p.command !== ""); + if (match) selectPreset(match.id); + if (wtEnabled.checked) probeGitRoot(); + } + + async function loadRecents(): Promise { + recentsEl.replaceChildren(loadingRow()); + let dirs: RecentDir[] = []; + try { + dirs = (await recentDirs()).dirs ?? []; + } catch { + dirs = []; + } + recentsEl.replaceChildren(); + for (const dir of dirs) { + recentsEl.appendChild(recentRow(dir)); + } + // Persistent escape hatch + empty-history fallback. + recentsEl.appendChild(otherDirRow()); + if (seedCwd) { + // A seed sets the directory verbatim, regardless of recorded history. + // `cwdRawVisible === false` marks a draft restore whose cwd came from a + // picked recent row: keep the picker and don't steal focus. Otherwise + // land focused in the raw input (clone / empty-history flows). + if (prefill.cwdRawVisible === false) { + cwd = seedCwd; + cwdRaw.value = seedCwd; + markActiveRecent(cwd); + } else { + showRawCwd(seedCwd); + } + seedCwd = null; + } else if (dirs.length === 0) { + // No history: default to raw input prefilled with the daemon's home dir. + showRawCwd(homeDir); + } else { + cwdRawWrap.hidden = true; + } + // The seeded cwd is in place — a restored worktree can probe now. + if (wtEnabled.checked) void probeGitRoot(); + } + + function recentRow(dir: RecentDir): HTMLElement { + const row = document.createElement("button"); + row.type = "button"; + row.className = "recent-row"; + row.dataset.cwd = dir.cwd; + // Abbreviate exactly like the sidebar: home → `~`, then collapse leading + // segments to initials (`~/g/n/maestro`). The full path stays in the tooltip + // and `dataset.cwd` (what's actually submitted). + const display = abbreviateFolderName(abbreviateCwd(dir.cwd, homeDir)); + row.innerHTML = ` + ${display} + ${escapeHtml(dir.last_command)}`; + row.addEventListener("click", () => selectRecent(dir)); + return row; + } + + function otherDirRow(): HTMLElement { + const row = document.createElement("button"); + row.type = "button"; + row.className = "recent-row recent-other"; + row.innerHTML = `Other directory…`; + row.addEventListener("click", () => showRawCwd(cwd || homeDir)); + return row; + } + + function loadingRow(): HTMLElement { + const p = document.createElement("p"); + p.className = "picker-loading"; + p.textContent = "Loading recent directories…"; + return p; + } + + // ---- worktree (branch-only; base_repo inferred via /gitroot) ---------- + async function probeGitRoot(): Promise { + const token = ++probeToken; + if (!cwd) { + inferredBaseRepo = null; + wtBody.hidden = true; + wtHint.hidden = false; + wtHint.textContent = "Choose a working directory first."; + return; + } + wtHint.hidden = false; + wtHint.textContent = "Checking for a git repository…"; + wtBody.hidden = true; + let root: string | undefined; + try { + root = (await gitRoot(cwd)).root; + } catch { + root = undefined; + } + if (token !== probeToken) return; // a newer probe superseded us + if (root) { + inferredBaseRepo = root; + wtHint.hidden = true; + wtBody.hidden = false; + wtRepo.textContent = `repo: ${root} (inferred)`; + } else { + inferredBaseRepo = null; + wtEnabled.checked = false; + wtBody.hidden = true; + wtHint.hidden = false; + wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree."; + } + } + + wtEnabled.addEventListener("change", () => { + emit(); + if (wtEnabled.checked) { + probeGitRoot(); + } else { + inferredBaseRepo = null; + wtBody.hidden = true; + wtHint.hidden = true; + } + }); + cleanupInput.addEventListener("change", emit); + + // A seeded worktree restores its checkbox/body up front; its gitroot probe + // waits for loadRecents to apply the seeded cwd. + if (prefill.worktreeEnabled || prefill.worktreeBranch || prefill.worktreeCleanup) { + wtEnabled.checked = Boolean(prefill.worktreeEnabled); + if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch; + cleanupInput.checked = Boolean(prefill.worktreeCleanup); + wtBody.hidden = !wtEnabled.checked; + wtHint.hidden = true; + emit(); + } + + // Seed ordering: the raw-cwd application races the async info load (which + // provides homeDir for the recents' abbreviations), so recents wait for it. + void infoLoad.then(() => loadRecents()); + + // ---- live dir autocomplete on the raw cwd input (NAP-329) ------------ + const autocomplete = attachDirAutocomplete(cwdRaw); + + // ---- submit ---------------------------------------------------------- + form.addEventListener("submit", async (e) => { + e.preventDefault(); + errEl.textContent = ""; + const built = buildSpawnRequest( + { + command, + cwd, + name: nameInput.value, + worktreeEnabled: wtEnabled.checked, + worktreeBranch: branchInput.value, + worktreeCleanup: cleanupInput.checked, + }, + inferredBaseRepo, + ); + if ("error" in built) { + errEl.textContent = built.error; + return; + } + + const submitBtn = form.querySelector("button[type=submit]") as HTMLButtonElement; + submitBtn.disabled = true; + try { + const session = await createSession(built.req); + opts.onSuccess(session); + } catch (err) { + errEl.textContent = (err as Error).message; + submitBtn.disabled = false; + } + }); + + return { + el: form, + destroy() { + autocomplete.destroy(); + form.remove(); + }, + }; +} diff --git a/web/src/styles.css b/web/src/styles.css index 5061384..cd01222 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1280,7 +1280,8 @@ textarea { scrollbar-color: var(--border-strong) transparent; } -.modal-body label { +.modal-body label, +.spawn-form label { display: flex; flex-direction: column; gap: 5px; @@ -1289,7 +1290,8 @@ textarea { letter-spacing: 0.01em; } -.modal-body label.row-inline { +.modal-body label.row-inline, +.spawn-form label.row-inline { flex-direction: row; align-items: center; gap: var(--sp-2); @@ -1300,7 +1302,8 @@ textarea { .modal-body input[type="text"], .modal-body input[type="search"], -.modal-body select { +.modal-body select, +.spawn-form input[type="text"] { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-2); @@ -1316,20 +1319,23 @@ textarea { } .modal-body input[type="text"]:hover, -.modal-body select:hover { +.modal-body select:hover, +.spawn-form input[type="text"]:hover { border-color: var(--border-strong); } .modal-body input[type="text"]:focus, .modal-body input[type="search"]:focus, -.modal-body select:focus { +.modal-body select:focus, +.spawn-form input[type="text"]:focus { outline: none; border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-tint); } -.modal-body input[type="checkbox"] { +.modal-body input[type="checkbox"], +.spawn-form input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); @@ -1375,7 +1381,8 @@ textarea { /* ---------- spawn form: tiles + recents picker (#27) ---------- */ -.spawn-modal .field { +.spawn-modal .field, +.spawn-form .field { display: flex; flex-direction: column; gap: var(--sp-2); @@ -1546,6 +1553,13 @@ textarea { flex: 1 1 auto; } +.form-actions { + display: flex; + gap: var(--sp-2); + justify-content: flex-end; + margin-top: var(--sp-2); +} + .reroll { flex: 0 0 auto; } @@ -2056,14 +2070,15 @@ textarea { display: none; } -.mv-pane-empty { +/* Draft pane (#57): an empty pane renders the shared spawn form inline. + The host is already a flex column; the form fills and scrolls within it. */ +.mv-pane-host > .mv-draft { flex: 1; - display: grid; - place-items: center; - padding: var(--sp-4); - color: var(--faint); - font: var(--fs-12) / 1.5 var(--font-mono); - text-align: center; + min-width: 0; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; } /* pane tools cluster (disconnect / split / close), top-right corner. The attach From 790d1e207532e84919143f4c418cf71300e645fc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:52:18 +0100 Subject: [PATCH 05/71] feat(maestro): empty panes render an inline spawn draft form (#57) --- web/src/main.ts | 3 +++ web/src/multiview.ts | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index aa5fd48..7e81f41 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -236,6 +236,9 @@ mv = createMultiView({ infoFor, onDisconnect: closePane, renderWelcome, + // A draft-pane submit (#57) fills its leaf in place; the shell route may flip + // from catalog (no sessions) to attach. + onOpenSessionsChange: updateShellRoute, onLayoutChange: scheduleSave, }); let contentShowsMv = false; diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 777865b..725fd53 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -1,8 +1,9 @@ // The multi-view renderer/controller (issues #30 splits, #31 tabs, #33 // highlights). Owns the tab bar + canvas inside the content pane, builds the // active tab's split tree into DOM, and mounts a live AttachConnection into each -// session leaf via the pool. Empty leaves (freshly split, not yet filled) show a -// placeholder. Drag-to-dock is a later issue (#32); mobile projection is #35. +// session leaf via the pool. Empty leaves are draft panes (#57): they render +// the spawn form inline, and submitting replaces the draft with that session's +// terminal in place. Drag-to-dock is issue #32; mobile projection is #35. import type { SidebarHandle } from "./catalog.js"; import type { ConnectionPool } from "./connections.js"; @@ -22,6 +23,7 @@ import { } from "./layout.js"; import { sessionColor } from "./sessioncolor.js"; import type { AttachInfo } from "./attach.js"; +import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; export interface MultiViewOptions { pool: ConnectionPool; @@ -33,6 +35,9 @@ export interface MultiViewOptions { onDisconnect: (sessionId: string) => void; /** Welcome content for an empty tab. */ renderWelcome: () => HTMLElement; + /** Called when a draft-pane submit turns an empty pane into a session pane, + * so the shell can re-evaluate its route (sidebar vs attach layout). */ + onOpenSessionsChange?: () => void; /** Called after any layout change (structure, focus, or divider resize) so the * shell can persist it (issue #34). */ onLayoutChange?: () => void; @@ -60,6 +65,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts; const persist = () => opts.onLayoutChange?.(); + // Draft-pane forms (#57) carry global listeners (dir autocomplete), so each + // render tears down the previous generation before rebuilding the canvas. + const draftForms = new Set(); + const el = document.createElement("div"); el.className = "mv"; const tabBar = document.createElement("div"); @@ -133,7 +142,26 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const host = document.createElement("div"); host.className = "mv-pane-host"; if (!leaf.sessionId) { - host.innerHTML = `
Empty pane — pick a session from the sidebar.
`; + // Draft pane (#57): an empty pane is a spawn form. Values hang off the + // leaf (never persisted) so each draft keeps its own half-filled state; + // submitting fills this leaf with the new session in place. + const form = createSpawnForm({ + seed: leaf.draft ?? undefined, + class: "mv-draft", + onChange: (values) => { + leaf.draft = values; + }, + onSuccess: (session) => { + leaf.sessionId = session.id; + leaf.draft = null; + const t = activeTab(layout); + if (leaves(t.root).includes(leaf)) t.focusedLeafId = leaf.id; + render(); + opts.onOpenSessionsChange?.(); + }, + }); + draftForms.add(form); + host.appendChild(form.el); } const tools = document.createElement("div"); @@ -326,6 +354,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // Fill the focused pane (VS Code style: open replaces the active group). const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0]; target.sessionId = sessionId; + target.draft = null; tab.focusedLeafId = target.id; render(); } @@ -333,6 +362,8 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // ---- top-level render ---- function render(): void { + for (const f of draftForms) f.destroy(); + draftForms.clear(); renderTabs(); canvas.replaceChildren(); const tab = activeTab(layout); @@ -569,6 +600,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { if (source.kind === "pane" && self) { const dragged = findLeaf(tab.root, self)!; [dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId]; + [dragged.draft, target.draft] = [target.draft, dragged.draft]; tab.focusedLeafId = target.id; } else if (sessionId) { const loc = locateSession(layout, sessionId); @@ -576,6 +608,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const t2 = findLeaf(tab.root, targetLeafId); if (t2) { t2.sessionId = sessionId; + t2.draft = null; tab.focusedLeafId = t2.id; } } From e3996dec769aa8ac4bb8177550dc508ee695aa68 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:57:24 +0100 Subject: [PATCH 06/71] feat(maestro): wire spawn overlay fields to the text-input widget --- crates/maestro-tui/src/app.rs | 12 +-- crates/maestro-tui/src/overlays.rs | 133 ++++++++++++++++++--------- crates/maestro-tui/src/terminal.rs | 18 +++- crates/maestro-tui/src/text_input.rs | 35 ++++--- 4 files changed, 130 insertions(+), 68 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 82a46f7..aa9574d 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -190,8 +190,8 @@ impl App { } SpawnInputResult::Submit => { effects.push(Effect::Spawn(CreateSessionRequest { - cwd: std::path::PathBuf::from(self.spawn.cwd.clone()), - command: self.spawn.command.clone(), + cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()), + command: self.spawn.command.value().to_string(), env: Default::default(), metadata: Default::default(), worktree: None, @@ -267,8 +267,8 @@ impl App { } Action::OpenSpawnModal => { self.spawn = SpawnModal { - cwd: String::new(), - command: String::new(), + cwd: Default::default(), + command: Default::default(), focused_field: Default::default(), }; self.state.show_overlay(OverlayKind::Spawn); @@ -590,8 +590,8 @@ mod tests { fn submitting_the_spawn_modal_carries_the_typed_fields() { let mut app = make_app(); app.dispatch(Action::OpenSpawnModal); - app.spawn.cwd = "/srv/work".into(); - app.spawn.command = "claude".into(); + app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work"); + app.spawn.command = crate::text_input::TextInput::with_value("claude"); app.spawn.focused_field = crate::overlays::SpawnField::Submit; let (_, effects) = app.handle_overlay_input(&InputKey::Enter); diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 5cc5722..5bb49d2 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -20,8 +20,8 @@ pub enum SpawnField { #[derive(Debug, Default, Clone)] pub struct SpawnModal { - pub cwd: String, - pub command: String, + pub cwd: crate::text_input::TextInput, + pub command: crate::text_input::TextInput, pub focused_field: SpawnField, } @@ -64,27 +64,16 @@ impl SpawnModal { SpawnInputResult::Consumed } }, - InputKey::Char { c, .. } => { + // Everything else — characters, motion, editing, paste — goes to + // the focused field's text input. + other => { match self.focused_field { - SpawnField::Cwd => self.cwd.push(*c), - SpawnField::Command => self.command.push(*c), + SpawnField::Cwd => self.cwd.handle_key(other), + SpawnField::Command => self.command.handle_key(other), SpawnField::Submit => {} } SpawnInputResult::Consumed } - InputKey::Backspace => { - match self.focused_field { - SpawnField::Cwd => { - self.cwd.pop(); - } - SpawnField::Command => { - self.command.pop(); - } - _ => {} - } - SpawnInputResult::Consumed - } - _ => SpawnInputResult::Consumed, } } @@ -107,20 +96,40 @@ impl SpawnModal { Style::default() } }; - let field_row = |label: &str, value: &str, style: Style| { - Paragraph::new(Line::from(vec![ - ratatui::text::Span::raw(format!("{label:8} ")), - ratatui::text::Span::styled(value.to_string(), style), - ])) + + // Each text field renders through the shared `TextInput`, which + // handles horizontal scrolling and reports where the terminal cursor + // belongs. Label is fixed-width 8 chars + 1 space = column 9. + let label = + |text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} ")))); + f.render_widget(label("cwd:"), rows[0]); + f.render_widget(label("command:"), rows[1]); + let draw_field = |f: &mut Frame, + input: &crate::text_input::TextInput, + row: Rect, + focused: bool| + -> u16 { + let style = if focused { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + }; + let area = Rect { + x: row.x.saturating_add(9), + y: row.y, + width: row.width.saturating_sub(9), + height: 1, + }; + input.render(area, f.buffer_mut(), style) }; - f.render_widget( - field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)), - rows[0], - ); - f.render_widget( - field_row("command:", &self.command, highlight(SpawnField::Command)), + let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd); + let cmd_col = draw_field( + f, + &self.command, rows[1], + self.focused_field == SpawnField::Command, ); + f.render_widget( Paragraph::new(Line::from(vec![ratatui::text::Span::styled( "[ Spawn ]", @@ -129,20 +138,9 @@ impl SpawnModal { rows[2], ); - // Place the cursor at the end of the currently-focused text field so - // the user can see where their typing lands. Label is fixed-width - // 8 chars + 1 space = column 9 inside the row. - let cursor_col_for = |value: &str| -> u16 { - inner - .x - .saturating_add(9) - .saturating_add(value.chars().count() as u16) - }; match self.focused_field { - SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[0].y)), - SpawnField::Command => { - f.set_cursor_position((cursor_col_for(&self.command), rows[1].y)) - } + SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)), + SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)), SpawnField::Submit => { // The submit button is indicated by the REVERSED style; no // cursor placement. @@ -267,8 +265,8 @@ mod tests { #[test] fn spawn_modal_renders_field_labels() { let modal = SpawnModal { - cwd: "/x".into(), - command: "bash".into(), + cwd: crate::text_input::TextInput::with_value("/x"), + command: crate::text_input::TextInput::with_value("bash"), focused_field: SpawnField::Cwd, }; let rendered = render_to_string(80, 20, |f| modal.render(f, f.area())); @@ -338,4 +336,51 @@ mod tests { assert_eq!(modal.focused_field, SpawnField::Cwd); } } + + /// Editing keys must reach the focused field's text input, not a bare + /// String — this is what gives the operator cursor motion and word ops. + #[test] + fn editing_keys_reach_the_focused_text_input() { + let mut modal = SpawnModal::default(); + assert_eq!(modal.focused_field, SpawnField::Cwd); + for k in [ + crate::input::InputKey::char('/'), + crate::input::InputKey::char('t'), + crate::input::InputKey::char('m'), + crate::input::InputKey::char('p'), + ] { + modal.on_input(&k); + } + modal.on_input(&crate::input::InputKey::Left); + modal.on_input(&crate::input::InputKey::Backspace); + assert_eq!(modal.cwd.value(), "/tp"); + + // Tab to command, then a paste and ctrl-w land there, not in cwd. + modal.on_input(&crate::input::InputKey::Tab); + assert_eq!(modal.focused_field, SpawnField::Command); + modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into())); + assert_eq!(modal.command.value(), "/tmp/foo"); + modal.on_input(&crate::input::InputKey::ctrl_char('w')); + assert_eq!(modal.command.value(), ""); + assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused"); + } + + /// The terminal cursor must sit right after the typed text in the focused + /// field (label is 8 chars + 1 space, modal is centered on screen). + #[test] + fn render_places_the_cursor_after_the_typed_text() { + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + let modal = SpawnModal { + cwd: crate::text_input::TextInput::with_value("abc"), + ..SpawnModal::default() + }; + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| modal.render(f, f.area())).unwrap(); + // Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9. + let pos = terminal.get_cursor_position().unwrap(); + assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6)); + } } diff --git a/crates/maestro-tui/src/terminal.rs b/crates/maestro-tui/src/terminal.rs index eeb5fb5..e21df3f 100644 --- a/crates/maestro-tui/src/terminal.rs +++ b/crates/maestro-tui/src/terminal.rs @@ -5,7 +5,9 @@ use std::io; -use crossterm::event::{DisableMouseCapture, EnableMouseCapture}; +use crossterm::event::{ + DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, +}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -26,7 +28,12 @@ impl TerminalGuard { pub fn enter() -> Result { enable_raw_mode()?; let mut out = io::stdout(); - execute!(out, EnterAlternateScreen, EnableMouseCapture)?; + execute!( + out, + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; Ok(TerminalGuard) } @@ -34,7 +41,12 @@ impl TerminalGuard { /// call it without producing weird errors. fn restore() { let mut out = io::stdout(); - let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen); + let _ = execute!( + out, + DisableBracketedPaste, + DisableMouseCapture, + LeaveAlternateScreen + ); let _ = disable_raw_mode(); } } diff --git a/crates/maestro-tui/src/text_input.rs b/crates/maestro-tui/src/text_input.rs index 402b7ce..55a3a4a 100644 --- a/crates/maestro-tui/src/text_input.rs +++ b/crates/maestro-tui/src/text_input.rs @@ -19,8 +19,10 @@ pub struct TextInput { value: String, /// Cursor position as a char offset into `value`. cursor: usize, - /// Char offset of the leftmost visible cell. - scroll: usize, + /// Char offset of the leftmost visible cell. A `Cell` so `render` can + /// re-clamp it without `&mut self` — widgets are drawn through shared + /// references in this codebase. + scroll: std::cell::Cell, } impl TextInput { @@ -44,7 +46,7 @@ impl TextInput { /// Char offset of the leftmost visible cell; only meaningful after /// [`TextInput::render`] has clamped it to the field width. pub fn scroll_offset(&self) -> usize { - self.scroll + self.scroll.get() } /// Insert `text` at the cursor as one operation. Used both for typed @@ -110,21 +112,24 @@ impl TextInput { /// Draw into `area` (expected one row tall), scrolling horizontally so the /// cursor stays visible, and return the screen column where the caller /// should place the terminal cursor. - pub fn render(&mut self, area: Rect, buf: &mut Buffer, style: Style) -> u16 { + pub fn render(&self, area: Rect, buf: &mut Buffer, style: Style) -> u16 { let width = area.width as usize; let len = self.value.chars().count(); - self.scroll = self.scroll.min(len); - if self.cursor < self.scroll { - self.scroll = self.cursor; - } else if width > 0 && self.cursor.saturating_sub(self.scroll) >= width { - self.scroll = self.cursor + 1 - width; - } - for (col, c) in self.value.chars().skip(self.scroll).take(width).enumerate() { + let scroll = self.scroll.get().min(len); + let scroll = if self.cursor < scroll { + self.cursor + } else if width > 0 && self.cursor.saturating_sub(scroll) >= width { + self.cursor + 1 - width + } else { + scroll + }; + self.scroll.set(scroll); + for (col, c) in self.value.chars().skip(scroll).take(width).enumerate() { buf[(area.x + col as u16, area.y)] .set_symbol(&c.to_string()) .set_style(style); } - area.x + self.cursor.saturating_sub(self.scroll) as u16 + area.x + self.cursor.saturating_sub(scroll) as u16 } fn byte_offset(&self, char_index: usize) -> usize { @@ -378,7 +383,7 @@ mod tests { #[test] fn rendering_an_empty_field_points_at_its_first_column() { - let mut input = TextInput::default(); + let input = TextInput::default(); let area = Rect::new(10, 5, 20, 1); let mut buf = Buffer::empty(area); let col = input.render(area, &mut buf, Style::default()); @@ -402,7 +407,7 @@ mod tests { #[test] fn rendering_a_value_wider_than_the_field_shows_its_tail() { let value = "0123456789abcdefghij"; - let mut input = TextInput::with_value(value); + let input = TextInput::with_value(value); let area = Rect::new(0, 0, 10, 1); let mut buf = Buffer::empty(area); let col = input.render(area, &mut buf, Style::default()); @@ -435,7 +440,7 @@ mod tests { let col = input.render(area, &mut buf, Style::default()); assert_eq!(input.cursor(), 5); assert_eq!( - input.value().chars().nth(input.scroll), + input.value().chars().nth(input.scroll_offset()), Some('5'), "window scrolled back to the cursor" ); From 85e7749e5af9677366da84b78f75629d396eaaa0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:05:26 +0100 Subject: [PATCH 07/71] feat(maestro): worktree specs can check out an existing branch --- crates/maestro-cli/src/sessions.rs | 1 + crates/maestro-daemon/src/session.rs | 18 ++++--- crates/maestro-daemon/src/worktree.rs | 36 ++++++++++---- crates/maestro-daemon/tests/worktree.rs | 66 +++++++++++++++++++++++-- crates/maestro-e2e/tests/e2e.rs | 35 +++++++++++++ crates/maestro-protocol/src/lib.rs | 35 +++++++++++++ 6 files changed, 169 insertions(+), 22 deletions(-) diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 2e3729a..e3ca37e 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -44,6 +44,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result None, }; diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 2372aef..41845eb 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -213,14 +213,16 @@ impl SessionManager { /// Spawn a new session. On success returns the freshly-created `Session`. pub async fn create(&self, req: CreateSessionRequest) -> Result { let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree { - Some(WorktreeSpec { - base_repo, branch, .. - }) => { - let path = self - .inner - .worktree_mgr - .add(base_repo, branch) - .map_err(SessionError::Worktree)?; + Some(spec) => { + let WorktreeSpec { + base_repo, branch, .. + } = spec; + let path = if spec.create_branch { + self.inner.worktree_mgr.add_new(base_repo, branch) + } else { + self.inner.worktree_mgr.add_existing(base_repo, branch) + } + .map_err(SessionError::Worktree)?; (path.clone(), Some(path), Some(base_repo.clone())) } None => (req.cwd.clone(), None, None), diff --git a/crates/maestro-daemon/src/worktree.rs b/crates/maestro-daemon/src/worktree.rs index b5ffa63..77f38b8 100644 --- a/crates/maestro-daemon/src/worktree.rs +++ b/crates/maestro-daemon/src/worktree.rs @@ -31,17 +31,35 @@ impl WorktreeManager { &self.root } - /// `git -C worktree add -b `. - /// Returns the path of the new worktree on success. - pub fn add(&self, base_repo: &Path, branch: &str) -> Result { + /// `git -C worktree add -b ` — creates the + /// branch. Returns the path of the new worktree on success. + pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result { + self.run_add(base_repo, branch, true) + } + + /// `git -C worktree add ` — checks out a branch + /// that must already exist (#56). + pub fn add_existing(&self, base_repo: &Path, branch: &str) -> Result { + self.run_add(base_repo, branch, false) + } + + fn run_add( + &self, + base_repo: &Path, + branch: &str, + create_branch: bool, + ) -> Result { std::fs::create_dir_all(&self.root)?; let path = self.generate_path(branch); - let out = Command::new("git") - .arg("-C") - .arg(base_repo) - .args(["worktree", "add", "-b", branch]) - .arg(&path) - .output()?; + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(base_repo).args(["worktree", "add"]); + if create_branch { + cmd.arg("-b").arg(branch).arg(&path); + } else { + // Existing branch: `worktree add ` with no `-b`. + cmd.arg(&path).arg(branch); + } + let out = cmd.output()?; check(out)?; Ok(path) } diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs index 1087ac6..c82e085 100644 --- a/crates/maestro-daemon/tests/worktree.rs +++ b/crates/maestro-daemon/tests/worktree.rs @@ -33,7 +33,7 @@ fn add_creates_worktree_on_new_branch() { let wt_root = TempDir::new().unwrap(); let mgr = WorktreeManager::new(wt_root.path()); - let path = mgr.add(&repo, "feat/example").expect("add worktree"); + let path = mgr.add_new(&repo, "feat/example").expect("add worktree"); assert!(path.exists()); assert!(path.join("README").exists()); assert!(path.starts_with(wt_root.path())); @@ -51,8 +51,8 @@ fn add_twice_generates_distinct_paths() { let wt_root = TempDir::new().unwrap(); let mgr = WorktreeManager::new(wt_root.path()); - let p1 = mgr.add(&repo, "feat/a").expect("add a"); - let p2 = mgr.add(&repo, "feat/b").expect("add b"); + let p1 = mgr.add_new(&repo, "feat/a").expect("add a"); + let p2 = mgr.add_new(&repo, "feat/b").expect("add b"); assert_ne!(p1, p2); assert!(p1.exists()); assert!(p2.exists()); @@ -67,7 +67,7 @@ fn remove_prunes_worktree() { let wt_root = TempDir::new().unwrap(); let mgr = WorktreeManager::new(wt_root.path()); - let path = mgr.add(&repo, "feat/gone").expect("add"); + let path = mgr.add_new(&repo, "feat/gone").expect("add"); assert!(path.exists()); mgr.remove(&repo, &path).expect("remove"); @@ -79,6 +79,62 @@ fn remove_prunes_worktree() { assert_eq!(branches, vec!["main".to_string()]); } +#[test] +fn add_existing_checks_out_branch_without_creating_it() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + // Pre-create the branch with its own commit so we can tell a checkout + // from an accidental `-b`. + let created = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["checkout", "-q", "-b", "feat/dup"]) + .status() + .unwrap(); + assert!(created.success()); + std::fs::write(repo.join("FEATURE"), b"dup\n").unwrap(); + let staged = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["add", "."]) + .status() + .unwrap(); + assert!(staged.success()); + let committed = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["commit", "-q", "-am", "wip"]) + .status() + .unwrap(); + assert!(committed.success()); + let back = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["checkout", "-q", "main"]) + .status() + .unwrap(); + assert!(back.success()); + + let wt_root = TempDir::new().unwrap(); + let mgr = WorktreeManager::new(wt_root.path()); + + let path = mgr.add_existing(&repo, "feat/dup").expect("add existing"); + assert!(path.exists()); + assert!(path.join("README").exists()); + + // The branch set is unchanged: `feat/dup` was checked out, not recreated. + let branches = worktree_branches(&repo); + let mut sorted = branches.clone(); + sorted.sort(); + assert_eq!( + sorted, + vec!["feat/dup".to_string(), "main".to_string()], + "{branches:?}" + ); +} + #[test] fn add_fails_when_branch_already_exists() { let repo_dir = TempDir::new().unwrap(); @@ -97,7 +153,7 @@ fn add_fails_when_branch_already_exists() { let wt_root = TempDir::new().unwrap(); let mgr = WorktreeManager::new(wt_root.path()); - let err = mgr.add(&repo, "feat/dup").unwrap_err(); + let err = mgr.add_new(&repo, "feat/dup").unwrap_err(); let msg = format!("{err}"); assert!(msg.contains("git exited"), "{msg}"); } diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index 5cb166a..f3fe278 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -129,6 +129,7 @@ async fn cleanup_on_kill_removes_worktree() { base_repo: base.path().to_path_buf(), branch: "feature/e2e".into(), cleanup_on_kill: true, + create_branch: true, }), }; @@ -160,6 +161,40 @@ async fn cleanup_on_kill_removes_worktree() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn spawn_on_existing_branch_checks_it_out() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + run_git(&["branch", "feature/existing"], base.path()); + + let req = CreateSessionRequest { + cwd: base.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: base.path().to_path_buf(), + branch: "feature/existing".into(), + cleanup_on_kill: true, + create_branch: false, + }), + }; + + let s = client + .spawn_session(&req) + .await + .expect("spawn on existing branch"); + let worktrees = git_worktree_list(base.path()); + assert!( + worktrees.iter().any(|w| w.contains("feature/existing")), + "expected a worktree checked out at feature/existing, got: {worktrees:?}" + ); + + client.kill_session(s.id).await.expect("kill"); +} + #[tokio::test(flavor = "multi_thread")] async fn daemon_restart_recovers_session() { let (mut daemon, client) = bring_up().await; diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index fe001b8..4b6fac9 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -113,6 +113,15 @@ pub struct WorktreeSpec { pub branch: String, #[serde(default)] pub cleanup_on_kill: bool, + /// When false, `branch` must already exist and is checked out as-is + /// (#56); when true (the default, and what every payload meant before + /// the field existed) the daemon creates it with `worktree add -b`. + #[serde(default = "default_true")] + pub create_branch: bool, +} + +fn default_true() -> bool { + true } /// HTTP `POST /api/sessions` body. @@ -393,6 +402,7 @@ mod tests { base_repo: PathBuf::from("/home/naps62/projects/maestro"), branch: "feat/foo".into(), cleanup_on_kill: true, + create_branch: true, }), }; assert_eq!(req, round_trip(&req)); @@ -410,6 +420,31 @@ mod tests { assert!(req.metadata.is_empty()); } + /// A client that predates the new/existing discriminator sends no + /// `create_branch`; the daemon must read that as "create a new branch" — + /// exactly what every payload meant before the field existed (#56). + #[test] + fn worktree_spec_defaults_create_branch_true_on_legacy_payload() { + let json = serde_json::json!({ + "base_repo": "/home/naps62/projects/maestro", + "branch": "feat/foo", + "cleanup_on_kill": true, + }); + let spec: WorktreeSpec = serde_json::from_value(json).unwrap(); + assert!(spec.create_branch); + } + + #[test] + fn worktree_spec_round_trips_existing_branch_mode() { + let spec = WorktreeSpec { + base_repo: PathBuf::from("/work"), + branch: "feat/foo".into(), + cleanup_on_kill: true, + create_branch: false, + }; + assert_eq!(spec, round_trip(&spec)); + } + #[test] fn catalog_event_round_trips_and_tags() { let session = sample_session(); From 018f44727c6170635739d20d23b32d88921940b9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:09:38 +0100 Subject: [PATCH 08/71] feat(maestro): place worktrees at /worktrees/ --- crates/maestro-config/src/lib.rs | 5 +- crates/maestro-daemon/src/cli.rs | 3 +- crates/maestro-daemon/src/config.rs | 12 ++-- crates/maestro-daemon/src/worktree.rs | 53 ++++++++++----- crates/maestro-daemon/tests/common/mod.rs | 6 +- .../maestro-daemon/tests/session_decisions.rs | 4 +- .../maestro-daemon/tests/session_manager.rs | 6 +- crates/maestro-daemon/tests/worktree.rs | 67 +++++++++++++++++-- crates/maestro-e2e/tests/e2e.rs | 12 ++-- docs/INSTALL.md | 5 +- 10 files changed, 123 insertions(+), 50 deletions(-) diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 57b2a6d..4780720 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -87,8 +87,9 @@ pub struct DaemonSection { /// Root for everything daemon-local: sidecars, FIFOs, etc. #[serde(default)] pub state_dir: Option, - /// Root for ephemeral worktrees the daemon creates on `CreateSession` - /// with a `worktree` spec. + /// Override root for ephemeral worktrees the daemon creates on + /// `CreateSession` with a `worktree` spec. Unset by default: worktrees + /// then live inside their base repo at `/worktrees/` (#56). #[serde(default)] pub worktree_root: Option, /// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index 542440b..a836059 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -47,7 +47,8 @@ async fn run(config_override: Option) -> Result<(), Box> { let _ = default_state_dir(); std::fs::create_dir_all(config.sidecar_dir())?; std::fs::create_dir_all(config.fifo_dir())?; - std::fs::create_dir_all(&config.worktree_root)?; + // No worktree dir to pre-create: without an override the worktrees live + // under their base repos, created on demand (#56). let sidecar = SidecarStore::new(config.sidecar_dir()); let worktree_mgr = WorktreeManager::new(config.worktree_root.clone()); diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index 88ded4b..dccb4f5 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -53,7 +53,10 @@ pub struct DaemonConfig { pub home_dir: String, pub bind_addr: SocketAddr, pub state_dir: PathBuf, - pub worktree_root: PathBuf, + /// `[daemon].worktree_root` override (#56). `None` — the default — places + /// worktrees inside their base repo at `/worktrees/`; + /// operators who want them elsewhere set this. + pub worktree_root: Option, /// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach /// receives this much of the prior PTY output before the live stream. /// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables. @@ -131,9 +134,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result p, None => default_state_dir()?, }; - let worktree_root = daemon - .worktree_root - .unwrap_or_else(|| state_dir.join("worktrees")); let pty_replay_bytes = daemon .pty_replay_bytes @@ -152,7 +152,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result/worktrees/` (#56) so they +/// are findable from the repo; an operator-set root overrides that. #[derive(Debug, Clone)] pub struct WorktreeManager { - root: PathBuf, + /// `[daemon].worktree_root` override. `None` uses the default + /// `/worktrees/` placement. + root_override: Option, } impl WorktreeManager { - pub fn new(root: impl Into) -> Self { - Self { root: root.into() } + pub fn new(root_override: Option) -> Self { + Self { root_override } } - pub fn root(&self) -> &Path { - &self.root + pub fn root(&self) -> Option<&Path> { + self.root_override.as_deref() + } + + /// Where a worktree for `branch` will be created: the override root, or + /// `/worktrees/` by default. The path is + /// predictable (no UUID suffix), so an existing directory at the target + /// is an error rather than a silently different location. + fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf { + let slug = sanitize_branch_for_path(branch); + match &self.root_override { + Some(root) => root.join(slug), + None => base_repo.join("worktrees").join(slug), + } } /// `git -C worktree add -b ` — creates the @@ -49,8 +65,15 @@ impl WorktreeManager { branch: &str, create_branch: bool, ) -> Result { - std::fs::create_dir_all(&self.root)?; - let path = self.generate_path(branch); + let path = self.target_path(base_repo, branch); + if path.symlink_metadata().is_ok() { + return Err(WorktreeError::PathExists { path }); + } + // The parent (`/worktrees` or the override root) may not exist + // yet; the target itself must not. + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } let mut cmd = Command::new("git"); cmd.arg("-C").arg(base_repo).args(["worktree", "add"]); if create_branch { @@ -74,12 +97,6 @@ impl WorktreeManager { .output()?; check(out) } - - fn generate_path(&self, branch: &str) -> PathBuf { - let slug = sanitize_branch_for_path(branch); - let unique = Uuid::new_v4().simple(); - self.root.join(format!("{slug}-{unique}")) - } } fn sanitize_branch_for_path(branch: &str) -> String { diff --git a/crates/maestro-daemon/tests/common/mod.rs b/crates/maestro-daemon/tests/common/mod.rs index 2e1e96a..cf077cf 100644 --- a/crates/maestro-daemon/tests/common/mod.rs +++ b/crates/maestro-daemon/tests/common/mod.rs @@ -115,7 +115,6 @@ pub struct DaemonFixture { pub tmux: TmuxFixture, pub manager: SessionManager, pub state_dir: TempDir, - pub worktree_root: TempDir, } impl DaemonFixture { @@ -132,9 +131,9 @@ impl DaemonFixture { fn build(history_days: Option) -> Self { let tmux = TmuxFixture::new(); let state_dir = TempDir::new().unwrap(); - let worktree_root = TempDir::new().unwrap(); let sidecar = SidecarStore::new(state_dir.path().join("sessions")); - let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf()); + // Default placement: worktrees land under their base repo (#56). + let worktree_mgr = WorktreeManager::new(None); let mut manager = SessionManager::new( Arc::new(tmux.tmux.clone()), sidecar, @@ -149,7 +148,6 @@ impl DaemonFixture { tmux, manager, state_dir, - worktree_root, } } diff --git a/crates/maestro-daemon/tests/session_decisions.rs b/crates/maestro-daemon/tests/session_decisions.rs index 967f6fd..fb48862 100644 --- a/crates/maestro-daemon/tests/session_decisions.rs +++ b/crates/maestro-daemon/tests/session_decisions.rs @@ -38,7 +38,7 @@ impl Fx { let manager = SessionManager::new( fake.clone(), SidecarStore::new(pick(tmp.path())), - WorktreeManager::new(tmp.path().join("worktrees")), + WorktreeManager::new(Some(tmp.path().join("worktrees"))), fifo_dir, ); Self { manager, fake, tmp } @@ -387,7 +387,7 @@ fn attaching_spawn_history_after_cloning_panics() { let manager = SessionManager::new( fake, SidecarStore::new(tmp.path().join("sidecars")), - WorktreeManager::new(tmp.path().join("worktrees")), + WorktreeManager::new(Some(tmp.path().join("worktrees"))), tmp.path().join("fifos"), ); let _clone = manager.clone(); diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 6b1df55..17dfd1e 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -123,8 +123,7 @@ async fn recover_adopts_existing_tmux_sessions() { // build a parallel manager pointing at the same dirs + same tmux server let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()); - let worktree_mgr = - maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf()); + let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None); let revived = maestro_daemon::SessionManager::new( Arc::new(fx.tmux.tmux.clone()), sidecar, @@ -214,7 +213,6 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() { let sidecar_dir = fx.sidecar_dir(); let fifo_dir = fx.fifo_dir(); let tmux = fx.tmux.tmux.clone(); - let worktree_root = fx.worktree_root.path().to_path_buf(); // Drop the original manager. Its PtyBridge closes the FIFO's only // reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but @@ -223,7 +221,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() { drop(fx.manager); let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir); - let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root); + let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None); let revived = maestro_daemon::SessionManager::new(Arc::new(tmux), sidecar, worktree_mgr, fifo_dir); assert_eq!(revived.recover().await.expect("recover"), 1); diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs index c82e085..5740f73 100644 --- a/crates/maestro-daemon/tests/worktree.rs +++ b/crates/maestro-daemon/tests/worktree.rs @@ -31,7 +31,7 @@ fn add_creates_worktree_on_new_branch() { init_repo(&repo); let wt_root = TempDir::new().unwrap(); - let mgr = WorktreeManager::new(wt_root.path()); + let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let path = mgr.add_new(&repo, "feat/example").expect("add worktree"); assert!(path.exists()); @@ -42,6 +42,63 @@ fn add_creates_worktree_on_new_branch() { assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}"); } +#[test] +fn add_places_worktree_inside_the_base_repo_by_default() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + // No override: the worktree must land where the repository keeps its + // own worktrees (#56), not under some daemon state dir. + let mgr = WorktreeManager::new(None); + + let path = mgr.add_new(&repo, "feat/example").expect("add worktree"); + assert_eq!(path, repo.join("worktrees").join("feat-example")); + assert!(path.exists()); + assert!(path.join("README").exists()); + + let branches = worktree_branches(&repo); + assert!(branches.iter().any(|b| b == "feat/example"), "{branches:?}"); +} + +#[test] +fn worktree_root_override_redirects_placement() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let wt_root = TempDir::new().unwrap(); + let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); + + let path = mgr.add_new(&repo, "feat/example").expect("add worktree"); + assert_eq!(path, wt_root.path().join("feat-example")); + assert!(path.exists()); +} + +#[test] +fn existing_directory_at_target_path_is_an_error_carrying_the_path() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + // A predictable path means collisions are possible; they must be + // reported, never silently relocated. + std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap(); + + let mgr = WorktreeManager::new(None); + let err = mgr.add_new(&repo, "feat/collide").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains( + repo.join("worktrees") + .join("feat-collide") + .to_str() + .unwrap() + ), + "{msg}" + ); +} + #[test] fn add_twice_generates_distinct_paths() { let repo_dir = TempDir::new().unwrap(); @@ -49,7 +106,7 @@ fn add_twice_generates_distinct_paths() { init_repo(&repo); let wt_root = TempDir::new().unwrap(); - let mgr = WorktreeManager::new(wt_root.path()); + let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let p1 = mgr.add_new(&repo, "feat/a").expect("add a"); let p2 = mgr.add_new(&repo, "feat/b").expect("add b"); @@ -65,7 +122,7 @@ fn remove_prunes_worktree() { init_repo(&repo); let wt_root = TempDir::new().unwrap(); - let mgr = WorktreeManager::new(wt_root.path()); + let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let path = mgr.add_new(&repo, "feat/gone").expect("add"); assert!(path.exists()); @@ -118,7 +175,7 @@ fn add_existing_checks_out_branch_without_creating_it() { assert!(back.success()); let wt_root = TempDir::new().unwrap(); - let mgr = WorktreeManager::new(wt_root.path()); + let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let path = mgr.add_existing(&repo, "feat/dup").expect("add existing"); assert!(path.exists()); @@ -151,7 +208,7 @@ fn add_fails_when_branch_already_exists() { assert!(status.success()); let wt_root = TempDir::new().unwrap(); - let mgr = WorktreeManager::new(wt_root.path()); + let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); let err = mgr.add_new(&repo, "feat/dup").unwrap_err(); let msg = format!("{err}"); diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index f3fe278..647f663 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -134,14 +134,14 @@ async fn cleanup_on_kill_removes_worktree() { }; let s = client.spawn_session(&req).await.expect("spawn worktree"); - // The daemon's WorktreeManager puts new worktrees under - // `/worktrees/`. We can't peek the daemon's state dir - // directly from here, but we can verify by asking git: a worktree must - // exist that lives outside the original repo. + // Worktrees live inside the base repo at `/worktrees/` + // (#56), so `git worktree list` must show the predictable in-repo path. let worktrees = git_worktree_list(base.path()); assert!( - worktrees.iter().any(|w| w.contains("feature/e2e")), - "expected a worktree for feature/e2e branch, got: {worktrees:?}" + worktrees + .iter() + .any(|w| w.contains("worktrees/feature-e2e")), + "expected a worktree for feature/e2e under /worktrees, got: {worktrees:?}" ); client.kill_session(s.id).await.expect("kill"); diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ad02b5f..a6b2a63 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -280,8 +280,9 @@ maestro daemon # foreground; or wire your own service manager ``` Every setting has a default, so `~/.config/maestro/config.toml` is optional — -create it only to change the bind address, worktree root, spawn presets, or TUI -keymap. +create it only to change the bind address, spawn presets, or TUI +keymap. (Worktrees live inside their base repos at `/worktrees/`; +set `[daemon].worktree_root` only to override that.) ## Non-Linux hosts From 6f8de8de7c0946ce2c874fe3d4a1c449fa0df66c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:12:28 +0100 Subject: [PATCH 09/71] fix(maestro): draft-pane regressions from the integration review Six findings from reviewing the accumulated blitz/spawn-v2 diff: - loadRecents restored the seeded cwd but never emitted it, so the leaf's draft kept cwd:"" and the next render reseeded the form blank. - stripDrafts did not recurse, so every half-filled draft in a split tab was written to localStorage; the old test passed only because hydrate discards draft on the way back in. - showRawCwd and selectPreset grabbed focus from async init paths, so a draft pane stole the caret seconds after mounting. - .mv-draft inherited no layout once the form left .modal-body. - onSuccess mounted the new session onto a leaf that may have been closed while the POST was in flight, stranding it in no pane. --- web/src/multiview.ts | 12 +++++++++--- web/src/persistence.test.ts | 26 ++++++++++++++++++++++++++ web/src/persistence.ts | 7 ++++++- web/src/spawnForm.ts | 20 +++++++++++++------- web/src/styles.css | 6 ++++++ 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 725fd53..f31e607 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -152,10 +152,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView { leaf.draft = values; }, onSuccess: (session) => { - leaf.sessionId = session.id; - leaf.draft = null; + // The pane can be closed while the POST is in flight. Mounting the + // session onto a leaf no longer in the tree would strand it in no + // pane at all, so only claim the leaf while it is still there. const t = activeTab(layout); - if (leaves(t.root).includes(leaf)) t.focusedLeafId = leaf.id; + const attached = leaves(t.root).includes(leaf); + if (attached) { + leaf.sessionId = session.id; + leaf.draft = null; + t.focusedLeafId = leaf.id; + } render(); opts.onOpenSessionsChange?.(); }, diff --git a/web/src/persistence.test.ts b/web/src/persistence.test.ts index c038d46..a626f58 100644 --- a/web/src/persistence.test.ts +++ b/web/src/persistence.test.ts @@ -99,4 +99,30 @@ describe("serializeLayout", () => { } expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]); }); + + it("keeps the draft out of the stored blob itself, not just out of hydrate", () => { + // A draft pane only exists once a tab has split, so the strip has to walk + // into split branches; asserting on the restored layout alone passes even + // when the draft was written to localStorage, because hydrate drops it. + const layout = hydrate({ + tabs: [ + { + id: "t", + root: { + kind: "split", + dir: "row", + a: { kind: "leaf", id: "l1", sessionId: "s1" }, + b: { kind: "leaf", id: "l2", sessionId: null }, + }, + }, + ], + activeTabId: "t", + }) as Layout; + const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!; + draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" }; + + const raw = serializeLayout(layout); + expect(raw).not.toContain("secret-project"); + expect(raw).not.toContain("amber-fox"); + }); }); diff --git a/web/src/persistence.ts b/web/src/persistence.ts index 708e36b..4a60195 100644 --- a/web/src/persistence.ts +++ b/web/src/persistence.ts @@ -34,7 +34,12 @@ export function serializeLayout(layout: Layout): string { } function stripDrafts(node: Node | null): Node | null { - if (!node || node.kind !== "leaf") return node; + if (!node) return node; + // Recurse: a draft pane only exists once a tab has split, so a shallow strip + // would persist every half-filled form it was meant to drop. + if (node.kind === "split") { + return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! }; + } return { ...node, draft: null }; } diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 26e1a3b..7b78eb4 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -209,14 +209,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { syncBranchToName(); // seed the branch from the initial name // ---- command tiles --------------------------------------------------- - function selectPreset(id: string): void { + // `focus` is false for the two initial applications (construction, and again + // when `/api/info` resolves): a draft pane must not grab the caret out from + // under the operator seconds after it mounts. + function selectPreset(id: string, focus = true): void { selectedPresetId = id; const preset = presets.find((p) => p.id === id); const isCustom = !preset || preset.command === ""; cmdRaw.hidden = !isCustom; if (isCustom) { command = cmdRaw.value.trim(); - cmdRaw.focus(); + if (focus) cmdRaw.focus(); } else { command = preset!.command; } @@ -270,17 +273,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (wanted) { const match = presets.find((p) => p.command === wanted && p.command !== ""); if (match) { - selectPreset(match.id); + selectPreset(match.id, false); } else { const custom = presets.find((p) => p.command === ""); cmdRaw.value = wanted; - selectPreset(custom ? custom.id : "custom"); + selectPreset(custom ? custom.id : "custom", false); command = wanted; } return; } const first = presets.find((p) => p.command !== ""); - if (first) selectPreset(first.id); + if (first) selectPreset(first.id, false); } // ---- working-directory picker --------------------------------------- @@ -345,17 +348,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { cwdRaw.value = seedCwd; markActiveRecent(cwd); } else { - showRawCwd(seedCwd); + showRawCwd(seedCwd, false); } seedCwd = null; } else if (dirs.length === 0) { // No history: default to raw input prefilled with the daemon's home dir. - showRawCwd(homeDir); + showRawCwd(homeDir, false); } else { cwdRawWrap.hidden = true; } // The seeded cwd is in place — a restored worktree can probe now. if (wtEnabled.checked) void probeGitRoot(); + // Publish it: construction ran `emit()` with an empty cwd, so without this + // the leaf's draft keeps `cwd: ""` and the next render reseeds it blank. + emit(); } function recentRow(dir: RecentDir): HTMLElement { diff --git a/web/src/styles.css b/web/src/styles.css index cd01222..93374c4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2079,6 +2079,12 @@ textarea { overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; + /* The modal form got its stacking and padding from `.modal-body`; in a pane + the form is the host, so it carries them itself. */ + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-3); } /* pane tools cluster (disconnect / split / close), top-right corner. The attach From 95f0e42df528a59f8e8215df72eda15ea938476d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:16:43 +0100 Subject: [PATCH 10/71] feat(maestro): add draft-pane widget with its own field focus --- crates/maestro-tui/src/draft.rs | 272 ++++++++++++++++++++++++++++++++ crates/maestro-tui/src/lib.rs | 1 + 2 files changed, 273 insertions(+) create mode 100644 crates/maestro-tui/src/draft.rs diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs new file mode 100644 index 0000000..2cb6afe --- /dev/null +++ b/crates/maestro-tui/src/draft.rs @@ -0,0 +1,272 @@ +//! Draft pane — the spawn form living in an ordinary pane (issue #63). +//! +//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands +//! the caller a built request and the pane is replaced with the new session, +//! matching the web client's inline draft panes (#57). Field editing goes +//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields. +//! +//! Scope note: presets, the project list, generated names, and the worktree +//! section land with issues #64/#65 — this is cwd + command only. + +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::input::InputKey; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum DraftField { + #[default] + Cwd, + Command, + Submit, +} + +#[derive(Debug, Default, Clone)] +pub struct DraftPane { + pub cwd: crate::text_input::TextInput, + pub command: crate::text_input::TextInput, + pub focused_field: DraftField, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DraftInputResult { + Consumed, + Submit, +} + +impl DraftPane { + pub fn next_field(&mut self) { + self.focused_field = match self.focused_field { + DraftField::Cwd => DraftField::Command, + DraftField::Command => DraftField::Submit, + DraftField::Submit => DraftField::Cwd, + }; + } + + pub fn prev_field(&mut self) { + self.focused_field = match self.focused_field { + DraftField::Cwd => DraftField::Submit, + DraftField::Command => DraftField::Cwd, + DraftField::Submit => DraftField::Command, + }; + } + + /// Handle one keystroke while this draft owns input (Insert mode, pane + /// focused). Returns [`DraftInputResult::Submit`] when the user accepts; + /// the caller fires the POST and replaces the draft with the session. + pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult { + match key { + InputKey::Tab => { + self.next_field(); + DraftInputResult::Consumed + } + InputKey::BackTab => { + self.prev_field(); + DraftInputResult::Consumed + } + InputKey::Enter => match self.focused_field { + DraftField::Submit => DraftInputResult::Submit, + _ => { + self.next_field(); + DraftInputResult::Consumed + } + }, + // Everything else — characters, motion, editing, paste — goes to + // the focused field's text input. + other => { + match self.focused_field { + DraftField::Cwd => self.cwd.handle_key(other), + DraftField::Command => self.command.handle_key(other), + DraftField::Submit => {} + } + DraftInputResult::Consumed + } + } + } + + /// Draw into the pane area. `pane_focused` gates the field highlight and + /// cursor placement the same way a session pane hides its cursor when + /// unfocused. + pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) { + let block = Block::default().title(" draft ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + let rows = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(1), + ]) + .split(inner); + + let style_for = |field: DraftField| { + if pane_focused && self.focused_field == field { + Style::default().add_modifier(Modifier::REVERSED) + } else { + Style::default() + } + }; + + // Each text field renders through the shared `TextInput`, which + // handles horizontal scrolling and reports where the terminal cursor + // belongs. Label is fixed-width 8 chars + 1 space = column 9. + let label = |text: &str| Paragraph::new(Line::from(Span::raw(format!("{text:8} ")))); + f.render_widget(label("cwd:"), rows[0]); + f.render_widget(label("command:"), rows[1]); + let draw_field = + |f: &mut Frame, input: &crate::text_input::TextInput, row: Rect, style: Style| -> u16 { + let area = Rect { + x: row.x.saturating_add(9), + y: row.y, + width: row.width.saturating_sub(9), + height: 1, + }; + input.render(area, f.buffer_mut(), style) + }; + let cwd_col = draw_field(f, &self.cwd, rows[0], style_for(DraftField::Cwd)); + let cmd_col = draw_field(f, &self.command, rows[1], style_for(DraftField::Command)); + + f.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + "[ Spawn ]", + style_for(DraftField::Submit), + )])), + rows[2], + ); + + if pane_focused { + match self.focused_field { + DraftField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)), + DraftField::Command => f.set_cursor_position((cmd_col, rows[1].y)), + DraftField::Submit => { + // The submit button is indicated by the REVERSED style; no + // cursor placement. + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(draw).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + #[test] + fn draft_pane_renders_field_labels_and_values() { + let draft = DraftPane { + cwd: crate::text_input::TextInput::with_value("/x"), + command: crate::text_input::TextInput::with_value("bash"), + focused_field: DraftField::Cwd, + }; + let rendered = render_to_string(80, 20, |f| draft.render(f, f.area(), true)); + for s in ["cwd", "command", "draft", "Spawn", "/x", "bash"] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + } + + #[test] + fn field_rotation_wraps_in_both_directions() { + let mut draft = DraftPane::default(); + assert_eq!(draft.focused_field, DraftField::Cwd); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::Command); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::Submit); + draft.next_field(); + assert_eq!(draft.focused_field, DraftField::Cwd); + draft.prev_field(); + assert_eq!(draft.focused_field, DraftField::Submit); + + draft.on_input(&InputKey::BackTab); + assert_eq!(draft.focused_field, DraftField::Command); + draft.on_input(&InputKey::Tab); + draft.on_input(&InputKey::Tab); + assert_eq!(draft.focused_field, DraftField::Cwd); + } + + /// Editing keys must reach the focused field's text input, not a bare + /// String — this is what gives the operator cursor motion and word ops. + #[test] + fn editing_keys_reach_the_focused_text_input() { + let mut draft = DraftPane::default(); + assert_eq!(draft.focused_field, DraftField::Cwd); + for k in [ + InputKey::char('/'), + InputKey::char('t'), + InputKey::char('m'), + InputKey::char('p'), + ] { + draft.on_input(&k); + } + draft.on_input(&InputKey::Left); + draft.on_input(&InputKey::Backspace); + assert_eq!(draft.cwd.value(), "/tp"); + + // Tab to command, then a paste and ctrl-w land there, not in cwd. + draft.on_input(&InputKey::Tab); + assert_eq!(draft.focused_field, DraftField::Command); + draft.on_input(&InputKey::Paste("/tmp/foo".into())); + assert_eq!(draft.command.value(), "/tmp/foo"); + draft.on_input(&InputKey::ctrl_char('w')); + assert_eq!(draft.command.value(), ""); + assert_eq!(draft.cwd.value(), "/tp", "cwd untouched while unfocused"); + } + + /// Enter walks forward until the submit field, where it submits. + #[test] + fn enter_walks_fields_then_submits_from_submit() { + let mut draft = DraftPane::default(); + assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed); + assert_eq!(draft.focused_field, DraftField::Command); + assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed); + assert_eq!(draft.focused_field, DraftField::Submit); + assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit); + } + + /// The terminal cursor must sit right after the typed text in the focused + /// field (label is 8 chars + 1 space, block border takes column 0). + #[test] + fn render_places_the_cursor_after_the_typed_text_when_focused() { + let draft = DraftPane { + cwd: crate::text_input::TextInput::with_value("abc"), + ..DraftPane::default() + }; + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draft.render(f, f.area(), true)).unwrap(); + let pos = terminal.get_cursor_position().unwrap(); + assert_eq!(pos, ratatui::layout::Position::new(1 + 9 + 3, 1)); + + // Unfocused pane: no field highlight either. + let rendered_unfocused = render_to_string(80, 20, |f| draft.render(f, f.area(), false)); + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| draft.render(f, f.area(), false)).unwrap(); + let buf = terminal.backend().buffer(); + assert!( + !buf.content() + .iter() + .any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)), + "unfocused draft must not highlight fields:\n{rendered_unfocused}" + ); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index b76bd84..5d805bb 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -7,6 +7,7 @@ pub mod app; pub mod catalog; pub mod cli; pub mod config; +pub mod draft; pub mod emulators; pub mod input; pub mod keymap; From d8acc3adde5b68d2a9da74e1e347f76702cf955e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:17:08 +0100 Subject: [PATCH 11/71] feat(maestro): always clean up worktrees on kill behind a dirty-work guard --- crates/maestro-cli/src/cli.rs | 27 +++++- crates/maestro-daemon/src/session.rs | 33 ++++---- crates/maestro-daemon/src/worktree.rs | 83 ++++++++++++++++++- .../maestro-daemon/tests/session_manager.rs | 70 +++++++++++++++- crates/maestro-daemon/tests/worktree.rs | 42 ++++++++++ crates/maestro-e2e/tests/e2e.rs | 47 ++++++++++- crates/maestro-protocol/src/lib.rs | 9 +- 7 files changed, 285 insertions(+), 26 deletions(-) diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index 660f494..3f497e6 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -67,8 +67,10 @@ pub struct SpawnArgs { /// Branch name for the worktree (required with --worktree-from). #[arg(long)] pub branch: Option, - /// Remove the worktree when the session is killed. - #[arg(long)] + /// Remove the worktree when the session is killed. Kept for wire + /// compatibility (#56): the daemon now always cleans up on kill, guarded + /// against unsaved work. + #[arg(long, default_value_t = true)] pub cleanup_on_kill: bool, /// After spawning, attach immediately. #[arg(long, short = 'a')] @@ -147,6 +149,27 @@ mod tests { } } + #[test] + fn cleanup_on_kill_defaults_true_for_the_wire() { + let cli = Cli::try_parse_from([ + "maestro", + "spawn", + "--cwd", + "/tmp", + "--command", + "bash", + "--worktree-from", + "/repos/foo", + "--branch", + "feat/x", + ]) + .unwrap(); + match cli.command { + Command::Spawn(a) => assert!(a.cleanup_on_kill), + _ => panic!("wrong subcommand"), + } + } + #[test] fn spawn_worktree_requires_branch() { let err = Cli::try_parse_from([ diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 41845eb..7ae544c 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -38,13 +38,12 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2); pub struct SessionRecord { pub session: Session, pub fifo_path: PathBuf, - /// Worktree we created at spawn time and that should be cleaned up on - /// kill (if `cleanup_worktree_on_kill`). + /// Worktree we created at spawn time; removed on kill unless the + /// dirty-work guard refuses (#56). pub worktree_path: Option, /// `base_repo` from the original `WorktreeSpec`. Needed because `git /// worktree remove` is invoked from the base repo. pub worktree_base_repo: Option, - pub cleanup_worktree_on_kill: bool, pub bridge: PtyBridge, } @@ -227,12 +226,6 @@ impl SessionManager { } None => (req.cwd.clone(), None, None), }; - let cleanup_worktree_on_kill = req - .worktree - .as_ref() - .map(|w| w.cleanup_on_kill) - .unwrap_or(false); - let id = Uuid::new_v4(); // Build the FIFO + reader task *before* asking tmux to spawn the // command — otherwise the child's first bytes race the separate @@ -317,7 +310,6 @@ impl SessionManager { fifo_path, worktree_path, worktree_base_repo, - cleanup_worktree_on_kill, bridge, }, ); @@ -359,8 +351,9 @@ impl SessionManager { } } - /// Kill a session and remove its on-disk artifacts. The worktree is only - /// pruned if the create request set `cleanup_on_kill: true`. + /// Kill a session and remove its on-disk artifacts. The worktree is + /// always pruned -- unless it holds uncommitted changes or commits not on + /// any other ref, in which case the guard leaves it on disk (#56). /// /// This is also how an already-dead session is dismissed. tmux is only /// asked to kill something it still has — calling `kill-session` on a @@ -456,7 +449,6 @@ impl SessionManager { fifo_path, worktree_path: None, worktree_base_repo: None, - cleanup_worktree_on_kill: false, bridge, }, ); @@ -705,9 +697,18 @@ async fn cleanup_record(inner: &Arc, id: Uuid, record: SessionRecord, kil if let Err(e) = inner.sidecar.delete(id) { tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar"); } - if record.cleanup_worktree_on_kill { - if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { - let _ = inner.worktree_mgr.remove(base, path); + // Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a + // user-facing choice. The manager's guard is what protects unsaved work -- + // when it refuses, leave the tree on disk and say where it is so the + // operator can find it. + if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { + if let Err(e) = inner.worktree_mgr.remove(base, path) { + tracing::warn!( + %id, + path = %path.display(), + error = %e, + "left worktree on disk" + ); } } let _ = inner.events.send(CatalogEvent::SessionRemoved { id }); diff --git a/crates/maestro-daemon/src/worktree.rs b/crates/maestro-daemon/src/worktree.rs index 73a9097..0e8ad5e 100644 --- a/crates/maestro-daemon/src/worktree.rs +++ b/crates/maestro-daemon/src/worktree.rs @@ -14,6 +14,9 @@ pub enum WorktreeError { /// to do; the daemon never relocates to a different directory (#56). #[error("worktree path already exists: {}", .path.display())] PathExists { path: PathBuf }, + /// The worktree holds unsaved work; removal was refused (#56). + #[error("left {} on disk: {}", .path.display(), .reason)] + Guarded { path: PathBuf, reason: String }, } /// Runs `git worktree add` rooted at `base_repo`. By default worktrees land @@ -87,12 +90,23 @@ impl WorktreeManager { Ok(path) } - /// `git -C worktree remove --force `. + /// `git -C worktree remove ` — but only when the + /// worktree holds nothing unique (#56). A worktree with uncommitted + /// changes, or with commits reachable from no other ref, is refused and + /// left on disk so the operator can rescue it; cleanup is unconditional, + /// which makes this guard the only thing standing between a stray kill + /// and destroyed work. pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> { + if let Some(reason) = unsafe_to_remove(path)? { + return Err(WorktreeError::Guarded { + path: path.to_path_buf(), + reason, + }); + } let out = Command::new("git") .arg("-C") .arg(base_repo) - .args(["worktree", "remove", "--force"]) + .args(["worktree", "remove"]) .arg(path) .output()?; check(out) @@ -114,6 +128,71 @@ fn sanitize_branch_for_path(branch: &str) -> String { out } +/// `Some(reason)` when deleting `path` would destroy work: uncommitted +/// changes, or commits reachable from no ref other than the worktree's own +/// branch (`git worktree remove` keeps the branch, but nothing checks it out +/// anymore — treat its commits as at risk anyway, per #56). +fn unsafe_to_remove(path: &Path) -> Result, WorktreeError> { + let status = run_git(path, ["status", "--porcelain"])?; + if !status.stdout.is_empty() { + return Ok(Some("has uncommitted changes".into())); + } + + let head = String::from_utf8_lossy(&run_git(path, ["rev-parse", "HEAD"])?.stdout) + .trim() + .to_string(); + let branch = + String::from_utf8_lossy(&run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?.stdout) + .trim() + .to_string(); + + // Every ref except this worktree's own branch counts as a safe home for + // its commits. Detached HEAD excludes everything. + let refs = + String::from_utf8_lossy(&run_git(path, ["for-each-ref", "--format=%(refname)"])?.stdout) + .lines() + .filter(|r| match branch.as_str() { + "HEAD" => true, + b => *r != format!("refs/heads/{b}"), + }) + .map(str::to_string) + .collect::>(); + + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(path).args(["rev-list", "--count", &head]); + if !refs.is_empty() { + cmd.arg("--not").args(&refs); + } + let out = cmd.output()?; + check(out.clone())?; + let count: usize = String::from_utf8_lossy(&out.stdout) + .trim() + .parse() + .unwrap_or(0); + if count > 0 { + return Ok(Some(format!( + "has {count} commit(s) not present on any other ref" + ))); + } + Ok(None) +} + +fn run_git( + dir: &Path, + args: impl IntoIterator, +) -> Result { + let mut cmd = Command::new("git"); + cmd.arg("-C").arg(dir).args(args); + let out = cmd.output()?; + if !out.status.success() { + return Err(WorktreeError::NonZero { + code: out.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }); + } + Ok(out) +} + fn check(out: Output) -> Result<(), WorktreeError> { if out.status.success() { Ok(()) diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 17dfd1e..deb2cc7 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -7,9 +7,9 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use common::{wait_for_async, DaemonFixture}; +use common::{init_repo, wait_for_async, DaemonFixture}; use maestro_protocol::CreateSessionRequest; -use maestro_protocol::{CatalogEvent, SessionStatus}; +use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec}; use tempfile::TempDir; #[tokio::test] @@ -363,6 +363,72 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() { assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists()); } +/// Cleanup is unconditional (#56): even a spec that says "don't clean up" +/// loses its worktree on kill — the guard, not the flag, protects work. +#[tokio::test] +async fn kill_removes_worktree_even_when_spec_says_keep() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat-keep"); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/keep".into(), + cleanup_on_kill: false, + create_branch: true, + }), + }) + .await + .expect("create"); + assert!(wt_path.exists()); + + fx.manager.kill(s.id).await.expect("kill"); + assert!(!wt_path.exists(), "worktree must be removed on kill"); +} + +/// The guard (#56): a worktree holding uncommitted changes survives the kill, +/// left on disk where the operator can find it. +#[tokio::test] +async fn kill_leaves_worktree_with_unsaved_work_on_disk() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat-guarded"); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/guarded".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + fx.manager.kill(s.id).await.expect("kill"); + assert!( + wt_path.exists(), + "dirty worktree must be left on disk, not destroyed" + ); + assert!(fx.manager.snapshot().await.is_empty()); +} + #[tokio::test] async fn liveness_watcher_leaves_running_sessions_alone() { let fx = DaemonFixture::new(); diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs index 5740f73..fdb6945 100644 --- a/crates/maestro-daemon/tests/worktree.rs +++ b/crates/maestro-daemon/tests/worktree.rs @@ -192,6 +192,48 @@ fn add_existing_checks_out_branch_without_creating_it() { ); } +#[test] +fn remove_refuses_worktree_with_uncommitted_changes() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/dirty").expect("add"); + std::fs::write(path.join("README"), b"unsaved edits\n").unwrap(); + + mgr.remove(&repo, &path) + .expect_err("dirty worktree must be refused"); + assert!(path.exists(), "guarded worktree must stay on disk"); + + // Committing the change doesn't help while it is the only copy… + run_git(&path, &["add", "."]); + run_git(&path, &["commit", "-q", "-m", "unsaved"]); + + mgr.remove(&repo, &path) + .expect_err("unique commit must be refused"); + assert!(path.exists()); + + // …but once the commit is reachable from main, removal is safe. + run_git(&repo, &["merge", "-q", "--no-edit", "feat/dirty"]); + mgr.remove(&repo, &path).expect("merged worktree can go"); + assert!(!path.exists()); +} + +fn run_git(dir: &std::path::Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .expect("git spawn"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + #[test] fn add_fails_when_branch_already_exists() { let repo_dir = TempDir::new().unwrap(); diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index 647f663..ccf9da3 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -114,7 +114,7 @@ async fn multi_attach_sees_same_output() { } #[tokio::test(flavor = "multi_thread")] -async fn cleanup_on_kill_removes_worktree() { +async fn kill_removes_clean_worktree() { let (_daemon, client) = bring_up().await; let base = TempDir::new().unwrap(); @@ -161,6 +161,51 @@ async fn cleanup_on_kill_removes_worktree() { ); } +/// The guard (#56): killing a session whose worktree holds uncommitted +/// changes must leave the tree on disk — cleanup is unconditional, so this +/// guard is what keeps a stray kill from destroying work. +#[tokio::test(flavor = "multi_thread")] +async fn kill_spares_worktree_with_uncommitted_changes() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let wt_path = base.path().join("worktrees").join("feature-unsaved"); + + let req = CreateSessionRequest { + cwd: base.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: base.path().to_path_buf(), + branch: "feature/unsaved".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }; + + let s = client.spawn_session(&req).await.expect("spawn worktree"); + assert!(wt_path.exists()); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + client.kill_session(s.id).await.expect("kill"); + + // The session is gone but the dirty worktree stays findable on disk. + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + let sessions = client.list_sessions().await.unwrap_or_default(); + if sessions.is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + wt_path.exists(), + "guarded worktree must be left on disk after kill" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn spawn_on_existing_branch_checks_it_out() { let (_daemon, client) = bring_up().await; diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 4b6fac9..82c73b7 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -111,7 +111,10 @@ pub enum ActivityState { pub struct WorktreeSpec { pub base_repo: PathBuf, pub branch: String, - #[serde(default)] + /// Kept on the wire for CLI compatibility (#56) but no longer a + /// user-facing choice: the daemon always cleans up on kill, guarded + /// against unsaved work. Defaults to `true`. + #[serde(default = "default_true")] pub cleanup_on_kill: bool, /// When false, `branch` must already exist and is checked out as-is /// (#56); when true (the default, and what every payload meant before @@ -424,14 +427,14 @@ mod tests { /// `create_branch`; the daemon must read that as "create a new branch" — /// exactly what every payload meant before the field existed (#56). #[test] - fn worktree_spec_defaults_create_branch_true_on_legacy_payload() { + fn worktree_spec_defaults_on_legacy_payload() { let json = serde_json::json!({ "base_repo": "/home/naps62/projects/maestro", "branch": "feat/foo", - "cleanup_on_kill": true, }); let spec: WorktreeSpec = serde_json::from_value(json).unwrap(); assert!(spec.create_branch); + assert!(spec.cleanup_on_kill); } #[test] From 11995965288162383d6a95968d83a40568c20d75 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:25:46 +0100 Subject: [PATCH 12/71] feat(maestro): inline draft pane replaces the SpawnModal overlay Action::OpenSpawnModal becomes NewDraftPane: pressing s turns the empty pane into an inline draft form (or opens a new tab when the pane shows a session). The form is a pane-level widget with its own field focus; a successful submit replaces the draft with the new session in place, matching the web client's draft panes. SpawnModal and OverlayKind::Spawn are removed; kill-confirm and help overlays stay. --- crates/maestro-tui/src/action.rs | 15 +- crates/maestro-tui/src/app.rs | 164 +++++++++++------ crates/maestro-tui/src/config.rs | 6 +- crates/maestro-tui/src/draft.rs | 2 +- crates/maestro-tui/src/overlays.rs | 256 +-------------------------- crates/maestro-tui/src/render.rs | 67 ++++++- crates/maestro-tui/src/state.rs | 124 +++++++++++-- crates/maestro-tui/src/text_input.rs | 2 +- 8 files changed, 305 insertions(+), 331 deletions(-) diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 4ebf0e6..8a0059c 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -38,7 +38,7 @@ pub enum Action { // Catalog actions on the highlighted sidebar row AttachHighlighted, ToggleAttachHighlighted, - OpenSpawnModal, + NewDraftPane, KillHighlighted, // Tabs @@ -78,7 +78,7 @@ impl Action { "SidebarPrev" => return Ok(Action::SidebarPrev), "AttachHighlighted" => return Ok(Action::AttachHighlighted), "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), - "OpenSpawnModal" => return Ok(Action::OpenSpawnModal), + "NewDraftPane" => return Ok(Action::NewDraftPane), "KillHighlighted" => return Ok(Action::KillHighlighted), "NewTab" => return Ok(Action::NewTab), "CloseTab" => return Ok(Action::CloseTab), @@ -202,6 +202,7 @@ mod tests { for a in [ Action::EnterInsert, Action::Quit, + Action::NewDraftPane, Action::FocusPane(Direction::Right), Action::GotoTab(7), ] { @@ -209,4 +210,14 @@ mod tests { assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}"); } } + + /// The spawn modal is gone (issue #63); its old action string must not + /// resolve to anything. + #[test] + fn the_removed_spawn_modal_action_no_longer_parses() { + assert_eq!( + Action::parse("OpenSpawnModal").unwrap_err(), + ActionParseError::Unknown("OpenSpawnModal".into()) + ); + } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index aa9574d..f262b2f 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -6,9 +6,9 @@ //! changes, and a 30Hz heartbeat. //! //! Actions mutate `AppState` directly. Side effects that talk to the daemon -//! (`AttachHighlighted` spawning a `SessionTask`, `OpenSpawnModal` submit -//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in -//! `dispatch` / `handle_overlay_input`. +//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing +//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in +//! `dispatch` / the Insert-mode pane routing. use std::collections::{HashMap, HashSet}; use std::io::Stdout; @@ -24,15 +24,16 @@ use uuid::Uuid; use crate::action::Action; use crate::catalog::CatalogStore; use crate::config::TuiConfig; +use crate::draft::DraftInputResult; use crate::emulators::SessionEmulators; use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey}; use crate::keymap::Mode; -use crate::overlays::{HelpOverlay, SpawnModal}; +use crate::overlays::HelpOverlay; use crate::pane::{Pane, PaneStatus}; use crate::render::{draw, RegionMap, RenderInputs}; use crate::session_task::{SessionInput, SessionTask}; use crate::sidebar::Sidebar; -use crate::state::{AppState, Focus, OverlayKind}; +use crate::state::{AppState, Focus, OverlayKind, PaneRef}; use crate::tabs::TabBar; use crate::TuiError; @@ -49,7 +50,6 @@ pub struct App { catalog: CatalogStore, emulators: SessionEmulators, state: AppState, - spawn: SpawnModal, help: HelpOverlay, chord: ChordState, daemon: DaemonClient, @@ -90,7 +90,6 @@ impl App { catalog, emulators: SessionEmulators::default(), state: AppState::new(), - spawn: SpawnModal::default(), help, chord: ChordState::new(chord_timeout), daemon, @@ -152,7 +151,15 @@ impl App { self.perform(effects).await; } else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane) { - if let Some(effect) = self.forward_to_session(&key) { + // Insert mode over a focused pane: a draft eats the key + // into its form, a session gets the raw bytes. + let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_))) + { + self.handle_draft_input(&key) + } else { + self.forward_to_session(&key) + }; + if let Some(effect) = effect { self.perform(vec![effect]).await; } } @@ -179,27 +186,7 @@ impl App { } fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec) { - use crate::overlays::SpawnInputResult; match self.state.overlay().cloned() { - Some(OverlayKind::Spawn) => { - let mut effects = Vec::new(); - match self.spawn.on_input(key) { - SpawnInputResult::Consumed => {} - SpawnInputResult::Cancel => { - self.state.dismiss_overlay(); - } - SpawnInputResult::Submit => { - effects.push(Effect::Spawn(CreateSessionRequest { - cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()), - command: self.spawn.command.value().to_string(), - env: Default::default(), - metadata: Default::default(), - worktree: None, - })); - } - } - (OverlayDecision::Consumed, effects) - } Some(OverlayKind::KillConfirm(id)) => match key { InputKey::Char { c: 'y', .. } => { self.state.dismiss_overlay(); @@ -265,14 +252,7 @@ impl App { }; self.state.show_overlay(OverlayKind::Help); } - Action::OpenSpawnModal => { - self.spawn = SpawnModal { - cwd: Default::default(), - command: Default::default(), - focused_field: Default::default(), - }; - self.state.show_overlay(OverlayKind::Spawn); - } + Action::NewDraftPane => self.state.open_draft_pane(), Action::AttachHighlighted | Action::ToggleAttachHighlighted => { if let Some(sid) = self.pick_sidebar_target() { effects.push(Effect::Attach(sid)); @@ -322,7 +302,7 @@ impl App { /// Encode a keystroke for the focused pane. `None` when there is nothing /// attached to send it to. fn forward_to_session(&mut self, key: &InputKey) -> Option { - let sid = self.state.active_tab().pane?; + let sid = self.state.active_session()?; self.emulators.get(sid)?; let bytes = match key { InputKey::Char { c, ctrl, alt, .. } => { @@ -356,10 +336,32 @@ impl App { Some(Effect::Forward { id: sid, bytes }) } + /// Feed one keystroke into the active tab's draft form. Returns the spawn + /// effect when the user accepted the form. + fn handle_draft_input(&mut self, key: &InputKey) -> Option { + let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else { + return None; + }; + if draft.on_input(key) != DraftInputResult::Submit { + return None; + } + Some(Effect::Spawn(CreateSessionRequest { + cwd: std::path::PathBuf::from(draft.cwd.value().to_string()), + command: draft.command.value().to_string(), + env: Default::default(), + metadata: Default::default(), + worktree: None, + })) + } + async fn submit_spawn(&mut self, req: CreateSessionRequest) { match self.daemon.create_session(&req).await { - Ok(_) => { - self.state.dismiss_overlay(); + Ok(session) => { + // Fill the pane in place with the new session (web parity, + // #57) and attach to it right away. + let sid = session.id; + self.state.complete_spawn(sid); + self.attach(sid); } Err(e) => self.state.toast(format!("spawn failed: {e}")), } @@ -377,7 +379,12 @@ impl App { terminal: &mut Terminal>, ) -> Result<(), TuiError> { let snap = self.catalog.snapshot(); - let attached: HashSet = self.state.tabs.iter().filter_map(|t| t.pane).collect(); + let attached: HashSet = self + .state + .tabs + .iter() + .filter_map(crate::state::Tab::session) + .collect(); let sidebar = Sidebar { sessions: snap.sessions.clone(), highlight: self.state.sidebar_highlight, @@ -387,7 +394,7 @@ impl App { tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(), active_idx: self.state.active_tab_idx, }; - let active_sid = self.state.active_tab().pane; + let active_sid = self.state.active_session(); let pane = active_sid.and_then(|sid| { self.emulators.get(sid).map(|h| Pane { screen: h.screen.clone(), @@ -395,13 +402,17 @@ impl App { focused: matches!(self.state.focus, Focus::Pane), }) }); + let draft = match self.state.active_tab().pane.as_ref() { + Some(PaneRef::Draft(d)) => Some(d), + _ => None, + }; let inputs = RenderInputs { sidebar: &sidebar, tab_bar: &tab_bar, pane: pane.as_ref(), state: &self.state, - spawn: &self.spawn, + draft, help: &self.help, }; @@ -516,10 +527,33 @@ mod tests { } #[test] - fn open_spawn_modal_sets_overlay() { + fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() { let mut app = make_app(); - app.dispatch(Action::OpenSpawnModal); - assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn))); + app.dispatch(Action::NewDraftPane); + assert!(matches!( + app.state.active_tab().pane, + Some(PaneRef::Draft(_)) + )); + assert_eq!(app.state.focus, Focus::Pane); + assert_eq!(app.state.mode, Mode::Insert); + assert!(app.state.overlay().is_none(), "no overlay involved"); + } + + /// A tab already showing a session gets a new tab for the draft. + #[test] + fn new_draft_pane_over_a_session_opens_a_new_tab() { + let sid = Uuid::from_u128(10); + let mut app = app_with_highlighted_session(sid); + app.dispatch(Action::AttachHighlighted); + + app.dispatch(Action::NewDraftPane); + assert_eq!(app.state.tabs.len(), 2); + assert_eq!(app.state.active_tab_idx, 1); + assert!(matches!( + app.state.active_tab().pane, + Some(PaneRef::Draft(_)) + )); + assert_eq!(app.state.tabs[0].session(), Some(sid)); } /// Seed the catalog with one session and highlight it. @@ -554,7 +588,7 @@ mod tests { app.dispatch(Action::AttachHighlighted), vec![Effect::Attach(sid)] ); - assert_eq!(app.state.active_tab().pane, Some(sid)); + assert_eq!(app.state.active_tab().session(), Some(sid)); } #[test] @@ -587,23 +621,35 @@ mod tests { } #[test] - fn submitting_the_spawn_modal_carries_the_typed_fields() { + fn typing_into_the_draft_then_enter_carries_the_typed_fields() { let mut app = make_app(); - app.dispatch(Action::OpenSpawnModal); - app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work"); - app.spawn.command = crate::text_input::TextInput::with_value("claude"); - app.spawn.focused_field = crate::overlays::SpawnField::Submit; + app.dispatch(Action::NewDraftPane); - let (_, effects) = app.handle_overlay_input(&InputKey::Enter); - match effects.as_slice() { - [Effect::Spawn(req)] => { + for c in "/srv/work".chars() { + app.handle_draft_input(&InputKey::char(c)); + } + app.handle_draft_input(&InputKey::Tab); // cwd → command + for c in "claude".chars() { + app.handle_draft_input(&InputKey::char(c)); + } + app.handle_draft_input(&InputKey::Tab); // command → submit + + match app.handle_draft_input(&InputKey::Enter) { + Some(Effect::Spawn(req)) => { assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work")); assert_eq!(req.command, "claude"); } - other => panic!("expected one Spawn effect, got {other:?}"), + other => panic!("expected a Spawn effect, got {other:?}"), } } + /// Keys fed while no draft exists must be ignored, not panic. + #[test] + fn draft_input_is_a_no_op_without_a_draft() { + let mut app = make_app(); + assert!(app.handle_draft_input(&InputKey::char('x')).is_none()); + } + /// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a /// failure message stayed on screen until another overlay replaced it. #[test] @@ -632,6 +678,14 @@ mod tests { assert!(app.forward_to_session(&InputKey::Enter).is_none()); } + /// A draft pane is not a session: forwarding must not try to reach it. + #[test] + fn forwarding_is_a_no_op_with_a_draft_pane() { + let mut app = make_app(); + app.dispatch(Action::NewDraftPane); + assert!(app.forward_to_session(&InputKey::Enter).is_none()); + } + #[test] fn kill_highlighted_opens_confirm() { use chrono::Utc; diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index e4e7c9f..9a727c2 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -45,7 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ ("up", "SidebarPrev"), // Catalog actions ("enter", "AttachHighlighted"), - ("s", "OpenSpawnModal"), + ("s", "NewDraftPane"), ("x", "KillHighlighted"), // Tabs ("ctrl-t", "NewTab"), @@ -155,6 +155,10 @@ mod tests { cfg.keymap.resolve(Mode::Normal, "g t"), Some(Action::NextTab), ); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "s"), + Some(Action::NewDraftPane), + ); } #[test] diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 2cb6afe..93fe613 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -24,7 +24,7 @@ pub enum DraftField { Submit, } -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct DraftPane { pub cwd: crate::text_input::TextInput, pub command: crate::text_input::TextInput, diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 5bb49d2..a046224 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -1,161 +1,16 @@ -//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay, -//! transient toast. +//! Overlay widgets: kill-confirm prompt, help overlay, transient toast. +//! +//! The spawn form is no longer an overlay (issue #63) — it lives in draft +//! panes ([`crate::draft::DraftPane`]). //! //! All overlays are centered popups drawn over the rest of the UI; the //! `Clear` widget wipes the underlying buffer before drawing. -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::layout::Rect; use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; use ratatui::Frame; -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum SpawnField { - #[default] - Cwd, - Command, - Submit, -} - -#[derive(Debug, Default, Clone)] -pub struct SpawnModal { - pub cwd: crate::text_input::TextInput, - pub command: crate::text_input::TextInput, - pub focused_field: SpawnField, -} - -impl SpawnModal { - pub fn next_field(&mut self) { - self.focused_field = match self.focused_field { - SpawnField::Cwd => SpawnField::Command, - SpawnField::Command => SpawnField::Submit, - SpawnField::Submit => SpawnField::Cwd, - }; - } - - pub fn prev_field(&mut self) { - self.focused_field = match self.focused_field { - SpawnField::Cwd => SpawnField::Submit, - SpawnField::Command => SpawnField::Cwd, - SpawnField::Submit => SpawnField::Command, - }; - } - - /// Handle one keystroke while this modal owns input. Returns - /// [`SpawnInputResult::Submit`] when the user accepts; the caller fires - /// the POST and dismisses the modal. - pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult { - use crate::input::InputKey; - match key { - InputKey::Tab => { - self.next_field(); - SpawnInputResult::Consumed - } - InputKey::BackTab => { - self.prev_field(); - SpawnInputResult::Consumed - } - InputKey::Esc => SpawnInputResult::Cancel, - InputKey::Enter => match self.focused_field { - SpawnField::Submit => SpawnInputResult::Submit, - _ => { - self.next_field(); - SpawnInputResult::Consumed - } - }, - // Everything else — characters, motion, editing, paste — goes to - // the focused field's text input. - other => { - match self.focused_field { - SpawnField::Cwd => self.cwd.handle_key(other), - SpawnField::Command => self.command.handle_key(other), - SpawnField::Submit => {} - } - SpawnInputResult::Consumed - } - } - } - - pub fn render(&self, f: &mut Frame, parent: Rect) { - let area = centered(parent, 60, 10); - f.render_widget(Clear, area); - let block = Block::default().title(" spawn ").borders(Borders::ALL); - let inner = block.inner(area); - f.render_widget(block, area); - let rows = Layout::vertical([ - Constraint::Length(2), - Constraint::Length(2), - Constraint::Length(1), - ]) - .split(inner); - let highlight = |field: SpawnField| { - if self.focused_field == field { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - } - }; - - // Each text field renders through the shared `TextInput`, which - // handles horizontal scrolling and reports where the terminal cursor - // belongs. Label is fixed-width 8 chars + 1 space = column 9. - let label = - |text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} ")))); - f.render_widget(label("cwd:"), rows[0]); - f.render_widget(label("command:"), rows[1]); - let draw_field = |f: &mut Frame, - input: &crate::text_input::TextInput, - row: Rect, - focused: bool| - -> u16 { - let style = if focused { - Style::default().add_modifier(Modifier::REVERSED) - } else { - Style::default() - }; - let area = Rect { - x: row.x.saturating_add(9), - y: row.y, - width: row.width.saturating_sub(9), - height: 1, - }; - input.render(area, f.buffer_mut(), style) - }; - let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd); - let cmd_col = draw_field( - f, - &self.command, - rows[1], - self.focused_field == SpawnField::Command, - ); - - f.render_widget( - Paragraph::new(Line::from(vec![ratatui::text::Span::styled( - "[ Spawn ]", - highlight(SpawnField::Submit), - )])), - rows[2], - ); - - match self.focused_field { - SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)), - SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)), - SpawnField::Submit => { - // The submit button is indicated by the REVERSED style; no - // cursor placement. - } - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SpawnInputResult { - Consumed, - Cancel, - Submit, -} - #[derive(Debug, Clone)] pub struct KillConfirm { pub label: String, @@ -262,33 +117,6 @@ mod tests { out } - #[test] - fn spawn_modal_renders_field_labels() { - let modal = SpawnModal { - cwd: crate::text_input::TextInput::with_value("/x"), - command: crate::text_input::TextInput::with_value("bash"), - focused_field: SpawnField::Cwd, - }; - let rendered = render_to_string(80, 20, |f| modal.render(f, f.area())); - for s in ["cwd", "command", "Spawn", "/x", "bash"] { - assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); - } - } - - #[test] - fn spawn_modal_field_rotation() { - let mut modal = SpawnModal::default(); - assert_eq!(modal.focused_field, SpawnField::Cwd); - modal.next_field(); - assert_eq!(modal.focused_field, SpawnField::Command); - modal.next_field(); - assert_eq!(modal.focused_field, SpawnField::Submit); - modal.next_field(); - assert_eq!(modal.focused_field, SpawnField::Cwd); - modal.prev_field(); - assert_eq!(modal.focused_field, SpawnField::Submit); - } - #[test] fn kill_confirm_renders_prompt() { let modal = KillConfirm { @@ -306,81 +134,11 @@ mod tests { let help = HelpOverlay { lines: vec![ "i EnterInsert".into(), - "s OpenSpawnModal".into(), + "s NewDraftPane".into(), ], }; let rendered = render_to_string(80, 12, |f| help.render(f, f.area())); assert!(rendered.contains("EnterInsert")); - assert!(rendered.contains("OpenSpawnModal")); - } - /// Shift-Tab walked nowhere: `prev_field` existed with no caller, so the - /// spawn modal could only be cycled forwards. - #[test] - fn shift_tab_walks_the_spawn_modal_backwards() { - let mut modal = SpawnModal::default(); - assert_eq!(modal.focused_field, SpawnField::Cwd); - - modal.on_input(&crate::input::InputKey::BackTab); - assert_eq!(modal.focused_field, SpawnField::Submit); - - modal.on_input(&crate::input::InputKey::BackTab); - assert_eq!(modal.focused_field, SpawnField::Command); - } - - #[test] - fn tab_and_shift_tab_are_inverses() { - let mut modal = SpawnModal::default(); - for _ in 0..3 { - modal.on_input(&crate::input::InputKey::Tab); - modal.on_input(&crate::input::InputKey::BackTab); - assert_eq!(modal.focused_field, SpawnField::Cwd); - } - } - - /// Editing keys must reach the focused field's text input, not a bare - /// String — this is what gives the operator cursor motion and word ops. - #[test] - fn editing_keys_reach_the_focused_text_input() { - let mut modal = SpawnModal::default(); - assert_eq!(modal.focused_field, SpawnField::Cwd); - for k in [ - crate::input::InputKey::char('/'), - crate::input::InputKey::char('t'), - crate::input::InputKey::char('m'), - crate::input::InputKey::char('p'), - ] { - modal.on_input(&k); - } - modal.on_input(&crate::input::InputKey::Left); - modal.on_input(&crate::input::InputKey::Backspace); - assert_eq!(modal.cwd.value(), "/tp"); - - // Tab to command, then a paste and ctrl-w land there, not in cwd. - modal.on_input(&crate::input::InputKey::Tab); - assert_eq!(modal.focused_field, SpawnField::Command); - modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into())); - assert_eq!(modal.command.value(), "/tmp/foo"); - modal.on_input(&crate::input::InputKey::ctrl_char('w')); - assert_eq!(modal.command.value(), ""); - assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused"); - } - - /// The terminal cursor must sit right after the typed text in the focused - /// field (label is 8 chars + 1 space, modal is centered on screen). - #[test] - fn render_places_the_cursor_after_the_typed_text() { - use ratatui::backend::TestBackend; - use ratatui::Terminal; - - let modal = SpawnModal { - cwd: crate::text_input::TextInput::with_value("abc"), - ..SpawnModal::default() - }; - let backend = TestBackend::new(80, 20); - let mut terminal = Terminal::new(backend).unwrap(); - terminal.draw(|f| modal.render(f, f.area())).unwrap(); - // Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9. - let pos = terminal.get_cursor_position().unwrap(); - assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6)); + assert!(rendered.contains("NewDraftPane")); } } diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 0bb2c75..8908d93 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -10,7 +10,7 @@ use ratatui::Frame; use crate::input::Region; use crate::keymap::Mode; -use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast}; +use crate::overlays::{HelpOverlay, KillConfirm, Toast}; use crate::pane::Pane; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind}; @@ -63,8 +63,9 @@ pub struct RenderInputs<'a> { pub sidebar: &'a Sidebar, pub tab_bar: &'a TabBar, pub pane: Option<&'a Pane>, + /// Inline spawn form when the active tab's pane is a draft (issue #63). + pub draft: Option<&'a crate::draft::DraftPane>, pub state: &'a AppState, - pub spawn: &'a SpawnModal, pub help: &'a HelpOverlay, } @@ -131,6 +132,9 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { if let Some(p) = inputs.pane { p.render(f, pane_area); } + if let Some(d) = inputs.draft { + d.render(f, pane_area, pane_focused); + } map.pane = Some(pane_area); // Status bar — always visible, never eclipsed by overlays so the user can @@ -138,10 +142,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { render_status_bar(f, status_area, inputs.state); match inputs.state.overlay() { - Some(OverlayKind::Spawn) => { - inputs.spawn.render(f, area); - map.modal = Some(area); - } Some(OverlayKind::KillConfirm(id)) => { let kc = KillConfirm { label: id.to_string(), @@ -186,7 +186,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { Focus::Pane => "pane", }; let hint = match state.mode { - Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit", + Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit", Mode::Insert => "^a: back to normal", }; let mode_style = match state.mode { @@ -266,4 +266,57 @@ mod tests { assert_eq!(map.region_at(15, 10), Region::Modal); assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal } + + /// Issue #63: an empty pane is a draft — the frame must show the spawn + /// form where the terminal would otherwise be. + #[test] + fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() { + use crate::overlays::HelpOverlay; + use crate::state::PaneRef; + use std::collections::HashSet; + + let mut state = AppState::new(); + state.open_draft_pane(); + let draft = match &state.tabs[0].pane { + Some(PaneRef::Draft(d)) => d, + other => panic!("expected a draft pane, got {other:?}"), + }; + let sidebar = Sidebar { + sessions: Vec::new(), + highlight: None, + attached: HashSet::new(), + }; + let tab_bar = TabBar { + tab_names: vec!["1".into()], + active_idx: 0, + }; + let help = HelpOverlay { lines: Vec::new() }; + let inputs = RenderInputs { + sidebar: &sidebar, + tab_bar: &tab_bar, + pane: None, + draft: Some(draft), + state: &state, + help: &help, + }; + + let backend = ratatui::backend::TestBackend::new(80, 24); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal + .draw(|f| { + draw(f, inputs); + }) + .unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + for s in ["draft", "cwd:", "command:", "[ Spawn ]"] { + assert!(out.contains(s), "missing {s:?}:\n{out}"); + } + } } diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 4e9e32f..0bdfb44 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -1,14 +1,16 @@ //! `AppState` — the top-level mutable state of the running TUI. //! //! v1 keeps it small on purpose: a list of tabs (each bound to at most one -//! session), an active tab index, focus split between sidebar and pane, the -//! current input mode, and an optional overlay (spawn modal, kill confirm, -//! help, transient toast). +//! session or a draft form), an active tab index, focus split between sidebar +//! and pane, the current input mode, and an optional overlay (kill confirm, +//! help, transient toast). The spawn form is no longer an overlay — an empty +//! pane is a draft pane (issue #63). //! -//! v1.5 will replace `Tab::pane: Option` with a split tree. +//! v1.5 will replace `Tab::pane`'s single slot with a split tree. use uuid::Uuid; +use crate::draft::DraftPane; use crate::keymap::Mode; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -17,15 +19,31 @@ pub enum Focus { Pane, } +/// What a tab's pane slot holds: a live session, or an inline draft form. +#[derive(Debug, Clone, PartialEq)] +pub enum PaneRef { + Session(Uuid), + Draft(DraftPane), +} + #[derive(Debug, Clone)] pub struct Tab { pub name: String, - pub pane: Option, + pub pane: Option, +} + +impl Tab { + /// The session this tab shows, if it shows one. Draft panes have none. + pub fn session(&self) -> Option { + match &self.pane { + Some(PaneRef::Session(sid)) => Some(*sid), + _ => None, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum OverlayKind { - Spawn, KillConfirm(Uuid), Help, Toast(String), @@ -108,7 +126,8 @@ impl AppState { /// /// Returns the session that tab was showing, if any, so the caller can /// release its emulator. Without that the tab's WebSocket task and vt100 - /// parser outlive the tab forever. + /// parser outlive the tab forever. A draft pane holds no session and + /// releases nothing. pub fn close_tab(&mut self) -> Option { if self.tabs.len() <= 1 { return None; @@ -117,9 +136,10 @@ impl AppState { if self.active_tab_idx >= self.tabs.len() { self.active_tab_idx = self.tabs.len() - 1; } + let sid = closed.session()?; // Only release it if no other tab still shows the same session. - let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane); - closed.pane.filter(|_| !still_shown) + let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid)); + (!still_shown).then_some(sid) } pub fn next_tab(&mut self) { @@ -147,11 +167,37 @@ impl AppState { /// drop straight into Insert mode so keystrokes go to the session /// without an extra `i` press. The leader returns to Normal. pub fn attach_to_focused(&mut self, session_id: Uuid) { - self.active_tab_mut().pane = Some(session_id); + self.active_tab_mut().pane = Some(PaneRef::Session(session_id)); self.focus = Focus::Pane; self.mode = Mode::Insert; } + /// Open a draft form in a pane (issue #63): reuse the empty active pane, + /// otherwise open a fresh tab for it — v1 has no split tree, so "split" + /// means a new tab. Focus lands in the pane, Insert mode on, so typing + /// starts immediately, same flow as [`Self::attach_to_focused`]. + pub fn open_draft_pane(&mut self) { + if self.active_tab().pane.is_some() { + self.new_tab(); + } + self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default())); + self.focus = Focus::Pane; + self.mode = Mode::Insert; + } + + /// A draft submit succeeded: replace whatever the active tab showed with + /// the new session in place, matching the web client's behaviour (#57). + pub fn complete_spawn(&mut self, sid: Uuid) { + self.active_tab_mut().pane = Some(PaneRef::Session(sid)); + self.focus = Focus::Pane; + self.mode = Mode::Insert; + } + + /// The session shown by the active tab's pane, if any. + pub fn active_session(&self) -> Option { + self.active_tab().session() + } + /// Move the sidebar highlight by `delta` (positive = down) within the /// given ordered list of selectable session ids. Wraps. No-op on empty. pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) { @@ -288,7 +334,7 @@ mod tests { let mut state = AppState::new(); let sid = Uuid::from_u128(7); state.attach_to_focused(sid); - assert_eq!(state.tabs[0].pane, Some(sid)); + assert_eq!(state.tabs[0].session(), Some(sid)); assert_eq!(state.focus, Focus::Pane); // The user expects to type into the session right away; the leader // (default ctrl-a) is the way back. @@ -314,7 +360,7 @@ mod tests { let mut state = AppState::new(); let sid = Uuid::from_u128(3); state.new_tab(); - state.active_tab_mut().pane = Some(sid); + state.active_tab_mut().pane = Some(PaneRef::Session(sid)); assert_eq!(state.close_tab(), Some(sid)); } @@ -324,9 +370,9 @@ mod tests { fn close_tab_keeps_a_session_another_tab_still_shows() { let mut state = AppState::new(); let sid = Uuid::from_u128(4); - state.active_tab_mut().pane = Some(sid); + state.active_tab_mut().pane = Some(PaneRef::Session(sid)); state.new_tab(); - state.active_tab_mut().pane = Some(sid); + state.active_tab_mut().pane = Some(PaneRef::Session(sid)); assert_eq!(state.close_tab(), None); } @@ -334,7 +380,7 @@ mod tests { #[test] fn close_tab_is_a_no_op_on_the_last_tab() { let mut state = AppState::new(); - state.active_tab_mut().pane = Some(Uuid::from_u128(5)); + state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5))); assert_eq!(state.close_tab(), None); assert_eq!(state.tabs.len(), 1); } @@ -347,4 +393,52 @@ mod tests { assert!(state.dismiss_overlay()); assert!(state.overlay().is_none()); } + + /// Issue #63: `s` over an empty pane turns that pane into a draft — + /// focused, Insert mode on, typing starts immediately. + #[test] + fn open_draft_reuses_the_empty_active_pane() { + let mut state = AppState::new(); + state.open_draft_pane(); + assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_)))); + assert_eq!(state.focus, Focus::Pane); + assert_eq!(state.mode, Mode::Insert); + assert_eq!(state.tabs.len(), 1); + } + + /// A tab already showing a session gets a fresh tab for the draft; v1 + /// has no split tree, so a new tab is the "split". + #[test] + fn open_draft_over_a_session_opens_a_new_tab() { + let mut state = AppState::new(); + state.attach_to_focused(Uuid::from_u128(9)); + state.open_draft_pane(); + assert_eq!(state.tabs.len(), 2); + assert_eq!(state.active_tab_idx, 1); + assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_)))); + // The session tab is untouched. + assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9))); + } + + /// Web parity (#57): submitting fills the pane in place. + #[test] + fn complete_spawn_replaces_the_draft_with_the_session() { + let mut state = AppState::new(); + state.open_draft_pane(); + let sid = Uuid::from_u128(21); + state.complete_spawn(sid); + assert_eq!(state.active_tab().session(), Some(sid)); + assert_eq!(state.focus, Focus::Pane); + assert_eq!(state.mode, Mode::Insert); + } + + /// A closed draft tab holds no session, so nothing is released. + #[test] + fn close_tab_reports_nothing_for_a_draft_pane() { + let mut state = AppState::new(); + state.open_draft_pane(); + state.new_tab(); + state.active_tab_idx = 0; + assert_eq!(state.close_tab(), None); + } } diff --git a/crates/maestro-tui/src/text_input.rs b/crates/maestro-tui/src/text_input.rs index 55a3a4a..c3e1b44 100644 --- a/crates/maestro-tui/src/text_input.rs +++ b/crates/maestro-tui/src/text_input.rs @@ -14,7 +14,7 @@ use ratatui::style::Style; use crate::input::InputKey; -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct TextInput { value: String, /// Cursor position as a char offset into `value`. From cea06f920444d86eb16ab0398e7627fd3e7a547a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:25:57 +0100 Subject: [PATCH 13/71] fix(maestro): forward bracketed pastes into the attached session forward_to_session dropped Paste(_) on the floor, so pastes into a session vanished once terminal.rs enabled bracketed paste. Split the arm: Paste forwards the payload verbatim (the remote readline reassembles it), only Mouse events have no wire encoding. --- crates/maestro-tui/src/app.rs | 90 ++++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 29 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index f262b2f..aae1d75 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -304,35 +304,7 @@ impl App { fn forward_to_session(&mut self, key: &InputKey) -> Option { let sid = self.state.active_session()?; self.emulators.get(sid)?; - let bytes = match key { - InputKey::Char { c, ctrl, alt, .. } => { - let mut out = Vec::with_capacity(8); - if *alt { - out.push(0x1b); - } - if *ctrl && c.is_ascii_alphabetic() { - out.push((c.to_ascii_uppercase() as u8) - b'@'); - } else { - let mut b = [0u8; 4]; - let s = c.encode_utf8(&mut b); - out.extend_from_slice(s.as_bytes()); - } - out - } - InputKey::Enter => b"\r".to_vec(), - InputKey::Tab => b"\t".to_vec(), - InputKey::BackTab => b"\x1b[Z".to_vec(), - InputKey::Backspace => vec![0x7f], - InputKey::Esc => vec![0x1b], - InputKey::Up => b"\x1b[A".to_vec(), - InputKey::Down => b"\x1b[B".to_vec(), - InputKey::Right => b"\x1b[C".to_vec(), - InputKey::Left => b"\x1b[D".to_vec(), - InputKey::Home => b"\x1b[H".to_vec(), - InputKey::End => b"\x1b[F".to_vec(), - InputKey::Delete => b"\x1b[3~".to_vec(), - InputKey::Paste(_) | InputKey::Mouse { .. } => return None, - }; + let bytes = encode_key(key)?; Some(Effect::Forward { id: sid, bytes }) } @@ -460,6 +432,43 @@ fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec { snap.sessions.iter().map(|s| s.id).collect() } +/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys +/// with no byte representation (mouse events). A bracketed paste is forwarded +/// verbatim — the remote readline reassembles it, since the pane's PTY also +/// has bracketed paste enabled. +fn encode_key(key: &InputKey) -> Option> { + match key { + InputKey::Char { c, ctrl, alt, .. } => { + let mut out = Vec::with_capacity(8); + if *alt { + out.push(0x1b); + } + if *ctrl && c.is_ascii_alphabetic() { + out.push((c.to_ascii_uppercase() as u8) - b'@'); + } else { + let mut b = [0u8; 4]; + let s = c.encode_utf8(&mut b); + out.extend_from_slice(s.as_bytes()); + } + Some(out) + } + InputKey::Enter => Some(b"\r".to_vec()), + InputKey::Tab => Some(b"\t".to_vec()), + InputKey::BackTab => Some(b"\x1b[Z".to_vec()), + InputKey::Backspace => Some(vec![0x7f]), + InputKey::Esc => Some(vec![0x1b]), + InputKey::Up => Some(b"\x1b[A".to_vec()), + InputKey::Down => Some(b"\x1b[B".to_vec()), + InputKey::Right => Some(b"\x1b[C".to_vec()), + InputKey::Left => Some(b"\x1b[D".to_vec()), + InputKey::Home => Some(b"\x1b[H".to_vec()), + InputKey::End => Some(b"\x1b[F".to_vec()), + InputKey::Delete => Some(b"\x1b[3~".to_vec()), + InputKey::Paste(text) => Some(text.as_bytes().to_vec()), + InputKey::Mouse { .. } => None, + } +} + /// Build the lines shown by the help overlay from the live keymap, so the /// user always sees the bindings that are actually active (defaults + their /// own `[tui.keymap.*]` overrides). @@ -686,6 +695,29 @@ mod tests { assert!(app.forward_to_session(&InputKey::Enter).is_none()); } + /// Regression review fix: `Paste(_)` used to be silently dropped, so a + /// paste into an attached session vanished once bracketed paste was + /// enabled — the payload must be forwarded verbatim. + #[test] + fn a_bracketed_paste_is_forwarded_verbatim_to_the_session() { + assert_eq!( + encode_key(&InputKey::Paste("cd /tmp/foo".into())), + Some(b"cd /tmp/foo".to_vec()) + ); + } + + #[test] + fn mouse_events_have_no_wire_encoding() { + use crate::input::{MouseKind, Region}; + assert!(encode_key(&InputKey::Mouse { + kind: MouseKind::Click, + region: Region::Pane, + col: 3, + row: 4, + }) + .is_none()); + } + #[test] fn kill_highlighted_opens_confirm() { use chrono::Utc; From b6cf87244f8e5c0f215c2bc018b3a55f87360a98 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:30:38 +0100 Subject: [PATCH 14/71] feat(maestro): [spawn].project_roots and project_scan_depth config (#54) --- crates/maestro-config/src/lib.rs | 39 ++++++++++++ crates/maestro-daemon/src/config.rs | 98 +++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index 4780720..e7d6520 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -162,6 +162,15 @@ pub struct SpawnSection { /// it resolves to just the `custom` tile. #[serde(default)] pub presets: Option>, + /// Root directories the daemon scans for git repositories (#54), `~` + /// expanded by the daemon against its own home. When absent the daemon + /// scans `["~"]`. + #[serde(default)] + pub project_roots: Option>, + /// How many directory levels below each [`Self::project_roots`] entry the + /// scanner descends (#54). When absent the daemon uses `3`. + #[serde(default)] + pub project_scan_depth: Option, } impl SpawnSection { @@ -387,6 +396,7 @@ mod tests { label: "fish".into(), command: "fish".into(), }]), + ..Default::default() }; let ids: Vec = section .resolve() @@ -403,6 +413,7 @@ mod tests { // only the implicit custom tile. let section = SpawnSection { presets: Some(vec![]), + ..Default::default() }; let ids: Vec = section .resolve() @@ -421,6 +432,7 @@ mod tests { label: "x".into(), command: "x".into(), }]), + ..Default::default() }; assert_eq!(empty_id.resolve().unwrap_err(), SpawnConfigError::EmptyId); @@ -430,6 +442,7 @@ mod tests { label: "x".into(), command: "".into(), }]), + ..Default::default() }; assert_eq!( empty_cmd.resolve().unwrap_err(), @@ -452,6 +465,7 @@ mod tests { command: "a2".into(), }, ]), + ..Default::default() }; assert_eq!( dup.resolve().unwrap_err(), @@ -464,6 +478,7 @@ mod tests { label: "Custom".into(), command: "whatever".into(), }]), + ..Default::default() }; assert_eq!( reserved.resolve().unwrap_err(), @@ -497,6 +512,30 @@ presets = [ assert_eq!(ids, ["claude", "zsh", "custom"]); } + #[test] + fn spawn_project_scan_fields_default_to_none() { + // Absent [spawn].project_* → None; the daemon applies its own defaults. + assert_eq!(SpawnSection::default().project_roots, None); + assert_eq!(SpawnSection::default().project_scan_depth, None); + } + + #[test] + fn spawn_project_scan_fields_parse_from_toml() { + let cfg = toml::from_str::( + r#" +[spawn] +project_roots = ["~/code", "/srv/work"] +project_scan_depth = 5 +"#, + ) + .expect("parse"); + assert_eq!( + cfg.spawn.project_roots, + Some(vec!["~/code".to_string(), "/srv/work".to_string()]) + ); + assert_eq!(cfg.spawn.project_scan_depth, Some(5)); + } + #[test] fn partial_file_loads_with_section_defaults() { // Only [daemon] present (typical single-host install). diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index dccb4f5..4e8684d 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -23,6 +23,14 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070"; /// `[daemon].spawn_history_days` is unset. `0` in config disables history. pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30; +/// Default scan roots for project discovery (#54) when `[spawn].project_roots` +/// is unset: the whole home directory, with the scanner's own ignore list +/// keeping it cheap. +pub const DEFAULT_PROJECT_ROOTS: [&str; 1] = ["~"]; +/// Default directory depth for project discovery (#54) when +/// `[spawn].project_scan_depth` is unset. +pub const DEFAULT_PROJECT_SCAN_DEPTH: u32 = 3; + /// Env var that overrides `[daemon].bind_addr`. pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR"; /// Env var that overrides `[daemon].tmux_tmpdir`. @@ -74,6 +82,12 @@ pub struct DaemonConfig { /// built-ins) with the implicit `custom` tile appended. Advertised to /// clients in `GET /api/info`. pub spawn_presets: Vec, + /// Project-discovery scan roots (#54): `[spawn].project_roots` (default + /// [`DEFAULT_PROJECT_ROOTS`]) with `~` expanded against the daemon's home. + pub project_roots: Vec, + /// Project-discovery depth (#54): `[spawn].project_scan_depth`, default + /// [`DEFAULT_PROJECT_SCAN_DEPTH`]. + pub project_scan_depth: u32, } impl DaemonConfig { @@ -147,6 +161,25 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result = file + .spawn + .project_roots + .unwrap_or_else(|| { + DEFAULT_PROJECT_ROOTS + .iter() + .map(|s| s.to_string()) + .collect() + }) + .iter() + .map(|root| expand_tilde(root, &home_dir)) + .collect(); + let project_scan_depth = file + .spawn + .project_scan_depth + .unwrap_or(DEFAULT_PROJECT_SCAN_DEPTH); + Ok(DaemonConfig { hostname, home_dir, @@ -157,9 +190,24 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result PathBuf { + match path.strip_prefix("~") { + Some(_) if home.is_empty() => PathBuf::from(path), + Some(rest) => { + let mut p = PathBuf::from(home); + p.push(rest.trim_start_matches('/')); + p + } + None => PathBuf::from(path), + } +} + pub fn default_state_dir() -> Result { Ok(maestro_config::default_state_dir("maestro")?) } @@ -280,6 +328,56 @@ spawn_history_days = 0 assert_eq!(cfg.spawn_history_days, 0); } + #[test] + fn project_scan_defaults_to_home_root_at_depth_3() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "").unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.project_scan_depth, DEFAULT_PROJECT_SCAN_DEPTH); + assert_eq!( + cfg.project_roots, + vec![PathBuf::from(cfg.home_dir.clone())], + "bare ~ must expand to the daemon's home" + ); + } + + #[test] + fn project_scan_roots_expand_tilde_and_pass_through_absolutes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + r#" +[spawn] +project_roots = ["~/code", "/srv/work"] +project_scan_depth = 5 +"#, + ) + .unwrap(); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.project_scan_depth, 5); + assert_eq!( + cfg.project_roots, + vec![ + PathBuf::from(cfg.home_dir.clone()).join("code"), + PathBuf::from("/srv/work"), + ] + ); + } + + #[test] + fn tilde_expansion_handles_bare_and_nested_forms() { + assert_eq!(expand_tilde("~", "/home/u"), PathBuf::from("/home/u")); + assert_eq!( + expand_tilde("~/a/b", "/home/u"), + PathBuf::from("/home/u/a/b") + ); + assert_eq!(expand_tilde("/abs", "/home/u"), PathBuf::from("/abs")); + // Unresolvable home leaves the tilde as-is rather than mangling it. + assert_eq!(expand_tilde("~", ""), PathBuf::from("~")); + } + #[test] fn system_hostname_is_nonempty() { let h = system_hostname(); From 33fb50be6e712736a5f46c12d4c7eb17c9e9b1a3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:32:29 +0100 Subject: [PATCH 15/71] feat(maestro): Project wire type and PROJECTS route (#54) --- crates/maestro-protocol/src/client.rs | 7 +++- crates/maestro-protocol/src/lib.rs | 52 +++++++++++++++++++++++++++ crates/maestro-protocol/src/routes.rs | 4 ++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 71ae127..dd4203f 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -15,7 +15,8 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ - ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session, + ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project, + RecentDirs, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -138,6 +139,10 @@ impl DaemonClient { .await } + pub async fn list_projects(&self) -> Result, ClientError> { + self.get(routes::PROJECTS, &[]).await + } + // -- plumbing ----------------------------------------------------------- async fn get( diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 82c73b7..b06e3b7 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -188,6 +188,30 @@ pub struct GitRoot { pub root: Option, } +/// One discovered git repository (or linked worktree) for `GET /api/projects` +/// (#54). The daemon reports the timestamp; clients decide what "stale" means +/// (the web UI dims entries older than 7 days). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Project { + /// Absolute path of the repository/worktree toplevel. + pub path: PathBuf, + /// Basename of `path`. + pub name: String, + #[serde(default)] + pub is_worktree: bool, + /// The main checkout this worktree belongs to. Set only when + /// [`Self::is_worktree`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_repo: Option, + /// Later of the repo's git HEAD mtime and the newest spawn-history use of + /// any directory inside it. `None` when neither is known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_activity: Option>, + /// Command last spawned in this project, from spawn history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_command: Option, +} + /// Structured failure used everywhere we don't want to leak a string-only error. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ErrorInfo { @@ -329,6 +353,34 @@ mod tests { serde_json::from_str(&json).expect("deserialize") } + #[test] + fn project_round_trips_and_defaults_on_legacy_payload() { + let full = Project { + path: PathBuf::from("/home/u/tea/maestro"), + name: "maestro".into(), + is_worktree: true, + base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")), + last_activity: Some( + DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z") + .unwrap() + .with_timezone(&Utc), + ), + last_command: Some("claude".into()), + }; + assert_eq!(full, round_trip(&full)); + + // A payload from a daemon predating the optional fields still decodes. + let legacy = serde_json::json!({ + "path": "/home/u/proj", + "name": "proj", + }); + let p: Project = serde_json::from_value(legacy).unwrap(); + assert!(!p.is_worktree); + assert_eq!(p.base_repo, None); + assert_eq!(p.last_activity, None); + assert_eq!(p.last_command, None); + } + fn sample_session() -> Session { Session { id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 10c8131..777d086 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -17,6 +17,8 @@ pub const INFO: &str = "/api/info"; pub const DIRS: &str = "/api/dirs"; /// `GET` spawn history. pub const RECENT_DIRS: &str = "/api/recent-dirs"; +/// `GET` discovered git repositories and worktrees, newest activity first. +pub const PROJECTS: &str = "/api/projects"; /// `GET` the enclosing repository of a path. Takes a `path` query param. pub const GITROOT: &str = "/api/gitroot"; /// `WS` catalog snapshot + session deltas. @@ -172,7 +174,7 @@ mod tests { #[test] fn every_route_sits_under_the_api_prefix() { - for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, GITROOT, EVENTS] { + for path in [SESSIONS, INFO, DIRS, RECENT_DIRS, PROJECTS, GITROOT, EVENTS] { assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}"); } assert!(attach(id()).starts_with(API_PREFIX)); From 71301861c054fa1bd7f73639a06b2c0d4f17a7ab Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:38:55 +0100 Subject: [PATCH 16/71] chore(maestro): add happy-dom for DOM-level web tests --- web/package.json | 1 + web/pnpm-lock.yaml | 104 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/web/package.json b/web/package.json index 26e4793..528365c 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "@xterm/xterm": "^5.5.0" }, "devDependencies": { + "happy-dom": "^20.11.6", "typescript": "^5.6.3", "vite": "^5.4.10", "vitest": "^2.1.9" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index cc85eb1..c4de6d0 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -15,15 +15,18 @@ importers: specifier: ^5.5.0 version: 5.5.0 devDependencies: + happy-dom: + specifier: ^20.11.6 + version: 20.11.6 typescript: specifier: ^5.6.3 version: 5.9.3 vite: specifier: ^5.4.10 - version: 5.4.21 + version: 5.4.21(@types/node@26.2.0) vitest: specifier: ^2.1.9 - version: 2.1.9 + version: 2.1.9(@types/node@26.2.0)(happy-dom@20.11.6) packages: @@ -296,6 +299,15 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -337,6 +349,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -362,6 +378,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -382,6 +402,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + happy-dom@20.11.6: + resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==} + engines: {node: '>=20.0.0'} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -451,6 +475,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -512,11 +539,27 @@ packages: jsdom: optional: true + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + snapshots: '@esbuild/aix-ppc64@0.21.5': @@ -667,6 +710,16 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@26.2.0': + dependencies: + undici-types: 8.3.0 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.2.0 + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -674,13 +727,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21)': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.2.0))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21 + vite: 5.4.21(@types/node@26.2.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -715,6 +768,10 @@ snapshots: assertion-error@2.0.1: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.2.0 + cac@6.7.14: {} chai@5.3.3: @@ -733,6 +790,8 @@ snapshots: deep-eql@5.0.2: {} + entities@7.0.1: {} + es-module-lexer@1.7.0: {} esbuild@0.21.5: @@ -770,6 +829,19 @@ snapshots: fsevents@2.3.3: optional: true + happy-dom@20.11.6: + dependencies: + '@types/node': 26.2.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + loupe@3.2.1: {} magic-string@0.30.21: @@ -843,13 +915,15 @@ snapshots: typescript@5.9.3: {} - vite-node@2.1.9: + undici-types@8.3.0: {} + + vite-node@2.1.9(@types/node@26.2.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21 + vite: 5.4.21(@types/node@26.2.0) transitivePeerDependencies: - '@types/node' - less @@ -861,18 +935,19 @@ snapshots: - supports-color - terser - vite@5.4.21: + vite@5.4.21(@types/node@26.2.0): dependencies: esbuild: 0.21.5 postcss: 8.5.15 rollup: 4.60.4 optionalDependencies: + '@types/node': 26.2.0 fsevents: 2.3.3 - vitest@2.1.9: + vitest@2.1.9(@types/node@26.2.0)(happy-dom@20.11.6): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.2.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -888,9 +963,12 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21 - vite-node: 2.1.9 + vite: 5.4.21(@types/node@26.2.0) + vite-node: 2.1.9(@types/node@26.2.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + happy-dom: 20.11.6 transitivePeerDependencies: - less - lightningcss @@ -902,7 +980,11 @@ snapshots: - supports-color - terser + whatwg-mimetype@3.0.0: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + ws@8.21.3: {} From ca585447ffbc8e2ab356892dae30151fd86afaf8 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:38:55 +0100 Subject: [PATCH 17/71] fix(maestro): dir autocomplete dismissal and key handling Escape closes the popup, refocuses the input and stops propagation so it can't also discard a surrounding draft pane. Tab commits the highlighted entry and lets focus move to the next field. Blur / clicking outside dismisses, and a dismissed popup stays closed until the operator types again. --- web/src/dirAutocomplete.test.ts | 143 ++++++++++++++++++++++++++++++++ web/src/dirAutocomplete.ts | 63 ++++++++------ 2 files changed, 179 insertions(+), 27 deletions(-) create mode 100644 web/src/dirAutocomplete.test.ts diff --git a/web/src/dirAutocomplete.test.ts b/web/src/dirAutocomplete.test.ts new file mode 100644 index 0000000..3c9e811 --- /dev/null +++ b/web/src/dirAutocomplete.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./api.js", () => ({ listDir: vi.fn() })); + +import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js"; +import { listDir } from "./api.js"; + +const listDirMock = vi.mocked(listDir); + +let input: HTMLInputElement; +let nextField: HTMLInputElement; +let docKeydown: ReturnType; +let ac: DirAutocompleteHandle | undefined; + +function popup(): HTMLUListElement { + return document.querySelector(".dir-ac") as HTMLUListElement; +} + +function items(): HTMLElement[] { + return [...popup().querySelectorAll(".dir-ac-item")]; +} + +async function type(value: string): Promise { + input.focus(); + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + await vi.advanceTimersByTimeAsync(100); // past the fetch debounce +} + +/** Dispatch a cancelable, bubbling keydown on the input; report whether the + * default browser behaviour (focus move, form submit) was suppressed. */ +function press(key: string): { defaultPrevented: boolean } { + const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + input.dispatchEvent(evt); + return { defaultPrevented: evt.defaultPrevented }; +} + +beforeEach(() => { + vi.useFakeTimers(); + document.body.replaceChildren(); + input = document.createElement("input"); + nextField = document.createElement("input"); + document.body.append(input, nextField); + docKeydown = vi.fn(); + document.addEventListener("keydown", docKeydown); + listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false }); +}); + +afterEach(() => { + ac?.destroy(); + document.removeEventListener("keydown", docKeydown); + vi.useRealTimers(); +}); + +describe("dirAutocomplete dismissal", () => { + it("Escape closes the popup, keeps the typed value and does not propagate", async () => { + ac = attachDirAutocomplete(input); + await type("/tmp/pro"); + expect(popup().hidden).toBe(false); + + const { defaultPrevented } = press("Escape"); + + expect(defaultPrevented).toBe(true); + expect(popup().hidden).toBe(true); + expect(input.value).toBe("/tmp/pro"); + expect(document.activeElement).toBe(input); + expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire + }); + + it("blur dismisses the popup", async () => { + ac = attachDirAutocomplete(input); + await type("/tmp/pro"); + expect(popup().hidden).toBe(false); + + input.blur(); + await vi.advanceTimersByTimeAsync(150); + + expect(popup().hidden).toBe(true); + }); + + it("does not reopen on refocus until the operator types again", async () => { + ac = attachDirAutocomplete(input); + await type("/tmp/pro"); + input.blur(); + await vi.advanceTimersByTimeAsync(150); + + input.focus(); + await vi.advanceTimersByTimeAsync(150); + expect(popup().hidden).toBe(true); + + await type("/tmp/ot"); + expect(popup().hidden).toBe(false); + }); +}); + +describe("dirAutocomplete keyboard model", () => { + it("Tab commits the highlighted entry and lets focus move to the next field", async () => { + ac = attachDirAutocomplete(input); + await type("/tmp/pro"); // highlight defaults to the first match: proj-a + + nextField.focus(); + const { defaultPrevented } = press("Tab"); + + expect(input.value).toBe("/tmp/proj-a/"); + expect(defaultPrevented).toBe(false); // native tab navigation proceeds + expect(document.activeElement).toBe(nextField); // focus was not yanked back + }); + + it("Tab with nothing highlighted just moves on", async () => { + ac = attachDirAutocomplete(input); + await type("/tmp/nope-matches-nothing"); + expect(popup().hidden).toBe(true); + + const { defaultPrevented } = press("Tab"); + + expect(defaultPrevented).toBe(false); + expect(input.value).toBe("/tmp/nope-matches-nothing"); + }); + + it("arrows move the highlight and Enter commits it without submitting", async () => { + ac = attachDirAutocomplete(input); + await type("/tmp/"); + + press("ArrowDown"); + expect(items()[1].classList.contains("is-active")).toBe(true); + + const enter = press("Enter"); + expect(enter.defaultPrevented).toBe(true); // no form submit + expect(input.value).toBe("/tmp/proj-b/"); + }); +}); + +describe("dirAutocomplete opening", () => { + it("still opens on focus when not previously dismissed", async () => { + ac = attachDirAutocomplete(input); + input.focus(); + await vi.advanceTimersByTimeAsync(50); + + expect(popup().hidden).toBe(false); + expect(listDirMock).toHaveBeenCalled(); + }); +}); diff --git a/web/src/dirAutocomplete.ts b/web/src/dirAutocomplete.ts index 3bf9647..0eb5efe 100644 --- a/web/src/dirAutocomplete.ts +++ b/web/src/dirAutocomplete.ts @@ -4,15 +4,19 @@ // per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/` // hits the network. Keyboard model mirrors `cd dir/sub`: // -// Tab complete the longest common prefix; if one match, accept it (and -// append `/` so the next level loads); if nothing to extend, cycle. +// Tab commit the highlighted entry and let focus move on; with nothing +// highlighted, plain tab navigation // ↑/↓ move the highlight // Enter accept the highlighted entry (and suppress form submit) -// Esc close the dropdown +// Esc close the dropdown, refocus the input, and swallow the key so it +// can't also discard a surrounding draft pane +// +// Once dismissed (Esc or blur) the popup stays closed until the operator types +// again — refocusing alone doesn't reopen it. // // Failures (offline daemon, permission denied) degrade to no suggestions. -import { longestCommonPrefix, splitPath } from "./paths.js"; +import { splitPath } from "./paths.js"; import { listDir } from "./api.js"; export interface DirAutocompleteHandle { @@ -42,6 +46,9 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH let fetchSeq = 0; let debounce: number | undefined; let open = false; + // Set when the operator dismissed the popup (Esc, blur, outside click); a + // refocus must not reopen it — only new typing does. + let dismissed = false; function close(): void { @@ -54,6 +61,11 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH window.removeEventListener("resize", reposition); } + function dismiss(): void { + dismissed = true; + close(); + } + function reposition(): void { const r = input.getBoundingClientRect(); dropdown.style.left = `${r.left}px`; @@ -132,21 +144,28 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH } // Accept `name` as the next path segment: complete it and append `/` so the - // child directory's listing loads immediately (the drill-in gesture). - function accept(name: string): void { + // child directory's listing loads on the next interaction (the drill-in + // gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit + // and move to the next field in one gesture. + function commit(name: string): void { const { dir } = splitPath(input.value); input.value = `${dir}${name}/`; - input.focus(); - // Notify other listeners (spawn.ts marks the field user-edited) and load - // the child level. + // Notify other listeners (spawnForm marks the field user-edited). input.dispatchEvent(new Event("input", { bubbles: true })); } + function accept(name: string): void { + commit(name); + input.focus(); + } + function onInput(): void { + dismissed = false; scheduleRefresh(); } function onFocus(): void { + if (dismissed) return; refresh(); } @@ -154,27 +173,17 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH if (e.key === "Escape") { if (open) { e.preventDefault(); - close(); + // Swallow the key: an Escape that bubbles would also discard the + // draft pane hosting this form. + e.stopPropagation(); + dismiss(); + input.focus(); } return; } if (e.key === "Tab") { - if (!open || filtered.length === 0) return; // let focus move normally - e.preventDefault(); - if (filtered.length === 1) { - accept(filtered[0]); - return; - } - const { dir, prefix } = splitPath(input.value); - const lcp = longestCommonPrefix(filtered); - if (lcp.length > prefix.length) { - input.value = `${dir}${lcp}`; - input.dispatchEvent(new Event("input", { bubbles: true })); - } else { - // Nothing to extend — cycle the highlight like a second shell . - highlight = (highlight + 1) % filtered.length; - render(); - } + if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation + commit(filtered[highlight]); // no preventDefault — focus moves to the next field return; } if (!open) return; @@ -196,7 +205,7 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH function onBlur(): void { // Delay so a dropdown mousedown can complete first. - window.setTimeout(close, 120); + window.setTimeout(dismiss, 120); } input.addEventListener("input", onInput); From 1809f5d5812d96f594ec96f4edf20f1e3bf6df12 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:44:15 +0100 Subject: [PATCH 18/71] feat(maestro): uncapped spawn-history recents for project enrichment (#54) --- crates/maestro-daemon/src/session.rs | 10 ++++++ crates/maestro-daemon/src/spawn_history.rs | 40 ++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 7ae544c..ac54fb1 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -169,6 +169,16 @@ impl SessionManager { .unwrap_or_default() } + /// Every deduped recent dir, newest-first, without the picker's result + /// cap (#54): project enrichment folds the whole history. + pub fn all_recent_dirs(&self) -> Vec { + self.inner + .spawn_history + .as_ref() + .map(|h| h.all_recent_dirs()) + .unwrap_or_default() + } + /// Subscribe to session deltas. Each subscriber gets its own lag domain — /// a slow client makes only itself resync. pub fn events(&self) -> broadcast::Receiver { diff --git a/crates/maestro-daemon/src/spawn_history.rs b/crates/maestro-daemon/src/spawn_history.rs index 668bca4..b0aec59 100644 --- a/crates/maestro-daemon/src/spawn_history.rs +++ b/crates/maestro-daemon/src/spawn_history.rs @@ -143,11 +143,23 @@ impl SpawnHistory { /// Recent working dirs, newest use first, deduped by `cwd`, within the /// recency cap, capped at `max_results`. Empty when disabled or unreadable. pub fn recent_dirs(&self) -> Vec { - self.recent_dirs_as_of(Utc::now()) + self.recent_dirs_capped(Utc::now(), self.max_results) + } + + /// Every deduped recent dir, newest-first, without the picker's result + /// cap (#54): project enrichment folds the whole history, not its first + /// page, so a project spawned in long ago still reads as fresh. + pub fn all_recent_dirs(&self) -> Vec { + self.recent_dirs_capped(Utc::now(), usize::MAX) } /// [`recent_dirs`](Self::recent_dirs) with an explicit clock for testing. + #[cfg(test)] fn recent_dirs_as_of(&self, now: DateTime) -> Vec { + self.recent_dirs_capped(now, self.max_results) + } + + fn recent_dirs_capped(&self, now: DateTime, max_results: usize) -> Vec { if !self.is_enabled() { return Vec::new(); } @@ -168,7 +180,7 @@ impl SpawnHistory { last_command: rec.command, last_used: rec.at, }); - if out.len() >= self.max_results { + if out.len() >= max_results { break; } } @@ -362,4 +374,28 @@ mod tests { assert_eq!(recents[0].cwd, PathBuf::from("/d4")); assert_eq!(recents[1].cwd, PathBuf::from("/d3")); } + + /// Project enrichment (#54) folds the whole log, not just the picker's + /// first page. + #[test] + fn all_recent_dirs_skips_the_result_cap() { + let tmp = tempfile::tempdir().unwrap(); + let mut h = SpawnHistory::new(tmp.path().join("h.jsonl"), 30); + h.max_results = 2; + // Recent enough to survive the 30-day cap against the real clock. + let base = Utc::now() - Duration::hours(1); + for i in 0..5 { + h.append(&rec( + &format!("/d{i}"), + "claude", + base + Duration::minutes(i), + )) + .unwrap(); + } + assert_eq!(h.recent_dirs().len(), 2); + let all = h.all_recent_dirs(); + assert_eq!(all.len(), 5); + assert_eq!(all[0].cwd, PathBuf::from("/d4")); + assert_eq!(all[4].cwd, PathBuf::from("/d0")); + } } From 4a814d5b17d3eebcc8f871283db6a15cd20a2e6c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:44:15 +0100 Subject: [PATCH 19/71] feat(maestro): project discovery scanner and GET /api/projects (#54) --- crates/maestro-daemon/src/cli.rs | 8 + crates/maestro-daemon/src/lib.rs | 1 + crates/maestro-daemon/src/local_api.rs | 19 +- crates/maestro-daemon/src/projects.rs | 530 +++++++++++++++++++++++ crates/maestro-daemon/tests/attach.rs | 1 + crates/maestro-daemon/tests/local_api.rs | 79 ++++ 6 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 crates/maestro-daemon/src/projects.rs diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index a836059..9aafcf6 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -72,11 +72,19 @@ async fn run(config_override: Option) -> Result<(), Box> { tracing::info!(recovered, "adopted existing tmux sessions"); manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL); + let projects = std::sync::Arc::new(crate::projects::ProjectScanner::new( + config.project_roots.clone(), + config.project_scan_depth, + PathBuf::from(&config.home_dir), + )); + projects.start_background_refresh(); + let router = local_api::router(local_api::state_for( manager, config.hostname.clone(), config.home_dir.clone(), config.spawn_presets.clone(), + projects, )); let listener = TcpListener::bind(config.bind_addr).await?; diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index f40ca02..993536c 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -16,6 +16,7 @@ pub mod gitroot; pub mod local_api; pub mod panes; pub mod proc; +pub mod projects; pub mod pty; pub mod session; pub mod sidecar; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index d7bb438..515324b 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -36,6 +36,8 @@ pub struct LocalApiState { pub home_dir: Arc, /// Resolved spawn presets (#22/#26) advertised in `GET /api/info`. pub presets: Arc>, + /// Project discovery (#54) backing `GET /api/projects`. + pub projects: Arc, } pub fn router(state: LocalApiState) -> Router { @@ -44,6 +46,7 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::patterns::SESSION, delete(kill_session)) .route(routes::DIRS, get(list_dir)) .route(routes::RECENT_DIRS, get(recent_dirs)) + .route(routes::PROJECTS, get(projects)) .route(routes::GITROOT, get(git_root)) .route(routes::INFO, get(daemon_info)) .route(routes::EVENTS, any(events_ws)) @@ -98,6 +101,15 @@ async fn recent_dirs(State(s): State) -> Response { Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response() } +/// Discovered git repositories and worktrees, newest activity first (#54). +/// `last_activity` is the later of the repo's git HEAD mtime and the newest +/// spawn-history use of any directory inside it. +async fn projects(State(s): State) -> Response { + let repos = s.projects.repos().await; + let history = s.manager.all_recent_dirs(); + Json(crate::projects::to_projects(&repos, &history)).into_response() +} + /// Probe the enclosing git repo of `path`, to infer the worktree `base_repo` /// for the spawn form (#26). `path` is resolved against the daemon's home dir /// (empty/`~`/relative), like [`list_dir`]. @@ -157,18 +169,23 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response { } /// Convenience constructor — pulls the bits `LocalApiState` needs from -/// `DaemonConfig` so callers don't have to assemble it by hand. +/// `DaemonConfig` so callers don't have to assemble it by hand. The project +/// scanner starts with its cache cold; wire +/// [`ProjectScanner::start_background_refresh`](crate::projects::ProjectScanner::start_background_refresh) +/// on the returned handle's scanner to keep it eventually fresh. pub fn state_for( manager: SessionManager, hostname: impl Into, home_dir: impl Into, presets: Vec, + projects: Arc, ) -> LocalApiState { LocalApiState { manager, hostname: Arc::new(hostname.into()), home_dir: Arc::new(home_dir.into()), presets: Arc::new(presets), + projects, } } diff --git a/crates/maestro-daemon/src/projects.rs b/crates/maestro-daemon/src/projects.rs new file mode 100644 index 0000000..fb31467 --- /dev/null +++ b/crates/maestro-daemon/src/projects.rs @@ -0,0 +1,530 @@ +//! Project discovery for `GET /api/projects` (#54). +//! +//! Scans `[spawn].project_roots` (default the whole home) for `.git` entries — +//! directory or file, so linked worktrees count too — up to +//! `[spawn].project_scan_depth` levels. Descent stops at the first repo found: +//! nested independent repos are not discovered, but worktrees of every hit +//! still are, via `git worktree list`, which also catches linked worktrees +//! living outside the roots. +//! +//! Hidden directories are skipped everywhere; heavy well-known directories +//! (`node_modules`, `Downloads`, …) are skipped when they sit directly in the +//! home directory, so scanning all of home stays cheap. +//! +//! Results are cached behind a short TTL and refreshed on a slow background +//! timer ([`ProjectScanner::start_background_refresh`]) — repositories appear +//! rarely, and `/api/projects` may be polled. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use chrono::{DateTime, Utc}; + +use crate::spawn_history::RecentDir; + +/// Directory names never descended when they sit directly in the home dir, so +/// scanning all of home stays cheap (#54). Hidden dirs are skipped everywhere +/// regardless. +pub const DEFAULT_HOME_IGNORE: &[&str] = + &["node_modules", "Downloads", "Library", "go", "snap", "opt"]; + +/// How long a discovery snapshot is served as-is before the next request +/// triggers a rescan. Short: a scan is a bounded filesystem walk plus one +/// `git worktree list` per hit. +pub const CACHE_TTL: Duration = Duration::from_secs(5); +/// Background refresh cadence. Repositories appear rarely; this keeps the +/// served list eventually fresh without anyone asking. +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(60); + +/// One repository or linked worktree found by [`discover`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredRepo { + pub path: PathBuf, + /// Basename of `path`. + pub name: String, + pub is_worktree: bool, + /// The main checkout this worktree belongs to; set only when + /// [`Self::is_worktree`]. + pub base_repo: Option, + /// Mtime of the checkout's git HEAD — "someone committed / checked out + /// here". The later-of merge with spawn history happens in + /// [`to_projects`], which also sorts. + pub head_mtime: Option>, +} + +/// Repo toplevels under each root of `roots`, at most `max_depth` levels deep. +/// A dir holding `.git` (dir *or* file) is a hit and is not descended into; +/// hidden directories are never entered, and `DEFAULT_HOME_IGNORE` names are +/// skipped directly under `home`. +pub fn scan_roots(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec { + let mut found = Vec::new(); + for root in roots { + walk(root, 0, max_depth, home, &mut found); + } + found.sort(); + found.dedup(); + found +} + +fn walk(dir: &Path, depth: u32, max_depth: u32, home: &Path, found: &mut Vec) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + let mut subdirs = Vec::new(); + let mut has_git = false; + for entry in entries.flatten() { + match entry.file_name().to_string_lossy().into_owned() { + name if name == ".git" => has_git = true, + name if name.starts_with('.') => {} + name if dir == home && DEFAULT_HOME_IGNORE.contains(&name.as_str()) => {} + _ => { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + subdirs.push(entry.path()); + } + } + } + } + if has_git { + // Match `git worktree list` output, which prints real paths. + found.push(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf())); + return; + } + if depth >= max_depth { + return; + } + for sub in subdirs { + walk(&sub, depth + 1, max_depth, home, found); + } +} + +/// Every discovered project: the scanned repos plus all worktrees their +/// `git worktree list` reports (linked worktrees can live outside the roots). +/// A hit whose git invocation fails still lists, un-worktreed. +pub fn discover(roots: &[PathBuf], max_depth: u32, home: &Path) -> Vec { + let scanned = scan_roots(roots, max_depth, home); + let mut map: Vec<(PathBuf, PathBuf)> = Vec::new(); // (checkout, main) + for repo in scanned { + if map.iter().any(|(c, _)| c == &repo) { + continue; + } + let worktrees = list_worktrees(&repo).unwrap_or_else(|| vec![repo.clone()]); + let main = worktrees[0].clone(); + for wt in worktrees { + if !map.iter().any(|(c, _)| c == &wt) { + map.push((wt, main.clone())); + } + } + } + map.into_iter() + .map(|(path, main)| { + let is_worktree = path != main; + DiscoveredRepo { + name: path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + is_worktree, + base_repo: is_worktree.then_some(main), + head_mtime: head_mtime(&path), + path, + } + }) + .collect() +} + +/// `git worktree list --porcelain` from `repo`: absolute paths, main first. +/// `None` when git fails (broken/bare checkout). +fn list_worktrees(repo: &Path) -> Option> { + let out = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(["worktree", "list", "--porcelain"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let mut paths = Vec::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + if let Some(p) = line.strip_prefix("worktree ") { + paths.push(PathBuf::from(p)); + } + } + (!paths.is_empty()).then_some(paths) +} + +/// Mtime of the checkout's HEAD file. For a linked worktree `.git` is a file +/// holding a `gitdir:` pointer; resolve it so worktrees report their own HEAD +/// rather than nothing. +fn head_mtime(repo: &Path) -> Option> { + let dot_git = repo.join(".git"); + let head = if dot_git.is_dir() { + dot_git.join("HEAD") + } else { + let ptr = std::fs::read_to_string(&dot_git).ok()?; + let gitdir = ptr.trim().strip_prefix("gitdir:")?.trim(); + // Relative pointers are relative to the worktree. + let p = Path::new(gitdir); + let p = if p.is_absolute() { + p.to_path_buf() + } else { + repo.join(p) + }; + p.join("HEAD") + }; + let mtime = std::fs::metadata(head).ok()?.modified().ok()?; + Some(DateTime::::from(mtime)) +} + +/// Fold spawn history into the discovery snapshot and produce the wire list, +/// sorted newest-activity-first. `history` must be newest-first (what +/// [`SpawnHistory::all_recent_dirs`](crate::spawn_history::SpawnHistory) +/// returns): the first entry whose cwd sits inside a project is its most +/// recent use. +pub fn to_projects( + repos: &[DiscoveredRepo], + history: &[RecentDir], +) -> Vec { + let mut projects: Vec = repos + .iter() + .map(|repo| { + let last_use = history.iter().find(|h| contains(&repo.path, &h.cwd)); + maestro_protocol::Project { + path: repo.path.clone(), + name: repo.name.clone(), + is_worktree: repo.is_worktree, + base_repo: repo.base_repo.clone(), + last_activity: max_opt(repo.head_mtime, last_use.map(|u| u.last_used)), + last_command: last_use.map(|u| u.last_command.clone()), + } + }) + .collect(); + projects.sort_by_key(|p| std::cmp::Reverse(p.last_activity)); + projects +} + +/// Whether `dir` is `project` itself or anywhere inside it. +fn contains(project: &Path, dir: &Path) -> bool { + dir == project || dir.starts_with(project) +} + +fn max_opt(a: Option>, b: Option>) -> Option> { + match (a, b) { + (Some(a), Some(b)) => Some(a.max(b)), + (a, b) => a.or(b), + } +} + +/// Cached discovery behind a short TTL plus an optional slow background +/// refresh. Clone-free shared handle via `Arc` in [`crate::local_api`]. +#[derive(Debug)] +pub struct ProjectScanner { + roots: Vec, + max_depth: u32, + home: PathBuf, + ttl: Duration, + cache: tokio::sync::Mutex>)>>, +} + +impl ProjectScanner { + /// Scanner over the resolved config roots. An empty `roots` list scans + /// nothing and always serves an empty list. + pub fn new(roots: Vec, max_depth: u32, home: PathBuf) -> Self { + Self { + roots, + max_depth, + home, + ttl: CACHE_TTL, + cache: tokio::sync::Mutex::new(None), + } + } + + /// Scanner that never discovers anything — tests and embedders that don't + /// want the endpoint populated. + pub fn disabled() -> Self { + Self::new(Vec::new(), 0, PathBuf::new()) + } + + /// Cached snapshot, rescanning (off-thread) when the TTL has lapsed. + pub async fn repos(&self) -> std::sync::Arc> { + let mut cache = self.cache.lock().await; + if let Some((at, repos)) = cache.as_ref() { + if at.elapsed() < self.ttl { + return repos.clone(); + } + } + let roots = self.roots.clone(); + let max_depth = self.max_depth; + let home = self.home.clone(); + // Blocking fs walk + git subprocesses must not stall the reactor. + let repos = std::sync::Arc::new( + tokio::task::spawn_blocking(move || discover(&roots, max_depth, &home)) + .await + .unwrap_or_default(), + ); + *cache = Some((Instant::now(), repos.clone())); + repos + } + + /// Rescan now, ignoring the TTL. Called by the background timer. + async fn refresh(&self) { + self.repos().await; + } + + /// Spawn the slow background refresh task. Drop the returned handle to + /// detach it; it stops when the runtime shuts down. + pub fn start_background_refresh(self: &std::sync::Arc) -> tokio::task::JoinHandle<()> { + let scanner = std::sync::Arc::clone(self); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(REFRESH_INTERVAL); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + ticker.tick().await; // the immediate first tick — wait a full interval + loop { + scanner.refresh().await; + ticker.tick().await; + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spawn_history::RecentDir; + + /// Real git repo at `/`, one initial commit so HEAD exists. + fn make_repo(root: &Path, name: &str) -> PathBuf { + let path = root.join(name); + std::fs::create_dir_all(&path).unwrap(); + run_git(&path, &["init", "-q"]); + std::fs::write(path.join("f.txt"), "x\n").unwrap(); + run_git(&path, &["add", "-A"]); + run_git( + &path, + &[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "init", + ], + ); + path + } + + fn run_git(dir: &Path, args: &[&str]) { + let out = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "git {args:?} failed: {} | {}", + String::from_utf8_lossy(&out.stderr), + String::from_utf8_lossy(&out.stdout) + ); + } + + fn names(repos: &[DiscoveredRepo]) -> Vec { + let mut v: Vec = repos.iter().map(|r| r.name.clone()).collect(); + v.sort(); + v + } + + #[test] + fn scan_finds_repos_and_stops_at_the_first_hit() { + let tmp = tempfile::tempdir().unwrap(); + let outer = make_repo(tmp.path(), "outer"); + // A nested independent repo inside `outer` is NOT discovered + // separately: descent stops once a repo is found. + make_repo(&outer, "vendored"); + std::fs::create_dir_all(tmp.path().join("plain/deep/deeper")).unwrap(); + + let found = scan_roots( + &[tmp.path().to_path_buf()], + 3, + Path::new("/nonexistent-home"), + ); + assert_eq!(found, vec![outer]); + + let discovered = discover( + &[tmp.path().to_path_buf()], + 3, + Path::new("/nonexistent-home"), + ); + assert_eq!(names(&discovered), ["outer"]); + assert!(!discovered[0].is_worktree); + assert_eq!(discovered[0].base_repo, None); + assert!(discovered[0].head_mtime.is_some()); + } + + #[test] + fn scan_respects_depth_hidden_dirs_and_home_ignore() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().join("home"); + + // Hidden dirs are never entered, at any depth. + std::fs::create_dir_all(home.join(".secret/.git")).unwrap(); + // node_modules directly under home is not descended… + let _ignored = make_repo(&home.join("node_modules"), "dep"); + // …but the same name deeper down is fair game. + let kept = make_repo(&home.join("code/node_modules"), "dep2"); + + // Depth cap: a repo three levels below the root is the last tier — + // found at depth 3, but a fourth-level repo isn't. + let at_three = home.join("a/b/c"); + std::fs::create_dir_all(at_three.join(".git")).unwrap(); + std::fs::create_dir_all(home.join("a/b/c/d/.git")).unwrap(); + + let found = scan_roots(std::slice::from_ref(&home), 3, &home); + assert!(found.iter().all(|p| !p.starts_with(home.join(".secret")))); + assert!(found + .iter() + .all(|p| !p.starts_with(home.join("node_modules")))); + assert!(found.contains(&kept.canonicalize().unwrap())); + assert!(found.contains(&at_three.canonicalize().unwrap())); + assert!(!found.iter().any(|p| p.ends_with("d"))); + } + + #[test] + fn worktrees_outside_the_roots_are_discovered_via_git_worktree_list() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("scan-root"); + std::fs::create_dir_all(&root).unwrap(); + let main = make_repo(&root, "main-repo"); + // Linked worktree deliberately outside the scan root. + let wt = tmp.path().join("elsewhere/wt-of-main"); + std::fs::create_dir_all(wt.parent().unwrap()).unwrap(); + run_git( + &main, + &[ + "worktree", + "add", + "-q", + "-b", + "feature", + &wt.to_string_lossy(), + ], + ); + + let discovered = discover(&[root], 3, Path::new("/nonexistent-home")); + assert_eq!(names(&discovered), ["main-repo", "wt-of-main"]); + let wt_entry = discovered.iter().find(|d| d.name == "wt-of-main").unwrap(); + assert!(wt_entry.is_worktree); + assert_eq!( + wt_entry.base_repo.as_deref(), + Some(main.canonicalize().unwrap().as_path()) + ); + let main_entry = discovered.iter().find(|d| d.name == "main-repo").unwrap(); + assert!(!main_entry.is_worktree); + } + + #[test] + fn head_mtime_resolves_a_worktrees_git_file_pointer() { + let tmp = tempfile::tempdir().unwrap(); + let main = make_repo(tmp.path(), "m"); + let wt = tmp.path().join("wt"); + run_git( + &main, + &["worktree", "add", "-q", "-b", "b2", &wt.to_string_lossy()], + ); + assert!(head_mtime(&wt).is_some()); + } + + fn recent(cwd: &str, cmd: &str, at: &str) -> RecentDir { + RecentDir { + cwd: PathBuf::from(cwd), + last_command: cmd.into(), + last_used: DateTime::parse_from_rfc3339(at) + .unwrap() + .with_timezone(&Utc), + } + } + + #[test] + fn to_projects_merges_history_and_sorts_newest_first() { + let repo_old = DiscoveredRepo { + path: PathBuf::from("/w/old"), + name: "old".into(), + is_worktree: false, + base_repo: None, + // Committed recently but never spawned in. + head_mtime: Some(t("2026-08-23T10:00:00Z")), + }; + let repo_hist = DiscoveredRepo { + path: PathBuf::from("/w/hist"), + name: "hist".into(), + is_worktree: false, + base_repo: None, + // Old commit… + head_mtime: Some(t("2026-08-01T10:00:00Z")), + }; + let repo_stale = DiscoveredRepo { + path: PathBuf::from("/w/stale"), + name: "stale".into(), + is_worktree: false, + base_repo: None, + head_mtime: None, + }; + + let history = vec![ + // Newest overall: a subdir of /w/hist (first match wins for it). + recent("/w/hist/crates", "opencode", "2026-08-22T09:00:00Z"), + recent("/w/hist", "claude", "2026-08-20T09:00:00Z"), + ]; + let projects = to_projects(&[repo_old, repo_hist, repo_stale], &history); + + let paths: Vec<&str> = projects.iter().map(|p| p.path.to_str().unwrap()).collect(); + assert_eq!(paths, ["/w/old", "/w/hist", "/w/stale"]); + + // old: git activity wins (never spawned). + assert_eq!(projects[0].last_activity, Some(t("2026-08-23T10:00:00Z"))); + assert_eq!(projects[0].last_command, None); + // hist: spawn history beats the old commit; command from the newest + // matching entry (the crates/ one, not the repo root one). + assert_eq!(projects[1].last_activity, Some(t("2026-08-22T09:00:00Z"))); + assert_eq!(projects[1].last_command.as_deref(), Some("opencode")); + // stale: neither source knows anything. + assert_eq!(projects[2].last_activity, None); + assert_eq!(projects[2].last_command, None); + } + + fn t(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + #[tokio::test] + async fn scanner_caches_within_ttl_and_rescans_after() { + let tmp = tempfile::tempdir().unwrap(); + let mut scanner = ProjectScanner::new( + vec![tmp.path().to_path_buf()], + 3, + PathBuf::from("/nonexistent"), + ); + + let empty = scanner.repos().await; + assert!(empty.is_empty()); + + make_repo(tmp.path(), "late"); + // Within the TTL the cached (empty) snapshot is still served. + let still_empty = scanner.repos().await; + assert!(still_empty.is_empty()); + + // After the TTL lapses the rescan sees the new repo. + scanner.ttl = Duration::ZERO; + let fresh = scanner.repos().await; + assert_eq!(fresh.len(), 1); + assert_eq!(fresh[0].name, "late"); + } + + #[test] + fn disabled_scanner_always_serves_empty() { + let scanner = ProjectScanner::disabled(); + assert_eq!(scanner.roots, Vec::::new()); + } +} diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index d8c6b1f..21be5ab 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -37,6 +37,7 @@ async fn spawn_daemon_ws() -> Harness { "test-host", "/home/test", Vec::new(), + std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index c1be910..194df70 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -26,6 +26,17 @@ struct Harness { } async fn spawn(hostname: &str) -> Harness { + spawn_with( + hostname, + std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + ) + .await +} + +async fn spawn_with( + hostname: &str, + projects: std::sync::Arc, +) -> Harness { let fx = DaemonFixture::new(); let manager = fx.manager.clone(); // Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises @@ -36,6 +47,7 @@ async fn spawn(hostname: &str) -> Harness { hostname, "/home/test", presets, + projects, )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await @@ -153,6 +165,73 @@ async fn recent_dirs_returns_empty_without_history() { assert_eq!(resp["truncated"], false); } +/// #54: `GET /api/projects` lists every git repo under the scan roots, with +/// real git checkouts — not mocks. +#[tokio::test] +async fn projects_lists_repos_under_the_scan_roots() { + let tmp = tempfile::tempdir().unwrap(); + for name in ["alpha", "beta"] { + let repo = tmp.path().join(name); + std::fs::create_dir_all(&repo).unwrap(); + for args in [ + vec!["init", "-q"], + vec![ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-qm", + "init", + "--allow-empty", + ], + ] { + let out = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(&args) + .output() + .unwrap(); + assert!(out.status.success()); + } + } + + let scanner = std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::new( + vec![tmp.path().to_path_buf()], + 3, + tmp.path().to_path_buf(), + )); + let h = spawn_with("naps-laptop", scanner).await; + + let projects: Vec = reqwest::get(url(h.addr, "/api/projects")) + .await + .unwrap() + .json() + .await + .unwrap(); + + let mut names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); + names.sort(); + assert_eq!(names, ["alpha", "beta"]); + for p in &projects { + assert!(!p.is_worktree); + assert_eq!(p.base_repo, None); + assert!(p.last_activity.is_some(), "fresh commit ⇒ HEAD mtime known"); + } +} + +#[tokio::test] +async fn projects_returns_empty_for_a_disabled_scanner() { + let h = spawn("naps-laptop").await; + let projects: Vec = reqwest::get(url(h.addr, "/api/projects")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(projects.is_empty()); +} + #[tokio::test] async fn gitroot_probes_path() { // #26: resolves the query against the daemon home dir and walks up for a From 8376122dfe2b43e111686b009d9ab0c17cc23725 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:54:21 +0100 Subject: [PATCH 20/71] fix(maestro): second-review findings across daemon, tui and web Ten findings from reviewing the accumulated blitz/spawn-v2 diff. The two that could wedge a repo: - The cleanup guard refused any worktree whose commits were on no other ref, which is every ordinary unpushed branch. Paths are deterministic now, so the leftover tree blocked the next spawn and each branch became a one-shot. `worktree remove` keeps refs/heads/, so committed work is never at risk: guard on an unclean working tree alone. - A worktree whose directory had vanished failed the git probe and was reported as unsaved work, stranding the registration forever. Prune it instead, and prune before `worktree add` tests the target path. Also: - The CLI hardcoded create_branch: true, so the daemon's existing-branch path was unreachable. Added --existing-branch. - Pastes were forwarded without their DECSET 2004 markers, so a remote shell ran a multi-line paste line by line. - spawnForm emitted before its seed was restored and after destroy(), so a rebuilt draft blanked its cwd and a dead form clobbered the live one. - showRawCwd and the probe's worktree untick never reached the leaf. - A late autocomplete fetch reopened the popup under a blurred input. - The "remove worktree when killed" checkbox promised something the daemon no longer honours; removed. --- crates/maestro-cli/src/cli.rs | 30 ++++++++++ crates/maestro-cli/src/sessions.rs | 2 +- crates/maestro-daemon/src/worktree.rs | 75 +++++++++++-------------- crates/maestro-daemon/tests/worktree.rs | 50 ++++++++++++++--- crates/maestro-tui/src/app.rs | 21 +++++-- web/src/dirAutocomplete.ts | 8 +++ web/src/spawnForm.test.ts | 5 +- web/src/spawnForm.ts | 42 +++++++------- 8 files changed, 154 insertions(+), 79 deletions(-) diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index 3f497e6..877d344 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -67,6 +67,11 @@ pub struct SpawnArgs { /// Branch name for the worktree (required with --worktree-from). #[arg(long)] pub branch: Option, + /// Check out an existing branch instead of creating one. Without it the + /// daemon runs `worktree add -b`, which fails if the branch is already + /// there. + #[arg(long)] + pub existing_branch: bool, /// Remove the worktree when the session is killed. Kept for wire /// compatibility (#56): the daemon now always cleans up on kill, guarded /// against unsaved work. @@ -170,6 +175,31 @@ mod tests { } } + /// Review fix: without a flag the CLI always sent `create_branch: true`, + /// so the daemon's existing-branch path was unreachable and spawning onto + /// a branch that already existed just failed. + #[test] + fn parses_spawn_onto_an_existing_branch() { + let cli = Cli::try_parse_from([ + "maestro", + "spawn", + "--cwd", + "/tmp", + "--command", + "bash", + "--worktree-from", + "/repo", + "--branch", + "feat/existing", + "--existing-branch", + ]) + .unwrap(); + let Command::Spawn(a) = cli.command else { + panic!("expected spawn") + }; + assert!(a.existing_branch); + } + #[test] fn spawn_worktree_requires_branch() { let err = Cli::try_parse_from([ diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index e3ca37e..1a087dc 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -44,7 +44,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result None, }; diff --git a/crates/maestro-daemon/src/worktree.rs b/crates/maestro-daemon/src/worktree.rs index 0e8ad5e..c723066 100644 --- a/crates/maestro-daemon/src/worktree.rs +++ b/crates/maestro-daemon/src/worktree.rs @@ -69,6 +69,9 @@ impl WorktreeManager { create_branch: bool, ) -> Result { let path = self.target_path(base_repo, branch); + // Clear registrations whose directory is gone before testing the + // path, so a previously-leaked entry doesn't block this branch. + prune(base_repo)?; if path.symlink_metadata().is_ok() { return Err(WorktreeError::PathExists { path }); } @@ -97,6 +100,14 @@ impl WorktreeManager { /// which makes this guard the only thing standing between a stray kill /// and destroyed work. pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> { + // Gone from disk already (operator `rm -rf`, or a create that failed + // half-way). Probing it with git would fail and be misread as unsaved + // work, stranding the registration — and since paths are + // deterministic, `worktree add` would then refuse that branch forever + // with "missing but already registered" (#56 review). + if !path.exists() { + return prune(base_repo); + } if let Some(reason) = unsafe_to_remove(path)? { return Err(WorktreeError::Guarded { path: path.to_path_buf(), @@ -128,55 +139,35 @@ fn sanitize_branch_for_path(branch: &str) -> String { out } -/// `Some(reason)` when deleting `path` would destroy work: uncommitted -/// changes, or commits reachable from no ref other than the worktree's own -/// branch (`git worktree remove` keeps the branch, but nothing checks it out -/// anymore — treat its commits as at risk anyway, per #56). +/// `Some(reason)` when deleting `path` would destroy work. +/// +/// Only an unclean working tree qualifies. `git worktree remove` deletes the +/// checkout but keeps `refs/heads/`, so committed work — pushed or +/// not — stays reachable and is never at risk. Guarding on "commits on no +/// other ref" instead would refuse every ordinary unpushed feature branch, +/// and because the path is deterministic that branch could then never be +/// spawned again (#56 review). fn unsafe_to_remove(path: &Path) -> Result, WorktreeError> { - let status = run_git(path, ["status", "--porcelain"])?; + // `--ignored=no` keeps build output out of it; untracked sources count, + // since removal would delete them. + let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?; if !status.stdout.is_empty() { return Ok(Some("has uncommitted changes".into())); } - - let head = String::from_utf8_lossy(&run_git(path, ["rev-parse", "HEAD"])?.stdout) - .trim() - .to_string(); - let branch = - String::from_utf8_lossy(&run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?.stdout) - .trim() - .to_string(); - - // Every ref except this worktree's own branch counts as a safe home for - // its commits. Detached HEAD excludes everything. - let refs = - String::from_utf8_lossy(&run_git(path, ["for-each-ref", "--format=%(refname)"])?.stdout) - .lines() - .filter(|r| match branch.as_str() { - "HEAD" => true, - b => *r != format!("refs/heads/{b}"), - }) - .map(str::to_string) - .collect::>(); - - let mut cmd = Command::new("git"); - cmd.arg("-C").arg(path).args(["rev-list", "--count", &head]); - if !refs.is_empty() { - cmd.arg("--not").args(&refs); - } - let out = cmd.output()?; - check(out.clone())?; - let count: usize = String::from_utf8_lossy(&out.stdout) - .trim() - .parse() - .unwrap_or(0); - if count > 0 { - return Ok(Some(format!( - "has {count} commit(s) not present on any other ref" - ))); - } Ok(None) } +/// `git -C worktree prune` — drops registrations whose directory +/// no longer exists. +fn prune(base_repo: &Path) -> Result<(), WorktreeError> { + let out = Command::new("git") + .arg("-C") + .arg(base_repo) + .args(["worktree", "prune"]) + .output()?; + check(out) +} + fn run_git( dir: &Path, args: impl IntoIterator, diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs index fdb6945..722875a 100644 --- a/crates/maestro-daemon/tests/worktree.rs +++ b/crates/maestro-daemon/tests/worktree.rs @@ -206,18 +206,50 @@ fn remove_refuses_worktree_with_uncommitted_changes() { .expect_err("dirty worktree must be refused"); assert!(path.exists(), "guarded worktree must stay on disk"); - // Committing the change doesn't help while it is the only copy… + // Committing it makes removal safe: `worktree remove` keeps + // `refs/heads/feat/dirty`, so the work is still reachable. Refusing here + // instead would make every unpushed branch a one-shot, since the path is + // deterministic and the leftover tree blocks the next spawn (#56 review). run_git(&path, &["add", "."]); - run_git(&path, &["commit", "-q", "-m", "unsaved"]); + run_git(&path, &["commit", "-q", "-m", "saved"]); + + mgr.remove(&repo, &path).expect("committed work can go"); + assert!(!path.exists()); + assert!( + worktree_branches(&repo).contains(&"main".to_string()), + "the branch survives removal" + ); + let branches = String::from_utf8( + Command::new("git") + .arg("-C") + .arg(&repo) + .args(["for-each-ref", "--format=%(refname:short)", "refs/heads"]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!(branches.contains("feat/dirty"), "{branches}"); +} + +/// A worktree whose directory vanished must be pruned, not misread as holding +/// unsaved work — otherwise the registration is stranded and, because paths +/// are deterministic, that branch can never be spawned again (#56 review). +#[test] +fn remove_prunes_a_worktree_whose_directory_vanished() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/gone").expect("add"); + std::fs::remove_dir_all(&path).unwrap(); mgr.remove(&repo, &path) - .expect_err("unique commit must be refused"); - assert!(path.exists()); - - // …but once the commit is reachable from main, removal is safe. - run_git(&repo, &["merge", "-q", "--no-edit", "feat/dirty"]); - mgr.remove(&repo, &path).expect("merged worktree can go"); - assert!(!path.exists()); + .expect("a vanished worktree prunes"); + // The registration is gone too, so the same branch can be spawned again. + mgr.add_existing(&repo, "feat/gone") + .expect("branch is spawnable again"); } fn run_git(dir: &std::path::Path, args: &[&str]) { diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index aae1d75..98eac6c 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -464,7 +464,16 @@ fn encode_key(key: &InputKey) -> Option> { InputKey::Home => Some(b"\x1b[H".to_vec()), InputKey::End => Some(b"\x1b[F".to_vec()), InputKey::Delete => Some(b"\x1b[3~".to_vec()), - InputKey::Paste(text) => Some(text.as_bytes().to_vec()), + InputKey::Paste(text) => { + // Wrap in the DECSET 2004 markers. Raw bytes look like typed keys + // to an app that enabled bracketed paste, so a multi-line paste + // would execute line by line instead of arriving as one block. + let mut out = Vec::with_capacity(text.len() + 12); + out.extend_from_slice(b"\x1b[200~"); + out.extend_from_slice(text.as_bytes()); + out.extend_from_slice(b"\x1b[201~"); + Some(out) + } InputKey::Mouse { .. } => None, } } @@ -697,12 +706,14 @@ mod tests { /// Regression review fix: `Paste(_)` used to be silently dropped, so a /// paste into an attached session vanished once bracketed paste was - /// enabled — the payload must be forwarded verbatim. + /// enabled. It also has to keep its DECSET 2004 markers — without them a + /// remote shell reads the payload as typed keys and runs a multi-line + /// paste line by line. #[test] - fn a_bracketed_paste_is_forwarded_verbatim_to_the_session() { + fn a_bracketed_paste_reaches_the_session_with_its_markers() { assert_eq!( - encode_key(&InputKey::Paste("cd /tmp/foo".into())), - Some(b"cd /tmp/foo".to_vec()) + encode_key(&InputKey::Paste("cd /tmp/foo\nls".into())), + Some(b"\x1b[200~cd /tmp/foo\nls\x1b[201~".to_vec()) ); } diff --git a/web/src/dirAutocomplete.ts b/web/src/dirAutocomplete.ts index 0eb5efe..3ec9114 100644 --- a/web/src/dirAutocomplete.ts +++ b/web/src/dirAutocomplete.ts @@ -109,6 +109,14 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH } function applyFilter(entries: string[], prefix: string): void { + // A fetch can outlive the dismissal that was meant to close this popup + // (Tab-commit re-arms a refresh, then blur dismisses 120ms later). Showing + // it now would anchor a dropdown under an unfocused input that nothing + // closes until the next interaction. + if (dismissed || document.activeElement !== input) { + close(); + return; + } const lower = prefix.toLowerCase(); filtered = entries.filter((e) => e.toLowerCase().startsWith(lower)); highlight = filtered.length > 0 ? 0 : -1; diff --git a/web/src/spawnForm.test.ts b/web/src/spawnForm.test.ts index 4b7dd1b..4559526 100644 --- a/web/src/spawnForm.test.ts +++ b/web/src/spawnForm.test.ts @@ -8,7 +8,6 @@ const base: SpawnFormValues = { name: "amber-fox", worktreeEnabled: false, worktreeBranch: "", - worktreeCleanup: false, }; describe("buildSpawnRequest", () => { @@ -24,9 +23,9 @@ describe("buildSpawnRequest", () => { expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } }); }); - it("attaches a trimmed branch and cleanup flag when worktree is enabled", () => { + it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => { const res = buildSpawnRequest( - { ...base, worktreeEnabled: true, worktreeBranch: " feat/foo ", worktreeCleanup: true }, + { ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " }, "/home/user/project", ); expect(res).toEqual({ diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 7b78eb4..d28d295 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -14,7 +14,6 @@ export interface SpawnFormSeed { name?: string; worktreeEnabled?: boolean; worktreeBranch?: string; - worktreeCleanup?: boolean; cwdRawVisible?: boolean; } @@ -26,7 +25,6 @@ export interface SpawnFormValues { name: string; worktreeEnabled: boolean; worktreeBranch: string; - worktreeCleanup: boolean; /** Whether the cwd lives in the raw text input (vs a picked recent row). */ cwdRawVisible?: boolean; } @@ -55,7 +53,9 @@ export function buildSpawnRequest( req.worktree = { base_repo: baseRepo, branch, - cleanup_on_kill: values.worktreeCleanup, + // The daemon always cleans up, guarded against unsaved work (#56); + // the wire field stays for older daemons. + cleanup_on_kill: true, }; } @@ -130,10 +130,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { Branch - @@ -155,9 +151,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const wtBody = form.querySelector(".wt-body") as HTMLElement; const wtRepo = form.querySelector(".wt-repo") as HTMLElement; const branchInput = form.querySelector("input[name=worktree_branch]") as HTMLInputElement; - const cleanupInput = form.querySelector( - "input[name=worktree_cleanup_on_kill]", - ) as HTMLInputElement; const nameInput = form.querySelector("input[name=name]") as HTMLInputElement; // ---- form state ------------------------------------------------------ @@ -172,14 +165,22 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // settles — so the fetch can't race past and clobber the seeded directory. let seedCwd: string | null = prefill.cwd ?? null; + // `emit` stays silent until the initial load settles and goes silent again + // once the form is destroyed. Construction runs with `cwd === ""` and the + // seed is only restored two fetches later, so emitting in between would + // blank the leaf's draft; and `multiview.render()` rebuilds forms while the + // old one's fetches are still in flight, so a destroyed form emitting would + // clobber the live one's leaf (#57 review). + let ready = false; + let destroyed = false; const emit = () => { + if (!ready || destroyed) return; opts.onChange?.({ command, cwd, name: nameInput.value, worktreeEnabled: wtEnabled.checked, worktreeBranch: branchInput.value, - worktreeCleanup: cleanupInput.checked, cwdRawVisible: !cwdRawWrap.hidden, }); }; @@ -296,6 +297,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { cwdRaw.focus(); cwdRaw.select(); } + // Both the visibility and the directory are draft state: without this a + // render before the operator types snaps back to the recents picker. + emit(); } cwdRaw.addEventListener("input", () => { @@ -357,10 +361,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { } else { cwdRawWrap.hidden = true; } - // The seeded cwd is in place — a restored worktree can probe now. + // Everything seeded: the form may publish from here on. + ready = true; if (wtEnabled.checked) void probeGitRoot(); - // Publish it: construction ran `emit()` with an empty cwd, so without this - // the leaf's draft keeps `cwd: ""` and the next render reseeds it blank. emit(); } @@ -423,7 +426,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { wtRepo.textContent = `repo: ${root} (inferred)`; } else { inferredBaseRepo = null; + // Publish the untick too, or the next render seeds the box checked with + // no inferred repo and submitting fails. wtEnabled.checked = false; + emit(); wtBody.hidden = true; wtHint.hidden = false; wtHint.textContent = "Not inside a git repository — pick a dir in a repo to use a worktree."; @@ -440,14 +446,12 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { wtHint.hidden = true; } }); - cleanupInput.addEventListener("change", emit); // A seeded worktree restores its checkbox/body up front; its gitroot probe // waits for loadRecents to apply the seeded cwd. - if (prefill.worktreeEnabled || prefill.worktreeBranch || prefill.worktreeCleanup) { + if (prefill.worktreeEnabled || prefill.worktreeBranch) { wtEnabled.checked = Boolean(prefill.worktreeEnabled); if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch; - cleanupInput.checked = Boolean(prefill.worktreeCleanup); wtBody.hidden = !wtEnabled.checked; wtHint.hidden = true; emit(); @@ -471,8 +475,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { name: nameInput.value, worktreeEnabled: wtEnabled.checked, worktreeBranch: branchInput.value, - worktreeCleanup: cleanupInput.checked, - }, + }, inferredBaseRepo, ); if ("error" in built) { @@ -494,6 +497,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { return { el: form, destroy() { + destroyed = true; autocomplete.destroy(); form.remove(); }, From 9f582263b80ec8825ecf321a8eb09bf919917a89 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 21:02:16 +0100 Subject: [PATCH 21/71] feat(maestro): project list sort/group/filter helpers (#59) --- web/src/projectList.test.ts | 126 ++++++++++++++++++++++++++++++++++++ web/src/projectList.ts | 76 ++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 web/src/projectList.test.ts create mode 100644 web/src/projectList.ts diff --git a/web/src/projectList.test.ts b/web/src/projectList.test.ts new file mode 100644 index 0000000..cbf5ac9 --- /dev/null +++ b/web/src/projectList.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; +import type { Project } from "./types.js"; + +const NOW = new Date("2026-08-23T12:00:00Z"); + +function project(overrides: Partial): Project { + return { + path: `/home/user/${overrides.name ?? "proj"}`, + name: "proj", + is_worktree: false, + last_activity: undefined, + last_command: undefined, + ...overrides, + } as Project; +} + +describe("isStale", () => { + it("marks projects with no known activity stale", () => { + expect(isStale(project({ name: "never" }), NOW)).toBe(true); + }); + + it("is fresh within 7 days and stale beyond", () => { + const fresh = project({ name: "fresh", last_activity: "2026-08-17T00:00:00Z" }); + const stale = project({ name: "stale", last_activity: "2026-08-16T11:59:00Z" }); + expect(isStale(fresh, NOW)).toBe(false); + expect(isStale(stale, NOW)).toBe(true); + }); +}); + +describe("sortProjects", () => { + it("sorts newest-activity first", () => { + const a = project({ name: "a", last_activity: "2026-08-20T00:00:00Z" }); + const b = project({ name: "b", last_activity: "2026-08-22T00:00:00Z" }); + expect(sortProjects([a, b], NOW).map((p) => p.name)).toEqual(["b", "a"]); + }); + + it("sorts stale rows after fresh ones, each block newest first", () => { + const freshOld = project({ name: "fresh-old", last_activity: "2026-08-18T00:00:00Z" }); + const freshNew = project({ name: "fresh-new", last_activity: "2026-08-23T00:00:00Z" }); + const staleNew = project({ name: "stale-new", last_activity: "2026-08-10T00:00:00Z" }); + const staleNone = project({ name: "stale-none" }); + const sorted = sortProjects([staleNew, freshOld, staleNone, freshNew], NOW); + expect(sorted.map((p) => p.name)).toEqual(["fresh-new", "fresh-old", "stale-new", "stale-none"]); + }); + + it("does not mutate the input", () => { + const a = project({ name: "a", last_activity: "2026-08-01T00:00:00Z" }); + const b = project({ name: "b", last_activity: "2026-08-02T00:00:00Z" }); + sortProjects([a, b], NOW); + expect(a.name).toBe("a"); + }); +}); + +describe("groupProjects", () => { + it("keeps plain repositories as their own single-entry group", () => { + const solo = project({ name: "solo" }); + expect(groupProjects(sortProjects([solo], NOW))).toEqual([{ main: solo, worktrees: [] }]); + }); + + it("nests worktrees under their base repository even when fresher than it", () => { + const main = project({ name: "maestro" }); + const wt = project({ + name: "wt", + path: "/elsewhere/wt", + is_worktree: true, + base_repo: main.path, + last_activity: "2026-08-23T00:00:00Z", + }); + const groups = groupProjects(sortProjects([wt, main], NOW)); + expect(groups).toHaveLength(1); + expect(groups[0].main).toBe(main); + expect(groups[0].worktrees).toEqual([wt]); + }); + + it("orders groups by the freshest member and worktrees by activity", () => { + const mainA = project({ name: "a", path: "/w/a", last_activity: "2026-08-01T00:00:00Z" }); + const wtA = project({ + name: "a-wt", + path: "/w/a-wt", + is_worktree: true, + base_repo: "/w/a", + last_activity: "2026-08-20T00:00:00Z", + }); + const mainB = project({ name: "b", path: "/w/b", last_activity: "2026-08-15T00:00:00Z" }); + const wtB1 = project({ + name: "b-wt1", + path: "/w/b-wt1", + is_worktree: true, + base_repo: "/w/b", + last_activity: "2026-08-19T00:00:00Z", + }); + const wtB2 = project({ + name: "b-wt2", + path: "/w/b-wt2", + is_worktree: true, + base_repo: "/w/b", + last_activity: "2026-08-21T00:00:00Z", + }); + + const groups = groupProjects(sortProjects([mainA, mainB, wtB1, wtA, wtB2], NOW)); + expect(groups.map((g) => g.main.name)).toEqual(["b", "a"]); + const gA = groups.find((g) => g.main.name === "a")!; + const gB = groups.find((g) => g.main.name === "b")!; + expect(gA.worktrees.map((p) => p.name)).toEqual(["a-wt"]); + expect(gB.worktrees.map((p) => p.name)).toEqual(["b-wt2", "b-wt1"]); + }); +}); + +describe("filterProjects", () => { + const maestro = project({ name: "maestro", path: "/home/u/git/maestro" }); + const tea = project({ name: "tea", path: "/home/u/work/tea" }); + + it("matches on name case-insensitively", () => { + expect(filterProjects([maestro, tea], "MAES")).toEqual([maestro]); + }); + + it("matches on the full path", () => { + expect(filterProjects([maestro, tea], "work")).toEqual([tea]); + }); + + it("returns everything unchanged for an empty query", () => { + expect(filterProjects([maestro, tea], " ")).toEqual([maestro, tea]); + }); +}); diff --git a/web/src/projectList.ts b/web/src/projectList.ts new file mode 100644 index 0000000..4a44ca6 --- /dev/null +++ b/web/src/projectList.ts @@ -0,0 +1,76 @@ +// Pure helpers behind the spawn form's project picker (#59): staleness, +// recency ordering, worktree grouping, and free-text filtering. The daemon +// reports the timestamps; what "stale" means is a client decision (#54). + +import type { Project } from "./types.js"; + +/** Projects whose last activity is older than this are dimmed and sort after + * fresh ones — still reachable, visibly stale (#59). */ +export const STALE_AFTER_DAYS = 7; + +export function isStale(project: Project, now: Date): boolean { + if (!project.last_activity) return true; + const age = now.getTime() - Date.parse(project.last_activity); + return !(age >= 0) || age > STALE_AFTER_DAYS * 24 * 60 * 60 * 1000; +} + +/** Fresh rows first, each block newest-activity-first; unknown or future + * timestamps sort as stalest. Ties fall back to name so the order is stable. */ +export function sortProjects(projects: Project[], now: Date): Project[] { + return [...projects].sort((a, b) => { + const staleDiff = (isStale(a, now) ? 1 : 0) - (isStale(b, now) ? 1 : 0); + if (staleDiff !== 0) return staleDiff; + const ta = timeOf(a); + const tb = timeOf(b); + if (ta !== tb) return tb - ta; + return a.name.localeCompare(b.name) || a.path.localeCompare(b.path); + }); +} + +function timeOf(project: Project): number { + return project.last_activity ? Date.parse(project.last_activity) : Number.NEGATIVE_INFINITY; +} + +/** One repository group: the main checkout followed by its linked worktrees. + * A group's position follows its freshest member, since a worktree worked on + * today should pull its (dormant) base repo up with it. */ +export interface ProjectGroup { + main: Project; + worktrees: Project[]; +} + +/** Group `sorted` projects by their base repository. A worktree nests under + * its main checkout even when the main sorts later; a main checkout missing + * from the list doesn't drop its worktrees. */ +export function groupProjects(sorted: Project[]): ProjectGroup[] { + const members = new Map(); + const keys: string[] = []; + for (const p of sorted) { + const key = p.base_repo ?? p.path; + let list = members.get(key); + if (!list) { + list = []; + members.set(key, list); + keys.push(key); + } + list.push(p); + } + return keys.map((key) => { + const list = members.get(key)!; + const main = list.find((p) => p.path === key && !p.is_worktree) ?? list[0]; + // Keep the daemon's newest-first order within the group. + const worktrees = list + .filter((p) => p !== main) + .sort((a, b) => timeOf(b) - timeOf(a)); + return { main, worktrees }; + }); +} + +/** Substring filter over project name and full path, case-insensitive. */ +export function filterProjects(projects: Project[], query: string): Project[] { + const q = query.trim().toLowerCase(); + if (!q) return [...projects]; + return projects.filter( + (p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q), + ); +} From b9829bc56f0a7aebb30f33c0f1da8c9bde24c818 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 21:06:10 +0100 Subject: [PATCH 22/71] feat(maestro): project list replaces recents picker in spawn form (#59) --- web/src/api.ts | 10 ++- web/src/mock.ts | 47 +++++++--- web/src/projectPicker.test.ts | 162 ++++++++++++++++++++++++++++++++++ web/src/routes.ts | 1 + web/src/spawnForm.ts | 113 +++++++++++++++--------- web/src/styles.css | 47 ++++++++-- web/src/types.ts | 21 +++++ 7 files changed, 336 insertions(+), 65 deletions(-) create mode 100644 web/src/projectPicker.test.ts diff --git a/web/src/api.ts b/web/src/api.ts index 3a7c055..92c3b64 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -5,6 +5,7 @@ import type { DaemonInfo, DirListing, GitRoot, + Projects, RecentDirs, Session, Uuid, @@ -61,12 +62,17 @@ export async function getDaemonInfo(): Promise { return jsonOrError(await fetch(routes.INFO, { credentials: "same-origin" })); } -/** Recently-used working dirs, newest-first and deduped by dir (#26). Drives the - * spawn form's recents picker. */ +/** Recently-used working dirs, newest-first and deduped by dir (#26). */ export async function recentDirs(): Promise { return jsonOrError(await fetch(routes.RECENT_DIRS, { credentials: "same-origin" })); } +/** Every discovered git repository / worktree, newest-activity-first (#54). + * Drives the spawn form's project picker. */ +export async function projects(): Promise { + return jsonOrError(await fetch(routes.PROJECTS, { credentials: "same-origin" })); +} + /** Probe the enclosing git repo of `path` (#26). `root` is absent when the path * isn't inside a repo. `path` may be `~`/relative/absolute; the daemon resolves * it against its home dir. */ diff --git a/web/src/mock.ts b/web/src/mock.ts index e3847da..098978d 100644 --- a/web/src/mock.ts +++ b/web/src/mock.ts @@ -33,8 +33,8 @@ function sess( }; } -/** Stub the spawn-form backend endpoints (#26) so `?mock&spawn` renders the full - * tile-first flow — presets, recents, gitroot — with no daemon running. Dev-only; +/** Stub the spawn-form backend endpoints (#26/#59) so `?mock&spawn` renders the + * full flow — presets, projects, gitroot — with no daemon running. Dev-only; * delegates every other request to the real `fetch`. */ function stubSpawnApi(): void { const real = globalThis.fetch.bind(globalThis); @@ -61,16 +61,39 @@ function stubSpawnApi(): void { }), ); } - if (path.includes("/recent-dirs")) { + if (path.includes("/projects")) { + const now = Date.now(); + const iso = (daysAgo: number) => new Date(now - daysAgo * 86_400_000).toISOString(); return Promise.resolve( - json({ - dirs: [ - { cwd: "/home/dev/git/maestro", last_command: "claude", last_used: "2026-06-20T10:00:00Z" }, - { cwd: "/home/dev/git/dotfiles", last_command: "zsh", last_used: "2026-06-19T18:00:00Z" }, - { cwd: "/home/dev/work/acme/api", last_command: "opencode", last_used: "2026-06-18T09:00:00Z" }, - ], - truncated: false, - }), + json([ + { + path: "/home/dev/git/maestro", + name: "maestro", + is_worktree: false, + last_activity: iso(0), + last_command: "claude", + }, + { + path: "/home/dev/worktrees/maestro-59", + name: "maestro-59", + is_worktree: true, + base_repo: "/home/dev/git/maestro", + last_activity: iso(1), + last_command: "opencode", + }, + { + path: "/home/dev/git/dotfiles", + name: "dotfiles", + is_worktree: false, + last_activity: iso(30), + last_command: "zsh", + }, + { + path: "/home/dev/work/acme/api", + name: "api", + is_worktree: false, + }, + ]), ); } if (path.includes("/gitroot")) { @@ -113,7 +136,7 @@ export function seedMock(store: CatalogStore): void { }); // `?mock&spawn` auto-opens the spawn modal for visual review, with the spawn - // backend endpoints stubbed so presets/recents/gitroot all render. + // backend endpoints stubbed so presets/projects/gitroot all render. if (location.search.includes("spawn")) { stubSpawnApi(); void import("./spawn.js").then((m) => m.openSpawnModal()); diff --git a/web/src/projectPicker.test.ts b/web/src/projectPicker.test.ts new file mode 100644 index 0000000..f3e2b51 --- /dev/null +++ b/web/src/projectPicker.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js"; +import type { Project } from "./types.js"; + +const NOW = "2026-08-23T12:00:00Z"; + +function project(overrides: Partial): Project { + return { + path: `/home/user/${overrides.name ?? "proj"}`, + name: "proj", + is_worktree: false, + ...overrides, + }; +} + +/** Two fetches gate the form's initial load: /api/info then /api/projects. */ +function stubFetch(info: unknown, projects: Project[]): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes("/api/info") + ? info + : url.includes("/api/projects") + ? projects + : undefined; + return new Response(JSON.stringify(body), { status: 200 }); + }), + ); +} + +function rowPaths(form: HTMLFormElement): string[] { + return [...form.querySelectorAll(".project-row:not(.project-other)")].map( + (r) => r.dataset.cwd ?? "", + ); +} + +describe("spawnForm project picker", () => { + let handle: SpawnFormHandle | null = null; + + beforeEach(() => { + vi.useFakeTimers({ now: Date.parse(NOW) }); + }); + + afterEach(() => { + handle?.destroy(); + handle = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + async function mount(projects: Project[], seed?: Parameters[0]["seed"]) { + stubFetch({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }, projects); + handle = createSpawnForm({ + seed, + onSuccess() {}, + }); + document.body.appendChild(handle.el); + for (let i = 0; i < 20; i++) await Promise.resolve(); + return handle; + } + + it("renders one row per project, fresh before stale", async () => { + const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW }); + const stale = project({ + name: "stale", + path: "/home/user/stale", + last_activity: "2026-01-01T00:00:00Z", + }); + const form = (await mount([stale, fresh])).el; + expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]); + const rows = [...form.querySelectorAll(".project-row")]; + expect(rows[1].classList.contains("is-stale")).toBe(true); + expect(rows[0].classList.contains("is-stale")).toBe(false); + }); + + it("nests worktrees under their base repository", async () => { + const main = project({ name: "maestro", path: "/home/user/maestro" }); + const wt = project({ + name: "wt", + path: "/elsewhere/wt", + is_worktree: true, + base_repo: main.path, + }); + const form = (await mount([wt, main])).el; + expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]); + const [mainRow, wtRow] = [ + ...form.querySelectorAll(".project-row:not(.project-other)"), + ]; + expect(wtRow.classList.contains("is-worktree")).toBe(true); + expect(mainRow.classList.contains("is-worktree")).toBe(false); + }); + + it("filters rows as the operator types", async () => { + const a = project({ name: "maestro", path: "/home/user/maestro" }); + const b = project({ name: "tea", path: "/home/user/work/tea" }); + const form = (await mount([a, b])).el; + const filter = form.querySelector(".project-filter")!; + filter.value = "tea"; + filter.dispatchEvent(new Event("input")); + expect(rowPaths(form)).toEqual(["/home/user/work/tea"]); + filter.value = "zzz"; + filter.dispatchEvent(new Event("input")); + expect(rowPaths(form)).toEqual([]); + expect(form.querySelector(".picker-empty")).toBeTruthy(); + }); + + it("picking a project selects its cwd and pre-selects its last command", async () => { + const p = project({ + name: "maestro", + path: "/home/user/maestro", + last_command: "claude", + }); + const onChange = vi.fn(); + stubFetch( + { + hostname: "h", + version: "0", + home_dir: "/home/user", + presets: [ + { id: "claude", label: "Claude", command: "claude" }, + { id: "custom", label: "Custom…", command: "" }, + ], + }, + [p], + ); + handle = createSpawnForm({ onSuccess() {}, onChange }); + const form = handle.el; + document.body.appendChild(form); + for (let i = 0; i < 20; i++) await Promise.resolve(); + onChange.mockClear(); + + const row = form.querySelector(".project-row")!; + row.click(); + + const values = form.querySelector("input[name=name]")!.value; + const last = onChange.mock.lastCall![0]; + expect(last.cwd).toBe("/home/user/maestro"); + expect(last.command).toBe("claude"); + expect(values).toBeTruthy(); + // The raw input stays hidden — the picked row is the source of truth. + expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true); + expect(row.classList.contains("is-active")).toBe(true); + }); + + it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => { + const p = project({ name: "maestro", path: "/home/user/maestro" }); + const form = (await mount([p])).el; + const other = form.querySelector(".project-other")!; + other.click(); + expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false); + }); + + it("falls back to the raw input when no projects exist", async () => { + const form = (await mount([])).el; + expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(false); + const raw = form.querySelector(".cwd-raw")!; + expect(raw.value).toBe("/home/user"); + }); +}); diff --git a/web/src/routes.ts b/web/src/routes.ts index 6fb38e2..1263611 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -9,6 +9,7 @@ export const SESSIONS = "/api/sessions"; export const INFO = "/api/info"; export const DIRS = "/api/dirs"; export const RECENT_DIRS = "/api/recent-dirs"; +export const PROJECTS = "/api/projects"; export const GITROOT = "/api/gitroot"; export const EVENTS = "/api/events"; diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index d28d295..916ca18 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -1,8 +1,9 @@ import { escapeHtml } from "./html.js"; -import { createSession, getDaemonInfo, recentDirs, gitRoot } from "./api.js"; +import { createSession, getDaemonInfo, projects as fetchProjects, gitRoot } from "./api.js"; import { attachDirAutocomplete } from "./dirAutocomplete.js"; +import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; import { generateName } from "./names.js"; -import type { CreateSessionRequest, RecentDir, Session, SpawnPreset } from "./types.js"; +import type { CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; import { abbreviateCwd, abbreviateFolderName } from "./types.js"; /** Seed values for the spawn form — used by "clone" to reopen with the same @@ -87,7 +88,7 @@ export interface SpawnFormOptions { onSuccess: (session: Session) => void; } -/** The spawn form itself — preset tiles, recent-dirs picker, name, worktree. +/** The spawn form itself — preset tiles, project picker, name, worktree. * Shared by the modal (`spawn.ts`) and inline draft panes (issue #57). */ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { const prefill = opts.seed ?? {}; @@ -103,7 +104,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
Working directory -
+ +
`; // Field queries are scoped to this form instance — several forms can be - // mounted at once (modal + draft panes), so ids would collide. + // mounted at once (one per draft pane), so ids would collide. const errEl = form.querySelector(".form-error") as HTMLElement; const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement; diff --git a/web/src/styles.css b/web/src/styles.css index c1800ce..86673da 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1205,72 +1205,9 @@ textarea { border-color: var(--accent-strong); } -/* ---------- modal ---------- */ +/* ---------- forms ---------- */ -.modal-backdrop { - position: fixed; - inset: 0; - background: oklch(0.12 0.01 256 / 0.6); - backdrop-filter: blur(3px); - display: flex; - align-items: flex-end; - justify-content: center; - padding: 0; - z-index: var(--z-modal); - animation: fade var(--dur) var(--ease-out); -} - -@media (min-width: 600px) { - .modal-backdrop { - align-items: center; - padding: var(--sp-6); - } -} - -.modal { - background: var(--surface); - border-radius: var(--r-3) var(--r-3) 0 0; - border: 1px solid var(--border); - width: 100%; - max-width: 560px; - max-height: 92vh; - display: flex; - flex-direction: column; - box-shadow: var(--shadow-2); - animation: sheet-up var(--dur-slow) var(--ease-out); -} - -@media (min-width: 600px) { - .modal { - border-radius: var(--r-3); - animation: pop var(--dur) var(--ease-out); - } -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: var(--sp-3); - border-bottom: 1px solid var(--border); -} - -.modal-header h2 { - margin: 0; - font-size: var(--fs-15); - font-weight: 600; - display: inline-flex; - align-items: center; - gap: var(--sp-2); -} - -.modal-header h2::before { - content: "+"; - color: var(--accent); - font-weight: 700; -} - -.modal-body { +.spawn-form { padding: var(--sp-3); overflow: auto; display: flex; @@ -1280,7 +1217,6 @@ textarea { scrollbar-color: var(--border-strong) transparent; } -.modal-body label, .spawn-form label { display: flex; flex-direction: column; @@ -1290,7 +1226,6 @@ textarea { letter-spacing: 0.01em; } -.modal-body label.row-inline, .spawn-form label.row-inline { flex-direction: row; align-items: center; @@ -1300,10 +1235,9 @@ textarea { cursor: pointer; } -.modal-body input[type="text"], -.modal-body input[type="search"], -.modal-body select, -.spawn-form input[type="text"] { +.spawn-form input[type="text"], +.spawn-form input[type="search"], +.spawn-form select { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-2); @@ -1318,23 +1252,20 @@ textarea { background var(--dur-fast) var(--ease-out); } -.modal-body input[type="text"]:hover, -.modal-body select:hover, -.spawn-form input[type="text"]:hover { +.spawn-form input[type="text"]:hover, +.spawn-form select:hover { border-color: var(--border-strong); } -.modal-body input[type="text"]:focus, -.modal-body input[type="search"]:focus, -.modal-body select:focus, -.spawn-form input[type="text"]:focus { +.spawn-form input[type="text"]:focus, +.spawn-form input[type="search"]:focus, +.spawn-form select:focus { outline: none; border-color: var(--accent); background: var(--surface); box-shadow: 0 0 0 3px var(--accent-tint); } -.modal-body input[type="checkbox"], .spawn-form input[type="checkbox"] { width: 18px; height: 18px; @@ -1359,13 +1290,6 @@ textarea { font-size: var(--fs-13); } -.modal-actions { - display: flex; - gap: var(--sp-2); - justify-content: flex-end; - margin-top: var(--sp-2); -} - .form-error { color: var(--danger); margin: 0; @@ -1381,7 +1305,6 @@ textarea { /* ---------- spawn form: tiles + recents picker (#27) ---------- */ -.spawn-modal .field, .spawn-form .field { display: flex; flex-direction: column; @@ -1611,7 +1534,7 @@ textarea { .dir-ac { position: fixed; - z-index: var(--z-toast); /* above the modal it completes within */ + z-index: var(--z-toast); /* above the draft pane it completes within */ margin: 0; padding: var(--sp-1); list-style: none; @@ -1759,26 +1682,6 @@ textarea { } } -@keyframes fade { - from { - opacity: 0; - } -} - -@keyframes sheet-up { - from { - transform: translateY(16px); - opacity: 0; - } -} - -@keyframes pop { - from { - transform: scale(0.98); - opacity: 0; - } -} - /* ---------- responsive ---------- */ @media (max-width: 520px) { @@ -2119,15 +2022,6 @@ textarea { flex: 1; min-width: 0; min-height: 0; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: var(--border-strong) transparent; - /* The modal form got its stacking and padding from `.modal-body`; in a pane - the form is the host, so it carries them itself. */ - display: flex; - flex-direction: column; - gap: var(--sp-2); - padding: var(--sp-3); } /* pane tools cluster (disconnect / split / close), top-right corner. The attach From 51f91b3f5d9ba3d97ef7e7618dd465876d49ed77 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:36:51 +0100 Subject: [PATCH 32/71] docs(maestro): amend DESIGN.md for draft panes (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spawn modal is no longer one of the three surfaces: the Components section now describes the draft pane, and the stale ≥600px bottom-sheet→dialog breakpoint note is dropped. --- DESIGN.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 282b754..7cb8220 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -79,16 +79,15 @@ no bounce. Tokens `--dur-fast`/`--dur`/`--dur-slow`. `connecting` state pill pulses. - Interaction: row hover/focus, button press (1px), input focus ring, chevron rotation, theme-toggle sun/moon swap. -- Modal: bottom-sheet slide-up on mobile, scale-pop on ≥600px. - Every animation has a `prefers-reduced-motion: reduce` path (pulses off, transitions near-instant). ## Layout & structure Mobile-first, structural responsiveness (not fluid type). Single `≤520px` -breakpoint stacks the header (full-width search, wrapped actions). `≥600px` -switches the spawn modal from bottom-sheet to centered dialog. A faint slate grid texture sits on `--bg` as -terminal atmosphere (very low contrast, not decoration). +breakpoint stacks the header (full-width search, wrapped actions). A faint +slate grid texture sits on `--bg` as terminal atmosphere (very low contrast, +not decoration). ## Components @@ -101,8 +100,10 @@ Three surfaces, all DOM built in TS, styled by class: no-sessions, no-search-match. - **Attach** — header (back, `session `, live/closed/error state pill) over a framed dark xterm with a green cursor. -- **Spawn modal** — bottom-sheet/dialog form; mono inputs with green focus - ring; grouped worktree fieldset. +- **Draft pane** — the spawn form mounted inline in an empty split pane, + focusable like any session pane; mono inputs with green focus ring; grouped + worktree fieldset. Spawn opens one (reusing an empty focused pane, else + splitting), and "Spawn similar" seeds one from a session's dir + command. Interactive elements ship default / hover / focus-visible / active / disabled states; the accent green is used only for action, selection, focus, and live @@ -111,5 +112,5 @@ status — never decoration. ## Dev review aids (DEV-only, stripped from production) `web/src/mock.ts` + query params seed a representative catalog without a running -daemon: `?mock` (seed sessions), `?mock&spawn` (open the spawn modal), +daemon: `?mock` (seed sessions), `?mock&spawn` (open a draft pane), `?theme=light|dark` (pin a theme). Guarded by `import.meta.env.DEV`. From 264332266c8a9a9e9f585d83217174c97ba189cf Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:42:46 +0100 Subject: [PATCH 33/71] feat(maestro): tui draft form gains a worktree section (#65) Worktree on by default over a git repository with Enter/Space to spawn in the checkout itself; base repo inferred from picked projects or a guarded /api/gitroot probe for raw paths (a miss unticks, web parity); new-branch name tracking the generated session name until touched; Left/Right switches to picking an existing branch from GET /api/branches, with held branches skipped and rendered dimmed beside their holder. No cleanup option: cleanup is unconditional daemon-side (#56). --- crates/maestro-tui/src/app.rs | 7 +- crates/maestro-tui/src/draft.rs | 846 +++++++++++++++++++++++++++++++- 2 files changed, 843 insertions(+), 10 deletions(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 2279e0e..732124f 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -767,6 +767,8 @@ mod tests { }; draft.picked_cwd = Some(std::path::PathBuf::from("/srv/work")); + draft.base_repo = Some(std::path::PathBuf::from("/srv/work")); + draft.probed_cwd = Some(std::path::PathBuf::from("/srv/work")); draft.selected_preset = draft.presets.len() - 1; // custom app.handle_draft_input(&InputKey::Tab); // preset → custom command for c in "claude".chars() { @@ -777,6 +779,9 @@ mod tests { for c in "crimson-otter".chars() { app.handle_draft_input(&InputKey::char(c)); } + app.handle_draft_input(&InputKey::Tab); // → worktree toggle + app.handle_draft_input(&InputKey::Tab); // → branch mode + app.handle_draft_input(&InputKey::Tab); // → new-branch name app.handle_draft_input(&InputKey::Tab); // → submit match app.handle_draft_input(&InputKey::Enter).as_slice() { @@ -800,7 +805,7 @@ mod tests { let mut app = make_app(); app.dispatch(Action::NewDraftPane); // Walk to Submit without picking anything. - for _ in 0..3 { + for _ in 0..4 { app.handle_draft_input(&InputKey::Tab); } assert!(app.handle_draft_input(&InputKey::Enter).is_empty()); diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index 7920c82..aab9bd8 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -10,15 +10,17 @@ //! entry), a project picker fed by [`crate::projects`] over `/api/projects` //! plus an `Other directory…` escape hatch, a generated session name from //! `/api/names` with a reroll key, and validation that reports which field is -//! missing instead of posting an empty request. The worktree section lands -//! with #65. +//! missing instead of posting an empty request. A worktree section (#65) +//! mirrors `web/src/spawnForm.ts`: on by default over a git repository, +//! branch tracking the generated name until touched, with a new-or-existing +//! branch switch fed by `/api/branches`. use std::collections::VecDeque; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use maestro_config::{builtin_presets, custom_preset}; -use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset}; +use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -48,9 +50,27 @@ pub enum DraftField { /// Free-text path, revealed by picking `Other directory…`. CwdRaw, Name, + /// Worktree on/off for this session (#65). Enter / Space toggles. + WorktreeToggle, + /// New-vs-existing branch source; Left / Right switches. + BranchMode, + /// Free-text new-branch name, shown in `New` mode. + BranchNew, + /// Existing-branch picker, shown in `Existing` mode. + BranchExisting, Submit, } +/// Where a worktree's branch comes from (#65), matching the web form's +/// `WorktreeBranchMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorktreeBranchMode { + /// Create the branch (named after the generated session name by default). + New, + /// Check out a branch picked from `GET /api/branches` as-is. + Existing, +} + /// One flattened project-picker entry: a discovered repo/worktree plus what /// rendering needs to know about it. `indent` marks worktrees nested under /// their main checkout. @@ -67,6 +87,9 @@ pub struct DraftRow { pub enum DraftValidationError { MissingDirectory, MissingCommand, + MissingWorktreeRepo, + MissingNewBranch, + MissingExistingBranch, } impl std::fmt::Display for DraftValidationError { @@ -74,6 +97,11 @@ impl std::fmt::Display for DraftValidationError { match self { Self::MissingDirectory => write!(f, "Pick a working directory."), Self::MissingCommand => write!(f, "Pick a command (or fill the custom command)."), + Self::MissingWorktreeRepo => { + write!(f, "Worktree needs a directory inside a git repository.") + } + Self::MissingNewBranch => write!(f, "Worktree requires a branch name."), + Self::MissingExistingBranch => write!(f, "Pick an existing branch."), } } } @@ -105,6 +133,30 @@ pub struct DraftPane { /// clobbering it, exactly like the web form's `nameTouched`. pub name_touched: bool, needs_name_refill: bool, + /// Worktree on for this spawn (#65). Defaults on; a failed gitroot probe + /// unticks it, like the web form. + pub worktree_enabled: bool, + /// Enclosing repository of the chosen target — from the project scan for + /// picked rows, `/api/gitroot` for raw paths. `None` until known. + pub base_repo: Option, + /// The cwd the current `base_repo` answer belongs to. + pub(crate) probed_cwd: Option, + /// Bumped on every target change; answers carrying an older seq are stale. + probe_seq: u32, + needs_probe: bool, + /// Set when a probe found no repository (drives the rendered hint). + repo_missing: bool, + /// New-branch name. Tracks [`Self::name`] until edited, like the web form. + pub branch: crate::text_input::TextInput, + branch_edited: bool, + pub branch_mode: WorktreeBranchMode, + /// Branches of the target repo (`GET /api/branches`), with occupancy. + pub branches: Vec, + /// Highlighted entry in `Existing` mode; always an available branch + /// unless moved by hand. + pub selected_branch: usize, + branches_for: Option, + needs_branches: bool, /// Last refusal (validation or a failed POST), rendered inside the form /// instead of dismissing it. pub error: Option, @@ -132,6 +184,19 @@ impl Default for DraftPane { name_queue: VecDeque::new(), name_touched: false, needs_name_refill: false, + worktree_enabled: true, + base_repo: None, + probed_cwd: None, + probe_seq: 0, + needs_probe: false, + repo_missing: false, + branch: crate::text_input::TextInput::default(), + branch_edited: false, + branch_mode: WorktreeBranchMode::New, + branches: Vec::new(), + selected_branch: 0, + branches_for: None, + needs_branches: false, error: None, focused_field: DraftField::Preset, home_dir: String::new(), @@ -200,6 +265,15 @@ impl DraftPane { pub fn set_name(&mut self, name: impl Into) { self.name = crate::text_input::TextInput::with_value(name.into()); + self.sync_branch_to_name(); + } + + /// The new-branch field mirrors the session name until the operator types + /// their own branch — and only in `New` mode, like the web form. + fn sync_branch_to_name(&mut self) { + if !self.branch_edited && self.branch_mode == WorktreeBranchMode::New { + self.branch = crate::text_input::TextInput::with_value(self.name.value().to_string()); + } } /// Reroll the generated name. Pulls from the local batch; when it has run @@ -217,6 +291,167 @@ impl DraftPane { std::mem::take(&mut self.needs_name_refill) } + // -- worktree (#65) ----------------------------------------------------- + + /// Whether the branch controls are shown: the toggle alone appears until + /// the target is known to be inside a repository. + fn worktree_body_visible(&self) -> bool { + self.worktree_enabled && self.base_repo.is_some() + } + + /// A target the draft already knows the repo for (picked project row). + fn note_picked_repo(&mut self, base_repo: PathBuf) { + self.base_repo = Some(base_repo); + self.repo_missing = false; + self.probe_seq += 1; + self.needs_probe = false; + self.probed_cwd = self.cwd(); + self.branches_for = None; + self.schedule_branch_load(); + } + + /// A target with an unknown repo (raw path edit or the Other… hatch). + /// Cancels any in-flight probe and requests a fresh one. + fn note_unknown_repo(&mut self) { + self.base_repo = None; + self.repo_missing = false; + self.probe_seq += 1; + self.needs_probe = self.worktree_enabled && self.cwd().is_some(); + self.branches_for = None; + self.schedule_branch_load(); + } + + fn schedule_branch_load(&mut self) { + if self.worktree_body_visible() && self.branch_mode == WorktreeBranchMode::Existing { + self.needs_branches = true; + } + } + + /// Consume the gitroot-probe request: the caller fetches + /// `GET /api/gitroot?path=…` and reports back via [`Self::resolve_gitroot`] + /// with the same seq. + pub fn take_gitroot_probe(&mut self) -> Option<(u32, PathBuf)> { + if !self.needs_probe { + return None; + } + self.needs_probe = false; + let cwd = self.cwd()?; + self.probe_seq += 1; + Some((self.probe_seq, cwd)) + } + + /// Apply one `/api/gitroot` answer. Stale answers — anything not matching + /// the latest seq — are dropped, so rapid retyping can't resurrect an old + /// repo. A miss unticks the toggle (web parity) and records the hint. + pub fn resolve_gitroot(&mut self, seq: u32, root: Option) { + if seq != self.probe_seq { + return; + } + self.needs_probe = false; + self.probed_cwd = self.cwd(); + match root { + Some(repo) => { + self.base_repo = Some(repo); + self.repo_missing = false; + } + None => { + self.base_repo = None; + self.repo_missing = true; + self.worktree_enabled = false; + } + } + self.schedule_branch_load(); + } + + /// Consume the branch-list request: the caller fetches + /// `GET /api/branches?path=…` for the returned cwd. + pub fn take_branch_load(&mut self) -> Option { + if !self.needs_branches { + return None; + } + self.needs_branches = false; + self.cwd() + } + + /// Store a fetched branch list; answers for anything but the current cwd + /// are stale and dropped. + pub fn set_branches(&mut self, cwd: &Path, branches: Vec) { + if self.cwd().as_deref() != Some(cwd) { + return; + } + self.branches_for = Some(cwd.to_path_buf()); + self.branches = branches; + self.selected_branch = (0..self.branches.len()) + .find(|&i| self.branches[i].worktree.is_none()) + .unwrap_or(0); + } + + pub fn set_branch_mode(&mut self, mode: WorktreeBranchMode) { + if self.branch_mode == mode { + return; + } + self.branch_mode = mode; + self.sync_branch_to_name(); + self.schedule_branch_load(); + } + + pub fn cycle_branch_mode(&mut self, delta: i32) { + let next = match (self.branch_mode, delta >= 0) { + (WorktreeBranchMode::New, true) | (WorktreeBranchMode::Existing, false) => { + WorktreeBranchMode::Existing + } + _ => WorktreeBranchMode::New, + }; + self.set_branch_mode(next); + } + + /// Enter / Space on the toggle. Re-enabling reuses a still-valid repo + /// answer instead of re-probing. + pub fn toggle_worktree(&mut self) { + self.worktree_enabled = !self.worktree_enabled; + if !self.worktree_enabled { + return; + } + let cwd = self.cwd(); + if cwd.as_deref() != self.probed_cwd.as_deref() { + self.base_repo = None; + self.repo_missing = false; + } + if self.base_repo.is_none() { + self.probe_seq += 1; + self.needs_probe = cwd.is_some(); + } + self.schedule_branch_load(); + } + + fn available_branch(&self, i: usize) -> bool { + self.branches.get(i).is_some_and(|b| b.worktree.is_none()) + } + + /// Move the existing-branch highlight, skipping branches another worktree + /// holds (git refuses to check them out twice). + pub fn move_branch_selection(&mut self, delta: i32) { + let available: Vec = (0..self.branches.len()) + .filter(|&i| self.available_branch(i)) + .collect(); + if available.is_empty() { + return; + } + let cur = available + .iter() + .position(|&i| i == self.selected_branch) + .unwrap_or(0); + let len = available.len() as i32; + self.selected_branch = available[((cur as i32) + delta).rem_euclid(len) as usize]; + } + + /// The highlighted branch, only when no other worktree holds it. + pub fn picked_branch(&self) -> Option<&BranchInfo> { + self.branches + .get(self.selected_branch) + .filter(|b| b.worktree.is_none()) + } + pub fn is_custom(&self) -> bool { self.presets .get(self.selected_preset) @@ -260,15 +495,57 @@ impl DraftPane { serde_json::Value::String(name.to_string()), ); } + let worktree = self.build_worktree()?; Ok(CreateSessionRequest { cwd, command: command.to_string(), env: Default::default(), metadata, - worktree: None, + worktree, }) } + /// The worktree half of the request (#65): `None` while toggled off; + /// otherwise the inferred base repo plus a branch that is either created + /// (`New`) or checked out as-is (`Existing`). Cleanup is unconditional + /// daemon-side (#56), so no option for it here. + fn build_worktree(&self) -> Result, DraftValidationError> { + if !self.worktree_enabled { + return Ok(None); + } + let Some(base_repo) = self.base_repo.clone() else { + return Err(DraftValidationError::MissingWorktreeRepo); + }; + let spec = match self.branch_mode { + WorktreeBranchMode::New => { + let branch = self.branch.value().trim(); + if branch.is_empty() { + return Err(DraftValidationError::MissingNewBranch); + } + WorktreeSpec { + base_repo, + branch: branch.to_string(), + cleanup_on_kill: true, + create_branch: true, + } + } + WorktreeBranchMode::Existing => { + let name = self + .picked_branch() + .ok_or(DraftValidationError::MissingExistingBranch)? + .name + .clone(); + WorktreeSpec { + base_repo, + branch: name, + cleanup_on_kill: true, + create_branch: false, + } + } + }; + Ok(Some(spec)) + } + fn field_order(&self) -> Vec { let mut order = vec![DraftField::Preset]; if self.is_custom() { @@ -278,7 +555,16 @@ impl DraftPane { if self.cwd_raw_visible { order.push(DraftField::CwdRaw); } - order.extend([DraftField::Name, DraftField::Submit]); + order.push(DraftField::Name); + order.push(DraftField::WorktreeToggle); + if self.worktree_body_visible() { + order.push(DraftField::BranchMode); + order.push(match self.branch_mode { + WorktreeBranchMode::New => DraftField::BranchNew, + WorktreeBranchMode::Existing => DraftField::BranchExisting, + }); + } + order.extend([DraftField::Submit]); order } @@ -346,6 +632,12 @@ impl DraftPane { let project = visible[sel].project.clone(); self.picked_cwd = Some(project.path.clone()); self.cwd_raw_visible = false; + // The scan already knows this directory's repo (#61): no probe + // round trip needed, exactly like the web form. + let repo = project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()); if let Some(cmd) = project.last_command.as_deref() { if !cmd.is_empty() { if let Some(idx) = self.presets.iter().position(|p| p.command == cmd) { @@ -353,6 +645,7 @@ impl DraftPane { } } } + self.note_picked_repo(repo); return; } let seed = self.cwd().unwrap_or_else(|| { @@ -366,6 +659,7 @@ impl DraftPane { self.cwd_raw_visible = true; self.cwd_raw = crate::text_input::TextInput::with_value(seed.to_string_lossy()); self.focused_field = DraftField::CwdRaw; + self.note_unknown_repo(); } /// Handle one keystroke while this draft owns input (Insert mode, pane @@ -388,11 +682,25 @@ impl DraftPane { self.select_entry(); DraftInputResult::Consumed } + DraftField::WorktreeToggle => { + self.toggle_worktree(); + DraftInputResult::Consumed + } + // The existing-branch selection is live; Enter just confirms. + DraftField::BranchExisting => DraftInputResult::Consumed, _ => { self.next_field(); DraftInputResult::Consumed } }, + InputKey::Char { + c: ' ', + ctrl: false, + .. + } if self.focused_field == DraftField::WorktreeToggle => { + self.toggle_worktree(); + DraftInputResult::Consumed + } InputKey::Up if self.focused_field == DraftField::DirPicker => { self.move_selection(-1); DraftInputResult::Consumed @@ -409,6 +717,22 @@ impl DraftPane { self.cycle_preset(1); DraftInputResult::Consumed } + InputKey::Left if self.focused_field == DraftField::BranchMode => { + self.cycle_branch_mode(-1); + DraftInputResult::Consumed + } + InputKey::Right if self.focused_field == DraftField::BranchMode => { + self.cycle_branch_mode(1); + DraftInputResult::Consumed + } + InputKey::Up if self.focused_field == DraftField::BranchExisting => { + self.move_branch_selection(-1); + DraftInputResult::Consumed + } + InputKey::Down if self.focused_field == DraftField::BranchExisting => { + self.move_branch_selection(1); + DraftInputResult::Consumed + } InputKey::Char { c: 'r', ctrl: true, .. } if self.focused_field == DraftField::Name => { @@ -425,11 +749,22 @@ impl DraftPane { self.filter.handle_key(other); self.highlighted = 0; } - DraftField::CwdRaw => self.cwd_raw.handle_key(other), + DraftField::CwdRaw => { + self.cwd_raw.handle_key(other); + // The target moved: the old repo answer no longer + // applies and a fresh probe is needed. + self.note_unknown_repo(); + } DraftField::Name => { self.name.handle_key(other); self.name_touched = true; + self.sync_branch_to_name(); } + DraftField::BranchNew => { + self.branch.handle_key(other); + self.branch_edited = true; + } + _ => {} } DraftInputResult::Consumed } @@ -446,9 +781,14 @@ impl DraftPane { let show_custom = self.is_custom(); let show_raw = self.cwd_raw_visible; - let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* spawn */ + let show_wt_body = self.worktree_body_visible(); + // Up to three branch rows stay visible around the selection; held + // branches need their reason on screen, so they aren't collapsed. + let branch_rows = self.branches.len().clamp(1, 3) as u16; + let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* worktree */ + 1 /* spawn */ + u16::from(show_custom) + u16::from(show_raw) + + u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows }) + u16::from(self.error.is_some()); let list_capacity = (inner.height.saturating_sub(fixed)).max(1) as usize; @@ -461,7 +801,15 @@ impl DraftPane { if show_raw { constraints.push(Constraint::Length(1)); } - constraints.push(Constraint::Length(1)); + constraints.push(Constraint::Length(1)); // name + constraints.push(Constraint::Length(1)); // worktree toggle + if show_wt_body { + constraints.push(Constraint::Length(1)); // branch mode + match self.branch_mode { + WorktreeBranchMode::New => constraints.push(Constraint::Length(1)), + WorktreeBranchMode::Existing => constraints.push(Constraint::Length(branch_rows)), + } + } constraints.push(Constraint::Length(1)); if self.error.is_some() { constraints.push(Constraint::Length(1)); @@ -631,6 +979,135 @@ impl DraftPane { }, ); + // -- worktree (#65) ----------------------------------------------------- + let wt_row = next_row(); + let hint = if self.cwd().is_none() { + Some("choose a directory first") + } else if self.repo_missing { + Some("not inside a git repository") + } else if !self.worktree_enabled { + Some("off — spawns in the checkout itself") + } else { + None + }; + let mut tiles = vec![Span::raw(format!("{: { + let row = next_row(); + draw_gutter(f, row, "br:"); + let col = + draw_text_field(f, &self.branch, row, field_style(DraftField::BranchNew)); + if self.focused_field == DraftField::BranchNew { + cursor = Some((col, row.y)); + } + } + WorktreeBranchMode::Existing => { + let len = self.branches.len(); + let capacity = branch_rows as usize; + let sel = self.selected_branch.min(len.saturating_sub(1)); + let start = if sel >= capacity { + sel + 1 - capacity + } else { + 0 + }; + let name_width = self + .branches + .iter() + .map(|b| b.name.chars().count()) + .max() + .unwrap_or(0) + .min(20); + let list_row = next_row(); + for i in start..(start + capacity).min(len.max(1)) { + let y = list_row.y + (i - start) as u16; + if y >= list_row.y + list_row.height { + break; + } + let line = match self.branches.get(i) { + Some(b) => { + let held = b.worktree.is_some(); + let mut base = Style::default(); + if held { + base = base.add_modifier(Modifier::DIM); + } + if pane_focused + && self.focused_field == DraftField::BranchExisting + && i == sel + { + base = base.add_modifier(Modifier::REVERSED); + } + let reason = b + .worktree + .as_ref() + .map(|w| { + format!(" — held by {}", abbreviate_path(w, &self.home_dir)) + }) + .unwrap_or_default(); + Line::from(vec![ + Span::styled(if i == sel { ">" } else { " " }, base), + Span::raw(" "), + Span::styled(format!("{: Line::from(Span::styled( + "no local branches found", + Style::default().add_modifier(Modifier::DIM), + )), + }; + f.render_widget( + Paragraph::new(line), + Rect { + x: list_row.x + GUTTER as u16, + y, + width: list_row.width.saturating_sub(GUTTER as u16), + height: 1, + }, + ); + } + } + } + } + // -- submit ------------------------------------------------------------------ let spawn_row = next_row(); f.render_widget( @@ -754,6 +1231,19 @@ mod tests { } } + fn repo_project( + name: &str, + path: &str, + base: &str, + days_ago: i64, + cmd: Option<&str>, + ) -> Project { + Project { + base_repo: Some(PathBuf::from(base)), + ..project(name, path, days_ago, cmd) + } + } + fn typed(draft: &mut DraftPane, s: &str) { for c in s.chars() { draft.on_input(&InputKey::char(c)); @@ -830,6 +1320,9 @@ mod tests { fn custom_command_flows_into_the_request() { let mut draft = DraftPane::default(); draft.picked_cwd = Some(PathBuf::from("/srv/work")); + draft.base_repo = Some(PathBuf::from("/srv/work")); + draft.probed_cwd = Some(PathBuf::from("/srv/work")); + draft.worktree_enabled = false; // under test: command + cwd only draft.selected_preset = draft.presets.len() - 1; draft.custom_command = crate::text_input::TextInput::with_value("bash -l"); let req = draft.build_request().unwrap(); @@ -943,8 +1436,16 @@ mod tests { assert_eq!(draft.cwd_raw.value(), "/home/u"); typed(&mut draft, "/opt/tooling"); assert_eq!(draft.cwd(), Some(PathBuf::from("/home/u/opt/tooling"))); + // The raw target probed clean, so the worktree spec carries its repo. + let (seq, _) = draft.take_gitroot_probe().unwrap(); + draft.resolve_gitroot(seq, Some(PathBuf::from("/home/u/opt/tooling"))); + draft.seed_names(vec!["amber-falcon".into()]); let req = draft.build_request().unwrap(); assert_eq!(req.cwd, PathBuf::from("/home/u/opt/tooling")); + assert_eq!( + req.worktree.unwrap().base_repo, + PathBuf::from("/home/u/opt/tooling") + ); } #[test] @@ -970,6 +1471,8 @@ mod tests { assert_eq!(draft.name.value(), "crimson-otter"); draft.picked_cwd = Some(PathBuf::from("/w")); + draft.base_repo = Some(PathBuf::from("/w")); + draft.probed_cwd = Some(PathBuf::from("/w")); let req = draft.build_request().unwrap(); assert_eq!( req.metadata.get("name").and_then(|v| v.as_str()), @@ -1014,10 +1517,15 @@ mod tests { #[test] fn an_empty_name_sends_no_metadata_at_all() { - let draft = DraftPane { + let mut draft = DraftPane { picked_cwd: Some(PathBuf::from("/w")), ..DraftPane::default() }; + draft.base_repo = Some(PathBuf::from("/w")); + draft.probed_cwd = Some(PathBuf::from("/w")); + // No names seeded: the empty new-branch field would refuse, so spawn + // in the checkout to isolate the metadata assertion. + draft.worktree_enabled = false; let req = draft.build_request().unwrap(); assert!(req.metadata.is_empty()); } @@ -1129,6 +1637,326 @@ mod tests { ); } + #[test] + fn worktree_is_on_by_default_and_builds_a_create_branch_spec() { + let mut draft = DraftPane::default(); + assert!(draft.worktree_enabled, "on before any directory is chosen"); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.focused_field = DraftField::DirPicker; + draft.on_input(&InputKey::Enter); // picks the repo row + assert_eq!( + draft.base_repo.as_deref(), + Some(Path::new("/repos/maestro")), + "a picked project resolves its base repo without a probe" + ); + assert!(draft.take_gitroot_probe().is_none()); + draft.seed_names(vec!["crimson-otter".into()]); + + let wt = draft.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.base_repo, PathBuf::from("/repos/maestro")); + assert_eq!(wt.branch, "crimson-otter", "branch defaults to the name"); + assert!(wt.create_branch); + assert!(wt.cleanup_on_kill); + } + + #[test] + fn raw_path_targets_probe_gitroot_and_resolution_feeds_the_spec() { + let mut draft = DraftPane { + focused_field: DraftField::DirPicker, + ..DraftPane::default() + }; + draft.on_input(&InputKey::Enter); // no projects → Other directory… + typed(&mut draft, "/opt/tool"); + + let (seq, path) = draft.take_gitroot_probe().expect("probe requested"); + assert_eq!(path, PathBuf::from("/opt/tool")); + assert!(draft.take_gitroot_probe().is_none(), "one probe at a time"); + + // Further typing supersedes the in-flight probe… + typed(&mut draft, "-x"); + let (seq2, _) = draft.take_gitroot_probe().unwrap(); + assert_ne!(seq2, seq); + // …so a late answer to the old one must be ignored. + draft.resolve_gitroot(seq, Some(PathBuf::from("/somewhere/else"))); + assert!(draft.base_repo.is_none()); + + draft.resolve_gitroot(seq2, Some(PathBuf::from("/opt/tool"))); + assert_eq!(draft.base_repo.as_deref(), Some(Path::new("/opt/tool"))); + draft.seed_names(vec!["amber-falcon".into()]); + let req = draft.build_request().unwrap(); + assert_eq!(req.worktree.unwrap().base_repo, PathBuf::from("/opt/tool")); + } + + #[test] + fn a_failed_gitroot_probe_unticks_the_toggle_like_the_web_form() { + let mut draft = DraftPane { + focused_field: DraftField::DirPicker, + ..DraftPane::default() + }; + draft.on_input(&InputKey::Enter); + typed(&mut draft, "/not/a/repo"); + let (seq, _) = draft.take_gitroot_probe().unwrap(); + + draft.resolve_gitroot(seq, None); + assert!(!draft.worktree_enabled); + assert!( + !draft.build_request().unwrap().worktree.is_some(), + "spawns in the checkout itself" + ); + + // A stale success must not re-enable it. + draft.resolve_gitroot(seq, Some(PathBuf::from("/not/a/repo"))); + assert!(!draft.worktree_enabled); + } + + #[test] + fn the_new_branch_tracks_the_session_name_until_touched() { + let mut draft = DraftPane::default(); + draft.seed_names(vec!["crimson-otter".into(), "amber-falcon".into()]); + assert_eq!(draft.branch.value(), "crimson-otter"); + draft.reroll(); + assert_eq!(draft.branch.value(), "amber-falcon", "rerolls carry over"); + + draft.focused_field = DraftField::BranchNew; + typed(&mut draft, "-mine"); + assert_eq!(draft.branch.value(), "amber-falcon-mine"); + draft.seed_names(vec!["late-batch".into()]); + assert_eq!( + draft.branch.value(), + "amber-falcon-mine", + "an edited branch stops tracking the name" + ); + } + + #[test] + fn existing_mode_checks_out_a_picked_branch_without_creating_it() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.focused_field = DraftField::DirPicker; + draft.on_input(&InputKey::Enter); + draft.focused_field = DraftField::BranchMode; + draft.on_input(&InputKey::Right); + assert_eq!(draft.branch_mode, WorktreeBranchMode::Existing); + assert!( + draft.take_branch_load().is_some(), + "switching modes requests the branch list" + ); + + draft.set_branches( + Path::new("/repos/maestro"), + vec![ + BranchInfo { + name: "main".into(), + is_head: true, + worktree: None, + }, + BranchInfo { + name: "held".into(), + is_head: false, + worktree: Some(PathBuf::from("/repos/maestro-held")), + }, + BranchInfo { + name: "feature".into(), + is_head: false, + worktree: None, + }, + ], + ); + assert_eq!( + draft.branches[draft.selected_branch].name, "main", + "selection lands on an available branch" + ); + + draft.focused_field = DraftField::BranchExisting; + draft.on_input(&InputKey::Down); + assert_eq!( + draft.branches[draft.selected_branch].name, "feature", + "moving skips branches another worktree holds" + ); + + let wt = draft.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.branch, "feature"); + assert!(!wt.create_branch, "existing picks are checked out as-is"); + } + + #[test] + fn held_branches_render_unavailable_with_their_holder_as_the_reason() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.picked_cwd = Some(PathBuf::from("/repos/maestro")); + draft.base_repo = Some(PathBuf::from("/repos/maestro")); + draft.probed_cwd = Some(PathBuf::from("/repos/maestro")); + draft.focused_field = DraftField::BranchMode; + draft.on_input(&InputKey::Right); + let _ = draft.take_branch_load(); + draft.set_branches( + Path::new("/repos/maestro"), + vec![ + BranchInfo { + name: "held".into(), + is_head: false, + worktree: Some(PathBuf::from("/home/u/wt")), + }, + BranchInfo { + name: "free".into(), + is_head: false, + worktree: None, + }, + ], + ); + + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + assert!( + rendered.contains("held by"), + "the holder path is the reason:\n{rendered}" + ); + assert!(rendered.contains("free"), "{rendered}"); + + // A held branch is never submittable, even highlighted directly. + draft.selected_branch = 0; + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingExistingBranch) + ); + } + + #[test] + fn toggling_worktree_off_spawns_in_the_checkout_itself() { + let mut draft = DraftPane::default(); + draft.set_projects( + vec![repo_project( + "maestro", + "/repos/maestro", + "/repos/maestro", + 1, + None, + )], + Utc::now(), + ); + draft.picked_cwd = Some(PathBuf::from("/repos/maestro")); + draft.base_repo = Some(PathBuf::from("/repos/maestro")); + draft.probed_cwd = Some(PathBuf::from("/repos/maestro")); + + draft.focused_field = DraftField::WorktreeToggle; + draft.on_input(&InputKey::Enter); + assert!(!draft.worktree_enabled); + assert!(draft.build_request().unwrap().worktree.is_none()); + + draft.on_input(&InputKey::Enter); + assert!(draft.worktree_enabled); + assert!( + draft.take_gitroot_probe().is_none(), + "re-enabling reuses the known repo for an unchanged target" + ); + } + + #[test] + fn worktree_validation_names_the_problem() { + let mut draft = DraftPane { + cwd_raw: crate::text_input::TextInput::with_value("/opt/tool"), + picked_cwd: Some(PathBuf::from("/opt/tool")), + ..DraftPane::default() + }; + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingWorktreeRepo) + ); + assert_eq!( + DraftValidationError::MissingWorktreeRepo.to_string(), + "Worktree needs a directory inside a git repository." + ); + + draft.base_repo = Some(PathBuf::from("/opt/tool")); + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingNewBranch), + "no names seeded yet, so the new-branch field is empty" + ); + assert_eq!( + DraftValidationError::MissingNewBranch.to_string(), + "Worktree requires a branch name." + ); + + draft.branch_mode = WorktreeBranchMode::Existing; + assert_eq!( + draft.build_request(), + Err(DraftValidationError::MissingExistingBranch) + ); + assert_eq!( + DraftValidationError::MissingExistingBranch.to_string(), + "Pick an existing branch." + ); + } + + #[test] + fn render_shows_the_worktree_section() { + let mut draft = DraftPane { + home_dir: "/home/u".into(), + ..DraftPane::default() + }; + draft.set_projects( + vec![project("maestro", "/home/u/tea/maestro", 1, Some("claude"))], + Utc::now(), + ); + draft.seed_names(vec!["crimson-otter".into()]); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + for s in ["wt:", "[x] worktree"] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + assert!( + rendered.contains("choose a directory first"), + "hint while no target:\n{rendered}" + ); + assert!( + !rendered.contains("[new] [existing]"), + "body hidden until the repo is known:\n{rendered}" + ); + + draft.picked_cwd = Some(PathBuf::from("/home/u/tea/maestro")); + draft.base_repo = Some(PathBuf::from("/home/u/tea/maestro")); + draft.probed_cwd = Some(PathBuf::from("/home/u/tea/maestro")); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + for s in ["[new] [existing]", "br:", "crimson-otter"] { + assert!(rendered.contains(s), "missing {s:?}:\n{rendered}"); + } + + draft.focused_field = DraftField::WorktreeToggle; + draft.on_input(&InputKey::Enter); + let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true)); + assert!(rendered.contains("[ ] worktree"), "{rendered}"); + assert!( + !rendered.contains("[new] [existing]"), + "body hidden while off:\n{rendered}" + ); + } + #[test] fn abbreviate_path_matches_the_web_helpers() { assert_eq!( From 525dc9a04a1c2f72bb083a1ae4f09248d7e1d824 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:43:42 +0100 Subject: [PATCH 34/71] fix(maestro): reuse draft forms across renders (#68) render() destroyed and rebuilt every draft form, so an unrelated session exiting tore down a draft mid-edit: focus, caret and selection were lost and /api/info, /api/projects, /api/names were refetched for a form already on screen. Key draft forms by leaf id; rebuild only the pane DOM around them and destroy a form when its leaf is removed or filled by a session. --- web/src/multiview.test.ts | 25 ++++++++++++ web/src/multiview.ts | 84 +++++++++++++++++++++++++-------------- 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts index 4040a70..8067d5e 100644 --- a/web/src/multiview.test.ts +++ b/web/src/multiview.test.ts @@ -122,6 +122,31 @@ describe("multiview openDraft (#62)", () => { expect(tab.focusedLeafId).toBe(empty); }); + it("keeps the draft form instance when an unrelated session closes (#68)", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const s1 = splitLeaf(tab, "", "s1", "row", true); + const draftLeaf = splitLeaf(tab, s1, null, "row", true); + tab.focusedLeafId = draftLeaf; + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + await flush(); + + const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement; + nameInput.value = "wip"; + nameInput.dispatchEvent(new Event("input")); + await flush(); + expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip"); + + const formBefore = mv.el.querySelector(".mv-draft"); + mv.closeSession("s1"); + await flush(); + + expect(mv.el.querySelector(".mv-draft")).toBe(formBefore); + expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip"); + }); + it("seeds a clone's cwd and command into the draft", async () => { const layout = emptyLayout(); const mv = makeMv(layout); diff --git a/web/src/multiview.ts b/web/src/multiview.ts index f588239..c95daf7 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -70,9 +70,11 @@ export function createMultiView(opts: MultiViewOptions): MultiView { const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts; const persist = () => opts.onLayoutChange?.(); - // Draft-pane forms (#57) carry global listeners (dir autocomplete), so each - // render tears down the previous generation before rebuilding the canvas. - const draftForms = new Set(); + // Draft-pane forms (#57) carry global listeners (dir autocomplete), so forms + // whose leaves are gone are destroyed on each render. Forms for draft leaves + // that survive the render are reused in place (#68): rebuilding one mid-edit + // would drop focus, caret and selection, and re-fetch info/projects/names. + const draftForms = new Map(); const el = document.createElement("div"); el.className = "mv"; @@ -150,30 +152,35 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // Draft pane (#57): an empty pane is a spawn form. Values hang off the // leaf (never persisted) so each draft keeps its own half-filled state; // submitting fills this leaf with the new session in place. - const form = createSpawnForm({ - seed: leaf.draft ?? undefined, - class: "mv-draft", - onChange: (values) => { - leaf.draft = values; - }, - onSuccess: (session) => { - // The pane can be closed while the POST is in flight. Mounting the - // session onto a leaf no longer in the tree would strand it in no - // pane at all, so only claim the leaf while it is still there — - // searching every tab, since the operator may have switched tabs - // while the request was in flight. - const host = layout.tabs.find((t) => leaves(t.root).includes(leaf)); - if (host) { - leaf.sessionId = session.id; - leaf.draft = null; - host.focusedLeafId = leaf.id; - } - render(); - opts.onOpenSessionsChange?.(); - }, - }); - draftForms.add(form); - host.appendChild(form.el); + const form = draftForms.get(leaf.id); + if (form) { + host.appendChild(form.el); + } else { + const created = createSpawnForm({ + seed: leaf.draft ?? undefined, + class: "mv-draft", + onChange: (values) => { + leaf.draft = values; + }, + onSuccess: (session) => { + // The pane can be closed while the POST is in flight. Mounting the + // session onto a leaf no longer in the tree would strand it in no + // pane at all, so only claim the leaf while it is still there — + // searching every tab, since the operator may have switched tabs + // while the request was in flight. + const host = layout.tabs.find((t) => leaves(t.root).includes(leaf)); + if (host) { + leaf.sessionId = session.id; + leaf.draft = null; + host.focusedLeafId = leaf.id; + } + render(); + opts.onOpenSessionsChange?.(); + }, + }); + draftForms.set(leaf.id, created); + host.appendChild(created.el); + } } const tools = document.createElement("div"); @@ -395,9 +402,25 @@ export function createMultiView(opts: MultiViewOptions): MultiView { // ---- top-level render ---- + /** Destroy forms whose leaf is no longer a draft (leaf removed or filled by + * a session). Surviving draft leaves keep their form instance (#68). */ + function pruneForms(tab: { root: Node | null }): void { + const alive = new Set( + tab.root + ? leaves(tab.root) + .filter((l) => !l.sessionId) + .map((l) => l.id) + : [], + ); + for (const [id, f] of draftForms) { + if (!alive.has(id)) { + f.destroy(); + draftForms.delete(id); + } + } + } + function render(): void { - for (const f of draftForms) f.destroy(); - draftForms.clear(); renderTabs(); canvas.replaceChildren(); const tab = activeTab(layout); @@ -406,6 +429,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { el.classList.toggle("mv-mobile-solo", isMobile && layout.tabs.length === 1); if (!tab.root) { + pruneForms(tab); pool.setVisible([]); pool.setFocus(null); canvas.appendChild(renderWelcome()); @@ -421,12 +445,14 @@ export function createMultiView(opts: MultiViewOptions): MultiView { if (isMobile) { renderMobile(tab); + pruneForms(tab); persist(); return; } const hosts: { id: string; host: HTMLElement; info?: AttachInfo }[] = []; canvas.appendChild(buildTree(tab.root, hosts)); + pruneForms(tab); pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info }))); syncFocus(); persist(); From 67d17f48d0f792057e03ba6bc1421ce6ff64ff8f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:45:49 +0100 Subject: [PATCH 35/71] feat(maestro): app loop serves the draft's worktree fetches (#65) Two new effects: ProbeGitRoot resolves the draft target's base repo via /api/gitroot (seq-guarded, a miss unticks the toggle) and LoadBranches fills the existing-branch picker from /api/branches, both applied back to the tab that requested them. Branch loads triggered by a successful probe are served inline so perform() stays recursion-free. --- crates/maestro-tui/src/app.rs | 136 +++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 732124f..076954a 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -12,12 +12,13 @@ use std::collections::{HashMap, HashSet}; use std::io::Stdout; +use std::path::PathBuf; use std::time::Duration; use chrono::Utc; use crossterm::event::{Event as CtEvent, EventStream}; use futures_util::StreamExt; -use maestro_protocol::{routes, CreateSessionRequest, DaemonClient, NameBatch}; +use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use uuid::Uuid; @@ -88,6 +89,15 @@ pub enum Effect { LoadDraft(usize), /// A reroll drained the draft's local name batch (#64). RefillNames(usize), + /// The draft's target changed: probe `/api/gitroot` so the worktree + /// section knows its base repo (#65). `seq` guards stale answers. + ProbeGitRoot { + tab: usize, + seq: u32, + path: PathBuf, + }, + /// Fetch the draft target's branches from `/api/branches` (#65). + LoadBranches(usize), } impl App { @@ -193,6 +203,10 @@ impl App { Effect::Kill(id) => self.submit_kill(id).await, Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await, Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await, + Effect::ProbeGitRoot { tab, seq, path } => { + self.probe_git_root(tab, seq, path).await + } + Effect::LoadBranches(tab) => self.load_branches(tab).await, Effect::Forward { id, bytes } => { if let Some(handle) = self.emulators.get(id) { let _ = handle.tx.send(SessionInput::Bytes(bytes)).await; @@ -349,6 +363,16 @@ impl App { if draft.take_name_refill() { effects.push(Effect::RefillNames(tab_idx)); } + if let Some((seq, path)) = draft.take_gitroot_probe() { + effects.push(Effect::ProbeGitRoot { + tab: tab_idx, + seq, + path, + }); + } + if draft.take_branch_load().is_some() { + effects.push(Effect::LoadBranches(tab_idx)); + } effects } @@ -422,6 +446,72 @@ impl App { } } + /// Resolve the draft target's enclosing repository via `/api/gitroot` + /// (#65). A miss unticks the worktree toggle inside `resolve_gitroot`; + /// a hit may schedule a branch load (Existing mode), served inline. + async fn probe_git_root(&mut self, tab_idx: usize, seq: u32, path: PathBuf) { + let root = self + .daemon + .git_root(&path.to_string_lossy()) + .await + .ok() + .and_then(|g| g.root); + let Some(PaneRef::Draft(draft)) = self + .state + .tabs + .get_mut(tab_idx) + .and_then(|t| t.pane.as_mut()) + else { + return; + }; + draft.resolve_gitroot(seq, root); + if draft.take_branch_load().is_some() { + self.load_branches(tab_idx).await; + } + } + + /// One `/api/branches` batch for the draft's current cwd (#65). Same + /// reqwest route as [`Self::fetch_names`]: no typed client call exists. + async fn load_branches(&mut self, tab_idx: usize) { + let Some(path) = self + .state + .tabs + .get(tab_idx) + .and_then(|t| t.pane.as_ref()) + .and_then(|p| match p { + PaneRef::Draft(d) => d.cwd(), + _ => None, + }) + else { + return; + }; + let url = format!( + "{}{}", + maestro_protocol::routes::normalize_base(self.daemon.base()), + routes::BRANCHES + ); + let resp = self + .http + .get(url) + .query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())]) + .send() + .await; + // On failure the picker stays empty; "new" mode still works. + let branches = match resp { + Ok(resp) => resp.json::>().await.unwrap_or_default(), + Err(_) => return, + }; + let Some(PaneRef::Draft(draft)) = self + .state + .tabs + .get_mut(tab_idx) + .and_then(|t| t.pane.as_mut()) + else { + return; + }; + draft.set_branches(&path, branches); + } + async fn submit_spawn(&mut self, req: CreateSessionRequest) { match self.daemon.create_session(&req).await { Ok(session) => { @@ -613,6 +703,7 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec { mod tests { use super::*; use crate::action::Action; + use crate::draft::DraftField; use crate::keymap::Mode; use maestro_config::TuiSection; @@ -798,6 +889,49 @@ mod tests { } } + /// Editing the raw path with the worktree toggle on schedules a + /// `/api/gitroot` probe so the section learns its base repo (#65). + #[test] + fn typing_a_raw_path_schedules_a_gitroot_probe() { + let mut app = make_app(); + app.dispatch(Action::NewDraftPane); + let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { + panic!("expected a draft pane"); + }; + draft.cwd_raw_visible = true; + draft.focused_field = DraftField::CwdRaw; + + let effects = app.handle_draft_input(&InputKey::char('/')); + assert!( + matches!( + effects.as_slice(), + [Effect::ProbeGitRoot { tab: 0, seq: 2, .. }] + ), + "{effects:?}" + ); + } + + /// Switching the worktree branch source to Existing schedules one + /// `/api/branches` load for the current target (#65). + #[test] + fn switching_to_existing_branch_mode_schedules_a_branch_load() { + let mut app = make_app(); + app.dispatch(Action::NewDraftPane); + let PaneRef::Draft(draft) = app.state.active_tab_mut().pane.as_mut().unwrap() else { + panic!("expected a draft pane"); + }; + draft.picked_cwd = Some(std::path::PathBuf::from("/srv/repo")); + draft.base_repo = Some(std::path::PathBuf::from("/srv/repo")); + draft.probed_cwd = Some(std::path::PathBuf::from("/srv/repo")); + draft.focused_field = DraftField::BranchMode; + + let effects = app.handle_draft_input(&InputKey::Right); + assert!( + matches!(effects.as_slice(), [Effect::LoadBranches(0)]), + "{effects:?}" + ); + } + /// Submitting with no directory names the missing field inside the form /// and posts nothing (#64). #[test] From 8ed44e785d311b2436291f0fa8e74d7a6626f815 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:55:52 +0100 Subject: [PATCH 36/71] test(maestro): add branch/project clients and config seeding to e2e harness --- crates/maestro-e2e/src/client.rs | 12 +++++++++++- crates/maestro-e2e/src/daemon.rs | 21 +++++++++++++++++++++ crates/maestro-e2e/src/lib.rs | 2 +- crates/maestro-protocol/src/client.rs | 9 +++++++-- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/maestro-e2e/src/client.rs b/crates/maestro-e2e/src/client.rs index e247222..8de967e 100644 --- a/crates/maestro-e2e/src/client.rs +++ b/crates/maestro-e2e/src/client.rs @@ -11,7 +11,9 @@ use std::sync::Arc; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; -use maestro_protocol::{CatalogEvent, CreateSessionRequest, DaemonClient, Session}; +use maestro_protocol::{ + BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session, +}; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; @@ -62,6 +64,14 @@ impl TestClient { Ok(self.daemon.kill_session(id).await?) } + pub async fn list_projects(&self) -> Result, ClientError> { + Ok(self.daemon.list_projects().await?) + } + + pub async fn list_branches(&self, path: &str) -> Result, ClientError> { + Ok(self.daemon.list_branches(path).await?) + } + /// Connect to the daemon's per-session WS and return the stream. The /// caller drives the conversation. pub async fn attach( diff --git a/crates/maestro-e2e/src/daemon.rs b/crates/maestro-e2e/src/daemon.rs index 9c215a8..ca0e1b9 100644 --- a/crates/maestro-e2e/src/daemon.rs +++ b/crates/maestro-e2e/src/daemon.rs @@ -29,11 +29,32 @@ impl Daemon { Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await } + /// Spawn with a config file pre-seeded at the daemon's expected path + /// (`$XDG_CONFIG_HOME/maestro/config.toml`). + pub async fn spawn_with_config(toml: &str) -> Self { + Self::spawn_config_at( + SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())), + Some(toml), + ) + .await + } + /// Spawn on a caller-supplied bind address. pub async fn spawn_at(bind: SocketAddr) -> Self { + Self::spawn_config_at(bind, None).await + } + + /// Spawn on a caller-supplied bind address, with an optional config file. + async fn spawn_config_at(bind: SocketAddr, config: impl Into>) -> Self { let home = TempDir::new().expect("home tmpdir"); let tmux = TempDir::new().expect("tmux tmpdir"); + if let Some(toml) = config.into() { + let dir = home.path().join(".config").join("maestro"); + std::fs::create_dir_all(&dir).expect("config dir"); + std::fs::write(dir.join("config.toml"), toml).expect("write config.toml"); + } + let child = launch(bind, home.path(), tmux.path()); let me = Self { bind_addr: bind, diff --git a/crates/maestro-e2e/src/lib.rs b/crates/maestro-e2e/src/lib.rs index 9096913..630dc43 100644 --- a/crates/maestro-e2e/src/lib.rs +++ b/crates/maestro-e2e/src/lib.rs @@ -14,5 +14,5 @@ pub mod daemon; pub mod util; pub use build::workspace_target_bin; -pub use client::TestClient; +pub use client::{ClientError, TestClient}; pub use daemon::Daemon; diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index dd4203f..7d03e38 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -15,8 +15,8 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ - ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project, - RecentDirs, Session, + ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, + Project, RecentDirs, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -143,6 +143,11 @@ impl DaemonClient { self.get(routes::PROJECTS, &[]).await } + pub async fn list_branches(&self, path: &str) -> Result, ClientError> { + self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)]) + .await + } + // -- plumbing ----------------------------------------------------------- async fn get( From 7a2cc30bf973e48b8a467ca06a312dd0c22f4555 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:55:52 +0100 Subject: [PATCH 37/71] test(maestro): cover spawn-v2 discovery, worktrees and cleanup e2e (#66) --- crates/maestro-e2e/tests/e2e.rs | 308 ++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index ccf9da3..6ad0b82 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -35,6 +35,22 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest { } } +/// Spawn request carrying a worktree spec against `base`. +fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest { + CreateSessionRequest { + cwd: base.to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: base.to_path_buf(), + branch: branch.into(), + cleanup_on_kill: true, + create_branch, + }), + } +} + #[tokio::test(flavor = "multi_thread")] async fn lists_spawned_sessions() { let (_daemon, client) = bring_up().await; @@ -204,6 +220,19 @@ async fn kill_spares_worktree_with_uncommitted_changes() { wt_path.exists(), "guarded worktree must be left on disk after kill" ); + // Left findable, not silently half-deleted: git still registers it and + // the uncommitted edit survives byte for byte (#66). + assert!( + git_worktree_list(base.path()) + .iter() + .any(|w| w.contains("feature/unsaved")), + "guarded worktree must stay registered in git worktree list" + ); + assert_eq!( + std::fs::read(wt_path.join("README")).unwrap(), + b"unsaved edits\n", + "uncommitted changes must survive the guarded kill" + ); } #[tokio::test(flavor = "multi_thread")] @@ -240,6 +269,276 @@ async fn spawn_on_existing_branch_checks_it_out() { client.kill_session(s.id).await.expect("kill"); } +/// #66: a fresh branch spawns a worktree at `/worktrees/` and +/// the session actually runs inside it — `current_cwd` comes from tmux's live +/// pane state, not from the spawn request. +#[tokio::test(flavor = "multi_thread")] +async fn spawn_new_branch_creates_worktree_and_runs_in_it() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let wt_path = base.path().join("worktrees").join("feature-fresh"); + + let s = client + .spawn_session(&worktree_req(base.path(), "feature/fresh", true)) + .await + .expect("spawn new-branch worktree"); + + assert_eq!( + s.cwd, wt_path, + "session must report the worktree as its cwd" + ); + assert!(wt_path.is_dir(), "worktree directory must exist on disk"); + + // The activity watcher fills `current_cwd` from tmux's pane_current_path, + // so this is the daemon confirming where the process really runs. + wait_for_async( + "session's live cwd is the worktree", + Duration::from_secs(20), + || { + let client = client.clone(); + let id = s.id; + let want = wt_path.clone(); + async move { + client + .list_sessions() + .await + .ok() + .and_then(|ss| ss.iter().find(|s| s.id == id).cloned()) + .map(|s| s.current_cwd == Some(want.clone())) + .unwrap_or(false) + } + }, + ) + .await; + + client.kill_session(s.id).await.expect("kill"); +} + +/// #66: a second spawn onto a branch that already has a worktree is rejected +/// with a structured worktree error; the first worktree stays intact. +#[tokio::test(flavor = "multi_thread")] +async fn second_spawn_on_same_branch_is_rejected() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + + let first = client + .spawn_session(&worktree_req(base.path(), "feature/dup", true)) + .await + .expect("first spawn"); + let dup = client + .spawn_session(&worktree_req(base.path(), "feature/dup", true)) + .await; + match dup { + Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => { + assert_eq!( + info.code, "worktree_failed", + "expected worktree error: {info:?}" + ); + } + other => panic!("expected rejection for duplicate branch, got {other:?}"), + } + + // Exactly one registration, one directory, still usable. + let holders = git_worktree_list(base.path()) + .iter() + .filter(|w| w.contains("feature/dup")) + .count(); + assert_eq!(holders, 1, "duplicate spawn must not register another tree"); + assert!( + base.path().join("worktrees").join("feature-dup").is_dir(), + "original worktree must be untouched" + ); + + client.kill_session(first.id).await.expect("kill"); +} + +/// #66/#56: when the client picks a *linked worktree* as base_repo, placement +/// resolves through `--git-common-dir` to the main checkout, so the new tree +/// lands at `
/worktrees/` instead of nesting inside another +/// worktree. +#[tokio::test(flavor = "multi_thread")] +async fn spawn_from_linked_worktree_places_in_main_checkout() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let linked = base.path().join("linked"); + run_git( + &[ + "worktree", + "add", + "-q", + "-b", + "other", + &linked.to_string_lossy(), + ], + base.path(), + ); + + let s = client + .spawn_session(&worktree_req(&linked, "feature/nested", true)) + .await + .expect("spawn from linked worktree"); + + let want = base.path().join("worktrees").join("feature-nested"); + assert_eq!( + s.cwd, want, + "worktree must be placed under the main checkout" + ); + assert!(want.is_dir()); + assert!( + !s.cwd.starts_with(&linked), + "worktrees must never nest inside another worktree" + ); + + client.kill_session(s.id).await.expect("kill"); +} + +// --------------------------------------------------------------------------- +// GET /api/projects + /api/branches (#54/#55/#66) +// --------------------------------------------------------------------------- + +/// #66: two repos under a configured `[spawn].project_roots` root are both +/// discovered by `GET /api/projects`; git activity gives every repo a +/// `last_activity`, and spawning inside one merges its command into the +/// project entry via spawn history. +#[tokio::test(flavor = "multi_thread")] +async fn projects_endpoint_discovers_roots_and_merges_activity() { + let root = TempDir::new().unwrap(); + let alpha = make_repo(root.path(), "alpha"); + make_repo(root.path(), "beta"); + + let daemon = Daemon::spawn_with_config(&format!( + "[spawn]\nproject_roots = [\"{}\"]\nproject_scan_depth = 2\n", + root.path().display() + )) + .await; + let client = TestClient::new(&daemon.http_base()); + + let projects = client.list_projects().await.expect("list projects"); + let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect(); + assert!( + names.contains(&"alpha") && names.contains(&"beta"), + "both repos must be discovered, got: {names:?}" + ); + + let find = |name: &str| { + projects + .iter() + .find(|p| p.name == name) + .cloned() + .expect("project present") + }; + // Both have commits, so git activity alone populates last_activity… + assert!( + find("alpha").last_activity.is_some(), + "git activity must surface" + ); + assert!( + find("beta").last_activity.is_some(), + "git activity must surface" + ); + // …and neither has been spawned into yet. + assert_eq!(find("alpha").last_command, None); + assert_eq!(find("beta").last_command, None); + + // Spawning inside alpha writes durable history; /api/projects folds it in. + let s = client + .spawn_session(&create_req(&alpha, "sleep 30")) + .await + .expect("spawn in alpha"); + + wait_for_async( + "alpha's project entry reflects spawn history", + Duration::from_secs(5), + || { + let client = client.clone(); + async move { + client + .list_projects() + .await + .ok() + .map(|ps| { + ps.iter() + .find(|p| p.name == "alpha") + .map(|p| p.last_command.as_deref() == Some("sleep 30")) + .unwrap_or(false) + }) + .unwrap_or(false) + } + }, + ) + .await; + + // beta was never spawned into: no command recorded there. + let beta_after = client + .list_projects() + .await + .expect("list after spawn") + .into_iter() + .find(|p| p.name == "beta") + .unwrap(); + assert_eq!(beta_after.last_command, None); + + client.kill_session(s.id).await.expect("kill"); +} + +/// #66: `GET /api/branches` lists every local branch, flags the main +/// checkout's HEAD with `is_head`, and reports which linked worktree holds an +/// occupied branch. +#[tokio::test(flavor = "multi_thread")] +async fn branches_endpoint_lists_branches_and_flags_occupied() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + run_git(&["branch", "feature/taken"], base.path()); + run_git(&["branch", "feature/free"], base.path()); + let holder = base.path().join("holder-wt"); + run_git( + &[ + "worktree", + "add", + "-q", + &holder.to_string_lossy(), + "feature/taken", + ], + base.path(), + ); + + let branches = client + .list_branches(&base.path().to_string_lossy()) + .await + .expect("list branches"); + let by_name = |name: &str| { + branches + .iter() + .find(|b| b.name == name) + .cloned() + .unwrap_or_else(|| panic!("branch {name} missing from {branches:?}")) + }; + + let main = by_name("main"); + assert!(main.is_head, "main is the main checkout's HEAD"); + assert_eq!(main.worktree, None); + + let taken = by_name("feature/taken"); + assert!(!taken.is_head); + assert_eq!( + taken.worktree.as_deref(), + Some(holder.canonicalize().unwrap().as_path()), + "occupied branch must report its holding worktree" + ); + + let free = by_name("feature/free"); + assert!(!free.is_head); + assert_eq!(free.worktree, None, "unoccupied branch must be spawnable"); +} + #[tokio::test(flavor = "multi_thread")] async fn daemon_restart_recovers_session() { let (mut daemon, client) = bring_up().await; @@ -428,6 +727,15 @@ fn init_repo(dir: &std::path::Path) { run_git(&["commit", "-q", "-m", "initial"], dir); } +/// Real git repo at `/`, canonicalized (the scanner canonicalizes +/// discovered paths too, so comparisons must agree on the real path). +fn make_repo(root: &std::path::Path, name: &str) -> std::path::PathBuf { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + init_repo(&dir); + dir.canonicalize().unwrap() +} + fn run_git(args: &[&str], cwd: &std::path::Path) { let out = Command::new("git") .arg("-C") From d922cdaa32feaa02050e6cd1b5b00dbf7b67165e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:59:26 +0100 Subject: [PATCH 38/71] feat(maestro): fast-forward spawn default resolution (#67) --- crates/maestro-tui/src/fast_forward.rs | 283 +++++++++++++++++++++++++ crates/maestro-tui/src/lib.rs | 1 + 2 files changed, 284 insertions(+) create mode 100644 crates/maestro-tui/src/fast_forward.rs diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs new file mode 100644 index 0000000..4b8abb3 --- /dev/null +++ b/crates/maestro-tui/src/fast_forward.rs @@ -0,0 +1,283 @@ +//! Fast-forward spawn (#67): one keystroke, every default resolved, no form. +//! +//! The defaults mirror what a draft pane would settle on after the operator +//! picked the most recent project and kept their hands off everything else: +//! its last command (or the first non-custom preset), a freshly generated +//! name, and a worktree with a branch named after that name. Any default +//! that cannot be resolved — no projects yet, no command, no generated +//! name — downgrades the keystroke to a draft pane pre-filled with whatever +//! did resolve ([`FastForwardDefaults::apply_to_draft`]). + +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; + +use crate::draft::DraftPane; + +/// What one fast-forward keystroke resolved. Every field optional: `None` +/// anywhere means "open a draft instead of spawning". +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FastForwardDefaults { + pub project: Option, + pub command: Option, + pub name: Option, +} + +/// Resolve the fast-forward defaults from the daemon's answers. `name` is +/// `None` when `/api/names` yielded nothing; `presets` may be empty (the +/// caller falls back to the built-ins it already has). +pub fn resolve( + projects: &[Project], + presets: &[SpawnPreset], + name: Option, + now: DateTime, +) -> FastForwardDefaults { + let project = crate::projects::sort_projects(projects.to_vec(), now) + .into_iter() + .next(); + let command = project + .as_ref() + .and_then(|p| p.last_command.clone()) + .filter(|c| !c.trim().is_empty()) + .or_else(|| { + presets + .iter() + .find(|p| !p.command.is_empty()) + .map(|p| p.command.clone()) + }); + FastForwardDefaults { + project, + command, + name, + } +} + +impl FastForwardDefaults { + /// The spawn request when *every* default resolved: worktree on, new + /// branch named after the generated session name, base repo from the + /// scan (`None` means the project is itself a main checkout). + pub fn build_request(&self) -> Option { + let project = self.project.as_ref()?; + let command = self.command.as_deref()?.trim(); + let name = self.name.as_deref()?; + if command.is_empty() || name.is_empty() { + return None; + } + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + "name".to_string(), + serde_json::Value::String(name.to_string()), + ); + Some(CreateSessionRequest { + cwd: project.path.clone(), + command: command.to_string(), + env: Default::default(), + metadata, + worktree: Some(WorktreeSpec { + base_repo: project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()), + branch: name.to_string(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + } + + /// Seed a fresh draft pane with whatever did resolve — the fallback when + /// [`Self::build_request`] is `None`. The operator fills in the rest. + pub fn apply_to_draft(&self, draft: &mut DraftPane) { + if let Some(project) = &self.project { + draft.picked_cwd = Some(PathBuf::from(&project.path)); + // The scan already knows this directory's repo (#61): no probe + // round trip needed, exactly like picking the row by hand. + draft.base_repo = Some( + project + .base_repo + .clone() + .unwrap_or_else(|| project.path.clone()), + ); + draft.probed_cwd = Some(PathBuf::from(&project.path)); + } + if let Some(command) = &self.command { + if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) { + draft.selected_preset = idx; + } else if !command.trim().is_empty() { + // A last_command no preset advertises lands in custom free text. + draft.selected_preset = draft.presets.len().saturating_sub(1); + draft.custom_command = crate::text_input::TextInput::with_value(command); + } + } + if let Some(name) = &self.name { + draft.set_name(name.clone()); + // Seeded, not generated: a later refill must not clobber it. + draft.name_touched = true; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeDelta; + + fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project { + Project { + path: PathBuf::from(path), + name: path.rsplit('/').next().unwrap_or(path).into(), + is_worktree: false, + base_repo: None, + last_activity: Some(Utc::now() - TimeDelta::days(days_ago)), + last_command: cmd.map(String::from), + } + } + + fn repo_project(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project { + Project { + base_repo: Some(PathBuf::from(base)), + ..project(path, days_ago, cmd) + } + } + + fn preset(id: &str, command: &str) -> SpawnPreset { + SpawnPreset { + id: id.into(), + label: id.into(), + command: command.into(), + } + } + + #[test] + fn resolves_every_default_from_the_most_recent_project() { + let projects = vec![ + project("/repos/old", 30, Some("zsh")), + repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")), + ]; + let presets = vec![preset("aider", "aider"), preset("custom", "")]; + let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now()); + assert_eq!( + r.project.as_ref().unwrap().path, + PathBuf::from("/repos/fresh") + ); + assert_eq!(r.command.as_deref(), Some("claude")); + assert_eq!(r.name.as_deref(), Some("amber-falcon")); + + let req = r.build_request().expect("all defaults resolved"); + assert_eq!(req.cwd, PathBuf::from("/repos/fresh")); + assert_eq!(req.command, "claude"); + assert_eq!( + req.metadata.get("name").and_then(|v| v.as_str()), + Some("amber-falcon") + ); + let wt = req.worktree.expect("worktree on"); + assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh")); + assert_eq!(wt.branch, "amber-falcon"); + assert!(wt.create_branch, "fresh branch named after the session"); + } + + #[test] + fn worktree_of_the_most_recent_project_targets_its_base_repo() { + let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)]; + let r = resolve( + &projects, + &[preset("zsh", "zsh")], + Some("s".into()), + Utc::now(), + ); + let wt = r.build_request().unwrap().worktree.unwrap(); + assert_eq!(wt.base_repo, PathBuf::from("/repos/main")); + } + + #[test] + fn missing_last_command_falls_back_to_the_first_non_custom_preset() { + let projects = vec![project("/repos/p", 0, None)]; + let presets = vec![preset("custom", ""), preset("claude", "claude")]; + let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + assert_eq!(r.command.as_deref(), Some("claude")); + } + + #[test] + fn an_empty_last_command_is_no_command_at_all() { + let projects = vec![project("/repos/p", 0, Some(""))]; + let presets = vec![preset("custom", "")]; + let r = resolve(&projects, &presets, Some("n".into()), Utc::now()); + assert_eq!(r.command, None); + assert!(r.build_request().is_none()); + } + + #[test] + fn nothing_resolves_when_no_projects_are_known_yet() { + let r = resolve( + &[], + &[preset("claude", "claude")], + Some("n".into()), + Utc::now(), + ); + assert_eq!(r.project, None); + // No project → no directory → no request, even with command + name. + assert!(r.build_request().is_none()); + } + + #[test] + fn a_missing_generated_name_downgrades_to_a_draft() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + assert!(r.build_request().is_none(), "no name → no immediate spawn"); + } + + #[test] + fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() { + let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))]; + let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now()); + + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p"))); + assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p"))); + assert_eq!(draft.command(), "aider"); + assert!( + draft.name.value().is_empty(), + "nothing to seed the name with" + ); + assert!(draft.worktree_enabled); + } + + #[test] + fn a_resolved_name_survives_late_refills_and_tracks_the_branch() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let named = resolve( + &projects, + &[preset("claude", "claude")], + Some("amber-falcon".into()), + Utc::now(), + ); + let mut draft = DraftPane::default(); + named.apply_to_draft(&mut draft); + // A later batch must not overwrite the name the spawn was seeded with. + draft.seed_names(vec!["late-name".into()]); + assert_eq!(draft.name.value(), "amber-falcon"); + assert_eq!(draft.branch.value(), "amber-falcon"); + } + + #[test] + fn an_unresolved_name_still_gets_generated_by_a_later_batch() { + let projects = vec![project("/repos/p", 0, Some("claude"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + draft.seed_names(vec!["late-name".into()]); + assert_eq!(draft.name.value(), "late-name"); + } + + #[test] + fn a_last_command_no_preset_advertises_lands_in_custom_text() { + let projects = vec![project("/repos/p", 0, Some("nvim -d"))]; + let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now()); + let mut draft = DraftPane::default(); + r.apply_to_draft(&mut draft); + assert_eq!(draft.command(), "nvim -d"); + assert!(draft.is_custom()); + } +} diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 1e8c283..09e58af 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -9,6 +9,7 @@ pub mod cli; pub mod config; pub mod draft; pub mod emulators; +pub mod fast_forward; pub mod input; pub mod keymap; pub mod overlays; From 5aedaf838ab0c60a400915a5174708066d539244 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 23:03:46 +0100 Subject: [PATCH 39/71] feat(maestro): bind S to fast-forward spawn in the TUI (#67) --- crates/maestro-tui/src/action.rs | 14 +++ crates/maestro-tui/src/app.rs | 172 +++++++++++++++++++++++++++++++ crates/maestro-tui/src/config.rs | 7 ++ 3 files changed, 193 insertions(+) diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 8a0059c..98d5354 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -39,6 +39,9 @@ pub enum Action { AttachHighlighted, ToggleAttachHighlighted, NewDraftPane, + /// Fast-forward spawn (#67): spawn immediately with every default + /// resolved; unresolvable defaults open a pre-filled draft instead. + FastForwardSpawn, KillHighlighted, // Tabs @@ -79,6 +82,7 @@ impl Action { "AttachHighlighted" => return Ok(Action::AttachHighlighted), "ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted), "NewDraftPane" => return Ok(Action::NewDraftPane), + "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), "NewTab" => return Ok(Action::NewTab), "CloseTab" => return Ok(Action::CloseTab), @@ -197,6 +201,16 @@ mod tests { assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into())); } + /// The fast-forward pair sits next to the draft-pane key (#67). + #[test] + fn fast_forward_spawn_round_trips() { + assert_eq!( + Action::parse("FastForwardSpawn").unwrap(), + Action::FastForwardSpawn + ); + assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn"); + } + #[test] fn display_round_trips() { for a in [ diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 076954a..e4b3ac8 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -87,6 +87,9 @@ pub enum Effect { }, /// Fetch presets / projects / names for a freshly opened draft (#64). LoadDraft(usize), + /// Fast-forward spawn (#67): resolve every default from the daemon and + /// spawn with no form; unresolvable defaults open a pre-filled draft. + FastForward, /// A reroll drained the draft's local name batch (#64). RefillNames(usize), /// The draft's target changed: probe `/api/gitroot` so the worktree @@ -202,6 +205,7 @@ impl App { Effect::Spawn(req) => self.submit_spawn(req).await, Effect::Kill(id) => self.submit_kill(id).await, Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await, + Effect::FastForward => self.fast_forward_spawn().await, Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await, Effect::ProbeGitRoot { tab, seq, path } => { self.probe_git_root(tab, seq, path).await @@ -287,6 +291,7 @@ impl App { self.state.open_draft_pane(); effects.push(Effect::LoadDraft(self.state.active_tab_idx)); } + Action::FastForwardSpawn => effects.push(Effect::FastForward), Action::AttachHighlighted | Action::ToggleAttachHighlighted => { if let Some(sid) = self.pick_sidebar_target() { effects.push(Effect::Attach(sid)); @@ -430,6 +435,46 @@ impl App { } } + /// Fast-forward spawn (#67): one keystroke, every default resolved, no + /// form. The most recent project, its last command (or the first + /// non-custom preset), a freshly generated name, worktree on with a new + /// branch named after that name. Anything unresolvable — no projects + /// known yet, no command, no name — downgrades to a draft pane seeded + /// with whatever did resolve. + async fn fast_forward_spawn(&mut self) { + let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects()); + let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await; + let presets = match info { + Ok(info) if !info.presets.is_empty() => info.presets, + _ => Vec::new(), + }; + let discovered = projects.unwrap_or_default(); + let resolved = + crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now()); + if let Some(req) = resolved.build_request() { + // Never eat a pane already showing something: the spawn takes a + // fresh tab, same as opening a draft over a session would. + if self.state.active_tab().pane.is_some() { + self.state.new_tab(); + } + self.submit_spawn(req).await; + return; + } + // Unresolved defaults: a draft pre-filled with what did resolve. + self.state.open_draft_pane(); + let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else { + return; + }; + if !presets.is_empty() { + draft.set_presets(presets); + } + draft.set_projects(discovered, Utc::now()); + resolved.apply_to_draft(draft); + // A missing generated name still arrives here; seeding is a no-op + // once apply_to_draft has set a touched name. + draft.seed_names(names); + } + /// Fetch a fresh name batch after a reroll drained the local queue. async fn refill_names(&mut self, tab_idx: usize) { let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await; @@ -846,6 +891,133 @@ mod tests { ); } + /// The fast-forward keystroke itself is side-effect-free until its + /// daemon fetches come back. + #[test] + fn fast_forward_spawn_dispatches_the_fast_forward_effect() { + let mut app = make_app(); + assert_eq!( + app.dispatch(Action::FastForwardSpawn), + vec![Effect::FastForward] + ); + } + + /// Every default resolves: the POST goes straight out — most recent + /// project, its last command, generated name, worktree branch named + /// after it (#67). + #[tokio::test] + async fn fast_forward_spawns_immediately_when_every_default_resolves() { + use axum::routing::{get, post}; + use axum::Json; + use serde_json::json; + use std::sync::{Arc, Mutex}; + + async fn info() -> Json { + Json(json!({ + "hostname": "box", "version": "0.1.0", "home_dir": "/home/u", + "presets": [{"id": "claude", "label": "Claude", "command": "claude"}] + })) + } + async fn projects() -> Json { + Json(json!([ + {"path": "/repos/old", "name": "old", + "last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "zsh"}, + {"path": "/repos/fresh", "name": "fresh", "base_repo": "/repos/fresh", + "last_activity": chrono::Utc::now().to_rfc3339(), "last_command": "aider"} + ])) + } + async fn names() -> Json { + Json(json!({"names": ["crimson-otter"]})) + } + let posted: Arc>> = Arc::default(); + let seen = posted.clone(); + async fn spawn_session( + axum::extract::State(seen): axum::extract::State>>>, + Json(body): Json, + ) -> Json { + seen.lock().unwrap().push(body); + Json(json!({ + "id": uuid::Uuid::from_u128(77).to_string(), + "cwd": "/repos/fresh", "command": "aider", + "status": "Running", "created_at": chrono::Utc::now().to_rfc3339() + })) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::PROJECTS, get(projects)) + .route(maestro_protocol::routes::NAMES, get(names)) + .route( + maestro_protocol::routes::SESSIONS, + post(spawn_session).with_state(seen), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::FastForwardSpawn); + app.perform(effects).await; + + let posts = posted.lock().unwrap(); + assert_eq!(posts.len(), 1, "one immediate POST, no form shown"); + let req = &posts[0]; + assert_eq!(req["cwd"], json!("/repos/fresh")); + assert_eq!(req["command"], json!("aider")); + assert_eq!(req["metadata"]["name"], json!("crimson-otter")); + assert_eq!(req["worktree"]["base_repo"], json!("/repos/fresh")); + assert_eq!(req["worktree"]["branch"], json!("crimson-otter")); + assert_eq!(req["worktree"]["create_branch"], json!(true)); + } + + /// No projects known yet: nothing can resolve, so the keystroke opens a + /// draft pane (focused, Insert mode) instead of guessing (#67). + #[tokio::test] + async fn unresolvable_defaults_open_a_prefilled_draft_instead() { + use axum::routing::get; + use axum::Json; + use serde_json::json; + + async fn info() -> Json { + Json(json!({ + "hostname": "box", "version": "0.1.0", "home_dir": "/home/u", + "presets": [{"id": "claude", "label": "Claude", "command": "claude"}] + })) + } + async fn projects() -> Json { + Json(json!([])) + } + async fn names() -> Json { + Json(json!({"names": ["amber-falcon"]})) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::PROJECTS, get(projects)) + .route(maestro_protocol::routes::NAMES, get(names)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::FastForwardSpawn); + app.perform(effects).await; + + assert!( + matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))), + "the fallback is a draft pane" + ); + assert_eq!(app.state.focus, Focus::Pane); + assert_eq!(app.state.mode, Mode::Insert); + let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { + panic!("expected a draft pane"); + }; + // Whatever did resolve (nothing project-side) is seeded; the + // generated name still lands so the form is ready to go. + assert_eq!(draft.name.value(), "amber-falcon"); + assert_eq!(draft.command(), "claude"); + } + /// A valid form flows through: pick a project (set directly — the picker /// itself is covered in `draft.rs`), select the custom tile, type a /// command, then submit from the Spawn row. diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 9a727c2..26b0251 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -46,6 +46,9 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ // Catalog actions ("enter", "AttachHighlighted"), ("s", "NewDraftPane"), + // Fast-forward spawn (#67): the draft-pane key's neighbour does the same + // thing with every default resolved and no form. + ("S", "FastForwardSpawn"), ("x", "KillHighlighted"), // Tabs ("ctrl-t", "NewTab"), @@ -159,6 +162,10 @@ mod tests { cfg.keymap.resolve(Mode::Normal, "s"), Some(Action::NewDraftPane), ); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "S"), + Some(Action::FastForwardSpawn), + ); } #[test] From 1647e64aedc0699add32d9af453d28aef2ecd128 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 23:37:57 +0100 Subject: [PATCH 40/71] feat(maestro): fast-forward spawn resolution for the web client (#67) --- web/src/fastForward.test.ts | 93 +++++++++++++++++++++++++++++++++++++ web/src/fastForward.ts | 62 +++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 web/src/fastForward.test.ts create mode 100644 web/src/fastForward.ts diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts new file mode 100644 index 0000000..4acd661 --- /dev/null +++ b/web/src/fastForward.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + buildFastForwardRequest, + fastForwardSeed, + resolveFastForward, +} from "./fastForward.js"; +import type { Project, SpawnPreset } from "./types.js"; + +const presets: SpawnPreset[] = [ + { id: "custom", label: "Custom…", command: "" }, + { id: "claude", label: "Claude", command: "claude" }, +]; + +function project(overrides: Partial): Project { + return { + path: "/repos/p", + name: "p", + last_activity: new Date().toISOString(), + ...overrides, + }; +} + +describe("resolveFastForward (#67)", () => { + it("resolves every default from the most recent project", () => { + const projects = [ + project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }), + project({ + path: "/repos/fresh", + name: "fresh", + base_repo: "/repos/fresh", + last_command: "aider", + }), + ]; + const res = resolveFastForward(projects, presets, "amber-falcon"); + expect(res.project?.path).toBe("/repos/fresh"); + expect(res.command).toBe("aider"); + expect(res.name).toBe("amber-falcon"); + + const req = buildFastForwardRequest(res)!; + expect(req.cwd).toBe("/repos/fresh"); + expect(req.command).toBe("aider"); + expect(req.metadata).toEqual({ name: "amber-falcon" }); + expect(req.worktree).toEqual({ + base_repo: "/repos/fresh", + branch: "amber-falcon", + cleanup_on_kill: true, + create_branch: true, + }); + }); + + it("falls back to the first non-custom preset when there is no last command", () => { + const res = resolveFastForward([project({ last_command: undefined })], presets, "n"); + expect(res.command).toBe("claude"); + }); + + it("treats an empty last command as no command", () => { + const res = resolveFastForward([project({ last_command: "" })], [presets[0]], "n"); + expect(res.command).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("resolves nothing when no projects are known yet", () => { + const res = resolveFastForward([], presets, "n"); + expect(res.project).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("downgrades to a draft when the generated name is missing", () => { + const res = resolveFastForward([project({ last_command: "claude" })], presets); + expect(res.name).toBeUndefined(); + expect(buildFastForwardRequest(res)).toBeNull(); + }); + + it("seeds the fallback draft with whatever did resolve", () => { + const res = resolveFastForward( + [project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })], + presets, + ); + expect(fastForwardSeed(res)).toEqual({ + cwd: "/repos/p", + command: "aider", + name: "", + worktreeEnabled: true, + worktreeBranch: undefined, + }); + }); + + it("seeds worktree off when not even a project resolved", () => { + const seed = fastForwardSeed(resolveFastForward([], presets)); + expect(seed.worktreeEnabled).toBe(false); + expect(seed.cwd).toBe(""); + }); +}); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts new file mode 100644 index 0000000..fe05dd5 --- /dev/null +++ b/web/src/fastForward.ts @@ -0,0 +1,62 @@ +// Fast-forward spawn (#67): one keystroke, every default resolved, no form. +// Pure resolution only — the caller (main.ts) owns the fetches and the POST, +// and falls back to a seeded draft pane when anything is missing. + +import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js"; +import { sortProjects } from "./projectList.js"; + +/** What one fast-forward keystroke resolved. Anything absent means "open a + * draft pre-filled with the rest instead of spawning". */ +export interface FastForwardResolution { + project?: Project; + command?: string; + name?: string; +} + +/** The defaults: most recent project (fresh-first, same ordering the picker + * shows), its last command falling back to the first non-custom preset, and + * a freshly generated name when the daemon supplied one. */ +export function resolveFastForward( + projects: Project[], + presets: SpawnPreset[], + name?: string, +): FastForwardResolution { + const project = sortProjects(projects, new Date())[0]; + const lastCommand = project?.last_command?.trim(); + const command = + lastCommand || presets.find((p) => p.command !== "")?.command || undefined; + return { project, command, name: name || undefined }; +} + +/** The immediate spawn request — worktree on, new branch named after the + * generated session name — or `null` when any default is missing. */ +export function buildFastForwardRequest( + res: FastForwardResolution, +): CreateSessionRequest | null { + const { project, command, name } = res; + if (!project || !command?.trim() || !name) return null; + return { + cwd: project.path, + command, + metadata: { name }, + worktree: { + base_repo: project.base_repo ?? project.path, + branch: name, + cleanup_on_kill: true, + create_branch: true, + }, + }; +} + +/** Seed for the fallback draft pane: whatever did resolve goes into the + * form; the rest stays with its own defaults. */ +export function fastForwardSeed(res: FastForwardResolution) { + return { + cwd: res.project?.path ?? "", + command: res.command ?? "", + name: res.name ?? "", + worktreeEnabled: Boolean(res.project), + worktreeBranch: + res.project && res.name ? res.name : undefined, + }; +} From 586c2430568c2f920df3b68ab3ec0a7c7825e728 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 23:39:30 +0100 Subject: [PATCH 41/71] feat(maestro): bind N to fast-forward spawn in the web client (#67) --- web/src/catalog.ts | 2 ++ web/src/main.ts | 74 +++++++++++++++++++++++++++++++++++++++++++- web/src/spawnForm.ts | 5 +-- 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/web/src/catalog.ts b/web/src/catalog.ts index b96f4f5..db6186d 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -196,6 +196,8 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): spawnBtn.type = "button"; spawnBtn.className = "btn btn-primary spawn-btn"; spawnBtn.textContent = "+ Spawn"; + // The keyboard pair sits right here in the tooltip (#67). + spawnBtn.title = "Draft pane: n — fast-forward spawn: N"; spawnBtn.addEventListener("click", () => requestOpenSpawn()); actions.appendChild(spawnBtn); return header; diff --git a/web/src/main.ts b/web/src/main.ts index 27de74c..d393b84 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -3,13 +3,25 @@ import { ConnectionPool } from "./connections.js"; import { installHudKeybind } from "./debug.js"; import { CatalogStore } from "./events.js"; import { shouldRetryClose } from "./reconnect.js"; +import { + createSession, + fetchNames, + getDaemonInfo, + projects as fetchProjects, +} from "./api.js"; +import { + buildFastForwardRequest, + fastForwardSeed, + resolveFastForward, +} from "./fastForward.js"; +import { FALLBACK_PRESETS } from "./spawnForm.js"; import { mountHud, type Hud } from "./hud.js"; import { emptyLayout, type Layout, openSessions } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import { loadLayout, saveLayout } from "./persistence.js"; import { OPEN_SPAWN_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; -import { sessionName, type Session } from "./types.js"; +import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { installViewportSizing } from "./viewport.js"; import "./styles.css"; @@ -212,6 +224,7 @@ function renderWelcome(): HTMLElement { spawn.type = "button"; spawn.className = "btn btn-primary"; spawn.textContent = "+ Spawn session"; + spawn.title = "Draft pane: n — fast-forward spawn: N"; spawn.addEventListener("click", () => requestOpenSpawn()); (el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn); return el; @@ -265,6 +278,65 @@ document.addEventListener("catalog-spawn-similar", (ev) => { mv.openDraft({ cwd: s.cwd, command: s.command }); }); +// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns +// immediately with every default resolved — most recent project, its last +// command (or the first non-custom preset), a fresh name, worktree on with +// a new branch named after that name. Anything unresolvable (no projects +// known yet, no command, no generated name) opens a draft pre-filled with +// what did resolve instead. +async function requestFastForward(): Promise { + showMultiView(); + const [discovered, name] = await Promise.all([ + fetchProjects().catch(() => [] as Project[]), + fetchNames(1) + .then((batch) => batch.names[0]) + .catch(() => undefined), + ]); + let presets: SpawnPreset[] = FALLBACK_PRESETS; + try { + const info = await getDaemonInfo(); + if (info.presets?.length) presets = info.presets; + } catch { + /* keep fallbacks */ + } + const resolved = resolveFastForward(discovered, presets, name); + const req = buildFastForwardRequest(resolved); + if (req) { + try { + await createSession(req); + return; + } catch { + // A failed POST degrades to the same pre-filled draft as an + // unresolvable default — never a silent dead keystroke. + } + } + mv.openDraft(fastForwardSeed(resolved)); +} + +// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn. +// Ignored while typing anywhere (form fields, the terminal's hidden +// textarea, search) so an attached session's keys are never hijacked. +document.addEventListener("keydown", (e) => { + if (e.ctrlKey || e.altKey || e.metaKey) return; + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable) + ) { + return; + } + if (e.key === "n") { + e.preventDefault(); + requestOpenSpawn(); + } else if (e.key === "N") { + e.preventDefault(); + void requestFastForward(); + } +}); + type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 238d31a..b64fd0d 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -83,8 +83,9 @@ export function buildSpawnRequest( } /** Built-ins used when the daemon's `/api/info` presets can't be fetched - * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */ -const FALLBACK_PRESETS: SpawnPreset[] = [ + * (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. + * Exported for the fast-forward spawn's preset fallback (#67). */ +export const FALLBACK_PRESETS: SpawnPreset[] = [ { id: "claude", label: "Claude", command: "claude" }, { id: "opencode", label: "opencode", command: "opencode" }, { id: "zsh", label: "Shell", command: "zsh" }, From e9368bb2421b09336d9c4c9eee8e3f460927621a Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 00:10:35 +0100 Subject: [PATCH 42/71] fix(maestro): fourth-review findings across daemon, tui and web MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing-branch picker could never succeed in either client: both treated the main checkout's HEAD as available, and since that is usually `main` it became the default pick — git then refuses the second checkout. A TUI test had baked the bug in by asserting the selection lands on the `is_head` entry. Both pickers now treat "checked out anywhere" as held. Worktree paths were still not injective. Flattening `/` and `.` to `-` made `feat/x`, `feat.x` and `feat-x` share one directory, and the override root keyed on the repo basename alone, so `~/a/maestro` and `~/b/maestro` collided. A branch name is already a valid relative path — git stores it as refs/heads/feat/example — so it is used verbatim and nests under `worktrees/feat/`; the override root gains a digest of the full repo path. Git forbids holding both `feat` and `feat/x`, so the nesting cannot collide for valid refs. Also: - Recovered sessions dropped their worktree association, so after a restart a kill cleaned up nothing and the leftover directory blocked that branch forever. It is persisted on Session now. - The nesting guard compared git's resolved paths against an unresolved one, so it silently passed on any symlinked repo path — and that guard is what stands between a kill and an inner tree's uncommitted work. - Draft loads ran on the TUI event loop: /api/projects on a cold cache walks all of $HOME, freezing keys, frames and attached panes for up to the request timeout. They are detached tasks posting to a channel now. - Fast-forward in the web client created the session and never mounted it, so `N` looked like a dead key while the TUI attached. - The fast-forward fallback seed forced the worktree toggle off, against both a normal draft and the TUI. - The projects background refresh honoured the TTL, so two of every three ticks were no-ops and requests paid for the rescan instead. --- crates/maestro-cli/src/sessions.rs | 1 + crates/maestro-daemon/src/projects.rs | 18 +- crates/maestro-daemon/src/session.rs | 14 +- crates/maestro-daemon/src/worktree.rs | 64 +++- .../maestro-daemon/tests/session_manager.rs | 4 +- crates/maestro-daemon/tests/sidecar.rs | 1 + crates/maestro-daemon/tests/worktree.rs | 52 ++- crates/maestro-e2e/tests/e2e.rs | 23 +- crates/maestro-protocol/src/lib.rs | 17 + crates/maestro-testing/src/lib.rs | 1 + crates/maestro-tui/src/app.rs | 310 ++++++++++++------ crates/maestro-tui/src/catalog.rs | 1 + crates/maestro-tui/src/draft.rs | 24 +- crates/maestro-tui/src/sidebar.rs | 2 + web/src/fastForward.test.ts | 7 +- web/src/fastForward.ts | 6 +- web/src/main.ts | 7 +- web/src/spawnForm.ts | 13 +- 18 files changed, 413 insertions(+), 152 deletions(-) diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 1a087dc..902a2de 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -153,6 +153,7 @@ mod tests { command: command.into(), env: HashMap::new(), metadata: HashMap::new(), + worktree: None, status: SessionStatus::Running, created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(), foreground: None, diff --git a/crates/maestro-daemon/src/projects.rs b/crates/maestro-daemon/src/projects.rs index 68b49c4..aab747e 100644 --- a/crates/maestro-daemon/src/projects.rs +++ b/crates/maestro-daemon/src/projects.rs @@ -262,10 +262,16 @@ impl ProjectScanner { /// Cached snapshot, rescanning (off-thread) when the TTL has lapsed. pub async fn repos(&self) -> std::sync::Arc> { + self.repos_inner(false).await + } + + async fn repos_inner(&self, force: bool) -> std::sync::Arc> { let mut cache = self.cache.lock().await; - if let Some((at, repos)) = cache.as_ref() { - if at.elapsed() < self.ttl { - return repos.clone(); + if !force { + if let Some((at, repos)) = cache.as_ref() { + if at.elapsed() < self.ttl { + return repos.clone(); + } } } let roots = self.roots.clone(); @@ -281,9 +287,11 @@ impl ProjectScanner { repos } - /// Rescan now, ignoring the TTL. Called by the background timer. + /// Rescan now, ignoring the TTL. Called by the background timer — going + /// through `repos()` would hit the cache and make most ticks no-ops, + /// leaving requests to pay for the rescan instead (#54 review). async fn refresh(&self) { - self.repos().await; + self.repos_inner(true).await; } /// Spawn the slow background refresh task. Drop the returned handle to diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index ac54fb1..6b0d0af 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -290,6 +290,12 @@ impl SessionManager { command: req.command, env: req.env, metadata: req.metadata, + worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map( + |(path, base_repo)| maestro_protocol::SessionWorktree { + path: path.clone(), + base_repo: base_repo.clone(), + }, + ), status: SessionStatus::Running, created_at: Utc::now(), // A freshly-spawned session is producing its initial output; the @@ -430,6 +436,7 @@ impl SessionManager { command: String::new(), env: Default::default(), metadata: Default::default(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), // Activity is unknown on adoption; the watcher fills it in. @@ -457,8 +464,11 @@ impl SessionManager { SessionRecord { session: session.clone(), fifo_path, - worktree_path: None, - worktree_base_repo: None, + // Restored from the sidecar: without it the recovered + // session cleans up nothing on kill, and the leftover + // directory blocks that branch forever (#54 review). + worktree_path: session.worktree.as_ref().map(|w| w.path.clone()), + worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()), bridge, }, ); diff --git a/crates/maestro-daemon/src/worktree.rs b/crates/maestro-daemon/src/worktree.rs index 164021c..7a1450d 100644 --- a/crates/maestro-daemon/src/worktree.rs +++ b/crates/maestro-daemon/src/worktree.rs @@ -54,14 +54,20 @@ impl WorktreeManager { /// spawning the same branch name target one directory and the second /// fails with `PathExists`. fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf { - let slug = sanitize_branch_for_path(branch); + // An unusable branch name falls back to a digest rather than escaping + // the root; `run_add` surfaces git's own error for it anyway. + let slug = branch_relpath(branch).unwrap_or_else(|| PathBuf::from(short_hash(branch))); let main = main_checkout(base_repo); match &self.root_override { Some(root) => { - let repo = main + // Basename alone collides for two checkouts of the same name + // (`~/a/maestro` and `~/b/maestro`), so disambiguate with a + // digest of the full path (#54 review). + let base = main .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "repo".into()); + let repo = format!("{base}-{}", short_hash(&main.to_string_lossy())); root.join(repo).join(slug) } None => main.join("worktrees").join(slug), @@ -180,26 +186,54 @@ fn nested_worktree(path: &Path) -> Option { .args(["worktree", "list", "--porcelain"]) .output() .ok()?; + // `worktree list` prints fully-resolved paths while ours comes from + // `--git-common-dir` and may still hold symlinks. Comparing the two forms + // directly makes the guard silently pass on any symlinked repo path — and + // this guard is what stands between a kill and deleting an inner + // worktree's uncommitted work (#54 review). + let needle = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let text = String::from_utf8_lossy(&out.stdout); text.lines() .filter_map(|l| l.strip_prefix("worktree ")) .map(PathBuf::from) - .find(|p| p != path && p.starts_with(path)) + .find(|p| { + let real = p.canonicalize().unwrap_or_else(|_| p.clone()); + real != needle && real.starts_with(&needle) + }) } -fn sanitize_branch_for_path(branch: &str) -> String { - let mut out = String::with_capacity(branch.len()); - for c in branch.chars() { - if c.is_ascii_alphanumeric() || c == '-' || c == '_' { - out.push(c); - } else if c == '/' || c == '.' { - out.push('-'); - } +/// Short, stable digest used to disambiguate override roots. +fn short_hash(s: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut h); + format!("{:08x}", h.finish() as u32) +} + +/// The relative directory a branch's worktree lives in. +/// +/// A git branch name is already a valid relative path — git stores it as +/// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example` +/// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the +/// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x` +/// and `feat-x` share one directory, and with deterministic paths plus +/// `PathExists` as a hard error the losers could never get a worktree at all +/// (#54 review). +/// +/// Git already forbids holding both `feat` and `feat/x`, so the directory +/// collision that nesting could cause cannot arise from valid refs. Only +/// traversal and absolute components are rejected. +fn branch_relpath(branch: &str) -> Option { + use std::path::Component; + let candidate = Path::new(branch); + if branch.is_empty() + || !candidate + .components() + .all(|c| matches!(c, Component::Normal(_))) + { + return None; } - if out.is_empty() { - out.push_str("wt"); - } - out + Some(candidate.to_path_buf()) } /// `Some(reason)` when deleting `path` would destroy work. diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index deb2cc7..15e9aa2 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -370,7 +370,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() { let fx = DaemonFixture::new(); let repo = TempDir::new().unwrap(); init_repo(repo.path()); - let wt_path = repo.path().join("worktrees").join("feat-keep"); + let wt_path = repo.path().join("worktrees").join("feat").join("keep"); let s = fx .manager @@ -401,7 +401,7 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() { let fx = DaemonFixture::new(); let repo = TempDir::new().unwrap(); init_repo(repo.path()); - let wt_path = repo.path().join("worktrees").join("feat-guarded"); + let wt_path = repo.path().join("worktrees").join("feat").join("guarded"); let s = fx .manager diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index f270d1e..4997f1a 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -19,6 +19,7 @@ fn sample(id: Uuid) -> Session { command: "bash".into(), env: HashMap::from([("FOO".into(), "bar".into())]), metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]), + worktree: None, status: SessionStatus::Running, created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") .unwrap() diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs index 50f63dd..cea834e 100644 --- a/crates/maestro-daemon/tests/worktree.rs +++ b/crates/maestro-daemon/tests/worktree.rs @@ -53,7 +53,7 @@ fn add_places_worktree_inside_the_base_repo_by_default() { let mgr = WorktreeManager::new(None); let path = mgr.add_new(&repo, "feat/example").expect("add worktree"); - assert_eq!(path, repo.join("worktrees").join("feat-example")); + assert_eq!(path, repo.join("worktrees").join("feat").join("example")); assert!(path.exists()); assert!(path.join("README").exists()); @@ -74,7 +74,24 @@ fn worktree_root_override_redirects_placement() { // repos spawning the same branch name don't target one directory. let repo_name = repo.file_name().unwrap(); let path = mgr.add_new(&repo, "feat/example").expect("add worktree"); - assert_eq!(path, wt_root.path().join(repo_name).join("feat-example")); + // The override root disambiguates same-named checkouts with a digest of + // the full repo path, and the branch keeps its own shape. + // /-/feat/example + let prefix = format!("{}-", repo_name.to_string_lossy()); + let repo_dir_name = path + .strip_prefix(wt_root.path()) + .unwrap() + .components() + .next() + .unwrap(); + assert!( + repo_dir_name + .as_os_str() + .to_string_lossy() + .starts_with(&prefix), + "{path:?}" + ); + assert!(path.ends_with("feat/example"), "{path:?}"); assert!(path.exists()); } @@ -86,7 +103,7 @@ fn existing_directory_at_target_path_is_an_error_carrying_the_path() { // A predictable path means collisions are possible; they must be // reported, never silently relocated. - std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap(); + std::fs::create_dir_all(repo.join("worktrees").join("feat").join("collide")).unwrap(); let mgr = WorktreeManager::new(None); let err = mgr.add_new(&repo, "feat/collide").unwrap_err(); @@ -94,7 +111,8 @@ fn existing_directory_at_target_path_is_an_error_carrying_the_path() { assert!( msg.contains( repo.join("worktrees") - .join("feat-collide") + .join("feat") + .join("collide") .to_str() .unwrap() ), @@ -312,10 +330,9 @@ fn placement_resolves_a_linked_worktree_to_the_main_checkout() { !inner.starts_with(&outer), "must not nest: {inner:?} inside {outer:?}" ); - assert_eq!( - inner.parent().and_then(|p| p.parent()), - Some(repo.canonicalize().unwrap().as_path()), - "placed against the main checkout" + assert!( + inner.starts_with(repo.canonicalize().unwrap().join("worktrees")), + "placed against the main checkout: {inner:?}" ); } @@ -363,3 +380,22 @@ fn override_root_keeps_repos_apart() { let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b"); assert_ne!(a, b, "same branch name in two repos must not collide"); } + +/// Flattening `/` and `.` to `-` made these three share one directory, and a +/// deterministic path plus a hard `PathExists` meant the losers could never +/// get a worktree (#54 review). +#[test] +fn similar_branch_names_get_distinct_directories() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let a = mgr.add_new(&repo, "feat/x").expect("feat/x"); + let b = mgr.add_new(&repo, "feat.x").expect("feat.x"); + let c = mgr.add_new(&repo, "feat-x").expect("feat-x"); + assert_ne!(a, b); + assert_ne!(b, c); + assert_ne!(a, c); + assert!(a.ends_with("feat/x"), "{a:?}"); +} diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index 6ad0b82..cbb213a 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -156,7 +156,7 @@ async fn kill_removes_clean_worktree() { assert!( worktrees .iter() - .any(|w| w.contains("worktrees/feature-e2e")), + .any(|w| w.contains("worktrees/feature/e2e")), "expected a worktree for feature/e2e under /worktrees, got: {worktrees:?}" ); @@ -186,7 +186,11 @@ async fn kill_spares_worktree_with_uncommitted_changes() { let base = TempDir::new().unwrap(); init_repo(base.path()); - let wt_path = base.path().join("worktrees").join("feature-unsaved"); + let wt_path = base + .path() + .join("worktrees") + .join("feature") + .join("unsaved"); let req = CreateSessionRequest { cwd: base.path().to_path_buf(), @@ -278,7 +282,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() { let base = TempDir::new().unwrap(); init_repo(base.path()); - let wt_path = base.path().join("worktrees").join("feature-fresh"); + let wt_path = base.path().join("worktrees").join("feature").join("fresh"); let s = client .spawn_session(&worktree_req(base.path(), "feature/fresh", true)) @@ -343,13 +347,20 @@ async fn second_spawn_on_same_branch_is_rejected() { } // Exactly one registration, one directory, still usable. + // Count branch records only: the worktree path now contains the branch + // name too (`worktrees/feature/dup`), so a substring match over every line + // would count the same tree twice. let holders = git_worktree_list(base.path()) .iter() - .filter(|w| w.contains("feature/dup")) + .filter(|w| w.as_str() == "branch refs/heads/feature/dup") .count(); assert_eq!(holders, 1, "duplicate spawn must not register another tree"); assert!( - base.path().join("worktrees").join("feature-dup").is_dir(), + base.path() + .join("worktrees") + .join("feature") + .join("dup") + .is_dir(), "original worktree must be untouched" ); @@ -384,7 +395,7 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() { .await .expect("spawn from linked worktree"); - let want = base.path().join("worktrees").join("feature-nested"); + let want = base.path().join("worktrees").join("feature").join("nested"); assert_eq!( s.cwd, want, "worktree must be placed under the main checkout" diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 3bb1284..77979d8 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -53,6 +53,14 @@ pub struct Session { pub env: HashMap, #[serde(default)] pub metadata: HashMap, + /// The worktree this session owns, when it was spawned with one. + /// + /// Persisted rather than kept in memory only: after a daemon restart the + /// recovered session must still know what to clean up, and with + /// deterministic worktree paths a forgotten association leaves a directory + /// that blocks that branch from ever being spawned again (#54 review). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, pub status: SessionStatus, pub created_at: DateTime, /// Current foreground command in the session's pane (e.g. `claude`, @@ -127,6 +135,14 @@ fn default_true() -> bool { true } +/// The worktree a running session owns, carried on [`Session`] so it survives +/// a daemon restart. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionWorktree { + pub path: PathBuf, + pub base_repo: PathBuf, +} + /// HTTP `POST /api/sessions` body. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CreateSessionRequest { @@ -410,6 +426,7 @@ mod tests { command: "claude".into(), env: HashMap::from([("FOO".into(), "bar".into())]), metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]), + worktree: None, status: SessionStatus::Running, created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") .unwrap() diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index cce52c5..2706e83 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -36,6 +36,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { command: command.into(), env: HashMap::new(), metadata: HashMap::new(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index e4b3ac8..1676c5d 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -55,6 +55,14 @@ pub struct App { help: HelpOverlay, chord: ChordState, daemon: DaemonClient, + /// Results of draft data loads, delivered back to the event loop. + /// + /// These are network calls — `/api/projects` on a cold cache walks all of + /// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no + /// keys, no frames, attached panes stalled, for up to the request timeout + /// (#67 review). They run as detached tasks and post here instead. + draft_tx: tokio::sync::mpsc::UnboundedSender, + draft_rx: tokio::sync::mpsc::UnboundedReceiver, /// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet: /// `GET /api/names` (#64). http: reqwest::Client, @@ -105,6 +113,7 @@ pub enum Effect { impl App { pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon: DaemonClient) -> Self { + let (draft_tx, draft_rx) = tokio::sync::mpsc::unbounded_channel(); let chord_timeout = cfg.chord_timeout; let help = HelpOverlay { lines: render_help_lines(&cfg), @@ -123,6 +132,8 @@ impl App { chord: ChordState::new(chord_timeout), daemon, http, + draft_tx, + draft_rx, region: RegionMap::default(), last_sent_size: HashMap::new(), clicks: ClickTracker::default(), @@ -144,6 +155,9 @@ impl App { return Ok(()); } } + Some(update) = self.draft_rx.recv() => { + self.apply_draft_update(update).await; + } _ = self.catalog.changed() => { // Render on next loop iteration. } @@ -204,13 +218,13 @@ impl App { Effect::Attach(id) => self.attach(id), Effect::Spawn(req) => self.submit_spawn(req).await, Effect::Kill(id) => self.submit_kill(id).await, - Effect::LoadDraft(tab_idx) => self.load_draft_data(tab_idx).await, + Effect::LoadDraft(tab_idx) => self.spawn_load_draft(tab_idx), Effect::FastForward => self.fast_forward_spawn().await, - Effect::RefillNames(tab_idx) => self.refill_names(tab_idx).await, + Effect::RefillNames(tab_idx) => self.spawn_refill_names(tab_idx), Effect::ProbeGitRoot { tab, seq, path } => { - self.probe_git_root(tab, seq, path).await + self.spawn_probe_git_root(tab, seq, path) } - Effect::LoadBranches(tab) => self.load_branches(tab).await, + Effect::LoadBranches(tab) => self.spawn_load_branches(tab), Effect::Forward { id, bytes } => { if let Some(handle) = self.emulators.get(id) { let _ = handle.tx.send(SessionInput::Bytes(bytes)).await; @@ -410,28 +424,130 @@ impl App { /// to the built-ins already in place), projects from `/api/projects`, and /// a first name batch. Applies only if that tab still shows the draft — /// a slow response must never clobber what replaced it. - async fn load_draft_data(&mut self, tab_idx: usize) { - let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects()); - let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await; + /// Kick off the draft's initial data load without blocking the loop. + fn spawn_load_draft(&self, tab: usize) { + let (daemon, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); + let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; + let _ = tx.send(DraftUpdate::Data { + tab, + info: info.ok(), + projects: projects.ok(), + names, + }); + }); + } + + fn spawn_refill_names(&self, tab: usize) { + let (_, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; + let _ = tx.send(DraftUpdate::Names { tab, names }); + }); + } + + fn spawn_probe_git_root(&self, tab: usize, seq: u32, path: PathBuf) { + let (daemon, _, _, tx) = self.loader_handles(); + tokio::spawn(async move { + let root = daemon + .git_root(&path.to_string_lossy()) + .await + .ok() + .and_then(|g| g.root); + let _ = tx.send(DraftUpdate::GitRoot { tab, seq, root }); + }); + } + + fn spawn_load_branches(&self, tab: usize) { + let Some(cwd) = self + .state + .tabs + .get(tab) + .and_then(|t| t.pane.as_ref()) + .and_then(|p| match p { + PaneRef::Draft(d) => d.cwd(), + _ => None, + }) + else { + return; + }; + let (_, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let branches = fetch_branches(&http, &base, &cwd).await; + let _ = tx.send(DraftUpdate::Branches { tab, cwd, branches }); + }); + } + + fn loader_handles( + &self, + ) -> ( + DaemonClient, + reqwest::Client, + String, + tokio::sync::mpsc::UnboundedSender, + ) { + ( + self.daemon.clone(), + self.http.clone(), + self.daemon.base().to_string(), + self.draft_tx.clone(), + ) + } + + /// Fold a finished background load into the draft that asked for it. + async fn apply_draft_update(&mut self, update: DraftUpdate) { + let tab_idx = match &update { + DraftUpdate::Data { tab, .. } + | DraftUpdate::Names { tab, .. } + | DraftUpdate::GitRoot { tab, .. } + | DraftUpdate::Branches { tab, .. } => *tab, + }; let Some(PaneRef::Draft(draft)) = self .state .tabs .get_mut(tab_idx) .and_then(|t| t.pane.as_mut()) else { + // The pane went away while the request was in flight. return; }; - if let Ok(info) = info { - draft.home_dir.clone_from(&info.home_dir); - if !info.presets.is_empty() { - draft.set_presets(info.presets); + let mut follow_up_branches = false; + match update { + DraftUpdate::Data { + info, + projects, + names, + .. + } => { + if let Some(info) = info { + draft.home_dir.clone_from(&info.home_dir); + if !info.presets.is_empty() { + draft.set_presets(info.presets); + } + } + if let Some(projects) = projects { + draft.set_projects(projects, Utc::now()); + } + if !names.is_empty() { + draft.seed_names(names); + } + } + DraftUpdate::Names { names, .. } => { + if !names.is_empty() { + draft.extend_names(names); + } + } + DraftUpdate::GitRoot { seq, root, .. } => { + draft.resolve_gitroot(seq, root); + follow_up_branches = draft.take_branch_load().is_some(); + } + DraftUpdate::Branches { cwd, branches, .. } => { + draft.set_branches(&cwd, branches); } } - if let Ok(projects) = projects { - draft.set_projects(projects, Utc::now()); - } - if !names.is_empty() { - draft.seed_names(names); + if follow_up_branches { + self.spawn_load_branches(tab_idx); } } @@ -475,88 +591,6 @@ impl App { draft.seed_names(names); } - /// Fetch a fresh name batch after a reroll drained the local queue. - async fn refill_names(&mut self, tab_idx: usize) { - let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await; - let Some(PaneRef::Draft(draft)) = self - .state - .tabs - .get_mut(tab_idx) - .and_then(|t| t.pane.as_mut()) - else { - return; - }; - if !names.is_empty() { - draft.extend_names(names); - } - } - - /// Resolve the draft target's enclosing repository via `/api/gitroot` - /// (#65). A miss unticks the worktree toggle inside `resolve_gitroot`; - /// a hit may schedule a branch load (Existing mode), served inline. - async fn probe_git_root(&mut self, tab_idx: usize, seq: u32, path: PathBuf) { - let root = self - .daemon - .git_root(&path.to_string_lossy()) - .await - .ok() - .and_then(|g| g.root); - let Some(PaneRef::Draft(draft)) = self - .state - .tabs - .get_mut(tab_idx) - .and_then(|t| t.pane.as_mut()) - else { - return; - }; - draft.resolve_gitroot(seq, root); - if draft.take_branch_load().is_some() { - self.load_branches(tab_idx).await; - } - } - - /// One `/api/branches` batch for the draft's current cwd (#65). Same - /// reqwest route as [`Self::fetch_names`]: no typed client call exists. - async fn load_branches(&mut self, tab_idx: usize) { - let Some(path) = self - .state - .tabs - .get(tab_idx) - .and_then(|t| t.pane.as_ref()) - .and_then(|p| match p { - PaneRef::Draft(d) => d.cwd(), - _ => None, - }) - else { - return; - }; - let url = format!( - "{}{}", - maestro_protocol::routes::normalize_base(self.daemon.base()), - routes::BRANCHES - ); - let resp = self - .http - .get(url) - .query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())]) - .send() - .await; - // On failure the picker stays empty; "new" mode still works. - let branches = match resp { - Ok(resp) => resp.json::>().await.unwrap_or_default(), - Err(_) => return, - }; - let Some(PaneRef::Draft(draft)) = self - .state - .tabs - .get_mut(tab_idx) - .and_then(|t| t.pane.as_mut()) - else { - return; - }; - draft.set_branches(&path, branches); - } - async fn submit_spawn(&mut self, req: CreateSessionRequest) { match self.daemon.create_session(&req).await { Ok(session) => { @@ -744,6 +778,76 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec { lines } +/// A completed background load, applied on the event loop. +#[derive(Debug)] +enum DraftUpdate { + Data { + tab: usize, + info: Option, + projects: Option>, + names: Vec, + }, + Names { + tab: usize, + names: Vec, + }, + GitRoot { + tab: usize, + seq: u32, + root: Option, + }, + Branches { + tab: usize, + cwd: PathBuf, + branches: Vec, + }, +} + +/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`. +async fn fetch_names(http: &reqwest::Client, base: &str, count: usize) -> Vec { + let url = format!( + "{}{}", + maestro_protocol::routes::normalize_base(base), + routes::NAMES + ); + match http + .get(url) + .query(&[(routes::COUNT_QUERY, count)]) + .send() + .await + { + Ok(resp) => resp + .json::() + .await + .map(|batch| batch.names) + .unwrap_or_default(), + Err(_) => Vec::new(), + } +} + +/// `GET /api/branches?path=`. On failure the picker stays empty and "new +/// branch" mode still works. +async fn fetch_branches( + http: &reqwest::Client, + base: &str, + path: &std::path::Path, +) -> Vec { + let url = format!( + "{}{}", + maestro_protocol::routes::normalize_base(base), + routes::BRANCHES + ); + match http + .get(url) + .query(&[(routes::PATH_QUERY, path.to_string_lossy().as_ref())]) + .send() + .await + { + Ok(resp) => resp.json::>().await.unwrap_or_default(), + Err(_) => Vec::new(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -830,6 +934,7 @@ mod tests { cwd: std::path::PathBuf::from("/"), env: Default::default(), metadata: Default::default(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, @@ -1220,7 +1325,15 @@ mod tests { let mut app = make_app_at(&base); let effects = app.dispatch(Action::NewDraftPane); assert!(matches!(effects.as_slice(), [Effect::LoadDraft(0)])); + // `perform` only spawns the load — it must not block the event loop on + // the network (#67 review). The result arrives on the draft channel, + // which the run loop drains; do that by hand here. app.perform(effects).await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; let PaneRef::Draft(draft) = app.state.active_tab().pane.as_ref().unwrap() else { panic!("expected a draft pane"); @@ -1313,6 +1426,7 @@ mod tests { cwd: std::path::PathBuf::from("/"), env: Default::default(), metadata: Default::default(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index 2190ee4..32115d6 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -188,6 +188,7 @@ mod tests { cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), metadata: Default::default(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index aab9bd8..b20b99c 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -382,7 +382,7 @@ impl DraftPane { self.branches_for = Some(cwd.to_path_buf()); self.branches = branches; self.selected_branch = (0..self.branches.len()) - .find(|&i| self.branches[i].worktree.is_none()) + .find(|&i| Self::branch_is_available(&self.branches[i])) .unwrap_or(0); } @@ -424,8 +424,17 @@ impl DraftPane { self.schedule_branch_load(); } + /// A branch is pickable only when nothing has it checked out. That means + /// no linked worktree *and* not the main checkout's HEAD — git refuses a + /// second checkout either way, and HEAD is commonly `main`, so treating it + /// as available made it the default pick and every spawn failed with + /// "already used by worktree" (#54 review). + fn branch_is_available(b: &BranchInfo) -> bool { + b.worktree.is_none() && !b.is_head + } + fn available_branch(&self, i: usize) -> bool { - self.branches.get(i).is_some_and(|b| b.worktree.is_none()) + self.branches.get(i).is_some_and(Self::branch_is_available) } /// Move the existing-branch highlight, skipping branches another worktree @@ -445,11 +454,11 @@ impl DraftPane { self.selected_branch = available[((cur as i32) + delta).rem_euclid(len) as usize]; } - /// The highlighted branch, only when no other worktree holds it. + /// The highlighted branch, only when nothing has it checked out. pub fn picked_branch(&self) -> Option<&BranchInfo> { self.branches .get(self.selected_branch) - .filter(|b| b.worktree.is_none()) + .filter(|b| Self::branch_is_available(b)) } pub fn is_custom(&self) -> bool { @@ -1781,15 +1790,16 @@ mod tests { ], ); assert_eq!( - draft.branches[draft.selected_branch].name, "main", - "selection lands on an available branch" + draft.branches[draft.selected_branch].name, "feature", + "selection skips `main`: the main checkout has it, so git would \ + refuse a second checkout" ); draft.focused_field = DraftField::BranchExisting; draft.on_input(&InputKey::Down); assert_eq!( draft.branches[draft.selected_branch].name, "feature", - "moving skips branches another worktree holds" + "the only pickable branch stays selected" ); let wt = draft.build_request().unwrap().worktree.unwrap(); diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index d17c12e..fa60e90 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -92,6 +92,7 @@ mod tests { cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), metadata, + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, @@ -179,6 +180,7 @@ mod tests { cwd: std::path::PathBuf::from("/tmp"), env: Default::default(), metadata: HashMap::new(), + worktree: None, status: SessionStatus::Running, created_at: Utc::now(), foreground: None, diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts index 4acd661..7e46408 100644 --- a/web/src/fastForward.test.ts +++ b/web/src/fastForward.test.ts @@ -85,9 +85,12 @@ describe("resolveFastForward (#67)", () => { }); }); - it("seeds worktree off when not even a project resolved", () => { + it("leaves the worktree default alone when not even a project resolved", () => { + // Not `false`: the form defaults the toggle on and a false would stick, + // so the fallback draft would disagree with a normally-opened draft and + // with the TUI's fast path. const seed = fastForwardSeed(resolveFastForward([], presets)); - expect(seed.worktreeEnabled).toBe(false); + expect(seed.worktreeEnabled).toBeUndefined(); expect(seed.cwd).toBe(""); }); }); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts index fe05dd5..8ac91fb 100644 --- a/web/src/fastForward.ts +++ b/web/src/fastForward.ts @@ -55,7 +55,11 @@ export function fastForwardSeed(res: FastForwardResolution) { cwd: res.project?.path ?? "", command: res.command ?? "", name: res.name ?? "", - worktreeEnabled: Boolean(res.project), + // Left undefined rather than false when nothing resolved: the form + // defaults the toggle on, and `false` would stick, so the fallback draft + // would disagree with both a normally-opened draft and the TUI's fast path + // (#67 review). + worktreeEnabled: res.project ? true : undefined, worktreeBranch: res.project && res.name ? res.name : undefined, }; diff --git a/web/src/main.ts b/web/src/main.ts index d393b84..00e664d 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -303,7 +303,12 @@ async function requestFastForward(): Promise { const req = buildFastForwardRequest(resolved); if (req) { try { - await createSession(req); + const session = await createSession(req); + // Mount it, the way the TUI's fast path attaches. Without this the + // keystroke creates a session and leaves the UI untouched, so it reads + // as a dead key (#67 review). + mv.openSession(session.id); + updateShellRoute(); return; } catch { // A failed POST degrades to the same pre-filled draft as an diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index b64fd0d..8e365c6 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -634,11 +634,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { return; } for (const b of branches) { - // A branch another worktree holds stays visible but unpickable — git - // would refuse to check it out twice; the path says who holds it. - const o = opt(b.worktree ? `${b.name} — held by ${b.worktree}` : b.name, b.name); - o.disabled = Boolean(b.worktree); - o.title = b.worktree ?? ""; + // A branch that is already checked out anywhere stays visible but + // unpickable — git refuses a second checkout. That includes the main + // checkout's own HEAD, which is commonly `main` and would otherwise be + // the default pick, making every existing-branch spawn fail (#54 review). + const held = b.worktree ?? (b.is_head ? "the main checkout" : null); + const o = opt(held ? `${b.name} — held by ${held}` : b.name, b.name); + o.disabled = held !== null; + o.title = held ?? ""; branchSelect.appendChild(o); } } From bbbc24a4aaaaebaba033d6b437cb688e18010efc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 00:28:50 +0100 Subject: [PATCH 43/71] fix(maestro): fifth-review findings, incl. the guarded-worktree dead end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cleanup guard plus deterministic paths had created a trap: one untracked file an agent left behind — a log, a .env, a stray artifact — made the guard refuse removal, the directory stayed, and every later spawn of that branch hit PathExists. The branch was burned permanently and the product offered no way to clear it. A leftover tree registered for the same branch is now adopted, so the operator lands back in their own unsaved work. A tree a *live* session holds is still rejected, since two sessions must not share a checkout. Also: - Fast-forward still awaited /api/info, /api/projects and /api/names inline, the exact freeze the draft loaders were restructured to avoid. It posts to the same channel now. - Draft updates were routed by tab index, which shifts when a tab closes: an in-flight load could be dropped or land on the wrong pane. Drafts carry an id and are found by it. - extend_names left the name it had just displayed at the head of the queue, so the next reroll produced it again and the key looked dead. - The fast-forward fallback discarded info.home_dir, so its draft showed unabbreviated paths and seeded "Other directory…" with / — disagreeing with the normal draft entry point. - Web fast-forward mounted via openSession, which replaces the focused pane: pressing it while attached evicted that session from the layout. It reuses an empty pane or splits, as the TUI does. - branchEdited was inferred from the seeded branch value, but in "new" mode that value is the auto-synced session name — so any form rebuild froze the branch against later rerolls. - resolveBaseRepo ignored the worktree checkbox, rebuilding the fieldset expanded under an unticked box and probing gitroot for a worktree the operator had turned off. - short_hash used DefaultHasher, whose algorithm is not stable across Rust releases, in a directory name — a toolchain bump would orphan every worktree under the override root. Now FNV-1a. --- crates/maestro-daemon/src/session.rs | 19 +++ crates/maestro-daemon/src/worktree.rs | 62 +++++++++- crates/maestro-daemon/tests/worktree.rs | 43 +++++++ crates/maestro-tui/src/app.rs | 158 +++++++++++++++++------- crates/maestro-tui/src/draft.rs | 21 +++- web/src/main.ts | 2 +- web/src/multiview.ts | 35 ++++++ web/src/spawnForm.test.ts | 1 + web/src/spawnForm.ts | 23 +++- 9 files changed, 310 insertions(+), 54 deletions(-) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 6b0d0af..2472afe 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -226,6 +226,25 @@ impl SessionManager { let WorktreeSpec { base_repo, branch, .. } = spec; + // `run_add` adopts a leftover tree for this branch — the + // dirty-work guard refuses to delete unsaved work and the path + // is deterministic, so erroring would burn the branch forever. + // A tree a *live* session is using is a different matter: + // two sessions must not share one checkout, so reject it here + // where the session records are visible (#67 review). + let planned = self.inner.worktree_mgr.planned_path(base_repo, branch); + if self + .inner + .sessions + .lock() + .await + .values() + .any(|r| r.worktree_path.as_deref() == Some(planned.as_path())) + { + return Err(SessionError::Worktree( + crate::worktree::WorktreeError::PathExists { path: planned }, + )); + } let path = if spec.create_branch { self.inner.worktree_mgr.add_new(base_repo, branch) } else { diff --git a/crates/maestro-daemon/src/worktree.rs b/crates/maestro-daemon/src/worktree.rs index 7a1450d..1421260 100644 --- a/crates/maestro-daemon/src/worktree.rs +++ b/crates/maestro-daemon/src/worktree.rs @@ -53,6 +53,12 @@ impl WorktreeManager { /// The override root gets the repo's basename too: without it two repos /// spawning the same branch name target one directory and the second /// fails with `PathExists`. + /// Where [`Self::add_new`] / [`Self::add_existing`] would place this + /// branch's worktree. Callers use it to check ownership before creating. + pub fn planned_path(&self, base_repo: &Path, branch: &str) -> PathBuf { + self.target_path(base_repo, branch) + } + fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf { // An unusable branch name falls back to a digest rather than escaping // the root; `run_add` surfaces git's own error for it anyway. @@ -97,6 +103,16 @@ impl WorktreeManager { // path, so a previously-leaked entry doesn't block this branch. prune(base_repo)?; if path.symlink_metadata().is_ok() { + // A tree we left behind for this very branch — the dirty-work + // guard refuses to delete unsaved work, and the path is + // deterministic, so erroring here would burn the branch + // permanently: no spawn could ever produce a worktree for it + // again and the product offers no way to clear it. Adopt it + // instead; landing in the operator's own unsaved work on the + // branch they asked for is what they wanted (#67 review). + if registered_for(base_repo, &path, branch) { + return Ok(path); + } return Err(WorktreeError::PathExists { path }); } // The parent (`/worktrees` or the override root) may not exist @@ -176,6 +192,34 @@ fn main_checkout(path: &Path) -> PathBuf { .unwrap_or_else(|| path.to_path_buf()) } +/// True when `path` is already a registered worktree of `base_repo` holding +/// `branch` — i.e. one this daemon created for this branch and later declined +/// to remove. +fn registered_for(base_repo: &Path, path: &Path, branch: &str) -> bool { + let Ok(out) = Command::new("git") + .arg("-C") + .arg(base_repo) + .args(["worktree", "list", "--porcelain"]) + .output() + else { + return false; + }; + let want = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let text = String::from_utf8_lossy(&out.stdout); + let mut here = false; + for line in text.lines() { + if let Some(p) = line.strip_prefix("worktree ") { + let p = PathBuf::from(p); + here = p.canonicalize().unwrap_or(p) == want; + } else if here { + if let Some(r) = line.strip_prefix("branch ") { + return r.trim() == format!("refs/heads/{branch}"); + } + } + } + false +} + /// `Some(inner)` when a registered worktree lives under `path`. Git omits /// those directories from `git status`, so the dirty-work guard cannot see /// them and removal would delete them (#54 review). @@ -202,12 +246,20 @@ fn nested_worktree(path: &Path) -> Option { }) } -/// Short, stable digest used to disambiguate override roots. +/// Short digest used to disambiguate override roots. +/// +/// Hand-rolled FNV-1a rather than `DefaultHasher`: this value becomes a +/// directory name, and `DefaultHasher`'s algorithm is explicitly not +/// guaranteed stable across Rust releases — a toolchain bump would remap every +/// repo to a new directory and orphan the worktrees under the old one +/// (#67 review). fn short_hash(s: &str) -> String { - use std::hash::{Hash, Hasher}; - let mut h = std::collections::hash_map::DefaultHasher::new(); - s.hash(&mut h); - format!("{:08x}", h.finish() as u32) + let mut h: u32 = 0x811c_9dc5; + for b in s.as_bytes() { + h ^= u32::from(*b); + h = h.wrapping_mul(0x0100_0193); + } + format!("{h:08x}") } /// The relative directory a branch's worktree lives in. diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs index cea834e..0c59bc9 100644 --- a/crates/maestro-daemon/tests/worktree.rs +++ b/crates/maestro-daemon/tests/worktree.rs @@ -399,3 +399,46 @@ fn similar_branch_names_get_distinct_directories() { assert_ne!(a, c); assert!(a.ends_with("feat/x"), "{a:?}"); } + +/// The dead end the cleanup guard created: it refuses to delete unsaved work, +/// the path is deterministic, and `PathExists` is a hard error — so one stray +/// untracked file used to burn that branch permanently, with no way to clear +/// it from the product. The leftover is adopted instead (#67 review). +#[test] +fn a_guarded_leftover_is_adopted_rather_than_burning_the_branch() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let first = mgr.add_new(&repo, "feat/resume").expect("add"); + std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap(); + + // The guard refuses, leaving it on disk. + mgr.remove(&repo, &first) + .expect_err("dirty tree is guarded"); + assert!(first.exists()); + + // Spawning that branch again lands in the operator's own work, not an error. + let again = mgr + .add_existing(&repo, "feat/resume") + .expect("leftover is adopted"); + assert_eq!(again, first); + assert!( + again.join("notes.txt").exists(), + "their work is still there" + ); +} + +/// Adoption must not extend to a directory that merely sits in the way. +#[test] +fn an_unrelated_directory_at_the_target_path_is_still_an_error() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + std::fs::create_dir_all(repo.join("worktrees").join("feat").join("squatter")).unwrap(); + let mgr = WorktreeManager::new(None); + mgr.add_new(&repo, "feat/squatter") + .expect_err("a non-worktree directory must not be adopted"); +} diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 1676c5d..96b5375 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -219,7 +219,7 @@ impl App { Effect::Spawn(req) => self.submit_spawn(req).await, Effect::Kill(id) => self.submit_kill(id).await, Effect::LoadDraft(tab_idx) => self.spawn_load_draft(tab_idx), - Effect::FastForward => self.fast_forward_spawn().await, + Effect::FastForward => self.spawn_fast_forward(), Effect::RefillNames(tab_idx) => self.spawn_refill_names(tab_idx), Effect::ProbeGitRoot { tab, seq, path } => { self.spawn_probe_git_root(tab, seq, path) @@ -395,43 +395,21 @@ impl App { effects } - /// One `/api/names` batch. [`DaemonClient`] has no typed call for this - /// route yet, so it goes through plain reqwest against the same base URL - /// and query contract (`routes::NAMES` + `routes::COUNT_QUERY`). - async fn fetch_names(&self, count: usize) -> Vec { - let url = format!( - "{}{}", - maestro_protocol::routes::normalize_base(self.daemon.base()), - routes::NAMES - ); - match self - .http - .get(url) - .query(&[(routes::COUNT_QUERY, count)]) - .send() - .await - { - Ok(resp) => resp - .json::() - .await - .map(|batch| batch.names) - .unwrap_or_default(), - Err(_) => Vec::new(), - } - } - /// Populate a freshly opened draft: presets from `/api/info` (falling back /// to the built-ins already in place), projects from `/api/projects`, and /// a first name batch. Applies only if that tab still shows the draft — /// a slow response must never clobber what replaced it. /// Kick off the draft's initial data load without blocking the loop. fn spawn_load_draft(&self, tab: usize) { + let Some(draft) = self.draft_id(tab) else { + return; + }; let (daemon, http, base, tx) = self.loader_handles(); tokio::spawn(async move { let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; let _ = tx.send(DraftUpdate::Data { - tab, + draft, info: info.ok(), projects: projects.ok(), names, @@ -440,14 +418,20 @@ impl App { } fn spawn_refill_names(&self, tab: usize) { + let Some(draft) = self.draft_id(tab) else { + return; + }; let (_, http, base, tx) = self.loader_handles(); tokio::spawn(async move { let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; - let _ = tx.send(DraftUpdate::Names { tab, names }); + let _ = tx.send(DraftUpdate::Names { draft, names }); }); } fn spawn_probe_git_root(&self, tab: usize, seq: u32, path: PathBuf) { + let Some(draft) = self.draft_id(tab) else { + return; + }; let (daemon, _, _, tx) = self.loader_handles(); tokio::spawn(async move { let root = daemon @@ -455,11 +439,14 @@ impl App { .await .ok() .and_then(|g| g.root); - let _ = tx.send(DraftUpdate::GitRoot { tab, seq, root }); + let _ = tx.send(DraftUpdate::GitRoot { draft, seq, root }); }); } fn spawn_load_branches(&self, tab: usize) { + let Some(draft) = self.draft_id(tab) else { + return; + }; let Some(cwd) = self .state .tabs @@ -475,10 +462,22 @@ impl App { let (_, http, base, tx) = self.loader_handles(); tokio::spawn(async move { let branches = fetch_branches(&http, &base, &cwd).await; - let _ = tx.send(DraftUpdate::Branches { tab, cwd, branches }); + let _ = tx.send(DraftUpdate::Branches { + draft, + cwd, + branches, + }); }); } + /// The draft pane living in `tab`, by identity. + fn draft_id(&self, tab: usize) -> Option { + match self.state.tabs.get(tab).and_then(|t| t.pane.as_ref()) { + Some(PaneRef::Draft(d)) => Some(d.id), + _ => None, + } + } + fn loader_handles( &self, ) -> ( @@ -497,11 +496,34 @@ impl App { /// Fold a finished background load into the draft that asked for it. async fn apply_draft_update(&mut self, update: DraftUpdate) { - let tab_idx = match &update { - DraftUpdate::Data { tab, .. } - | DraftUpdate::Names { tab, .. } - | DraftUpdate::GitRoot { tab, .. } - | DraftUpdate::Branches { tab, .. } => *tab, + if let DraftUpdate::FastForward { + info, + projects, + names, + } = update + { + self.fast_forward_spawn(info, projects, names).await; + return; + } + let want = match &update { + DraftUpdate::Data { draft, .. } + | DraftUpdate::Names { draft, .. } + | DraftUpdate::GitRoot { draft, .. } + | DraftUpdate::Branches { draft, .. } => *draft, + // Handled above; it targets no particular draft. + DraftUpdate::FastForward { .. } => unreachable!(), + }; + // Find it by identity: closing a tab shifts every index to its right, + // so an in-flight load addressed by index would be dropped or land on + // whatever pane inherited the slot (#67 review). + let Some(tab_idx) = self + .state + .tabs + .iter() + .position(|t| matches!(&t.pane, Some(PaneRef::Draft(d)) if d.id == want)) + else { + // The draft went away while the request was in flight. + return; }; let Some(PaneRef::Draft(draft)) = self .state @@ -509,7 +531,6 @@ impl App { .get_mut(tab_idx) .and_then(|t| t.pane.as_mut()) else { - // The pane went away while the request was in flight. return; }; let mut follow_up_branches = false; @@ -545,6 +566,7 @@ impl App { DraftUpdate::Branches { cwd, branches, .. } => { draft.set_branches(&cwd, branches); } + DraftUpdate::FastForward { .. } => unreachable!(), } if follow_up_branches { self.spawn_load_branches(tab_idx); @@ -557,11 +579,38 @@ impl App { /// branch named after that name. Anything unresolvable — no projects /// known yet, no command, no name — downgrades to a draft pane seeded /// with whatever did resolve. - async fn fast_forward_spawn(&mut self) { - let (info, projects) = tokio::join!(self.daemon.daemon_info(), self.daemon.list_projects()); - let names = self.fetch_names(crate::draft::NAME_BATCH_SIZE).await; + /// Load what the fast path needs without blocking the loop — the same + /// reason the draft loaders were moved off it (#67 review): a cold + /// `/api/projects` walks all of `$HOME`, and pressing the key must not + /// freeze keys, frames and attached panes until it returns. + fn spawn_fast_forward(&self) { + let (daemon, http, base, tx) = self.loader_handles(); + tokio::spawn(async move { + let (info, projects) = tokio::join!(daemon.daemon_info(), daemon.list_projects()); + let names = fetch_names(&http, &base, crate::draft::NAME_BATCH_SIZE).await; + let _ = tx.send(DraftUpdate::FastForward { + info: info.ok(), + projects: projects.ok(), + names, + }); + }); + } + + async fn fast_forward_spawn( + &mut self, + info: Option, + projects: Option>, + names: Vec, + ) { + // Keep the home dir: without it the pre-filled draft renders + // unabbreviated paths and "Other directory…" seeds `/` instead of + // home, so the two draft entry points disagreed (#67 review). + let home_dir = info + .as_ref() + .map(|i| i.home_dir.clone()) + .unwrap_or_default(); let presets = match info { - Ok(info) if !info.presets.is_empty() => info.presets, + Some(info) if !info.presets.is_empty() => info.presets, _ => Vec::new(), }; let discovered = projects.unwrap_or_default(); @@ -581,6 +630,7 @@ impl App { let Some(PaneRef::Draft(draft)) = self.state.active_tab_mut().pane.as_mut() else { return; }; + draft.home_dir = home_dir; if !presets.is_empty() { draft.set_presets(presets); } @@ -782,25 +832,31 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec { #[derive(Debug)] enum DraftUpdate { Data { - tab: usize, + draft: u64, info: Option, projects: Option>, names: Vec, }, Names { - tab: usize, + draft: u64, names: Vec, }, GitRoot { - tab: usize, + draft: u64, seq: u32, root: Option, }, Branches { - tab: usize, + draft: u64, cwd: PathBuf, branches: Vec, }, + /// A fast-forward's data arrived; resolution and spawn happen on the loop. + FastForward { + info: Option, + projects: Option>, + names: Vec, + }, } /// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`. @@ -1062,7 +1118,14 @@ mod tests { let mut app = make_app_at(&base); let effects = app.dispatch(Action::FastForwardSpawn); + // The fast path loads off the event loop now; the run loop drains the + // channel, so do that by hand. app.perform(effects).await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; let posts = posted.lock().unwrap(); assert_eq!(posts.len(), 1, "one immediate POST, no form shown"); @@ -1106,7 +1169,14 @@ mod tests { let mut app = make_app_at(&base); let effects = app.dispatch(Action::FastForwardSpawn); + // The fast path loads off the event loop now; the run loop drains the + // channel, so do that by hand. app.perform(effects).await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; assert!( matches!(app.state.active_tab().pane, Some(PaneRef::Draft(_))), diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index b20b99c..cf3a165 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -114,6 +114,11 @@ pub enum DraftInputResult { #[derive(Debug, Clone, PartialEq)] pub struct DraftPane { + /// Identity for routing async load results back to *this* draft. + /// + /// Tab indices shift when a tab closes, so an in-flight load addressed by + /// index could be dropped or land on the wrong pane (#67 review). + pub id: u64, /// Includes the implicit custom tile (empty `command`) last. pub presets: Vec, pub selected_preset: usize, @@ -164,6 +169,9 @@ pub struct DraftPane { pub home_dir: String, } +/// Source of [`DraftPane::id`]. +static NEXT_DRAFT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + impl Default for DraftPane { fn default() -> Self { // Built-ins until `/api/info` answers — the form must be usable even @@ -171,6 +179,7 @@ impl Default for DraftPane { let mut presets = builtin_presets(); presets.push(custom_preset()); Self { + id: NEXT_DRAFT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), presets, selected_preset: 0, custom_command: crate::text_input::TextInput::default(), @@ -256,10 +265,16 @@ impl DraftPane { /// Append a refill batch; the first name replaces the field (this only /// happens after an explicit reroll on an empty batch). - pub fn extend_names(&mut self, names: Vec) { - if let Some(first) = names.first() { - self.set_name(first.clone()); + /// + /// The shown name is *consumed*, not left at the head — queueing it too + /// made the next reroll produce the same name and the key look dead + /// (#67 review). + pub fn extend_names(&mut self, mut names: Vec) { + if names.is_empty() { + return; } + let first = names.remove(0); + self.set_name(first); self.name_queue.extend(names); } diff --git a/web/src/main.ts b/web/src/main.ts index 00e664d..a89bda9 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -307,7 +307,7 @@ async function requestFastForward(): Promise { // Mount it, the way the TUI's fast path attaches. Without this the // keystroke creates a session and leaves the UI untouched, so it reads // as a dead key (#67 review). - mv.openSession(session.id); + mv.openSessionBeside(session.id); updateShellRoute(); return; } catch { diff --git a/web/src/multiview.ts b/web/src/multiview.ts index c95daf7..b837c6e 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -49,6 +49,7 @@ export interface MultiView { /** Bring a session into the layout: focus it if already open, else fill the * focused pane (or open the first pane in an empty tab). */ openSession: (sessionId: string) => void; + openSessionBeside: (sessionId: string) => void; /** Drop the pane showing `sessionId` (the session was disconnected/killed). */ closeSession: (sessionId: string) => void; /** Every session currently shown in some pane, across all tabs. */ @@ -378,6 +379,39 @@ export function createMultiView(opts: MultiViewOptions): MultiView { render(); } + /// Mount a session without evicting one. + /// + /// `openSession` deliberately replaces the focused pane (VS Code style) for + /// a sidebar click, but the fast path must never do that: pressing the key + /// while attached would drop the running session out of the layout + /// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else + /// split beside the focused one (#67 review). + function openSessionBeside(sessionId: string): void { + const loc = locateSession(layout, sessionId); + if (loc) { + layout.activeTabId = loc.tab.id; + loc.tab.focusedLeafId = loc.leaf.id; + render(); + return; + } + const tab = activeTab(layout); + if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { + tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); + render(); + opts.onOpenSessionsChange?.(); + return; + } + const target = findLeaf(tab.root, tab.focusedLeafId)!; + if (!target.sessionId) { + target.sessionId = sessionId; + target.draft = null; + } else { + tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); + } + render(); + opts.onOpenSessionsChange?.(); + } + function openDraft(seed?: Partial): void { const tab = activeTab(layout); const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed }); @@ -693,6 +727,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView { return { el, openSession, + openSessionBeside, openDraft, closeSession, openSessions: () => openSessions(layout), diff --git a/web/src/spawnForm.test.ts b/web/src/spawnForm.test.ts index 3481628..5deb9ea 100644 --- a/web/src/spawnForm.test.ts +++ b/web/src/spawnForm.test.ts @@ -9,6 +9,7 @@ const base: SpawnFormValues = { worktreeEnabled: false, worktreeBranch: "", worktreeBranchMode: "new", + worktreeBranchEdited: false, }; describe("buildSpawnRequest", () => { diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 8e365c6..5a21569 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -25,6 +25,10 @@ export interface SpawnFormSeed { name?: string; worktreeEnabled?: boolean; worktreeBranch?: string; + /** Whether the operator typed the branch themselves, rather than it having + * been auto-synced from the session name. Carried separately because the + * value alone cannot distinguish the two. */ + worktreeBranchEdited?: boolean; worktreeBranchMode?: WorktreeBranchMode; cwdRawVisible?: boolean; } @@ -37,6 +41,7 @@ export interface SpawnFormValues { name: string; worktreeEnabled: boolean; worktreeBranch: string; + worktreeBranchEdited: boolean; /** "new" creates the branch; "existing" checks out a picked one (#61). */ worktreeBranchMode: WorktreeBranchMode; /** Whether the cwd lives in the raw text input (vs a picked recent row). */ @@ -230,6 +235,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { name: nameInput.value, worktreeEnabled: wtEnabled.checked, worktreeBranch: branchValue(), + worktreeBranchEdited: branchEdited, worktreeBranchMode: branchMode, cwdRawVisible: !cwdRawWrap.hidden, }); @@ -239,7 +245,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { // The worktree branch defaults to the session name — including a clone's // prefilled name — and tracks it on reroll/edit until the user types their // own branch. Only in "new" mode: an existing-branch pick is not a name. - let branchEdited = Boolean(prefill.worktreeBranch); + // True only once the operator types their own branch. It cannot be inferred + // from the seed: in "new" mode `emit()` publishes the auto-synced session + // name, so seeding from it froze the branch after any form rebuild + // (#67 review). `worktreeBranchEdited` is carried explicitly instead. + let branchEdited = Boolean(prefill.worktreeBranchEdited); const syncBranchToName = () => { if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value; }; @@ -540,6 +550,16 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { /** Decide how the worktree section describes the current target. Called * once the initial load has settled `cwd`. */ function resolveBaseRepo(): void { + // Nothing to resolve when the operator turned the worktree off — the + // section would otherwise rebuild expanded under an unchecked box, and + // fire a `/api/gitroot` probe for a worktree nobody asked for + // (#67 review). + if (!wtEnabled.checked) { + wtBody.hidden = true; + wtHint.hidden = true; + inferredBaseRepo = null; + return; + } if (!cwd) { inferredBaseRepo = null; wtBody.hidden = true; @@ -688,6 +708,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { name: nameInput.value, worktreeEnabled: wtEnabled.checked, worktreeBranch: branchValue(), + worktreeBranchEdited: branchEdited, worktreeBranchMode: branchMode, }, inferredBaseRepo, From 35c75f92381af28ceea2be441cbe471e3bec49cc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:29:42 +0100 Subject: [PATCH 44/71] docs(maestro): write the worktree lifecycle contract Closes #89 --- CLAUDE.md | 4 ++ docs/WORKTREES.md | 127 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 docs/WORKTREES.md diff --git a/CLAUDE.md b/CLAUDE.md index cfa0ac5..50ec101 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,10 @@ nix/ # Home Manager module (`services.maestro`) See `docs/INSTALL.md` for end-user install instructions. +`docs/WORKTREES.md` is the contract for session worktrees — placement, +collisions, what blocks a kill, what `--force` retains, and what reclaim +touches. Read it before changing anything that creates or removes one. + `nix run .#dev` (or `just dev`) brings up the whole local stack under process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md new file mode 100644 index 0000000..fca3566 --- /dev/null +++ b/docs/WORKTREES.md @@ -0,0 +1,127 @@ +# Worktrees + +Maestro can create a git worktree for a session and is responsible for it from +that moment until the worktree is removed. This document is the contract for +that lifetime: where a worktree goes, what ends a session, what survives, and +what the operator has to do by hand. + +It exists because the rules here are not guessable from the code. Every rule +below was decided once, re-litigated in review, and decided the same way again. +The reasoning is written down alongside each rule so the next reader does not +have to reconstruct it. + +## Nothing is ever adopted + +The daemon only ever removes a worktree it created in this session's lifetime or +recorded in its retained store. It never takes ownership of a directory it finds +on disk, whatever that directory looks like. + +There is no reliable way to ask "is this worktree mine?". A git worktree carries +no marker distinguishing one a daemon made from one a human made ten minutes +earlier, and any heuristic that answers the question will eventually answer it +wrong — in the direction of deleting somebody's work. + +So the daemon does not ask. If the target path is occupied by anything at all, it +picks a different path. + +## Placement + +A worktree for branch `` of repository `` goes to +`/worktrees/`, where `` is the main checkout — not whichever +worktree the request happened to come from. + +The branch name is used verbatim as a relative path, so `feature/login` becomes +`worktrees/feature/login`. A branch name that cannot be a relative path falls +back to a short hash of the branch. + +If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds +a free one. The suffix names the **directory only**. The branch is whatever was +requested; a second worktree of the same branch is not what happens here, because +git refuses to check the same branch out twice and the branch picker already +marks branches that are checked out elsewhere. + +### The `worktree_root` override + +`[daemon].worktree_root` moves every worktree out of the repository and under one +root. The per-repository directory is the repository's basename plus a short +digest of its full path — the digest is what keeps two repositories with the same +basename apart. Inside it the layout and the collision suffix are the same. + +## What ends a session + +`DELETE /api/sessions/:id` is **refused** when the session's worktree holds +unsaved work, meaning any of: + +- uncommitted changes to tracked files +- untracked files that are not ignored +- commits on the branch reachable from no remote-tracking ref + +Ignored files never block. Every Rust worktree has a `target/`; if ignored files +counted, no session would ever be killable. + +The refusal is a structured error naming what blocked it and how much of it there +is, so a client can say "3 uncommitted files, 2 unpushed commits". The session +keeps running and the worktree is untouched. + +The point of refusing rather than cleaning up is that the session stays as the +operator's handle on their own unfinished work. A session that is killed while +its worktree still matters leaves a directory nobody is looking at. + +A consequence, accepted deliberately: a feature branch almost always has unpushed +commits, so `--force` is the ordinary way to end a session, not an exception. +That is only safe because force retains. + +### `--force` + +`--force` overrides the refusal and nothing else. It ends the session and +**retains** the worktree, which enters the retained store with reason +`force-killed`. Force never removes a tree that would have blocked. + +Ending a session and destroying its work are two separate destructive acts. Each +is chosen on its own, and the second one — reclaim — is chosen while looking at +exactly what would be lost. + +### Exiting on its own + +A session that ends by itself — the command finished, the operator typed `exit` — +never reaches the kill path. When the watcher notices the tmux session is gone it +applies the same rule: a pristine worktree is removed, anything else is retained +with reason `exited-with-unsaved-work`. + +Nobody is waiting for an answer here, so there is nothing to refuse; the choice is +only between removing and retaining. It uses the same predicate as the kill, from +the same place, so the two paths cannot drift apart. + +## The retained store + +A `state_dir`-backed record of worktrees the daemon created that no longer have a +session. For each one: the path, the base repository, the branch, why it was +retained, and when. + +It survives a daemon restart. It is the recovery path for everything above — a +store that is lost on restart is worse than no store, because the worktrees are +still on disk with nothing pointing at them. + +Entries leave when the worktree is reclaimed, and when the daemon notices the +directory is gone because somebody removed it by hand. + +**It is not an ownership record.** It must never be consulted to decide whether a +path may be adopted — see the first rule. Its only job is to remember what the +daemon is holding so that it can be listed and reclaimed. + +## Reclaim + +`GET /api/worktrees` lists the store: path, base repository, branch, reason, when, +and what is currently in the way. The in-the-way breakdown is recomputed on read, +not stored, so a tree whose work has since been pushed says so. + +`DELETE /api/worktrees/` reclaims one. It removes the directory and drops the +store entry. It does **not** consult the guard — reclaim is the deliberate +destructive act, and the operator chose it while looking at what was in the way. + +It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming +a tree must not throw away commits on it. This is worth saying out loud in every +client, because "reclaim" otherwise sounds like it takes the commits too. + +Nothing is reclaimed automatically. There is no age-based expiry and no bulk +reclaim. From 132b606830bf2c03a014c86190b67ead7270d46c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:35:26 +0100 Subject: [PATCH 45/71] feat(maestro): retained-worktree store in state_dir (#78) Per-entry JSON files under /retained-worktrees/, written like sidecar records: atomic tmp+rename, missing/malformed files tolerated on read. Entries carry path, base repo, branch, reason (force-killed, exited-with-unsaved-work), timestamp; they leave on reclaim or when prune_missing notices the directory was removed by hand. --- crates/maestro-daemon/src/config.rs | 5 + crates/maestro-daemon/src/lib.rs | 1 + crates/maestro-daemon/src/retained_store.rs | 319 ++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 crates/maestro-daemon/src/retained_store.rs diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index 4e8684d..31bf2c5 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -103,6 +103,11 @@ impl DaemonConfig { pub fn spawn_history_path(&self) -> PathBuf { self.state_dir.join("spawn-history.jsonl") } + + /// Retained-worktree store directory (#78): `/retained-worktrees/`. + pub fn retained_worktree_dir(&self) -> PathBuf { + self.state_dir.join("retained-worktrees") + } } /// Env-var overrides applied by [`load_config`]. Tests pass explicit diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 68e110c..35e623b 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -19,6 +19,7 @@ pub mod panes; pub mod proc; pub mod projects; pub mod pty; +pub mod retained_store; pub mod session; pub mod sidecar; pub mod spawn_history; diff --git a/crates/maestro-daemon/src/retained_store.rs b/crates/maestro-daemon/src/retained_store.rs new file mode 100644 index 0000000..064ba15 --- /dev/null +++ b/crates/maestro-daemon/src/retained_store.rs @@ -0,0 +1,319 @@ +//! Store of retained worktrees (#78): worktrees the daemon created whose +//! session is gone. +//! +//! One JSON file per entry under `/retained-worktrees/`, written +//! the way `sidecar.rs` writes session records — atomic tmp+rename replace, +//! missing or malformed files tolerated on read. The store survives a daemon +//! restart; it is the recovery path for everything else in the worktree +//! lifecycle. +//! +//! It is not an ownership record: it must never be consulted to decide whether +//! a path may be adopted. Its only job is to remember what the daemon is +//! holding so it can be listed and reclaimed. + +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum RetainedStoreError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("json: {0}")] + 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 id: Uuid, + pub path: PathBuf, + pub base_repo: PathBuf, + pub branch: String, + pub reason: RetainedReason, + pub retained_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct RetainedStore { + dir: PathBuf, +} + +impl RetainedStore { + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + fn path_for(&self, id: Uuid) -> PathBuf { + self.dir.join(format!("{id}.json")) + } + + /// Record a retained worktree and return the entry that was written. + pub fn retain( + &self, + path: &Path, + base_repo: &Path, + branch: &str, + reason: RetainedReason, + ) -> Result { + let entry = RetainedWorktree { + id: Uuid::new_v4(), + path: path.to_path_buf(), + base_repo: base_repo.to_path_buf(), + branch: branch.to_string(), + reason, + retained_at: Utc::now(), + }; + self.overwrite(&entry)?; + Ok(entry) + } + + /// Atomically write `entry`. Creates the directory if it does not exist. + pub fn overwrite(&self, entry: &RetainedWorktree) -> 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)); + let json = serde_json::to_vec_pretty(entry)?; + std::fs::write(&tmp, json)?; + std::fs::rename(&tmp, &final_path)?; + Ok(()) + } + + /// Drop one entry. No-op when the id is unknown — reclaiming an entry the + /// store already lost (e.g. pruned) must not fail. + pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> { + match std::fs::remove_file(self.path_for(id)) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } + } + + /// 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> { + let mut out = Vec::new(); + let entries = match std::fs::read_dir(&self.dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out), + Err(e) => return Err(e.into()), + }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + if let Ok(worktree) = serde_json::from_slice::(&bytes) { + out.push(worktree); + } + } + out.sort_by_key(|w| w.retained_at); + Ok(out) + } + + /// Drop entries whose worktree directory no longer exists — somebody + /// removed it by hand. Returns the ids that were pruned. + pub fn prune_missing(&self) -> Result, RetainedStoreError> { + let mut pruned = Vec::new(); + for entry in self.load_all()? { + if !entry.path.exists() { + self.remove(entry.id)?; + pruned.push(entry.id); + } + } + Ok(pruned) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + use std::path::PathBuf; + + fn tmp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("tempdir") + } + + fn sample_path(root: &std::path::Path, name: &str) -> PathBuf { + let p = root.join(name); + std::fs::create_dir_all(&p).expect("mkdir"); + p + } + + #[test] + fn retain_then_load_round_trips_every_field() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let wt = sample_path(dir.path(), "wt"); + let repo = sample_path(dir.path(), "repo"); + + let entry = store + .retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled) + .expect("retain"); + + let all = store.load_all().expect("load_all"); + assert_eq!(all, vec![entry.clone()]); + assert_eq!(entry.path, wt); + assert_eq!(entry.base_repo, repo); + assert_eq!(entry.branch, "feature/login"); + assert_eq!(entry.reason, RetainedReason::ForceKilled); + let age = Utc::now() - entry.retained_at; + assert!(age >= Duration::zero() && age < Duration::minutes(1)); + } + + #[test] + fn reason_serializes_kebab_case() { + let json = + serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize"); + assert_eq!(json, r#""exited-with-unsaved-work""#); + } + + #[test] + fn entries_survive_a_restart() { + let dir = tmp_dir(); + let wt = sample_path(dir.path(), "wt"); + + let first = RetainedStore::new(dir.path()); + let entry = first + .retain(&wt, dir.path(), "main", RetainedReason::ForceKilled) + .expect("retain"); + + // A fresh store over the same state_dir — what a daemon restart sees. + let second = RetainedStore::new(dir.path()); + assert_eq!(second.load_all().expect("load_all"), vec![entry]); + } + + #[test] + fn missing_store_reads_as_empty() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path().join("does-not-exist")); + assert_eq!(store.load_all().expect("load_all"), Vec::new()); + } + + #[test] + fn malformed_entries_are_skipped_silently() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let wt = sample_path(dir.path(), "wt"); + store + .retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled) + .expect("retain"); + std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk"); + + let all = store.load_all().expect("load_all"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].branch, "kept"); + } + + #[test] + fn ids_are_unique() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let a = store + .retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled) + .expect("retain"); + let b = store + .retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled) + .expect("retain"); + assert_ne!(a.id, b.id); + } + + #[test] + fn remove_drops_the_entry() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let wt = sample_path(dir.path(), "wt"); + let entry = store + .retain(&wt, dir.path(), "main", RetainedReason::ForceKilled) + .expect("retain"); + + store.remove(entry.id).expect("remove"); + assert_eq!(store.load_all().expect("load_all"), Vec::new()); + + // Removing an unknown id is a no-op, not an error. + store.remove(entry.id).expect("remove again"); + } + + #[test] + fn load_all_is_sorted_by_time() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let mut first = store + .retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled) + .expect("retain"); + first.retained_at -= Duration::hours(1); + store.overwrite(&first).expect("rewrite"); + + let second = store + .retain( + dir.path(), + dir.path(), + "second", + RetainedReason::ForceKilled, + ) + .expect("retain"); + + let branches: Vec<_> = store + .load_all() + .expect("load_all") + .into_iter() + .map(|e| e.branch) + .collect(); + assert_eq!(branches, vec!["first", "second"]); + let _ = second; + } + + #[test] + fn prune_missing_drops_gone_directories_only() { + let dir = tmp_dir(); + let store = RetainedStore::new(dir.path()); + let alive = sample_path(dir.path(), "alive"); + let gone = sample_path(dir.path(), "gone"); + let keep = store + .retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled) + .expect("retain"); + let drop = store + .retain( + &gone, + dir.path(), + "drop", + RetainedReason::ExitedWithUnsavedWork, + ) + .expect("retain"); + std::fs::remove_dir_all(&gone).expect("rm"); + + let pruned = store.prune_missing().expect("prune_missing"); + assert_eq!(pruned, vec![drop.id]); + + let branches: Vec<_> = store + .load_all() + .expect("load_all") + .into_iter() + .map(|e| e.branch) + .collect(); + assert_eq!(branches, vec!["keep"]); + let _ = keep; + } +} From 0457a61a61cd652a62a89dc94c963d565c356cdc Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:38:32 +0100 Subject: [PATCH 46/71] refactor(maestro): sidestep worktree path collisions with a numeric suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anything occupying /worktrees/ — one of ours, one the operator made, an unrelated directory — is now bypassed by appending -2, -3, ... to the directory; the branch stays as requested. The same suffix applies under the [daemon].worktree_root override. The daemon no longer asks whether an occupant is "ours": delete registered_for(), the adoption branch in run_add and the SessionManager::create live-session check, plus WorktreeError::PathExists. A branch already checked out elsewhere is git's refusal to surface, already marked by the branch picker's is_head/worktree flags. --- crates/maestro-daemon/src/session.rs | 22 +------ crates/maestro-daemon/src/worktree.rs | 85 +++++++------------------ crates/maestro-daemon/tests/worktree.rs | 77 ++++++++++++---------- 3 files changed, 69 insertions(+), 115 deletions(-) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 2472afe..72ae8f9 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -226,25 +226,9 @@ impl SessionManager { let WorktreeSpec { base_repo, branch, .. } = spec; - // `run_add` adopts a leftover tree for this branch — the - // dirty-work guard refuses to delete unsaved work and the path - // is deterministic, so erroring would burn the branch forever. - // A tree a *live* session is using is a different matter: - // two sessions must not share one checkout, so reject it here - // where the session records are visible (#67 review). - let planned = self.inner.worktree_mgr.planned_path(base_repo, branch); - if self - .inner - .sessions - .lock() - .await - .values() - .any(|r| r.worktree_path.as_deref() == Some(planned.as_path())) - { - return Err(SessionError::Worktree( - crate::worktree::WorktreeError::PathExists { path: planned }, - )); - } + // A path collision is sidestepped inside `run_add` (#77), and + // a branch already checked out elsewhere is git's refusal, + // not ours. let path = if spec.create_branch { self.inner.worktree_mgr.add_new(base_repo, branch) } else { diff --git a/crates/maestro-daemon/src/worktree.rs b/crates/maestro-daemon/src/worktree.rs index 1421260..d698606 100644 --- a/crates/maestro-daemon/src/worktree.rs +++ b/crates/maestro-daemon/src/worktree.rs @@ -10,10 +10,6 @@ pub enum WorktreeError { Spawn(#[from] std::io::Error), #[error("git exited with code {code}: {stderr}")] NonZero { code: i32, stderr: String }, - /// A worktree already occupies the target path. The caller decides what - /// to do; the daemon never relocates to a different directory (#56). - #[error("worktree path already exists: {}", .path.display())] - PathExists { path: PathBuf }, /// The worktree holds unsaved work; removal was refused (#56). #[error("left {} on disk: {}", .path.display(), .reason)] Guarded { path: PathBuf, reason: String }, @@ -39,9 +35,9 @@ impl WorktreeManager { } /// Where a worktree for `branch` will be created: the override root, or - /// `
/worktrees/` by default. The path is - /// predictable (no UUID suffix), so an existing directory at the target - /// is an error rather than a silently different location. + /// `
/worktrees/` by default. If the path + /// is occupied, [`Self::add_new`] / [`Self::add_existing`] sidestep to + /// `-2`, `-3`, ... instead. /// /// `base_repo` is whatever the client picked, which the project list makes /// easy to point at a *linked worktree* — placing the new tree inside it @@ -51,14 +47,7 @@ impl WorktreeManager { /// uncommitted work (#54 review). Always place against the main checkout. /// /// The override root gets the repo's basename too: without it two repos - /// spawning the same branch name target one directory and the second - /// fails with `PathExists`. - /// Where [`Self::add_new`] / [`Self::add_existing`] would place this - /// branch's worktree. Callers use it to check ownership before creating. - pub fn planned_path(&self, base_repo: &Path, branch: &str) -> PathBuf { - self.target_path(base_repo, branch) - } - + /// spawning the same branch name target one directory. fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf { // An unusable branch name falls back to a digest rather than escaping // the root; `run_add` surfaces git's own error for it anyway. @@ -80,6 +69,14 @@ impl WorktreeManager { } } + /// [`Self::target_path`] with `-n` appended to the final component, the + /// collision suffix (#77). + fn target_path_suffixed(&self, base_repo: &Path, branch: &str, n: u32) -> PathBuf { + let mut name = self.target_path(base_repo, branch).into_os_string(); + name.push(format!("-{n}")); + PathBuf::from(name) + } + /// `git -C worktree add -b ` — creates the /// branch. Returns the path of the new worktree on success. pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result { @@ -98,22 +95,18 @@ impl WorktreeManager { branch: &str, create_branch: bool, ) -> Result { - let path = self.target_path(base_repo, branch); - // Clear registrations whose directory is gone before testing the - // path, so a previously-leaked entry doesn't block this branch. + // Clear registrations whose directory is gone first, so a + // previously-leaked entry doesn't push this branch to a suffix. prune(base_repo)?; - if path.symlink_metadata().is_ok() { - // A tree we left behind for this very branch — the dirty-work - // guard refuses to delete unsaved work, and the path is - // deterministic, so erroring here would burn the branch - // permanently: no spawn could ever produce a worktree for it - // again and the product offers no way to clear it. Adopt it - // instead; landing in the operator's own unsaved work on the - // branch they asked for is what they wanted (#67 review). - if registered_for(base_repo, &path, branch) { - return Ok(path); - } - return Err(WorktreeError::PathExists { path }); + // Anything occupying the target — a tree we left behind, one the + // operator made, an unrelated directory — is sidestepped with a + // numeric suffix on the directory; the branch stays whatever was + // requested (#77). The daemon never adopts what it finds. + let mut path = self.target_path(base_repo, branch); + let mut n = 1; + while path.symlink_metadata().is_ok() { + n += 1; + path = self.target_path_suffixed(base_repo, branch, n); } // The parent (`/worktrees` or the override root) may not exist // yet; the target itself must not. @@ -192,34 +185,6 @@ fn main_checkout(path: &Path) -> PathBuf { .unwrap_or_else(|| path.to_path_buf()) } -/// True when `path` is already a registered worktree of `base_repo` holding -/// `branch` — i.e. one this daemon created for this branch and later declined -/// to remove. -fn registered_for(base_repo: &Path, path: &Path, branch: &str) -> bool { - let Ok(out) = Command::new("git") - .arg("-C") - .arg(base_repo) - .args(["worktree", "list", "--porcelain"]) - .output() - else { - return false; - }; - let want = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - let text = String::from_utf8_lossy(&out.stdout); - let mut here = false; - for line in text.lines() { - if let Some(p) = line.strip_prefix("worktree ") { - let p = PathBuf::from(p); - here = p.canonicalize().unwrap_or(p) == want; - } else if here { - if let Some(r) = line.strip_prefix("branch ") { - return r.trim() == format!("refs/heads/{branch}"); - } - } - } - false -} - /// `Some(inner)` when a registered worktree lives under `path`. Git omits /// those directories from `git status`, so the dirty-work guard cannot see /// them and removal would delete them (#54 review). @@ -268,9 +233,7 @@ fn short_hash(s: &str) -> String { /// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example` /// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the /// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x` -/// and `feat-x` share one directory, and with deterministic paths plus -/// `PathExists` as a hard error the losers could never get a worktree at all -/// (#54 review). +/// and `feat-x` share one directory (#54 review). /// /// Git already forbids holding both `feat` and `feat/x`, so the directory /// collision that nesting could cause cannot arise from valid refs. Only diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs index 0c59bc9..36615a5 100644 --- a/crates/maestro-daemon/tests/worktree.rs +++ b/crates/maestro-daemon/tests/worktree.rs @@ -95,28 +95,26 @@ fn worktree_root_override_redirects_placement() { assert!(path.exists()); } +/// Anything at all occupying the target path — ours, the operator's, or an +/// unrelated directory — is sidestepped, not adopted and not reported (#77). #[test] -fn existing_directory_at_target_path_is_an_error_carrying_the_path() { +fn an_occupied_target_path_gets_a_numeric_suffix() { let repo_dir = TempDir::new().unwrap(); let repo = repo_dir.path().to_path_buf(); init_repo(&repo); - // A predictable path means collisions are possible; they must be - // reported, never silently relocated. - std::fs::create_dir_all(repo.join("worktrees").join("feat").join("collide")).unwrap(); + // A non-git directory sits exactly where the worktree would go. + let squatter = repo.join("worktrees").join("feat").join("collide"); + std::fs::create_dir_all(&squatter).unwrap(); + std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap(); let mgr = WorktreeManager::new(None); - let err = mgr.add_new(&repo, "feat/collide").unwrap_err(); - let msg = format!("{err}"); + let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps"); + assert_eq!(path, repo.join("worktrees").join("feat").join("collide-2")); + assert!(path.exists()); assert!( - msg.contains( - repo.join("worktrees") - .join("feat") - .join("collide") - .to_str() - .unwrap() - ), - "{msg}" + squatter.join("operator-notes.txt").exists(), + "the occupant is untouched" ); } @@ -381,9 +379,8 @@ fn override_root_keeps_repos_apart() { assert_ne!(a, b, "same branch name in two repos must not collide"); } -/// Flattening `/` and `.` to `-` made these three share one directory, and a -/// deterministic path plus a hard `PathExists` meant the losers could never -/// get a worktree (#54 review). +/// Flattening `/` and `.` to `-` made these three share one directory, so the +/// losers could never get a worktree (#54 review). #[test] fn similar_branch_names_get_distinct_directories() { let repo_dir = TempDir::new().unwrap(); @@ -400,12 +397,11 @@ fn similar_branch_names_get_distinct_directories() { assert!(a.ends_with("feat/x"), "{a:?}"); } -/// The dead end the cleanup guard created: it refuses to delete unsaved work, -/// the path is deterministic, and `PathExists` is a hard error — so one stray -/// untracked file used to burn that branch permanently, with no way to clear -/// it from the product. The leftover is adopted instead (#67 review). +/// A guarded leftover stays on disk untouched, and is never adopted (#77). +/// Re-spawning its branch is refused by git itself — the branch is still +/// checked out inside the leftover — which the caller surfaces. #[test] -fn a_guarded_leftover_is_adopted_rather_than_burning_the_branch() { +fn a_guarded_leftover_is_never_adopted() { let repo_dir = TempDir::new().unwrap(); let repo = repo_dir.path().to_path_buf(); init_repo(&repo); @@ -417,28 +413,39 @@ fn a_guarded_leftover_is_adopted_rather_than_burning_the_branch() { // The guard refuses, leaving it on disk. mgr.remove(&repo, &first) .expect_err("dirty tree is guarded"); - assert!(first.exists()); - // Spawning that branch again lands in the operator's own work, not an error. - let again = mgr + let err = mgr .add_existing(&repo, "feat/resume") - .expect("leftover is adopted"); - assert_eq!(again, first); + .expect_err("a branch already checked out cannot be spawned twice"); + assert!(format!("{err}").contains("git exited"), "{err}"); assert!( - again.join("notes.txt").exists(), - "their work is still there" + first.join("notes.txt").exists(), + "the leftover's unsaved work is untouched" ); } -/// Adoption must not extend to a directory that merely sits in the way. +/// The `worktree_root` override gets the same collision suffix (#77). #[test] -fn an_unrelated_directory_at_the_target_path_is_still_an_error() { +fn the_override_root_suffixes_collisions_too() { let repo_dir = TempDir::new().unwrap(); let repo = repo_dir.path().to_path_buf(); init_repo(&repo); - std::fs::create_dir_all(repo.join("worktrees").join("feat").join("squatter")).unwrap(); - let mgr = WorktreeManager::new(None); - mgr.add_new(&repo, "feat/squatter") - .expect_err("a non-worktree directory must not be adopted"); + let wt_root = TempDir::new().unwrap(); + let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf())); + + // First add settles `/-/` so the squatter + // can be created at the exact target. + mgr.add_new(&repo, "feat/first").expect("add first"); + let repo_dir_in_root = std::fs::read_dir(wt_root.path()) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + let squatter = repo_dir_in_root.join("feat").join("collide"); + std::fs::create_dir_all(&squatter).unwrap(); + + let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps"); + assert_eq!(path, squatter.with_file_name("collide-2"), "{path:?}"); } From cc8af94254d01c0a59adea0e5afd187820e19f08 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:48:12 +0100 Subject: [PATCH 47/71] 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}"); From 44b9745e8c31e0a02d87ff6feea656b9c745daa0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:51:52 +0100 Subject: [PATCH 48/71] feat(maestro): read-side in-the-way probe and guard-free reclaim --- crates/maestro-daemon/src/lib.rs | 1 + crates/maestro-daemon/src/worktree_probe.rs | 316 ++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 crates/maestro-daemon/src/worktree_probe.rs diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 35e623b..8f85980 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -25,6 +25,7 @@ pub mod sidecar; pub mod spawn_history; pub mod tmux; pub mod worktree; +pub mod worktree_probe; pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides}; pub use session::{SessionError, SessionManager}; diff --git a/crates/maestro-daemon/src/worktree_probe.rs b/crates/maestro-daemon/src/worktree_probe.rs new file mode 100644 index 0000000..c7cb8d6 --- /dev/null +++ b/crates/maestro-daemon/src/worktree_probe.rs @@ -0,0 +1,316 @@ +//! Read-side git probes for retained worktrees (#80): what is currently in +//! the way, and guard-free reclaim. +//! +//! The kill-refusal breakdown lives in `worktree.rs`, which this module must +//! not touch (#77 owns it) and must not consult for reclaim — reclaim is the +//! deliberate destructive act and never asks the guard. Issue #79 will factor +//! the shared git plumbing out of `worktree.rs`; until then these probes are +//! duplicated here on purpose. + +use std::path::Path; + +use maestro_protocol::InTheWay; + +#[derive(Debug, thiserror::Error)] +pub enum ProbeError { + #[error("failed to spawn git: {0}")] + Spawn(#[from] std::io::Error), + #[error("git exited with code {code}: {stderr}")] + NonZero { code: i32, stderr: String }, +} + +/// What a kill refusal would report about the worktree at `path`, right now. +/// +/// Ignored files never count (`--ignored=no`) — every Rust worktree has a +/// `target/`, and counting those would put every tree permanently in the way. +/// A directory that no longer exists reports nothing in the way: there is +/// nothing left to lose, and the store's own pruning drops the entry. +pub fn in_the_way(path: &Path, base_repo: &Path, branch: &str) -> Result { + let mut breakdown = InTheWay::default(); + if !path.exists() { + return Ok(breakdown); + } + let status = run_git( + path, + [ + "status", + "--porcelain", + "--ignored=no", + "--untracked-files=normal", + ], + )?; + for line in String::from_utf8_lossy(&status.stdout).lines() { + if line.starts_with("??") { + breakdown.untracked_files += 1; + } else if !line.is_empty() { + breakdown.uncommitted_files += 1; + } + } + // Commits reachable from the branch but from no remote-tracking ref. A + // branch pushed anywhere counts as pushed; there is no "which remote" + // question to get wrong. + let unpushed = run_git( + base_repo, + [ + "rev-list", + "--count", + &format!("refs/heads/{branch}"), + "--not", + "--glob=refs/remotes", + ], + )?; + breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout) + .trim() + .parse() + .unwrap_or(0); + Ok(breakdown) +} + +/// Remove a retained worktree's directory without consulting any guard, per +/// the reclaim contract: the operator chose this while looking at what was in +/// the way. `git worktree remove` keeps `refs/heads/`, so commits on +/// the branch survive even when they were never pushed. +/// +/// A directory that is already gone only leaves a stale registration behind; +/// `worktree prune` clears it so the branch can be spawned again. +pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> { + if !path.exists() { + return run_git(base_repo, ["worktree", "prune"]).map(|_| ()); + } + run_git( + base_repo, + [ + "worktree", + "remove", + "--force", + path.to_str().expect("worktree path is valid utf-8"), + ], + ) + .map(|_| ()) +} + +fn run_git(dir: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut cmd = std::process::Command::new("git"); + cmd.arg("-C").arg(dir).args(args); + let out = cmd.output()?; + if !out.status.success() { + return Err(ProbeError::NonZero { + code: out.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::process::Command; + + struct Repo { + root: tempfile::TempDir, + repo: PathBuf, + } + + fn git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .expect("git spawn"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + + /// A repo with a remote, one commit on `main`, and a linked worktree for + /// branch `feature` at `/worktrees/feature`. + fn repo_with_worktree() -> Repo { + let root = tempfile::tempdir().expect("tempdir"); + let repo = root.path().join("repo"); + let remote = root.path().join("remote.git"); + std::fs::create_dir_all(&repo).unwrap(); + std::fs::create_dir_all(&remote).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + git(&repo, &["config", "user.email", "t@t"]); + git(&repo, &["config", "user.name", "T"]); + std::fs::write(repo.join("README"), b"hello\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + git(&remote, &["init", "-q", "--bare"]); + git(&repo, &["remote", "add", "origin", &path_str(&remote)]); + git(&repo, &["push", "-q", "-u", "origin", "main"]); + git( + &repo, + &[ + "worktree", + "add", + "-q", + "-b", + "feature", + "worktrees/feature", + ], + ); + Repo { root, repo } + } + + fn path_str(path: &Path) -> String { + path.to_string_lossy().into_owned() + } + + impl Repo { + fn wt(&self) -> PathBuf { + self.repo.join("worktrees").join("feature") + } + + fn commit_in_wt(&self, msg: &str) { + std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap(); + git(&self.wt(), &["add", "-A"]); + git(&self.wt(), &["commit", "-qm", msg]); + } + } + + #[test] + fn a_clean_pushed_worktree_has_nothing_in_the_way() { + let r = repo_with_worktree(); + git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]); + let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(breakdown, InTheWay::default()); + assert!(breakdown.is_clear()); + } + + #[test] + fn uncommitted_untracked_and_unpushed_are_counted_separately() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("README"), b"edited\n").unwrap(); + std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap(); + + let dirty = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(dirty.uncommitted_files, 1); + assert_eq!(dirty.untracked_files, 1); + assert_eq!(dirty.unpushed_commits, 0); + assert!(!dirty.is_clear()); + + // Committing clears exactly one dimension and creates another. + git(&r.wt(), &["add", "README"]); + git(&r.wt(), &["commit", "-qm", "wip"]); + + let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(breakdown.uncommitted_files, 0); + assert_eq!(breakdown.untracked_files, 1); + assert_eq!(breakdown.unpushed_commits, 1); + } + + #[test] + fn tracked_edits_count_as_uncommitted() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("README"), b"edited\n").unwrap(); + let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(breakdown.uncommitted_files, 1); + assert_eq!(breakdown.untracked_files, 0); + } + + #[test] + fn pushing_clears_unpushed_but_keeps_other_entries() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap(); + r.commit_in_wt("wip"); + + let before = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(before.unpushed_commits, 1); + + git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]); + // Same stored entry, different tree: the breakdown must move with it. + let after = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(after.unpushed_commits, 0); + assert_eq!(after.untracked_files, 0); + + std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap(); + let dirty = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!(dirty.untracked_files, 1); + } + + #[test] + fn ignored_files_never_count() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap(); + std::fs::create_dir_all(r.wt().join("target")).unwrap(); + std::fs::write(r.wt().join("target/artifact"), b"x").unwrap(); + + let breakdown = in_the_way(&r.wt(), &r.repo, "feature").expect("probe"); + assert_eq!( + breakdown, + InTheWay { + untracked_files: 1, + ..InTheWay::default() + }, + "the .gitignore itself is untracked; target/ is not" + ); + } + + #[test] + fn a_missing_directory_reports_nothing_in_the_way() { + let r = repo_with_worktree(); + let gone = r.root.path().join("nowhere"); + let breakdown = in_the_way(&gone, &r.repo, "feature").expect("probe"); + assert!(breakdown.is_clear()); + } + + #[test] + fn reclaim_removes_the_directory_and_prunes_a_gone_one() { + let r = repo_with_worktree(); + r.commit_in_wt("precious"); + reclaim(&r.repo, &r.wt()).expect("reclaim"); + assert!(!r.wt().exists()); + + // A second reclaim over the now-missing path just prunes. + reclaim(&r.repo, &r.wt()).expect("reclaim again"); + } + + #[test] + fn reclaim_keeps_the_branch_and_its_commits() { + let r = repo_with_worktree(); + r.commit_in_wt("precious"); + let sha = { + let out = Command::new("git") + .arg("-C") + .arg(r.wt()) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + + reclaim(&r.repo, &r.wt()).expect("reclaim"); + + let out = Command::new("git") + .arg("-C") + .arg(&r.repo) + .args(["rev-parse", "--verify", "refs/heads/feature"]) + .output() + .unwrap(); + assert!( + out.status.success(), + "branch must survive reclaim: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha); + } + + #[test] + fn reclaim_is_guard_free_on_a_dirty_tree() { + let r = repo_with_worktree(); + std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap(); + std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap(); + reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard"); + assert!(!r.wt().exists()); + } +} From 898c3cc763d532e824d18d6ed54bf147fb66b71f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:59:08 +0100 Subject: [PATCH 49/71] feat(maestro): shared unsaved-work probe for worktrees (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One function, unsaved_work(), returns the structured breakdown — uncommitted tracked changes, untracked non-ignored files, unpushed commits — so the kill guard and the natural-exit classifier (#82) answer from the same place. Ignored files never count: every Rust checkout has a target/, and counting those would leave no session killable. WorktreeManager::remove drops its own dirty-tree guard; git itself still refuses a dirty tree, and the real guard moves onto the kill. --- crates/maestro-daemon/src/worktree.rs | 83 ++++++++----- crates/maestro-daemon/tests/common/mod.rs | 32 +++++ crates/maestro-daemon/tests/worktree.rs | 136 ++++++++++++++++++++-- crates/maestro-protocol/src/lib.rs | 62 ++++++++++ 4 files changed, 274 insertions(+), 39 deletions(-) diff --git a/crates/maestro-daemon/src/worktree.rs b/crates/maestro-daemon/src/worktree.rs index d698606..d68640e 100644 --- a/crates/maestro-daemon/src/worktree.rs +++ b/crates/maestro-daemon/src/worktree.rs @@ -10,7 +10,9 @@ pub enum WorktreeError { Spawn(#[from] std::io::Error), #[error("git exited with code {code}: {stderr}")] NonZero { code: i32, stderr: String }, - /// The worktree holds unsaved work; removal was refused (#56). + /// A registered worktree lives under the tree about to be removed; git + /// hides its contents from `git status`, so removal would delete its + /// uncommitted work unseen (#54 review). #[error("left {} on disk: {}", .path.display(), .reason)] Guarded { path: PathBuf, reason: String }, } @@ -126,12 +128,13 @@ impl WorktreeManager { Ok(path) } - /// `git -C worktree remove ` — but only when the - /// worktree holds nothing unique (#56). A worktree with uncommitted - /// changes, or with commits reachable from no other ref, is refused and - /// left on disk so the operator can rescue it; cleanup is unconditional, - /// which makes this guard the only thing standing between a stray kill - /// and destroyed work. + /// `git -C worktree remove `. + /// + /// Unconditional (#79): the guard moved off the worktree and onto the + /// kill, so by the time removal runs the caller has already chosen to + /// destroy the tree — the kill only gets here after + /// [`unsaved_work`] came back empty. Still refused for a tree holding a + /// nested registered worktree, which `git status` cannot see. pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> { // Gone from disk already (operator `rm -rf`, or a create that failed // half-way). Probing it with git would fail and be misread as unsaved @@ -147,12 +150,6 @@ impl WorktreeManager { reason: format!("holds a nested worktree at {}", inner.display()), }); } - if let Some(reason) = unsafe_to_remove(path)? { - return Err(WorktreeError::Guarded { - path: path.to_path_buf(), - reason, - }); - } let out = Command::new("git") .arg("-C") .arg(base_repo) @@ -251,22 +248,49 @@ fn branch_relpath(branch: &str) -> Option { Some(candidate.to_path_buf()) } -/// `Some(reason)` when deleting `path` would destroy work. +/// The unsaved-work probe (#79): what would be lost if `path` were deleted. /// -/// Only an unclean working tree qualifies. `git worktree remove` deletes the -/// checkout but keeps `refs/heads/`, so committed work — pushed or -/// not — stays reachable and is never at risk. Guarding on "commits on no -/// other ref" instead would refuse every ordinary unpushed feature branch, -/// and because the path is deterministic that branch could then never be -/// spawned again (#56 review). -fn unsafe_to_remove(path: &Path) -> Result, WorktreeError> { - // `--ignored=no` keeps build output out of it; untracked sources count, - // since removal would delete them. - let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?; - if !status.stdout.is_empty() { - return Ok(Some("has uncommitted changes".into())); +/// One function, three answers, shared by every caller that must make the +/// same decision — the kill guard refuses on any non-empty result and the +/// natural-exit classifier (#82) retains on it — so the two paths cannot +/// drift. A worktree whose directory is already gone holds nothing to save. +pub fn unsaved_work(path: &Path) -> Result { + if std::fs::symlink_metadata(path).is_err() { + return Ok(maestro_protocol::UnsavedWork::default()); } - Ok(None) + // `--ignored=no` keeps build output out of it; ignored files never block + // (#79), or no Rust checkout would ever be killable. + let out = run_git(path, &["status", "--porcelain", "--ignored=no"])?; + let status = String::from_utf8_lossy(&out.stdout); + let mut dirty = 0u32; + let mut untracked = 0u32; + for line in status.lines() { + if line.starts_with("??") { + untracked += 1; + } else { + dirty += 1; + } + } + + // Detached HEAD has no branch; the tip itself still works as a starting + // point for the reachability query below. + let head = run_git(path, &["rev-parse", "--abbrev-ref", "HEAD"])?; + let branch = String::from_utf8_lossy(&head.stdout).trim().to_string(); + let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch }; + let count = run_git(path, &["rev-list", "--count", tip, "--not", "--remotes"])?; + let unpushed: u32 = String::from_utf8_lossy(&count.stdout) + .trim() + .parse() + .map_err(|e| WorktreeError::NonZero { + code: 0, + stderr: format!("unparseable rev-list count: {e}"), + })?; + + Ok(maestro_protocol::UnsavedWork { + dirty_tracked_files: dirty, + untracked_files: untracked, + unpushed_commits: unpushed, + }) } /// `git -C worktree prune` — drops registrations whose directory @@ -280,10 +304,7 @@ fn prune(base_repo: &Path) -> Result<(), WorktreeError> { check(out) } -fn run_git( - dir: &Path, - args: impl IntoIterator, -) -> Result { +fn run_git(dir: &Path, args: &[&str]) -> Result { let mut cmd = Command::new("git"); cmd.arg("-C").arg(dir).args(args); let out = cmd.output()?; diff --git a/crates/maestro-daemon/tests/common/mod.rs b/crates/maestro-daemon/tests/common/mod.rs index cf077cf..fc382d7 100644 --- a/crates/maestro-daemon/tests/common/mod.rs +++ b/crates/maestro-daemon/tests/common/mod.rs @@ -93,6 +93,38 @@ fn run(cmd: &mut Command) { ); } +/// An empty bare repository at `dir`, usable as a push target (#79). +pub fn init_bare_repo(dir: &Path) { + run(Command::new("git") + .arg("init") + .arg("-q") + .arg("--bare") + .arg(dir.to_str().expect("non-utf8 bare repo path"))); +} + +/// Push every branch of `repo` to `origin`, creating remote-tracking refs. +/// +/// The unsaved-work probe (#79) counts commits reachable from no +/// remote-tracking ref as unpushed, so tests that want a killable worktree +/// must give its branch one. Safe to call again: re-adding an existing +/// remote is ignored. +pub fn push_all_to_origin(repo: &Path, origin: &Path) { + let _ = Command::new("git") + .arg("-C") + .arg(repo) + .args([ + "remote", + "add", + "origin", + origin.to_str().expect("non-utf8 origin path"), + ]) + .status(); + run(Command::new("git") + .arg("-C") + .arg(repo) + .args(["push", "-q", "--all", "origin"])); +} + /// Async polling sibling of [`wait_for`] for tokio tests. pub async fn wait_for_async(timeout: Duration, mut cond: F) where diff --git a/crates/maestro-daemon/tests/worktree.rs b/crates/maestro-daemon/tests/worktree.rs index 36615a5..5dd2967 100644 --- a/crates/maestro-daemon/tests/worktree.rs +++ b/crates/maestro-daemon/tests/worktree.rs @@ -6,8 +6,8 @@ mod common; use std::path::PathBuf; use std::process::Command; -use common::init_repo; -use maestro_daemon::worktree::WorktreeManager; +use common::{init_bare_repo, init_repo, push_all_to_origin}; +use maestro_daemon::worktree::{unsaved_work, WorktreeManager}; use tempfile::TempDir; fn worktree_branches(repo: &PathBuf) -> Vec { @@ -134,6 +134,123 @@ fn add_twice_generates_distinct_paths() { assert!(p2.exists()); } +/// The unsaved-work probe (#79) counts uncommitted tracked changes. +#[test] +fn the_probe_counts_uncommitted_tracked_changes() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/dirty").expect("add"); + std::fs::write(path.join("README"), b"unsaved edits\n").unwrap(); + + let w = unsaved_work(&path).expect("probe"); + assert_eq!(w.dirty_tracked_files, 1); + assert_eq!(w.untracked_files, 0); +} + +/// Untracked sources count; a staged new file is also unsaved tracked work. +#[test] +fn the_probe_counts_untracked_files() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/untracked").expect("add"); + std::fs::write(path.join("notes.txt"), b"wip\n").unwrap(); + std::fs::write(path.join("more.txt"), b"also wip\n").unwrap(); + + let w = unsaved_work(&path).expect("probe"); + assert_eq!(w.untracked_files, 2); + assert_eq!(w.dirty_tracked_files, 0); + + run_git(&path, &["add", "notes.txt"]); + let w = unsaved_work(&path).expect("probe"); + assert_eq!(w.dirty_tracked_files, 1, "staged counts as uncommitted"); + assert_eq!(w.untracked_files, 1); +} + +/// Ignored files NEVER block: every Rust checkout has a `target/`, and +/// counting ignored output would leave no session killable (#79). +#[test] +fn the_probe_ignores_ignored_files() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + // .gitignore must be committed, or it would itself read as untracked. + std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap(); + run_git(&repo, &["add", ".gitignore"]); + run_git(&repo, &["commit", "-q", "-m", "ignore target"]); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/builds").expect("add"); + std::fs::create_dir_all(path.join("target")).unwrap(); + std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap(); + + let w = unsaved_work(&path).expect("probe"); + assert_eq!(w.dirty_tracked_files, 0); + assert_eq!(w.untracked_files, 0); + + // With the branch pushed too, an ignored-only tree reads fully clean. + let origin_dir = TempDir::new().unwrap(); + init_bare_repo(origin_dir.path()); + push_all_to_origin(&repo, origin_dir.path()); + let w = unsaved_work(&path).expect("probe after push"); + assert!(w.is_empty(), "ignored-only worktree must read clean: {w:?}"); +} + +/// Commits reachable from no remote-tracking ref block; pushing the branch +/// clears them. With no remote at all every commit is unreachable from one, +/// which is exactly why feature branches almost always refuse (#79). +#[test] +fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/branch").expect("add"); + + let w = unsaved_work(&path).expect("probe"); + assert!( + w.unpushed_commits >= 1, + "a fresh branch has no remote-tracking ref: {w:?}" + ); + + let origin_dir = TempDir::new().unwrap(); + init_bare_repo(origin_dir.path()); + push_all_to_origin(&repo, origin_dir.path()); + + let w = unsaved_work(&path).expect("probe after push"); + assert_eq!(w.unpushed_commits, 0, "{w:?}"); + assert!(w.is_empty(), "pushed and clean must be empty: {w:?}"); +} + +/// The full breakdown names each blocker with its count. +#[test] +fn the_probe_reports_every_blocker_at_once() { + let repo_dir = TempDir::new().unwrap(); + let repo = repo_dir.path().to_path_buf(); + init_repo(&repo); + + let mgr = WorktreeManager::new(None); + let path = mgr.add_new(&repo, "feat/mixed").expect("add"); + std::fs::write(path.join("README"), b"edited\n").unwrap(); + std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap(); + + let w = unsaved_work(&path).expect("probe"); + assert_eq!(w.dirty_tracked_files, 1); + assert_eq!(w.untracked_files, 1); + assert!(w.unpushed_commits >= 1); + let summary = w.to_string(); + assert!(summary.contains("1 uncommitted file"), "{summary}"); + assert!(summary.contains("1 untracked file"), "{summary}"); + assert!(summary.contains("unpushed commit"), "{summary}"); +} + #[test] fn remove_prunes_worktree() { let repo_dir = TempDir::new().unwrap(); @@ -211,8 +328,14 @@ fn add_existing_checks_out_branch_without_creating_it() { ); } +/// Removal is guarded upstream now (#79): the kill refuses unsaved work +/// before anything is torn down, so by the time `remove` runs the tree is +/// clean. Git still refuses a dirty tree on its own — this asserts that last +/// line of defense — and committed-but-unpushed work does not block removal, +/// because `worktree remove` keeps `refs/heads/` and the commits stay +/// reachable. #[test] -fn remove_refuses_worktree_with_uncommitted_changes() { +fn remove_still_relies_on_git_refusing_a_dirty_tree() { let repo_dir = TempDir::new().unwrap(); let repo = repo_dir.path().to_path_buf(); init_repo(&repo); @@ -225,10 +348,6 @@ fn remove_refuses_worktree_with_uncommitted_changes() { .expect_err("dirty worktree must be refused"); assert!(path.exists(), "guarded worktree must stay on disk"); - // Committing it makes removal safe: `worktree remove` keeps - // `refs/heads/feat/dirty`, so the work is still reachable. Refusing here - // instead would make every unpushed branch a one-shot, since the path is - // deterministic and the leftover tree blocks the next spawn (#56 review). run_git(&path, &["add", "."]); run_git(&path, &["commit", "-q", "-m", "saved"]); @@ -410,7 +529,8 @@ fn a_guarded_leftover_is_never_adopted() { let first = mgr.add_new(&repo, "feat/resume").expect("add"); std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap(); - // The guard refuses, leaving it on disk. + // Removal is refused — git itself refuses a dirty tree — leaving it on + // disk; the daemon-side guard lives on the kill (#79). mgr.remove(&repo, &first) .expect_err("dirty tree is guarded"); diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 77979d8..c79204b 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -143,6 +143,62 @@ pub struct SessionWorktree { pub base_repo: PathBuf, } +// --------------------------------------------------------------------------- +// Unsaved-work breakdown (#79) +// --------------------------------------------------------------------------- + +/// What a worktree holds that would be lost if its directory were destroyed. +/// +/// The kill guard refuses `DELETE /api/sessions/:id` with this breakdown, and +/// natural exits (#82) classify with the same daemon-side probe, so the two +/// paths cannot drift. Ignored files never count: every Rust checkout has a +/// `target/`, and counting those would leave no session killable. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnsavedWork { + /// Tracked files with uncommitted modifications or staged changes. + #[serde(default)] + pub dirty_tracked_files: u32, + /// Untracked files that are not ignored. + #[serde(default)] + pub untracked_files: u32, + /// Commits on the branch reachable from no remote-tracking ref. + #[serde(default)] + pub unpushed_commits: u32, +} + +impl UnsavedWork { + pub fn is_empty(&self) -> bool { + self.dirty_tracked_files == 0 && self.untracked_files == 0 && self.unpushed_commits == 0 + } +} + +impl std::fmt::Display for UnsavedWork { + /// "3 uncommitted files, 2 untracked files, 2 unpushed commits" — zero + /// parts are omitted; all-zero renders "no unsaved work". + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let parts = [ + (self.dirty_tracked_files, "uncommitted file"), + (self.untracked_files, "untracked file"), + (self.unpushed_commits, "unpushed commit"), + ]; + let said: Vec = parts + .into_iter() + .filter(|(n, _)| *n > 0) + .map(|(n, unit)| { + if n == 1 { + format!("1 {unit}") + } else { + format!("{n} {unit}s") + } + }) + .collect(); + if said.is_empty() { + return write!(f, "no unsaved work"); + } + write!(f, "{}", said.join(", ")) + } +} + /// HTTP `POST /api/sessions` body. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CreateSessionRequest { @@ -248,6 +304,10 @@ pub struct BranchInfo { pub struct ErrorInfo { pub code: String, pub message: String, + /// Set only by the kill guard (#79): the unsaved work that blocked + /// `DELETE /api/sessions/:id`, so a client can render counts directly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unsaved_work: Option, } /// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`. @@ -268,6 +328,7 @@ impl ApiError { error: ErrorInfo { code: code.into(), message: message.into(), + unsaved_work: None, }, } } @@ -578,6 +639,7 @@ mod tests { ClientControl::Error(ErrorInfo { code: "bad_frame".into(), message: "decode failed".into(), + unsaved_work: None, }), resize, ] { From 4921f6279654a6affd3c56273710a33fd695bf62 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:59:14 +0100 Subject: [PATCH 50/71] feat(maestro): GET /api/worktrees and DELETE reclaim endpoints --- crates/maestro-daemon/src/cli.rs | 3 + crates/maestro-daemon/src/local_api.rs | 115 ++++++++++ crates/maestro-daemon/src/retained_store.rs | 4 +- crates/maestro-daemon/tests/attach.rs | 1 + crates/maestro-daemon/tests/branches.rs | 1 + crates/maestro-daemon/tests/common/mod.rs | 9 + crates/maestro-daemon/tests/local_api.rs | 232 ++++++++++++++++++++ 7 files changed, 364 insertions(+), 1 deletion(-) diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index 9aafcf6..c3781a5 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -85,6 +85,9 @@ async fn run(config_override: Option) -> Result<(), Box> { config.home_dir.clone(), config.spawn_presets.clone(), projects, + std::sync::Arc::new(crate::retained_store::RetainedStore::new( + config.retained_worktree_dir(), + )), )); let listener = TcpListener::bind(config.bind_addr).await?; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 69a4494..a7c0bfb 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -38,6 +38,9 @@ pub struct LocalApiState { pub presets: Arc>, /// Project discovery (#54) backing `GET /api/projects`. pub projects: Arc, + /// Retained worktrees (#78) backing `GET /api/worktrees` and their + /// reclaim endpoint (#80). + pub retained: Arc, } pub fn router(state: LocalApiState) -> Router { @@ -50,6 +53,8 @@ pub fn router(state: LocalApiState) -> Router { .route(routes::GITROOT, get(git_root)) .route(routes::BRANCHES, get(crate::branches::handler)) .route(routes::NAMES, get(names)) + .route(routes::WORKTREES, get(list_worktrees)) + .route(routes::patterns::WORKTREE, delete(reclaim_worktree)) .route(routes::INFO, get(daemon_info)) .route(routes::EVENTS, any(events_ws)) .route(routes::patterns::ATTACH, any(attach_ws)) @@ -182,6 +187,114 @@ async fn kill_session(State(s): State, Path(id): Path) -> R } } +/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way +/// breakdown recomputed at read time — never stored, so a tree whose work has +/// since been committed or pushed says so. Entries whose directory somebody +/// removed by hand are pruned on the way through. +async fn list_worktrees(State(s): State) -> Response { + let retained = s.retained.clone(); + let listed = tokio::task::spawn_blocking(move || { + retained.prune_missing()?; + retained + .load_all()? + .iter() + .map(|entry| { + // A tree git cannot read (permissions, corrupt checkout) + // still gets listed; it just reports nothing in the way. + let in_the_way = + crate::worktree_probe::in_the_way(&entry.path, &entry.base_repo, &entry.branch) + .unwrap_or_default(); + Ok(maestro_protocol::RetainedWorktree { + id: entry.id, + path: entry.path.clone(), + base_repo: entry.base_repo.clone(), + branch: entry.branch.clone(), + reason: entry.reason, + retained_at: entry.retained_at, + in_the_way, + }) + }) + .collect::, crate::retained_store::RetainedStoreError>>() + }) + .await; + match listed { + Ok(Ok(worktrees)) => Json(worktrees).into_response(), + Ok(Err(e)) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "store_failed", + &e.to_string(), + ), + Err(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "join_failed", + &e.to_string(), + ), + } +} + +/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes +/// the directory and drops the store entry, without consulting the guard — +/// reclaim is the deliberate destructive act, chosen while looking at what is +/// in the way. The branch itself is never deleted; commits on it survive. +async fn reclaim_worktree(State(s): State, Path(id): Path) -> Response { + let found = { + let retained = s.retained.clone(); + tokio::task::spawn_blocking(move || -> Result< + Option, + crate::retained_store::RetainedStoreError, + > { + Ok(retained.load_all()?.into_iter().find(|e| e.id == id)) + }) + .await + }; + let entry = match found { + Ok(Ok(Some(entry))) => entry, + Ok(Ok(None)) => { + return error_response( + StatusCode::NOT_FOUND, + "not_found", + "no such retained worktree", + ) + } + Ok(Err(e)) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "store_failed", + &e.to_string(), + ) + } + Err(e) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "join_failed", + &e.to_string(), + ) + } + }; + + let outcome = { + let retained = s.retained.clone(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + crate::worktree_probe::reclaim(&entry.base_repo, &entry.path) + .map_err(|e| e.to_string())?; + retained.remove(id).map_err(|e| e.to_string())?; + Ok(()) + }) + .await + }; + match outcome { + Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(), + // The directory could not be removed: leave the entry so the + // operator still sees the tree and can retry. + Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()), + Err(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "join_failed", + &e.to_string(), + ), + } +} + fn error_response(status: StatusCode, code: &str, message: &str) -> Response { (status, Json(ApiError::new(code, message))).into_response() } @@ -197,6 +310,7 @@ pub fn state_for( home_dir: impl Into, presets: Vec, projects: Arc, + retained: Arc, ) -> LocalApiState { LocalApiState { manager, @@ -204,6 +318,7 @@ pub fn state_for( home_dir: Arc::new(home_dir.into()), presets: Arc::new(presets), projects, + retained, } } diff --git a/crates/maestro-daemon/src/retained_store.rs b/crates/maestro-daemon/src/retained_store.rs index 1d8528c..869e1b9 100644 --- a/crates/maestro-daemon/src/retained_store.rs +++ b/crates/maestro-daemon/src/retained_store.rs @@ -14,7 +14,9 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; -use maestro_protocol::RetainedReason; +/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`], +/// so the store and the wire payload cannot drift apart. +pub use maestro_protocol::RetainedReason; use uuid::Uuid; #[derive(Debug, thiserror::Error)] diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index 21be5ab..09f1d32 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness { "/home/test", Vec::new(), std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + std::sync::Arc::new(fx.retained_store()), )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await diff --git a/crates/maestro-daemon/tests/branches.rs b/crates/maestro-daemon/tests/branches.rs index 1884e38..cdb2191 100644 --- a/crates/maestro-daemon/tests/branches.rs +++ b/crates/maestro-daemon/tests/branches.rs @@ -27,6 +27,7 @@ async fn spawn() -> Harness { "/home/test", presets, Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), + std::sync::Arc::new(fx.retained_store()), )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await diff --git a/crates/maestro-daemon/tests/common/mod.rs b/crates/maestro-daemon/tests/common/mod.rs index cf077cf..266cc27 100644 --- a/crates/maestro-daemon/tests/common/mod.rs +++ b/crates/maestro-daemon/tests/common/mod.rs @@ -163,6 +163,15 @@ impl DaemonFixture { self.state_dir.path().join("sessions") } + /// Retained-worktree store rooted at the fixture's own state dir (#78). + pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore { + maestro_daemon::retained_store::RetainedStore::new(self.retained_dir()) + } + + pub fn retained_dir(&self) -> PathBuf { + self.state_dir.path().join("retained-worktrees") + } + pub fn fifo_dir(&self) -> PathBuf { self.state_dir.path().join("fifos") } diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index b04abeb..8e5d554 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -48,6 +48,7 @@ async fn spawn_with( "/home/test", presets, projects, + std::sync::Arc::new(fx.retained_store()), )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await @@ -492,3 +493,234 @@ async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent { } } } + +// --------------------------------------------------------------------------- +// GET /api/worktrees + DELETE /api/worktrees/:id (#80) +// --------------------------------------------------------------------------- + +/// `git -C dir args`, asserting success — the worktree endpoints are tested +/// against real git, like everything else in this crate. +fn git(dir: &std::path::Path, args: &[&str]) { + let out = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .expect("git spawn"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +struct RetainedFixture { + repo: tempfile::TempDir, + wt: std::path::PathBuf, +} + +/// A real repo with a linked worktree for branch `feat/login` whose tree holds +/// an uncommitted tracked edit and an unpushed commit. +fn retained_fixture() -> RetainedFixture { + let repo = tempfile::tempdir().unwrap(); + common::init_repo(repo.path()); + // A pushed main, so "unpushed" means commits made on the feature branch, + // not the shared history. + let remote = repo.path().join("remote.git"); + std::fs::create_dir_all(&remote).unwrap(); + let remote_s = remote.to_string_lossy().into_owned(); + git(&remote, &["init", "-q", "--bare"]); + git(repo.path(), &["remote", "add", "origin", &remote_s]); + git(repo.path(), &["push", "-q", "-u", "origin", "main"]); + git( + repo.path(), + &[ + "worktree", + "add", + "-q", + "-b", + "feat/login", + "worktrees/feat/login", + ], + ); + let wt = repo.path().join("worktrees").join("feat").join("login"); + RetainedFixture { repo, wt } +} + +impl RetainedFixture { + fn commit_in_wt(&self, msg: &str) { + std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap(); + git(&self.wt, &["add", "-A"]); + git(&self.wt, &["commit", "-qm", msg]); + } + + fn branch_sha(&self, branch: &str) -> String { + let out = std::process::Command::new("git") + .arg("-C") + .arg(self.repo.path()) + .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")]) + .output() + .unwrap(); + assert!(out.status.success(), "branch {branch} must exist"); + String::from_utf8_lossy(&out.stdout).trim().to_string() + } +} + +#[tokio::test] +async fn worktrees_lists_retained_entries_with_recomputed_breakdown() { + let h = spawn("naps-laptop").await; + let fx = retained_fixture(); + // Uncommitted tracked edit + an untracked file; the branch has no commits + // beyond main yet. + std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); + std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap(); + + let store = h._fx.retained_store(); + let entry = store + .retain( + &fx.wt, + fx.repo.path(), + "feat/login", + maestro_daemon::retained_store::RetainedReason::ForceKilled, + ) + .unwrap(); + + let listed: Vec = + reqwest::get(url(h.addr, "/api/worktrees")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(listed.len(), 1); + let w = &listed[0]; + assert_eq!(w.id, entry.id); + assert_eq!(w.path, fx.wt); + assert_eq!(w.base_repo, fx.repo.path()); + assert_eq!(w.branch, "feat/login"); + assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled); + assert_eq!(w.in_the_way.uncommitted_files, 1); + assert_eq!(w.in_the_way.untracked_files, 1); + assert_eq!(w.in_the_way.unpushed_commits, 0); + + // The operator saves the work themselves. The stored entry is untouched — + // the next read must report the tree as it is *now*, not as it was when + // it was retained. + fx.commit_in_wt("saved"); + let listed: Vec = + reqwest::get(url(h.addr, "/api/worktrees")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(listed.len(), 1, "same entry, still retained"); + assert_eq!(listed[0].id, entry.id); + assert_eq!(listed[0].in_the_way.uncommitted_files, 0); + assert_eq!(listed[0].in_the_way.unpushed_commits, 1); +} + +#[tokio::test] +async fn reclaim_removes_directory_and_entry_but_never_the_branch() { + let h = spawn("naps-laptop").await; + let fx = retained_fixture(); + fx.commit_in_wt("precious"); + let sha = fx.branch_sha("feat/login"); + std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse + + let store = h._fx.retained_store(); + let entry = store + .retain( + &fx.wt, + fx.repo.path(), + "feat/login", + maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork, + ) + .unwrap(); + + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/worktrees/{}", entry.id))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); + + assert!(!fx.wt.exists(), "reclaim removes the directory"); + let listed: Vec = + reqwest::get(url(h.addr, "/api/worktrees")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(listed.is_empty(), "reclaim drops the store entry"); + assert_eq!( + fx.branch_sha("feat/login"), + sha, + "the branch and its commits survive" + ); +} + +#[tokio::test] +async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() { + let h = spawn("naps-laptop").await; + let fx = retained_fixture(); + let store = h._fx.retained_store(); + let entry = store + .retain( + &fx.wt, + fx.repo.path(), + "feat/login", + maestro_daemon::retained_store::RetainedReason::ForceKilled, + ) + .unwrap(); + std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf + + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/worktrees/{}", entry.id))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT); + assert!(store.load_all().unwrap().is_empty()); +} + +#[tokio::test] +async fn reclaim_unknown_id_returns_404() { + let h = spawn("naps-laptop").await; + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4()))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() { + let h = spawn("naps-laptop").await; + let fx = retained_fixture(); + let store = h._fx.retained_store(); + store + .retain( + &fx.wt, + fx.repo.path(), + "feat/login", + maestro_daemon::retained_store::RetainedReason::ForceKilled, + ) + .unwrap(); + std::fs::remove_dir_all(&fx.wt).unwrap(); + + let listed: Vec = + reqwest::get(url(h.addr, "/api/worktrees")) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(listed.is_empty(), "a hand-removed directory is not listed"); + assert!( + store.load_all().unwrap().is_empty(), + "and its entry is gone" + ); +} From a575b8bef1558a2844be99ac64590ccc23b03f25 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:03:43 +0100 Subject: [PATCH 51/71] feat(maestro): refuse a kill when the worktree holds unsaved work (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard moves off the worktree and onto the kill. DELETE /api/sessions/:id now runs the shared unsaved_work probe before tearing anything down and answers 409 kill_guarded with the breakdown on the wire, so a client can say "3 uncommitted files, 2 unpushed commits". The session keeps running and the tree is untouched. WorktreeManager::remove is unconditional now — git itself still refuses a dirty tree as a last line of defense. --- crates/maestro-daemon/src/dirlist.rs | 1 + crates/maestro-daemon/src/local_api.rs | 13 +- crates/maestro-daemon/src/session.rs | 58 +++++-- crates/maestro-daemon/tests/local_api.rs | 49 +++++- .../maestro-daemon/tests/session_manager.rs | 155 ++++++++++++++++-- crates/maestro-e2e/tests/e2e.rs | 58 +++++-- 6 files changed, 295 insertions(+), 39 deletions(-) diff --git a/crates/maestro-daemon/src/dirlist.rs b/crates/maestro-daemon/src/dirlist.rs index cae1c96..19a14ec 100644 --- a/crates/maestro-daemon/src/dirlist.rs +++ b/crates/maestro-daemon/src/dirlist.rs @@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo { ErrorInfo { code: code.into(), message: format!("{message}: {}", dir.display()), + unsaved_work: None, } } diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 69a4494..089400c 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -94,6 +94,8 @@ fn session_error_status(code: &str) -> StatusCode { match code { "not_found" => StatusCode::NOT_FOUND, "invalid_env" => StatusCode::BAD_REQUEST, + // A guard refusal is the daemon doing its job, not a failure (#79). + "kill_guarded" => StatusCode::CONFLICT, _ => StatusCode::BAD_GATEWAY, } } @@ -168,16 +170,17 @@ async fn create_session( /// `kill` already reports a missing session as `SessionError::NotFound`, which /// maps to 404. Checking first would only add a window in which the liveness /// watcher reaps the session between the two awaits, turning a 404 into a 502. +/// +/// The full error info is passed through rather than rebuilt from +/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work +/// breakdown clients render as "3 uncommitted files, 2 unpushed commits". async fn kill_session(State(s): State, Path(id): Path) -> Response { match s.manager.kill(id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(e) => { let info = e.to_error_info(); - error_response( - session_error_status(&info.code), - info.code.as_str(), - info.message.as_str(), - ) + let status = session_error_status(&info.code); + (status, Json(ApiError { error: info })).into_response() } } } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 72ae8f9..04fd3aa 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -38,8 +38,8 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2); pub struct SessionRecord { pub session: Session, pub fifo_path: PathBuf, - /// Worktree we created at spawn time; removed on kill unless the - /// dirty-work guard refuses (#56). + /// Worktree we created at spawn time; removed on kill, but only after the + /// kill guard (#79) found nothing worth keeping in it. pub worktree_path: Option, /// `base_repo` from the original `WorktreeSpec`. Needed because `git /// worktree remove` is invoked from the base repo. @@ -61,6 +61,11 @@ pub enum SessionError { NotFound(Uuid), #[error("invalid environment variable name: {0:?}")] InvalidEnv(String), + /// The kill was refused (#79): the session's worktree holds unsaved work. + /// Nothing happened — the session keeps running and the tree is untouched, + /// so it stays the operator's handle on their own unfinished work. + #[error("kill refused: the session's worktree holds unsaved work ({0})")] + KillGuarded(maestro_protocol::UnsavedWork), } impl SessionError { @@ -72,10 +77,16 @@ impl SessionError { SessionError::Io(_) => "io_failed", SessionError::NotFound(_) => "not_found", SessionError::InvalidEnv(_) => "invalid_env", + SessionError::KillGuarded(_) => "kill_guarded", + }; + let unsaved_work = match self { + SessionError::KillGuarded(b) => Some(*b), + _ => None, }; ErrorInfo { code: code.into(), message: self.to_string(), + unsaved_work, } } } @@ -370,20 +381,48 @@ impl SessionManager { } } - /// Kill a session and remove its on-disk artifacts. The worktree is - /// always pruned -- unless it holds uncommitted changes or commits not on - /// any other ref, in which case the guard leaves it on disk (#56). + /// Kill a session and remove its on-disk artifacts. + /// + /// Refused (#79) when the session's worktree holds unsaved work — + /// uncommitted tracked changes, untracked non-ignored files, or commits + /// reachable from no remote-tracking ref. Nothing is killed and nothing + /// is removed; the error names what blocked it and how much of it there + /// is. The session stays as the operator's handle on their own unfinished + /// work, so ending a session that still matters becomes a deliberate + /// `--force`, which retains rather than destroys (WORKTREES.md). /// /// This is also how an already-dead session is dismissed. tmux is only /// asked to kill something it still has — calling `kill-session` on a /// session tmux already lost fails, and that failure is not interesting. pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> { + let (worktree, alive) = { + let sessions = self.inner.sessions.lock().await; + match sessions.get(&id) { + None => return Err(SessionError::NotFound(id)), + Some(record) => ( + record + .worktree_base_repo + .clone() + .zip(record.worktree_path.clone()), + record.session.status == SessionStatus::Running, + ), + } + }; + // The guard runs before anything is torn down: refusing must leave the + // session running, exactly as the operator left it. + if let Some((_, path)) = worktree { + let probe = tmux_blocking(move || crate::worktree::unsaved_work(&path)) + .await + .map_err(SessionError::Worktree)?; + if !probe.is_empty() { + return Err(SessionError::KillGuarded(probe)); + } + } let removed = { let mut sessions = self.inner.sessions.lock().await; sessions.remove(&id) }; let record = removed.ok_or(SessionError::NotFound(id))?; - let alive = record.session.status == SessionStatus::Running; cleanup_record(&self.inner, id, record, alive).await; Ok(()) } @@ -720,10 +759,9 @@ async fn cleanup_record(inner: &Arc, id: Uuid, record: SessionRecord, kil if let Err(e) = inner.sidecar.delete(id) { tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar"); } - // Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a - // user-facing choice. The manager's guard is what protects unsaved work -- - // when it refuses, leave the tree on disk and say where it is so the - // operator can find it. + // The kill already passed the unsaved-work guard (#79), so this removal + // is unconditional; the manager's own nested-worktree refusal is all that + // can still leave a tree on disk, and it says where. if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { if let Err(e) = inner.worktree_mgr.remove(base, path) { tracing::warn!( diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index b04abeb..8d9b2d4 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -9,7 +9,7 @@ use std::time::Duration; use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; use maestro_daemon::local_api; -use maestro_protocol::{CreateSessionRequest, Session}; +use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec}; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -338,6 +338,53 @@ async fn delete_owned_session_kills_it() { assert!(!h.manager.has(session.id).await); } +/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409 +/// with the structured breakdown, and the session keeps running. +#[tokio::test] +async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() { + let h = spawn("naps-laptop").await; + let repo = tempfile::TempDir::new().unwrap(); + common::init_repo(repo.path()); + let session = h + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 60".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/guarded".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .unwrap(); + std::fs::write( + repo.path() + .join("worktrees") + .join("feat") + .join("guarded") + .join("README"), + b"unsaved edits\n", + ) + .unwrap(); + + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT); + let body: ApiError = resp.json().await.unwrap(); + assert_eq!(body.error.code, "kill_guarded"); + let breakdown = body.error.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.dirty_tracked_files, 1); + + assert!(h.manager.has(session.id).await, "the session keeps running"); +} + #[tokio::test] async fn attach_ws_streams_pty_bytes() { let h = spawn("naps-laptop").await; diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 15e9aa2..e9c5ad7 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -4,10 +4,12 @@ mod common; use std::collections::HashMap; use std::path::PathBuf; +use std::process::Command; use std::sync::Arc; use std::time::Duration; -use common::{init_repo, wait_for_async, DaemonFixture}; +use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture}; +use maestro_daemon::SessionError; use maestro_protocol::CreateSessionRequest; use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec}; use tempfile::TempDir; @@ -363,8 +365,9 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() { assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists()); } -/// Cleanup is unconditional (#56): even a spec that says "don't clean up" -/// loses its worktree on kill — the guard, not the flag, protects work. +/// Cleanup on kill is unconditional once the guard passes (#79): even a spec +/// that says "don't clean up" loses its worktree — the branch was pushed, so +/// nothing in the tree is unique and removal goes ahead. #[tokio::test] async fn kill_removes_worktree_even_when_spec_says_keep() { let fx = DaemonFixture::new(); @@ -390,14 +393,21 @@ async fn kill_removes_worktree_even_when_spec_says_keep() { .expect("create"); assert!(wt_path.exists()); + // Push the branch so its commits are reachable from a remote-tracking ref. + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + fx.manager.kill(s.id).await.expect("kill"); assert!(!wt_path.exists(), "worktree must be removed on kill"); } -/// The guard (#56): a worktree holding uncommitted changes survives the kill, -/// left on disk where the operator can find it. +/// The kill guard (#79): a worktree holding uncommitted changes refuses the +/// kill outright. Nothing happens — the session keeps running as the +/// operator's handle on their unfinished work, and the tree is untouched. +/// The refusal names what blocked it and how much of it there is. #[tokio::test] -async fn kill_leaves_worktree_with_unsaved_work_on_disk() { +async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() { let fx = DaemonFixture::new(); let repo = TempDir::new().unwrap(); init_repo(repo.path()); @@ -420,13 +430,138 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() { .await .expect("create"); std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap(); - fx.manager.kill(s.id).await.expect("kill"); + let err = fx.manager.kill(s.id).await.expect_err("kill must refuse"); + let SessionError::KillGuarded(breakdown) = err else { + panic!("expected KillGuarded, got {err:?}"); + }; + assert_eq!(breakdown.dirty_tracked_files, 1); + assert_eq!(breakdown.untracked_files, 1); assert!( - wt_path.exists(), - "dirty worktree must be left on disk, not destroyed" + breakdown.unpushed_commits >= 1, + "a fresh branch has no remote-tracking ref" + ); + let message = breakdown.to_string(); + assert!(message.contains("1 uncommitted file"), "{message}"); + assert!(message.contains("1 untracked file"), "{message}"); + + // Nothing happened: the session survives and so does the tree. + assert!(fx.manager.has(s.id).await, "the session must keep running"); + assert!(wt_path.exists(), "the worktree must be untouched"); + + // A second kill after the work is saved goes through. + run_git_in(&wt_path, &["add", "."]); + run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]); + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + fx.manager.kill(s.id).await.expect("kill after saving"); + assert!(!fx.manager.has(s.id).await); +} + +/// Unpushed commits alone block the kill (#79): a feature branch almost +/// always has them, which is why `--force` (which retains) is the ordinary +/// way to end such a session. +#[tokio::test] +async fn killing_refuses_until_the_branch_is_pushed() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("pushed"); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/pushed".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + + let err = fx.manager.kill(s.id).await.expect_err("unpushed blocks"); + let SessionError::KillGuarded(breakdown) = err else { + panic!("expected KillGuarded, got {err:?}"); + }; + assert_eq!(breakdown.dirty_tracked_files, 0); + assert_eq!(breakdown.untracked_files, 0); + assert!(breakdown.unpushed_commits >= 1); + + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + fx.manager.kill(s.id).await.expect("pushed branch kills"); + assert!(!wt_path.exists()); + assert!(!fx.manager.has(s.id).await); +} + +/// Ignored files NEVER block (#79): build output like `target/` exists in +/// every Rust checkout, and an ignored-only worktree stays killable. +#[tokio::test] +async fn killing_an_ignored_only_worktree_succeeds() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + // .gitignore must be committed before the worktree branches off, or it + // would itself read as an untracked file. + std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap(); + run_git_in(repo.path(), &["add", ".gitignore"]); + run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]); + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + + let wt_path = repo.path().join("worktrees").join("feat").join("rusty"); + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/rusty".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::create_dir_all(wt_path.join("target")).unwrap(); + std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap(); + + // Branch pushed; the only dirt is ignored output. + push_all_to_origin(repo.path(), origin.path()); + + fx.manager + .kill(s.id) + .await + .expect("ignored files never block"); + assert!(!wt_path.exists()); + assert!(!fx.manager.has(s.id).await); +} + +fn run_git_in(dir: &std::path::Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .expect("git spawn"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) ); - assert!(fx.manager.snapshot().await.is_empty()); } #[tokio::test] diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index cbb213a..b049014 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -160,6 +160,7 @@ async fn kill_removes_clean_worktree() { "expected a worktree for feature/e2e under /worktrees, got: {worktrees:?}" ); + push_to_throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); // Give the daemon a moment to remove the worktree, then verify. @@ -177,11 +178,12 @@ async fn kill_removes_clean_worktree() { ); } -/// The guard (#56): killing a session whose worktree holds uncommitted -/// changes must leave the tree on disk — cleanup is unconditional, so this -/// guard is what keeps a stray kill from destroying work. +/// The kill guard (#79): killing a session whose worktree holds uncommitted +/// changes is refused outright. Nothing happens — the session keeps running +/// as the operator's handle on their unfinished work, the tree stays on disk, +/// and the refusal names what blocked it and how much. #[tokio::test(flavor = "multi_thread")] -async fn kill_spares_worktree_with_uncommitted_changes() { +async fn kill_refuses_worktree_with_uncommitted_changes() { let (_daemon, client) = bring_up().await; let base = TempDir::new().unwrap(); @@ -209,17 +211,25 @@ async fn kill_spares_worktree_with_uncommitted_changes() { assert!(wt_path.exists()); std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); - client.kill_session(s.id).await.expect("kill"); - - // The session is gone but the dirty worktree stays findable on disk. - let deadline = Instant::now() + Duration::from_secs(5); - while Instant::now() < deadline { - let sessions = client.list_sessions().await.unwrap_or_default(); - if sessions.is_empty() { - break; + let err = client + .kill_session(s.id) + .await + .expect_err("kill must refuse"); + match err { + maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => { + assert_eq!(info.code, "kill_guarded", "{info:?}"); + let breakdown = info.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.dirty_tracked_files, 1); } - tokio::time::sleep(Duration::from_millis(100)).await; + other => panic!("expected a kill_guarded refusal, got {other:?}"), } + + // The session survives and the dirty worktree stays findable on disk. + let sessions = client.list_sessions().await.unwrap_or_default(); + assert!( + sessions.iter().any(|x| x.id == s.id), + "a refused kill must keep the session alive" + ); assert!( wt_path.exists(), "guarded worktree must be left on disk after kill" @@ -270,6 +280,7 @@ async fn spawn_on_existing_branch_checks_it_out() { "expected a worktree checked out at feature/existing, got: {worktrees:?}" ); + push_to_throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); } @@ -317,6 +328,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() { ) .await; + push_to_throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); } @@ -364,6 +376,7 @@ async fn second_spawn_on_same_branch_is_rejected() { "original worktree must be untouched" ); + push_to_throwaway_origin(base.path()); client.kill_session(first.id).await.expect("kill"); } @@ -406,6 +419,7 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() { "worktrees must never nest inside another worktree" ); + push_to_throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); } @@ -774,6 +788,24 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec { .collect() } +/// Push every branch of `repo` to a throwaway bare remote, so its commits are +/// reachable from remote-tracking refs. Without one the kill guard (#79) +/// reads every commit as unpushed, and any worktree session refuses to die. +fn push_to_throwaway_origin(repo: &std::path::Path) { + let origin = tempfile::TempDir::new().unwrap(); + run_git( + &["init", "-q", "--bare", origin.path().to_str().unwrap()], + repo, + ); + let _ = Command::new("git") + .arg("-C") + .arg(repo) + .args(["remote", "add", "origin", origin.path().to_str().unwrap()]) + .output() + .expect("git remote add"); + run_git(&["push", "-q", "--all", "origin"], repo); +} + #[tokio::test(flavor = "multi_thread")] async fn daemon_events_ws_streams_snapshot_then_session_added() { // NAP-248: subscribe to the daemon's WS /api/events, expect an initial From 267f8a671214a744d9349c68f7d5e7fbf5758065 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:06:30 +0100 Subject: [PATCH 52/71] feat(maestro): DaemonClient calls for retained worktrees --- crates/maestro-protocol/src/client.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 7d03e38..59e0eb7 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, - Project, RecentDirs, Session, + Project, RecentDirs, RetainedWorktree, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -148,6 +148,23 @@ impl DaemonClient { .await } + /// `GET /api/worktrees` (#80): every retained worktree with its in-the-way + /// breakdown as of this read. + pub async fn list_worktrees(&self) -> Result, ClientError> { + self.get(routes::WORKTREES, &[]).await + } + + /// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The + /// daemon removes the directory and drops the store entry; the branch is + /// never deleted. + pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> { + let resp = self + .send(Method::DELETE, &routes::worktree(id), &[], None::<&()>) + .await?; + check(resp).await?; + Ok(()) + } + // -- plumbing ----------------------------------------------------------- async fn get( From abaa498b9fc4ae1147ea595e18620144e5067a1c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:13:55 +0100 Subject: [PATCH 53/71] feat(maestro): worktrees list / reclaim CLI (#86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maestro worktrees list mirrors the sessions table shape: hostname header, home-abbreviated paths, and the in-the-way breakdown recomputed by the daemon at read time. reclaim takes a required explicit ID — no reclaim-everything — and its confirmation says the branch survives. --- crates/maestro-cli/src/cli.rs | 82 +++++++++++ crates/maestro-cli/src/dispatch.rs | 23 +++- crates/maestro-cli/src/lib.rs | 1 + crates/maestro-cli/src/sessions.rs | 2 +- crates/maestro-cli/src/worktrees.rs | 204 ++++++++++++++++++++++++++++ 5 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 crates/maestro-cli/src/worktrees.rs diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index 877d344..280da34 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -41,6 +41,36 @@ pub enum Command { Attach(AttachArgs), /// Kill a session by ID. Kill(KillArgs), + /// List retained worktrees, or reclaim one by ID (#86). + Worktrees(WorktreesArgs), +} + +/// `maestro worktrees ` +#[derive(Debug, clap::Args)] +pub struct WorktreesArgs { + #[command(subcommand)] + pub command: WorktreesCommand, +} + +#[derive(Debug, Subcommand)] +pub enum WorktreesCommand { + /// List retained worktrees and what is in the way of reclaiming each. + List(ListWorktreesArgs), + /// Reclaim one retained worktree: removes the directory, never the branch. + Reclaim(ReclaimArgs), +} + +#[derive(Debug, clap::Args)] +pub struct ListWorktreesArgs { + /// Output as JSON instead of a table. + #[arg(long)] + pub json: bool, +} + +#[derive(Debug, clap::Args)] +pub struct ReclaimArgs { + /// Retained worktree ID to reclaim. Required — there is no reclaim-all. + pub id: Uuid, } #[derive(Debug, clap::Args)] @@ -241,4 +271,56 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("KEY=VALUE")); } + + #[test] + fn parses_worktrees_list() { + let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap(); + match cli.command { + Command::Worktrees(w) => match w.command { + WorktreesCommand::List(a) => assert!(a.json), + _ => panic!("wrong worktrees subcommand"), + }, + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn parses_worktrees_reclaim() { + let id = "01234567-89ab-cdef-0123-456789abcdef"; + let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap(); + match cli.command { + Command::Worktrees(w) => match w.command { + WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id), + _ => panic!("wrong worktrees subcommand"), + }, + _ => panic!("wrong subcommand"), + } + } + + /// Contract rule: reclaim refuses to run without an explicit target. + /// There is no reclaim-everything convenience. + #[test] + fn reclaim_requires_an_explicit_target() { + let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err(); + assert!( + matches!( + err.kind(), + clap::error::ErrorKind::InvalidSubcommand + | clap::error::ErrorKind::MissingRequiredArgument + ), + "{err}" + ); + } + + #[test] + fn worktrees_without_subcommand_shows_help() { + let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err(); + assert!( + matches!( + err.kind(), + clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + ), + "{err}" + ); + } } diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs index b1a6fed..c1fccb5 100644 --- a/crates/maestro-cli/src/dispatch.rs +++ b/crates/maestro-cli/src/dispatch.rs @@ -9,8 +9,9 @@ use std::path::{Path, PathBuf}; use maestro_protocol::DaemonClient; use crate::attach::{parse_detach_key, run_attach_url}; -use crate::cli::Command; +use crate::cli::{Command, WorktreesCommand}; use crate::sessions::{run_kill, run_list, run_spawn}; +use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim}; use maestro_config::{default_config_path, MaestroConfigFile}; #[derive(Debug, thiserror::Error)] @@ -64,6 +65,26 @@ pub async fn dispatch( run_kill(&daemon, &args).await?; println!("killed {}", args.session_id); } + Command::Worktrees(args) => match args.command { + WorktreesCommand::List(list_args) => { + let mut stdout = std::io::stdout().lock(); + run_worktree_list(&daemon, &list_args, &mut stdout).await?; + } + WorktreesCommand::Reclaim(reclaim_args) => { + // The daemon removes the directory and the store entry; the + // branch is never deleted — worth saying out loud (#86). + let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?; + match entry { + Some(wt) => println!( + "reclaimed {} ({})\nbranch '{}' kept", + wt.id, + wt.path.display(), + wt.branch + ), + None => println!("reclaimed {}\nbranch kept", reclaim_args.id), + } + } + }, } Ok(()) } diff --git a/crates/maestro-cli/src/lib.rs b/crates/maestro-cli/src/lib.rs index a6a9cc9..ce3599d 100644 --- a/crates/maestro-cli/src/lib.rs +++ b/crates/maestro-cli/src/lib.rs @@ -6,5 +6,6 @@ pub mod attach; pub mod cli; pub mod dispatch; pub mod sessions; +pub mod worktrees; pub use dispatch::{dispatch, CliError}; diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 902a2de..3bcbf65 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -122,7 +122,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon /// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web /// UI's abbreviation so CLI and browser show the same directory shorthand. -fn abbreviate_dir(path: &std::path::Path, home: &str) -> String { +pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String { let p = path.to_string_lossy(); if !home.is_empty() { if p == home { diff --git a/crates/maestro-cli/src/worktrees.rs b/crates/maestro-cli/src/worktrees.rs new file mode 100644 index 0000000..c00237c --- /dev/null +++ b/crates/maestro-cli/src/worktrees.rs @@ -0,0 +1,204 @@ +//! `maestro worktrees list / reclaim` — the retained-store surface (#86). +//! +//! `reclaim` removes one directory; it never deletes the branch, which is +//! said out loud in the output because "reclaim" otherwise sounds like it +//! takes the commits too. + +use std::io::Write; + +use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree}; + +use crate::cli::{ListWorktreesArgs, ReclaimArgs}; +use crate::sessions::abbreviate_dir; + +/// `maestro worktrees list` +pub async fn run_list( + client: &DaemonClient, + args: &ListWorktreesArgs, + out: &mut impl Write, +) -> Result<(), ClientError> { + let worktrees = client.list_worktrees().await?; + let info = client.daemon_info().await.unwrap_or_default(); + + if args.json { + let body = serde_json::json!({ "worktrees": worktrees }); + writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok(); + return Ok(()); + } + + print_worktree_table(out, &worktrees, &info); + Ok(()) +} + +/// `maestro worktrees reclaim ` +/// +/// Returns the entry as listed just before the reclaim, when it was found in +/// the listing, so the confirmation can name what was removed. +pub async fn run_reclaim( + client: &DaemonClient, + args: &ReclaimArgs, +) -> Result, ClientError> { + let listed = client + .list_worktrees() + .await? + .into_iter() + .find(|w| w.id == args.id); + client.reclaim_worktree(args.id).await?; + Ok(listed) +} + +fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) { + if worktrees.is_empty() { + writeln!(out, "no retained worktrees").ok(); + return; + } + + let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect(); + sorted.sort_by_key(|w| w.retained_at); + + if !info.hostname.is_empty() { + let warn = if maestro_protocol::is_compatible(&info.version) { + "" + } else { + " (protocol mismatch)" + }; + let version = if info.version.is_empty() { + String::new() + } else { + format!(" maestro {}{warn}", info.version) + }; + writeln!(out, "\n{}{version}", info.hostname).ok(); + } + writeln!( + out, + " {:<36} {:<24} {:<28} {:<24} IN THE WAY", + "ID", "BRANCH", "PATH", "REASON" + ) + .ok(); + for w in sorted { + writeln!( + out, + " {:<36} {:<24} {:<28} {:<24} {}", + w.id, + w.branch, + abbreviate_dir(&w.path, &info.home_dir), + reason_label(w.reason), + blocker_label(&w.in_the_way), + ) + .ok(); + } + // The branch survives every reclaim — see docs/WORKTREES.md. + writeln!( + out, + "\nreclaim removes the directory only; branches are never deleted" + ) + .ok(); +} + +fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str { + match reason { + maestro_protocol::RetainedReason::ForceKilled => "force-killed", + maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work", + } +} + +/// The same breakdown that blocks an unforced kill, as words: what is still +/// in the way, or `clear` when the work has since been committed and pushed. +fn blocker_label(in_the_way: &InTheWay) -> String { + if in_the_way.is_clear() { + return "clear".into(); + } + let mut parts: Vec = Vec::new(); + if in_the_way.uncommitted_files > 0 { + parts.push(format!("{} uncommitted", in_the_way.uncommitted_files)); + } + if in_the_way.untracked_files > 0 { + parts.push(format!("{} untracked", in_the_way.untracked_files)); + } + if in_the_way.unpushed_commits > 0 { + parts.push(format!("{} unpushed", in_the_way.unpushed_commits)); + } + parts.join(", ") +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use chrono::{TimeZone, Utc}; + use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree}; + use uuid::Uuid; + + fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree { + RetainedWorktree { + id: Uuid::from_u128(id), + path: PathBuf::from(path), + base_repo: PathBuf::from("/home/naps/maestro"), + branch: branch.into(), + reason: RetainedReason::ForceKilled, + retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(), + in_the_way: InTheWay::default(), + } + } + + fn info(home: &str) -> DaemonInfo { + DaemonInfo { + hostname: "naps-laptop".into(), + home_dir: home.into(), + ..Default::default() + } + } + + #[test] + fn formats_table() { + let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1); + w.in_the_way = InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }; + let mut buf = Vec::new(); + super::print_worktree_table(&mut buf, &[w], &info("/home/naps")); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("naps-laptop"), "{s}"); + assert!(s.contains("feat/login"), "{s}"); + // Home dir collapses to ~ in the PATH column, like sessions' DIR. + assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}"); + assert!(s.contains("force-killed"), "{s}"); + assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}"); + assert!(s.contains("IN THE WAY"), "{s}"); + } + + /// Contract rule, visible to the operator: reclaiming a tree must not + /// throw away commits on its branch. + #[test] + fn says_the_branch_survives() { + let mut buf = Vec::new(); + super::print_worktree_table( + &mut buf, + &[worktree("feat/x", "/repo/worktrees/feat/x", 1)], + &info(""), + ); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("never deleted"), "{s}"); + } + + #[test] + fn clear_tree_says_clear() { + let mut buf = Vec::new(); + super::print_worktree_table( + &mut buf, + &[worktree("main-ish", "/repo/worktrees/main-ish", 1)], + &info(""), + ); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("clear"), "{s}"); + } + + #[test] + fn empty_store() { + let mut buf = Vec::new(); + super::print_worktree_table(&mut buf, &[], &info("")); + assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n"); + } +} From bb2f439302917c13ae1b6dd1c108542d9dd23f96 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:20:00 +0100 Subject: [PATCH 54/71] feat(maestro): kill outcome payload and force query in protocol DELETE /api/sessions/:id gains a force query param (#81). A forced kill answers 200 with KillOutcome, naming what was retained and why; the unforced kill keeps its 204. DaemonClient::kill_session takes the flag. --- crates/maestro-protocol/src/client.rs | 22 +++++++++++++++----- crates/maestro-protocol/src/lib.rs | 29 +++++++++++++++++++++++++++ crates/maestro-protocol/src/routes.rs | 4 ++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 7d03e38..c4bfce4 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, - Project, RecentDirs, Session, + KillOutcome, Project, RecentDirs, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -114,12 +114,24 @@ impl DaemonClient { self.post(routes::SESSIONS, req).await } - pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> { + /// Kill a session. With `force`, the daemon overrides the kill refusal + /// (#79) and retains the worktree instead of removing it (#81); the + /// outcome then names what was kept and where. + pub async fn kill_session(&self, id: Uuid, force: bool) -> Result { + let query: &[(&str, &str)] = if force { + &[(routes::FORCE_QUERY, "true")] + } else { + &[] + }; let resp = self - .send(Method::DELETE, &routes::session(id), &[], None::<&()>) + .send(Method::DELETE, &routes::session(id), query, None::<&()>) .await?; - check(resp).await?; - Ok(()) + let resp = check(resp).await?; + // An unforced kill keeps its historical `204 No Content`. + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(KillOutcome { retained: None }); + } + json(resp).await } pub async fn daemon_info(&self) -> Result { diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index ba4ab02..3b88d6d 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -195,6 +195,19 @@ pub struct RetainedWorktree { pub in_the_way: InTheWay, } +/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81). +/// A forced kill ends the session and retains its worktree instead of +/// removing it; this names what was kept, where, and why, so a client can say +/// where the work went rather than leaving the operator to wonder. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct KillOutcome { + /// Set when the worktree was retained with reason `force-killed`; `None` + /// when there was nothing to retain — no worktree at all, or one whose + /// guard was clear, which a forced kill removes as usual. + #[serde(default)] + pub retained: Option, +} + impl std::fmt::Display for InTheWay { /// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted; /// all-zero renders "no unsaved work". This is the phrasing every client @@ -679,6 +692,22 @@ mod tests { ); } + #[test] + fn kill_outcome_round_trips_a_retained_worktree() { + let outcome = KillOutcome { + retained: Some(sample_retained_worktree()), + }; + assert_eq!(outcome, round_trip(&outcome)); + } + + /// A kill with nothing to retain — no worktree, or a clean one removed as + /// usual — decodes from an absent field. + #[test] + fn kill_outcome_defaults_to_nothing_retained() { + let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(outcome.retained, None); + } + #[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 7878813..7b3e024 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -38,6 +38,10 @@ pub const PATH_QUERY: &str = "path"; /// Name of the query parameter [`NAMES`] takes. pub const COUNT_QUERY: &str = "count"; +/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81). +/// `force=true` overrides the kill refusal and retains the worktree. +pub const FORCE_QUERY: &str = "force"; + /// `DELETE /api/sessions/:id`. pub fn session(id: Uuid) -> String { format!("{SESSIONS}/{id}") From 01666070644cc2793d306bd7baef006352f72d6d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:26:56 +0100 Subject: [PATCH 55/71] feat(maestro): retained-worktrees view overlay (#85) --- crates/maestro-tui/src/draft.rs | 2 +- crates/maestro-tui/src/lib.rs | 1 + crates/maestro-tui/src/overlays.rs | 2 +- crates/maestro-tui/src/worktrees.rs | 259 ++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 crates/maestro-tui/src/worktrees.rs diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index cf3a165..cd76fd2 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -1186,7 +1186,7 @@ fn draw_text_field( /// `~` abbreviation plus leading-segment collapsing, matching the web UI's /// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as /// `/h/u/t/Maestro` given home `/home/u`. -fn abbreviate_path(path: &Path, home: &str) -> String { +pub(crate) fn abbreviate_path(path: &Path, home: &str) -> String { let raw = path.to_string_lossy(); let abbreviated = if !home.is_empty() { if raw == home { diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index 09e58af..c7cfc56 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -22,6 +22,7 @@ pub mod state; pub mod tabs; pub mod terminal; pub mod text_input; +pub mod worktrees; use std::path::PathBuf; use std::time::Duration; diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index a046224..3f540a6 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -82,7 +82,7 @@ impl Toast { } } -fn centered(parent: Rect, w: u16, h: u16) -> Rect { +pub(crate) fn centered(parent: Rect, w: u16, h: u16) -> Rect { let w = w.min(parent.width); let h = h.min(parent.height); Rect { diff --git a/crates/maestro-tui/src/worktrees.rs b/crates/maestro-tui/src/worktrees.rs new file mode 100644 index 0000000..fbb640d --- /dev/null +++ b/crates/maestro-tui/src/worktrees.rs @@ -0,0 +1,259 @@ +//! Retained-worktrees view (#85): an overlay listing every retained worktree +//! — branch, abbreviated path, reason, what is in the way — with j/k to move, +//! `r` to reclaim behind a confirmation, esc to close. +//! +//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl, +//! the same source the kill refusal and the web client use, so no two clients +//! can describe the same tree differently. + +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use maestro_protocol::{RetainedReason, RetainedWorktree}; + +use crate::draft::abbreviate_path; + +/// The open worktrees overlay: its entries and which one is highlighted. +#[derive(Debug, Clone, Default)] +pub struct WorktreesView { + pub items: Vec, + pub highlight: usize, + /// Daemon-reported home dir, for `~` abbreviation of paths. + pub home_dir: String, +} + +impl WorktreesView { + /// Replace the listing and keep the highlight pointing at something real. + pub fn apply(&mut self, items: Vec) { + self.items = items; + if !self.items.is_empty() { + self.highlight = self.highlight.min(self.items.len() - 1); + } else { + self.highlight = 0; + } + } + + pub fn remove(&mut self, id: uuid::Uuid) { + let idx = self.items.iter().position(|w| w.id == id); + self.items.retain(|w| w.id != id); + if let Some(i) = idx { + self.highlight = self.highlight.min(self.items.len().saturating_sub(1)); + let _ = i; + } + } + + /// Move the highlight by `delta` (positive = down). Wraps. No-op on empty. + pub fn move_highlight(&mut self, delta: i32) { + if self.items.is_empty() { + return; + } + let len = self.items.len() as i32; + let next = (self.highlight as i32 + delta).rem_euclid(len); + self.highlight = next as usize; + } + + pub fn selected(&self) -> Option<&RetainedWorktree> { + self.items.get(self.highlight) + } + + pub fn render(&self, f: &mut Frame, parent: Rect) { + let h = (self.items.len() as u16 + 6).clamp(4, parent.height); + let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h); + f.render_widget(Clear, area); + let block = Block::default() + .title(" retained worktrees ") + .borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + + let mut lines: Vec = Vec::new(); + if self.items.is_empty() { + // A drained store is the normal state — say so plainly, not like + // something went wrong. + lines.push(Line::from("no retained worktrees")); + } else { + lines.push(Line::from(format!( + " {:<26} {:<34} {:<24} IN THE WAY", + "BRANCH", "PATH", "REASON" + ))); + for (i, w) in self.items.iter().enumerate() { + let marker = if i == self.highlight { ">" } else { " " }; + lines.push(Line::from(format!( + "{marker} {:<25} {:<34} {:<24} {}", + w.branch, + abbreviate_path(&w.path, &self.home_dir), + reason_label(w.reason), + w.in_the_way, + ))); + } + } + lines.push(Line::from("")); + lines.push(Line::from("j/k: select r: reclaim esc: close")); + f.render_widget(Paragraph::new(lines), inner); + } +} + +/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`]. +/// It names the branch and says out loud what reclaim does not take with it — +/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too. +#[derive(Debug, Clone)] +pub struct ReclaimConfirm { + pub branch: String, + /// [`maestro_protocol::InTheWay`] rendered via its `Display` impl. + pub blockers: String, +} + +impl ReclaimConfirm { + pub fn render(&self, f: &mut Frame, parent: Rect) { + let area = super::overlays::centered(parent, 70, 7); + f.render_widget(Clear, area); + let block = Block::default().title(" reclaim ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + f.render_widget( + ratatui::widgets::Paragraph::new(vec![ + Line::from(format!("Reclaim worktree {}?", self.branch)), + Line::from(format!("Currently in the way: {}", self.blockers)), + Line::from("The directory is removed; the branch is never deleted."), + Line::from(""), + Line::from("[y] yes [n] no"), + ]), + inner, + ); + } +} + +fn reason_label(reason: RetainedReason) -> &'static str { + match reason { + RetainedReason::ForceKilled => "force-killed", + RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use maestro_protocol::{InTheWay, RetainedReason}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn render_to_string(w: u16, h: u16, draw: F) -> String + where + F: FnOnce(&mut Frame), + { + let backend = TestBackend::new(w, h); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(draw).unwrap(); + let buf = terminal.backend().buffer().clone(); + let mut out = String::new(); + for y in 0..buf.area.height { + for x in 0..buf.area.width { + out.push_str(buf[(x, y)].symbol()); + } + out.push('\n'); + } + out + } + + fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree { + RetainedWorktree { + id: uuid::Uuid::from_u128(id), + path: path.into(), + base_repo: "/home/naps/tea/maestro".into(), + branch: branch.into(), + reason: RetainedReason::ForceKilled, + retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(), + in_the_way: InTheWay::default(), + } + } + + #[test] + fn view_lists_branch_path_reason_and_what_is_in_the_way() { + let mut view = WorktreesView { + home_dir: "/home/naps".into(), + ..Default::default() + }; + let mut wt = worktree( + "feat/login", + "/home/naps/tea/maestro/worktrees/feat/login", + 1, + ); + wt.in_the_way = InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }; + view.apply(vec![wt]); + + let rendered = render_to_string(160, 20, |f| view.render(f, f.area())); + assert!(rendered.contains("feat/login"), "{rendered}"); + // Path abbreviated against the daemon's home dir, same helper as the + // draft pane's project list. + assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}"); + assert!(rendered.contains("force-killed"), "{rendered}"); + // Phrasing comes from InTheWay's Display impl — shared with the kill + // refusal and the web client. + assert!( + rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"), + "{rendered}" + ); + } + + #[test] + fn empty_store_looks_like_a_normal_state_not_an_error() { + let view = WorktreesView::default(); + let rendered = render_to_string(80, 12, |f| view.render(f, f.area())); + assert!(rendered.contains("no retained worktrees"), "{rendered}"); + assert!(!rendered.contains("error"), "{rendered}"); + assert!(!rendered.contains("failed"), "{rendered}"); + } + + #[test] + fn highlight_moves_and_wraps() { + let mut view = WorktreesView::default(); + view.apply(vec![ + worktree("a", "/r/a", 1), + worktree("b", "/r/b", 2), + worktree("c", "/r/c", 3), + ]); + assert_eq!(view.selected().unwrap().branch, "a"); + view.move_highlight(1); + assert_eq!(view.selected().unwrap().branch, "b"); + view.move_highlight(-1); + assert_eq!(view.selected().unwrap().branch, "a"); + view.move_highlight(-1); + // Wraps backward to the last entry. + assert_eq!(view.selected().unwrap().branch, "c"); + view.move_highlight(1); + assert_eq!(view.selected().unwrap().branch, "a"); + } + + #[test] + fn apply_clamps_a_stale_highlight() { + let mut view = WorktreesView::default(); + view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]); + view.move_highlight(1); + view.apply(vec![worktree("c", "/r/c", 3)]); + assert_eq!(view.selected().unwrap().branch, "c"); + } + + #[test] + fn reclaim_confirm_names_the_branch_and_says_it_survives() { + let confirm = ReclaimConfirm { + branch: "feat/login".into(), + blockers: "2 uncommitted files".into(), + }; + let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area())); + assert!( + rendered.contains("Reclaim worktree feat/login?"), + "{rendered}" + ); + assert!(rendered.contains("2 uncommitted files"), "{rendered}"); + assert!(rendered.contains("never deleted"), "{rendered}"); + assert!(rendered.contains("yes"), "{rendered}"); + assert!(rendered.contains("no"), "{rendered}"); + } +} From b96f18c7cbe21bd352ad09b74ce460ebac7f5205 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:29:45 +0100 Subject: [PATCH 56/71] feat(maestro): forced kill ends the session and retains the worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --force overrides the kill refusal and nothing else (#81). The guarded worktree is recorded in the retained store with reason force-killed — never removed — and the outcome names what was kept so a client can say where the work went. A tree whose guard is clear never needed the override; force removes it as usual. The store write happens before teardown, so a failed retention leaves the session running and every artifact intact. DELETE /api/sessions/:id takes ?force=true and answers 200 with KillOutcome; the unforced kill keeps its 204. --- crates/maestro-daemon/src/cli.rs | 12 +- crates/maestro-daemon/src/local_api.rs | 28 +++- crates/maestro-daemon/src/session.rs | 139 ++++++++++++++--- crates/maestro-daemon/src/worktree_probe.rs | 8 + crates/maestro-daemon/tests/attach.rs | 8 +- crates/maestro-daemon/tests/common/mod.rs | 9 ++ crates/maestro-daemon/tests/local_api.rs | 65 +++++++- .../maestro-daemon/tests/session_decisions.rs | 4 +- .../maestro-daemon/tests/session_manager.rs | 144 ++++++++++++++++-- 9 files changed, 371 insertions(+), 46 deletions(-) diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index c3781a5..cef210d 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -56,6 +56,9 @@ async fn run(config_override: Option) -> Result<(), Box> { Some(p) => Tmux::new().with_tmpdir(p), None => Tmux::new(), }; + let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new( + config.retained_worktree_dir(), + )); let manager = SessionManager::new_with_replay( std::sync::Arc::new(tmux), sidecar, @@ -66,7 +69,10 @@ async fn run(config_override: Option) -> Result<(), Box> { .with_spawn_history(crate::spawn_history::SpawnHistory::new( config.spawn_history_path(), config.spawn_history_days, - )); + )) + // One store instance shared with the API handlers: forced kills (#81) + // record here, `GET /api/worktrees` (#80) reads back from here. + .with_retained_store((*retained).clone()); let recovered = manager.recover().await?; tracing::info!(recovered, "adopted existing tmux sessions"); @@ -85,9 +91,7 @@ async fn run(config_override: Option) -> Result<(), Box> { config.home_dir.clone(), config.spawn_presets.clone(), projects, - std::sync::Arc::new(crate::retained_store::RetainedStore::new( - config.retained_worktree_dir(), - )), + retained, )); let listener = TcpListener::bind(config.bind_addr).await?; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 106f73a..6ae1c3a 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -101,6 +101,8 @@ fn session_error_status(code: &str) -> StatusCode { "invalid_env" => StatusCode::BAD_REQUEST, // A guard refusal is the daemon doing its job, not a failure (#79). "kill_guarded" => StatusCode::CONFLICT, + // The retention record failed before anything was torn down (#81). + "retain_failed" => StatusCode::INTERNAL_SERVER_ERROR, _ => StatusCode::BAD_GATEWAY, } } @@ -179,9 +181,29 @@ async fn create_session( /// The full error info is passed through rather than rebuilt from /// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work /// breakdown clients render as "3 uncommitted files, 2 unpushed commits". -async fn kill_session(State(s): State, Path(id): Path) -> Response { - match s.manager.kill(id).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), +/// +/// With `force=true` (#81) the refusal is overridden and the worktree +/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept. +/// Without it the historical `204` stands. +#[derive(Debug, Deserialize)] +struct KillQuery { + #[serde(default)] + force: bool, +} + +async fn kill_session( + State(s): State, + Path(id): Path, + Query(q): Query, +) -> Response { + match s.manager.kill(id, q.force).await { + Ok(outcome) => { + if q.force { + (StatusCode::OK, Json(outcome)).into_response() + } else { + StatusCode::NO_CONTENT.into_response() + } + } Err(e) => { let info = e.to_error_info(); let status = session_error_status(&info.code); diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 2c269d7..9edd43c 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -12,8 +12,8 @@ use std::time::Duration; use chrono::Utc; use maestro_protocol::{ - ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus, - WorktreeSpec, + ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason, + RetainedWorktree, Session, SessionStatus, WorktreeSpec, }; use tokio::sync::{broadcast, Mutex}; use uuid::Uuid; @@ -44,6 +44,11 @@ pub struct SessionRecord { /// `base_repo` from the original `WorktreeSpec`. Needed because `git /// worktree remove` is invoked from the base repo. pub worktree_base_repo: Option, + /// Branch the worktree was created for, recorded at spawn time so a + /// forced kill (#81) can name it in the retained store without asking + /// git. Sessions adopted from a sidecar have no branch on record and + /// fall back to a probe. + pub worktree_branch: Option, pub bridge: PtyBridge, } @@ -66,6 +71,11 @@ pub enum SessionError { /// so it stays the operator's handle on their own unfinished work. #[error("kill refused: the session's worktree holds unsaved work ({0})")] KillGuarded(maestro_protocol::InTheWay), + /// A forced kill (#81) records the retained worktree *before* tearing + /// anything down; if that write fails nothing was destroyed, and the + /// error says so rather than silently dropping unrecorded work. + #[error("could not record the retained worktree: {0}")] + RetainFailed(String), } impl SessionError { @@ -78,6 +88,7 @@ impl SessionError { SessionError::NotFound(_) => "not_found", SessionError::InvalidEnv(_) => "invalid_env", SessionError::KillGuarded(_) => "kill_guarded", + SessionError::RetainFailed(_) => "retain_failed", }; let unsaved_work = match self { SessionError::KillGuarded(b) => Some(*b), @@ -110,6 +121,9 @@ struct Inner { /// `None` when not wired (tests, or before `with_spawn_history`); a present /// but disabled (`0`-day) history no-ops internally. spawn_history: Option, + /// Retained-worktree store (#78). `None` when not wired (tests); a forced + /// kill (#81) without one fails rather than destroying unrecorded work. + retained: Option, sessions: Mutex>, events: broadcast::Sender, } @@ -148,6 +162,7 @@ impl SessionManager { fifo_dir, replay_bytes, spawn_history: None, + retained: None, sessions: Mutex::new(HashMap::new()), events, }), @@ -170,6 +185,16 @@ impl SessionManager { self } + /// Attach the retained-worktree store (#78). A forced kill (#81) needs it + /// to record what it retained; without one, forcing a guarded kill fails + /// instead of destroying unrecorded work. + pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self { + let inner = Arc::get_mut(&mut self.inner) + .expect("with_retained_store must be called before the SessionManager is cloned"); + inner.retained = Some(store); + self + } + /// Recent working dirs for the spawn form's picker (#24), newest-first and /// deduped by dir. Empty when no history is wired or it's disabled. pub fn recent_dirs(&self) -> Vec { @@ -340,6 +365,7 @@ impl SessionManager { fifo_path, worktree_path, worktree_base_repo, + worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()), bridge, }, ); @@ -391,11 +417,17 @@ impl SessionManager { /// work, so ending a session that still matters becomes a deliberate /// `--force`, which retains rather than destroys (WORKTREES.md). /// + /// With `force` (#81) the refusal — and nothing else — is overridden: + /// the session ends and the guarded worktree is retained into the store + /// with reason `force-killed`, never removed. The outcome names what was + /// kept so a client can say where the work went. A tree whose guard is + /// clear never needed the override; force removes it as usual. + /// /// This is also how an already-dead session is dismissed. tmux is only /// asked to kill something it still has — calling `kill-session` on a /// session tmux already lost fails, and that failure is not interesting. - pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> { - let (worktree, alive) = { + pub async fn kill(&self, id: Uuid, force: bool) -> Result { + let (worktree, alive, known_branch) = { let sessions = self.inner.sessions.lock().await; match sessions.get(&id) { None => return Err(SessionError::NotFound(id)), @@ -405,26 +437,75 @@ impl SessionManager { .clone() .zip(record.worktree_path.clone()), record.session.status == SessionStatus::Running, + record.worktree_branch.clone(), ), } }; - // The guard runs before anything is torn down: refusing must leave the - // session running, exactly as the operator left it. - if let Some((_, path)) = worktree { - let probe = tmux_blocking(move || crate::worktree_probe::in_the_way(&path)) - .await - .map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))?; - if !probe.is_clear() { - return Err(SessionError::KillGuarded(probe)); + // The probe runs before anything is torn down: both refusing (#79) + // and recording the retention (#81) must see the tree as it stands, + // and a refused kill leaves the session running as the operator left + // it. + let probe = match &worktree { + Some((_, path)) => { + let path = path.clone(); + Some( + tmux_blocking(move || crate::worktree_probe::in_the_way(&path)) + .await + .map_err(|e| { + SessionError::Worktree(crate::worktree::WorktreeError::from(e)) + })?, + ) } + None => None, + }; + let guarded = probe.as_ref().is_some_and(|p| !p.is_clear()); + if guarded && !force { + return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed"))); } + + // Record the retention BEFORE tearing down: a failed store write then + // leaves the session running and every artifact untouched, instead of + // ending a session whose work outlives it unrecorded. + let mut retained = None; + if guarded { + let store = + self.inner.retained.clone().ok_or_else(|| { + SessionError::RetainFailed("no retained store is wired".into()) + })?; + let (base_repo, path) = worktree.as_ref().expect("guarded ⇒ worktree"); + let branch = known_branch + .or_else(|| crate::worktree_probe::current_branch(path)) + .unwrap_or_default(); + let entry = { + let store = store.clone(); + let base_repo = base_repo.clone(); + let path = path.clone(); + tmux_blocking(move || { + store.retain(&path, &base_repo, &branch, RetainedReason::ForceKilled) + }) + .await + .map_err(|e| SessionError::RetainFailed(e.to_string()))? + }; + retained = Some((entry, probe.expect("guarded ⇒ probed"))); + } + let removed = { let mut sessions = self.inner.sessions.lock().await; sessions.remove(&id) }; let record = removed.ok_or(SessionError::NotFound(id))?; - cleanup_record(&self.inner, id, record, alive).await; - Ok(()) + cleanup_record(&self.inner, id, record, alive, retained.is_some()).await; + Ok(KillOutcome { + retained: retained.map(|(entry, in_the_way)| RetainedWorktree { + id: entry.id, + path: entry.path, + base_repo: entry.base_repo, + branch: entry.branch, + reason: entry.reason, + retained_at: entry.retained_at, + in_the_way, + }), + }) } /// Spawn a background task that polls tmux every `interval` and reaps any @@ -511,6 +592,9 @@ impl SessionManager { // directory blocks that branch forever (#54 review). worktree_path: session.worktree.as_ref().map(|w| w.path.clone()), worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()), + // The sidecar carries no branch; a forced kill of a + // recovered session probes git for it at retain time. + worktree_branch: None, bridge, }, ); @@ -750,7 +834,17 @@ async fn mark_session_dead(inner: &Arc, id: Uuid) { /// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't /// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the /// liveness reaper (tmux already lost the session). -async fn cleanup_record(inner: &Arc, id: Uuid, record: SessionRecord, kill_tmux: bool) { +/// +/// `retain_worktree` is `true` for a forced kill (#81) that retained the +/// worktree instead of removing it: the store entry is already written, so +/// only the removal is skipped. +async fn cleanup_record( + inner: &Arc, + id: Uuid, + record: SessionRecord, + kill_tmux: bool, + retain_worktree: bool, +) { if kill_tmux { let tmux = inner.tmux.clone(); let _ = tmux_blocking(move || tmux.kill_session(id)).await; @@ -759,11 +853,18 @@ async fn cleanup_record(inner: &Arc, id: Uuid, record: SessionRecord, kil if let Err(e) = inner.sidecar.delete(id) { tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar"); } - // The kill already passed the unsaved-work guard (#79), so this removal - // is unconditional; the manager's own nested-worktree refusal is all that - // can still leave a tree on disk, and it says where. if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { - if let Err(e) = inner.worktree_mgr.remove(base, path) { + if retain_worktree { + tracing::info!( + %id, + path = %path.display(), + "forced kill: worktree retained in the store" + ); + } else if let Err(e) = inner.worktree_mgr.remove(base, path) { + // The kill already passed the unsaved-work guard (#79), so this + // removal is unconditional; the manager's own nested-worktree + // refusal is all that can still leave a tree on disk, and it + // says where. tracing::warn!( %id, path = %path.display(), diff --git a/crates/maestro-daemon/src/worktree_probe.rs b/crates/maestro-daemon/src/worktree_probe.rs index 24f81cb..f1720e7 100644 --- a/crates/maestro-daemon/src/worktree_probe.rs +++ b/crates/maestro-daemon/src/worktree_probe.rs @@ -90,6 +90,14 @@ pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> { .map(|_| ()) } +/// Branch checked out at `path`. Sessions adopted from a sidecar carry no +/// branch on record, so a forced kill (#81) asks the tree itself when it +/// needs one for the retained store. `None` when git cannot answer. +pub fn current_branch(path: &Path) -> Option { + let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + fn run_git(dir: &Path, args: I) -> Result where I: IntoIterator, diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index 09f1d32..6aa0e31 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -122,7 +122,7 @@ async fn pty_round_trip_through_real_tmux() { "marker not seen in {} bytes", got.len() ); - let _ = h.manager.kill(id).await; + let _ = h.manager.kill(id, false).await; } #[tokio::test] @@ -170,7 +170,7 @@ async fn multi_attach_broadcasts_to_every_client() { "client B missed marker" ); - let _ = h.manager.kill(id).await; + let _ = h.manager.kill(id, false).await; } #[tokio::test] @@ -219,7 +219,7 @@ async fn sends_close_frame_when_session_is_killed() { // give the broadcast subscriber a moment to register before killing. tokio::time::sleep(Duration::from_millis(100)).await; - h.manager.kill(id).await.unwrap(); + h.manager.kill(id, false).await.unwrap(); let mut saw_close = false; let deadline = std::time::Instant::now() + Duration::from_secs(2); @@ -267,7 +267,7 @@ async fn ping_text_frame_gets_pong() { } } assert!(saw_pong, "no Pong frame received"); - let _ = h.manager.kill(id).await; + let _ = h.manager.kill(id, false).await; } #[tokio::test] diff --git a/crates/maestro-daemon/tests/common/mod.rs b/crates/maestro-daemon/tests/common/mod.rs index 3603e33..a079660 100644 --- a/crates/maestro-daemon/tests/common/mod.rs +++ b/crates/maestro-daemon/tests/common/mod.rs @@ -176,6 +176,11 @@ impl DaemonFixture { manager = manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days)); } + // Same wiring as production: the manager records retained worktrees + // into the same dir the API reads back from (#78/#81). + let retained = + maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir)); + manager = manager.with_retained_store(retained.clone()); Self { tmux, manager, @@ -187,6 +192,10 @@ impl DaemonFixture { state_dir.path().join("spawn-history.jsonl") } + fn retained_path(state_dir: &TempDir) -> PathBuf { + state_dir.path().join("retained-worktrees") + } + pub fn spawn_history_path(&self) -> PathBuf { Self::history_path(&self.state_dir) } diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index 10f08bb..ce6e071 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -25,6 +25,13 @@ struct Harness { _fx: DaemonFixture, } +impl Harness { + /// Store over the same dir the manager records into. + fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore { + self._fx.retained_store() + } +} + async fn spawn(hostname: &str) -> Harness { spawn_with( hostname, @@ -386,6 +393,62 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the assert!(h.manager.has(session.id).await, "the session keeps running"); } +/// `?force=true` on the wire (#81): the refusal is overridden, the session +/// ends, and the answer is 200 with the retained worktree named — path, +/// branch, reason — while the directory survives on disk. +#[tokio::test] +async fn delete_with_force_ends_the_session_and_answers_what_was_retained() { + let h = spawn("naps-laptop").await; + let repo = tempfile::TempDir::new().unwrap(); + common::init_repo(repo.path()); + let wt_path = repo + .path() + .join("worktrees") + .join("feat") + .join("guarded") + .to_path_buf(); + let session = h + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 60".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/guarded".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .unwrap(); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + let resp = reqwest::Client::new() + .delete(url(h.addr, &format!("/api/sessions/{}", session.id))) + .query(&[("force", "true")]) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + let body: maestro_protocol::KillOutcome = resp.json().await.unwrap(); + let retained = body.retained.expect("force names what was kept"); + assert_eq!(retained.path, wt_path); + assert_eq!(retained.branch, "feat/guarded"); + assert_eq!( + retained.reason, + maestro_protocol::RetainedReason::ForceKilled + ); + + // The tree would have blocked; force must not have removed it. + assert!(wt_path.exists(), "a forced kill never removes the worktree"); + // And the store entry exists, so reclaim can find it (#80). + let all = h.fx_retained().load_all().unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, retained.id); +} + #[tokio::test] async fn attach_ws_streams_pty_bytes() { let h = spawn("naps-laptop").await; @@ -476,7 +539,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() { other => panic!("expected SessionAdded, got {other:?}"), } - h.manager.kill(created.id).await.expect("kill"); + h.manager.kill(created.id, false).await.expect("kill"); // Activity ticks may interleave; the removal is what we are waiting for. let deadline = tokio::time::Instant::now() + Duration::from_secs(5); diff --git a/crates/maestro-daemon/tests/session_decisions.rs b/crates/maestro-daemon/tests/session_decisions.rs index fb48862..aae5475 100644 --- a/crates/maestro-daemon/tests/session_decisions.rs +++ b/crates/maestro-daemon/tests/session_decisions.rs @@ -211,7 +211,7 @@ async fn killing_a_dead_session_dismisses_it_without_touching_tmux() { fx.manager.start_liveness_watcher(Duration::from_millis(25)); wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await; - fx.manager.kill(s.id).await.expect("dismiss"); + fx.manager.kill(s.id, false).await.expect("dismiss"); assert!(fx.manager.snapshot().await.is_empty()); assert!( @@ -371,7 +371,7 @@ async fn killing_an_unknown_session_is_reported_as_not_found() { let fx = Fx::new(); let err = fx .manager - .kill(uuid::Uuid::new_v4()) + .kill(uuid::Uuid::new_v4(), false) .await .expect_err("should fail"); assert_eq!(err.to_error_info().code, "not_found"); diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 4b1f25f..234421c 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -44,7 +44,7 @@ async fn create_then_kill_round_trip() { // FIFO should also exist assert!(fx.fifo_dir().join(format!("{}.fifo", session.id)).exists()); - fx.manager.kill(session.id).await.expect("kill"); + fx.manager.kill(session.id, false).await.expect("kill"); assert!(!fx.manager.has(session.id).await); assert!(!fx .sidecar_dir() @@ -79,7 +79,7 @@ async fn create_appends_to_spawn_history() { assert_eq!(recents[0].cwd, cwd.path()); assert_eq!(recents[0].last_command, "sleep 30"); - fx.manager.kill(session.id).await.expect("kill"); + fx.manager.kill(session.id, false).await.expect("kill"); // History is durable — it survives the kill that deletes the sidecar. assert_eq!(fx.manager.recent_dirs().len(), 1); } @@ -185,7 +185,7 @@ async fn create_captures_initial_output_in_replay_buffer() { "first-line marker never reached the replay buffer — pipe-pane raced past the child's initial output", ); - fx.manager.kill(s.id).await.unwrap(); + fx.manager.kill(s.id, false).await.unwrap(); } /// Regression for NAP-228: after `recover`, PTY output must reach new @@ -251,7 +251,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() { String::from_utf8_lossy(&buf), ); - revived.kill(s.id).await.unwrap(); + revived.kill(s.id, false).await.unwrap(); } #[tokio::test] @@ -300,7 +300,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() { assert!(buf_a.windows(want.len()).any(|w| w == want), "A missed"); assert!(buf_b.windows(want.len()).any(|w| w == want), "B missed"); - fx.manager.kill(s.id).await.unwrap(); + fx.manager.kill(s.id, false).await.unwrap(); let _ = (cwd, PathBuf::from("/")); } @@ -359,7 +359,7 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() { ); // Dismissing it is what finally releases the artifacts. - fx.manager.kill(s.id).await.expect("dismiss"); + fx.manager.kill(s.id, false).await.expect("dismiss"); assert!(!fx.manager.has(s.id).await); assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists()); assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists()); @@ -398,7 +398,7 @@ async fn kill_removes_worktree_even_when_spec_says_keep() { init_bare_repo(origin.path()); push_all_to_origin(repo.path(), origin.path()); - fx.manager.kill(s.id).await.expect("kill"); + fx.manager.kill(s.id, false).await.expect("kill"); assert!(!wt_path.exists(), "worktree must be removed on kill"); } @@ -432,7 +432,11 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() { std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap(); - let err = fx.manager.kill(s.id).await.expect_err("kill must refuse"); + let err = fx + .manager + .kill(s.id, false) + .await + .expect_err("kill must refuse"); let SessionError::KillGuarded(breakdown) = err else { panic!("expected KillGuarded, got {err:?}"); }; @@ -456,7 +460,10 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() { let origin = TempDir::new().unwrap(); init_bare_repo(origin.path()); push_all_to_origin(repo.path(), origin.path()); - fx.manager.kill(s.id).await.expect("kill after saving"); + fx.manager + .kill(s.id, false) + .await + .expect("kill after saving"); assert!(!fx.manager.has(s.id).await); } @@ -487,7 +494,11 @@ async fn killing_refuses_until_the_branch_is_pushed() { .await .expect("create"); - let err = fx.manager.kill(s.id).await.expect_err("unpushed blocks"); + let err = fx + .manager + .kill(s.id, false) + .await + .expect_err("unpushed blocks"); let SessionError::KillGuarded(breakdown) = err else { panic!("expected KillGuarded, got {err:?}"); }; @@ -499,11 +510,118 @@ async fn killing_refuses_until_the_branch_is_pushed() { init_bare_repo(origin.path()); push_all_to_origin(repo.path(), origin.path()); - fx.manager.kill(s.id).await.expect("pushed branch kills"); + fx.manager + .kill(s.id, false) + .await + .expect("pushed branch kills"); assert!(!wt_path.exists()); assert!(!fx.manager.has(s.id).await); } +/// `--force` overrides the refusal and nothing else (#81): the session ends, +/// but the guarded worktree is RETAINED — never removed — and enters the +/// retained store with reason `force-killed`. The outcome names what was +/// kept, so a client can say where the work went. +#[tokio::test] +async fn force_killing_a_guarded_worktree_retains_it_in_the_store() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("forced"); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/forced".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + // Without force the same kill is refused (#79). + assert!(fx.manager.kill(s.id, false).await.is_err()); + + let outcome = fx + .manager + .kill(s.id, true) + .await + .expect("force overrides the refusal"); + + // The tree would have blocked, so force must NOT have removed it... + assert!( + wt_path.exists(), + "a forced kill never removes a tree that would have blocked" + ); + // ...but the session itself still ends. + assert!(!fx.manager.has(s.id).await); + assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists()); + + let retained = outcome.retained.expect("the outcome names what was kept"); + assert_eq!(retained.path, wt_path); + assert_eq!(retained.branch, "feat/forced"); + assert_eq!( + retained.reason, + maestro_protocol::RetainedReason::ForceKilled + ); + assert!( + retained.in_the_way.uncommitted_files >= 1, + "the outcome carries what blocked the unforced kill" + ); + + // The store holds the same entry, so reclaim can find it later (#80). + let all = fx.retained_store().load_all().expect("load_all"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].id, retained.id); + assert_eq!(all[0].path, wt_path); + assert_eq!(all[0].branch, "feat/forced"); +} + +/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose +/// guard is clear never needed the override, so a forced kill removes it as +/// usual and retains nothing. +#[tokio::test] +async fn force_on_a_clear_worktree_removes_it_as_usual() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("clear"); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/clear".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + let outcome = fx.manager.kill(s.id, true).await.expect("kill"); + assert!(!wt_path.exists(), "a clear tree needs no override"); + assert_eq!(outcome.retained, None); + assert!(fx.retained_store().load_all().unwrap().is_empty()); +} + /// Ignored files NEVER block (#79): build output like `target/` exists in /// every Rust checkout, and an ignored-only worktree stays killable. #[tokio::test] @@ -543,7 +661,7 @@ async fn killing_an_ignored_only_worktree_succeeds() { push_all_to_origin(repo.path(), origin.path()); fx.manager - .kill(s.id) + .kill(s.id, false) .await .expect("ignored files never block"); assert!(!wt_path.exists()); @@ -587,5 +705,5 @@ async fn liveness_watcher_leaves_running_sessions_alone() { tokio::time::sleep(Duration::from_millis(400)).await; assert!(fx.manager.has(s.id).await, "live session got reaped"); - fx.manager.kill(s.id).await.unwrap(); + fx.manager.kill(s.id, false).await.unwrap(); } From 56c8e35d6f005f2c9aca9c377340c3606e421f1c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:32:22 +0100 Subject: [PATCH 57/71] feat(maestro): wire worktrees view into the tui loop (#85) w opens the view; r confirms reclaim and says the branch survives. The in-the-way summary uses InTheWay's Display impl so the TUI cannot drift from the CLI's wording. --- crates/maestro-tui/src/action.rs | 3 + crates/maestro-tui/src/app.rs | 270 ++++++++++++++++++++++++++++++- crates/maestro-tui/src/config.rs | 5 + crates/maestro-tui/src/render.rs | 20 +++ crates/maestro-tui/src/state.rs | 11 ++ 5 files changed, 305 insertions(+), 4 deletions(-) diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 98d5354..0efba87 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -43,6 +43,8 @@ pub enum Action { /// resolved; unresolvable defaults open a pre-filled draft instead. FastForwardSpawn, KillHighlighted, + /// Open the retained-worktrees view (#85). + ShowWorktrees, // Tabs NewTab, @@ -84,6 +86,7 @@ impl Action { "NewDraftPane" => return Ok(Action::NewDraftPane), "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), + "ShowWorktrees" => return Ok(Action::ShowWorktrees), "NewTab" => return Ok(Action::NewTab), "CloseTab" => return Ok(Action::CloseTab), "NextTab" => return Ok(Action::NextTab), diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 96b5375..1f93e76 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -18,7 +18,9 @@ use std::time::Duration; use chrono::Utc; use crossterm::event::{Event as CtEvent, EventStream}; use futures_util::StreamExt; -use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch}; +use maestro_protocol::{ + routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree, +}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use uuid::Uuid; @@ -37,6 +39,7 @@ use crate::session_task::{SessionInput, SessionTask}; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind, PaneRef}; use crate::tabs::TabBar; +use crate::worktrees::WorktreesView; use crate::TuiError; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -55,7 +58,8 @@ pub struct App { help: HelpOverlay, chord: ChordState, daemon: DaemonClient, - /// Results of draft data loads, delivered back to the event loop. + /// Results of draft data loads (and the worktrees view fetch, #85), + /// delivered back to the event loop. /// /// These are network calls — `/api/projects` on a cold cache walks all of /// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no @@ -66,6 +70,9 @@ pub struct App { /// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet: /// `GET /api/names` (#64). http: reqwest::Client, + /// Entries of the retained-worktrees view (#85). Daemon data, kept here + /// rather than in `AppState` so the overlay kind stays payload-free. + worktrees: WorktreesView, region: RegionMap, last_sent_size: HashMap, clicks: ClickTracker, @@ -109,6 +116,11 @@ pub enum Effect { }, /// Fetch the draft target's branches from `/api/branches` (#65). LoadBranches(usize), + /// Fetch `/api/worktrees` for the retained-worktrees view (#85). + LoadWorktrees, + /// Reclaim one retained worktree (#85) — removes its directory; the + /// branch is never deleted. + ReclaimWorktree(Uuid), } impl App { @@ -132,6 +144,7 @@ impl App { chord: ChordState::new(chord_timeout), daemon, http, + worktrees: WorktreesView::default(), draft_tx, draft_rx, region: RegionMap::default(), @@ -225,6 +238,8 @@ impl App { self.spawn_probe_git_root(tab, seq, path) } Effect::LoadBranches(tab) => self.spawn_load_branches(tab), + Effect::LoadWorktrees => self.spawn_load_worktrees(), + Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await, Effect::Forward { id, bytes } => { if let Some(handle) = self.emulators.get(id) { let _ = handle.tx.send(SessionInput::Bytes(bytes)).await; @@ -247,6 +262,44 @@ impl App { } _ => (OverlayDecision::Consumed, Vec::new()), }, + Some(OverlayKind::Worktrees) => match key { + InputKey::Char { c: 'j', .. } | InputKey::Down => { + self.worktrees.move_highlight(1); + (OverlayDecision::Consumed, Vec::new()) + } + InputKey::Char { c: 'k', .. } | InputKey::Up => { + self.worktrees.move_highlight(-1); + (OverlayDecision::Consumed, Vec::new()) + } + InputKey::Char { c: 'r', .. } => { + if let Some(wt) = self.worktrees.selected() { + self.state.show_overlay(OverlayKind::ReclaimConfirm { + id: wt.id, + branch: wt.branch.clone(), + // Shared phrasing: InTheWay's Display impl is what + // the kill refusal and the web client use too. + blockers: wt.in_the_way.to_string(), + }); + } + (OverlayDecision::Consumed, Vec::new()) + } + InputKey::Char { c: 'q', .. } | InputKey::Esc => { + self.state.dismiss_overlay(); + (OverlayDecision::Consumed, Vec::new()) + } + _ => (OverlayDecision::Consumed, Vec::new()), + }, + Some(OverlayKind::ReclaimConfirm { id, .. }) => match key { + InputKey::Char { c: 'y', .. } => { + self.state.dismiss_overlay(); + (OverlayDecision::Consumed, vec![Effect::ReclaimWorktree(id)]) + } + InputKey::Char { c: 'n', .. } | InputKey::Esc => { + self.state.show_overlay(OverlayKind::Worktrees); + (OverlayDecision::Consumed, Vec::new()) + } + _ => (OverlayDecision::Consumed, Vec::new()), + }, Some(OverlayKind::Help) => { self.state.dismiss_overlay(); (OverlayDecision::Consumed, Vec::new()) @@ -317,6 +370,10 @@ impl App { self.state.show_overlay(OverlayKind::KillConfirm(sid)); } } + Action::ShowWorktrees => { + self.state.show_overlay(OverlayKind::Worktrees); + effects.push(Effect::LoadWorktrees); + } } effects } @@ -505,13 +562,22 @@ impl App { self.fast_forward_spawn(info, projects, names).await; return; } + if let DraftUpdate::WorktreesLoaded { + worktrees, + home_dir, + } = update + { + self.worktrees.apply(worktrees); + self.worktrees.home_dir = home_dir; + return; + } let want = match &update { DraftUpdate::Data { draft, .. } | DraftUpdate::Names { draft, .. } | DraftUpdate::GitRoot { draft, .. } | DraftUpdate::Branches { draft, .. } => *draft, // Handled above; it targets no particular draft. - DraftUpdate::FastForward { .. } => unreachable!(), + DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(), }; // Find it by identity: closing a tab shifts every index to its right, // so an in-flight load addressed by index would be dropped or land on @@ -566,7 +632,7 @@ impl App { DraftUpdate::Branches { cwd, branches, .. } => { draft.set_branches(&cwd, branches); } - DraftUpdate::FastForward { .. } => unreachable!(), + DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(), } if follow_up_branches { self.spawn_load_branches(tab_idx); @@ -673,6 +739,38 @@ impl App { } } + /// Fetch the retained-worktree store without blocking the loop (#85) — + /// same off-loop pattern as the draft loaders. + fn spawn_load_worktrees(&self) { + let (daemon, _, _, tx) = self.loader_handles(); + tokio::spawn(async move { + let (worktrees, info) = tokio::join!(daemon.list_worktrees(), daemon.daemon_info()); + let _ = tx.send(DraftUpdate::WorktreesLoaded { + worktrees: worktrees.unwrap_or_default(), + home_dir: info.map(|i| i.home_dir).unwrap_or_default(), + }); + }); + } + + /// Reclaim one retained worktree (#85): the directory goes, the branch + /// never does. The open view drops the entry so the list tells the truth + /// without waiting for a re-fetch. + async fn submit_reclaim(&mut self, id: Uuid) { + match self.daemon.reclaim_worktree(id).await { + Ok(()) => { + self.worktrees.remove(id); + self.state.toast("worktree reclaimed; branch kept"); + } + Err(e) => { + let message = e + .daemon_message() + .map(String::from) + .unwrap_or_else(|| e.to_string()); + self.state.toast(format!("reclaim failed: {message}")); + } + } + } + fn redraw( &mut self, terminal: &mut Terminal>, @@ -713,6 +811,7 @@ impl App { state: &self.state, draft, help: &self.help, + worktrees: Some(&self.worktrees), }; let mut new_region = RegionMap::default(); @@ -857,6 +956,11 @@ enum DraftUpdate { projects: Option>, names: Vec, }, + /// The retained-worktree store arrived for the worktrees view (#85). + WorktreesLoaded { + worktrees: Vec, + home_dir: String, + }, } /// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`. @@ -1509,4 +1613,162 @@ mod tests { app.dispatch(Action::KillHighlighted); assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid)); } + + // -- retained worktrees (#85) ------------------------------------------- + + fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree { + use chrono::TimeZone; + maestro_protocol::RetainedWorktree { + id: Uuid::from_u128(id), + path: "/home/naps/tea/maestro/worktrees/feat/login".into(), + base_repo: "/home/naps/tea/maestro".into(), + branch: "feat/login".into(), + reason: maestro_protocol::RetainedReason::ForceKilled, + retained_at: chrono::Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(), + in_the_way: maestro_protocol::InTheWay { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }, + } + } + + fn app_with_one_retained_worktree() -> App { + let mut app = make_app(); + app.worktrees.home_dir = "/home/naps".into(); + app.worktrees.apply(vec![sample_worktree(42)]); + app.state.show_overlay(OverlayKind::Worktrees); + app + } + + #[test] + fn show_worktrees_opens_the_view_and_fetches() { + let mut app = make_app(); + assert_eq!( + app.dispatch(Action::ShowWorktrees), + vec![Effect::LoadWorktrees] + ); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees)); + } + + #[test] + fn r_over_the_highlighted_worktree_asks_for_a_confirmation() { + let mut app = app_with_one_retained_worktree(); + + let (decision, effects) = app.handle_overlay_input(&InputKey::char('r')); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + match app.state.overlay() { + Some(OverlayKind::ReclaimConfirm { id, branch, .. }) => { + assert_eq!(*id, Uuid::from_u128(42)); + assert_eq!(branch, "feat/login"); + } + other => panic!("expected a reclaim confirm, got {other:?}"), + } + } + + /// The confirmation names what is in the way using the shared phrasing, + /// so the confirm step cannot drift from the list or the CLI. + #[test] + fn the_reclaim_confirmation_carries_the_shared_in_the_way_phrasing() { + let mut app = app_with_one_retained_worktree(); + app.handle_overlay_input(&InputKey::char('r')); + match app.state.overlay() { + Some(OverlayKind::ReclaimConfirm { blockers, .. }) => { + assert_eq!( + blockers, + "2 uncommitted files, 1 untracked file, 3 unpushed commits" + ); + } + other => panic!("expected a reclaim confirm, got {other:?}"), + } + } + + #[test] + fn confirming_a_reclaim_asks_for_the_reclaim() { + let mut app = app_with_one_retained_worktree(); + app.handle_overlay_input(&InputKey::char('r')); + + let (decision, effects) = app.handle_overlay_input(&InputKey::char('y')); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!(effects, vec![Effect::ReclaimWorktree(Uuid::from_u128(42))]); + assert!(app.state.overlay().is_none()); + } + + #[test] + fn declining_a_reclaim_returns_to_the_list() { + let mut app = app_with_one_retained_worktree(); + app.handle_overlay_input(&InputKey::char('r')); + + let (decision, effects) = app.handle_overlay_input(&InputKey::char('n')); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees)); + } + + #[test] + fn j_and_k_move_the_highlight_inside_the_view() { + let mut app = make_app(); + app.worktrees + .apply(vec![sample_worktree(1), sample_worktree(2)]); + // Distinct ids so movement is observable. + app.worktrees.items[1].branch = "other".into(); + app.state.show_overlay(OverlayKind::Worktrees); + + app.handle_overlay_input(&InputKey::char('j')); + assert_eq!(app.worktrees.selected().unwrap().branch, "other"); + app.handle_overlay_input(&InputKey::char('k')); + assert_eq!(app.worktrees.selected().unwrap().branch, "feat/login"); + } + + #[tokio::test] + async fn loading_worktrees_fills_the_view_from_the_daemon() { + use axum::routing::{delete, get}; + use axum::Json; + use serde_json::json; + + async fn info() -> Json { + Json(json!({"hostname": "box", "version": "0.1.0", "home_dir": "/home/naps"})) + } + async fn worktrees() -> Json { + Json(json!([{ + "id": Uuid::from_u128(42).to_string(), + "path": "/home/naps/tea/maestro/worktrees/feat/login", + "base_repo": "/home/naps/tea/maestro", + "branch": "feat/login", + "reason": "force-killed", + "retained_at": "2026-08-24T10:00:00Z", + "in_the_way": {"uncommitted_files": 2, "untracked_files": 1, "unpushed_commits": 3}, + }])) + } + async fn reclaim() -> Json { + Json(json!(null)) + } + + let app_router = axum::Router::new() + .route(maestro_protocol::routes::INFO, get(info)) + .route(maestro_protocol::routes::WORKTREES, get(worktrees)) + .route("/api/worktrees/:id", delete(reclaim)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() }); + + let mut app = make_app_at(&base); + let effects = app.dispatch(Action::ShowWorktrees); + app.perform(effects).await; + let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv()) + .await + .expect("load completed") + .expect("channel open"); + app.apply_draft_update(update).await; + + assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees)); + assert_eq!(app.worktrees.items.len(), 1); + assert_eq!(app.worktrees.items[0].branch, "feat/login"); + assert_eq!(app.worktrees.home_dir, "/home/naps"); + + // Reclaiming drops the entry from the open view. + app.submit_reclaim(Uuid::from_u128(42)).await; + assert!(app.worktrees.items.is_empty()); + } } diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index 26b0251..9f4eb2b 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -50,6 +50,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ // thing with every default resolved and no form. ("S", "FastForwardSpawn"), ("x", "KillHighlighted"), + ("w", "ShowWorktrees"), // Tabs ("ctrl-t", "NewTab"), ("g t", "NextTab"), @@ -166,6 +167,10 @@ mod tests { cfg.keymap.resolve(Mode::Normal, "S"), Some(Action::FastForwardSpawn), ); + assert_eq!( + cfg.keymap.resolve(Mode::Normal, "w"), + Some(Action::ShowWorktrees), + ); } #[test] diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 78e4dee..01e3c9b 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -15,6 +15,7 @@ use crate::pane::Pane; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind}; use crate::tabs::TabBar; +use crate::worktrees::{ReclaimConfirm, WorktreesView}; /// Tagged regions for mouse hit-testing. The input router resolves a mouse /// coordinate to one of these via [`RegionMap::region_at`]. @@ -67,6 +68,8 @@ pub struct RenderInputs<'a> { pub draft: Option<&'a crate::draft::DraftPane>, pub state: &'a AppState, pub help: &'a HelpOverlay, + /// Retained-worktree entries for the worktrees view (#85). + pub worktrees: Option<&'a WorktreesView>, } /// Draw one frame. Returns the `RegionMap` the input router will consult for @@ -153,6 +156,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { inputs.help.render(f, area); map.modal = Some(area); } + Some(OverlayKind::Worktrees) => { + if let Some(view) = inputs.worktrees { + view.render(f, area); + map.modal = Some(area); + } + } + Some(OverlayKind::ReclaimConfirm { + branch, blockers, .. + }) => { + let rc = ReclaimConfirm { + branch: branch.clone(), + blockers: blockers.clone(), + }; + rc.render(f, area); + map.modal = Some(area); + } Some(OverlayKind::Toast(text)) => { let t = Toast { text: text.clone() }; t.render(f, area); @@ -298,6 +317,7 @@ mod tests { draft: Some(draft), state: &state, help: &help, + worktrees: None, }; let backend = ratatui::backend::TestBackend::new(80, 24); diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index b77eb92..4e487c8 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -50,6 +50,17 @@ pub enum OverlayKind { KillConfirm(Uuid), Help, Toast(String), + /// The retained-worktrees view (#85); its entries live on `App` because + /// they are daemon data, not UI state. + Worktrees, + /// Reclaim confirmation for the worktree with this id (#85), guarding + /// the destructive key like [`OverlayKind::KillConfirm`] does. + ReclaimConfirm { + id: Uuid, + branch: String, + /// [`maestro_protocol::InTheWay`] as of when the confirm was opened. + blockers: String, + }, } #[derive(Debug)] From 0c135aa06503523828e801f326c5f86973b352f6 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:34:15 +0100 Subject: [PATCH 58/71] feat(maestro): maestro kill --force MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag overrides the kill refusal (#81): the session ends, the guarded worktree is retained, and the CLI prints where the work went — path, branch, force-killed — instead of leaving the operator to wonder. WORKTREES.md: write down that a forced kill of a tree whose guard is clear removes it as usual — force changes what happens to blocked work, not ordinary cleanup. --- crates/maestro-cli/src/cli.rs | 31 +++++++++++++++++++ crates/maestro-cli/src/dispatch.rs | 9 +++++- crates/maestro-cli/src/sessions.rs | 11 +++++-- .../maestro-cli/tests/against_mock_daemon.rs | 7 +++-- crates/maestro-e2e/src/client.rs | 2 +- crates/maestro-tui/src/app.rs | 10 ++++-- docs/WORKTREES.md | 13 +++++--- 7 files changed, 70 insertions(+), 13 deletions(-) diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index 877d344..e34ffbb 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -95,6 +95,11 @@ pub struct AttachArgs { pub struct KillArgs { /// Session ID to kill. pub session_id: Uuid, + /// End the session even when its worktree holds unsaved work (#81). The + /// worktree is retained — never removed — and enters the retained store; + /// the output says where the work went. + #[arg(long)] + pub force: bool, } fn parse_kv(s: &str) -> Result<(String, String), String> { @@ -226,6 +231,32 @@ mod tests { } } + #[test] + fn parses_kill_with_force() { + let id = "01234567-89ab-cdef-0123-456789abcdef"; + let cli = Cli::try_parse_from(["maestro", "kill", "--force", id]).unwrap(); + match cli.command { + Command::Kill(a) => { + assert!(a.force); + assert_eq!(a.session_id.to_string(), id); + } + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn kill_force_defaults_false() { + let id = "01234567-89ab-cdef-0123-456789abcdef"; + let cli = Cli::try_parse_from(["maestro", "kill", id]).unwrap(); + match cli.command { + Command::Kill(a) => { + assert!(!a.force); + assert_eq!(a.session_id.to_string(), id); + } + _ => panic!("wrong subcommand"), + } + } + #[test] fn rejects_env_without_equals() { let err = Cli::try_parse_from([ diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs index b1a6fed..f96675e 100644 --- a/crates/maestro-cli/src/dispatch.rs +++ b/crates/maestro-cli/src/dispatch.rs @@ -61,8 +61,15 @@ pub async fn dispatch( run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?; } Command::Kill(args) => { - run_kill(&daemon, &args).await?; + let outcome = run_kill(&daemon, &args).await?; println!("killed {}", args.session_id); + if let Some(w) = outcome.retained { + println!( + "worktree retained at {} (branch {}, force-killed)", + w.path.display(), + w.branch + ); + } } } Ok(()) diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 902a2de..64db192 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -59,9 +59,14 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result Result<(), ClientError> { - client.kill_session(args.session_id).await +/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead +/// of removing it; the outcome names what was kept so dispatch can say where +/// the work went. +pub async fn run_kill( + client: &DaemonClient, + args: &KillArgs, +) -> Result { + client.kill_session(args.session_id, args.force).await } #[allow(clippy::write_literal)] diff --git a/crates/maestro-cli/tests/against_mock_daemon.rs b/crates/maestro-cli/tests/against_mock_daemon.rs index 955cc8b..064daaf 100644 --- a/crates/maestro-cli/tests/against_mock_daemon.rs +++ b/crates/maestro-cli/tests/against_mock_daemon.rs @@ -76,7 +76,7 @@ async fn killing_an_unknown_session_reports_the_daemon_reason() { let err = mock .client() - .kill_session(Uuid::new_v4()) + .kill_session(Uuid::new_v4(), false) .await .expect_err("should fail"); @@ -102,6 +102,9 @@ async fn spawn_round_trips_through_the_daemon() { assert_eq!(created.command, "bash"); assert_eq!(mock.session_count(), 1); - mock.client().kill_session(created.id).await.expect("kill"); + mock.client() + .kill_session(created.id, false) + .await + .expect("kill"); assert_eq!(mock.session_count(), 0); } diff --git a/crates/maestro-e2e/src/client.rs b/crates/maestro-e2e/src/client.rs index 8de967e..f89cd2c 100644 --- a/crates/maestro-e2e/src/client.rs +++ b/crates/maestro-e2e/src/client.rs @@ -61,7 +61,7 @@ impl TestClient { } pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> { - Ok(self.daemon.kill_session(id).await?) + Ok(self.daemon.kill_session(id, false).await.map(|_| ())?) } pub async fn list_projects(&self) -> Result, ClientError> { diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 96b5375..5cdf662 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -667,8 +667,14 @@ impl App { } async fn submit_kill(&mut self, sid: Uuid) { - match self.daemon.kill_session(sid).await { - Ok(()) => self.release_session(sid), + match self.daemon.kill_session(sid, false).await { + Ok(outcome) => { + if let Some(w) = outcome.retained { + self.state + .toast(format!("worktree retained: {}", w.path.display())); + } + self.release_session(sid) + } Err(e) => self.state.toast(format!("kill failed: {e}")), } } diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index fca3566..2c5bbd8 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -75,11 +75,16 @@ That is only safe because force retains. `--force` overrides the refusal and nothing else. It ends the session and **retains** the worktree, which enters the retained store with reason -`force-killed`. Force never removes a tree that would have blocked. +`force-killed`. Force never removes a tree that would have blocked. A tree +whose guard is clear never needed the override, so a forced kill removes it as +usual — force changes what happens to blocked work, not ordinary cleanup. -Ending a session and destroying its work are two separate destructive acts. Each -is chosen on its own, and the second one — reclaim — is chosen while looking at -exactly what would be lost. +Ending a session and destroying its work are two separate destructive acts. +Each is chosen on its own, and the second one — reclaim — is chosen while +looking at exactly what would be lost. + +A forced kill answers with what was retained and why, so a client can say where +the work went rather than leaving the operator to wonder. ### Exiting on its own From 8e8540460f297fa3e16b223328415ebe51201600 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:44:22 +0100 Subject: [PATCH 59/71] feat(maestro): web wire types + client calls for retained worktrees --- web/src/api.ts | 19 +++++++++++++++++++ web/src/routes.ts | 6 ++++++ web/src/types.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) diff --git a/web/src/api.ts b/web/src/api.ts index 89cec3f..b1cd03d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -9,6 +9,7 @@ import type { NameBatch, Projects, RecentDirs, + RetainedWorktree, Session, Uuid, } from "./types.js"; @@ -97,4 +98,22 @@ export async function fetchBranches(path: string): Promise { return jsonOrError(await fetch(url, { credentials: "same-origin" })); } +/** The daemon's retained worktrees (#80): trees kept after a session ended + * with unsaved work, with what is currently in the way. Drives the + * retained-worktrees view (#84). */ +export async function listWorktrees(): Promise { + return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" })); +} + +/** Reclaim a retained worktree: removes its directory and drops the store + * entry. Never deletes the branch (docs/WORKTREES.md). */ +export async function reclaimWorktree(id: Uuid): Promise { + await jsonOrError( + await fetch(routes.worktree(id), { + method: "DELETE", + credentials: "same-origin", + }), + ); +} + export { eventsWsUrl } from "./routes.js"; diff --git a/web/src/routes.ts b/web/src/routes.ts index 812160a..299812c 100644 --- a/web/src/routes.ts +++ b/web/src/routes.ts @@ -13,6 +13,7 @@ export const PROJECTS = "/api/projects"; export const GITROOT = "/api/gitroot"; export const NAMES = "/api/names"; export const BRANCHES = "/api/branches"; +export const WORKTREES = "/api/worktrees"; export const EVENTS = "/api/events"; /** Name of the query parameter `DIRS` and `GITROOT` both take. */ @@ -29,6 +30,11 @@ export function attach(id: Uuid): string { return `${API_PREFIX}/attach/${encodeURIComponent(id)}`; } +/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */ +export function worktree(id: Uuid): string { + return `${WORKTREES}/${encodeURIComponent(id)}`; +} + /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */ export function withPathQuery(route: string, value: string): string { return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`; diff --git a/web/src/types.ts b/web/src/types.ts index 11991c3..10a8e43 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -133,6 +133,54 @@ export interface BranchInfo { /** `GET /api/branches?path=` — the local branches of one repository (#55). */ export type Branches = BranchInfo[]; +/** Why a retained worktree outlived its session (#80). New values are + * additive; unknown reasons render generically. */ +export type RetainedReason = "force-killed" | "exited-with-unsaved-work"; + +/** What currently blocks an unforced kill of a worktree's session (#80), + * recomputed by the daemon on every read. All counts default to 0 on the + * wire for older daemons. */ +export interface InTheWay { + uncommitted_files?: number; + untracked_files?: number; + unpushed_commits?: number; +} + +/** One entry of `GET /api/worktrees` (#80): a retained worktree with its + * in-the-way state as of this read. Reclaiming it removes the directory but + * never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */ +export interface RetainedWorktree { + id: Uuid; + path: string; + base_repo: string; + branch: string; + reason: RetainedReason; + retained_at: IsoTimestamp; + in_the_way?: InTheWay; +} + +/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by + * the kill refusal, the TUI and this client, so no two clients can describe + * the same tree differently. Zero parts are omitted; all-zero renders + * "no unsaved work". */ +export function formatInTheWay(w: InTheWay): string { + const parts: string[] = []; + const push = (n: number | undefined, unit: string): void => { + if (!n || n <= 0) return; + parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`); + }; + push(w.uncommitted_files, "uncommitted file"); + push(w.untracked_files, "untracked file"); + push(w.unpushed_commits, "unpushed commit"); + return parts.length > 0 ? parts.join(", ") : "no unsaved work"; +} + +/** Same reason labels the CLI table and the TUI overlay use; unknown future + * reasons render their raw wire value. */ +export function reasonLabel(reason: RetainedReason): string { + return reason; +} + export interface CreateSessionRequest { cwd: string; command: string; From 6f698df9f4e406e8791cf8b23d7a70a7281ef2a0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:44:22 +0100 Subject: [PATCH 60/71] feat(maestro): retained-worktrees view with reclaim (#84) --- web/src/catalog.ts | 9 +++ web/src/main.ts | 37 +++++++++ web/src/styles.css | 145 +++++++++++++++++++++++++++++++++ web/src/worktrees.test.ts | 114 ++++++++++++++++++++++++++ web/src/worktreesView.ts | 166 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 471 insertions(+) create mode 100644 web/src/worktrees.test.ts create mode 100644 web/src/worktreesView.ts diff --git a/web/src/catalog.ts b/web/src/catalog.ts index db6186d..3fad212 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -180,6 +180,15 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): renderStatus(); const actions = header.querySelector(".app-actions") as HTMLElement; + // Retained worktrees (#84): a section reachable from the header, not a + // sidebar fixture — PRODUCT.md's scan is the product, so this must not + // compete with the session catalog for attention. + const worktreesLink = document.createElement("a"); + worktreesLink.className = "btn btn-ghost"; + worktreesLink.href = "#/worktrees"; + worktreesLink.textContent = "worktrees"; + worktreesLink.title = "Retained worktrees"; + actions.appendChild(worktreesLink); // DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by // clicking inside a proxied preview (e.g. crit live) that has no address bar. if (import.meta.env.DEV) { diff --git a/web/src/main.ts b/web/src/main.ts index a89bda9..6b2a26a 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,7 +7,9 @@ import { createSession, fetchNames, getDaemonInfo, + listWorktrees, projects as fetchProjects, + reclaimWorktree, } from "./api.js"; import { buildFastForwardRequest, @@ -22,6 +24,7 @@ import { loadLayout, saveLayout } from "./persistence.js"; import { OPEN_SPAWN_EVENT } from "./catalog.js"; import { initTheme } from "./theme.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; +import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js"; import { installViewportSizing } from "./viewport.js"; import "./styles.css"; @@ -345,16 +348,47 @@ document.addEventListener("keydown", (e) => { type Route = | { kind: "catalog" } | { kind: "attach"; sessionId: string } + | { kind: "worktrees" } | { kind: "not-found"; raw: string }; function parseRoute(): Route { const hash = location.hash.replace(/^#/, ""); if (hash === "" || hash === "/") return { kind: "catalog" }; + if (hash === "/worktrees") return { kind: "worktrees" }; const m = hash.match(/^\/attach\/([^/?#]+)$/); if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) }; return { kind: "not-found", raw: hash }; } +// Retained worktrees (#84): built lazily on first visit, then kept so a +// revisit re-enters instantly. The daemon's home dir abbreviates paths once +// `/api/info` answers. +let worktreesView: WorktreesViewHandle | undefined; +let homeDir: string | undefined; +void getDaemonInfo() + .then((info) => { + homeDir = info.home_dir; + }) + .catch(() => { + /* absolute paths are a fine fallback */ + }); + +function showWorktrees(): void { + pool.setVisible([]); + pool.setFocus(null); + contentShowsMv = false; + if (!worktreesView) { + worktreesView = createWorktreesView({ + load: listWorktrees, + reclaim: reclaimWorktree, + homeDir: () => homeDir, + }); + } + content.replaceChildren(worktreesView.el); + shellEl.dataset.route = "worktrees"; + void worktreesView.refresh(); +} + function dispatch(): void { const route = parseRoute(); switch (route.kind) { @@ -376,6 +410,9 @@ function dispatch(): void { // tears panes down. showMultiView(); return; + case "worktrees": + showWorktrees(); + return; } } diff --git a/web/src/styles.css b/web/src/styles.css index 86673da..5ed77a1 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2225,3 +2225,148 @@ textarea { background: oklch(0 0 0 / 0.5); } } + +/* ---------- retained worktrees (#84) ---------- */ + +.worktrees { + padding: var(--sp-4); + max-width: 960px; + margin: 0 auto; +} + +.wt-head { + margin-bottom: var(--sp-4); +} + +.wt-title { + font-family: var(--font-mono); + font-size: var(--fs-15); + font-weight: 600; + color: var(--ink); + margin: 0 0 var(--sp-1); +} + +.wt-sub { + color: var(--muted); + font-size: var(--fs-13); + margin: 0; +} + +.wt-row { + display: flex; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-2); + border-bottom: 1px solid var(--surface-2); + font-family: var(--font-mono); + font-size: var(--fs-13); + flex-wrap: wrap; +} + +.wt-branch { + color: var(--ink); + font-weight: 600; +} + +.wt-path { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wt-reason { + color: var(--faint); +} + +/* Status never rides on color alone: the label itself says what blocks. */ +.wt-way[data-clear] { + color: var(--faint); +} + +.wt-way:not([data-clear]) { + color: var(--warn); +} + +.wt-row .wt-reclaim-btn { + margin-left: auto; +} + +.wt-row .wt-reclaim-btn::before { + content: "["; + color: var(--danger); +} + +.wt-row .wt-reclaim-btn::after { + content: "]"; + color: var(--danger); +} + +.wt-row .wt-reclaim-btn:hover { + color: var(--danger); + background: var(--danger-tint); +} + +.btn-danger { + color: var(--danger); + font-weight: 600; +} + +.btn-danger:hover { + background: var(--danger-tint); +} + +/* The destructive confirmation, centered over the section. */ +.wt-confirm { + position: fixed; + inset: 0; + z-index: var(--z-backdrop); + display: flex; + align-items: center; + justify-content: center; + background: oklch(0 0 0 / 0.5); + padding: var(--sp-4); +} + +.wt-confirm[hidden] { + display: none; +} + +.wt-confirm-panel { + pointer-events: auto; + background: var(--bg); + border: 1px solid var(--surface-2); + padding: var(--sp-4); + max-width: 480px; + font-family: var(--font-sans); + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.wt-confirm-q { + margin: 0; + font-family: var(--font-mono); + font-weight: 600; + color: var(--ink); +} + +.wt-confirm-way { + margin: 0; + font-family: var(--font-mono); + font-size: var(--fs-13); + color: var(--warn); +} + +.wt-confirm-note { + margin: 0; + font-size: var(--fs-13); + color: var(--muted); +} + +.wt-confirm-actions { + display: flex; + gap: var(--sp-2); + justify-content: flex-end; + margin-top: var(--sp-2); +} diff --git a/web/src/worktrees.test.ts b/web/src/worktrees.test.ts new file mode 100644 index 0000000..f240877 --- /dev/null +++ b/web/src/worktrees.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; + +import { createWorktreesView } from "./worktreesView.js"; +import type { RetainedWorktree } from "./types.js"; + +function worktree(overrides: Partial): RetainedWorktree { + return { + id: "11111111-2222-3333-4444-555555555555", + path: "/home/naps/tea/maestro/worktrees/feat/login", + base_repo: "/home/naps/tea/maestro", + branch: "feat/login", + reason: "force-killed", + retained_at: "2026-08-24T10:00:00Z", + in_the_way: { + uncommitted_files: 2, + untracked_files: 1, + unpushed_commits: 3, + }, + ...overrides, + }; +} + +/** Mount a view over a scripted store and return (el, reclaimed-ids). */ +function mount(items: RetainedWorktree[], homeDir = "/home/naps") { + const reclaimed: string[] = []; + const view = createWorktreesView({ + load: async () => items, + reclaim: async (id) => { + reclaimed.push(id); + items.splice( + items.findIndex((w) => w.id === id), + 1, + ); + }, + homeDir: () => homeDir, + }); + document.body.appendChild(view.el); + return { view, reclaimed }; +} + +describe("retained-worktrees view (#84)", () => { + it("lists branch, abbreviated path, reason and what is in the way", async () => { + const { view } = mount([ + worktree({ + path: "/home/naps/tea/maestro/worktrees/feat/login", + }), + ]); + await view.refresh(); + const text = view.el.textContent ?? ""; + expect(text).toContain("feat/login"); + // Sidebar-style abbreviation of the path against the daemon's home dir, + // full path kept in the tooltip. + expect(text).toContain("t/m/w/f/login"); + const pathEl = view.el.querySelector(".wt-path")!; + expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login"); + // Same reason labels as `maestro worktrees list` and the TUI overlay. + expect(text).toContain("force-killed"); + // Same phrasing as InTheWay's Display impl — shared with the kill refusal. + expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits"); + }); + + it("says a tree whose work has since been pushed is clear", async () => { + const { view } = mount([worktree({ in_the_way: {} })]); + await view.refresh(); + expect(view.el.textContent).toContain("no unsaved work"); + }); + + it("renders an empty store as a normal state, not an error", async () => { + const { view } = mount([]); + await view.refresh(); + const text = view.el.textContent ?? ""; + expect(text).toContain("no retained worktrees"); + expect(text.toLowerCase()).not.toContain("error"); + expect(text.toLowerCase()).not.toContain("failed"); + }); + + it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => { + const wt = worktree({ branch: "feat/login" }); + const { view, reclaimed } = mount([wt]); + await view.refresh(); + + view.el.querySelector(".wt-reclaim-btn")!.click(); + const confirmText = view.el.querySelector(".wt-confirm")!.textContent ?? ""; + expect(confirmText).toContain("Reclaim worktree feat/login?"); + // Names what will be lost before it happens… + expect(confirmText).toContain( + "2 uncommitted files, 1 untracked file, 3 unpushed commits", + ); + // …and says reclaim does not take the commits too. + expect(confirmText).toContain("never deleted"); + + view.el.querySelector(".wt-confirm-cancel")!.click(); + expect(reclaimed).toEqual([]); + + view.el.querySelector(".wt-reclaim-btn")!.click(); + view.el.querySelector(".wt-confirm-go")!.click(); + await Promise.resolve(); + await Promise.resolve(); + expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]); + // The row is gone once the refresh lands. + expect(view.el.textContent).toContain("no retained worktrees"); + }); + + it("sorts oldest-retained first, like every other client", async () => { + const { view } = mount([ + worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }), + worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }), + ]); + await view.refresh(); + const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent); + expect(branches).toEqual(["older", "newer"]); + }); +}); diff --git a/web/src/worktreesView.ts b/web/src/worktreesView.ts new file mode 100644 index 0000000..2ec67ed --- /dev/null +++ b/web/src/worktreesView.ts @@ -0,0 +1,166 @@ +// Retained-worktrees view (#84): every worktree the daemon is holding that no +// session uses, listed from `GET /api/worktrees` with a reclaim action. +// +// Wording is shared with the CLI (`maestro worktrees list`) and the TUI +// overlay: same reason labels, same in-the-way phrasing via +// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads +// as what it is — destructive — naming what will be lost before it happens +// and saying out loud that the branch survives (docs/WORKTREES.md). + +import { escapeHtml } from "./html.js"; +import { + abbreviateCwd, + abbreviateFolderName, + formatInTheWay, + reasonLabel, + type RetainedWorktree, + type Uuid, +} from "./types.js"; + +export interface WorktreesDeps { + /** `GET /api/worktrees`. */ + load(): Promise; + /** `DELETE /api/worktrees/:id`. */ + reclaim(id: Uuid): Promise; + /** The daemon's home dir, for `~` abbreviation of paths. */ + homeDir(): string | undefined; +} + +export interface WorktreesViewHandle { + el: HTMLElement; + /** Re-fetch the store and re-render. Safe to call repeatedly. */ + refresh(): Promise; + dispose(): void; +} + +/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */ +function sorted(items: RetainedWorktree[]): RetainedWorktree[] { + return [...items].sort( + (a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at), + ); +} + +export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle { + const el = document.createElement("section"); + el.className = "worktrees"; + el.innerHTML = ` +
+

retained worktrees

+

+ Kept when a session ended with unsaved work. Reclaiming removes the + directory only; branches are never deleted. +

+
+
+ + `; + const listHost = el.querySelector(".wt-list")!; + const confirmEl = el.querySelector(".wt-confirm")!; + + let pendingId: string | null = null; + + const render = (items: RetainedWorktree[]) => { + listHost.replaceChildren(); + if (items.length === 0) { + // A drained store is the normal state — say so plainly, not like + // something went wrong. + const empty = document.createElement("div"); + empty.className = "empty"; + empty.innerHTML = ` + +
no retained worktrees
+

Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.

`; + listHost.appendChild(empty); + return; + } + const home = deps.homeDir(); + for (const w of sorted(items)) { + listHost.appendChild(renderRow(w, home)); + } + }; + + const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => { + const row = document.createElement("div"); + row.className = "wt-row"; + const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work"; + row.innerHTML = ` + ${escapeHtml(w.branch)} + ${abbreviateFolderName(abbreviateCwd(w.path, home))} + ${escapeHtml(reasonLabel(w.reason))} + ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))} + + `; + row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => { + showConfirm(w); + }); + return row; + }; + + const showConfirm = (w: RetainedWorktree) => { + pendingId = w.id; + // Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is + // in the way right now, and that the branch survives reclaim. + confirmEl.innerHTML = ` +
+

Reclaim worktree ${escapeHtml(w.branch)}?

+

Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}

+

The directory is removed; the branch is never deleted.

+
+ + +
+
`; + confirmEl.hidden = false; + confirmEl.querySelector(".wt-confirm-go")!.focus(); + }; + + const hideConfirm = () => { + pendingId = null; + confirmEl.hidden = true; + confirmEl.replaceChildren(); + }; + + confirmEl.addEventListener("click", async (e) => { + const target = e.target as HTMLElement; + if (target.closest(".wt-confirm-cancel")) { + hideConfirm(); + return; + } + if (!target.closest(".wt-confirm-go") || !pendingId) return; + const id = pendingId; + hideConfirm(); + try { + await deps.reclaim(id); + } catch (err) { + alert(`Failed to reclaim: ${(err as Error).message}`); + } + await refresh(); + }); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !confirmEl.hidden) hideConfirm(); + }; + document.addEventListener("keydown", onKey); + + const refresh = async () => { + try { + render(await deps.load()); + } catch (err) { + listHost.replaceChildren(); + const fail = document.createElement("div"); + fail.className = "empty"; + fail.innerHTML = `
Couldn't load retained worktrees

`; + fail.querySelector(".empty-hint")!.textContent = (err as Error).message; + listHost.appendChild(fail); + } + }; + + return { + el, + refresh, + dispose() { + document.removeEventListener("keydown", onKey); + el.remove(); + }, + }; +} From a85084d0d96f1646181ac36279f436b9a5bafc4e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:49:36 +0100 Subject: [PATCH 61/71] refactor(maestro): extract shared probe and retain helpers from the kill path probe_worktree and record_retention will be reused by the natural-exit path (#82); kill's behavior is unchanged. --- crates/maestro-daemon/src/session.rs | 86 +++++++++++++++++----------- 1 file changed, 51 insertions(+), 35 deletions(-) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 9edd43c..0f5769d 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -441,21 +441,8 @@ impl SessionManager { ), } }; - // The probe runs before anything is torn down: both refusing (#79) - // and recording the retention (#81) must see the tree as it stands, - // and a refused kill leaves the session running as the operator left - // it. let probe = match &worktree { - Some((_, path)) => { - let path = path.clone(); - Some( - tmux_blocking(move || crate::worktree_probe::in_the_way(&path)) - .await - .map_err(|e| { - SessionError::Worktree(crate::worktree::WorktreeError::from(e)) - })?, - ) - } + Some(wt) => Some(probe_worktree(wt).await?), None => None, }; let guarded = probe.as_ref().is_some_and(|p| !p.is_clear()); @@ -463,29 +450,15 @@ impl SessionManager { return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed"))); } - // Record the retention BEFORE tearing down: a failed store write then - // leaves the session running and every artifact untouched, instead of - // ending a session whose work outlives it unrecorded. let mut retained = None; if guarded { - let store = - self.inner.retained.clone().ok_or_else(|| { - SessionError::RetainFailed("no retained store is wired".into()) - })?; - let (base_repo, path) = worktree.as_ref().expect("guarded ⇒ worktree"); - let branch = known_branch - .or_else(|| crate::worktree_probe::current_branch(path)) - .unwrap_or_default(); - let entry = { - let store = store.clone(); - let base_repo = base_repo.clone(); - let path = path.clone(); - tmux_blocking(move || { - store.retain(&path, &base_repo, &branch, RetainedReason::ForceKilled) - }) - .await - .map_err(|e| SessionError::RetainFailed(e.to_string()))? - }; + let entry = record_retention( + &self.inner, + worktree.as_ref().expect("guarded ⇒ worktree"), + known_branch, + RetainedReason::ForceKilled, + ) + .await?; retained = Some((entry, probe.expect("guarded ⇒ probed"))); } @@ -671,6 +644,49 @@ where .expect("tmux blocking task panicked") } +/// What would be lost if this session's worktree were removed right now. +/// +/// The single [`crate::worktree_probe::in_the_way`] call site behind both +/// endings of a session — the kill guard (#79) and the natural-exit +/// classifier (#82) — so neither can grow its own answer. Runs on the +/// blocking pool; callers invoke it before anything is torn down, because +/// refusing (#79), recording a retention (#81) and classifying an exit (#82) +/// must all see the tree as it stands. +async fn probe_worktree( + (_, path): &(PathBuf, PathBuf), +) -> Result { + let path = path.clone(); + tmux_blocking(move || crate::worktree_probe::in_the_way(&path)) + .await + .map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e))) +} + +/// Record a guarded worktree into the retained store with `reason`, BEFORE +/// the caller tears anything down: a failed write then leaves every artifact +/// untouched instead of destroying work nothing records anymore. Shared by +/// the forced kill (`ForceKilled`, #81) and the natural exit +/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart. +async fn record_retention( + inner: &Arc, + (base_repo, path): &(PathBuf, PathBuf), + known_branch: Option, + reason: RetainedReason, +) -> Result { + let store = inner + .retained + .clone() + .ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?; + // Sessions adopted from a sidecar carry no branch; ask the tree itself. + let branch = known_branch + .or_else(|| crate::worktree_probe::current_branch(path)) + .unwrap_or_default(); + let base_repo = base_repo.clone(); + let path = path.clone(); + tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason)) + .await + .map_err(|e| SessionError::RetainFailed(e.to_string())) +} + /// One pass of the liveness watcher: diff the in-memory session set against /// the tmux-reported one, and reap anything we still track but tmux has lost. async fn run_liveness_pass(inner: &Arc) { From 2fc19a886efc8ca8bab4ababf1e83acce6ec3d4d Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 11:54:43 +0100 Subject: [PATCH 62/71] feat(maestro): apply the kill's worktree rule when a session exits on its own When the liveness watcher finds the tmux session gone, it now settles the worktree through the same probe_worktree/record_retention helpers the kill uses: pristine trees are removed, anything else is retained as exited-with-unsaved-work before anything is torn down. The dead session drops its worktree reference once settled, so dismissing it neither refuses nor retains twice. If the probe or store write fails, nothing is destroyed and the tree stays attached to the record. WORKTREES.md: written down the settle-then-drop and nothing-destroyed- when-unrecordable rules alongside the existing exit rule. --- crates/maestro-daemon/src/session.rs | 110 +++++++++- .../maestro-daemon/tests/session_manager.rs | 189 +++++++++++++++++- docs/WORKTREES.md | 11 + 3 files changed, 303 insertions(+), 7 deletions(-) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 0f5769d..910894b 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -810,17 +810,23 @@ async fn run_activity_pass(inner: &Arc) { } /// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`] -/// and announce it, keeping everything else intact. +/// and settle its worktree per the natural-exit rule (#82). /// /// The record, its [`PtyBridge`] and its sidecar all survive, so a client can /// still attach and read the replay buffer — the last thing a crashed agent -/// printed. Artifacts are only released when the operator dismisses the session -/// via [`SessionManager::kill`]. +/// printed. The worktree, though, is settled right away: a pristine tree is +/// removed, anything else is retained into the store with reason +/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here, +/// so there is nothing to refuse. Once settled, the session drops its +/// worktree reference so a later dismissal neither re-probes nor retains +/// twice. When settling is impossible (probe or store failure) nothing is +/// destroyed: the tree stays attached to the dead record for a later forced +/// dismissal (#81). /// /// Dead sessions do not survive a daemon restart: [`SessionManager::recover`] /// adopts only what tmux still holds. async fn mark_session_dead(inner: &Arc, id: Uuid) { - let updated = { + let flipped = { let mut sessions = inner.sessions.lock().await; match sessions.get_mut(&id) { Some(record) if record.session.status == SessionStatus::Running => { @@ -828,19 +834,111 @@ async fn mark_session_dead(inner: &Arc, id: Uuid) { // Release anyone attached; the scrollback survives for a later // attach. record.bridge.seal(); - Some(record.session.clone()) + let session = record.session.clone(); + let worktree = record + .worktree_base_repo + .clone() + .zip(record.worktree_path.clone()); + let branch = record.worktree_branch.clone(); + Some((session, worktree, branch)) } _ => None, } }; - let Some(session) = updated else { + let Some((mut session, worktree, branch)) = flipped else { return; }; tracing::info!(%id, "session vanished from tmux — marking dead"); + + if let Some(wt) = &worktree { + if settle_worktree_on_exit(inner, id, wt, branch).await { + session.worktree = None; + let mut sessions = inner.sessions.lock().await; + if let Some(record) = sessions.get_mut(&id) { + record.session.worktree = None; + record.worktree_path = None; + record.worktree_base_repo = None; + record.worktree_branch = None; + } + } + } + let _ = inner.sidecar.write(&session); let _ = inner.events.send(CatalogEvent::SessionUpdated { session }); } +/// Apply the exit rule (#82) to a vanished session's worktree: probe what +/// would be lost through the kill's own predicate, then remove a pristine +/// tree or retain anything else under `exited-with-unsaved-work` — recorded +/// before anything is torn down, exactly as the forced kill does (#81). A +/// removal that fails falls back to retaining, since whatever blocked it is +/// by definition something worth keeping. +/// +/// Returns whether the worktree was fully accounted for. When it wasn't, +/// nothing was destroyed and the caller keeps the reference on the record. +async fn settle_worktree_on_exit( + inner: &Arc, + id: Uuid, + (base_repo, path): &(PathBuf, PathBuf), + known_branch: Option, +) -> bool { + let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await { + Ok(probe) => probe, + Err(e) => { + tracing::error!( + %id, + path = %path.display(), + error = ?e, + "natural exit: could not probe the worktree; leaving it untouched" + ); + return false; + } + }; + if probe.is_clear() { + match inner.worktree_mgr.remove(base_repo, path) { + Ok(()) => { + tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed"); + return true; + } + Err(e) => { + tracing::warn!( + %id, + path = %path.display(), + error = ?e, + "natural exit: removal failed; falling back to retention" + ); + } + } + } + match record_retention( + inner, + &(base_repo.clone(), path.clone()), + known_branch, + RetainedReason::ExitedWithUnsavedWork, + ) + .await + { + Ok(entry) => { + tracing::info!( + %id, + path = %path.display(), + entry = %entry.id, + "natural exit: worktree retained with unsaved work" + ); + true + } + Err(e) => { + tracing::error!( + %id, + path = %path.display(), + error = ?e, + "natural exit: could not record the retained worktree; leaving it untouched" + ); + false + } + } +} + /// Tear down a session's artifacts after it has already been removed from the /// live map. Shared by [`SessionManager::kill`] and [`reap_dead_session`], /// which previously hand-rolled the same five-step choreography and only diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 234421c..9d8a19a 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -8,7 +8,9 @@ use std::process::Command; use std::sync::Arc; use std::time::Duration; -use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture}; +use common::{ + init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture, +}; use maestro_daemon::SessionError; use maestro_protocol::CreateSessionRequest; use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec}; @@ -668,6 +670,191 @@ async fn killing_an_ignored_only_worktree_succeeds() { assert!(!fx.manager.has(s.id).await); } +/// Natural exit (#82): a session that ends on its own never reaches the kill +/// path, so the watcher applies the kill's rule itself. A worktree holding +/// unsaved work is RETAINED into the store with reason +/// `exited-with-unsaved-work`; the session stays in the catalog marked dead +/// until dismissed — and dismissing it afterwards neither refuses nor +/// retains a second time. +#[tokio::test] +async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("exited"); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/exited".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + // The command finishes / the operator types exit: tmux loses the session. + fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly"); + fx.manager.start_liveness_watcher(Duration::from_millis(50)); + wait_for_async(Duration::from_secs(3), || async { + fx.manager.status(s.id).await == Some(SessionStatus::Dead) + }) + .await; + + // The tree would have blocked a kill, so the exit kept it... + assert!( + wt_path.exists(), + "a dirty worktree must be retained on natural exit" + ); + // ...in the store, under the exit reason. + let all = fx.retained_store().load_all().expect("load_all"); + assert_eq!(all.len(), 1); + assert_eq!(all[0].path, wt_path); + assert_eq!(all[0].branch, "feat/exited"); + assert_eq!( + all[0].reason, + maestro_protocol::RetainedReason::ExitedWithUnsavedWork + ); + + // The session itself stays until dismissed; its worktree reference is + // settled, so dismissal is neither refused nor retained twice. + // (`Dead` is observable a moment before the settle step finishes.) + wait_for_async(Duration::from_secs(3), || async { + fx.manager.snapshot().await[0].worktree.is_none() + }) + .await; + let snap = fx.manager.snapshot().await; + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].worktree, None); + + fx.manager.kill(s.id, false).await.expect("dismiss"); + assert!(!fx.manager.has(s.id).await); + assert!( + wt_path.exists(), + "dismissal must not remove what was retained" + ); + assert_eq!( + fx.retained_store().load_all().unwrap().len(), + 1, + "dismissal must not record a second entry for the same tree" + ); +} + +/// Natural exit (#82), pristine side of the same rule: nothing in the tree, +/// nothing to keep — the worktree is removed and no store entry appears. +#[tokio::test] +async fn natural_exit_of_a_pristine_worktree_removes_it() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("clean"); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/clean".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + + // Push everything so the branch has no unpushed commits: fully pristine. + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly"); + fx.manager.start_liveness_watcher(Duration::from_millis(50)); + wait_for_async(Duration::from_secs(3), || async { + fx.manager.status(s.id).await == Some(SessionStatus::Dead) + }) + .await; + + assert!( + !wt_path.exists(), + "a pristine worktree leaves nothing behind" + ); + assert!( + fx.retained_store().load_all().unwrap().is_empty(), + "nothing was retained" + ); + assert!( + fx.manager.has(s.id).await, + "the session stays until dismissed" + ); +} + +/// When nothing can be recorded — here: no retained store wired — the exit +/// destroys nothing. The tree stays on disk AND stays attached to the dead +/// record, where dismissing it later still goes through #81's forced-kill +/// retention. Losing the store must not lose the work. +#[tokio::test] +async fn natural_exit_without_a_retained_store_destroys_nothing() { + let tmux = TmuxFixture::new(); + let state_dir = TempDir::new().unwrap(); + let manager = maestro_daemon::SessionManager::new( + Arc::new(tmux.tmux.clone()), + maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")), + maestro_daemon::worktree::WorktreeManager::new(None), + state_dir.path().join("fifos"), + ); + // Deliberately NO with_retained_store. + + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("nostore"); + + let s = manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/nostore".into(), + cleanup_on_kill: true, + create_branch: true, + }), + }) + .await + .expect("create"); + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + + tmux.tmux.kill_session(s.id).expect("kill tmux directly"); + manager.start_liveness_watcher(Duration::from_millis(50)); + wait_for_async(Duration::from_secs(3), || async { + manager.status(s.id).await == Some(SessionStatus::Dead) + }) + .await; + + assert!( + wt_path.exists(), + "nothing may be destroyed when it cannot be recorded" + ); + let snap = manager.snapshot().await; + assert!( + snap[0].worktree.is_some(), + "the tree must stay attached to the record so a later forced kill can retain it" + ); +} + fn run_git_in(dir: &std::path::Path, args: &[&str]) { let out = Command::new("git") .arg("-C") diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 2c5bbd8..cdbf0d3 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -97,6 +97,17 @@ Nobody is waiting for an answer here, so there is nothing to refuse; the choice only between removing and retaining. It uses the same predicate as the kill, from the same place, so the two paths cannot drift apart. +The session itself stays in the catalog marked dead until it is dismissed — the +replay buffer of a crashed agent outlives it — but its worktree is settled at +once. Once settled, the session drops its worktree reference, so dismissing it +afterwards neither refuses (there is no guard left to trip) nor records a second +store entry. + +If settling is impossible — the probe fails, or the store cannot take the entry — +nothing is destroyed. The tree stays on disk *and* stays attached to the dead +record, where dismissing it later still goes through `--force`'s +retain-before-teardown path. Losing the store must never mean losing the work. + ## The retained store A `state_dir`-backed record of worktrees the daemon created that no longer have a From 31702e03e3690a911cd337dc0335b32e65834efe Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 12:08:41 +0100 Subject: [PATCH 63/71] refactor(maestro): drop cleanup_on_kill from the wire and the CLI Nothing has honoured WorktreeSpec.cleanup_on_kill since #56, and after #79 and #81 survival is decided by what is in a worktree, not by what the spawn asked for. A field that lies is worse than a missing one. Removes the field from the protocol, the --cleanup-on-kill flag from the CLI, and the tests asserting its default. --- crates/maestro-cli/src/cli.rs | 28 ------------------- crates/maestro-cli/src/sessions.rs | 1 - crates/maestro-daemon/tests/local_api.rs | 2 -- .../maestro-daemon/tests/session_manager.rs | 6 ---- crates/maestro-protocol/src/lib.rs | 8 ------ crates/maestro-tui/src/draft.rs | 3 -- crates/maestro-tui/src/fast_forward.rs | 1 - web/src/fastForward.test.ts | 1 - web/src/fastForward.ts | 1 - web/src/spawnForm.test.ts | 2 -- web/src/spawnForm.ts | 3 -- web/src/types.ts | 1 - 12 files changed, 57 deletions(-) diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index 6c8fdc4..b1c7ac2 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -102,11 +102,6 @@ pub struct SpawnArgs { /// there. #[arg(long)] pub existing_branch: bool, - /// Remove the worktree when the session is killed. Kept for wire - /// compatibility (#56): the daemon now always cleans up on kill, guarded - /// against unsaved work. - #[arg(long, default_value_t = true)] - pub cleanup_on_kill: bool, /// After spawning, attach immediately. #[arg(long, short = 'a')] pub attach: bool, @@ -168,7 +163,6 @@ mod tests { "/repos/foo", "--branch", "feat/x", - "--cleanup-on-kill", "-e", "FOO=bar", "-e", @@ -179,7 +173,6 @@ mod tests { Command::Spawn(a) => { assert_eq!(a.command, "bash"); assert_eq!(a.branch.as_deref(), Some("feat/x")); - assert!(a.cleanup_on_kill); assert_eq!( a.env, vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),] @@ -189,27 +182,6 @@ mod tests { } } - #[test] - fn cleanup_on_kill_defaults_true_for_the_wire() { - let cli = Cli::try_parse_from([ - "maestro", - "spawn", - "--cwd", - "/tmp", - "--command", - "bash", - "--worktree-from", - "/repos/foo", - "--branch", - "feat/x", - ]) - .unwrap(); - match cli.command { - Command::Spawn(a) => assert!(a.cleanup_on_kill), - _ => panic!("wrong subcommand"), - } - } - /// Review fix: without a flag the CLI always sent `create_branch: true`, /// so the daemon's existing-branch path was unreachable and spawning onto /// a branch that already existed just failed. diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index dd1b38f..7423ddd 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -43,7 +43,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result Some(WorktreeSpec { base_repo: repo.clone(), branch: branch.clone(), - cleanup_on_kill: args.cleanup_on_kill, create_branch: !args.existing_branch, }), _ => None, diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index ce6e071..c5d1ab7 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -363,7 +363,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/guarded".into(), - cleanup_on_kill: true, create_branch: true, }), }) @@ -417,7 +416,6 @@ async fn delete_with_force_ends_the_session_and_answers_what_was_retained() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/guarded".into(), - cleanup_on_kill: true, create_branch: true, }), }) diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 234421c..c3574d8 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -385,7 +385,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/keep".into(), - cleanup_on_kill: false, create_branch: true, }), }) @@ -423,7 +422,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/guarded".into(), - cleanup_on_kill: true, create_branch: true, }), }) @@ -487,7 +485,6 @@ async fn killing_refuses_until_the_branch_is_pushed() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/pushed".into(), - cleanup_on_kill: true, create_branch: true, }), }) @@ -539,7 +536,6 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/forced".into(), - cleanup_on_kill: true, create_branch: true, }), }) @@ -605,7 +601,6 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/clear".into(), - cleanup_on_kill: true, create_branch: true, }), }) @@ -648,7 +643,6 @@ async fn killing_an_ignored_only_worktree_succeeds() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/rusty".into(), - cleanup_on_kill: true, create_branch: true, }), }) diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 3b88d6d..8a24179 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -119,11 +119,6 @@ pub enum ActivityState { pub struct WorktreeSpec { pub base_repo: PathBuf, pub branch: String, - /// Kept on the wire for CLI compatibility (#56) but no longer a - /// user-facing choice: the daemon always cleans up on kill, guarded - /// against unsaved work. Defaults to `true`. - #[serde(default = "default_true")] - pub cleanup_on_kill: bool, /// When false, `branch` must already exist and is checked out as-is /// (#56); when true (the default, and what every payload meant before /// the field existed) the daemon creates it with `worktree add -b`. @@ -594,7 +589,6 @@ mod tests { worktree: Some(WorktreeSpec { base_repo: PathBuf::from("/home/naps62/projects/maestro"), branch: "feat/foo".into(), - cleanup_on_kill: true, create_branch: true, }), }; @@ -624,7 +618,6 @@ mod tests { }); let spec: WorktreeSpec = serde_json::from_value(json).unwrap(); assert!(spec.create_branch); - assert!(spec.cleanup_on_kill); } #[test] @@ -632,7 +625,6 @@ mod tests { let spec = WorktreeSpec { base_repo: PathBuf::from("/work"), branch: "feat/foo".into(), - cleanup_on_kill: true, create_branch: false, }; assert_eq!(spec, round_trip(&spec)); diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index cd76fd2..3e26386 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -549,7 +549,6 @@ impl DraftPane { WorktreeSpec { base_repo, branch: branch.to_string(), - cleanup_on_kill: true, create_branch: true, } } @@ -562,7 +561,6 @@ impl DraftPane { WorktreeSpec { base_repo, branch: name, - cleanup_on_kill: true, create_branch: false, } } @@ -1689,7 +1687,6 @@ mod tests { assert_eq!(wt.base_repo, PathBuf::from("/repos/maestro")); assert_eq!(wt.branch, "crimson-otter", "branch defaults to the name"); assert!(wt.create_branch); - assert!(wt.cleanup_on_kill); } #[test] diff --git a/crates/maestro-tui/src/fast_forward.rs b/crates/maestro-tui/src/fast_forward.rs index 4b8abb3..daa879a 100644 --- a/crates/maestro-tui/src/fast_forward.rs +++ b/crates/maestro-tui/src/fast_forward.rs @@ -80,7 +80,6 @@ impl FastForwardDefaults { .clone() .unwrap_or_else(|| project.path.clone()), branch: name.to_string(), - cleanup_on_kill: true, create_branch: true, }), }) diff --git a/web/src/fastForward.test.ts b/web/src/fastForward.test.ts index 7e46408..9d1d6e5 100644 --- a/web/src/fastForward.test.ts +++ b/web/src/fastForward.test.ts @@ -43,7 +43,6 @@ describe("resolveFastForward (#67)", () => { expect(req.worktree).toEqual({ base_repo: "/repos/fresh", branch: "amber-falcon", - cleanup_on_kill: true, create_branch: true, }); }); diff --git a/web/src/fastForward.ts b/web/src/fastForward.ts index 8ac91fb..39d620c 100644 --- a/web/src/fastForward.ts +++ b/web/src/fastForward.ts @@ -42,7 +42,6 @@ export function buildFastForwardRequest( worktree: { base_repo: project.base_repo ?? project.path, branch: name, - cleanup_on_kill: true, create_branch: true, }, }; diff --git a/web/src/spawnForm.test.ts b/web/src/spawnForm.test.ts index 5deb9ea..34834d2 100644 --- a/web/src/spawnForm.test.ts +++ b/web/src/spawnForm.test.ts @@ -38,7 +38,6 @@ describe("buildSpawnRequest", () => { worktree: { base_repo: "/home/user/project", branch: "feat/foo", - cleanup_on_kill: true, create_branch: true, }, }, @@ -63,7 +62,6 @@ describe("buildSpawnRequest", () => { worktree: { base_repo: "/home/user/project", branch: "spawn-v2", - cleanup_on_kill: true, create_branch: false, }, }, diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 5a21569..194d71c 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -75,9 +75,6 @@ export function buildSpawnRequest( req.worktree = { base_repo: baseRepo, branch, - // The daemon always cleans up, guarded against unsaved work (#56); - // the wire field stays for older daemons. - cleanup_on_kill: true, // Existing-mode picks must check out as-is; the daemon creates // anything else with `worktree add -b` (#56/#61). create_branch: mode !== "existing", diff --git a/web/src/types.ts b/web/src/types.ts index 10a8e43..48040cf 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -113,7 +113,6 @@ export type Projects = Project[]; export interface WorktreeSpec { base_repo: string; branch: string; - cleanup_on_kill?: boolean; /** When false, `branch` already exists and is checked out as-is (#61). * Defaults to true (create a new branch) on older daemons. */ create_branch?: boolean; From 8aa007c73b07139ce9c0bca5e6ca7904f464021c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 12:12:02 +0100 Subject: [PATCH 64/71] fix(maestro): drop the cleanup_on_kill call sites #87 could not reach Three in session_manager.rs arrived with #82 after #87 branched; four in maestro-e2e were fenced off because #88 owns that crate. Removing the field without them left the workspace uncompilable. --- crates/maestro-daemon/tests/session_manager.rs | 3 --- crates/maestro-e2e/tests/e2e.rs | 4 ---- 2 files changed, 7 deletions(-) diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 5a8cba4..12d5c5e 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -687,7 +687,6 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/exited".into(), - cleanup_on_kill: true, create_branch: true, }), }) @@ -761,7 +760,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/clean".into(), - cleanup_on_kill: true, create_branch: true, }), }) @@ -823,7 +821,6 @@ async fn natural_exit_without_a_retained_store_destroys_nothing() { worktree: Some(WorktreeSpec { base_repo: repo.path().to_path_buf(), branch: "feat/nostore".into(), - cleanup_on_kill: true, create_branch: true, }), }) diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index d5fb649..af64f16 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -45,7 +45,6 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr worktree: Some(WorktreeSpec { base_repo: base.to_path_buf(), branch: branch.into(), - cleanup_on_kill: true, create_branch, }), } @@ -144,7 +143,6 @@ async fn kill_removes_clean_worktree() { worktree: Some(WorktreeSpec { base_repo: base.path().to_path_buf(), branch: "feature/e2e".into(), - cleanup_on_kill: true, create_branch: true, }), }; @@ -202,7 +200,6 @@ async fn kill_refuses_worktree_with_uncommitted_changes() { worktree: Some(WorktreeSpec { base_repo: base.path().to_path_buf(), branch: "feature/unsaved".into(), - cleanup_on_kill: true, create_branch: true, }), }; @@ -265,7 +262,6 @@ async fn spawn_on_existing_branch_checks_it_out() { worktree: Some(WorktreeSpec { base_repo: base.path().to_path_buf(), branch: "feature/existing".into(), - cleanup_on_kill: true, create_branch: false, }), }; From b5450651d14508024f3ba8024fc3a227dc5fe177 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 12:33:39 +0100 Subject: [PATCH 65/71] test(maestro): expose forced kill and retained-worktree calls in the e2e harness --- crates/maestro-e2e/src/client.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/maestro-e2e/src/client.rs b/crates/maestro-e2e/src/client.rs index f89cd2c..6ba4410 100644 --- a/crates/maestro-e2e/src/client.rs +++ b/crates/maestro-e2e/src/client.rs @@ -12,7 +12,8 @@ use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use maestro_protocol::{ - BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session, + BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project, + RetainedWorktree, Session, }; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -64,6 +65,20 @@ impl TestClient { Ok(self.daemon.kill_session(id, false).await.map(|_| ())?) } + /// Kill with `force=true`: overrides a kill refusal and retains the + /// guarded worktree (#81). Answers with what was kept. + pub async fn kill_session_forced(&self, id: Uuid) -> Result { + Ok(self.daemon.kill_session(id, true).await?) + } + + pub async fn list_worktrees(&self) -> Result, ClientError> { + Ok(self.daemon.list_worktrees().await?) + } + + pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> { + Ok(self.daemon.reclaim_worktree(id).await?) + } + pub async fn list_projects(&self) -> Result, ClientError> { Ok(self.daemon.list_projects().await?) } From 2083911baa17c9b481d260ad549ad82ef7f22cc0 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 12:33:39 +0100 Subject: [PATCH 66/71] test(maestro): cover the whole worktree lifecycle end to end (#88) Every scenario from the contract, against a real daemon, tmux and git: collision suffixes with both trees working, foreign directories sidestepped without adoption, the guarded kill and its refusal breakdown, save-then-kill succeeding, --force retaining the tree, natural exits settling dirty and pristine worktrees differently, the retained list with reasons, reclaim keeping the branch, and the store surviving a daemon restart. Worktree specs are built through their wire shape so the tests carry no reference to WorktreeSpec.cleanup_on_kill, which #87 is removing. --- crates/maestro-e2e/tests/e2e.rs | 556 ++++++++++++++++++++++++++++++-- 1 file changed, 523 insertions(+), 33 deletions(-) diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index d5fb649..361c794 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -35,6 +35,18 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest { } } +/// A worktree spec built through its wire shape. The daemon and clients ship +/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON +/// object carries only the fields the lifecycle actually honours. +fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool) -> WorktreeSpec { + serde_json::from_value(serde_json::json!({ + "base_repo": base_repo, + "branch": branch, + "create_branch": create_branch, + })) + .expect("worktree spec") +} + /// Spawn request carrying a worktree spec against `base`. fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest { CreateSessionRequest { @@ -42,12 +54,7 @@ fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> Cr command: "sleep 30".into(), env: HashMap::new(), metadata: HashMap::new(), - worktree: Some(WorktreeSpec { - base_repo: base.to_path_buf(), - branch: branch.into(), - cleanup_on_kill: true, - create_branch, - }), + worktree: Some(worktree_spec(base, branch, create_branch)), } } @@ -141,12 +148,7 @@ async fn kill_removes_clean_worktree() { command: "sleep 30".into(), env: HashMap::new(), metadata: HashMap::new(), - worktree: Some(WorktreeSpec { - base_repo: base.path().to_path_buf(), - branch: "feature/e2e".into(), - cleanup_on_kill: true, - create_branch: true, - }), + worktree: Some(worktree_spec(base.path(), "feature/e2e", true)), }; let s = client.spawn_session(&req).await.expect("spawn worktree"); @@ -160,7 +162,7 @@ async fn kill_removes_clean_worktree() { "expected a worktree for feature/e2e under /worktrees, got: {worktrees:?}" ); - push_to_throwaway_origin(base.path()); + let _origin = throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); // Give the daemon a moment to remove the worktree, then verify. @@ -199,12 +201,7 @@ async fn kill_refuses_worktree_with_uncommitted_changes() { command: "sleep 30".into(), env: HashMap::new(), metadata: HashMap::new(), - worktree: Some(WorktreeSpec { - base_repo: base.path().to_path_buf(), - branch: "feature/unsaved".into(), - cleanup_on_kill: true, - create_branch: true, - }), + worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)), }; let s = client.spawn_session(&req).await.expect("spawn worktree"); @@ -262,12 +259,7 @@ async fn spawn_on_existing_branch_checks_it_out() { command: "sleep 30".into(), env: HashMap::new(), metadata: HashMap::new(), - worktree: Some(WorktreeSpec { - base_repo: base.path().to_path_buf(), - branch: "feature/existing".into(), - cleanup_on_kill: true, - create_branch: false, - }), + worktree: Some(worktree_spec(base.path(), "feature/existing", false)), }; let s = client @@ -280,7 +272,7 @@ async fn spawn_on_existing_branch_checks_it_out() { "expected a worktree checked out at feature/existing, got: {worktrees:?}" ); - push_to_throwaway_origin(base.path()); + let _origin = throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); } @@ -328,7 +320,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() { ) .await; - push_to_throwaway_origin(base.path()); + let _origin = throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); } @@ -376,7 +368,7 @@ async fn second_spawn_on_same_branch_is_rejected() { "original worktree must be untouched" ); - push_to_throwaway_origin(base.path()); + let _origin = throwaway_origin(base.path()); client.kill_session(first.id).await.expect("kill"); } @@ -419,10 +411,481 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() { "worktrees must never nest inside another worktree" ); - push_to_throwaway_origin(base.path()); + let _origin = throwaway_origin(base.path()); client.kill_session(s.id).await.expect("kill"); } +// --------------------------------------------------------------------------- +// Worktree lifecycle: collisions, retention, reclaim (#88) +// --------------------------------------------------------------------------- + +/// #77/#88: the target path `/worktrees/` is occupied by a real +/// registered worktree the operator made by hand. The daemon sidesteps to the +/// suffixed directory and never touches the occupant — and both trees keep +/// working: two registered worktrees, two sessions, each running in its own. +#[tokio::test(flavor = "multi_thread")] +async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + + // An operator-made worktree sits exactly where `feature/collide` would go. + // Its branch is a different one, so the requested branch stays creatable. + let squatter = base + .path() + .join("worktrees") + .join("feature") + .join("collide"); + run_git( + &[ + "worktree", + "add", + "-q", + "-b", + "operator/manual", + &squatter.to_string_lossy(), + ], + base.path(), + ); + + let s = client + .spawn_session(&worktree_req(base.path(), "feature/collide", true)) + .await + .expect("spawn onto occupied path"); + + let suffixed = base + .path() + .join("worktrees") + .join("feature") + .join("collide-2"); + assert_eq!( + s.cwd, suffixed, + "the daemon must take the suffixed directory, not the occupied one" + ); + assert!(suffixed.is_dir(), "suffixed worktree must exist on disk"); + + // Both trees are registered, distinct, and the squatter is byte-intact. + let registrations = git_worktree_list(base.path()); + assert!( + registrations + .iter() + .any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())), + "operator's worktree must stay registered: {registrations:?}" + ); + assert!( + registrations.iter().any(|l| l.contains("collide-2")), + "suffixed worktree must be registered too: {registrations:?}" + ); + + // Both sessions run inside their own tree at the same time. + let sessions = client.list_sessions().await.expect("list"); + assert!( + sessions.iter().any(|x| x.id == s.id && x.cwd == suffixed), + "session must keep running in its suffixed tree" + ); + + let _origin = throwaway_origin(base.path()); + client.kill_session(s.id).await.expect("kill"); + + // Only the daemon-made trees go; the operator's manual one survives. + let after = git_worktree_list(base.path()); + assert!( + after + .iter() + .any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())), + "operator's worktree must outlive the sessions: {after:?}" + ); +} + +/// #77/#88: the target path holds a plain directory the daemon never made and +/// git has never heard of. The daemon neither adopts it nor errors — it builds +/// beside it and leaves every byte of it alone. +#[tokio::test(flavor = "multi_thread")] +async fn foreign_directory_is_sidestepped_never_adopted() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + + let squatter = base + .path() + .join("worktrees") + .join("feature") + .join("foreign"); + std::fs::create_dir_all(&squatter).unwrap(); + std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap(); + + let s = client + .spawn_session(&worktree_req(base.path(), "feature/foreign", true)) + .await + .expect("spawn must sidestep, not adopt or fail"); + + let suffixed = base + .path() + .join("worktrees") + .join("feature") + .join("foreign-2"); + assert_eq!(s.cwd, suffixed, "session must run in the suffixed tree"); + assert_eq!( + std::fs::read(squatter.join("operator-notes.txt")).unwrap(), + b"mine\n", + "the foreign directory must be untouched" + ); + let registrations = git_worktree_list(base.path()); + let registered_paths: Vec<&str> = registrations + .iter() + .filter_map(|l| l.strip_prefix("worktree ")) + .collect(); + assert!( + !registered_paths + .iter() + .any(|p| *p == squatter.canonicalize().unwrap().display().to_string()), + "a plain directory must never be adopted as a worktree: {registered_paths:?}" + ); + + let _origin = throwaway_origin(base.path()); + client.kill_session(s.id).await.expect("kill"); + assert!(squatter.join("operator-notes.txt").exists()); +} + +/// #79/#88: killing with uncommitted changes is refused; committing and +/// pushing that work clears the guard and the very same kill then succeeds +/// and removes the tree. The session is the handle on the work: while it is +/// unsaved the session stays, once it is saved the tree goes. +#[tokio::test(flavor = "multi_thread")] +async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let _origin = throwaway_origin(base.path()); + + let wt = base.path().join("worktrees").join("feature").join("guard"); + let s = client + .spawn_session(&worktree_req(base.path(), "feature/guard", true)) + .await + .expect("spawn"); + std::fs::write(wt.join("README"), b"work in progress\n").unwrap(); + + // Refused: the edit is uncommitted. + let err = client + .kill_session(s.id) + .await + .expect_err("kill must refuse"); + match err { + maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => { + assert_eq!(info.code, "kill_guarded", "{info:?}"); + let breakdown = info.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.uncommitted_files, 1); + assert_eq!(breakdown.unpushed_commits, 0); + } + other => panic!("expected a kill_guarded refusal, got {other:?}"), + } + assert!( + client + .list_sessions() + .await + .unwrap() + .iter() + .any(|x| x.id == s.id), + "a refused kill keeps the session running" + ); + assert_eq!( + std::fs::read(wt.join("README")).unwrap(), + b"work in progress\n", + "the worktree is untouched by the refusal" + ); + + // The operator does exactly what the refusal asked for: save and push. + commit_all(&wt, "wip"); + push_all(base.path()); + + client.kill_session(s.id).await.expect("kill after saving"); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if !wt.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!( + !wt.exists(), + "a cleared worktree must be removed after kill" + ); + let registrations = git_worktree_list(base.path()); + assert!( + !registrations.iter().any(|l| l.contains("feature/guard")), + "removal must deregister the tree: {registrations:?}" + ); +} + +/// #81/#88: commits that no remote has refuse the kill; `--force` ends the +/// session anyway but RETAINS the worktree — force never destroys blocked +/// work, it parks it in the retained store with reason `force-killed`. +#[tokio::test(flavor = "multi_thread")] +async fn unpushed_commits_block_kill_and_force_retains_the_tree() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let _origin = throwaway_origin(base.path()); + + let wt = base.path().join("worktrees").join("feature").join("local"); + let s = client + .spawn_session(&worktree_req(base.path(), "feature/local", true)) + .await + .expect("spawn"); + std::fs::write(wt.join("README"), b"local only\n").unwrap(); + commit_all(&wt, "not pushed yet"); + + // Refused: the commit is on no remote-tracking ref. + let err = client + .kill_session(s.id) + .await + .expect_err("kill must refuse"); + match err { + maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => { + assert_eq!(info.code, "kill_guarded", "{info:?}"); + let breakdown = info.unsaved_work.expect("breakdown on the wire"); + assert_eq!(breakdown.unpushed_commits, 1); + assert_eq!(breakdown.uncommitted_files, 0); + } + other => panic!("expected a kill_guarded refusal, got {other:?}"), + } + + // Force overrides the refusal and nothing else: session gone, tree kept. + let outcome = client.kill_session_forced(s.id).await.expect("forced kill"); + let retained = outcome + .retained + .expect("force must retain the guarded tree"); + assert_eq!( + retained.reason, + maestro_protocol::RetainedReason::ForceKilled + ); + assert_eq!(retained.path, wt); + assert_eq!(retained.branch, "feature/local"); + assert_eq!(retained.in_the_way.unpushed_commits, 1); + + assert!( + !client + .list_sessions() + .await + .unwrap() + .iter() + .any(|x| x.id == s.id), + "the forced kill ends the session" + ); + assert!(wt.is_dir(), "a force-retained worktree must stay on disk"); + assert!( + git_worktree_list(base.path()) + .iter() + .any(|l| l.contains("feature/local")), + "the retained tree stays registered in git" + ); +} + +/// #82/#88: a session that exits on its own never reaches the kill path, but +/// its worktree follows the same rule. Dirty → retained with reason +/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind. +#[tokio::test(flavor = "multi_thread")] +async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() { + let (_daemon, client) = bring_up().await; + + // Two repos so the two cases cannot see each other's state. + let dirty_repo = TempDir::new().unwrap(); + init_repo(dirty_repo.path()); + let _origin = throwaway_origin(dirty_repo.path()); + let pristine_repo = TempDir::new().unwrap(); + init_repo(pristine_repo.path()); + let _origin_pristine = throwaway_origin(pristine_repo.path()); + + let dirty_wt = dirty_repo + .path() + .join("worktrees") + .join("feature") + .join("messy"); + let pristine_wt = pristine_repo + .path() + .join("worktrees") + .join("feature") + .join("tidy"); + + let _dirty = client + .spawn_session(&CreateSessionRequest { + cwd: dirty_repo.path().to_path_buf(), + command: "sh -c 'echo unsaved > scratch.txt'".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)), + }) + .await + .expect("spawn dirty"); + let _pristine = client + .spawn_session(&create_req_cmd( + pristine_repo.path(), + "true", + "feature/tidy", + )) + .await + .expect("spawn pristine"); + + assert!(dirty_wt.is_dir() && pristine_wt.is_dir()); + + // The watcher reaps dead tmux sessions every couple of ticks and settles + // each worktree accordingly. Poll for both outcomes. + wait_for_async( + "dirty exit retains its worktree", + Duration::from_secs(20), + || { + let client = client.clone(); + let path = dirty_wt.clone(); + async move { + client + .list_worktrees() + .await + .ok() + .map(|wts| { + wts.iter().any(|w| { + w.path == path + && w.reason + == maestro_protocol::RetainedReason::ExitedWithUnsavedWork + }) + }) + .unwrap_or(false) + } + }, + ) + .await; + wait_for_async( + "pristine exit removes its worktree entirely", + Duration::from_secs(20), + || { + let client = client.clone(); + let repo = pristine_repo.path().to_path_buf(); + let path = pristine_wt.clone(); + async move { + if path.exists() { + return false; + } + let listed = client.list_worktrees().await.ok(); + let listed_clear = listed + .map(|wts| !wts.iter().any(|w| w.path == path)) + .unwrap_or(false); + let registered = git_worktree_list(&repo); + !registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear + } + }, + ) + .await; + + // The dirty tree is still fully there, file included. + assert!(dirty_wt.is_dir()); + assert_eq!( + std::fs::read(dirty_wt.join("scratch.txt")).unwrap(), + b"unsaved\n" + ); +} + +fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest { + CreateSessionRequest { + cwd: cwd.to_path_buf(), + command: command.into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(worktree_spec(cwd, branch, true)), + } +} + +/// #80/#88: `GET /api/worktrees` lists what the daemon is holding, with the +/// reason and a live in-the-way breakdown; reclaim removes the directory and +/// the store entry but NEVER the branch — its commits outlive the reclaim. +#[tokio::test(flavor = "multi_thread")] +async fn worktrees_endpoint_lists_reasons_and_reclaim_keeps_the_branch() { + let (_daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let _origin = throwaway_origin(base.path()); + + let wt = base.path().join("worktrees").join("feature").join("parked"); + let s = client + .spawn_session(&worktree_req(base.path(), "feature/parked", true)) + .await + .expect("spawn"); + std::fs::write(wt.join("README"), b"worth keeping\n").unwrap(); + commit_all(&wt, "worth keeping"); + let sha = branch_sha(&wt, "feature/parked"); + + let outcome = client.kill_session_forced(s.id).await.expect("force kill"); + assert!(outcome.retained.is_some()); + + let listed = client.list_worktrees().await.expect("list worktrees"); + assert_eq!(listed.len(), 1, "exactly one retained entry: {listed:?}"); + let entry = &listed[0]; + assert_eq!(entry.path, wt); + assert_eq!(entry.branch, "feature/parked"); + assert_eq!(entry.reason, maestro_protocol::RetainedReason::ForceKilled); + assert_eq!(entry.in_the_way.unpushed_commits, 1); + + client.reclaim_worktree(entry.id).await.expect("reclaim"); + assert!(!wt.exists(), "reclaim removes the directory"); + assert!( + client.list_worktrees().await.unwrap().is_empty(), + "reclaim drops the store entry" + ); + + // The whole point of reclaim: the branch and its commits survive. + assert_eq!( + branch_sha(base.path(), "feature/parked"), + sha, + "the branch must keep pointing at the same commit after reclaim" + ); +} + +/// The retained store is state-dir backed (#80): a daemon restart must not +/// orphan the trees it is holding — they are still on disk with entries +/// pointing at them, and still reclaimable afterwards. +#[tokio::test(flavor = "multi_thread")] +async fn retained_store_survives_a_daemon_restart() { + let (mut daemon, client) = bring_up().await; + + let base = TempDir::new().unwrap(); + init_repo(base.path()); + let _origin = throwaway_origin(base.path()); + + let wt = base + .path() + .join("worktrees") + .join("feature") + .join("durable"); + let s = client + .spawn_session(&worktree_req(base.path(), "feature/durable", true)) + .await + .expect("spawn"); + std::fs::write(wt.join("README"), b"survives restarts\n").unwrap(); + commit_all(&wt, "survives restarts"); + client.kill_session_forced(s.id).await.expect("force kill"); + + daemon.restart().await; + + let listed = client.list_worktrees().await.expect("list after restart"); + assert_eq!(listed.len(), 1, "store must survive restart: {listed:?}"); + assert_eq!(listed[0].path, wt); + assert_eq!( + listed[0].reason, + maestro_protocol::RetainedReason::ForceKilled + ); + assert!(wt.is_dir(), "the tree itself must survive the restart too"); + + client + .reclaim_worktree(listed[0].id) + .await + .expect("reclaim"); + assert!(!wt.exists()); +} + // --------------------------------------------------------------------------- // GET /api/projects + /api/branches (#54/#55/#66) // --------------------------------------------------------------------------- @@ -788,10 +1251,12 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec { .collect() } -/// Push every branch of `repo` to a throwaway bare remote, so its commits are -/// reachable from remote-tracking refs. Without one the kill guard (#79) -/// reads every commit as unpushed, and any worktree session refuses to die. -fn push_to_throwaway_origin(repo: &std::path::Path) { +/// Bare remote registered as `origin` on `repo`, seeded with every current +/// branch, so existing commits are reachable from remote-tracking refs. +/// Without one the kill guard (#79) reads every commit as unpushed, and any +/// worktree session refuses to die. The caller must keep the `TempDir` alive +/// while the remote is still needed; later pushes go through [`push_all`]. +fn throwaway_origin(repo: &std::path::Path) -> TempDir { let origin = tempfile::TempDir::new().unwrap(); run_git( &["init", "-q", "--bare", origin.path().to_str().unwrap()], @@ -803,9 +1268,34 @@ fn push_to_throwaway_origin(repo: &std::path::Path) { .args(["remote", "add", "origin", origin.path().to_str().unwrap()]) .output() .expect("git remote add"); + push_all(repo); + origin +} + +fn push_all(repo: &std::path::Path) { run_git(&["push", "-q", "--all", "origin"], repo); } +fn commit_all(dir: &std::path::Path, message: &str) { + run_git(&["add", "-A"], dir); + run_git(&["commit", "-q", "-m", message], dir); +} + +fn branch_sha(repo: &std::path::Path, branch: &str) -> String { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")]) + .output() + .expect("git rev-parse"); + assert!( + out.status.success(), + "branch {branch} missing: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + #[tokio::test(flavor = "multi_thread")] async fn daemon_events_ws_streams_snapshot_then_session_added() { // NAP-248: subscribe to the daemon's WS /api/events, expect an initial From 003e3839bc31e7e4f33090026da156d2522332c1 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 12:53:17 +0100 Subject: [PATCH 67/71] feat(maestro): carry a blocked-work indicator on Session (#83) Session.blocked_work is Option, refreshed by the daemon's watcher; None for worktree-less sessions and until first probed. Defaults on the wire so older payloads stay forward-compatible. --- crates/maestro-cli/src/sessions.rs | 1 + crates/maestro-daemon/src/session.rs | 4 +++ crates/maestro-daemon/tests/sidecar.rs | 1 + crates/maestro-protocol/src/lib.rs | 36 ++++++++++++++++++++++++++ crates/maestro-testing/src/lib.rs | 1 + crates/maestro-tui/src/app.rs | 2 ++ crates/maestro-tui/src/catalog.rs | 1 + crates/maestro-tui/src/sidebar.rs | 2 ++ 8 files changed, 48 insertions(+) diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 7423ddd..c88d0e7 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -164,6 +164,7 @@ mod tests { activity: Default::default(), last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, } } diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 910894b..7d0c44e 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -344,6 +344,9 @@ impl SessionManager { last_activity_at: Utc::now(), current_cwd: None, + // The watcher fills this in on its first blocked-work pass; a + // fresh tree has nothing in the way anyway. + blocked_work: None, }; if let Err(e) = self.inner.sidecar.write(&session) { @@ -541,6 +544,7 @@ impl SessionManager { last_activity_at: Utc::now(), current_cwd: None, + blocked_work: None, }, }; let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index 4997f1a..bf864ed 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -28,6 +28,7 @@ fn sample(id: Uuid) -> Session { activity: Default::default(), last_activity_at: chrono::Utc::now(), current_cwd: Some(PathBuf::from("/tmp/work/crates")), + blocked_work: None, } } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 8a24179..66fb569 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -87,6 +87,13 @@ pub struct Session { /// from daemons that predate the field. #[serde(default)] pub current_cwd: Option, + /// Whether the session's worktree currently holds anything that would + /// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`. + /// Refreshed by the daemon's watcher; `None` for sessions without a + /// worktree and until the first probe. Defaults on the wire so payloads + /// from daemons that predate the field stay forward-compatible. + #[serde(default)] + pub blocked_work: Option, } /// Default for [`Session::last_activity_at`] when an older payload omits it. @@ -531,6 +538,7 @@ mod tests { .unwrap() .with_timezone(&Utc), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), + blocked_work: None, } } @@ -550,6 +558,34 @@ mod tests { assert_eq!(s.activity, ActivityState::Idle); assert_eq!(s.last_activity_at, epoch()); assert!(s.current_cwd.is_none()); + assert!(s.blocked_work.is_none()); + } + + #[test] + fn blocked_work_defaults_to_none_on_legacy_payload() { + // A pre-#83 daemon omits the indicator; it must deserialize to `None` + // rather than failing, and a present value round-trips. + let json = serde_json::json!({ + "id": Uuid::from_u128(1), + "cwd": "/tmp", + "command": "bash", + "status": "Running", + "created_at": "2026-05-24T12:00:00Z" + }); + let s: Session = serde_json::from_value(json).unwrap(); + assert_eq!(s.blocked_work, None); + + let mut with_blocker = sample_session(); + with_blocker.blocked_work = Some(InTheWay { + uncommitted_files: 3, + ..InTheWay::default() + }); + assert_eq!( + with_blocker.clone().blocked_work, + serde_json::from_value::(serde_json::to_value(&with_blocker).unwrap()) + .unwrap() + .blocked_work + ); } #[test] diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index 2706e83..881fd32 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -43,6 +43,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session { activity: Default::default(), last_activity_at: Utc::now(), current_cwd: None, + blocked_work: None, } } diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index b329821..cecf43e 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -1107,6 +1107,7 @@ mod tests { activity: Default::default(), last_activity_at: Utc::now(), current_cwd: None, + blocked_work: None, }], }); app.dispatch(Action::SidebarNext); @@ -1614,6 +1615,7 @@ mod tests { last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, }], }); app.dispatch(Action::KillHighlighted); diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index 32115d6..d2f539b 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -195,6 +195,7 @@ mod tests { activity: Default::default(), last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, } } diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index fa60e90..ea9b566 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -99,6 +99,7 @@ mod tests { activity: Default::default(), last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, } } @@ -188,6 +189,7 @@ mod tests { last_activity_at: chrono::Utc::now(), current_cwd: None, + blocked_work: None, }; let sb = Sidebar { sessions: vec![s], From 187bcb01d6f4d8071eded156f72f5908d9dee108 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 13:04:30 +0100 Subject: [PATCH 68/71] feat(maestro): refresh the blocked-work indicator in the watcher (#83) run_blocked_work_pass rides the liveness tick but probes on a slower cadence (DEFAULT_BLOCKED_WORK_INTERVAL, 15s): each probe is three git subprocesses per session. Sessions without a worktree are filtered out before any probe, so their indicator costs nothing. A failed probe keeps the previous value; settled exits clear it. --- crates/maestro-daemon/src/session.rs | 86 ++++++++++++- .../maestro-daemon/tests/blocked_work_cost.rs | 116 ++++++++++++++++++ .../maestro-daemon/tests/session_manager.rs | 64 +++++++++- 3 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 crates/maestro-daemon/tests/blocked_work_cost.rs diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 7d0c44e..fd27f50 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -33,6 +33,13 @@ pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY; /// How often the liveness watcher polls tmux for sessions that disappeared. pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2); +/// How often the liveness watcher re-probes worktrees for the blocked-work +/// indicator (#83). Each probe costs three git subprocesses per session, so +/// the pass rides the same tick as everything else but runs on a slower +/// cadence: an indicator a few seconds stale still warns before the kill +/// attempt, which is all #83 needs. +pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15); + /// Per-session daemon-local bookkeeping that complements the public `Session`. #[derive(Debug)] pub struct SessionRecord { @@ -492,6 +499,17 @@ impl SessionManager { /// `SessionManager` clone is dropped — call this once from the daemon /// startup path after [`Self::recover`] has populated the map. pub fn start_liveness_watcher(&self, interval: Duration) { + self.start_liveness_watcher_with_blocked_work(interval, DEFAULT_BLOCKED_WORK_INTERVAL); + } + + /// [`Self::start_liveness_watcher`] with the blocked-work cadence (#83) + /// made explicit. Tests pass a small `blocked_work_interval` to watch the + /// probe actually happen. + pub fn start_liveness_watcher_with_blocked_work( + &self, + interval: Duration, + blocked_work_interval: Duration, + ) { let weak = Arc::downgrade(&self.inner); tokio::spawn(async move { let mut ticker = tokio::time::interval(interval); @@ -499,6 +517,9 @@ impl SessionManager { // first tick fires immediately — skip it so we don't reap before // the caller has a chance to create anything. ticker.tick().await; + // Start "due" so the first live tick probes: a recovered daemon + // should not wait a full interval to show what blocks. + let mut since_probe = blocked_work_interval; loop { ticker.tick().await; let Some(inner) = weak.upgrade() else { @@ -508,6 +529,11 @@ impl SessionManager { // Same tick, no new timer (NAP-340): refresh activity for the // survivors and emit SessionUpdated on any transition. run_activity_pass(&inner).await; + since_probe += interval; + if since_probe >= blocked_work_interval { + since_probe = Duration::ZERO; + run_blocked_work_pass(&inner).await; + } } }); } @@ -813,6 +839,61 @@ async fn run_activity_pass(inner: &Arc) { } } +/// Refresh each running session's blocked-work indicator (#83): what its +/// worktree holds right now that would block an unforced kill. Rides the +/// liveness tick on its own (slower) cadence — see +/// [`DEFAULT_BLOCKED_WORK_INTERVAL`] — because every probe is three git +/// subprocesses per session. +/// +/// Sessions without a worktree are filtered out BEFORE any probe: they can +/// never block a kill, so their indicator must cost nothing at all. A failed +/// probe keeps the previous value rather than clearing it — a transient git +/// error must not read as "nothing blocks". Like the activity pass, this only +/// emits `SessionUpdated` when the value actually changed. +async fn run_blocked_work_pass(inner: &Arc) { + let targets: Vec<(Uuid, (PathBuf, PathBuf))> = { + let sessions = inner.sessions.lock().await; + sessions + .iter() + .filter(|(_, r)| r.session.status == SessionStatus::Running) + .filter_map(|(id, r)| { + let wt = r.worktree_base_repo.clone().zip(r.worktree_path.clone())?; + Some((*id, wt)) + }) + .collect() + }; + if targets.is_empty() { + return; + } + + for (id, wt) in targets { + // The kill guard's own probe (#79), so the indicator can never + // disagree with what a kill attempt would find. + let breakdown = match probe_worktree(&wt).await { + Ok(breakdown) => breakdown, + Err(e) => { + tracing::debug!(%id, error = ?e, "blocked-work: probe failed; keeping previous value"); + continue; + } + }; + let updated = { + let mut sessions = inner.sessions.lock().await; + let Some(rec) = sessions.get_mut(&id) else { + continue; + }; + if rec.session.blocked_work == Some(breakdown) { + None + } else { + rec.session.blocked_work = Some(breakdown); + Some(rec.session.clone()) + } + }; + if let Some(session) = updated { + let _ = inner.events.send(CatalogEvent::SessionUpdated { session }); + } + } +} + /// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`] /// and settle its worktree per the natural-exit rule (#82). /// @@ -856,10 +937,13 @@ async fn mark_session_dead(inner: &Arc, id: Uuid) { if let Some(wt) = &worktree { if settle_worktree_on_exit(inner, id, wt, branch).await { - session.worktree = None; + // The tree is settled (removed or retained); a stale "in the + // way" would describe something the session no longer holds. + session.blocked_work = None; let mut sessions = inner.sessions.lock().await; if let Some(record) = sessions.get_mut(&id) { record.session.worktree = None; + record.session.blocked_work = None; record.worktree_path = None; record.worktree_base_repo = None; record.worktree_branch = None; diff --git a/crates/maestro-daemon/tests/blocked_work_cost.rs b/crates/maestro-daemon/tests/blocked_work_cost.rs new file mode 100644 index 0000000..a04a923 --- /dev/null +++ b/crates/maestro-daemon/tests/blocked_work_cost.rs @@ -0,0 +1,116 @@ +//! The performance contract of #83: the blocked-work pass must cost NO git +//! probe at all for a session without a worktree, while a session with one is +//! still probed. +//! +//! `git` itself is shimmed through PATH with a wrapper that logs every +//! invocation before exec'ing the real binary, so the assertion counts actual +//! probes rather than inferring them from state. This lives in its own test +//! binary because it mutates the process environment. + +mod common; + +use std::collections::HashMap; +use std::process::Command; +use std::time::Duration; + +use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture}; +use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec}; +use tempfile::TempDir; + +fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest { + CreateSessionRequest { + cwd: cwd.to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.to_path_buf(), + branch: branch.into(), + create_branch: true, + }), + } +} + +#[tokio::test] +async fn a_session_without_a_worktree_costs_no_probe() { + let fx = DaemonFixture::new(); + let log = TempDir::new().unwrap(); + let log_path = log.path().join("git-calls.log"); + + // Session A owns a real (pristine, pushed) worktree; session B has none. + let repo_a = TempDir::new().unwrap(); + init_repo(repo_a.path()); + init_bare_repo(&repo_a.path().join("origin.git")); + push_all_to_origin(repo_a.path(), &repo_a.path().join("origin.git")); + fx.manager + .create(request(repo_a.path(), repo_a.path(), "feat/probed")) + .await + .expect("create A"); + + let cwd_b = TempDir::new().unwrap(); + let mut req_b = request(cwd_b.path(), cwd_b.path(), "feat/unused"); + req_b.worktree = None; + fx.manager.create(req_b).await.expect("create B"); + + // Shim `git` AFTER setup so only watcher-driven probes get logged. + let real_git = String::from_utf8( + Command::new("sh") + .arg("-c") + .arg("command -v git") + .output() + .expect("locate git") + .stdout, + ) + .unwrap() + .trim() + .to_string(); + let shim_dir = TempDir::new().unwrap(); + let script = format!( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexec {} \"$@\"\n", + log_path.display(), + real_git, + ); + std::fs::write(shim_dir.path().join("git"), script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + shim_dir.path().join("git"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + let old_path = std::env::var("PATH").unwrap_or_default(); + std::env::set_var( + "PATH", + format!("{}:{}", shim_dir.path().display(), old_path), + ); + + fx.manager.start_liveness_watcher_with_blocked_work( + Duration::from_millis(50), + Duration::from_millis(100), + ); + + // The pass demonstrably runs and probed A's tree... + wait_for_async(Duration::from_secs(5), || async { + fx.manager + .snapshot() + .await + .iter() + .any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default())) + }) + .await; + + std::env::set_var("PATH", &old_path); + + let calls = std::fs::read_to_string(&log_path).unwrap_or_default(); + assert!( + !calls.is_empty(), + "the pass never probed anything — the shim saw no git call at all" + ); + // ...while B — no worktree, so nothing to probe — was never touched. + assert!( + !calls.contains(cwd_b.path().to_str().unwrap()), + "a worktree-less session was probed:\n{calls}" + ); +} diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 12d5c5e..6b4f016 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -367,6 +367,67 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() { assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists()); } +/// #83: the watcher refreshes `Session.blocked_work` on its own cadence — +/// first "nothing blocks" for a pristine tree, then the breakdown once the +/// tree picks up unsaved work, so clients can warn before the kill attempt. +#[tokio::test] +async fn watcher_refreshes_the_blocked_work_indicator() { + let fx = DaemonFixture::new(); + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let wt_path = repo.path().join("worktrees").join("feat").join("indicator"); + + fx.manager + .create(CreateSessionRequest { + cwd: repo.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: Some(WorktreeSpec { + base_repo: repo.path().to_path_buf(), + branch: "feat/indicator".into(), + create_branch: true, + }), + }) + .await + .expect("create"); + + // Pushed and clean: nothing in the way, and the indicator must say so. + let origin = TempDir::new().unwrap(); + init_bare_repo(origin.path()); + push_all_to_origin(repo.path(), origin.path()); + + fx.manager.start_liveness_watcher_with_blocked_work( + Duration::from_millis(50), + Duration::from_millis(100), + ); + + wait_for_async(Duration::from_secs(5), || async { + fx.manager.snapshot().await[0].blocked_work == Some(maestro_protocol::InTheWay::default()) + }) + .await; + + // Unsaved work appears; the next pass must surface it. + std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap(); + std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap(); + wait_for_async(Duration::from_secs(5), || async { + matches!( + &fx.manager.snapshot().await[0].blocked_work, + Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1 + ) + }) + .await; + + // A steady state must not re-emit every tick (#83 change-detection). + let mut events = fx.manager.events(); + while events.try_recv().is_ok() {} + tokio::time::sleep(Duration::from_millis(400)).await; + assert!( + !matches!(events.try_recv(), Ok(CatalogEvent::SessionUpdated { .. })), + "an unchanged worktree must not re-emit SessionUpdated" + ); +} + /// Cleanup on kill is unconditional once the guard passes (#79): even a spec /// that says "don't clean up" loses its worktree — the branch was pushed, so /// nothing in the tree is unique and removal goes ahead. @@ -721,7 +782,8 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() { // settled, so dismissal is neither refused nor retained twice. // (`Dead` is observable a moment before the settle step finishes.) wait_for_async(Duration::from_secs(3), || async { - fx.manager.snapshot().await[0].worktree.is_none() + let s = &fx.manager.snapshot().await[0]; + s.worktree.is_none() && s.blocked_work.is_none() }) .await; let snap = fx.manager.snapshot().await; From 524a848fc63f8d6aa063e0f0fa0814847a7ed68b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 13:06:43 +0100 Subject: [PATCH 69/71] feat(maestro): show blocked work in the CLI table and warn on kill (#83) --- crates/maestro-cli/src/dispatch.rs | 24 +++++++++++++ crates/maestro-cli/src/sessions.rs | 54 +++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs index 9653629..3e2b36a 100644 --- a/crates/maestro-cli/src/dispatch.rs +++ b/crates/maestro-cli/src/dispatch.rs @@ -62,6 +62,11 @@ pub async fn dispatch( run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?; } Command::Kill(args) => { + // #83: warn BEFORE the attempt, so the refusal is not a surprise. + // Best-effort: an unreachable list must not block the kill. + if !args.force { + warn_if_blocked(&daemon, args.session_id).await; + } let outcome = run_kill(&daemon, &args).await?; println!("killed {}", args.session_id); if let Some(w) = outcome.retained { @@ -99,6 +104,25 @@ pub async fn dispatch( /// Default detach byte, matching `maestro attach --detach-key`'s `^q`. const CTRL_Q: u8 = 0x11; +/// #83: say "this will be refused" before the operator commits to it, in the +/// same words [`maestro_protocol::InTheWay`] uses everywhere else. +async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) { + let Ok(sessions) = daemon.list_sessions().await else { + return; + }; + if let Some(b) = sessions + .iter() + .find(|s| s.id == id) + .and_then(|s| s.blocked_work) + { + if !b.is_clear() { + eprintln!( + "warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it" + ); + } + } +} + /// HTTP client pointed at the daemon's API. That surface needs no auth — /// reaching `bind_addr` is the authorization. #[allow(clippy::result_large_err)] diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index c88d0e7..ad43be6 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -93,8 +93,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon } writeln!( out, - " {:<36} {:<8} {:<9} {:<28} {}", - "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND" + " {:<36} {:<8} {:<9} {:<28} {:<40} {}", + "ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED" ) .ok(); for s in sorted { @@ -115,15 +115,28 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg), _ => s.command.clone(), }; + // #83: what an unforced kill would be refused for, in InTheWay's own + // words; blank while nothing blocks (or hasn't been probed yet). + let blocked = blocked_label(s); writeln!( out, - " {:<36} {:<8} {:<9} {:<28} {command}", - s.id, status, activity, dir + " {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}", + s.id, status, activity, dir, command ) .ok(); } } +/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`. +/// Empty unless something actually blocks — "no unsaved work" would just be +/// noise on every row. +pub(crate) fn blocked_label(s: &Session) -> String { + s.blocked_work + .filter(|b| !b.is_clear()) + .map(|b| b.to_string()) + .unwrap_or_default() +} + /// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web /// UI's abbreviation so CLI and browser show the same directory shorthand. pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String { @@ -225,4 +238,37 @@ mod tests { super::print_session_table(&mut buf, &[], &info("")); assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n"); } + + /// #83: a blocked session shows what an unforced kill would be refused + /// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing. + #[test] + fn shows_the_blocked_work_summary_only_when_something_blocks() { + use maestro_protocol::InTheWay; + + let mut blocked = session("/tmp", "claude", 1); + blocked.blocked_work = Some(InTheWay { + uncommitted_files: 3, + unpushed_commits: 2, + ..InTheWay::default() + }); + let mut clear = session("/tmp", "bash", 2); + clear.blocked_work = Some(InTheWay::default()); + let unprobed = session("/tmp", "zsh", 3); + + let mut buf = Vec::new(); + super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info("")); + let out = String::from_utf8(buf).unwrap(); + assert!( + out.contains("3 uncommitted files, 2 unpushed commits"), + "{out}" + ); + // Clear and unprobed rows stay blank in the BLOCKED column. + let lines: Vec<&str> = out.lines().collect(); + let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap(); + for line in &lines[header + 1..] { + if !line.contains("uncommitted files") { + assert!(!line.contains("unsaved work"), "{line}"); + } + } + } } From 6161cabe77e66f924ed410a93075046e818c08cd Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 13:08:13 +0100 Subject: [PATCH 70/71] feat(maestro): warn on kill and mark blocked sessions in the TUI (#83) --- crates/maestro-tui/src/overlays.rs | 40 ++++++++++++++++++++------- crates/maestro-tui/src/render.rs | 11 ++++++++ crates/maestro-tui/src/sidebar.rs | 43 +++++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 10 deletions(-) diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 3f540a6..8ac67ca 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -14,23 +14,28 @@ use ratatui::Frame; #[derive(Debug, Clone)] pub struct KillConfirm { pub label: String, + /// #83: what the daemon's watcher last saw in the worktree, phrased by + /// [`maestro_protocol::InTheWay`]'s Display — so "this will be refused" + /// is visible before the attempt. `None` while nothing blocks. + pub blockers: Option, } impl KillConfirm { pub fn render(&self, f: &mut Frame, parent: Rect) { - let area = centered(parent, 50, 5); + let area = centered(parent, 60, if self.blockers.is_some() { 6 } else { 5 }); f.render_widget(Clear, area); let block = Block::default().title(" kill ").borders(Borders::ALL); let inner = block.inner(area); f.render_widget(block, area); - f.render_widget( - Paragraph::new(vec![ - Line::from(format!("Kill session {}?", self.label)), - Line::from(""), - Line::from("[y] yes [n] no"), - ]), - inner, - ); + let mut lines = vec![Line::from(format!("Kill session {}?", self.label))]; + if let Some(blockers) = &self.blockers { + // Same phrasing the reclaim confirmation uses for its own guard. + lines.push(Line::from(format!("Currently in the way: {blockers}"))); + lines.push(Line::from("The kill will be refused unless forced.")); + } + lines.push(Line::from("")); + lines.push(Line::from("[y] yes [n] no")); + f.render_widget(Paragraph::new(lines), inner); } } @@ -121,6 +126,7 @@ mod tests { fn kill_confirm_renders_prompt() { let modal = KillConfirm { label: "session-7".into(), + blockers: None, }; let rendered = render_to_string(60, 10, |f| modal.render(f, f.area())); assert!(rendered.contains("Kill")); @@ -129,6 +135,22 @@ mod tests { assert!(rendered.contains("no")); } + /// #83: when the worktree blocks, the confirm names it before the + /// operator commits — in `InTheWay`'s own words. + #[test] + fn kill_confirm_names_what_blocks() { + let modal = KillConfirm { + label: "session-7".into(), + blockers: Some("2 uncommitted files".into()), + }; + let rendered = render_to_string(70, 10, |f| modal.render(f, f.area())); + assert!( + rendered.contains("Currently in the way: 2 uncommitted files"), + "{rendered}" + ); + assert!(rendered.contains("refused"), "{rendered}"); + } + #[test] fn help_overlay_lists_lines() { let help = HelpOverlay { diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 01e3c9b..12673cb 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -146,8 +146,19 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { match inputs.state.overlay() { Some(OverlayKind::KillConfirm(id)) => { + // #83: surface what the watcher last saw in the worktree, in + // InTheWay's own words, before the operator commits to the kill. + let blockers = inputs + .sidebar + .sessions + .iter() + .find(|s| &s.id == id) + .and_then(|s| s.blocked_work) + .filter(|b| !b.is_clear()) + .map(|b| b.to_string()); let kc = KillConfirm { label: id.to_string(), + blockers, }; kc.render(f, area); map.modal = Some(area); diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index ea9b566..724eff0 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -52,6 +52,13 @@ impl Sidebar { (SessionStatus::Running, false) => "○", }; let label = name_for(s); + // #83: a session whose worktree blocks a kill gets a warning + // glyph; the kill confirm names the specifics. + let blocked = s + .blocked_work + .filter(|b| !b.is_clear()) + .map(|_| " ⚠") + .unwrap_or_default(); let style = if self.highlight == Some(s.id) { Style::default().add_modifier(Modifier::REVERSED) } else { @@ -59,7 +66,7 @@ impl Sidebar { }; lines.push(Line::from(vec![ Span::raw(" "), - Span::styled(format!("{glyph} {label}"), style), + Span::styled(format!("{glyph} {label}{blocked}"), style), ])); } f.render_widget(Paragraph::new(lines), inner); @@ -173,6 +180,40 @@ mod tests { assert!(line.contains('✗'), "expected dead glyph in: {line:?}"); } + /// #83: a session whose worktree blocks a kill carries the warning glyph; + /// a clear or unprobed one does not. + #[test] + fn blocked_session_gets_a_warning_glyph() { + use maestro_protocol::InTheWay; + + let mut blocked = session_named(1, "claude"); + blocked.blocked_work = Some(InTheWay { + unpushed_commits: 4, + ..InTheWay::default() + }); + let mut clear = session_named(2, "bash"); + clear.blocked_work = Some(InTheWay::default()); + let unprobed = session_named(3, "vim"); + let sb = Sidebar { + sessions: vec![blocked, clear, unprobed], + highlight: None, + attached: Default::default(), + }; + let rendered = render_to_string(&sb, 40, 8); + let line = rendered + .lines() + .find(|l| l.contains("claude")) + .expect("blocked row"); + assert!(line.contains('⚠'), "expected warning glyph in: {line:?}"); + for name in ["bash", "vim"] { + let line = rendered + .lines() + .find(|l| l.contains(name)) + .expect("clear row"); + assert!(!line.contains('⚠'), "{name} must not warn: {line:?}"); + } + } + #[test] fn falls_back_to_command_when_name_metadata_is_absent() { let s = Session { From d4dcfe7f0053f347857b49104f2f4ab33f08a442 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 13:19:12 +0100 Subject: [PATCH 71/71] feat(maestro): show blocked work on session rows and warn on kill in the web UI (#83) --- web/src/catalog.test.ts | 106 ++++++++++++++++++++++++++++++++++++++++ web/src/catalog.ts | 13 ++++- web/src/styles.css | 7 +++ web/src/types.ts | 18 +++++++ 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 web/src/catalog.test.ts diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts new file mode 100644 index 0000000..b502fd7 --- /dev/null +++ b/web/src/catalog.test.ts @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom +import { beforeAll, describe, expect, it } from "vitest"; + +import { renderSidebar } from "./catalog.js"; +import type { CatalogListener, CatalogStore } from "./events.js"; +import type { InTheWay, Session } from "./types.js"; + +// happy-dom runs without a backing store; renderSidebar reads the persisted +// sidebar width on mount. +beforeAll(() => { + Object.defineProperty(window, "localStorage", { + value: { getItem: () => null, setItem: () => {} }, + }); + // No daemon here; renderSidebar treats a failed info fetch as "absolute + // paths are a fine fallback". + window.fetch = () => Promise.reject(new Error("offline")); +}); + +function session(overrides: Partial): Session { + return { + id: "11111111-2222-3333-4444-555555555555", + cwd: "/home/naps/tea/maestro", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-08-24T10:00:00Z", + ...overrides, + }; +} + +function storeWith(sessions: Session[]): CatalogStore { + const state = { sessions: new Map(sessions.map((s) => [s.id, s])) }; + return { + snapshot: () => state, + // Like the real store, deliver an immediate snapshot so the sidebar + // renders its rows during construction. + subscribe: (fn: CatalogListener) => { + fn(state); + return () => {}; + }, + } as unknown as CatalogStore; +} + +function rowFor(el: HTMLElement, id: string): HTMLElement | null { + return el.querySelector(`[data-session-id="${id}"]`); +} + +describe("blocked-work indicator (#83)", () => { + it("marks the row of a blocked session with the shared phrasing", () => { + const blocked = session({ + id: "aaaaaaaa-0000-0000-0000-000000000001", + blocked_work: { uncommitted_files: 3, unpushed_commits: 2 }, + }); + const el = renderSidebar(storeWith([blocked])).el; + const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked"); + expect(badge?.textContent).toContain("⚠"); + expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits"); + }); + + it("does not mark clear or unprobed sessions", () => { + const clear = session({ + id: "aaaaaaaa-0000-0000-0000-000000000002", + blocked_work: {} as InTheWay, + }); + const unprobed = session({ + id: "aaaaaaaa-0000-0000-0000-000000000003", + }); + const el = renderSidebar(storeWith([clear, unprobed])).el; + expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull(); + expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull(); + }); + + it("restates the warning at the kill confirm", async () => { + const confirmations: string[] = []; + window.confirm = (msg?: string) => { + confirmations.push(msg ?? ""); + return false; + }; + const blocked = session({ + id: "aaaaaaaa-0000-0000-0000-000000000004", + command: "bash", + blocked_work: { untracked_files: 1 }, + }); + const el = renderSidebar(storeWith([blocked])).el; + const kill = rowFor(el, blocked.id)?.querySelector(".leaf-kill"); + kill?.click(); + await Promise.resolve(); + expect(confirmations).toHaveLength(1); + expect(confirmations[0]).toContain("1 untracked file"); + expect(confirmations[0]).toContain("refused unless forced"); + }); + + it("confirms plainly when nothing blocks", async () => { + const confirmations: string[] = []; + window.confirm = (msg?: string) => { + confirmations.push(msg ?? ""); + return false; + }; + const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" }); + const el = renderSidebar(storeWith([plain])).el; + rowFor(el, plain.id)?.querySelector(".leaf-kill")?.click(); + await Promise.resolve(); + expect(confirmations).toEqual(['Kill session "claude"?']); + }); +}); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 3fad212..b9d4bf0 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -3,7 +3,7 @@ import { getDaemonInfo, killSession } from "./api.js"; import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js"; import { CatalogStore } from "./events.js"; import type { Session } from "./types.js"; -import { abbreviateFolderName, sessionName } from "./types.js"; +import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js"; import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; import { createThemeToggle } from "./theme.js"; @@ -64,10 +64,14 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { // The directory is the enclosing folder, so only surface the command — and // only when it isn't already what the name shows. const showCmd = s.command !== name; + // #83: warn on the row itself, in `formatInTheWay`'s shared phrasing, so + // "this will be refused" is visible before the kill is attempted. + const blockers = sessionBlockers(s); row.innerHTML = ` ${escapeHtml(name)} ${showCmd ? `${escapeHtml(s.command)}` : ""} ${running ? "" : `exited`} + ${blockers ? `⚠ ${escapeHtml(formatInTheWay(blockers))}` : ""} @@ -83,7 +87,12 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { killBtn.addEventListener("click", async (ev) => { ev.preventDefault(); ev.stopPropagation(); - if (!confirm(`Kill session "${sessionName(s)}"?`)) return; + // #83: the confirm restates the warning at the moment of decision. + const blockers = sessionBlockers(s); + const question = blockers + ? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.` + : `Kill session "${name}"?`; + if (!confirm(question)) return; try { await killSession(s.id); } catch (e) { diff --git a/web/src/styles.css b/web/src/styles.css index 5ed77a1..065d7e1 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -831,6 +831,13 @@ textarea { flex: 0 0 auto; } +/* #83: the session row's blocked-work warning — an unforced kill will be + refused, and the operator should see that before pressing the button. */ +.leaf-badge.leaf-blocked { + color: var(--warn, #b45309); + border-color: var(--warn, #b45309); +} + .leaf-actions { position: absolute; right: var(--sp-1); diff --git a/web/src/types.ts b/web/src/types.ts index 48040cf..b036b5b 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -31,6 +31,11 @@ export interface Session { * observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir). * Drives the directory-based session tree. Absent until first observed. */ current_cwd?: string | null; + /** What currently blocks an unforced kill of this session (#83), as the + * daemon's watcher last probed it. Absent until probed and for sessions + * without a worktree; all counts default to 0 on the wire for older + * daemons. */ + blocked_work?: InTheWay | null; } /** One directory's immediate subdirectories (NAP-329). */ @@ -180,6 +185,19 @@ export function reasonLabel(reason: RetainedReason): string { return reason; } +/** #83: this session's blocked-work breakdown when something actually blocks + * an unforced kill; `null` while clear or not yet probed. Render it with + * {@link formatInTheWay} so every client phrases it identically. */ +export function sessionBlockers(s: Session): InTheWay | null { + const w = s.blocked_work; + if (!w) return null; + const blocked = + (w.uncommitted_files ?? 0) > 0 || + (w.untracked_files ?? 0) > 0 || + (w.unpushed_commits ?? 0) > 0; + return blocked ? w : null; +} + export interface CreateSessionRequest { cwd: string; command: string;