feat(maestro): worktrees list / reclaim CLI (#86)
maestro worktrees list mirrors the sessions table shape: hostname header, home-abbreviated paths, and the in-the-way breakdown recomputed by the daemon at read time. reclaim takes a required explicit ID — no reclaim-everything — and its confirmation says the branch survives.
This commit is contained in:
@@ -41,6 +41,36 @@ pub enum Command {
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// List retained worktrees, or reclaim one by ID (#86).
|
||||
Worktrees(WorktreesArgs),
|
||||
}
|
||||
|
||||
/// `maestro worktrees <list|reclaim>`
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct WorktreesArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: WorktreesCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum WorktreesCommand {
|
||||
/// List retained worktrees and what is in the way of reclaiming each.
|
||||
List(ListWorktreesArgs),
|
||||
/// Reclaim one retained worktree: removes the directory, never the branch.
|
||||
Reclaim(ReclaimArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ListWorktreesArgs {
|
||||
/// Output as JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ReclaimArgs {
|
||||
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
@@ -241,4 +271,56 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("KEY=VALUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktrees_list() {
|
||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
|
||||
match cli.command {
|
||||
Command::Worktrees(w) => match w.command {
|
||||
WorktreesCommand::List(a) => assert!(a.json),
|
||||
_ => panic!("wrong worktrees subcommand"),
|
||||
},
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktrees_reclaim() {
|
||||
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
|
||||
match cli.command {
|
||||
Command::Worktrees(w) => match w.command {
|
||||
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
|
||||
_ => panic!("wrong worktrees subcommand"),
|
||||
},
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract rule: reclaim refuses to run without an explicit target.
|
||||
/// There is no reclaim-everything convenience.
|
||||
#[test]
|
||||
fn reclaim_requires_an_explicit_target() {
|
||||
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::InvalidSubcommand
|
||||
| clap::error::ErrorKind::MissingRequiredArgument
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_without_subcommand_shows_help() {
|
||||
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ use std::path::{Path, PathBuf};
|
||||
use maestro_protocol::DaemonClient;
|
||||
|
||||
use crate::attach::{parse_detach_key, run_attach_url};
|
||||
use crate::cli::Command;
|
||||
use crate::cli::{Command, WorktreesCommand};
|
||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
||||
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
|
||||
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -64,6 +65,26 @@ pub async fn dispatch(
|
||||
run_kill(&daemon, &args).await?;
|
||||
println!("killed {}", args.session_id);
|
||||
}
|
||||
Command::Worktrees(args) => match args.command {
|
||||
WorktreesCommand::List(list_args) => {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
|
||||
}
|
||||
WorktreesCommand::Reclaim(reclaim_args) => {
|
||||
// The daemon removes the directory and the store entry; the
|
||||
// branch is never deleted — worth saying out loud (#86).
|
||||
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
|
||||
match entry {
|
||||
Some(wt) => println!(
|
||||
"reclaimed {} ({})\nbranch '{}' kept",
|
||||
wt.id,
|
||||
wt.path.display(),
|
||||
wt.branch
|
||||
),
|
||||
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ pub mod attach;
|
||||
pub mod cli;
|
||||
pub mod dispatch;
|
||||
pub mod sessions;
|
||||
pub mod worktrees;
|
||||
|
||||
pub use dispatch::{dispatch, CliError};
|
||||
|
||||
@@ -122,7 +122,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
|
||||
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
||||
/// UI's abbreviation so CLI and browser show the same directory shorthand.
|
||||
fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
||||
pub(crate) fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
||||
let p = path.to_string_lossy();
|
||||
if !home.is_empty() {
|
||||
if p == home {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
|
||||
//!
|
||||
//! `reclaim` removes one directory; it never deletes the branch, which is
|
||||
//! said out loud in the output because "reclaim" otherwise sounds like it
|
||||
//! takes the commits too.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
||||
|
||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
||||
use crate::sessions::abbreviate_dir;
|
||||
|
||||
/// `maestro worktrees list`
|
||||
pub async fn run_list(
|
||||
client: &DaemonClient,
|
||||
args: &ListWorktreesArgs,
|
||||
out: &mut impl Write,
|
||||
) -> Result<(), ClientError> {
|
||||
let worktrees = client.list_worktrees().await?;
|
||||
let info = client.daemon_info().await.unwrap_or_default();
|
||||
|
||||
if args.json {
|
||||
let body = serde_json::json!({ "worktrees": worktrees });
|
||||
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
print_worktree_table(out, &worktrees, &info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `maestro worktrees reclaim <id>`
|
||||
///
|
||||
/// Returns the entry as listed just before the reclaim, when it was found in
|
||||
/// the listing, so the confirmation can name what was removed.
|
||||
pub async fn run_reclaim(
|
||||
client: &DaemonClient,
|
||||
args: &ReclaimArgs,
|
||||
) -> Result<Option<RetainedWorktree>, ClientError> {
|
||||
let listed = client
|
||||
.list_worktrees()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|w| w.id == args.id);
|
||||
client.reclaim_worktree(args.id).await?;
|
||||
Ok(listed)
|
||||
}
|
||||
|
||||
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
|
||||
if worktrees.is_empty() {
|
||||
writeln!(out, "no retained worktrees").ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
|
||||
sorted.sort_by_key(|w| w.retained_at);
|
||||
|
||||
if !info.hostname.is_empty() {
|
||||
let warn = if maestro_protocol::is_compatible(&info.version) {
|
||||
""
|
||||
} else {
|
||||
" (protocol mismatch)"
|
||||
};
|
||||
let version = if info.version.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" maestro {}{warn}", info.version)
|
||||
};
|
||||
writeln!(out, "\n{}{version}", info.hostname).ok();
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<24} {:<28} {:<24} IN THE WAY",
|
||||
"ID", "BRANCH", "PATH", "REASON"
|
||||
)
|
||||
.ok();
|
||||
for w in sorted {
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<24} {:<28} {:<24} {}",
|
||||
w.id,
|
||||
w.branch,
|
||||
abbreviate_dir(&w.path, &info.home_dir),
|
||||
reason_label(w.reason),
|
||||
blocker_label(&w.in_the_way),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
// The branch survives every reclaim — see docs/WORKTREES.md.
|
||||
writeln!(
|
||||
out,
|
||||
"\nreclaim removes the directory only; branches are never deleted"
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
|
||||
match reason {
|
||||
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
|
||||
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
|
||||
}
|
||||
}
|
||||
|
||||
/// The same breakdown that blocks an unforced kill, as words: what is still
|
||||
/// in the way, or `clear` when the work has since been committed and pushed.
|
||||
fn blocker_label(in_the_way: &InTheWay) -> String {
|
||||
if in_the_way.is_clear() {
|
||||
return "clear".into();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if in_the_way.uncommitted_files > 0 {
|
||||
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
|
||||
}
|
||||
if in_the_way.untracked_files > 0 {
|
||||
parts.push(format!("{} untracked", in_the_way.untracked_files));
|
||||
}
|
||||
if in_the_way.unpushed_commits > 0 {
|
||||
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
|
||||
RetainedWorktree {
|
||||
id: Uuid::from_u128(id),
|
||||
path: PathBuf::from(path),
|
||||
base_repo: PathBuf::from("/home/naps/maestro"),
|
||||
branch: branch.into(),
|
||||
reason: RetainedReason::ForceKilled,
|
||||
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
||||
in_the_way: InTheWay::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn info(home: &str) -> DaemonInfo {
|
||||
DaemonInfo {
|
||||
hostname: "naps-laptop".into(),
|
||||
home_dir: home.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_table() {
|
||||
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
|
||||
w.in_the_way = InTheWay {
|
||||
uncommitted_files: 2,
|
||||
untracked_files: 1,
|
||||
unpushed_commits: 3,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("naps-laptop"), "{s}");
|
||||
assert!(s.contains("feat/login"), "{s}");
|
||||
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
|
||||
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
|
||||
assert!(s.contains("force-killed"), "{s}");
|
||||
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
|
||||
assert!(s.contains("IN THE WAY"), "{s}");
|
||||
}
|
||||
|
||||
/// Contract rule, visible to the operator: reclaiming a tree must not
|
||||
/// throw away commits on its branch.
|
||||
#[test]
|
||||
fn says_the_branch_survives() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(
|
||||
&mut buf,
|
||||
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
|
||||
&info(""),
|
||||
);
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("never deleted"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_tree_says_clear() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(
|
||||
&mut buf,
|
||||
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
|
||||
&info(""),
|
||||
);
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("clear"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(&mut buf, &[], &info(""));
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user