Compare commits

...
5 changed files with 348 additions and 4 deletions
+11 -1
View File
@@ -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<Vec<Project>, ClientError> {
Ok(self.daemon.list_projects().await?)
}
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, 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(
+21
View File
@@ -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<Option<&str>>) -> 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,
+1 -1
View File
@@ -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;
+308
View File
@@ -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 `<repo>/worktrees/<branch>` 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 `<main>/worktrees/<branch>` 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 `<root>/<name>`, 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")
+7 -2
View File
@@ -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<Vec<BranchInfo>, ClientError> {
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
.await
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(