From 8ed44e785d311b2436291f0fa8e74d7a6626f815 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 22:55:52 +0100 Subject: [PATCH 1/2] 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 2/2] 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")