diff --git a/crates/maestro-cli/src/cli.rs b/crates/maestro-cli/src/cli.rs index e34ffbb..6c8fdc4 100644 --- a/crates/maestro-cli/src/cli.rs +++ b/crates/maestro-cli/src/cli.rs @@ -41,6 +41,36 @@ pub enum Command { Attach(AttachArgs), /// Kill a session by ID. Kill(KillArgs), + /// List retained worktrees, or reclaim one by ID (#86). + Worktrees(WorktreesArgs), +} + +/// `maestro worktrees ` +#[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)] @@ -272,4 +302,56 @@ mod tests { .unwrap_err(); assert!(err.to_string().contains("KEY=VALUE")); } + + #[test] + fn parses_worktrees_list() { + let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap(); + match cli.command { + Command::Worktrees(w) => match w.command { + WorktreesCommand::List(a) => assert!(a.json), + _ => panic!("wrong worktrees subcommand"), + }, + _ => panic!("wrong subcommand"), + } + } + + #[test] + fn parses_worktrees_reclaim() { + let id = "01234567-89ab-cdef-0123-456789abcdef"; + let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap(); + match cli.command { + Command::Worktrees(w) => match w.command { + WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id), + _ => panic!("wrong worktrees subcommand"), + }, + _ => panic!("wrong subcommand"), + } + } + + /// Contract rule: reclaim refuses to run without an explicit target. + /// There is no reclaim-everything convenience. + #[test] + fn reclaim_requires_an_explicit_target() { + let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err(); + assert!( + matches!( + err.kind(), + clap::error::ErrorKind::InvalidSubcommand + | clap::error::ErrorKind::MissingRequiredArgument + ), + "{err}" + ); + } + + #[test] + fn worktrees_without_subcommand_shows_help() { + let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err(); + assert!( + matches!( + err.kind(), + clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + ), + "{err}" + ); + } } diff --git a/crates/maestro-cli/src/dispatch.rs b/crates/maestro-cli/src/dispatch.rs index f96675e..9653629 100644 --- a/crates/maestro-cli/src/dispatch.rs +++ b/crates/maestro-cli/src/dispatch.rs @@ -9,8 +9,9 @@ use std::path::{Path, PathBuf}; use maestro_protocol::DaemonClient; use crate::attach::{parse_detach_key, run_attach_url}; -use crate::cli::Command; +use crate::cli::{Command, WorktreesCommand}; use crate::sessions::{run_kill, run_list, run_spawn}; +use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim}; use maestro_config::{default_config_path, MaestroConfigFile}; #[derive(Debug, thiserror::Error)] @@ -71,6 +72,26 @@ pub async fn dispatch( ); } } + 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(()) } diff --git a/crates/maestro-cli/src/lib.rs b/crates/maestro-cli/src/lib.rs index a6a9cc9..ce3599d 100644 --- a/crates/maestro-cli/src/lib.rs +++ b/crates/maestro-cli/src/lib.rs @@ -6,5 +6,6 @@ pub mod attach; pub mod cli; pub mod dispatch; pub mod sessions; +pub mod worktrees; pub use dispatch::{dispatch, CliError}; diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 64db192..dd1b38f 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -127,7 +127,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon /// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web /// UI's abbreviation so CLI and browser show the same directory shorthand. -fn abbreviate_dir(path: &std::path::Path, home: &str) -> String { +pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String { let p = path.to_string_lossy(); if !home.is_empty() { if p == home { diff --git a/crates/maestro-cli/src/worktrees.rs b/crates/maestro-cli/src/worktrees.rs new file mode 100644 index 0000000..c00237c --- /dev/null +++ b/crates/maestro-cli/src/worktrees.rs @@ -0,0 +1,204 @@ +//! `maestro worktrees list / reclaim` — the retained-store surface (#86). +//! +//! `reclaim` removes one directory; it never deletes the branch, which is +//! said out loud in the output because "reclaim" otherwise sounds like it +//! takes the commits too. + +use std::io::Write; + +use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree}; + +use crate::cli::{ListWorktreesArgs, ReclaimArgs}; +use crate::sessions::abbreviate_dir; + +/// `maestro worktrees list` +pub async fn run_list( + client: &DaemonClient, + args: &ListWorktreesArgs, + out: &mut impl Write, +) -> Result<(), ClientError> { + let worktrees = client.list_worktrees().await?; + let info = client.daemon_info().await.unwrap_or_default(); + + if args.json { + let body = serde_json::json!({ "worktrees": worktrees }); + writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok(); + return Ok(()); + } + + print_worktree_table(out, &worktrees, &info); + Ok(()) +} + +/// `maestro worktrees reclaim ` +/// +/// 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, 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 = 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"); + } +} diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index c4bfce4..61bcffd 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -16,7 +16,7 @@ use uuid::Uuid; use crate::routes::{self, UrlError}; use crate::{ ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, - KillOutcome, Project, RecentDirs, Session, + KillOutcome, Project, RecentDirs, RetainedWorktree, Session, }; /// Time budget for establishing a TCP connection to the daemon. @@ -160,6 +160,23 @@ impl DaemonClient { .await } + /// `GET /api/worktrees` (#80): every retained worktree with its in-the-way + /// breakdown as of this read. + pub async fn list_worktrees(&self) -> Result, ClientError> { + self.get(routes::WORKTREES, &[]).await + } + + /// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The + /// daemon removes the directory and drops the store entry; the branch is + /// never deleted. + pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> { + let resp = self + .send(Method::DELETE, &routes::worktree(id), &[], None::<&()>) + .await?; + check(resp).await?; + Ok(()) + } + // -- plumbing ----------------------------------------------------------- async fn get(