Compare commits

..
Author SHA1 Message Date
Miguel Palhas 4921f62796 feat(maestro): GET /api/worktrees and DELETE reclaim endpoints 2026-08-24 10:59:14 +01:00
Miguel Palhas 44b9745e8c feat(maestro): read-side in-the-way probe and guard-free reclaim 2026-08-24 10:53:38 +01:00
Miguel Palhas cc8af94254 feat(maestro): worktree routes and retained-worktree payload in protocol 2026-08-24 10:48:12 +01:00
Miguel Palhas 50a9d35f62 merge: never adopt, sidestep collisions with a suffix
Closes #77
2026-08-24 10:41:17 +01:00
Miguel Palhas a9ef3ff97b merge: retained-worktree store
Closes #78
2026-08-24 10:39:08 +01:00
Miguel Palhas 0457a61a61 refactor(maestro): sidestep worktree path collisions with a numeric suffix
Anything occupying <repo>/worktrees/<branch> — 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.
2026-08-24 10:38:32 +01:00
14 changed files with 880 additions and 133 deletions
+3
View File
@@ -85,6 +85,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
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?;
+1
View File
@@ -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};
+115
View File
@@ -38,6 +38,9 @@ pub struct LocalApiState {
pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>,
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
/// reclaim endpoint (#80).
pub retained: Arc<crate::retained_store::RetainedStore>,
}
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<LocalApiState>, Path(id): Path<Uuid>) -> 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<LocalApiState>) -> 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::<Result<Vec<_>, 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<LocalApiState>, Path(id): Path<Uuid>) -> Response {
let found = {
let retained = s.retained.clone();
tokio::task::spawn_blocking(move || -> Result<
Option<crate::retained_store::RetainedWorktreeRecord>,
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<String>,
presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>,
retained: Arc<crate::retained_store::RetainedStore>,
) -> LocalApiState {
LocalApiState {
manager,
@@ -204,6 +318,7 @@ pub fn state_for(
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
projects,
retained,
}
}
+9 -18
View File
@@ -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<RetainedWorktree, RetainedStoreError> {
let entry = RetainedWorktree {
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
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<Vec<RetainedWorktree>, RetainedStoreError> {
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, 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::<RetainedWorktree>(&bytes) {
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
out.push(worktree);
}
}
+3 -19
View File
@@ -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 {
+24 -61
View File
@@ -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
/// `<main checkout>/worktrees/<sanitized branch>` 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.
/// `<main checkout>/worktrees/<sanitized branch>` by default. If the path
/// is occupied, [`Self::add_new`] / [`Self::add_existing`] sidestep to
/// `<path>-2`, `<path>-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 <base_repo> worktree add -b <branch> <path>` — creates the
/// branch. Returns the path of the new worktree on success.
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
@@ -98,22 +95,18 @@ impl WorktreeManager {
branch: &str,
create_branch: bool,
) -> Result<PathBuf, WorktreeError> {
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 (`<repo>/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
+316
View File
@@ -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<InTheWay, ProbeError> {
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/<branch>`, 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<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
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 `<repo>/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());
}
}
+1
View File
@@ -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
+1
View File
@@ -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
@@ -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")
}
+232
View File
@@ -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<maestro_protocol::RetainedWorktree> =
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<maestro_protocol::RetainedWorktree> =
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<maestro_protocol::RetainedWorktree> =
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<maestro_protocol::RetainedWorktree> =
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"
);
}
+42 -35
View File
@@ -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 `<root>/<repo basename>-<digest>/` 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:?}");
}
+106
View File
@@ -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<Utc>,
#[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();
+18
View File
@@ -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}");