feat(maestro): drop retained worktrees and header debug toggle

Removes the retained-worktree store end to end: the daemon's state-dir
store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the
TUI overlay behind `w`, and the web `#/worktrees` view and header link.

The kill guard stays. A forced kill and a dirty natural exit now leave
the worktree on disk untouched instead of parking it in a store, so
removing one is an ordinary `git worktree remove`. A pristine tree is
still removed on exit. DELETE /api/sessions/:id always answers 204.

The header's debug button also goes; Ctrl+Shift+D and `?hud` still
toggle the HUD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-31 09:00:52 +01:00
co-authored by Claude Opus 5
parent 43b90ccf0a
commit d39fbdbbfc
40 changed files with 152 additions and 2949 deletions
+2 -2
View File
@@ -63,8 +63,8 @@ nix/ # Home Manager module (`services.maestro`)
See `docs/INSTALL.md` for end-user install instructions. See `docs/INSTALL.md` for end-user install instructions.
`docs/WORKTREES.md` is the contract for session worktrees — placement, `docs/WORKTREES.md` is the contract for session worktrees — placement,
collisions, what blocks a kill, what `--force` retains, and what reclaim collisions, what blocks a kill, and what `--force` leaves behind. Read it
touches. Read it before changing anything that creates or removes one. before changing anything that creates or removes one.
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI `docs/SIDEBAR.md` is the contract for session cards in the web and TUI
sidebars — line layout, activity colours, duration format, state precedence. sidebars — line layout, activity colours, duration format, state precedence.
+2 -85
View File
@@ -41,8 +41,6 @@ pub enum Command {
Attach(AttachArgs), Attach(AttachArgs),
/// Kill a session by ID. /// Kill a session by ID.
Kill(KillArgs), Kill(KillArgs),
/// List retained worktrees, or reclaim one by ID (#86).
Worktrees(WorktreesArgs),
/// Install or update the agent activity hook (#106). /// Install or update the agent activity hook (#106).
Hooks(HooksArgs), Hooks(HooksArgs),
} }
@@ -62,34 +60,6 @@ pub enum HooksCommand {
Install, Install,
} }
/// `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)] #[derive(Debug, clap::Args)]
pub struct ListArgs { pub struct ListArgs {
/// Output as JSON instead of a table. /// Output as JSON instead of a table.
@@ -138,8 +108,7 @@ pub struct KillArgs {
/// Session ID to kill. /// Session ID to kill.
pub session_id: Uuid, pub session_id: Uuid,
/// End the session even when its worktree holds unsaved work (#81). The /// End the session even when its worktree holds unsaved work (#81). The
/// worktree is retained — never removed — and enters the retained store; /// worktree is left on disk — never removed.
/// the output says where the work went.
#[arg(long)] #[arg(long)]
pub force: bool, pub force: bool,
} }
@@ -301,63 +270,11 @@ mod tests {
} }
_ => panic!("wrong subcommand"), _ => panic!("wrong subcommand"),
} }
// No subcommand → help, like `worktrees`. // No subcommand → help.
let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err(); let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err();
assert!(matches!( assert!(matches!(
err.kind(), err.kind(),
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
)); ));
} }
#[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}"
);
}
} }
+3 -31
View File
@@ -9,9 +9,8 @@ use std::path::{Path, PathBuf};
use maestro_protocol::DaemonClient; use maestro_protocol::DaemonClient;
use crate::attach::{parse_detach_key, run_attach_url}; use crate::attach::{parse_detach_key, run_attach_url};
use crate::cli::{Command, WorktreesCommand}; use crate::cli::Command;
use crate::sessions::{run_kill, run_list, run_spawn}; 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}; use maestro_config::{default_config_path, MaestroConfigFile};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -80,37 +79,10 @@ pub async fn dispatch(
if !args.force { if !args.force {
warn_if_blocked(&daemon, args.session_id).await; warn_if_blocked(&daemon, args.session_id).await;
} }
let outcome = run_kill(&daemon, &args).await?; run_kill(&daemon, &args).await?;
println!("killed {}", args.session_id); println!("killed {}", args.session_id);
if let Some(w) = outcome.retained {
println!(
"worktree retained at {} (branch {}, force-killed)",
w.path.display(),
w.branch
);
}
} }
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"), Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
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(()) Ok(())
} }
@@ -150,7 +122,7 @@ async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
{ {
if !b.is_clear() { if !b.is_clear() {
eprintln!( eprintln!(
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it" "warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to end it anyway"
); );
} }
} }
-1
View File
@@ -6,6 +6,5 @@ pub mod attach;
pub mod cli; pub mod cli;
pub mod dispatch; pub mod dispatch;
pub mod sessions; pub mod sessions;
pub mod worktrees;
pub use dispatch::{dispatch, CliError}; pub use dispatch::{dispatch, CliError};
+1 -4
View File
@@ -62,10 +62,7 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead /// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
/// of removing it; the outcome names what was kept so dispatch can say where /// of removing it; the outcome names what was kept so dispatch can say where
/// the work went. /// the work went.
pub async fn run_kill( pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> {
client: &DaemonClient,
args: &KillArgs,
) -> Result<maestro_protocol::KillOutcome, ClientError> {
client.kill_session(args.session_id, args.force).await client.kill_session(args.session_id, args.force).await
} }
-204
View File
@@ -1,204 +0,0 @@
//! `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::display::abbreviate_dir;
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
/// `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");
}
}
+1 -8
View File
@@ -70,9 +70,6 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
Some(p) => Tmux::new().with_tmpdir(p), Some(p) => Tmux::new().with_tmpdir(p),
None => Tmux::new(), None => Tmux::new(),
}; };
let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new(
config.retained_worktree_dir(),
));
let manager = SessionManager::new_with_replay( let manager = SessionManager::new_with_replay(
std::sync::Arc::new(tmux), std::sync::Arc::new(tmux),
sidecar, sidecar,
@@ -83,10 +80,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
.with_spawn_history(crate::spawn_history::SpawnHistory::new( .with_spawn_history(crate::spawn_history::SpawnHistory::new(
config.spawn_history_path(), config.spawn_history_path(),
config.spawn_history_days, config.spawn_history_days,
)) ));
// One store instance shared with the API handlers: forced kills (#81)
// record here, `GET /api/worktrees` (#80) reads back from here.
.with_retained_store((*retained).clone());
// Spawned sessions learn where to report back (#103). Derived from the // Spawned sessions learn where to report back (#103). Derived from the
// configured bind address; set before any spawn can happen. // configured bind address; set before any spawn can happen.
manager.set_daemon_url(format!("http://{}", config.bind_addr)); manager.set_daemon_url(format!("http://{}", config.bind_addr));
@@ -108,7 +102,6 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
config.home_dir.clone(), config.home_dir.clone(),
config.spawn_presets.clone(), config.spawn_presets.clone(),
projects, projects,
retained,
)); ));
let listener = TcpListener::bind(config.bind_addr).await?; let listener = TcpListener::bind(config.bind_addr).await?;
-5
View File
@@ -107,11 +107,6 @@ impl DaemonConfig {
pub fn spawn_history_path(&self) -> PathBuf { pub fn spawn_history_path(&self) -> PathBuf {
self.state_dir.join("spawn-history.jsonl") 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 /// Env-var overrides applied by [`load_config`]. Tests pass explicit
-1
View File
@@ -20,7 +20,6 @@ pub mod panes;
pub mod proc; pub mod proc;
pub mod projects; pub mod projects;
pub mod pty; pub mod pty;
pub mod retained_store;
pub mod session; pub mod session;
pub mod sidecar; pub mod sidecar;
pub mod spawn_history; pub mod spawn_history;
+3 -125
View File
@@ -38,9 +38,6 @@ pub struct LocalApiState {
pub presets: Arc<Vec<SpawnPreset>>, pub presets: Arc<Vec<SpawnPreset>>,
/// Project discovery (#54) backing `GET /api/projects`. /// Project discovery (#54) backing `GET /api/projects`.
pub projects: Arc<crate::projects::ProjectScanner>, 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 { pub fn router(state: LocalApiState) -> Router {
@@ -57,8 +54,6 @@ pub fn router(state: LocalApiState) -> Router {
.route(routes::GITROOT, get(git_root)) .route(routes::GITROOT, get(git_root))
.route(routes::BRANCHES, get(crate::branches::handler)) .route(routes::BRANCHES, get(crate::branches::handler))
.route(routes::NAMES, get(names)) .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::INFO, get(daemon_info))
.route(routes::EVENTS, any(events_ws)) .route(routes::EVENTS, any(events_ws))
.route(routes::patterns::ATTACH, any(attach_ws)) .route(routes::patterns::ATTACH, any(attach_ws))
@@ -105,8 +100,6 @@ fn session_error_status(code: &str) -> StatusCode {
"invalid_env" => StatusCode::BAD_REQUEST, "invalid_env" => StatusCode::BAD_REQUEST,
// A guard refusal is the daemon doing its job, not a failure (#79). // A guard refusal is the daemon doing its job, not a failure (#79).
"kill_guarded" => StatusCode::CONFLICT, "kill_guarded" => StatusCode::CONFLICT,
// The retention record failed before anything was torn down (#81).
"retain_failed" => StatusCode::INTERNAL_SERVER_ERROR,
_ => StatusCode::BAD_GATEWAY, _ => StatusCode::BAD_GATEWAY,
} }
} }
@@ -186,9 +179,8 @@ async fn create_session(
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work /// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits". /// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
/// ///
/// With `force=true` (#81) the refusal is overridden and the worktree /// With `force=true` (#81) the refusal is overridden and the worktree is left
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept. /// on disk rather than removed. Either way the answer is `204`.
/// Without it the historical `204` stands.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct KillQuery { struct KillQuery {
#[serde(default)] #[serde(default)]
@@ -201,13 +193,7 @@ async fn kill_session(
Query(q): Query<KillQuery>, Query(q): Query<KillQuery>,
) -> Response { ) -> Response {
match s.manager.kill(id, q.force).await { match s.manager.kill(id, q.force).await {
Ok(outcome) => { Ok(()) => StatusCode::NO_CONTENT.into_response(),
if q.force {
(StatusCode::OK, Json(outcome)).into_response()
} else {
StatusCode::NO_CONTENT.into_response()
}
}
Err(e) => { Err(e) => {
let info = e.to_error_info(); let info = e.to_error_info();
let status = session_error_status(&info.code); let status = session_error_status(&info.code);
@@ -238,112 +224,6 @@ async fn rename_session(
} }
} }
/// `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(),
),
}
}
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state. /// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
/// The report holds above the daemon's inference for a TTL, then inference /// The report holds above the daemon's inference for a TTL, then inference
/// resumes — an agent that dies mid-run cannot pin its session at `Error` /// resumes — an agent that dies mid-run cannot pin its session at `Error`
@@ -382,7 +262,6 @@ pub fn state_for(
home_dir: impl Into<String>, home_dir: impl Into<String>,
presets: Vec<SpawnPreset>, presets: Vec<SpawnPreset>,
projects: Arc<crate::projects::ProjectScanner>, projects: Arc<crate::projects::ProjectScanner>,
retained: Arc<crate::retained_store::RetainedStore>,
) -> LocalApiState { ) -> LocalApiState {
LocalApiState { LocalApiState {
manager, manager,
@@ -390,7 +269,6 @@ pub fn state_for(
home_dir: Arc::new(home_dir.into()), home_dir: Arc::new(home_dir.into()),
presets: Arc::new(presets), presets: Arc::new(presets),
projects, projects,
retained,
} }
} }
-310
View File
@@ -1,310 +0,0 @@
//! 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;
}
}
+41 -146
View File
@@ -12,8 +12,8 @@ use std::time::Duration;
use chrono::Utc; use chrono::Utc;
use maestro_protocol::{ use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason, ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
RetainedWorktree, Session, SessionStatus, WorktreeSpec, WorktreeSpec,
}; };
use tokio::sync::{broadcast, Mutex}; use tokio::sync::{broadcast, Mutex};
use uuid::Uuid; use uuid::Uuid;
@@ -51,11 +51,6 @@ pub struct SessionRecord {
/// `base_repo` from the original `WorktreeSpec`. Needed because `git /// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo. /// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>, pub worktree_base_repo: Option<PathBuf>,
/// Branch the worktree was created for, recorded at spawn time so a
/// forced kill (#81) can name it in the retained store without asking
/// git. Sessions adopted from a sidecar have no branch on record and
/// fall back to a probe.
pub worktree_branch: Option<String>,
pub bridge: PtyBridge, pub bridge: PtyBridge,
/// Agent-reported activity (#105) still within its TTL. While in force it /// Agent-reported activity (#105) still within its TTL. While in force it
/// outranks the activity pass's own inference; when it lapses, inference /// outranks the activity pass's own inference; when it lapses, inference
@@ -88,11 +83,6 @@ pub enum SessionError {
/// so it stays the operator's handle on their own unfinished work. /// so it stays the operator's handle on their own unfinished work.
#[error("kill refused: the session's worktree holds unsaved work ({0})")] #[error("kill refused: the session's worktree holds unsaved work ({0})")]
KillGuarded(maestro_protocol::InTheWay), KillGuarded(maestro_protocol::InTheWay),
/// A forced kill (#81) records the retained worktree *before* tearing
/// anything down; if that write fails nothing was destroyed, and the
/// error says so rather than silently dropping unrecorded work.
#[error("could not record the retained worktree: {0}")]
RetainFailed(String),
} }
impl SessionError { impl SessionError {
@@ -105,7 +95,6 @@ impl SessionError {
SessionError::NotFound(_) => "not_found", SessionError::NotFound(_) => "not_found",
SessionError::InvalidEnv(_) => "invalid_env", SessionError::InvalidEnv(_) => "invalid_env",
SessionError::KillGuarded(_) => "kill_guarded", SessionError::KillGuarded(_) => "kill_guarded",
SessionError::RetainFailed(_) => "retain_failed",
}; };
let unsaved_work = match self { let unsaved_work = match self {
SessionError::KillGuarded(b) => Some(*b), SessionError::KillGuarded(b) => Some(*b),
@@ -138,9 +127,6 @@ struct Inner {
/// `None` when not wired (tests, or before `with_spawn_history`); a present /// `None` when not wired (tests, or before `with_spawn_history`); a present
/// but disabled (`0`-day) history no-ops internally. /// but disabled (`0`-day) history no-ops internally.
spawn_history: Option<SpawnHistory>, spawn_history: Option<SpawnHistory>,
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
/// kill (#81) without one fails rather than destroying unrecorded work.
retained: Option<crate::retained_store::RetainedStore>,
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as /// The daemon's own HTTP base URL (#103), exported to spawned sessions as
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get /// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
/// the variable. A mutex because the runner sets it after construction, /// the variable. A mutex because the runner sets it after construction,
@@ -187,7 +173,6 @@ impl SessionManager {
fifo_dir, fifo_dir,
replay_bytes, replay_bytes,
spawn_history: None, spawn_history: None,
retained: None,
daemon_url: std::sync::Mutex::new(None), daemon_url: std::sync::Mutex::new(None),
report_ttl: crate::activity::REPORT_TTL, report_ttl: crate::activity::REPORT_TTL,
sessions: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()),
@@ -212,16 +197,6 @@ impl SessionManager {
self self
} }
/// Attach the retained-worktree store (#78). A forced kill (#81) needs it
/// to record what it retained; without one, forcing a guarded kill fails
/// instead of destroying unrecorded work.
pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_retained_store must be called before the SessionManager is cloned");
inner.retained = Some(store);
self
}
/// Override the agent-report TTL (#105). Tests pass a short one to watch /// Override the agent-report TTL (#105). Tests pass a short one to watch
/// inference resume without waiting out [`crate::activity::REPORT_TTL`]. /// inference resume without waiting out [`crate::activity::REPORT_TTL`].
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self { pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
@@ -442,7 +417,6 @@ impl SessionManager {
fifo_path, fifo_path,
worktree_path, worktree_path,
worktree_base_repo, worktree_base_repo,
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
bridge, bridge,
reported_activity: None, reported_activity: None,
settled: false, settled: false,
@@ -494,19 +468,19 @@ impl SessionManager {
/// is removed; the error names what blocked it and how much of it there /// 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 /// is. The session stays as the operator's handle on their own unfinished
/// work, so ending a session that still matters becomes a deliberate /// work, so ending a session that still matters becomes a deliberate
/// `--force`, which retains rather than destroys (WORKTREES.md). /// `--force`, which leaves the tree alone rather than destroying it
/// (WORKTREES.md).
/// ///
/// With `force` (#81) the refusal — and nothing else — is overridden: /// With `force` (#81) the refusal — and nothing else — is overridden:
/// the session ends and the guarded worktree is retained into the store /// the session ends and the guarded worktree is left on disk, never
/// with reason `force-killed`, never removed. The outcome names what was /// removed. A tree whose guard is clear never needed the override; force
/// kept so a client can say where the work went. A tree whose guard is /// removes it as usual.
/// clear never needed the override; force removes it as usual.
/// ///
/// This is also how an already-dead session is dismissed. tmux is only /// 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 /// asked to kill something it still has — calling `kill-session` on a
/// session tmux already lost fails, and that failure is not interesting. /// session tmux already lost fails, and that failure is not interesting.
pub async fn kill(&self, id: Uuid, force: bool) -> Result<KillOutcome, SessionError> { pub async fn kill(&self, id: Uuid, force: bool) -> Result<(), SessionError> {
let (worktree, alive, known_branch) = { let (worktree, alive) = {
let sessions = self.inner.sessions.lock().await; let sessions = self.inner.sessions.lock().await;
match sessions.get(&id) { match sessions.get(&id) {
None => return Err(SessionError::NotFound(id)), None => return Err(SessionError::NotFound(id)),
@@ -516,7 +490,6 @@ impl SessionManager {
.clone() .clone()
.zip(record.worktree_path.clone()), .zip(record.worktree_path.clone()),
record.session.status == SessionStatus::Running, record.session.status == SessionStatus::Running,
record.worktree_branch.clone(),
), ),
} }
}; };
@@ -529,35 +502,13 @@ impl SessionManager {
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed"))); return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
} }
let mut retained = None;
if guarded {
let entry = record_retention(
&self.inner,
worktree.as_ref().expect("guarded ⇒ worktree"),
known_branch,
RetainedReason::ForceKilled,
)
.await?;
retained = Some((entry, probe.expect("guarded ⇒ probed")));
}
let removed = { let removed = {
let mut sessions = self.inner.sessions.lock().await; let mut sessions = self.inner.sessions.lock().await;
sessions.remove(&id) sessions.remove(&id)
}; };
let record = removed.ok_or(SessionError::NotFound(id))?; let record = removed.ok_or(SessionError::NotFound(id))?;
cleanup_record(&self.inner, id, record, alive, retained.is_some()).await; cleanup_record(&self.inner, id, record, alive, guarded).await;
Ok(KillOutcome { Ok(())
retained: retained.map(|(entry, in_the_way)| RetainedWorktree {
id: entry.id,
path: entry.path,
base_repo: entry.base_repo,
branch: entry.branch,
reason: entry.reason,
retained_at: entry.retained_at,
in_the_way,
}),
})
} }
/// Spawn a background task that polls tmux every `interval` and reaps any /// Spawn a background task that polls tmux every `interval` and reaps any
@@ -669,9 +620,6 @@ impl SessionManager {
// directory blocks that branch forever (#54 review). // directory blocks that branch forever (#54 review).
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()), worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()), worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
// The sidecar carries no branch; a forced kill of a
// recovered session probes git for it at retain time.
worktree_branch: None,
bridge, bridge,
reported_activity: None, reported_activity: None,
settled: false, settled: false,
@@ -840,32 +788,6 @@ async fn probe_worktree(
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e))) .map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
} }
/// Record a guarded worktree into the retained store with `reason`, BEFORE
/// the caller tears anything down: a failed write then leaves every artifact
/// untouched instead of destroying work nothing records anymore. Shared by
/// the forced kill (`ForceKilled`, #81) and the natural exit
/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart.
async fn record_retention(
inner: &Arc<Inner>,
(base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
reason: RetainedReason,
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
let store = inner
.retained
.clone()
.ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?;
// Sessions adopted from a sidecar carry no branch; ask the tree itself.
let branch = known_branch
.or_else(|| crate::worktree_probe::current_branch(path))
.unwrap_or_default();
let base_repo = base_repo.clone();
let path = path.clone();
tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason))
.await
.map_err(|e| SessionError::RetainFailed(e.to_string()))
}
/// One pass of the liveness watcher: diff the in-memory session set against /// One pass of the liveness watcher: diff the in-memory session set against
/// the tmux-reported one, and reap anything we still track but tmux has lost. /// the tmux-reported one, and reap anything we still track but tmux has lost.
async fn run_liveness_pass(inner: &Arc<Inner>) { async fn run_liveness_pass(inner: &Arc<Inner>) {
@@ -1069,13 +991,11 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can /// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
/// still attach and read the replay buffer — the last thing a crashed agent /// still attach and read the replay buffer — the last thing a crashed agent
/// printed. The worktree, though, is settled right away: a pristine tree is /// printed. The worktree, though, is settled right away: a pristine tree is
/// removed, anything else is retained into the store with reason /// removed (#82) — nobody is waiting for an answer here, so there is nothing
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here, /// to refuse. Once settled, the session drops its worktree reference so a
/// so there is nothing to refuse. Once settled, the session drops its /// later dismissal does not re-probe it. A tree holding unsaved work is left
/// worktree reference so a later dismissal neither re-probes nor retains /// alone and stays attached to the dead record for a later forced dismissal
/// twice. When settling is impossible (probe or store failure) nothing is /// (#81), which leaves it on disk too.
/// destroyed: the tree stays attached to the dead record for a later forced
/// dismissal (#81).
/// ///
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`] /// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
/// adopts only what tmux still holds. /// adopts only what tmux still holds.
@@ -1093,21 +1013,20 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
.worktree_base_repo .worktree_base_repo
.clone() .clone()
.zip(record.worktree_path.clone()); .zip(record.worktree_path.clone());
let branch = record.worktree_branch.clone(); Some((session, worktree))
Some((session, worktree, branch))
} }
_ => None, _ => None,
} }
}; };
let Some((mut session, worktree, branch)) = flipped else { let Some((mut session, worktree)) = flipped else {
return; return;
}; };
tracing::info!(%id, "session vanished from tmux — marking dead"); tracing::info!(%id, "session vanished from tmux — marking dead");
if let Some(wt) = &worktree { if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt, branch).await { if settle_worktree_on_exit(inner, id, wt).await {
// The tree is settled (removed or retained); a stale "in the // The tree is settled (removed); a stale "in the way" would
// way" would describe something the session no longer holds. // describe something the session no longer holds.
session.blocked_work = None; session.blocked_work = None;
let mut sessions = inner.sessions.lock().await; let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) { if let Some(record) = sessions.get_mut(&id) {
@@ -1115,7 +1034,6 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
record.session.blocked_work = None; record.session.blocked_work = None;
record.worktree_path = None; record.worktree_path = None;
record.worktree_base_repo = None; record.worktree_base_repo = None;
record.worktree_branch = None;
} }
} }
} }
@@ -1134,10 +1052,8 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
/// Apply the exit rule (#82) to a vanished session's worktree: probe what /// Apply the exit rule (#82) to a vanished session's worktree: probe what
/// would be lost through the kill's own predicate, then remove a pristine /// would be lost through the kill's own predicate, then remove a pristine
/// tree or retain anything else under `exited-with-unsaved-work` — recorded /// tree. Anything holding unsaved work is left exactly where it is — the
/// before anything is torn down, exactly as the forced kill does (#81). A /// same thing a forced kill does with a guarded tree (#81).
/// removal that fails falls back to retaining, since whatever blocked it is
/// by definition something worth keeping.
/// ///
/// Returns whether the worktree was fully accounted for. When it wasn't, /// Returns whether the worktree was fully accounted for. When it wasn't,
/// nothing was destroyed and the caller keeps the reference on the record. /// nothing was destroyed and the caller keeps the reference on the record.
@@ -1145,7 +1061,6 @@ async fn settle_worktree_on_exit(
inner: &Arc<Inner>, inner: &Arc<Inner>,
id: Uuid, id: Uuid,
(base_repo, path): &(PathBuf, PathBuf), (base_repo, path): &(PathBuf, PathBuf),
known_branch: Option<String>,
) -> bool { ) -> bool {
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await { let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
Ok(probe) => probe, Ok(probe) => probe,
@@ -1159,45 +1074,25 @@ async fn settle_worktree_on_exit(
return false; return false;
} }
}; };
if probe.is_clear() { if !probe.is_clear() {
match inner.worktree_mgr.remove(base_repo, path) { tracing::info!(
Ok(()) => { %id,
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed"); path = %path.display(),
return true; "natural exit: worktree holds unsaved work; leaving it on disk"
} );
Err(e) => { return false;
tracing::warn!(
%id,
path = %path.display(),
error = ?e,
"natural exit: removal failed; falling back to retention"
);
}
}
} }
match record_retention( match inner.worktree_mgr.remove(base_repo, path) {
inner, Ok(()) => {
&(base_repo.clone(), path.clone()), tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
known_branch,
RetainedReason::ExitedWithUnsavedWork,
)
.await
{
Ok(entry) => {
tracing::info!(
%id,
path = %path.display(),
entry = %entry.id,
"natural exit: worktree retained with unsaved work"
);
true true
} }
Err(e) => { Err(e) => {
tracing::error!( tracing::warn!(
%id, %id,
path = %path.display(), path = %path.display(),
error = ?e, error = ?e,
"natural exit: could not record the retained worktree; leaving it untouched" "natural exit: removal failed; leaving the worktree on disk"
); );
false false
} }
@@ -1214,15 +1109,15 @@ async fn settle_worktree_on_exit(
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the /// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
/// liveness reaper (tmux already lost the session). /// liveness reaper (tmux already lost the session).
/// ///
/// `retain_worktree` is `true` for a forced kill (#81) that retained the /// `keep_worktree` is `true` for a forced kill (#81) over a guarded tree: the
/// worktree instead of removing it: the store entry is already written, so /// session ends but the worktree is left on disk, so only the removal is
/// only the removal is skipped. /// skipped.
async fn cleanup_record( async fn cleanup_record(
inner: &Arc<Inner>, inner: &Arc<Inner>,
id: Uuid, id: Uuid,
record: SessionRecord, record: SessionRecord,
kill_tmux: bool, kill_tmux: bool,
retain_worktree: bool, keep_worktree: bool,
) { ) {
if kill_tmux { if kill_tmux {
let tmux = inner.tmux.clone(); let tmux = inner.tmux.clone();
@@ -1233,11 +1128,11 @@ async fn cleanup_record(
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar"); tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
} }
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) { if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if retain_worktree { if keep_worktree {
tracing::info!( tracing::info!(
%id, %id,
path = %path.display(), path = %path.display(),
"forced kill: worktree retained in the store" "forced kill: worktree left on disk"
); );
} else if let Err(e) = inner.worktree_mgr.remove(base, path) { } else if let Err(e) = inner.worktree_mgr.remove(base, path) {
// The kill already passed the unsaved-work guard (#79), so this // The kill already passed the unsaved-work guard (#79), so this
+5 -92
View File
@@ -1,14 +1,8 @@
//! The one git probe for what a worktree holds that would be lost, plus //! The one git probe for what a worktree holds that would be lost (#79).
//! guard-free reclaim (#79, #80).
//! //!
//! [`in_the_way`] is the single predicate behind all three paths: the kill //! [`in_the_way`] is the single predicate behind both paths: the kill refusal
//! refusal (#79), the natural-exit classifier (#82), and the retained list //! (#79) and the natural-exit classifier (#82). It lives here rather than in
//! (#80). It lives here rather than in `worktree.rs` so that none of them can //! `worktree.rs` so that neither can answer the question differently.
//! 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 std::path::Path;
@@ -27,7 +21,7 @@ pub enum ProbeError {
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a /// Ignored files never count (`--ignored=no`) — every Rust worktree has a
/// `target/`, and counting those would put every tree permanently in the way. /// `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 /// 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. /// nothing left to lose.
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> { pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
let mut breakdown = InTheWay::default(); let mut breakdown = InTheWay::default();
// `symlink_metadata` rather than `exists`: a broken symlink is gone for // `symlink_metadata` rather than `exists`: a broken symlink is gone for
@@ -67,37 +61,6 @@ pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
Ok(breakdown) 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(|_| ())
}
/// Branch checked out at `path`. Sessions adopted from a sidecar carry no
/// branch on record, so a forced kill (#81) asks the tree itself when it
/// needs one for the retained store. `None` when git cannot answer.
pub fn current_branch(path: &Path) -> Option<String> {
let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError> fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
where where
I: IntoIterator<Item = S>, I: IntoIterator<Item = S>,
@@ -272,54 +235,4 @@ mod tests {
let breakdown = in_the_way(&gone).expect("probe"); let breakdown = in_the_way(&gone).expect("probe");
assert!(breakdown.is_clear()); 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,7 +38,6 @@ async fn spawn_daemon_ws() -> Harness {
"/home/test", "/home/test",
Vec::new(), Vec::new(),
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), 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))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
-1
View File
@@ -27,7 +27,6 @@ async fn spawn() -> Harness {
"/home/test", "/home/test",
presets, presets,
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()), Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
std::sync::Arc::new(fx.retained_store()),
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
-18
View File
@@ -176,11 +176,6 @@ impl DaemonFixture {
manager = manager =
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days)); manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
} }
// Same wiring as production: the manager records retained worktrees
// into the same dir the API reads back from (#78/#81).
let retained =
maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir));
manager = manager.with_retained_store(retained.clone());
Self { Self {
tmux, tmux,
manager, manager,
@@ -192,10 +187,6 @@ impl DaemonFixture {
state_dir.path().join("spawn-history.jsonl") state_dir.path().join("spawn-history.jsonl")
} }
fn retained_path(state_dir: &TempDir) -> PathBuf {
state_dir.path().join("retained-worktrees")
}
pub fn spawn_history_path(&self) -> PathBuf { pub fn spawn_history_path(&self) -> PathBuf {
Self::history_path(&self.state_dir) Self::history_path(&self.state_dir)
} }
@@ -204,15 +195,6 @@ impl DaemonFixture {
self.state_dir.path().join("sessions") 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 { pub fn fifo_dir(&self) -> PathBuf {
self.state_dir.path().join("fifos") self.state_dir.path().join("fifos")
} }
+5 -256
View File
@@ -25,13 +25,6 @@ struct Harness {
_fx: DaemonFixture, _fx: DaemonFixture,
} }
impl Harness {
/// Store over the same dir the manager records into.
fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore {
self._fx.retained_store()
}
}
async fn spawn(hostname: &str) -> Harness { async fn spawn(hostname: &str) -> Harness {
spawn_with( spawn_with(
hostname, hostname,
@@ -55,7 +48,6 @@ async fn spawn_with(
"/home/test", "/home/test",
presets, presets,
projects, projects,
std::sync::Arc::new(fx.retained_store()),
)); ));
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.await .await
@@ -537,11 +529,10 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
assert!(h.manager.has(session.id).await, "the session keeps running"); assert!(h.manager.has(session.id).await, "the session keeps running");
} }
/// `?force=true` on the wire (#81): the refusal is overridden, the session /// `?force=true` on the wire (#81): the refusal is overridden and the session
/// ends, and the answer is 200 with the retained worktree named — path, /// ends, while the guarded worktree survives on disk.
/// branch, reason — while the directory survives on disk.
#[tokio::test] #[tokio::test]
async fn delete_with_force_ends_the_session_and_answers_what_was_retained() { async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
let h = spawn("naps-laptop").await; let h = spawn("naps-laptop").await;
let repo = tempfile::TempDir::new().unwrap(); let repo = tempfile::TempDir::new().unwrap();
common::init_repo(repo.path()); common::init_repo(repo.path());
@@ -574,22 +565,11 @@ async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
.send() .send()
.await .await
.unwrap(); .unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::OK); assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
let body: maestro_protocol::KillOutcome = resp.json().await.unwrap();
let retained = body.retained.expect("force names what was kept");
assert_eq!(retained.path, wt_path);
assert_eq!(retained.branch, "feat/guarded");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
// The tree would have blocked; force must not have removed it. // The tree would have blocked; force must not have removed it.
assert!(wt_path.exists(), "a forced kill never removes the worktree"); assert!(wt_path.exists(), "a forced kill never removes the worktree");
// And the store entry exists, so reclaim can find it (#80). assert!(!h.manager.has(session.id).await, "the session is gone");
let all = h.fx_retained().load_all().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, retained.id);
} }
#[tokio::test] #[tokio::test]
@@ -746,234 +726,3 @@ 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"
);
}
+30 -152
View File
@@ -8,9 +8,7 @@ use std::process::Command;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use common::{ use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture,
};
use maestro_daemon::SessionError; use maestro_daemon::SessionError;
use maestro_protocol::CreateSessionRequest; use maestro_protocol::CreateSessionRequest;
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec}; use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
@@ -609,11 +607,9 @@ async fn killing_refuses_until_the_branch_is_pushed() {
} }
/// `--force` overrides the refusal and nothing else (#81): the session ends, /// `--force` overrides the refusal and nothing else (#81): the session ends,
/// but the guarded worktree is RETAINED — never removed — and enters the /// but the guarded worktree is left on disk — never removed.
/// retained store with reason `force-killed`. The outcome names what was
/// kept, so a client can say where the work went.
#[tokio::test] #[tokio::test]
async fn force_killing_a_guarded_worktree_retains_it_in_the_store() { async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap(); let repo = TempDir::new().unwrap();
init_repo(repo.path()); init_repo(repo.path());
@@ -639,8 +635,7 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
// Without force the same kill is refused (#79). // Without force the same kill is refused (#79).
assert!(fx.manager.kill(s.id, false).await.is_err()); assert!(fx.manager.kill(s.id, false).await.is_err());
let outcome = fx fx.manager
.manager
.kill(s.id, true) .kill(s.id, true)
.await .await
.expect("force overrides the refusal"); .expect("force overrides the refusal");
@@ -653,30 +648,11 @@ async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
// ...but the session itself still ends. // ...but the session itself still ends.
assert!(!fx.manager.has(s.id).await); assert!(!fx.manager.has(s.id).await);
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists()); assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
let retained = outcome.retained.expect("the outcome names what was kept");
assert_eq!(retained.path, wt_path);
assert_eq!(retained.branch, "feat/forced");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert!(
retained.in_the_way.uncommitted_files >= 1,
"the outcome carries what blocked the unforced kill"
);
// The store holds the same entry, so reclaim can find it later (#80).
let all = fx.retained_store().load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].id, retained.id);
assert_eq!(all[0].path, wt_path);
assert_eq!(all[0].branch, "feat/forced");
} }
/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose /// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose
/// guard is clear never needed the override, so a forced kill removes it as /// guard is clear never needed the override, so a forced kill removes it as
/// usual and retains nothing. /// usual.
#[tokio::test] #[tokio::test]
async fn force_on_a_clear_worktree_removes_it_as_usual() { async fn force_on_a_clear_worktree_removes_it_as_usual() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
@@ -704,10 +680,8 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
init_bare_repo(origin.path()); init_bare_repo(origin.path());
push_all_to_origin(repo.path(), origin.path()); push_all_to_origin(repo.path(), origin.path());
let outcome = fx.manager.kill(s.id, true).await.expect("kill"); fx.manager.kill(s.id, true).await.expect("kill");
assert!(!wt_path.exists(), "a clear tree needs no override"); assert!(!wt_path.exists(), "a clear tree needs no override");
assert_eq!(outcome.retained, None);
assert!(fx.retained_store().load_all().unwrap().is_empty());
} }
/// Ignored files NEVER block (#79): build output like `target/` exists in /// Ignored files NEVER block (#79): build output like `target/` exists in
@@ -757,12 +731,11 @@ async fn killing_an_ignored_only_worktree_succeeds() {
/// Natural exit (#82): a session that ends on its own never reaches the kill /// Natural exit (#82): a session that ends on its own never reaches the kill
/// path, so the watcher applies the kill's rule itself. A worktree holding /// path, so the watcher applies the kill's rule itself. A worktree holding
/// unsaved work is RETAINED into the store with reason /// unsaved work is LEFT ON DISK and stays attached to the dead record; the
/// `exited-with-unsaved-work`; the session stays in the catalog marked dead /// session stays in the catalog marked dead until dismissed, and dismissing
/// until dismissed — and dismissing it afterwards neither refuses nor /// it goes through `--force`, which leaves the tree alone too.
/// retains a second time.
#[tokio::test] #[tokio::test]
async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() { async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
let repo = TempDir::new().unwrap(); let repo = TempDir::new().unwrap();
init_repo(repo.path()); init_repo(repo.path());
@@ -793,56 +766,35 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
}) })
.await; .await;
// The tree would have blocked a kill, so the exit kept it... // The settle step has been applied or refused either way — here refused,
// since the tree holds unsaved work. Only from this point is "the tree
// survived" a settled fact rather than a race (#115).
wait_for_async(Duration::from_secs(3), || async {
fx.manager.is_settled(s.id).await == Some(true)
})
.await;
// The tree would have blocked a kill, so the exit kept it.
assert!( assert!(
wt_path.exists(), wt_path.exists(),
"a dirty worktree must be retained on natural exit" "a dirty worktree must be left alone on natural exit"
); );
// ...in the store, under the exit reason.
// (`Dead` is observable a moment before the settle step's store write lands.)
wait_for_async(Duration::from_secs(3), || async {
fx.retained_store()
.load_all()
.map(|all| all.len() == 1)
.unwrap_or(false)
})
.await;
let all = fx.retained_store().load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].path, wt_path);
assert_eq!(all[0].branch, "feat/exited");
assert_eq!(
all[0].reason,
maestro_protocol::RetainedReason::ExitedWithUnsavedWork
);
// The session itself stays until dismissed; its worktree reference is
// settled, so dismissal is neither refused nor retained twice.
// (`Dead` is observable a moment before the settle step finishes.)
wait_for_async(Duration::from_secs(3), || async {
let s = &fx.manager.snapshot().await[0];
s.worktree.is_none() && s.blocked_work.is_none()
})
.await;
let snap = fx.manager.snapshot().await; let snap = fx.manager.snapshot().await;
assert_eq!(snap.len(), 1); assert_eq!(snap.len(), 1);
assert_eq!(snap[0].worktree, None);
fx.manager.kill(s.id, false).await.expect("dismiss");
assert!(!fx.manager.has(s.id).await);
assert!( assert!(
wt_path.exists(), snap[0].worktree.is_some(),
"dismissal must not remove what was retained" "the tree stays attached to the dead record"
);
assert_eq!(
fx.retained_store().load_all().unwrap().len(),
1,
"dismissal must not record a second entry for the same tree"
); );
// Dismissing it needs --force, and force never removes what would block.
assert!(fx.manager.kill(s.id, false).await.is_err());
fx.manager.kill(s.id, true).await.expect("forced dismissal");
assert!(!fx.manager.has(s.id).await);
assert!(wt_path.exists(), "dismissal must not remove the work");
} }
/// Natural exit (#82), pristine side of the same rule: nothing in the tree, /// Natural exit (#82), pristine side of the same rule: nothing in the tree,
/// nothing to keep — the worktree is removed and no store entry appears. /// nothing to keep — the worktree is removed.
#[tokio::test] #[tokio::test]
async fn natural_exit_of_a_pristine_worktree_removes_it() { async fn natural_exit_of_a_pristine_worktree_removes_it() {
let fx = DaemonFixture::new(); let fx = DaemonFixture::new();
@@ -879,92 +831,18 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
.await; .await;
// (`Dead` is observable a moment before the settle step removes the tree.) // (`Dead` is observable a moment before the settle step removes the tree.)
wait_for_async(Duration::from_secs(3), || async { wait_for_async(Duration::from_secs(3), || async { !wt_path.exists() }).await;
!wt_path.exists()
&& fx
.retained_store()
.load_all()
.map(|all| all.is_empty())
.unwrap_or(false)
})
.await;
assert!( assert!(
!wt_path.exists(), !wt_path.exists(),
"a pristine worktree leaves nothing behind" "a pristine worktree leaves nothing behind"
); );
assert!(
fx.retained_store().load_all().unwrap().is_empty(),
"nothing was retained"
);
assert!( assert!(
fx.manager.has(s.id).await, fx.manager.has(s.id).await,
"the session stays until dismissed" "the session stays until dismissed"
); );
} }
/// When nothing can be recorded — here: no retained store wired — the exit
/// destroys nothing. The tree stays on disk AND stays attached to the dead
/// record, where dismissing it later still goes through #81's forced-kill
/// retention. Losing the store must not lose the work.
#[tokio::test]
async fn natural_exit_without_a_retained_store_destroys_nothing() {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let manager = maestro_daemon::SessionManager::new(
Arc::new(tmux.tmux.clone()),
maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")),
maestro_daemon::worktree::WorktreeManager::new(None),
state_dir.path().join("fifos"),
);
// Deliberately NO with_retained_store.
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let wt_path = repo.path().join("worktrees").join("feat").join("nostore");
let s = 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/nostore".into(),
create_branch: true,
}),
})
.await
.expect("create");
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
tmux.tmux.kill_session(s.id).expect("kill tmux directly");
manager.start_liveness_watcher(Duration::from_millis(50));
wait_for_async(Duration::from_secs(3), || async {
manager.status(s.id).await == Some(SessionStatus::Dead)
})
.await;
// The settle step has been applied or refused either way — here refused,
// since there is no store to record the tree into. Only from this point
// is "the tree survived" a settled fact rather than a race (#115).
wait_for_async(Duration::from_secs(3), || async {
manager.is_settled(s.id).await == Some(true)
})
.await;
assert!(
wt_path.exists(),
"nothing may be destroyed when it cannot be recorded"
);
let snap = manager.snapshot().await;
assert!(
snap[0].worktree.is_some(),
"the tree must stay attached to the record so a later forced kill can retain it"
);
}
fn run_git_in(dir: &std::path::Path, args: &[&str]) { fn run_git_in(dir: &std::path::Path, args: &[&str]) {
let out = Command::new("git") let out = Command::new("git")
.arg("-C") .arg("-C")
+4 -13
View File
@@ -12,8 +12,7 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use maestro_protocol::{ use maestro_protocol::{
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project, BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, Project, Session,
RetainedWorktree, Session,
}; };
use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -65,20 +64,12 @@ impl TestClient {
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?) Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
} }
/// Kill with `force=true`: overrides a kill refusal and retains the /// Kill with `force=true`: overrides a kill refusal and leaves the
/// guarded worktree (#81). Answers with what was kept. /// guarded worktree on disk (#81).
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> { pub async fn kill_session_forced(&self, id: Uuid) -> Result<(), ClientError> {
Ok(self.daemon.kill_session(id, true).await?) Ok(self.daemon.kill_session(id, true).await?)
} }
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
Ok(self.daemon.list_worktrees().await?)
}
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
Ok(self.daemon.reclaim_worktree(id).await?)
}
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> { pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
Ok(self.daemon.list_projects().await?) Ok(self.daemon.list_projects().await?)
} }
+10 -151
View File
@@ -415,7 +415,7 @@ async fn spawn_from_linked_worktree_places_in_main_checkout() {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Worktree lifecycle: collisions, retention, reclaim (#88) // Worktree lifecycle: collisions, guards, settling (#88)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real /// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
@@ -621,10 +621,10 @@ async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() {
} }
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the /// #81/#88: commits that no remote has refuse the kill; `--force` ends the
/// session anyway but RETAINS the worktree — force never destroys blocked /// session anyway but LEAVES the worktree on disk — force never destroys
/// work, it parks it in the retained store with reason `force-killed`. /// blocked work.
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn unpushed_commits_block_kill_and_force_retains_the_tree() { async fn unpushed_commits_block_kill_and_force_keeps_the_tree() {
let (_daemon, client) = bring_up().await; let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap(); let base = TempDir::new().unwrap();
@@ -655,17 +655,7 @@ async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
} }
// Force overrides the refusal and nothing else: session gone, tree kept. // Force overrides the refusal and nothing else: session gone, tree kept.
let outcome = client.kill_session_forced(s.id).await.expect("forced kill"); client.kill_session_forced(s.id).await.expect("forced kill");
let retained = outcome
.retained
.expect("force must retain the guarded tree");
assert_eq!(
retained.reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert_eq!(retained.path, wt);
assert_eq!(retained.branch, "feature/local");
assert_eq!(retained.in_the_way.unpushed_commits, 1);
assert!( assert!(
!client !client
@@ -676,18 +666,18 @@ async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
.any(|x| x.id == s.id), .any(|x| x.id == s.id),
"the forced kill ends the session" "the forced kill ends the session"
); );
assert!(wt.is_dir(), "a force-retained worktree must stay on disk"); assert!(wt.is_dir(), "a guarded worktree must stay on disk");
assert!( assert!(
git_worktree_list(base.path()) git_worktree_list(base.path())
.iter() .iter()
.any(|l| l.contains("feature/local")), .any(|l| l.contains("feature/local")),
"the retained tree stays registered in git" "the kept tree stays registered in git"
); );
} }
/// #82/#88: a session that exits on its own never reaches the kill path, but /// #82/#88: a session that exits on its own never reaches the kill path, but
/// its worktree follows the same rule. Dirty → retained with reason /// its worktree follows the same rule. Dirty: left on disk. Pristine:
/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind. /// removed, leaving nothing behind.
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() { async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
let (_daemon, client) = bring_up().await; let (_daemon, client) = bring_up().await;
@@ -734,46 +724,18 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
// The watcher reaps dead tmux sessions every couple of ticks and settles // The watcher reaps dead tmux sessions every couple of ticks and settles
// each worktree accordingly. Poll for both outcomes. // each worktree accordingly. Poll for both outcomes.
wait_for_async(
"dirty exit retains its worktree",
Duration::from_secs(20),
|| {
let client = client.clone();
let path = dirty_wt.clone();
async move {
client
.list_worktrees()
.await
.ok()
.map(|wts| {
wts.iter().any(|w| {
w.path == path
&& w.reason
== maestro_protocol::RetainedReason::ExitedWithUnsavedWork
})
})
.unwrap_or(false)
}
},
)
.await;
wait_for_async( wait_for_async(
"pristine exit removes its worktree entirely", "pristine exit removes its worktree entirely",
Duration::from_secs(20), Duration::from_secs(20),
|| { || {
let client = client.clone();
let repo = pristine_repo.path().to_path_buf(); let repo = pristine_repo.path().to_path_buf();
let path = pristine_wt.clone(); let path = pristine_wt.clone();
async move { async move {
if path.exists() { if path.exists() {
return false; return false;
} }
let listed = client.list_worktrees().await.ok();
let listed_clear = listed
.map(|wts| !wts.iter().any(|w| w.path == path))
.unwrap_or(false);
let registered = git_worktree_list(&repo); let registered = git_worktree_list(&repo);
!registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear !registered.iter().any(|l| l.contains("feature/tidy"))
} }
}, },
) )
@@ -797,94 +759,6 @@ fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateS
} }
} }
/// #80/#88: `GET /api/worktrees` lists what the daemon is holding, with the
/// reason and a live in-the-way breakdown; reclaim removes the directory and
/// the store entry but NEVER the branch — its commits outlive the reclaim.
#[tokio::test(flavor = "multi_thread")]
async fn worktrees_endpoint_lists_reasons_and_reclaim_keeps_the_branch() {
let (_daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base.path().join("worktrees").join("feature").join("parked");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/parked", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"worth keeping\n").unwrap();
commit_all(&wt, "worth keeping");
let sha = branch_sha(&wt, "feature/parked");
let outcome = client.kill_session_forced(s.id).await.expect("force kill");
assert!(outcome.retained.is_some());
let listed = client.list_worktrees().await.expect("list worktrees");
assert_eq!(listed.len(), 1, "exactly one retained entry: {listed:?}");
let entry = &listed[0];
assert_eq!(entry.path, wt);
assert_eq!(entry.branch, "feature/parked");
assert_eq!(entry.reason, maestro_protocol::RetainedReason::ForceKilled);
assert_eq!(entry.in_the_way.unpushed_commits, 1);
client.reclaim_worktree(entry.id).await.expect("reclaim");
assert!(!wt.exists(), "reclaim removes the directory");
assert!(
client.list_worktrees().await.unwrap().is_empty(),
"reclaim drops the store entry"
);
// The whole point of reclaim: the branch and its commits survive.
assert_eq!(
branch_sha(base.path(), "feature/parked"),
sha,
"the branch must keep pointing at the same commit after reclaim"
);
}
/// The retained store is state-dir backed (#80): a daemon restart must not
/// orphan the trees it is holding — they are still on disk with entries
/// pointing at them, and still reclaimable afterwards.
#[tokio::test(flavor = "multi_thread")]
async fn retained_store_survives_a_daemon_restart() {
let (mut daemon, client) = bring_up().await;
let base = TempDir::new().unwrap();
init_repo(base.path());
let _origin = throwaway_origin(base.path());
let wt = base
.path()
.join("worktrees")
.join("feature")
.join("durable");
let s = client
.spawn_session(&worktree_req(base.path(), "feature/durable", true))
.await
.expect("spawn");
std::fs::write(wt.join("README"), b"survives restarts\n").unwrap();
commit_all(&wt, "survives restarts");
client.kill_session_forced(s.id).await.expect("force kill");
daemon.restart().await;
let listed = client.list_worktrees().await.expect("list after restart");
assert_eq!(listed.len(), 1, "store must survive restart: {listed:?}");
assert_eq!(listed[0].path, wt);
assert_eq!(
listed[0].reason,
maestro_protocol::RetainedReason::ForceKilled
);
assert!(wt.is_dir(), "the tree itself must survive the restart too");
client
.reclaim_worktree(listed[0].id)
.await
.expect("reclaim");
assert!(!wt.exists());
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /api/projects + /api/branches (#54/#55/#66) // GET /api/projects + /api/branches (#54/#55/#66)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1280,21 +1154,6 @@ fn commit_all(dir: &std::path::Path, message: &str) {
run_git(&["commit", "-q", "-m", message], dir); run_git(&["commit", "-q", "-m", message], dir);
} }
fn branch_sha(repo: &std::path::Path, branch: &str) -> String {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
.output()
.expect("git rev-parse");
assert!(
out.status.success(),
"branch {branch} missing: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn daemon_events_ws_streams_snapshot_then_session_added() { async fn daemon_events_ws_streams_snapshot_then_session_added() {
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial // NAP-248: subscribe to the daemon's WS /api/events, expect an initial
+5 -28
View File
@@ -16,8 +16,7 @@ use uuid::Uuid;
use crate::routes::{self, UrlError}; use crate::routes::{self, UrlError};
use crate::{ use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
KillOutcome, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Project, RecentDirs, RenameSessionRequest, ReportActivityRequest, Session,
RetainedWorktree, Session,
}; };
/// Time budget for establishing a TCP connection to the daemon. /// Time budget for establishing a TCP connection to the daemon.
@@ -116,9 +115,8 @@ impl DaemonClient {
} }
/// Kill a session. With `force`, the daemon overrides the kill refusal /// Kill a session. With `force`, the daemon overrides the kill refusal
/// (#79) and retains the worktree instead of removing it (#81); the /// (#79) and leaves the worktree on disk instead of removing it (#81).
/// outcome then names what was kept and where. pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<(), ClientError> {
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
let query: &[(&str, &str)] = if force { let query: &[(&str, &str)] = if force {
&[(routes::FORCE_QUERY, "true")] &[(routes::FORCE_QUERY, "true")]
} else { } else {
@@ -127,12 +125,8 @@ impl DaemonClient {
let resp = self let resp = self
.send(Method::DELETE, &routes::session(id), query, None::<&()>) .send(Method::DELETE, &routes::session(id), query, None::<&()>)
.await?; .await?;
let resp = check(resp).await?; check(resp).await?;
// An unforced kill keeps its historical `204 No Content`. Ok(())
if resp.status() == reqwest::StatusCode::NO_CONTENT {
return Ok(KillOutcome { retained: None });
}
json(resp).await
} }
/// `PATCH /api/sessions/:id` (#125): rename a live session. A blank name /// `PATCH /api/sessions/:id` (#125): rename a live session. A blank name
@@ -187,23 +181,6 @@ impl DaemonClient {
.await .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 ----------------------------------------------------------- // -- plumbing -----------------------------------------------------------
async fn get<T: DeserializeOwned>( async fn get<T: DeserializeOwned>(
+3 -115
View File
@@ -222,23 +222,9 @@ pub struct SessionWorktree {
pub base_repo: PathBuf, pub base_repo: PathBuf,
} }
/// Why a retained worktree outlived its session (#80). Same values the /// What a kill refusal reports about a worktree (#80) — the breakdown that
/// daemon's retained store persists; new variants are additive but clients /// blocks an unforced kill, recomputed on every probe so it reflects the tree
/// should render unknown reasons generically. /// now.
#[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)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InTheWay { pub struct InTheWay {
/// Tracked files with uncommitted modifications. /// Tracked files with uncommitted modifications.
@@ -259,34 +245,6 @@ impl InTheWay {
} }
} }
/// 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,
}
/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81).
/// A forced kill ends the session and retains its worktree instead of
/// removing it; this names what was kept, where, and why, so a client can say
/// where the work went rather than leaving the operator to wonder.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KillOutcome {
/// Set when the worktree was retained with reason `force-killed`; `None`
/// when there was nothing to retain — no worktree at all, or one whose
/// guard was clear, which a forced kill removes as usual.
#[serde(default)]
pub retained: Option<RetainedWorktree>,
}
impl std::fmt::Display for InTheWay { impl std::fmt::Display for InTheWay {
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted; /// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
/// all-zero renders "no unsaved work". This is the phrasing every client /// all-zero renders "no unsaved work". This is the phrasing every client
@@ -873,76 +831,6 @@ mod tests {
assert_eq!(spec, round_trip(&spec)); 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 kill_outcome_round_trips_a_retained_worktree() {
let outcome = KillOutcome {
retained: Some(sample_retained_worktree()),
};
assert_eq!(outcome, round_trip(&outcome));
}
/// A kill with nothing to retain — no worktree, or a clean one removed as
/// usual — decodes from an absent field.
#[test]
fn kill_outcome_defaults_to_nothing_retained() {
let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(outcome.retained, None);
}
#[test] #[test]
fn catalog_event_round_trips_and_tags() { fn catalog_event_round_trips_and_tags() {
let session = sample_session(); let session = sample_session();
+1 -19
View File
@@ -26,9 +26,6 @@ pub const BRANCHES: &str = "/api/branches";
/// `GET` a batch of generated session handles (#53). Takes a `count` query /// `GET` a batch of generated session handles (#53). Takes a `count` query
/// param (default 1, capped at [`maestro_names`]' batch cap). /// param (default 1, capped at [`maestro_names`]' batch cap).
pub const NAMES: &str = "/api/names"; 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. /// `WS` catalog snapshot + session deltas.
pub const EVENTS: &str = "/api/events"; pub const EVENTS: &str = "/api/events";
@@ -39,7 +36,7 @@ pub const PATH_QUERY: &str = "path";
pub const COUNT_QUERY: &str = "count"; pub const COUNT_QUERY: &str = "count";
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81). /// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
/// `force=true` overrides the kill refusal and retains the worktree. /// `force=true` overrides the kill refusal and leaves the worktree on disk.
pub const FORCE_QUERY: &str = "force"; pub const FORCE_QUERY: &str = "force";
/// `DELETE /api/sessions/:id`. /// `DELETE /api/sessions/:id`.
@@ -52,11 +49,6 @@ pub fn session_activity(id: Uuid) -> String {
format!("{SESSIONS}/{id}/activity") format!("{SESSIONS}/{id}/activity")
} }
/// `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. /// `WS /api/attach/:id` — raw PTY bytes.
pub fn attach(id: Uuid) -> String { pub fn attach(id: Uuid) -> String {
format!("{API_PREFIX}/attach/{id}") format!("{API_PREFIX}/attach/{id}")
@@ -68,7 +60,6 @@ pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id"; pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity"; pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
pub const ATTACH: &str = "/api/attach/: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. /// A daemon base URL that could not be turned into a WebSocket base.
@@ -183,10 +174,6 @@ mod tests {
attach(id()), attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" "/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
); );
assert_eq!(
worktree(id()),
"/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!( assert_eq!(
attach_url("https://laptop.lan:7070", id()).unwrap(), attach_url("https://laptop.lan:7070", id()).unwrap(),
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50" "wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -209,10 +196,6 @@ mod tests {
patterns::ATTACH.trim_end_matches(":id"), patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string()) 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] #[test]
@@ -226,7 +209,6 @@ mod tests {
GITROOT, GITROOT,
BRANCHES, BRANCHES,
NAMES, NAMES,
WORKTREES,
EVENTS, EVENTS,
] { ] {
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}"); assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
-4
View File
@@ -51,8 +51,6 @@ pub enum Action {
/// Rename the highlighted session (#125): opens a prompt pre-filled with /// Rename the highlighted session (#125): opens a prompt pre-filled with
/// its current name. /// its current name.
RenameHighlighted, RenameHighlighted,
/// Open the retained-worktrees view (#85).
ShowWorktrees,
// Chrome // Chrome
ToggleSidebar, ToggleSidebar,
@@ -96,7 +94,6 @@ impl Action {
Action::FastForwardSpawn => "Create session with defaults".into(), Action::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(), Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(), Action::RenameHighlighted => "Rename selected session".into(),
Action::ShowWorktrees => "Show retained worktrees".into(),
Action::ToggleSidebar => "Show or hide sidebar".into(), Action::ToggleSidebar => "Show or hide sidebar".into(),
Action::ShowHelp => "Show help".into(), Action::ShowHelp => "Show help".into(),
Action::Quit => "Quit Maestro".into(), Action::Quit => "Quit Maestro".into(),
@@ -123,7 +120,6 @@ impl Action {
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted), "KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted), "RenameHighlighted" => return Ok(Action::RenameHighlighted),
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
"ToggleSidebar" => return Ok(Action::ToggleSidebar), "ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp), "ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit), "Quit" => return Ok(Action::Quit),
+5 -273
View File
@@ -18,9 +18,7 @@ use std::time::Duration;
use chrono::Utc; use chrono::Utc;
use crossterm::event::{Event as CtEvent, EventStream}; use crossterm::event::{Event as CtEvent, EventStream};
use futures_util::StreamExt; use futures_util::StreamExt;
use maestro_protocol::{ use maestro_protocol::{routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch};
routes, BranchInfo, CreateSessionRequest, DaemonClient, NameBatch, RetainedWorktree,
};
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::Terminal;
use uuid::Uuid; use uuid::Uuid;
@@ -41,7 +39,6 @@ use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar; use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem}; use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
use crate::text_input::TextInput; use crate::text_input::TextInput;
use crate::worktrees::WorktreesView;
use crate::TuiError; use crate::TuiError;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -61,8 +58,7 @@ pub struct App {
help: HelpOverlay, help: HelpOverlay,
chord: ChordState, chord: ChordState,
daemon: DaemonClient, daemon: DaemonClient,
/// Results of draft data loads (and the worktrees view fetch, #85), /// Results of draft data loads, delivered back to the event loop.
/// delivered back to the event loop.
/// ///
/// These are network calls — `/api/projects` on a cold cache walks all of /// These are network calls — `/api/projects` on a cold cache walks all of
/// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no /// `$HOME` — and awaiting them inside `perform` froze the whole TUI: no
@@ -73,9 +69,6 @@ pub struct App {
/// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet: /// Direct HTTP for the one endpoint [`DaemonClient`] doesn't cover yet:
/// `GET /api/names` (#64). /// `GET /api/names` (#64).
http: reqwest::Client, 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,
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the /// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
/// same reason the worktree entries are. /// same reason the worktree entries are.
rename_input: TextInput, rename_input: TextInput,
@@ -122,11 +115,6 @@ pub enum Effect {
}, },
/// Fetch the draft target's branches from `/api/branches` (#65). /// Fetch the draft target's branches from `/api/branches` (#65).
LoadBranches(usize), 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),
/// Rename a session (#125). An empty name clears the custom one, so the /// Rename a session (#125). An empty name clears the custom one, so the
/// card falls back to the command. /// card falls back to the command.
Rename { Rename {
@@ -157,7 +145,6 @@ impl App {
chord: ChordState::new(chord_timeout), chord: ChordState::new(chord_timeout),
daemon, daemon,
http, http,
worktrees: WorktreesView::default(),
rename_input: TextInput::default(), rename_input: TextInput::default(),
draft_tx, draft_tx,
draft_rx, draft_rx,
@@ -293,8 +280,6 @@ impl App {
self.spawn_probe_git_root(tab, seq, path) self.spawn_probe_git_root(tab, seq, path)
} }
Effect::LoadBranches(tab) => self.spawn_load_branches(tab), Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
Effect::LoadWorktrees => self.spawn_load_worktrees(),
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
Effect::Rename { id, name } => self.submit_rename(id, &name).await, Effect::Rename { id, name } => self.submit_rename(id, &name).await,
Effect::Forward { id, bytes } => { Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) { if let Some(handle) = self.emulators.get(id) {
@@ -333,44 +318,6 @@ impl App {
(OverlayDecision::Consumed, Vec::new()) (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) => { Some(OverlayKind::Help) => {
self.state.dismiss_overlay(); self.state.dismiss_overlay();
(OverlayDecision::Consumed, Vec::new()) (OverlayDecision::Consumed, Vec::new())
@@ -474,10 +421,6 @@ impl App {
self.state.show_overlay(OverlayKind::Rename(sid)); self.state.show_overlay(OverlayKind::Rename(sid));
} }
} }
Action::ShowWorktrees => {
self.state.show_overlay(OverlayKind::Worktrees);
effects.push(Effect::LoadWorktrees);
}
} }
effects effects
} }
@@ -791,22 +734,13 @@ impl App {
self.fast_forward_spawn(info, projects, names).await; self.fast_forward_spawn(info, projects, names).await;
return; return;
} }
if let DraftUpdate::WorktreesLoaded {
worktrees,
home_dir,
} = update
{
self.worktrees.apply(worktrees);
self.worktrees.home_dir = home_dir;
return;
}
let want = match &update { let want = match &update {
DraftUpdate::Data { draft, .. } DraftUpdate::Data { draft, .. }
| DraftUpdate::Names { draft, .. } | DraftUpdate::Names { draft, .. }
| DraftUpdate::GitRoot { draft, .. } | DraftUpdate::GitRoot { draft, .. }
| DraftUpdate::Branches { draft, .. } => *draft, | DraftUpdate::Branches { draft, .. } => *draft,
// Handled above; it targets no particular draft. // Handled above; it targets no particular draft.
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(), DraftUpdate::FastForward { .. } => unreachable!(),
}; };
// Find it by identity: closing a tab shifts every index to its right, // 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 // so an in-flight load addressed by index would be dropped or land on
@@ -861,7 +795,7 @@ impl App {
DraftUpdate::Branches { cwd, branches, .. } => { DraftUpdate::Branches { cwd, branches, .. } => {
draft.set_branches(&cwd, branches); draft.set_branches(&cwd, branches);
} }
DraftUpdate::FastForward { .. } | DraftUpdate::WorktreesLoaded { .. } => unreachable!(), DraftUpdate::FastForward { .. } => unreachable!(),
} }
if follow_up_branches { if follow_up_branches {
self.spawn_load_branches(tab_idx); self.spawn_load_branches(tab_idx);
@@ -961,13 +895,7 @@ impl App {
async fn submit_kill(&mut self, sid: Uuid) { async fn submit_kill(&mut self, sid: Uuid) {
match self.daemon.kill_session(sid, false).await { match self.daemon.kill_session(sid, false).await {
Ok(outcome) => { Ok(()) => self.release_session(sid),
if let Some(w) = outcome.retained {
self.state
.toast(format!("worktree retained: {}", w.path.display()));
}
self.release_session(sid)
}
Err(e) => self.state.toast(format!("kill failed: {e}")), Err(e) => self.state.toast(format!("kill failed: {e}")),
} }
} }
@@ -980,38 +908,6 @@ 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( fn redraw(
&mut self, &mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>, terminal: &mut Terminal<CrosstermBackend<Stdout>>,
@@ -1059,7 +955,6 @@ impl App {
state: &self.state, state: &self.state,
draft, draft,
help: &self.help, help: &self.help,
worktrees: Some(&self.worktrees),
rename_input: &self.rename_input, rename_input: &self.rename_input,
}; };
@@ -1248,11 +1143,6 @@ enum DraftUpdate {
projects: Option<Vec<maestro_protocol::Project>>, projects: Option<Vec<maestro_protocol::Project>>,
names: Vec<String>, 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`. /// `GET /api/names?count=N`. Free function: the spawned loaders have no `self`.
@@ -2198,162 +2088,4 @@ mod tests {
}] }]
); );
} }
// -- 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
@@ -54,7 +54,6 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("S", "FastForwardSpawn"), ("S", "FastForwardSpawn"),
("x", "KillHighlighted"), ("x", "KillHighlighted"),
("r", "RenameHighlighted"), ("r", "RenameHighlighted"),
("w", "ShowWorktrees"),
// Focus // Focus
("ctrl-w h", "FocusSidebar"), ("ctrl-w h", "FocusSidebar"),
// Session scrollback // Session scrollback
@@ -211,10 +210,6 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "S"), cfg.keymap.resolve(Mode::Normal, "S"),
Some(Action::FastForwardSpawn), Some(Action::FastForwardSpawn),
); );
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "w"),
Some(Action::ShowWorktrees),
);
} }
#[test] #[test]
-1
View File
@@ -22,7 +22,6 @@ pub mod sidebar;
pub mod state; pub mod state;
pub mod terminal; pub mod terminal;
pub mod text_input; pub mod text_input;
pub mod worktrees;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
+2 -4
View File
@@ -33,7 +33,6 @@ impl KillConfirm {
f.render_widget(block, area); f.render_widget(block, area);
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))]; let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
if let Some(blockers) = &self.blockers { if let Some(blockers) = &self.blockers {
// Same phrasing the reclaim confirmation uses for its own guard.
lines.push(Line::from(format!("Currently in the way: {blockers}"))); lines.push(Line::from(format!("Currently in the way: {blockers}")));
lines.push(Line::from("The kill will be refused unless forced.")); lines.push(Line::from("The kill will be refused unless forced."));
} }
@@ -45,9 +44,8 @@ impl KillConfirm {
/// Rename prompt for the highlighted session (#125). /// Rename prompt for the highlighted session (#125).
/// ///
/// Borrows its text buffer rather than owning it: the buffer lives on `App` /// Borrows its text buffer rather than owning it: the buffer lives on `App`,
/// alongside the worktrees view's entries, so [`crate::state::OverlayKind`] /// so [`crate::state::OverlayKind`] stays a plain comparable enum.
/// stays a plain comparable enum.
#[derive(Debug)] #[derive(Debug)]
pub struct RenamePrompt<'a> { pub struct RenamePrompt<'a> {
/// What the card shows today — the session's name, or the command it /// What the card shows today — the session's name, or the command it
-22
View File
@@ -15,7 +15,6 @@ use crate::palette;
use crate::pane::Pane; use crate::pane::Pane;
use crate::sidebar::Sidebar; use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, SidebarItem}; use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
use crate::worktrees::{ReclaimConfirm, WorktreesView};
/// Tagged regions for mouse hit-testing. The input router resolves a mouse /// Tagged regions for mouse hit-testing. The input router resolves a mouse
/// coordinate to one of these via [`RegionMap::region_at`]. /// coordinate to one of these via [`RegionMap::region_at`].
@@ -82,8 +81,6 @@ pub struct RenderInputs<'a> {
pub draft: Option<&'a crate::draft::DraftPane>, pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState, pub state: &'a AppState,
pub help: &'a HelpOverlay, pub help: &'a HelpOverlay,
/// Retained-worktree entries for the worktrees view (#85).
pub worktrees: Option<&'a WorktreesView>,
/// Edit buffer behind the rename prompt (#125). /// Edit buffer behind the rename prompt (#125).
pub rename_input: &'a crate::text_input::TextInput, pub rename_input: &'a crate::text_input::TextInput,
} }
@@ -193,22 +190,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
inputs.help.render(f, area); inputs.help.render(f, area);
map.modal = Some(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)) => { Some(OverlayKind::Toast(text)) => {
let t = Toast { text: text.clone() }; let t = Toast { text: text.clone() };
t.render(f, area); t.render(f, area);
@@ -372,7 +353,6 @@ mod tests {
draft: Some(draft), draft: Some(draft),
state: &state, state: &state,
help: &help, help: &help,
worktrees: None,
rename_input: &crate::text_input::TextInput::default(), rename_input: &crate::text_input::TextInput::default(),
}; };
@@ -426,7 +406,6 @@ mod tests {
draft: None, draft: None,
state: &state, state: &state,
help: &help, help: &help,
worktrees: None,
rename_input: &crate::text_input::TextInput::default(), rename_input: &crate::text_input::TextInput::default(),
}; };
let backend = ratatui::backend::TestBackend::new(80, 24); let backend = ratatui::backend::TestBackend::new(80, 24);
@@ -466,7 +445,6 @@ mod tests {
draft: None, draft: None,
state: &state, state: &state,
help: &help, help: &help,
worktrees: None,
rename_input: &crate::text_input::TextInput::default(), rename_input: &crate::text_input::TextInput::default(),
}; };
let backend = ratatui::backend::TestBackend::new(80, 24); let backend = ratatui::backend::TestBackend::new(80, 24);
+1 -12
View File
@@ -61,21 +61,10 @@ impl Tab {
pub enum OverlayKind { pub enum OverlayKind {
KillConfirm(Uuid), KillConfirm(Uuid),
/// Rename prompt for this session (#125). The edit buffer lives on `App`, /// Rename prompt for this session (#125). The edit buffer lives on `App`,
/// like the worktrees view's entries, so this stays comparable. /// so this stays comparable.
Rename(Uuid), Rename(Uuid),
Help, Help,
Toast(String), 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)] #[derive(Debug)]
-259
View File
@@ -1,259 +0,0 @@
//! 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}");
}
}
+28 -55
View File
@@ -12,9 +12,9 @@ have to reconstruct it.
## Nothing is ever adopted ## Nothing is ever adopted
The daemon only ever removes a worktree it created in this session's lifetime or The daemon only ever removes a worktree it created in this session's lifetime.
recorded in its retained store. It never takes ownership of a directory it finds It never takes ownership of a directory it finds on disk, whatever that
on disk, whatever that directory looks like. directory looks like.
There is no reliable way to ask "is this worktree mine?". A git worktree carries There is no reliable way to ask "is this worktree mine?". A git worktree carries
no marker distinguishing one a daemon made from one a human made ten minutes no marker distinguishing one a daemon made from one a human made ten minutes
@@ -69,75 +69,48 @@ its worktree still matters leaves a directory nobody is looking at.
A consequence, accepted deliberately: a feature branch almost always has unpushed A consequence, accepted deliberately: a feature branch almost always has unpushed
commits, so `--force` is the ordinary way to end a session, not an exception. commits, so `--force` is the ordinary way to end a session, not an exception.
That is only safe because force retains. That is only safe because force never removes what blocked.
### `--force` ### `--force`
`--force` overrides the refusal and nothing else. It ends the session and `--force` overrides the refusal and nothing else. It ends the session and
**retains** the worktree, which enters the retained store with reason **leaves the worktree on disk**, untouched. Force never removes a tree that
`force-killed`. Force never removes a tree that would have blocked. A tree would have blocked. A tree whose guard is clear never needed the override, so a
whose guard is clear never needed the override, so a forced kill removes it as forced kill removes it as usual — force changes what happens to blocked work,
usual — force changes what happens to blocked work, not ordinary cleanup. not ordinary cleanup.
Ending a session and destroying its work are two separate destructive acts. Ending a session and destroying its work are two separate acts. Maestro only
Each is chosen on its own, and the second one — reclaim — is chosen while performs the first one. Removing a tree it left behind is the operator's own
looking at exactly what would be lost. `git worktree remove`, chosen while looking at exactly what would be lost.
A forced kill answers with what was retained and why, so a client can say where
the work went rather than leaving the operator to wonder.
### Exiting on its own ### Exiting on its own
A session that ends by itself — the command finished, the operator typed `exit` — A session that ends by itself — the command finished, the operator typed `exit` —
never reaches the kill path. When the watcher notices the tmux session is gone it never reaches the kill path. When the watcher notices the tmux session is gone it
applies the same rule: a pristine worktree is removed, anything else is retained applies the same rule: a pristine worktree is removed, anything else is left on
with reason `exited-with-unsaved-work`. disk.
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
only between removing and retaining. It uses the same predicate as the kill, from only between removing and leaving. It uses the same predicate as the kill, from
the same place, so the two paths cannot drift apart. the same place, so the two paths cannot drift apart.
The session itself stays in the catalog marked dead until it is dismissed — the The session itself stays in the catalog marked dead until it is dismissed — the
replay buffer of a crashed agent outlives it — but its worktree is settled at replay buffer of a crashed agent outlives it — but its worktree is settled at
once. Once settled, the session drops its worktree reference, so dismissing it once. A removed tree drops off the record, so dismissing the session afterwards
afterwards neither refuses (there is no guard left to trip) nor records a second does not refuse: there is no guard left to trip.
store entry.
If settling is impossible — the probe fails, or the store cannot take the entry — A tree that was left alone stays attached to the dead record, where dismissing it
nothing is destroyed. The tree stays on disk *and* stays attached to the dead later goes through `--force`, which leaves it on disk too. The same holds when
record, where dismissing it later still goes through `--force`'s the probe itself fails: nothing is destroyed.
retain-before-teardown path. Losing the store must never mean losing the work.
## The retained store ## Nothing is removed on the operator's behalf
A `state_dir`-backed record of worktrees the daemon created that no longer have a There is no store of worktrees the daemon is holding, no list endpoint, and no
session. For each one: the path, the base repository, the branch, why it was reclaim command. A tree left behind by a forced kill or a dirty exit is an
retained, and when. ordinary git worktree on disk; `git worktree list` finds it and
`git worktree remove` removes it.
It survives a daemon restart. It is the recovery path for everything above — a That is deliberate. A tracked list of trees pending deletion is a second place
store that is lost on restart is worse than no store, because the worktrees are the truth can live, and it goes stale the moment somebody removes a directory by
still on disk with nothing pointing at them. hand. Git already knows which worktrees exist, and removing one is not a
decision Maestro is better placed to make.
Entries leave when the worktree is reclaimed, and when the daemon notices the
directory is gone because somebody removed it by hand.
**It is not an ownership record.** It must never be consulted to decide whether a
path may be adopted — see the first rule. Its only job is to remember what the
daemon is holding so that it can be listed and reclaimed.
## Reclaim
`GET /api/worktrees` lists the store: path, base repository, branch, reason, when,
and what is currently in the way. The in-the-way breakdown is recomputed on read,
not stored, so a tree whose work has since been pushed says so.
`DELETE /api/worktrees/<id>` reclaims one. It removes the directory and drops the
store entry. It does **not** consult the guard — reclaim is the deliberate
destructive act, and the operator chose it while looking at what was in the way.
It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming
a tree must not throw away commits on it. This is worth saying out loud in every
client, because "reclaim" otherwise sounds like it takes the commits too.
Nothing is reclaimed automatically. There is no age-based expiry and no bulk
reclaim.
-19
View File
@@ -9,7 +9,6 @@ import type {
NameBatch, NameBatch,
Projects, Projects,
RecentDirs, RecentDirs,
RetainedWorktree,
Session, Session,
Uuid, Uuid,
} from "./types.js"; } from "./types.js";
@@ -112,22 +111,4 @@ export async function fetchBranches(path: string): Promise<Branches> {
return jsonOrError(await fetch(url, { credentials: "same-origin" })); return jsonOrError(await fetch(url, { credentials: "same-origin" }));
} }
/** The daemon's retained worktrees (#80): trees kept after a session ended
* with unsaved work, with what is currently in the way. Drives the
* retained-worktrees view (#84). */
export async function listWorktrees(): Promise<RetainedWorktree[]> {
return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" }));
}
/** Reclaim a retained worktree: removes its directory and drops the store
* entry. Never deletes the branch (docs/WORKTREES.md). */
export async function reclaimWorktree(id: Uuid): Promise<void> {
await jsonOrError(
await fetch(routes.worktree(id), {
method: "DELETE",
credentials: "same-origin",
}),
);
}
export { eventsWsUrl } from "./routes.js"; export { eventsWsUrl } from "./routes.js";
-30
View File
@@ -1,7 +1,6 @@
import { escapeHtml } from "./html.js"; import { escapeHtml } from "./html.js";
import { getDaemonInfo, killSession, renameSession } from "./api.js"; import { getDaemonInfo, killSession, renameSession } from "./api.js";
import { openContextMenu } from "./contextMenu.js"; import { openContextMenu } from "./contextMenu.js";
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js"; import { CatalogStore } from "./events.js";
import type { Session } from "./types.js"; import type { Session } from "./types.js";
import { import {
@@ -269,25 +268,6 @@ function renderEmpty(filtering: boolean, onSpawn: () => void): HTMLElement {
* the app-level actions (spawn, theme toggle). Kept out of the sidebar so these * the app-level actions (spawn, theme toggle). Kept out of the sidebar so these
* stay put and reachable regardless of which pane is showing. * stay put and reachable regardless of which pane is showing.
*/ */
/** Navbar toggle for the debug HUD (#20), next to the design-kit shortcut.
* Reflects the current state and stays in sync with the Ctrl+Shift+D keybind. */
function createDebugToggle(): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn btn-ghost debug-toggle";
btn.textContent = "debug";
btn.title = "Toggle the debug HUD (Ctrl+Shift+D)";
const sync = () => {
const on = isHudEnabled();
btn.setAttribute("aria-pressed", String(on));
btn.classList.toggle("is-active", on);
};
btn.addEventListener("click", () => toggleHud());
window.addEventListener(HUD_TOGGLE_EVENT, sync);
sync();
return btn;
}
export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement { export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void): HTMLElement {
const header = document.createElement("header"); const header = document.createElement("header");
header.className = "app-header"; header.className = "app-header";
@@ -315,15 +295,6 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
renderStatus(); renderStatus();
const actions = header.querySelector(".app-actions") as HTMLElement; const actions = header.querySelector(".app-actions") as HTMLElement;
// Retained worktrees (#84): a section reachable from the header, not a
// sidebar fixture — PRODUCT.md's scan is the product, so this must not
// compete with the session catalog for attention.
const worktreesLink = document.createElement("a");
worktreesLink.className = "btn btn-ghost";
worktreesLink.href = "#/worktrees";
worktreesLink.textContent = "worktrees";
worktreesLink.title = "Retained worktrees";
actions.appendChild(worktreesLink);
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by // DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
// clicking inside a proxied preview (e.g. crit live) that has no address bar. // clicking inside a proxied preview (e.g. crit live) that has no address bar.
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@@ -334,7 +305,6 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
kit.title = "Open the design kit (DEV)"; kit.title = "Open the design kit (DEV)";
actions.appendChild(kit); actions.appendChild(kit);
} }
actions.appendChild(createDebugToggle());
actions.appendChild(createThemeToggle()); actions.appendChild(createThemeToggle());
const spawnBtn = document.createElement("button"); const spawnBtn = document.createElement("button");
spawnBtn.type = "button"; spawnBtn.type = "button";
-37
View File
@@ -7,9 +7,7 @@ import {
createSession, createSession,
fetchNames, fetchNames,
getDaemonInfo, getDaemonInfo,
listWorktrees,
projects as fetchProjects, projects as fetchProjects,
reclaimWorktree,
} from "./api.js"; } from "./api.js";
import { import {
buildFastForwardRequest, buildFastForwardRequest,
@@ -24,7 +22,6 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { OPEN_SPAWN_EVENT } from "./catalog.js"; import { OPEN_SPAWN_EVENT } from "./catalog.js";
import { initTheme } from "./theme.js"; import { initTheme } from "./theme.js";
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js"; import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js";
import { installViewportSizing } from "./viewport.js"; import { installViewportSizing } from "./viewport.js";
import "./styles.css"; import "./styles.css";
@@ -352,47 +349,16 @@ document.addEventListener("keydown", (e) => {
type Route = type Route =
| { kind: "catalog" } | { kind: "catalog" }
| { kind: "attach"; sessionId: string } | { kind: "attach"; sessionId: string }
| { kind: "worktrees" }
| { kind: "not-found"; raw: string }; | { kind: "not-found"; raw: string };
function parseRoute(): Route { function parseRoute(): Route {
const hash = location.hash.replace(/^#/, ""); const hash = location.hash.replace(/^#/, "");
if (hash === "" || hash === "/") return { kind: "catalog" }; if (hash === "" || hash === "/") return { kind: "catalog" };
if (hash === "/worktrees") return { kind: "worktrees" };
const m = hash.match(/^\/attach\/([^/?#]+)$/); const m = hash.match(/^\/attach\/([^/?#]+)$/);
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) }; if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
return { kind: "not-found", raw: hash }; return { kind: "not-found", raw: hash };
} }
// Retained worktrees (#84): built lazily on first visit, then kept so a
// revisit re-enters instantly. The daemon's home dir abbreviates paths once
// `/api/info` answers.
let worktreesView: WorktreesViewHandle | undefined;
let homeDir: string | undefined;
void getDaemonInfo()
.then((info) => {
homeDir = info.home_dir;
})
.catch(() => {
/* absolute paths are a fine fallback */
});
function showWorktrees(): void {
pool.setVisible([]);
pool.setFocus(null);
contentShowsMv = false;
if (!worktreesView) {
worktreesView = createWorktreesView({
load: listWorktrees,
reclaim: reclaimWorktree,
homeDir: () => homeDir,
});
}
content.replaceChildren(worktreesView.el);
shellEl.dataset.route = "worktrees";
void worktreesView.refresh();
}
function dispatch(): void { function dispatch(): void {
const route = parseRoute(); const route = parseRoute();
switch (route.kind) { switch (route.kind) {
@@ -414,9 +380,6 @@ function dispatch(): void {
// tears panes down. // tears panes down.
showMultiView(); showMultiView();
return; return;
case "worktrees":
showWorktrees();
return;
} }
} }
-6
View File
@@ -13,7 +13,6 @@ export const PROJECTS = "/api/projects";
export const GITROOT = "/api/gitroot"; export const GITROOT = "/api/gitroot";
export const NAMES = "/api/names"; export const NAMES = "/api/names";
export const BRANCHES = "/api/branches"; export const BRANCHES = "/api/branches";
export const WORKTREES = "/api/worktrees";
export const EVENTS = "/api/events"; export const EVENTS = "/api/events";
/** Name of the query parameter `DIRS` and `GITROOT` both take. */ /** Name of the query parameter `DIRS` and `GITROOT` both take. */
@@ -30,11 +29,6 @@ export function attach(id: Uuid): string {
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`; return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
} }
/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */
export function worktree(id: Uuid): string {
return `${WORKTREES}/${encodeURIComponent(id)}`;
}
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */ /** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
export function withPathQuery(route: string, value: string): string { export function withPathQuery(route: string, value: string): string {
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`; return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
-151
View File
@@ -2015,12 +2015,6 @@ input.leaf-rename:focus {
color: var(--muted); color: var(--muted);
} }
/* debug-HUD navbar toggle: lit when the HUD is on. */
.debug-toggle.is-active {
color: var(--accent);
border-color: var(--accent);
}
/* ========================================================================= /* =========================================================================
multi-view (#30 splits, #31 tabs, #33 highlights) multi-view (#30 splits, #31 tabs, #33 highlights)
Tabs of nested split trees; each session leaf hosts a live .attach. The Tabs of nested split trees; each session leaf hosts a live .attach. The
@@ -2439,148 +2433,3 @@ input.leaf-rename:focus {
background: oklch(0 0 0 / 0.5); background: oklch(0 0 0 / 0.5);
} }
} }
/* ---------- retained worktrees (#84) ---------- */
.worktrees {
padding: var(--sp-4);
max-width: 960px;
margin: 0 auto;
}
.wt-head {
margin-bottom: var(--sp-4);
}
.wt-title {
font-family: var(--font-mono);
font-size: var(--fs-15);
font-weight: 600;
color: var(--ink);
margin: 0 0 var(--sp-1);
}
.wt-sub {
color: var(--muted);
font-size: var(--fs-13);
margin: 0;
}
.wt-row {
display: flex;
align-items: baseline;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-2);
border-bottom: 1px solid var(--surface-2);
font-family: var(--font-mono);
font-size: var(--fs-13);
flex-wrap: wrap;
}
.wt-branch {
color: var(--ink);
font-weight: 600;
}
.wt-path {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wt-reason {
color: var(--faint);
}
/* Status never rides on color alone: the label itself says what blocks. */
.wt-way[data-clear] {
color: var(--faint);
}
.wt-way:not([data-clear]) {
color: var(--warn);
}
.wt-row .wt-reclaim-btn {
margin-left: auto;
}
.wt-row .wt-reclaim-btn::before {
content: "[";
color: var(--danger);
}
.wt-row .wt-reclaim-btn::after {
content: "]";
color: var(--danger);
}
.wt-row .wt-reclaim-btn:hover {
color: var(--danger);
background: var(--danger-tint);
}
.btn-danger {
color: var(--danger);
font-weight: 600;
}
.btn-danger:hover {
background: var(--danger-tint);
}
/* The destructive confirmation, centered over the section. */
.wt-confirm {
position: fixed;
inset: 0;
z-index: var(--z-backdrop);
display: flex;
align-items: center;
justify-content: center;
background: oklch(0 0 0 / 0.5);
padding: var(--sp-4);
}
.wt-confirm[hidden] {
display: none;
}
.wt-confirm-panel {
pointer-events: auto;
background: var(--bg);
border: 1px solid var(--surface-2);
padding: var(--sp-4);
max-width: 480px;
font-family: var(--font-sans);
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.wt-confirm-q {
margin: 0;
font-family: var(--font-mono);
font-weight: 600;
color: var(--ink);
}
.wt-confirm-way {
margin: 0;
font-family: var(--font-mono);
font-size: var(--fs-13);
color: var(--warn);
}
.wt-confirm-note {
margin: 0;
font-size: var(--fs-13);
color: var(--muted);
}
.wt-confirm-actions {
display: flex;
gap: var(--sp-2);
justify-content: flex-end;
margin-top: var(--sp-2);
}
-23
View File
@@ -157,10 +157,6 @@ export interface BranchInfo {
/** `GET /api/branches?path=` — the local branches of one repository (#55). */ /** `GET /api/branches?path=` — the local branches of one repository (#55). */
export type Branches = BranchInfo[]; export type Branches = BranchInfo[];
/** Why a retained worktree outlived its session (#80). New values are
* additive; unknown reasons render generically. */
export type RetainedReason = "force-killed" | "exited-with-unsaved-work";
/** What currently blocks an unforced kill of a worktree's session (#80), /** What currently blocks an unforced kill of a worktree's session (#80),
* recomputed by the daemon on every read. All counts default to 0 on the * recomputed by the daemon on every read. All counts default to 0 on the
* wire for older daemons. */ * wire for older daemons. */
@@ -170,19 +166,6 @@ export interface InTheWay {
unpushed_commits?: number; unpushed_commits?: number;
} }
/** One entry of `GET /api/worktrees` (#80): a retained worktree with its
* in-the-way state as of this read. Reclaiming it removes the directory but
* never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */
export interface RetainedWorktree {
id: Uuid;
path: string;
base_repo: string;
branch: string;
reason: RetainedReason;
retained_at: IsoTimestamp;
in_the_way?: InTheWay;
}
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by /** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
* the kill refusal, the TUI and this client, so no two clients can describe * the kill refusal, the TUI and this client, so no two clients can describe
* the same tree differently. Zero parts are omitted; all-zero renders * the same tree differently. Zero parts are omitted; all-zero renders
@@ -199,12 +182,6 @@ export function formatInTheWay(w: InTheWay): string {
return parts.length > 0 ? parts.join(", ") : "no unsaved work"; return parts.length > 0 ? parts.join(", ") : "no unsaved work";
} }
/** Same reason labels the CLI table and the TUI overlay use; unknown future
* reasons render their raw wire value. */
export function reasonLabel(reason: RetainedReason): string {
return reason;
}
/** #83: this session's blocked-work breakdown when something actually blocks /** #83: this session's blocked-work breakdown when something actually blocks
* an unforced kill; `null` while clear or not yet probed. Render it with * an unforced kill; `null` while clear or not yet probed. Render it with
* {@link formatInTheWay} so every client phrases it identically. */ * {@link formatInTheWay} so every client phrases it identically. */
-114
View File
@@ -1,114 +0,0 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { createWorktreesView } from "./worktreesView.js";
import type { RetainedWorktree } from "./types.js";
function worktree(overrides: Partial<RetainedWorktree>): RetainedWorktree {
return {
id: "11111111-2222-3333-4444-555555555555",
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,
},
...overrides,
};
}
/** Mount a view over a scripted store and return (el, reclaimed-ids). */
function mount(items: RetainedWorktree[], homeDir = "/home/naps") {
const reclaimed: string[] = [];
const view = createWorktreesView({
load: async () => items,
reclaim: async (id) => {
reclaimed.push(id);
items.splice(
items.findIndex((w) => w.id === id),
1,
);
},
homeDir: () => homeDir,
});
document.body.appendChild(view.el);
return { view, reclaimed };
}
describe("retained-worktrees view (#84)", () => {
it("lists branch, abbreviated path, reason and what is in the way", async () => {
const { view } = mount([
worktree({
path: "/home/naps/tea/maestro/worktrees/feat/login",
}),
]);
await view.refresh();
const text = view.el.textContent ?? "";
expect(text).toContain("feat/login");
// Sidebar-style abbreviation of the path against the daemon's home dir,
// full path kept in the tooltip.
expect(text).toContain("t/m/w/f/login");
const pathEl = view.el.querySelector<HTMLElement>(".wt-path")!;
expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login");
// Same reason labels as `maestro worktrees list` and the TUI overlay.
expect(text).toContain("force-killed");
// Same phrasing as InTheWay's Display impl — shared with the kill refusal.
expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits");
});
it("says a tree whose work has since been pushed is clear", async () => {
const { view } = mount([worktree({ in_the_way: {} })]);
await view.refresh();
expect(view.el.textContent).toContain("no unsaved work");
});
it("renders an empty store as a normal state, not an error", async () => {
const { view } = mount([]);
await view.refresh();
const text = view.el.textContent ?? "";
expect(text).toContain("no retained worktrees");
expect(text.toLowerCase()).not.toContain("error");
expect(text.toLowerCase()).not.toContain("failed");
});
it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => {
const wt = worktree({ branch: "feat/login" });
const { view, reclaimed } = mount([wt]);
await view.refresh();
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
const confirmText = view.el.querySelector<HTMLElement>(".wt-confirm")!.textContent ?? "";
expect(confirmText).toContain("Reclaim worktree feat/login?");
// Names what will be lost before it happens…
expect(confirmText).toContain(
"2 uncommitted files, 1 untracked file, 3 unpushed commits",
);
// …and says reclaim does not take the commits too.
expect(confirmText).toContain("never deleted");
view.el.querySelector<HTMLButtonElement>(".wt-confirm-cancel")!.click();
expect(reclaimed).toEqual([]);
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
view.el.querySelector<HTMLButtonElement>(".wt-confirm-go")!.click();
await Promise.resolve();
await Promise.resolve();
expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]);
// The row is gone once the refresh lands.
expect(view.el.textContent).toContain("no retained worktrees");
});
it("sorts oldest-retained first, like every other client", async () => {
const { view } = mount([
worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }),
worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }),
]);
await view.refresh();
const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent);
expect(branches).toEqual(["older", "newer"]);
});
});
-166
View File
@@ -1,166 +0,0 @@
// Retained-worktrees view (#84): every worktree the daemon is holding that no
// session uses, listed from `GET /api/worktrees` with a reclaim action.
//
// Wording is shared with the CLI (`maestro worktrees list`) and the TUI
// overlay: same reason labels, same in-the-way phrasing via
// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads
// as what it is — destructive — naming what will be lost before it happens
// and saying out loud that the branch survives (docs/WORKTREES.md).
import { escapeHtml } from "./html.js";
import {
abbreviateCwd,
abbreviateFolderName,
formatInTheWay,
reasonLabel,
type RetainedWorktree,
type Uuid,
} from "./types.js";
export interface WorktreesDeps {
/** `GET /api/worktrees`. */
load(): Promise<RetainedWorktree[]>;
/** `DELETE /api/worktrees/:id`. */
reclaim(id: Uuid): Promise<void>;
/** The daemon's home dir, for `~` abbreviation of paths. */
homeDir(): string | undefined;
}
export interface WorktreesViewHandle {
el: HTMLElement;
/** Re-fetch the store and re-render. Safe to call repeatedly. */
refresh(): Promise<void>;
dispose(): void;
}
/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */
function sorted(items: RetainedWorktree[]): RetainedWorktree[] {
return [...items].sort(
(a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at),
);
}
export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle {
const el = document.createElement("section");
el.className = "worktrees";
el.innerHTML = `
<header class="wt-head">
<h2 class="wt-title">retained worktrees</h2>
<p class="wt-sub">
Kept when a session ended with unsaved work. Reclaiming removes the
directory only; branches are never deleted.
</p>
</header>
<div class="wt-list"></div>
<div class="wt-confirm" role="alertdialog" aria-modal="true" hidden></div>
`;
const listHost = el.querySelector<HTMLElement>(".wt-list")!;
const confirmEl = el.querySelector<HTMLElement>(".wt-confirm")!;
let pendingId: string | null = null;
const render = (items: RetainedWorktree[]) => {
listHost.replaceChildren();
if (items.length === 0) {
// A drained store is the normal state — say so plainly, not like
// something went wrong.
const empty = document.createElement("div");
empty.className = "empty";
empty.innerHTML = `
<div class="empty-glyph" aria-hidden="true">⌂</div>
<div class="empty-title">no retained worktrees</div>
<p class="empty-hint">Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.</p>`;
listHost.appendChild(empty);
return;
}
const home = deps.homeDir();
for (const w of sorted(items)) {
listHost.appendChild(renderRow(w, home));
}
};
const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => {
const row = document.createElement("div");
row.className = "wt-row";
const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work";
row.innerHTML = `
<span class="wt-branch">${escapeHtml(w.branch)}</span>
<span class="wt-path" title="${escapeHtml(w.path)}">${abbreviateFolderName(abbreviateCwd(w.path, home))}</span>
<span class="wt-reason">${escapeHtml(reasonLabel(w.reason))}</span>
<span class="wt-way"${clear ? ' data-clear="true"' : ""}>${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</span>
<button class="btn wt-reclaim-btn" type="button">Reclaim</button>
`;
row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => {
showConfirm(w);
});
return row;
};
const showConfirm = (w: RetainedWorktree) => {
pendingId = w.id;
// Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is
// in the way right now, and that the branch survives reclaim.
confirmEl.innerHTML = `
<div class="wt-confirm-panel">
<p class="wt-confirm-q">Reclaim worktree ${escapeHtml(w.branch)}?</p>
<p class="wt-confirm-way">Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</p>
<p class="wt-confirm-note">The directory is removed; the branch is never deleted.</p>
<div class="wt-confirm-actions">
<button class="btn btn-danger wt-confirm-go" type="button">Reclaim</button>
<button class="btn btn-ghost wt-confirm-cancel" type="button">Cancel</button>
</div>
</div>`;
confirmEl.hidden = false;
confirmEl.querySelector<HTMLButtonElement>(".wt-confirm-go")!.focus();
};
const hideConfirm = () => {
pendingId = null;
confirmEl.hidden = true;
confirmEl.replaceChildren();
};
confirmEl.addEventListener("click", async (e) => {
const target = e.target as HTMLElement;
if (target.closest(".wt-confirm-cancel")) {
hideConfirm();
return;
}
if (!target.closest(".wt-confirm-go") || !pendingId) return;
const id = pendingId;
hideConfirm();
try {
await deps.reclaim(id);
} catch (err) {
alert(`Failed to reclaim: ${(err as Error).message}`);
}
await refresh();
});
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !confirmEl.hidden) hideConfirm();
};
document.addEventListener("keydown", onKey);
const refresh = async () => {
try {
render(await deps.load());
} catch (err) {
listHost.replaceChildren();
const fail = document.createElement("div");
fail.className = "empty";
fail.innerHTML = `<div class="empty-title">Couldn't load retained worktrees</div><p class="empty-hint"></p>`;
fail.querySelector(".empty-hint")!.textContent = (err as Error).message;
listHost.appendChild(fail);
}
};
return {
el,
refresh,
dispose() {
document.removeEventListener("keydown", onKey);
el.remove();
},
};
}