From 4921f6279654a6affd3c56273710a33fd695bf62 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 10:59:14 +0100 Subject: [PATCH] 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" + ); +}