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.
205 lines
6.4 KiB
Rust
205 lines
6.4 KiB
Rust
//! `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");
|
|
}
|
|
}
|