Compare commits

...
Author SHA1 Message Date
Miguel Palhas 56c8e35d6f feat(maestro): wire worktrees view into the tui loop (#85)
w opens the view; r confirms reclaim and says the branch survives.
The in-the-way summary uses InTheWay's Display impl so the TUI cannot
drift from the CLI's wording.
2026-08-24 11:32:22 +01:00
Miguel Palhas 0166607064 feat(maestro): retained-worktrees view overlay (#85) 2026-08-24 11:26:56 +01:00
Miguel Palhas 58b4257c92 merge: maestro worktrees list / reclaim
Closes #86
2026-08-24 11:18:17 +01:00
Miguel Palhas 86443d195e merge: base update from blitz/worktree-lifecycle 2026-08-24 11:14:12 +01:00
Miguel Palhas abaa498b9f feat(maestro): worktrees list / reclaim CLI (#86)
maestro worktrees list mirrors the sessions table shape: hostname header,
home-abbreviated paths, and the in-the-way breakdown recomputed by the
daemon at read time. reclaim takes a required explicit ID — no
reclaim-everything — and its confirmation says the branch survives.
2026-08-24 11:13:55 +01:00
Miguel Palhas 62581d9f85 merge: refuse a kill when the worktree holds unsaved work
Collapses the two probes #79 and #80 grew in parallel into one:
worktree_probe::in_the_way is the single predicate behind the kill
refusal, the retained list, and the natural-exit classifier to come.
Keeps #79's in-worktree resolution (detached HEAD, missing directory)
and #80's InTheWay payload, and drops the duplicate UnsavedWork type.

Closes #79
2026-08-24 11:09:24 +01:00
Miguel Palhas 267f8a6712 feat(maestro): DaemonClient calls for retained worktrees 2026-08-24 11:06:30 +01:00
Miguel Palhas a575b8bef1 feat(maestro): refuse a kill when the worktree holds unsaved work (#79)
The guard moves off the worktree and onto the kill. DELETE
/api/sessions/:id now runs the shared unsaved_work probe before tearing
anything down and answers 409 kill_guarded with the breakdown on the
wire, so a client can say "3 uncommitted files, 2 unpushed commits".
The session keeps running and the tree is untouched.

WorktreeManager::remove is unconditional now — git itself still refuses
a dirty tree as a last line of defense.
2026-08-24 11:03:43 +01:00
Miguel Palhas e9e0136a89 merge: /api/worktrees list and reclaim
Closes #80
2026-08-24 11:02:13 +01:00
Miguel Palhas 4921f62796 feat(maestro): GET /api/worktrees and DELETE reclaim endpoints 2026-08-24 10:59:14 +01:00
Miguel Palhas 898c3cc763 feat(maestro): shared unsaved-work probe for worktrees (#79)
One function, unsaved_work(), returns the structured breakdown —
uncommitted tracked changes, untracked non-ignored files, unpushed
commits — so the kill guard and the natural-exit classifier (#82)
answer from the same place. Ignored files never count: every Rust
checkout has a target/, and counting those would leave no session
killable.

WorktreeManager::remove drops its own dirty-tree guard; git itself
still refuses a dirty tree, and the real guard moves onto the kill.
2026-08-24 10:59:08 +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 132b606830 feat(maestro): retained-worktree store in state_dir (#78)
Per-entry JSON files under <state_dir>/retained-worktrees/, written like
sidecar records: atomic tmp+rename, missing/malformed files tolerated on
read. Entries carry path, base repo, branch, reason (force-killed,
exited-with-unsaved-work), timestamp; they leave on reclaim or when
prune_missing notices the directory was removed by hand.
2026-08-24 10:35:26 +01:00
33 changed files with 2522 additions and 101 deletions
+82
View File
@@ -41,6 +41,36 @@ pub enum Command {
Attach(AttachArgs),
/// Kill a session by ID.
Kill(KillArgs),
/// List retained worktrees, or reclaim one by ID (#86).
Worktrees(WorktreesArgs),
}
/// `maestro worktrees <list|reclaim>`
#[derive(Debug, clap::Args)]
pub struct WorktreesArgs {
#[command(subcommand)]
pub command: WorktreesCommand,
}
#[derive(Debug, Subcommand)]
pub enum WorktreesCommand {
/// List retained worktrees and what is in the way of reclaiming each.
List(ListWorktreesArgs),
/// Reclaim one retained worktree: removes the directory, never the branch.
Reclaim(ReclaimArgs),
}
#[derive(Debug, clap::Args)]
pub struct ListWorktreesArgs {
/// Output as JSON instead of a table.
#[arg(long)]
pub json: bool,
}
#[derive(Debug, clap::Args)]
pub struct ReclaimArgs {
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
pub id: Uuid,
}
#[derive(Debug, clap::Args)]
@@ -241,4 +271,56 @@ mod tests {
.unwrap_err();
assert!(err.to_string().contains("KEY=VALUE"));
}
#[test]
fn parses_worktrees_list() {
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
match cli.command {
Command::Worktrees(w) => match w.command {
WorktreesCommand::List(a) => assert!(a.json),
_ => panic!("wrong worktrees subcommand"),
},
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parses_worktrees_reclaim() {
let id = "01234567-89ab-cdef-0123-456789abcdef";
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
match cli.command {
Command::Worktrees(w) => match w.command {
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
_ => panic!("wrong worktrees subcommand"),
},
_ => panic!("wrong subcommand"),
}
}
/// Contract rule: reclaim refuses to run without an explicit target.
/// There is no reclaim-everything convenience.
#[test]
fn reclaim_requires_an_explicit_target() {
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
assert!(
matches!(
err.kind(),
clap::error::ErrorKind::InvalidSubcommand
| clap::error::ErrorKind::MissingRequiredArgument
),
"{err}"
);
}
#[test]
fn worktrees_without_subcommand_shows_help() {
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
assert!(
matches!(
err.kind(),
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
),
"{err}"
);
}
}
+22 -1
View File
@@ -9,8 +9,9 @@ use std::path::{Path, PathBuf};
use maestro_protocol::DaemonClient;
use crate::attach::{parse_detach_key, run_attach_url};
use crate::cli::Command;
use crate::cli::{Command, WorktreesCommand};
use crate::sessions::{run_kill, run_list, run_spawn};
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
use maestro_config::{default_config_path, MaestroConfigFile};
#[derive(Debug, thiserror::Error)]
@@ -64,6 +65,26 @@ pub async fn dispatch(
run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id);
}
Command::Worktrees(args) => match args.command {
WorktreesCommand::List(list_args) => {
let mut stdout = std::io::stdout().lock();
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
}
WorktreesCommand::Reclaim(reclaim_args) => {
// The daemon removes the directory and the store entry; the
// branch is never deleted — worth saying out loud (#86).
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
match entry {
Some(wt) => println!(
"reclaimed {} ({})\nbranch '{}' kept",
wt.id,
wt.path.display(),
wt.branch
),
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
}
}
},
}
Ok(())
}
+1
View File
@@ -6,5 +6,6 @@ pub mod attach;
pub mod cli;
pub mod dispatch;
pub mod sessions;
pub mod worktrees;
pub use dispatch::{dispatch, CliError};
+1 -1
View File
@@ -122,7 +122,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
/// UI's abbreviation so CLI and browser show the same directory shorthand.
fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
let p = path.to_string_lossy();
if !home.is_empty() {
if p == home {
+204
View File
@@ -0,0 +1,204 @@
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
//!
//! `reclaim` removes one directory; it never deletes the branch, which is
//! said out loud in the output because "reclaim" otherwise sounds like it
//! takes the commits too.
use std::io::Write;
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
use crate::sessions::abbreviate_dir;
/// `maestro worktrees list`
pub async fn run_list(
client: &DaemonClient,
args: &ListWorktreesArgs,
out: &mut impl Write,
) -> Result<(), ClientError> {
let worktrees = client.list_worktrees().await?;
let info = client.daemon_info().await.unwrap_or_default();
if args.json {
let body = serde_json::json!({ "worktrees": worktrees });
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
return Ok(());
}
print_worktree_table(out, &worktrees, &info);
Ok(())
}
/// `maestro worktrees reclaim <id>`
///
/// Returns the entry as listed just before the reclaim, when it was found in
/// the listing, so the confirmation can name what was removed.
pub async fn run_reclaim(
client: &DaemonClient,
args: &ReclaimArgs,
) -> Result<Option<RetainedWorktree>, ClientError> {
let listed = client
.list_worktrees()
.await?
.into_iter()
.find(|w| w.id == args.id);
client.reclaim_worktree(args.id).await?;
Ok(listed)
}
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
if worktrees.is_empty() {
writeln!(out, "no retained worktrees").ok();
return;
}
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
sorted.sort_by_key(|w| w.retained_at);
if !info.hostname.is_empty() {
let warn = if maestro_protocol::is_compatible(&info.version) {
""
} else {
" (protocol mismatch)"
};
let version = if info.version.is_empty() {
String::new()
} else {
format!(" maestro {}{warn}", info.version)
};
writeln!(out, "\n{}{version}", info.hostname).ok();
}
writeln!(
out,
" {:<36} {:<24} {:<28} {:<24} IN THE WAY",
"ID", "BRANCH", "PATH", "REASON"
)
.ok();
for w in sorted {
writeln!(
out,
" {:<36} {:<24} {:<28} {:<24} {}",
w.id,
w.branch,
abbreviate_dir(&w.path, &info.home_dir),
reason_label(w.reason),
blocker_label(&w.in_the_way),
)
.ok();
}
// The branch survives every reclaim — see docs/WORKTREES.md.
writeln!(
out,
"\nreclaim removes the directory only; branches are never deleted"
)
.ok();
}
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
match reason {
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
}
}
/// The same breakdown that blocks an unforced kill, as words: what is still
/// in the way, or `clear` when the work has since been committed and pushed.
fn blocker_label(in_the_way: &InTheWay) -> String {
if in_the_way.is_clear() {
return "clear".into();
}
let mut parts: Vec<String> = Vec::new();
if in_the_way.uncommitted_files > 0 {
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
}
if in_the_way.untracked_files > 0 {
parts.push(format!("{} untracked", in_the_way.untracked_files));
}
if in_the_way.unpushed_commits > 0 {
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
}
parts.join(", ")
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
use uuid::Uuid;
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
RetainedWorktree {
id: Uuid::from_u128(id),
path: PathBuf::from(path),
base_repo: PathBuf::from("/home/naps/maestro"),
branch: branch.into(),
reason: RetainedReason::ForceKilled,
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: InTheWay::default(),
}
}
fn info(home: &str) -> DaemonInfo {
DaemonInfo {
hostname: "naps-laptop".into(),
home_dir: home.into(),
..Default::default()
}
}
#[test]
fn formats_table() {
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
w.in_the_way = InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
};
let mut buf = Vec::new();
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("naps-laptop"), "{s}");
assert!(s.contains("feat/login"), "{s}");
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
assert!(s.contains("force-killed"), "{s}");
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
assert!(s.contains("IN THE WAY"), "{s}");
}
/// Contract rule, visible to the operator: reclaiming a tree must not
/// throw away commits on its branch.
#[test]
fn says_the_branch_survives() {
let mut buf = Vec::new();
super::print_worktree_table(
&mut buf,
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
&info(""),
);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("never deleted"), "{s}");
}
#[test]
fn clear_tree_says_clear() {
let mut buf = Vec::new();
super::print_worktree_table(
&mut buf,
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
&info(""),
);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("clear"), "{s}");
}
#[test]
fn empty_store() {
let mut buf = Vec::new();
super::print_worktree_table(&mut buf, &[], &info(""));
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
}
}
+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?;
+5
View File
@@ -103,6 +103,11 @@ impl DaemonConfig {
pub fn spawn_history_path(&self) -> PathBuf {
self.state_dir.join("spawn-history.jsonl")
}
/// Retained-worktree store directory (#78): `<state_dir>/retained-worktrees/`.
pub fn retained_worktree_dir(&self) -> PathBuf {
self.state_dir.join("retained-worktrees")
}
}
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
+1
View File
@@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
ErrorInfo {
code: code.into(),
message: format!("{message}: {}", dir.display()),
unsaved_work: None,
}
}
+2
View File
@@ -19,11 +19,13 @@ pub mod panes;
pub mod proc;
pub mod projects;
pub mod pty;
pub mod retained_store;
pub mod session;
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};
+120 -4
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))
@@ -94,6 +99,8 @@ fn session_error_status(code: &str) -> StatusCode {
match code {
"not_found" => StatusCode::NOT_FOUND,
"invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT,
_ => StatusCode::BAD_GATEWAY,
}
}
@@ -168,17 +175,124 @@ async fn create_session(
/// `kill` already reports a missing session as `SessionError::NotFound`, which
/// maps to 404. Checking first would only add a window in which the liveness
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
///
/// The full error info is passed through rather than rebuilt from
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
match s.manager.kill(id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
let status = session_error_status(&info.code);
(status, Json(ApiError { error: info })).into_response()
}
}
}
/// `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).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(),
),
}
}
@@ -197,6 +311,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 +319,7 @@ pub fn state_for(
home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets),
projects,
retained,
}
}
+310
View File
@@ -0,0 +1,310 @@
//! Store of retained worktrees (#78): worktrees the daemon created whose
//! session is gone.
//!
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
//! missing or malformed files tolerated on read. The store survives a daemon
//! restart; it is the recovery path for everything else in the worktree
//! lifecycle.
//!
//! It is not an ownership record: it must never be consulted to decide whether
//! a path may be adopted. Its only job is to remember what the daemon is
//! holding so it can be listed and reclaimed.
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)]
pub enum RetainedStoreError {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RetainedWorktreeRecord {
pub id: Uuid,
pub path: PathBuf,
pub base_repo: PathBuf,
pub branch: String,
pub reason: RetainedReason,
pub retained_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct RetainedStore {
dir: PathBuf,
}
impl RetainedStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.json"))
}
/// Record a retained worktree and return the entry that was written.
pub fn retain(
&self,
path: &Path,
base_repo: &Path,
branch: &str,
reason: RetainedReason,
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
let entry = RetainedWorktreeRecord {
id: Uuid::new_v4(),
path: path.to_path_buf(),
base_repo: base_repo.to_path_buf(),
branch: branch.to_string(),
reason,
retained_at: Utc::now(),
};
self.overwrite(&entry)?;
Ok(entry)
}
/// Atomically write `entry`. Creates the directory if it does not exist.
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));
let json = serde_json::to_vec_pretty(entry)?;
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &final_path)?;
Ok(())
}
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
/// store already lost (e.g. pruned) must not fail.
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
match std::fs::remove_file(self.path_for(id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// 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<RetainedWorktreeRecord>, RetainedStoreError> {
let mut out = Vec::new();
let entries = match std::fs::read_dir(&self.dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(e) => return Err(e.into()),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
out.push(worktree);
}
}
out.sort_by_key(|w| w.retained_at);
Ok(out)
}
/// Drop entries whose worktree directory no longer exists — somebody
/// removed it by hand. Returns the ids that were pruned.
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
let mut pruned = Vec::new();
for entry in self.load_all()? {
if !entry.path.exists() {
self.remove(entry.id)?;
pruned.push(entry.id);
}
}
Ok(pruned)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use std::path::PathBuf;
fn tmp_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("tempdir")
}
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
let p = root.join(name);
std::fs::create_dir_all(&p).expect("mkdir");
p
}
#[test]
fn retain_then_load_round_trips_every_field() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
let repo = sample_path(dir.path(), "repo");
let entry = store
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
.expect("retain");
let all = store.load_all().expect("load_all");
assert_eq!(all, vec![entry.clone()]);
assert_eq!(entry.path, wt);
assert_eq!(entry.base_repo, repo);
assert_eq!(entry.branch, "feature/login");
assert_eq!(entry.reason, RetainedReason::ForceKilled);
let age = Utc::now() - entry.retained_at;
assert!(age >= Duration::zero() && age < Duration::minutes(1));
}
#[test]
fn reason_serializes_kebab_case() {
let json =
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
assert_eq!(json, r#""exited-with-unsaved-work""#);
}
#[test]
fn entries_survive_a_restart() {
let dir = tmp_dir();
let wt = sample_path(dir.path(), "wt");
let first = RetainedStore::new(dir.path());
let entry = first
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
.expect("retain");
// A fresh store over the same state_dir — what a daemon restart sees.
let second = RetainedStore::new(dir.path());
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
}
#[test]
fn missing_store_reads_as_empty() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path().join("does-not-exist"));
assert_eq!(store.load_all().expect("load_all"), Vec::new());
}
#[test]
fn malformed_entries_are_skipped_silently() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
store
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
.expect("retain");
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
let all = store.load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].branch, "kept");
}
#[test]
fn ids_are_unique() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let a = store
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
.expect("retain");
let b = store
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
.expect("retain");
assert_ne!(a.id, b.id);
}
#[test]
fn remove_drops_the_entry() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let wt = sample_path(dir.path(), "wt");
let entry = store
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
.expect("retain");
store.remove(entry.id).expect("remove");
assert_eq!(store.load_all().expect("load_all"), Vec::new());
// Removing an unknown id is a no-op, not an error.
store.remove(entry.id).expect("remove again");
}
#[test]
fn load_all_is_sorted_by_time() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let mut first = store
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
.expect("retain");
first.retained_at -= Duration::hours(1);
store.overwrite(&first).expect("rewrite");
let second = store
.retain(
dir.path(),
dir.path(),
"second",
RetainedReason::ForceKilled,
)
.expect("retain");
let branches: Vec<_> = store
.load_all()
.expect("load_all")
.into_iter()
.map(|e| e.branch)
.collect();
assert_eq!(branches, vec!["first", "second"]);
let _ = second;
}
#[test]
fn prune_missing_drops_gone_directories_only() {
let dir = tmp_dir();
let store = RetainedStore::new(dir.path());
let alive = sample_path(dir.path(), "alive");
let gone = sample_path(dir.path(), "gone");
let keep = store
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
.expect("retain");
let drop = store
.retain(
&gone,
dir.path(),
"drop",
RetainedReason::ExitedWithUnsavedWork,
)
.expect("retain");
std::fs::remove_dir_all(&gone).expect("rm");
let pruned = store.prune_missing().expect("prune_missing");
assert_eq!(pruned, vec![drop.id]);
let branches: Vec<_> = store
.load_all()
.expect("load_all")
.into_iter()
.map(|e| e.branch)
.collect();
assert_eq!(branches, vec!["keep"]);
let _ = keep;
}
}
+48 -10
View File
@@ -38,8 +38,8 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
pub struct SessionRecord {
pub session: Session,
pub fifo_path: PathBuf,
/// Worktree we created at spawn time; removed on kill unless the
/// dirty-work guard refuses (#56).
/// Worktree we created at spawn time; removed on kill, but only after the
/// kill guard (#79) found nothing worth keeping in it.
pub worktree_path: Option<PathBuf>,
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo.
@@ -61,6 +61,11 @@ pub enum SessionError {
NotFound(Uuid),
#[error("invalid environment variable name: {0:?}")]
InvalidEnv(String),
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
KillGuarded(maestro_protocol::InTheWay),
}
impl SessionError {
@@ -72,10 +77,16 @@ impl SessionError {
SessionError::Io(_) => "io_failed",
SessionError::NotFound(_) => "not_found",
SessionError::InvalidEnv(_) => "invalid_env",
SessionError::KillGuarded(_) => "kill_guarded",
};
let unsaved_work = match self {
SessionError::KillGuarded(b) => Some(*b),
_ => None,
};
ErrorInfo {
code: code.into(),
message: self.to_string(),
unsaved_work,
}
}
}
@@ -370,20 +381,48 @@ impl SessionManager {
}
}
/// Kill a session and remove its on-disk artifacts. The worktree is
/// always pruned -- unless it holds uncommitted changes or commits not on
/// any other ref, in which case the guard leaves it on disk (#56).
/// Kill a session and remove its on-disk artifacts.
///
/// Refused (#79) when the session's worktree holds unsaved work —
/// uncommitted tracked changes, untracked non-ignored files, or commits
/// reachable from no remote-tracking ref. Nothing is killed and nothing
/// is removed; the error names what blocked it and how much of it there
/// is. The session stays as the operator's handle on their own unfinished
/// work, so ending a session that still matters becomes a deliberate
/// `--force`, which retains rather than destroys (WORKTREES.md).
///
/// This is also how an already-dead session is dismissed. tmux is only
/// asked to kill something it still has — calling `kill-session` on a
/// session tmux already lost fails, and that failure is not interesting.
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
let (worktree, alive) = {
let sessions = self.inner.sessions.lock().await;
match sessions.get(&id) {
None => return Err(SessionError::NotFound(id)),
Some(record) => (
record
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone()),
record.session.status == SessionStatus::Running,
),
}
};
// The guard runs before anything is torn down: refusing must leave the
// session running, exactly as the operator left it.
if let Some((_, path)) = worktree {
let probe = tmux_blocking(move || crate::worktree_probe::in_the_way(&path))
.await
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))?;
if !probe.is_clear() {
return Err(SessionError::KillGuarded(probe));
}
}
let removed = {
let mut sessions = self.inner.sessions.lock().await;
sessions.remove(&id)
};
let record = removed.ok_or(SessionError::NotFound(id))?;
let alive = record.session.status == SessionStatus::Running;
cleanup_record(&self.inner, id, record, alive).await;
Ok(())
}
@@ -720,10 +759,9 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
// user-facing choice. The manager's guard is what protects unsaved work --
// when it refuses, leave the tree on disk and say where it is so the
// operator can find it.
// The kill already passed the unsaved-work guard (#79), so this removal
// is unconditional; the manager's own nested-worktree refusal is all that
// can still leave a tree on disk, and it says where.
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if let Err(e) = inner.worktree_mgr.remove(base, path) {
tracing::warn!(
+21 -47
View File
@@ -10,11 +10,24 @@ pub enum WorktreeError {
Spawn(#[from] std::io::Error),
#[error("git exited with code {code}: {stderr}")]
NonZero { code: i32, stderr: String },
/// The worktree holds unsaved work; removal was refused (#56).
/// A registered worktree lives under the tree about to be removed; git
/// hides its contents from `git status`, so removal would delete its
/// uncommitted work unseen (#54 review).
#[error("left {} on disk: {}", .path.display(), .reason)]
Guarded { path: PathBuf, reason: String },
}
impl From<crate::worktree_probe::ProbeError> for WorktreeError {
fn from(e: crate::worktree_probe::ProbeError) -> Self {
match e {
crate::worktree_probe::ProbeError::Spawn(io) => WorktreeError::Spawn(io),
crate::worktree_probe::ProbeError::NonZero { code, stderr } => {
WorktreeError::NonZero { code, stderr }
}
}
}
}
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
/// are findable from the repo; an operator-set root overrides that.
@@ -126,12 +139,13 @@ impl WorktreeManager {
Ok(path)
}
/// `git -C <base_repo> worktree remove <path>` — but only when the
/// worktree holds nothing unique (#56). A worktree with uncommitted
/// changes, or with commits reachable from no other ref, is refused and
/// left on disk so the operator can rescue it; cleanup is unconditional,
/// which makes this guard the only thing standing between a stray kill
/// and destroyed work.
/// `git -C <base_repo> worktree remove <path>`.
///
/// Unconditional (#79): the guard moved off the worktree and onto the
/// kill, so by the time removal runs the caller has already chosen to
/// destroy the tree — the kill only gets here after
/// [`crate::worktree_probe::in_the_way`] came back clear. Still refused for a tree holding a
/// nested registered worktree, which `git status` cannot see.
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
// Gone from disk already (operator `rm -rf`, or a create that failed
// half-way). Probing it with git would fail and be misread as unsaved
@@ -147,12 +161,6 @@ impl WorktreeManager {
reason: format!("holds a nested worktree at {}", inner.display()),
});
}
if let Some(reason) = unsafe_to_remove(path)? {
return Err(WorktreeError::Guarded {
path: path.to_path_buf(),
reason,
});
}
let out = Command::new("git")
.arg("-C")
.arg(base_repo)
@@ -251,24 +259,6 @@ fn branch_relpath(branch: &str) -> Option<PathBuf> {
Some(candidate.to_path_buf())
}
/// `Some(reason)` when deleting `path` would destroy work.
///
/// Only an unclean working tree qualifies. `git worktree remove` deletes the
/// checkout but keeps `refs/heads/<branch>`, so committed work — pushed or
/// not — stays reachable and is never at risk. Guarding on "commits on no
/// other ref" instead would refuse every ordinary unpushed feature branch,
/// and because the path is deterministic that branch could then never be
/// spawned again (#56 review).
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
// `--ignored=no` keeps build output out of it; untracked sources count,
// since removal would delete them.
let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?;
if !status.stdout.is_empty() {
return Ok(Some("has uncommitted changes".into()));
}
Ok(None)
}
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
/// no longer exists.
fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
@@ -280,22 +270,6 @@ fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
check(out)
}
fn run_git(
dir: &Path,
args: impl IntoIterator<Item = &'static str>,
) -> Result<Output, WorktreeError> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).args(args);
let out = cmd.output()?;
if !out.status.success() {
return Err(WorktreeError::NonZero {
code: out.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
});
}
Ok(out)
}
fn check(out: Output) -> Result<(), WorktreeError> {
if out.status.success() {
Ok(())
+317
View File
@@ -0,0 +1,317 @@
//! The one git probe for what a worktree holds that would be lost, plus
//! guard-free reclaim (#79, #80).
//!
//! [`in_the_way`] is the single predicate behind all three paths: the kill
//! refusal (#79), the natural-exit classifier (#82), and the retained list
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
//! answer the question differently — #79 and #80 were built in parallel and
//! each grew its own copy, which is exactly the drift this collapses.
//!
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
//! the operator chose it while looking at what was in the way.
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) -> Result<InTheWay, ProbeError> {
let mut breakdown = InTheWay::default();
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
// our purposes, and following it would probe the wrong tree.
if std::fs::symlink_metadata(path).is_err() {
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;
}
}
// Resolved inside the worktree so a detached HEAD still has a starting
// point; asking the base repo for `refs/heads/<branch>` fails there.
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
// Commits reachable from the tip 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(path, ["rev-list", "--count", tip, "--not", "--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()).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()).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()).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()).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()).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()).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()).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()).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).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
+41
View File
@@ -93,6 +93,38 @@ fn run(cmd: &mut Command) {
);
}
/// An empty bare repository at `dir`, usable as a push target (#79).
pub fn init_bare_repo(dir: &Path) {
run(Command::new("git")
.arg("init")
.arg("-q")
.arg("--bare")
.arg(dir.to_str().expect("non-utf8 bare repo path")));
}
/// Push every branch of `repo` to `origin`, creating remote-tracking refs.
///
/// The unsaved-work probe (#79) counts commits reachable from no
/// remote-tracking ref as unpushed, so tests that want a killable worktree
/// must give its branch one. Safe to call again: re-adding an existing
/// remote is ignored.
pub fn push_all_to_origin(repo: &Path, origin: &Path) {
let _ = Command::new("git")
.arg("-C")
.arg(repo)
.args([
"remote",
"add",
"origin",
origin.to_str().expect("non-utf8 origin path"),
])
.status();
run(Command::new("git")
.arg("-C")
.arg(repo)
.args(["push", "-q", "--all", "origin"]));
}
/// Async polling sibling of [`wait_for`] for tokio tests.
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
where
@@ -163,6 +195,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")
}
+280 -1
View File
@@ -9,7 +9,7 @@ use std::time::Duration;
use common::DaemonFixture;
use futures_util::{SinkExt, StreamExt};
use maestro_daemon::local_api;
use maestro_protocol::{CreateSessionRequest, Session};
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -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
@@ -338,6 +339,53 @@ async fn delete_owned_session_kills_it() {
assert!(!h.manager.has(session.id).await);
}
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
/// with the structured breakdown, and the session keeps running.
#[tokio::test]
async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() {
let h = spawn("naps-laptop").await;
let repo = tempfile::TempDir::new().unwrap();
common::init_repo(repo.path());
let session = h
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/guarded".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.unwrap();
std::fs::write(
repo.path()
.join("worktrees")
.join("feat")
.join("guarded")
.join("README"),
b"unsaved edits\n",
)
.unwrap();
let resp = reqwest::Client::new()
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
let body: ApiError = resp.json().await.unwrap();
assert_eq!(body.error.code, "kill_guarded");
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.uncommitted_files, 1);
assert!(h.manager.has(session.id).await, "the session keeps running");
}
#[tokio::test]
async fn attach_ws_streams_pty_bytes() {
let h = spawn("naps-laptop").await;
@@ -492,3 +540,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"
);
}
+145 -10
View File
@@ -4,10 +4,12 @@ mod common;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use common::{init_repo, wait_for_async, DaemonFixture};
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_daemon::SessionError;
use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
use tempfile::TempDir;
@@ -363,8 +365,9 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
}
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
/// loses its worktree on kill — the guard, not the flag, protects work.
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
/// that says "don't clean up" loses its worktree — the branch was pushed, so
/// nothing in the tree is unique and removal goes ahead.
#[tokio::test]
async fn kill_removes_worktree_even_when_spec_says_keep() {
let fx = DaemonFixture::new();
@@ -390,14 +393,21 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
.expect("create");
assert!(wt_path.exists());
// Push the branch so its commits are reachable from a remote-tracking ref.
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.kill(s.id).await.expect("kill");
assert!(!wt_path.exists(), "worktree must be removed on kill");
}
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
/// left on disk where the operator can find it.
/// The kill guard (#79): a worktree holding uncommitted changes refuses the
/// kill outright. Nothing happens — the session keeps running as the
/// operator's handle on their unfinished work, and the tree is untouched.
/// The refusal names what blocked it and how much of it there is.
#[tokio::test]
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
@@ -420,13 +430,138 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
fx.manager.kill(s.id).await.expect("kill");
let err = fx.manager.kill(s.id).await.expect_err("kill must refuse");
let SessionError::KillGuarded(breakdown) = err else {
panic!("expected KillGuarded, got {err:?}");
};
assert_eq!(breakdown.uncommitted_files, 1);
assert_eq!(breakdown.untracked_files, 1);
assert!(
wt_path.exists(),
"dirty worktree must be left on disk, not destroyed"
breakdown.unpushed_commits >= 1,
"a fresh branch has no remote-tracking ref"
);
let message = breakdown.to_string();
assert!(message.contains("1 uncommitted file"), "{message}");
assert!(message.contains("1 untracked file"), "{message}");
// Nothing happened: the session survives and so does the tree.
assert!(fx.manager.has(s.id).await, "the session must keep running");
assert!(wt_path.exists(), "the worktree must be untouched");
// A second kill after the work is saved goes through.
run_git_in(&wt_path, &["add", "."]);
run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]);
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.kill(s.id).await.expect("kill after saving");
assert!(!fx.manager.has(s.id).await);
}
/// Unpushed commits alone block the kill (#79): a feature branch almost
/// always has them, which is why `--force` (which retains) is the ordinary
/// way to end such a session.
#[tokio::test]
async fn killing_refuses_until_the_branch_is_pushed() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("pushed");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/pushed".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
let err = fx.manager.kill(s.id).await.expect_err("unpushed blocks");
let SessionError::KillGuarded(breakdown) = err else {
panic!("expected KillGuarded, got {err:?}");
};
assert_eq!(breakdown.uncommitted_files, 0);
assert_eq!(breakdown.untracked_files, 0);
assert!(breakdown.unpushed_commits >= 1);
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path());
fx.manager.kill(s.id).await.expect("pushed branch kills");
assert!(!wt_path.exists());
assert!(!fx.manager.has(s.id).await);
}
/// Ignored files NEVER block (#79): build output like `target/` exists in
/// every Rust checkout, and an ignored-only worktree stays killable.
#[tokio::test]
async fn killing_an_ignored_only_worktree_succeeds() {
let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
// .gitignore must be committed before the worktree branches off, or it
// would itself read as an untracked file.
std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap();
run_git_in(repo.path(), &["add", ".gitignore"]);
run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]);
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
let wt_path = repo.path().join("worktrees").join("feat").join("rusty");
let s = fx
.manager
.create(CreateSessionRequest {
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: "feat/rusty".into(),
cleanup_on_kill: true,
create_branch: true,
}),
})
.await
.expect("create");
std::fs::create_dir_all(wt_path.join("target")).unwrap();
std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap();
// Branch pushed; the only dirt is ignored output.
push_all_to_origin(repo.path(), origin.path());
fx.manager
.kill(s.id)
.await
.expect("ignored files never block");
assert!(!wt_path.exists());
assert!(!fx.manager.has(s.id).await);
}
fn run_git_in(dir: &std::path::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)
);
assert!(fx.manager.snapshot().await.is_empty());
}
#[tokio::test]
+128 -7
View File
@@ -6,8 +6,9 @@ mod common;
use std::path::PathBuf;
use std::process::Command;
use common::init_repo;
use common::{init_bare_repo, init_repo, push_all_to_origin};
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::worktree_probe::in_the_way;
use tempfile::TempDir;
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
@@ -134,6 +135,123 @@ fn add_twice_generates_distinct_paths() {
assert!(p2.exists());
}
/// The unsaved-work probe (#79) counts uncommitted tracked changes.
#[test]
fn the_probe_counts_uncommitted_tracked_changes() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1);
assert_eq!(w.untracked_files, 0);
}
/// Untracked sources count; a staged new file is also unsaved tracked work.
#[test]
fn the_probe_counts_untracked_files() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/untracked").expect("add");
std::fs::write(path.join("notes.txt"), b"wip\n").unwrap();
std::fs::write(path.join("more.txt"), b"also wip\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.untracked_files, 2);
assert_eq!(w.uncommitted_files, 0);
run_git(&path, &["add", "notes.txt"]);
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1, "staged counts as uncommitted");
assert_eq!(w.untracked_files, 1);
}
/// Ignored files NEVER block: every Rust checkout has a `target/`, and
/// counting ignored output would leave no session killable (#79).
#[test]
fn the_probe_ignores_ignored_files() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
// .gitignore must be committed, or it would itself read as untracked.
std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap();
run_git(&repo, &["add", ".gitignore"]);
run_git(&repo, &["commit", "-q", "-m", "ignore target"]);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/builds").expect("add");
std::fs::create_dir_all(path.join("target")).unwrap();
std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 0);
assert_eq!(w.untracked_files, 0);
// With the branch pushed too, an ignored-only tree reads fully clean.
let origin_dir = TempDir::new().unwrap();
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = in_the_way(&path).expect("probe after push");
assert!(w.is_clear(), "ignored-only worktree must read clean: {w:?}");
}
/// Commits reachable from no remote-tracking ref block; pushing the branch
/// clears them. With no remote at all every commit is unreachable from one,
/// which is exactly why feature branches almost always refuse (#79).
#[test]
fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/branch").expect("add");
let w = in_the_way(&path).expect("probe");
assert!(
w.unpushed_commits >= 1,
"a fresh branch has no remote-tracking ref: {w:?}"
);
let origin_dir = TempDir::new().unwrap();
init_bare_repo(origin_dir.path());
push_all_to_origin(&repo, origin_dir.path());
let w = in_the_way(&path).expect("probe after push");
assert_eq!(w.unpushed_commits, 0, "{w:?}");
assert!(w.is_clear(), "pushed and clean must be empty: {w:?}");
}
/// The full breakdown names each blocker with its count.
#[test]
fn the_probe_reports_every_blocker_at_once() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
let mgr = WorktreeManager::new(None);
let path = mgr.add_new(&repo, "feat/mixed").expect("add");
std::fs::write(path.join("README"), b"edited\n").unwrap();
std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap();
let w = in_the_way(&path).expect("probe");
assert_eq!(w.uncommitted_files, 1);
assert_eq!(w.untracked_files, 1);
assert!(w.unpushed_commits >= 1);
let summary = w.to_string();
assert!(summary.contains("1 uncommitted file"), "{summary}");
assert!(summary.contains("1 untracked file"), "{summary}");
assert!(summary.contains("unpushed commit"), "{summary}");
}
#[test]
fn remove_prunes_worktree() {
let repo_dir = TempDir::new().unwrap();
@@ -211,8 +329,14 @@ fn add_existing_checks_out_branch_without_creating_it() {
);
}
/// Removal is guarded upstream now (#79): the kill refuses unsaved work
/// before anything is torn down, so by the time `remove` runs the tree is
/// clean. Git still refuses a dirty tree on its own — this asserts that last
/// line of defense — and committed-but-unpushed work does not block removal,
/// because `worktree remove` keeps `refs/heads/<branch>` and the commits stay
/// reachable.
#[test]
fn remove_refuses_worktree_with_uncommitted_changes() {
fn remove_still_relies_on_git_refusing_a_dirty_tree() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
@@ -225,10 +349,6 @@ fn remove_refuses_worktree_with_uncommitted_changes() {
.expect_err("dirty worktree must be refused");
assert!(path.exists(), "guarded worktree must stay on disk");
// Committing it makes removal safe: `worktree remove` keeps
// `refs/heads/feat/dirty`, so the work is still reachable. Refusing here
// instead would make every unpushed branch a one-shot, since the path is
// deterministic and the leftover tree blocks the next spawn (#56 review).
run_git(&path, &["add", "."]);
run_git(&path, &["commit", "-q", "-m", "saved"]);
@@ -410,7 +530,8 @@ fn a_guarded_leftover_is_never_adopted() {
let first = mgr.add_new(&repo, "feat/resume").expect("add");
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
// The guard refuses, leaving it on disk.
// Removal is refused — git itself refuses a dirty tree — leaving it on
// disk; the daemon-side guard lives on the kill (#79).
mgr.remove(&repo, &first)
.expect_err("dirty tree is guarded");
+45 -13
View File
@@ -160,6 +160,7 @@ async fn kill_removes_clean_worktree() {
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
// Give the daemon a moment to remove the worktree, then verify.
@@ -177,11 +178,12 @@ async fn kill_removes_clean_worktree() {
);
}
/// The guard (#56): killing a session whose worktree holds uncommitted
/// changes must leave the tree on disk — cleanup is unconditional, so this
/// guard is what keeps a stray kill from destroying work.
/// The kill guard (#79): killing a session whose worktree holds uncommitted
/// changes is refused outright. Nothing happens — the session keeps running
/// as the operator's handle on their unfinished work, the tree stays on disk,
/// and the refusal names what blocked it and how much.
#[tokio::test(flavor = "multi_thread")]
async fn kill_spares_worktree_with_uncommitted_changes() {
async fn kill_refuses_worktree_with_uncommitted_changes() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
@@ -209,17 +211,25 @@ async fn kill_spares_worktree_with_uncommitted_changes() {
assert!(wt_path.exists());
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
client.kill_session(s.id).await.expect("kill");
// The session is gone but the dirty worktree stays findable on disk.
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let sessions = client.list_sessions().await.unwrap_or_default();
if sessions.is_empty() {
break;
let err = client
.kill_session(s.id)
.await
.expect_err("kill must refuse");
match err {
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
assert_eq!(info.code, "kill_guarded", "{info:?}");
let breakdown = info.unsaved_work.expect("breakdown on the wire");
assert_eq!(breakdown.uncommitted_files, 1);
}
tokio::time::sleep(Duration::from_millis(100)).await;
other => panic!("expected a kill_guarded refusal, got {other:?}"),
}
// The session survives and the dirty worktree stays findable on disk.
let sessions = client.list_sessions().await.unwrap_or_default();
assert!(
sessions.iter().any(|x| x.id == s.id),
"a refused kill must keep the session alive"
);
assert!(
wt_path.exists(),
"guarded worktree must be left on disk after kill"
@@ -270,6 +280,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
);
push_to_throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
@@ -317,6 +328,7 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
)
.await;
push_to_throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
@@ -364,6 +376,7 @@ async fn second_spawn_on_same_branch_is_rejected() {
"original worktree must be untouched"
);
push_to_throwaway_origin(base.path());
client.kill_session(first.id).await.expect("kill");
}
@@ -406,6 +419,7 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
"worktrees must never nest inside another worktree"
);
push_to_throwaway_origin(base.path());
client.kill_session(s.id).await.expect("kill");
}
@@ -774,6 +788,24 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
.collect()
}
/// Push every branch of `repo` to a throwaway bare remote, so its commits are
/// reachable from remote-tracking refs. Without one the kill guard (#79)
/// reads every commit as unpushed, and any worktree session refuses to die.
fn push_to_throwaway_origin(repo: &std::path::Path) {
let origin = tempfile::TempDir::new().unwrap();
run_git(
&["init", "-q", "--bare", origin.path().to_str().unwrap()],
repo,
);
let _ = Command::new("git")
.arg("-C")
.arg(repo)
.args(["remote", "add", "origin", origin.path().to_str().unwrap()])
.output()
.expect("git remote add");
run_git(&["push", "-q", "--all", "origin"], repo);
}
#[tokio::test(flavor = "multi_thread")]
async fn daemon_events_ws_streams_snapshot_then_session_added() {
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
+18 -1
View File
@@ -16,7 +16,7 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
Project, RecentDirs, Session,
Project, RecentDirs, RetainedWorktree, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -148,6 +148,23 @@ impl DaemonClient {
.await
}
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
/// breakdown as of this read.
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
self.get(routes::WORKTREES, &[]).await
}
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
/// daemon removes the directory and drops the store entry; the branch is
/// never deleted.
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
let resp = self
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
.await?;
check(resp).await?;
Ok(())
}
// -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>(
+141
View File
@@ -143,6 +143,87 @@ 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,
}
impl std::fmt::Display for InTheWay {
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
/// all-zero renders "no unsaved work". This is the phrasing every client
/// uses for both the kill refusal and the retained list, so the two
/// cannot describe the same tree differently.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let parts = [
(self.uncommitted_files, "uncommitted file"),
(self.untracked_files, "untracked file"),
(self.unpushed_commits, "unpushed commit"),
];
let said: Vec<String> = parts
.into_iter()
.filter(|(n, _)| *n > 0)
.map(|(n, unit)| {
if n == 1 {
format!("1 {unit}")
} else {
format!("{n} {unit}s")
}
})
.collect();
if said.is_empty() {
return write!(f, "no unsaved work");
}
write!(f, "{}", said.join(", "))
}
}
/// HTTP `POST /api/sessions` body.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateSessionRequest {
@@ -248,6 +329,10 @@ pub struct BranchInfo {
pub struct ErrorInfo {
pub code: String,
pub message: String,
/// Set only by the kill guard (#79): the unsaved work that blocked
/// `DELETE /api/sessions/:id`, so a client can render counts directly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unsaved_work: Option<InTheWay>,
}
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
@@ -268,6 +353,7 @@ impl ApiError {
error: ErrorInfo {
code: code.into(),
message: message.into(),
unsaved_work: None,
},
}
}
@@ -539,6 +625,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();
@@ -578,6 +718,7 @@ mod tests {
ClientControl::Error(ErrorInfo {
code: "bad_frame".into(),
message: "decode failed".into(),
unsaved_work: None,
}),
resize,
] {
+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}");
+3
View File
@@ -43,6 +43,8 @@ pub enum Action {
/// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn,
KillHighlighted,
/// Open the retained-worktrees view (#85).
ShowWorktrees,
// Tabs
NewTab,
@@ -84,6 +86,7 @@ impl Action {
"NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
"NextTab" => return Ok(Action::NextTab),
+266 -4
View File
@@ -18,7 +18,9 @@ use std::time::Duration;
use chrono::Utc;
use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt;
use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
use maestro_protocol::{
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use uuid::Uuid;
@@ -37,6 +39,7 @@ use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
use crate::tabs::TabBar;
use crate::worktrees::WorktreesView;
use crate::TuiError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -55,7 +58,8 @@ pub struct App {
help: HelpOverlay,
chord: ChordState,
daemon: DaemonClient,
/// Results of draft data loads, delivered back to the event loop.
/// Results of draft data loads (and the worktrees view fetch, #85),
/// delivered back to the event loop.
///
/// These are network calls — `/api/projects` on a cold cache walks all of
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
@@ -66,6 +70,9 @@ pub struct App {
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
/// `GET /api/names` (#64).
http: reqwest::Client,
/// Entries of the retained-worktrees view (#85). Daemon data, kept here
/// rather than in `AppState` so the overlay kind stays payload-free.
worktrees: WorktreesView,
region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker,
@@ -109,6 +116,11 @@ pub enum Effect {
},
/// Fetch the draft target's branches from `/api/branches` (#65).
LoadBranches(usize),
/// Fetch `/api/worktrees` for the retained-worktrees view (#85).
LoadWorktrees,
/// Reclaim one retained worktree (#85) — removes its directory; the
/// branch is never deleted.
ReclaimWorktree(Uuid),
}
impl App {
@@ -132,6 +144,7 @@ impl App {
chord: ChordState::new(chord_timeout),
daemon,
http,
worktrees: WorktreesView::default(),
draft_tx,
draft_rx,
region: RegionMap::default(),
@@ -225,6 +238,8 @@ impl App {
self.spawn_probe_git_root(tab, seq, path)
}
Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
Effect::LoadWorktrees => self.spawn_load_worktrees(),
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
@@ -247,6 +262,44 @@ impl App {
}
_ => (OverlayDecision::Consumed, Vec::new()),
},
Some(OverlayKind::Worktrees) => match key {
InputKey::Char { c: 'j', .. } | InputKey::Down => {
self.worktrees.move_highlight(1);
(OverlayDecision::Consumed, Vec::new())
}
InputKey::Char { c: 'k', .. } | InputKey::Up => {
self.worktrees.move_highlight(-1);
(OverlayDecision::Consumed, Vec::new())
}
InputKey::Char { c: 'r', .. } => {
if let Some(wt) = self.worktrees.selected() {
self.state.show_overlay(OverlayKind::ReclaimConfirm {
id: wt.id,
branch: wt.branch.clone(),
// Shared phrasing: InTheWay's Display impl is what
// the kill refusal and the web client use too.
blockers: wt.in_the_way.to_string(),
});
}
(OverlayDecision::Consumed, Vec::new())
}
InputKey::Char { c: 'q', .. } | InputKey::Esc => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
}
_ => (OverlayDecision::Consumed, Vec::new()),
},
Some(OverlayKind::ReclaimConfirm { id, .. }) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, vec![Effect::ReclaimWorktree(id)])
}
InputKey::Char { c: 'n', .. } | InputKey::Esc => {
self.state.show_overlay(OverlayKind::Worktrees);
(OverlayDecision::Consumed, Vec::new())
}
_ => (OverlayDecision::Consumed, Vec::new()),
},
Some(OverlayKind::Help) => {
self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new())
@@ -317,6 +370,10 @@ impl App {
self.state.show_overlay(OverlayKind::KillConfirm(sid));
}
}
Action::ShowWorktrees => {
self.state.show_overlay(OverlayKind::Worktrees);
effects.push(Effect::LoadWorktrees);
}
}
effects
}
@@ -505,13 +562,22 @@ impl App {
self.fast_forward_spawn(info, projects, names).await;
return;
}
if let DraftUpdate::WorktreesLoaded {
worktrees,
home_dir,
} = update
{
self.worktrees.apply(worktrees);
self.worktrees.home_dir = home_dir;
return;
}
let want = match &update {
DraftUpdate::Data { draft, .. }
| DraftUpdate::Names { draft, .. }
| DraftUpdate::GitRoot { draft, .. }
| DraftUpdate::Branches { draft, .. } => *draft,
// Handled above; it targets no particular draft.
DraftUpdate::FastForward { .. } => unreachable!(),
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
};
// Find it by identity: closing a tab shifts every index to its right,
// so an in-flight load addressed by index would be dropped or land on
@@ -566,7 +632,7 @@ impl App {
DraftUpdate::Branches { cwd, branches, .. } => {
draft.set_branches(&cwd, branches);
}
DraftUpdate::FastForward { .. } => unreachable!(),
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(),
}
if follow_up_branches {
self.spawn_load_branches(tab_idx);
@@ -673,6 +739,38 @@ impl App {
}
}
/// Fetch the retained-worktree store without blocking the loop (#85) —
/// same off-loop pattern as the draft loaders.
fn spawn_load_worktrees(&self) {
let (daemon, _, _, tx) = self.loader_handles();
tokio::spawn(async move {
let (worktrees, info) = tokio::join!(daemon.list_worktrees(), daemon.daemon_info());
let _ = tx.send(DraftUpdate::WorktreesLoaded {
worktrees: worktrees.unwrap_or_default(),
home_dir: info.map(|i| i.home_dir).unwrap_or_default(),
});
});
}
/// Reclaim one retained worktree (#85): the directory goes, the branch
/// never does. The open view drops the entry so the list tells the truth
/// without waiting for a re-fetch.
async fn submit_reclaim(&mut self, id: Uuid) {
match self.daemon.reclaim_worktree(id).await {
Ok(()) => {
self.worktrees.remove(id);
self.state.toast("worktree reclaimed; branch kept");
}
Err(e) => {
let message = e
.daemon_message()
.map(String::from)
.unwrap_or_else(|| e.to_string());
self.state.toast(format!("reclaim failed: {message}"));
}
}
}
fn redraw(
&mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
@@ -713,6 +811,7 @@ impl App {
state: &self.state,
draft,
help: &self.help,
worktrees: Some(&self.worktrees),
};
let mut new_region = RegionMap::default();
@@ -857,6 +956,11 @@ enum DraftUpdate {
projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>,
},
/// The retained-worktree store arrived for the worktrees view (#85).
WorktreesLoaded {
worktrees: Vec<RetainedWorktree>,
home_dir: String,
},
}
/// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
@@ -1509,4 +1613,162 @@ mod tests {
app.dispatch(Action::KillHighlighted);
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid));
}
// -- retained worktrees (#85) -------------------------------------------
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
use chrono::TimeZone;
maestro_protocol::RetainedWorktree {
id: Uuid::from_u128(id),
path: "/home/naps/tea/maestro/worktrees/feat/login".into(),
base_repo: "/home/naps/tea/maestro".into(),
branch: "feat/login".into(),
reason: maestro_protocol::RetainedReason::ForceKilled,
retained_at: chrono::Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: maestro_protocol::InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
},
}
}
fn app_with_one_retained_worktree() -> App {
let mut app = make_app();
app.worktrees.home_dir = "/home/naps".into();
app.worktrees.apply(vec![sample_worktree(42)]);
app.state.show_overlay(OverlayKind::Worktrees);
app
}
#[test]
fn show_worktrees_opens_the_view_and_fetches() {
let mut app = make_app();
assert_eq!(
app.dispatch(Action::ShowWorktrees),
vec![Effect::LoadWorktrees]
);
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
}
#[test]
fn r_over_the_highlighted_worktree_asks_for_a_confirmation() {
let mut app = app_with_one_retained_worktree();
let (decision, effects) = app.handle_overlay_input(&InputKey::char('r'));
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
match app.state.overlay() {
Some(OverlayKind::ReclaimConfirm { id, branch, .. }) => {
assert_eq!(*id, Uuid::from_u128(42));
assert_eq!(branch, "feat/login");
}
other => panic!("expected a reclaim confirm, got {other:?}"),
}
}
/// The confirmation names what is in the way using the shared phrasing,
/// so the confirm step cannot drift from the list or the CLI.
#[test]
fn the_reclaim_confirmation_carries_the_shared_in_the_way_phrasing() {
let mut app = app_with_one_retained_worktree();
app.handle_overlay_input(&InputKey::char('r'));
match app.state.overlay() {
Some(OverlayKind::ReclaimConfirm { blockers, .. }) => {
assert_eq!(
blockers,
"2 uncommitted files, 1 untracked file, 3 unpushed commits"
);
}
other => panic!("expected a reclaim confirm, got {other:?}"),
}
}
#[test]
fn confirming_a_reclaim_asks_for_the_reclaim() {
let mut app = app_with_one_retained_worktree();
app.handle_overlay_input(&InputKey::char('r'));
let (decision, effects) = app.handle_overlay_input(&InputKey::char('y'));
assert_eq!(decision, OverlayDecision::Consumed);
assert_eq!(effects, vec![Effect::ReclaimWorktree(Uuid::from_u128(42))]);
assert!(app.state.overlay().is_none());
}
#[test]
fn declining_a_reclaim_returns_to_the_list() {
let mut app = app_with_one_retained_worktree();
app.handle_overlay_input(&InputKey::char('r'));
let (decision, effects) = app.handle_overlay_input(&InputKey::char('n'));
assert_eq!(decision, OverlayDecision::Consumed);
assert!(effects.is_empty());
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
}
#[test]
fn j_and_k_move_the_highlight_inside_the_view() {
let mut app = make_app();
app.worktrees
.apply(vec![sample_worktree(1), sample_worktree(2)]);
// Distinct ids so movement is observable.
app.worktrees.items[1].branch = "other".into();
app.state.show_overlay(OverlayKind::Worktrees);
app.handle_overlay_input(&InputKey::char('j'));
assert_eq!(app.worktrees.selected().unwrap().branch, "other");
app.handle_overlay_input(&InputKey::char('k'));
assert_eq!(app.worktrees.selected().unwrap().branch, "feat/login");
}
#[tokio::test]
async fn loading_worktrees_fills_the_view_from_the_daemon() {
use axum::routing::{delete, get};
use axum::Json;
use serde_json::json;
async fn info() -> Json<serde_json::Value> {
Json(json!({"hostname": "box", "version": "0.1.0", "home_dir": "/home/naps"}))
}
async fn worktrees() -> Json<serde_json::Value> {
Json(json!([{
"id": Uuid::from_u128(42).to_string(),
"path": "/home/naps/tea/maestro/worktrees/feat/login",
"base_repo": "/home/naps/tea/maestro",
"branch": "feat/login",
"reason": "force-killed",
"retained_at": "2026-08-24T10:00:00Z",
"in_the_way": {"uncommitted_files": 2, "untracked_files": 1, "unpushed_commits": 3},
}]))
}
async fn reclaim() -> Json<serde_json::Value> {
Json(json!(null))
}
let app_router = axum::Router::new()
.route(maestro_protocol::routes::INFO, get(info))
.route(maestro_protocol::routes::WORKTREES, get(worktrees))
.route("/api/worktrees/:id", delete(reclaim));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app_router).await.unwrap() });
let mut app = make_app_at(&base);
let effects = app.dispatch(Action::ShowWorktrees);
app.perform(effects).await;
let update = tokio::time::timeout(Duration::from_secs(5), app.draft_rx.recv())
.await
.expect("load completed")
.expect("channel open");
app.apply_draft_update(update).await;
assert_eq!(app.state.overlay(), Some(&OverlayKind::Worktrees));
assert_eq!(app.worktrees.items.len(), 1);
assert_eq!(app.worktrees.items[0].branch, "feat/login");
assert_eq!(app.worktrees.home_dir, "/home/naps");
// Reclaiming drops the entry from the open view.
app.submit_reclaim(Uuid::from_u128(42)).await;
assert!(app.worktrees.items.is_empty());
}
}
+5
View File
@@ -50,6 +50,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
// thing with every default resolved and no form.
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
("w", "ShowWorktrees"),
// Tabs
("ctrl-t", "NewTab"),
("g t", "NextTab"),
@@ -166,6 +167,10 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "S"),
Some(Action::FastForwardSpawn),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "w"),
Some(Action::ShowWorktrees),
);
}
#[test]
+1 -1
View File
@@ -1186,7 +1186,7 @@ fn draw_text_field(
/// `~` abbreviation plus leading-segment collapsing, matching the web UI's
/// `abbreviateCwd` + `abbreviateFolderName`: `/home/u/tea/Maestro` renders as
/// `/h/u/t/Maestro` given home `/home/u`.
fn abbreviate_path(path: &Path, home: &str) -> String {
pub(crate) fn abbreviate_path(path: &Path, home: &str) -> String {
let raw = path.to_string_lossy();
let abbreviated = if !home.is_empty() {
if raw == home {
+1
View File
@@ -22,6 +22,7 @@ pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
pub mod worktrees;
use std::path::PathBuf;
use std::time::Duration;
+1 -1
View File
@@ -82,7 +82,7 @@ impl Toast {
}
}
fn centered(parent: Rect, w: u16, h: u16) -> Rect {
pub(crate) fn centered(parent: Rect, w: u16, h: u16) -> Rect {
let w = w.min(parent.width);
let h = h.min(parent.height);
Rect {
+20
View File
@@ -15,6 +15,7 @@ use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::worktrees::{ReclaimConfirm, WorktreesView};
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
/// coordinate to one of these via [`RegionMap::region_at`].
@@ -67,6 +68,8 @@ pub struct RenderInputs<'a> {
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub help: &'a HelpOverlay,
/// Retained-worktree entries for the worktrees view (#85).
pub worktrees: Option<&'a WorktreesView>,
}
/// Draw one frame. Returns the `RegionMap` the input router will consult for
@@ -153,6 +156,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
inputs.help.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Worktrees) => {
if let Some(view) = inputs.worktrees {
view.render(f, area);
map.modal = Some(area);
}
}
Some(OverlayKind::ReclaimConfirm {
branch, blockers, ..
}) => {
let rc = ReclaimConfirm {
branch: branch.clone(),
blockers: blockers.clone(),
};
rc.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Toast(text)) => {
let t = Toast { text: text.clone() };
t.render(f, area);
@@ -298,6 +317,7 @@ mod tests {
draft: Some(draft),
state: &state,
help: &help,
worktrees: None,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
+11
View File
@@ -50,6 +50,17 @@ pub enum OverlayKind {
KillConfirm(Uuid),
Help,
Toast(String),
/// The retained-worktrees view (#85); its entries live on `App` because
/// they are daemon data, not UI state.
Worktrees,
/// Reclaim confirmation for the worktree with this id (#85), guarding
/// the destructive key like [`OverlayKind::KillConfirm`] does.
ReclaimConfirm {
id: Uuid,
branch: String,
/// [`maestro_protocol::InTheWay`] as of when the confirm was opened.
blockers: String,
},
}
#[derive(Debug)]
+259
View File
@@ -0,0 +1,259 @@
//! Retained-worktrees view (#85): an overlay listing every retained worktree
//! — branch, abbreviated path, reason, what is in the way — with j/k to move,
//! `r` to reclaim behind a confirmation, esc to close.
//!
//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl,
//! the same source the kill refusal and the web client use, so no two clients
//! can describe the same tree differently.
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use ratatui::Frame;
use maestro_protocol::{RetainedReason, RetainedWorktree};
use crate::draft::abbreviate_path;
/// The open worktrees overlay: its entries and which one is highlighted.
#[derive(Debug, Clone, Default)]
pub struct WorktreesView {
pub items: Vec<RetainedWorktree>,
pub highlight: usize,
/// Daemon-reported home dir, for `~` abbreviation of paths.
pub home_dir: String,
}
impl WorktreesView {
/// Replace the listing and keep the highlight pointing at something real.
pub fn apply(&mut self, items: Vec<RetainedWorktree>) {
self.items = items;
if !self.items.is_empty() {
self.highlight = self.highlight.min(self.items.len() - 1);
} else {
self.highlight = 0;
}
}
pub fn remove(&mut self, id: uuid::Uuid) {
let idx = self.items.iter().position(|w| w.id == id);
self.items.retain(|w| w.id != id);
if let Some(i) = idx {
self.highlight = self.highlight.min(self.items.len().saturating_sub(1));
let _ = i;
}
}
/// Move the highlight by `delta` (positive = down). Wraps. No-op on empty.
pub fn move_highlight(&mut self, delta: i32) {
if self.items.is_empty() {
return;
}
let len = self.items.len() as i32;
let next = (self.highlight as i32 + delta).rem_euclid(len);
self.highlight = next as usize;
}
pub fn selected(&self) -> Option<&RetainedWorktree> {
self.items.get(self.highlight)
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let h = (self.items.len() as u16 + 6).clamp(4, parent.height);
let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h);
f.render_widget(Clear, area);
let block = Block::default()
.title(" retained worktrees ")
.borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
if self.items.is_empty() {
// A drained store is the normal state — say so plainly, not like
// something went wrong.
lines.push(Line::from("no retained worktrees"));
} else {
lines.push(Line::from(format!(
" {:<26} {:<34} {:<24} IN THE WAY",
"BRANCH", "PATH", "REASON"
)));
for (i, w) in self.items.iter().enumerate() {
let marker = if i == self.highlight { ">" } else { " " };
lines.push(Line::from(format!(
"{marker} {:<25} {:<34} {:<24} {}",
w.branch,
abbreviate_path(&w.path, &self.home_dir),
reason_label(w.reason),
w.in_the_way,
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from("j/k: select r: reclaim esc: close"));
f.render_widget(Paragraph::new(lines), inner);
}
}
/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`].
/// It names the branch and says out loud what reclaim does not take with it —
/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too.
#[derive(Debug, Clone)]
pub struct ReclaimConfirm {
pub branch: String,
/// [`maestro_protocol::InTheWay`] rendered via its `Display` impl.
pub blockers: String,
}
impl ReclaimConfirm {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = super::overlays::centered(parent, 70, 7);
f.render_widget(Clear, area);
let block = Block::default().title(" reclaim ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
ratatui::widgets::Paragraph::new(vec![
Line::from(format!("Reclaim worktree {}?", self.branch)),
Line::from(format!("Currently in the way: {}", self.blockers)),
Line::from("The directory is removed; the branch is never deleted."),
Line::from(""),
Line::from("[y] yes [n] no"),
]),
inner,
);
}
}
fn reason_label(reason: RetainedReason) -> &'static str {
match reason {
RetainedReason::ForceKilled => "force-killed",
RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use maestro_protocol::{InTheWay, RetainedReason};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string<F>(w: u16, h: u16, draw: F) -> String
where
F: FnOnce(&mut Frame),
{
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
RetainedWorktree {
id: uuid::Uuid::from_u128(id),
path: path.into(),
base_repo: "/home/naps/tea/maestro".into(),
branch: branch.into(),
reason: RetainedReason::ForceKilled,
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
in_the_way: InTheWay::default(),
}
}
#[test]
fn view_lists_branch_path_reason_and_what_is_in_the_way() {
let mut view = WorktreesView {
home_dir: "/home/naps".into(),
..Default::default()
};
let mut wt = worktree(
"feat/login",
"/home/naps/tea/maestro/worktrees/feat/login",
1,
);
wt.in_the_way = InTheWay {
uncommitted_files: 2,
untracked_files: 1,
unpushed_commits: 3,
};
view.apply(vec![wt]);
let rendered = render_to_string(160, 20, |f| view.render(f, f.area()));
assert!(rendered.contains("feat/login"), "{rendered}");
// Path abbreviated against the daemon's home dir, same helper as the
// draft pane's project list.
assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}");
assert!(rendered.contains("force-killed"), "{rendered}");
// Phrasing comes from InTheWay's Display impl — shared with the kill
// refusal and the web client.
assert!(
rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"),
"{rendered}"
);
}
#[test]
fn empty_store_looks_like_a_normal_state_not_an_error() {
let view = WorktreesView::default();
let rendered = render_to_string(80, 12, |f| view.render(f, f.area()));
assert!(rendered.contains("no retained worktrees"), "{rendered}");
assert!(!rendered.contains("error"), "{rendered}");
assert!(!rendered.contains("failed"), "{rendered}");
}
#[test]
fn highlight_moves_and_wraps() {
let mut view = WorktreesView::default();
view.apply(vec![
worktree("a", "/r/a", 1),
worktree("b", "/r/b", 2),
worktree("c", "/r/c", 3),
]);
assert_eq!(view.selected().unwrap().branch, "a");
view.move_highlight(1);
assert_eq!(view.selected().unwrap().branch, "b");
view.move_highlight(-1);
assert_eq!(view.selected().unwrap().branch, "a");
view.move_highlight(-1);
// Wraps backward to the last entry.
assert_eq!(view.selected().unwrap().branch, "c");
view.move_highlight(1);
assert_eq!(view.selected().unwrap().branch, "a");
}
#[test]
fn apply_clamps_a_stale_highlight() {
let mut view = WorktreesView::default();
view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]);
view.move_highlight(1);
view.apply(vec![worktree("c", "/r/c", 3)]);
assert_eq!(view.selected().unwrap().branch, "c");
}
#[test]
fn reclaim_confirm_names_the_branch_and_says_it_survives() {
let confirm = ReclaimConfirm {
branch: "feat/login".into(),
blockers: "2 uncommitted files".into(),
};
let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area()));
assert!(
rendered.contains("Reclaim worktree feat/login?"),
"{rendered}"
);
assert!(rendered.contains("2 uncommitted files"), "{rendered}");
assert!(rendered.contains("never deleted"), "{rendered}");
assert!(rendered.contains("yes"), "{rendered}");
assert!(rendered.contains("no"), "{rendered}");
}
}