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/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/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 064ba15..869e1b9 100644 --- a/crates/maestro-daemon/src/retained_store.rs +++ b/crates/maestro-daemon/src/retained_store.rs @@ -14,6 +14,9 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; +/// 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)] @@ -24,20 +27,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 +62,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 +76,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 +99,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 +115,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-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()); + } +} 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" + ); +} 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}");