//! `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"); } }