From 1eb335512db4e2541d43c25c33306f6fd8cfd206 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 25 Sep 2026 12:47:31 -0500 Subject: [PATCH 1/2] feat: support recursive worktree list and status [[tasks/harmony-1932]] --- RECURSIVE_WORKTREES.md | 28 ++++++ src/commands/worktree/cli_types.rs | 9 +- src/commands/worktree/list.rs | 51 +++++++---- src/commands/worktree/mod.rs | 15 ++- src/commands/worktree/status.rs | 35 +++++-- tests/recursive_inventory.rs | 141 +++++++++++++++++++++++++++++ 6 files changed, 250 insertions(+), 29 deletions(-) create mode 100644 RECURSIVE_WORKTREES.md create mode 100644 tests/recursive_inventory.rs diff --git a/RECURSIVE_WORKTREES.md b/RECURSIVE_WORKTREES.md new file mode 100644 index 0000000..aab46ec --- /dev/null +++ b/RECURSIVE_WORKTREES.md @@ -0,0 +1,28 @@ +# Recursive worktree inventory + +```sh +meta git worktree list --recursive --json +meta git worktree status my-feature --recursive --json +``` + +The global `--recursive` / `-r` option now reaches these two commands. +Without it, discovery still stops at the first repository in each subtree. +With it, discovery continues beneath repositories, retaining fully qualified +relative aliases, sorted deterministically. Only actual `.git` files count as +members; configured repositories without a realized checkout are absent. + +For a set containing root, `open-source`, and `open-source/atc`, the default +member aliases remain `[".", "open-source"]`; recursive inventory returns +`[".", "open-source", "open-source/atc"]`. JSON field names and types are unchanged. + +Recursive discovery does not follow directory symlinks or enter hidden +directories. It stops with an error on traversal failures, malformed Git files, +failed Git status observations, or more than 2,000,000 scanned entries per set. +These errors produce a nonzero exit without a partial successful JSON payload. +`list` fails if any scanned set fails; `status NAME` confines the scan to one set. +Build trees are scanned and can add latency. Consumers should bound subprocess +runtime and treat failure as unknown, not an empty or clean inventory. + +This option does not change create, add, remove, prune, exec, diff, or automatic +worktree context detection. It performs no Git mutation. The accompanying +`meta_cli` discovery API must land before this plugin change is built/released. diff --git a/src/commands/worktree/cli_types.rs b/src/commands/worktree/cli_types.rs index 6d01645..03d5097 100644 --- a/src/commands/worktree/cli_types.rs +++ b/src/commands/worktree/cli_types.rs @@ -124,10 +124,17 @@ pub struct DestroyArgs { } #[derive(Args)] -pub struct ListArgs {} +pub struct ListArgs { + /// Include realized repositories nested beneath other worktree repositories + #[arg(long, short = 'r')] + pub recursive: bool, +} #[derive(Args)] pub struct StatusArgs { + /// Include realized repositories nested beneath other worktree repositories + #[arg(long, short = 'r')] + pub recursive: bool, /// Worktree name pub name: String, } diff --git a/src/commands/worktree/list.rs b/src/commands/worktree/list.rs index fce0886..f8f4e2f 100644 --- a/src/commands/worktree/list.rs +++ b/src/commands/worktree/list.rs @@ -11,7 +11,7 @@ use meta_git_lib::worktree::types::*; use super::cli_types::ListArgs; -pub(crate) fn handle_list(_args: ListArgs, _verbose: bool, json: bool) -> Result<()> { +pub(crate) fn handle_list(args: ListArgs, _verbose: bool, json: bool) -> Result<()> { let meta_dir = find_meta_dir(); let worktree_root = resolve_worktree_root(meta_dir.as_deref())?; @@ -32,36 +32,52 @@ pub(crate) fn handle_list(_args: ListArgs, _verbose: bool, json: bool) -> Result let now = Utc::now().timestamp(); // Collect directory entries, then process in parallel - let dir_entries: Vec<_> = std::fs::read_dir(&worktree_root)? - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) - .collect(); + let mut dir_entries = Vec::new(); + for entry in std::fs::read_dir(&worktree_root)? { + if args.recursive { + let entry = entry?; + if entry.file_type()?.is_dir() { + dir_entries.push(entry); + } + } else if let Ok(entry) = entry { + if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { + dir_entries.push(entry); + } + } + } let mut entries: Vec = dir_entries .par_iter() - .filter_map(|entry| { + .map(|entry| -> Result> { let name = entry.file_name().to_string_lossy().to_string(); let wt_dir = entry.path(); - let repos = discover_worktree_repos(&wt_dir).unwrap_or_default(); + let repos = if args.recursive { + meta_cli::worktree::discover_worktree_repos_recursive(&wt_dir)? + } else { + discover_worktree_repos(&wt_dir).unwrap_or_default() + }; if repos.is_empty() { - return None; // Not a valid worktree set + return Ok(None); // Not a valid worktree set } let has_meta_root = repos.iter().any(|r| r.alias == "."); let repo_entries: Vec = repos .par_iter() .map(|r| { - let dirty = git_status_summary(&r.path) - .map(|s| s.dirty) - .unwrap_or(false); - ListRepoEntry { + let status = git_status_summary(&r.path); + let dirty = if args.recursive { + status?.dirty + } else { + status.map(|s| s.dirty).unwrap_or(false) + }; + Ok(ListRepoEntry { alias: r.alias.clone(), branch: r.branch.clone(), dirty, - } + }) }) - .collect(); + .collect::>>()?; // Merge store metadata if available let wt_key = wt_dir.to_string_lossy().to_string(); @@ -76,7 +92,7 @@ pub(crate) fn handle_list(_args: ListArgs, _verbose: bool, json: bool) -> Result (None, None, None) }; - Some(ListEntry { + Ok(Some(ListEntry { name, root: wt_dir.display().to_string(), has_meta_root, @@ -84,8 +100,11 @@ pub(crate) fn handle_list(_args: ListArgs, _verbose: bool, json: bool) -> Result ephemeral, ttl_remaining_seconds: ttl_remaining, custom, - }) + })) }) + .collect::>>()? + .into_iter() + .flatten() .collect(); // Sort by name for deterministic output diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index 49be9e9..9924c1d 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -59,9 +59,14 @@ pub fn execute_worktree_command( // Inject --recursive if the protocol option was set but the flag wasn't in the // command args (the host strips global flags like --recursive before forwarding). - // Only inject for `create` — it's the only subcommand that declares this flag. + // Inject only for subcommands that explicitly support recursive behavior. let is_create = matches!(clap_args.first().map(String::as_str), Some("create")); - if recursive && is_create && !clap_args.iter().any(|a| a == "--recursive" || a == "-r") { + let supports_recursive = matches!( + clap_args.first().map(String::as_str), + Some("create" | "list" | "status") + ); + if recursive && supports_recursive && !clap_args.iter().any(|a| a == "--recursive" || a == "-r") + { clap_args.push("--recursive".to_string()); } if dry_run && is_create && !clap_args.iter().any(|a| a == "--dry-run") { @@ -206,6 +211,12 @@ fn write_worktree_help(w: &mut dyn std::io::Write) { ); let _ = writeln!(w, " --meta Store custom metadata"); let _ = writeln!(w); + let _ = writeln!(w, "LIST / STATUS OPTIONS:"); + let _ = writeln!( + w, + " -r, --recursive Include repositories nested beneath worktree repos" + ); + let _ = writeln!(w); let _ = writeln!(w, "REMOVE OPTIONS:"); let _ = writeln!( w, diff --git a/src/commands/worktree/status.rs b/src/commands/worktree/status.rs index 9cfb27c..41ae966 100644 --- a/src/commands/worktree/status.rs +++ b/src/commands/worktree/status.rs @@ -3,7 +3,9 @@ use colored::*; use rayon::prelude::*; use meta_git_lib::worktree::git_ops::*; -use meta_git_lib::worktree::helpers::discover_and_validate_worktree; +use meta_git_lib::worktree::helpers::{ + discover_and_validate_worktree, resolve_existing_worktree, validate_worktree_name, +}; use meta_git_lib::worktree::types::*; use super::cli_types::StatusArgs; @@ -11,19 +13,32 @@ use super::cli_types::StatusArgs; pub(crate) fn handle_status(args: StatusArgs, _verbose: bool, json: bool) -> Result<()> { let name = &args.name; - let repos = discover_and_validate_worktree(name)?; + let repos = if args.recursive { + validate_worktree_name(name)?; + let ctx = resolve_existing_worktree(name)?; + let repos = meta_cli::worktree::discover_worktree_repos_recursive(&ctx.wt_dir)?; + anyhow::ensure!(!repos.is_empty(), "No repos found in worktree '{name}'"); + repos + } else { + discover_and_validate_worktree(name)? + }; let statuses: Vec = repos .par_iter() .map(|r| { - let summary = git_status_summary(&r.path).unwrap_or(GitStatusSummary { - dirty: false, - modified_files: vec![], - untracked_count: 0, - }); + let observed = git_status_summary(&r.path); + let summary = if args.recursive { + observed? + } else { + observed.unwrap_or(GitStatusSummary { + dirty: false, + modified_files: vec![], + untracked_count: 0, + }) + }; let (ahead, behind) = git_ahead_behind(&r.path).unwrap_or((0, 0)); - StatusRepoEntry { + Ok(StatusRepoEntry { alias: r.alias.clone(), path: r.path.display().to_string(), branch: r.branch.clone(), @@ -33,9 +48,9 @@ pub(crate) fn handle_status(args: StatusArgs, _verbose: bool, json: bool) -> Res ahead, behind, modified_files: summary.modified_files, - } + }) }) - .collect(); + .collect::>>()?; if json { let output = StatusOutput { diff --git a/tests/recursive_inventory.rs b/tests/recursive_inventory.rs new file mode 100644 index 0000000..4843af5 --- /dev/null +++ b/tests/recursive_inventory.rs @@ -0,0 +1,141 @@ +use serde_json::{json, Value}; +use std::{ + fs, + io::Write, + path::Path, + process::{Command, Stdio}, +}; + +fn git(path: &Path, args: &[&str]) { + let out = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn request(root: &Path, command: &str, recursive: bool, explicit: bool) -> std::process::Output { + let mut args = if command == "status" { + vec!["example"] + } else { + vec![] + }; + if explicit { + args.push("--recursive"); + } + let request = json!({"command": format!("git worktree {command}"), "args": args, + "cwd": root, "options": {"json_output": true, "recursive": recursive}}); + let mut child = Command::new(env!("CARGO_BIN_EXE_meta-git")) + .arg("--meta-plugin-exec") + .current_dir(root) + .env("META_WORKTREES", root.join("sets")) + .env("META_DATA_DIR", root.join("data")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(request.to_string().as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() +} + +#[test] +fn public_list_status_recursive_protocol_and_explicit_flags() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let set = root.join("sets/example"); + for (index, alias) in [".", "open-source", "open-source/atc", "other/atc"] + .iter() + .enumerate() + { + let source = root.join(format!("source{index}")); + fs::create_dir(&source).unwrap(); + git(&source, &["init", "-q"]); + git( + &source, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "--allow-empty", + "-qm", + "initial", + ], + ); + let destination = set.join(alias); + fs::create_dir_all(destination.parent().unwrap()).unwrap(); + git( + &source, + &[ + "worktree", + "add", + "-qb", + "topic", + destination.to_str().unwrap(), + ], + ); + } + fs::write(set.join("open-source/atc/untracked.txt"), "dirty").unwrap(); + for command in ["list", "status"] { + for (recursive, explicit) in [(false, false), (true, false), (false, true), (true, true)] { + let out = request(root, command, recursive, explicit); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + let rows = if command == "list" { + &value["worktrees"][0]["repos"] + } else { + &value["repos"] + }; + let aliases: Vec<_> = rows + .as_array() + .unwrap() + .iter() + .map(|row| row["alias"].as_str().unwrap().replace('\\', "/")) + .collect(); + if recursive || explicit { + assert_eq!( + aliases, + [".", "open-source", "open-source/atc", "other/atc"] + ); + let nested = rows + .as_array() + .unwrap() + .iter() + .find(|row| { + row["alias"].as_str().unwrap().replace('\\', "/") == "open-source/atc" + }) + .unwrap(); + assert_eq!(nested["branch"], "topic"); + assert_eq!(nested["dirty"], true); + } else { + assert_eq!(aliases, [".", "open-source", "other/atc"]); + } + } + } + fs::write(set.join("open-source/atc/.git"), "broken").unwrap(); + for command in ["list", "status"] { + let out = request(root, command, true, false); + assert!(!out.status.success()); + assert!( + out.stdout.is_empty(), + "must not emit a partial successful inventory" + ); + } +} From b5a0dd14c3ce09092b2002441beeb2a84a7b867d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 25 Sep 2026 13:41:19 -0500 Subject: [PATCH 2/2] test: document inventory behavior and cover missing roots [[tasks/harmony-1932]] --- src/commands/worktree/list.rs | 1 + src/commands/worktree/mod.rs | 2 ++ src/commands/worktree/status.rs | 1 + tests/recursive_inventory.rs | 18 ++++++++++++++++++ 4 files changed, 22 insertions(+) diff --git a/src/commands/worktree/list.rs b/src/commands/worktree/list.rs index f8f4e2f..61d4a24 100644 --- a/src/commands/worktree/list.rs +++ b/src/commands/worktree/list.rs @@ -11,6 +11,7 @@ use meta_git_lib::worktree::types::*; use super::cli_types::ListArgs; +/// Render worktree sets, failing recursive inventory atomically on observation errors. pub(crate) fn handle_list(args: ListArgs, _verbose: bool, json: bool) -> Result<()> { let meta_dir = find_meta_dir(); let worktree_root = resolve_worktree_root(meta_dir.as_deref())?; diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index 9924c1d..e74903b 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -106,6 +106,7 @@ pub fn execute_worktree_command( } } +/// Dispatch parsed worktree commands while retaining command-specific policies. fn handle_worktree_command( command: WorktreeCommands, verbose: bool, @@ -173,6 +174,7 @@ pub fn eprint_worktree_help() { write_worktree_help(&mut std::io::stderr()); } +/// Write top-level help including recursive inventory usage. fn write_worktree_help(w: &mut dyn std::io::Write) { let _ = writeln!(w, "Manage git worktrees across repos"); let _ = writeln!(w); diff --git a/src/commands/worktree/status.rs b/src/commands/worktree/status.rs index 41ae966..62998d8 100644 --- a/src/commands/worktree/status.rs +++ b/src/commands/worktree/status.rs @@ -10,6 +10,7 @@ use meta_git_lib::worktree::types::*; use super::cli_types::StatusArgs; +/// Render a named set, preserving legacy defaults unless recursive is requested. pub(crate) fn handle_status(args: StatusArgs, _verbose: bool, json: bool) -> Result<()> { let name = &args.name; diff --git a/tests/recursive_inventory.rs b/tests/recursive_inventory.rs index 4843af5..a871050 100644 --- a/tests/recursive_inventory.rs +++ b/tests/recursive_inventory.rs @@ -6,6 +6,7 @@ use std::{ process::{Command, Stdio}, }; +/// Run a Git fixture command and surface diagnostics on failure. fn git(path: &Path, args: &[&str]) { let out = Command::new("git") .arg("-C") @@ -20,6 +21,7 @@ fn git(path: &Path, args: &[&str]) { ); } +/// Invoke the public plugin protocol with isolated worktree and data roots. fn request(root: &Path, command: &str, recursive: bool, explicit: bool) -> std::process::Output { let mut args = if command == "status" { vec!["example"] @@ -51,6 +53,7 @@ fn request(root: &Path, command: &str, recursive: bool, explicit: bool) -> std:: } #[test] +/// Verify real nested members, option forwarding and atomic error output. fn public_list_status_recursive_protocol_and_explicit_flags() { let temp = tempfile::tempdir().unwrap(); let root = temp.path(); @@ -139,3 +142,18 @@ fn public_list_status_recursive_protocol_and_explicit_flags() { ); } } + +/// A missing configured root is empty for list and an error for named status. +#[test] +fn missing_worktree_root_preserves_list_and_status_contracts() { + let temp = tempfile::tempdir().unwrap(); + for recursive in [false, true] { + let out = request(temp.path(), "list", recursive, false); + assert!(out.status.success()); + let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(value, json!({"worktrees": []})); + let out = request(temp.path(), "status", recursive, false); + assert!(!out.status.success()); + assert!(out.stdout.is_empty()); + } +}