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..61d4a24 100644 --- a/src/commands/worktree/list.rs +++ b/src/commands/worktree/list.rs @@ -11,7 +11,8 @@ use meta_git_lib::worktree::types::*; use super::cli_types::ListArgs; -pub(crate) fn handle_list(_args: ListArgs, _verbose: bool, json: bool) -> Result<()> { +/// 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())?; @@ -32,36 +33,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 +93,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 +101,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..e74903b 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") { @@ -101,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, @@ -168,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); @@ -206,6 +213,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..62998d8 100644 --- a/src/commands/worktree/status.rs +++ b/src/commands/worktree/status.rs @@ -3,27 +3,43 @@ 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; +/// 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; - 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 +49,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..a871050 --- /dev/null +++ b/tests/recursive_inventory.rs @@ -0,0 +1,159 @@ +use serde_json::{json, Value}; +use std::{ + fs, + io::Write, + path::Path, + 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") + .arg(path) + .args(args) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// 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"] + } 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] +/// 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(); + 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" + ); + } +} + +/// 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()); + } +}