From 47771a521c5e28c7352010e837ae1c713a5949eb Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 22 Oct 2026 13:08:37 -0500 Subject: [PATCH 1/5] feat: expose membership-only worktree discovery Implements [[tasks/harmony-2613]] --- src/commands/worktree/cli_types.rs | 11 +++++ src/commands/worktree/members.rs | 38 ++++++++++++++++ src/commands/worktree/mod.rs | 14 +++++- src/main.rs | 1 + tests/recursive_inventory.rs | 73 +++++++++++++++++++++++++++++- 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 src/commands/worktree/members.rs diff --git a/src/commands/worktree/cli_types.rs b/src/commands/worktree/cli_types.rs index 03d5097..0ad5310 100644 --- a/src/commands/worktree/cli_types.rs +++ b/src/commands/worktree/cli_types.rs @@ -21,6 +21,8 @@ pub enum WorktreeCommands { List(ListArgs), /// Show detailed status of a worktree set Status(StatusArgs), + /// List realized membership without Git status or branch probes + Members(MembersArgs), /// Show cross-repo diff vs base branch Diff(DiffArgs), /// Run a command across worktree repos @@ -139,6 +141,15 @@ pub struct StatusArgs { pub name: String, } +#[derive(Args)] +pub struct MembersArgs { + /// Include realized repositories nested beneath other worktree repositories + #[arg(long, short = 'r', required = true)] + pub recursive: bool, + /// Worktree name + pub name: String, +} + #[derive(Args)] pub struct DiffArgs { /// Worktree name diff --git a/src/commands/worktree/members.rs b/src/commands/worktree/members.rs new file mode 100644 index 0000000..b104411 --- /dev/null +++ b/src/commands/worktree/members.rs @@ -0,0 +1,38 @@ +//! Membership-only public projection for background workspace discovery. +use anyhow::Result; +use meta_cli::worktree::{discover_worktree_members_recursive, WorktreeMemberInfo}; +use meta_git_lib::worktree::helpers::{resolve_existing_worktree, validate_worktree_name}; +use serde::Serialize; + +use super::cli_types::MembersArgs; + +#[derive(Serialize)] +struct MembersOutput { + name: String, + repos: Vec, +} + +pub(crate) fn handle_members(args: MembersArgs, json: bool) -> Result<()> { + validate_worktree_name(&args.name)?; + let ctx = resolve_existing_worktree(&args.name)?; + let repos = discover_worktree_members_recursive(&ctx.wt_dir)?; + anyhow::ensure!( + !repos.is_empty(), + "No repos found in worktree '{}'", + args.name + ); + if json { + println!( + "{}", + serde_json::to_string_pretty(&MembersOutput { + name: args.name, + repos + })? + ); + } else { + for repo in repos { + println!("{} {}", repo.alias, repo.path.display()); + } + } + Ok(()) +} diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index e74903b..2153569 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -6,6 +6,7 @@ mod create; mod diff; mod exec; mod list; +mod members; mod prune; mod remove; mod status; @@ -63,7 +64,7 @@ pub fn execute_worktree_command( let is_create = matches!(clap_args.first().map(String::as_str), Some("create")); let supports_recursive = matches!( clap_args.first().map(String::as_str), - Some("create" | "list" | "status") + Some("create" | "list" | "status" | "members") ); if recursive && supports_recursive && !clap_args.iter().any(|a| a == "--recursive" || a == "-r") { @@ -121,6 +122,7 @@ fn handle_worktree_command( } WorktreeCommands::List(args) => list::handle_list(args, verbose, json), WorktreeCommands::Status(args) => status::handle_status(args, verbose, json), + WorktreeCommands::Members(args) => members::handle_members(args, json), WorktreeCommands::Diff(args) => diff::handle_diff(args, verbose, json), WorktreeCommands::Exec(args) => exec::handle_exec(args, verbose, json), WorktreeCommands::Prune(args) => prune::handle_prune(args, verbose, json, global_strict), @@ -186,6 +188,10 @@ fn write_worktree_help(w: &mut dyn std::io::Write) { let _ = writeln!(w, " remove Remove a worktree set"); let _ = writeln!(w, " list List all worktree sets"); let _ = writeln!(w, " status Show detailed status of a worktree set"); + let _ = writeln!( + w, + " members List realized membership without Git status probes" + ); let _ = writeln!(w, " diff Show cross-repo diff vs base branch"); let _ = writeln!(w, " exec Run a command across worktree repos"); let _ = writeln!(w, " prune Remove expired/orphaned worktrees"); @@ -213,12 +219,16 @@ 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, "LIST / STATUS / MEMBERS OPTIONS:"); let _ = writeln!( w, " -r, --recursive Include repositories nested beneath worktree repos" ); let _ = writeln!(w); + let _ = writeln!( + w, + "MEMBERS requires --recursive and omits branch/status probes." + ); let _ = writeln!(w, "REMOVE OPTIONS:"); let _ = writeln!( w, diff --git a/src/main.rs b/src/main.rs index 6d855d8..77e6c2a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,6 +73,7 @@ fn main() { "git worktree destroy".to_string(), "git worktree list".to_string(), "git worktree status".to_string(), + "git worktree members".to_string(), "git worktree diff".to_string(), "git worktree exec".to_string(), "git worktree prune".to_string(), diff --git a/tests/recursive_inventory.rs b/tests/recursive_inventory.rs index c6dbbd5..fbd8ef9 100644 --- a/tests/recursive_inventory.rs +++ b/tests/recursive_inventory.rs @@ -23,7 +23,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" { + let mut args = if matches!(command, "status" | "members") { vec!["example"] } else { vec![] @@ -33,7 +33,11 @@ fn request(root: &Path, command: &str, recursive: bool, explicit: bool) -> std:: } 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")) + let mut executable = Command::new(env!("CARGO_BIN_EXE_meta-git")); + if root.join("bin").is_dir() { + executable.env("PATH", root.join("bin")); + } + let mut child = executable .arg("--meta-plugin-exec") .current_dir(root) .env("META_WORKTREES", root.join("sets")) @@ -185,3 +189,68 @@ fn missing_worktree_root_preserves_list_and_status_contracts() { assert!(out.stdout.is_empty()); } } + +#[cfg(unix)] +#[test] +fn membership_only_protocol_runs_without_git_and_rejects_partial_membership() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let source = root.join("source"); + 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 set = root.join("sets/example"); + fs::create_dir_all(set.parent().unwrap()).unwrap(); + git( + &source, + &["worktree", "add", "-qb", "topic", set.to_str().unwrap()], + ); + fs::write(set.join("untracked"), "dirty, deliberately unobserved").unwrap(); + let bin = root.join("bin"); + fs::create_dir(&bin).unwrap(); + let fake_git = bin.join("git"); + fs::write( + &fake_git, + "#!/bin/sh\nprintf called >> \"$0.calls\"\nexit 1\n", + ) + .unwrap(); + fs::set_permissions(&fake_git, fs::Permissions::from_mode(0o700)).unwrap(); + for (recursive, explicit) in [(true, false), (false, true)] { + let out = request(&root, "members", recursive, explicit); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(value["name"], "example"); + assert_eq!(value["repos"].as_array().unwrap().len(), 1); + assert_eq!(value["repos"][0]["alias"], "."); + assert_eq!(value["repos"][0]["path"], set.to_str().unwrap()); + assert!(value["repos"][0].get("branch").is_none()); + assert!(value["repos"][0].get("dirty").is_none()); + } + assert!(!bin.join("git.calls").exists(), "membership launched Git"); + fs::create_dir(set.join("broken")).unwrap(); + fs::write(set.join("broken/.git"), "broken").unwrap(); + let out = request(&root, "members", true, false); + assert!(!out.status.success()); + assert!( + out.stdout.is_empty(), + "must not report a partial successful inventory" + ); + assert!(!bin.join("git.calls").exists()); +} From 1a6037f1052cbbf3b75cc67fb1e38bdac2ef6300 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 22 Oct 2026 17:15:29 -0500 Subject: [PATCH 2/5] feat(worktree): infer membership set from current directory Allow members --recursive inside a realized worktree or descendant without repeating its name. Preserve explicit-name precedence and reject missing context without Git probes. Refs [[tasks/harmony-2613]] --- src/commands/worktree/cli_types.rs | 4 +- src/commands/worktree/members.rs | 37 ++++++---- src/commands/worktree/mod.rs | 4 ++ tests/recursive_inventory.rs | 108 +++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 15 deletions(-) diff --git a/src/commands/worktree/cli_types.rs b/src/commands/worktree/cli_types.rs index 0ad5310..57a6c58 100644 --- a/src/commands/worktree/cli_types.rs +++ b/src/commands/worktree/cli_types.rs @@ -146,8 +146,8 @@ pub struct MembersArgs { /// Include realized repositories nested beneath other worktree repositories #[arg(long, short = 'r', required = true)] pub recursive: bool, - /// Worktree name - pub name: String, + /// Worktree name (defaults to the enclosing .worktrees/ set) + pub name: Option, } #[derive(Args)] diff --git a/src/commands/worktree/members.rs b/src/commands/worktree/members.rs index b104411..f99524a 100644 --- a/src/commands/worktree/members.rs +++ b/src/commands/worktree/members.rs @@ -1,6 +1,8 @@ //! Membership-only public projection for background workspace discovery. use anyhow::Result; -use meta_cli::worktree::{discover_worktree_members_recursive, WorktreeMemberInfo}; +use meta_cli::worktree::{ + detect_worktree_context, discover_worktree_members_recursive, WorktreeMemberInfo, +}; use meta_git_lib::worktree::helpers::{resolve_existing_worktree, validate_worktree_name}; use serde::Serialize; @@ -13,21 +15,30 @@ struct MembersOutput { } pub(crate) fn handle_members(args: MembersArgs, json: bool) -> Result<()> { - validate_worktree_name(&args.name)?; - let ctx = resolve_existing_worktree(&args.name)?; - let repos = discover_worktree_members_recursive(&ctx.wt_dir)?; - anyhow::ensure!( - !repos.is_empty(), - "No repos found in worktree '{}'", - args.name - ); + let (name, wt_dir) = match args.name { + Some(name) => { + validate_worktree_name(&name)?; + let ctx = resolve_existing_worktree(&name)?; + (name, ctx.wt_dir) + } + None => { + let cwd = std::env::current_dir()?; + let (name, wt_dir, _) = detect_worktree_context(&cwd).ok_or_else(|| { + anyhow::anyhow!( + "Worktree name required outside a realized .worktrees/ directory; \ + specify NAME or run from inside the worktree" + ) + })?; + validate_worktree_name(&name)?; + (name, wt_dir) + } + }; + let repos = discover_worktree_members_recursive(&wt_dir)?; + anyhow::ensure!(!repos.is_empty(), "No repos found in worktree '{}'", name); if json { println!( "{}", - serde_json::to_string_pretty(&MembersOutput { - name: args.name, - repos - })? + serde_json::to_string_pretty(&MembersOutput { name, repos })? ); } else { for repo in repos { diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index 2153569..b816c09 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -229,6 +229,10 @@ fn write_worktree_help(w: &mut dyn std::io::Write) { w, "MEMBERS requires --recursive and omits branch/status probes." ); + let _ = writeln!( + w, + " NAME defaults to the enclosing .worktrees/ set for MEMBERS." + ); let _ = writeln!(w, "REMOVE OPTIONS:"); let _ = writeln!( w, diff --git a/tests/recursive_inventory.rs b/tests/recursive_inventory.rs index fbd8ef9..d7e7a18 100644 --- a/tests/recursive_inventory.rs +++ b/tests/recursive_inventory.rs @@ -254,3 +254,111 @@ fn membership_only_protocol_runs_without_git_and_rejects_partial_membership() { ); assert!(!bin.join("git.calls").exists()); } + +/// Inference selects the enclosing physical set, including from deep descendants, +/// without consulting a different configured worktree root or launching Git. +#[cfg(unix)] +#[test] +fn membership_infers_enclosing_set_without_git_and_requires_context() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let source = root.join("source"); + 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 set = root.join(".worktrees/example"); + fs::create_dir_all(set.parent().unwrap()).unwrap(); + git( + &source, + &["worktree", "add", "-qb", "topic", set.to_str().unwrap()], + ); + let nested = set.join("deep/directory"); + fs::create_dir_all(&nested).unwrap(); + let other = root.join("configured/other"); + fs::create_dir_all(other.parent().unwrap()).unwrap(); + git( + &source, + &["worktree", "add", "-qb", "other", other.to_str().unwrap()], + ); + let bin = root.join("bin"); + fs::create_dir(&bin).unwrap(); + let fake_git = bin.join("git"); + fs::write( + &fake_git, + "#!/bin/sh\nprintf called >> \"$0.calls\"\nexit 1\n", + ) + .unwrap(); + fs::set_permissions(&fake_git, fs::Permissions::from_mode(0o700)).unwrap(); + let invoke = |cwd: &Path, args: Vec<&str>, recursive: bool| { + let mut child = Command::new(env!("CARGO_BIN_EXE_meta-git")) + .arg("--meta-plugin-exec") + .current_dir(cwd) + .env("PATH", &bin) + .env("META_WORKTREES", root.join("configured")) + .env("META_DATA_DIR", root.join("data")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let request = json!({"command": "git worktree members", "args": args, + "cwd": cwd, "options": {"json_output": true, "recursive": recursive}}); + child + .stdin + .take() + .unwrap() + .write_all(request.to_string().as_bytes()) + .unwrap(); + child.wait_with_output().unwrap() + }; + for cwd in [&set, &nested] { + for (args, recursive) in [(vec![], true), (vec!["--recursive"], false)] { + let out = invoke(cwd, args, recursive); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(value["name"], "example"); + assert_eq!(value["repos"][0]["path"], set.to_str().unwrap()); + } + } + let explicit = invoke(&nested, vec!["other"], true); + assert!(explicit.status.success()); + let value: Value = serde_json::from_slice(&explicit.stdout).unwrap(); + assert_eq!(value["name"], "other"); + assert_eq!(value["repos"][0]["path"], other.to_str().unwrap()); + + let outside = invoke(&source, vec![], true); + assert!(!outside.status.success()); + assert!(outside.stdout.is_empty()); + assert!(String::from_utf8_lossy(&outside.stderr).contains("Worktree name required")); + assert!( + !invoke(&set, vec![], false).status.success(), + "recursive remains required" + ); + + fs::create_dir(set.join("broken")).unwrap(); + fs::write(set.join("broken/.git"), "broken").unwrap(); + let malformed = invoke(&nested, vec![], true); + assert!(!malformed.status.success()); + assert!( + malformed.stdout.is_empty(), + "inference must not mask invalid membership" + ); + assert!(!bin.join("git.calls").exists(), "inference launched Git"); +} From 593a521fcb21d0403877923b24d16f4ba652619c Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 22 Oct 2026 17:59:15 -0500 Subject: [PATCH 3/5] feat(worktree): add contextual metadata-only listing Fold the unreleased members command into list [NAME] --metadata-only. Infer the enclosing set from root or descendant directories; keep all-set listing outside worktrees and selected failures explicit. Refs [[tasks/harmony-2613]] --- src/commands/worktree/cli_types.rs | 20 +- src/commands/worktree/list.rs | 321 +++++++++++++++++------------ src/commands/worktree/members.rs | 49 ----- src/commands/worktree/mod.rs | 15 +- src/main.rs | 1 - tests/recursive_inventory.rs | 117 +++++++++-- 6 files changed, 305 insertions(+), 218 deletions(-) delete mode 100644 src/commands/worktree/members.rs diff --git a/src/commands/worktree/cli_types.rs b/src/commands/worktree/cli_types.rs index 57a6c58..18e8878 100644 --- a/src/commands/worktree/cli_types.rs +++ b/src/commands/worktree/cli_types.rs @@ -17,12 +17,10 @@ pub enum WorktreeCommands { /// Remove a worktree set (alias for remove) #[command(hide = true)] Destroy(DestroyArgs), - /// List all worktree sets + /// List worktree sets (defaults to the current set when inside one) List(ListArgs), /// Show detailed status of a worktree set Status(StatusArgs), - /// List realized membership without Git status or branch probes - Members(MembersArgs), /// Show cross-repo diff vs base branch Diff(DiffArgs), /// Run a command across worktree repos @@ -127,9 +125,16 @@ pub struct DestroyArgs { #[derive(Args)] pub struct ListArgs { + /// Worktree name (defaults to the current set inside .worktrees/) + pub name: Option, + /// Include realized repositories nested beneath other worktree repositories #[arg(long, short = 'r')] pub recursive: bool, + + /// Return membership paths without observing branches or Git status + #[arg(long)] + pub metadata_only: bool, } #[derive(Args)] @@ -141,15 +146,6 @@ pub struct StatusArgs { pub name: String, } -#[derive(Args)] -pub struct MembersArgs { - /// Include realized repositories nested beneath other worktree repositories - #[arg(long, short = 'r', required = true)] - pub recursive: bool, - /// Worktree name (defaults to the enclosing .worktrees/ set) - pub name: Option, -} - #[derive(Args)] pub struct DiffArgs { /// Worktree name diff --git a/src/commands/worktree/list.rs b/src/commands/worktree/list.rs index 2535718..53f4ea4 100644 --- a/src/commands/worktree/list.rs +++ b/src/commands/worktree/list.rs @@ -2,191 +2,256 @@ use anyhow::Result; use chrono::Utc; use colored::*; use rayon::prelude::*; +use serde::Serialize; +use std::collections::HashMap; -use meta_cli::worktree::discover_worktree_repos; +use meta_cli::worktree::{ + detect_worktree_location, discover_worktree_members, discover_worktree_members_recursive, + discover_worktree_repos, discover_worktree_repos_recursive, WorktreeMemberInfo, +}; use meta_git_lib::worktree::git_ops::git_status_summary; use meta_git_lib::worktree::helpers::*; use meta_git_lib::worktree::store::{entry_ttl_remaining, store_list}; -use meta_git_lib::worktree::types::*; +use meta_git_lib::worktree::types::ListRepoEntry; use super::cli_types::ListArgs; -#[derive(serde::Serialize)] +#[derive(Serialize)] struct ListWarning { name: String, root: String, message: String, } -#[derive(serde::Serialize)] -struct ListWithWarnings { - #[serde(flatten)] - listing: ListOutput, +#[derive(Serialize)] +struct ListOutput { + worktrees: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] warnings: Vec, } -/// Render healthy worktree sets and report unavailable sets without aborting the list. -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())?; +#[derive(Serialize)] +struct ListEntry { + name: String, + root: String, + has_meta_root: bool, + repos: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + ephemeral: Option, + #[serde(skip_serializing_if = "Option::is_none")] + ttl_remaining_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + custom: Option>, +} - if !worktree_root.exists() { - if json { - println!( - "{}", - serde_json::to_string_pretty(&ListOutput { worktrees: vec![] })? - ); +/// Keep the existing observed JSON shape; metadata-only deliberately omits Git facts. +#[derive(Serialize)] +#[serde(untagged)] +enum RepoEntry { + Observed(ListRepoEntry), + Metadata(WorktreeMemberInfo), +} + +fn repo_entries(path: &std::path::Path, args: &ListArgs, selected: bool) -> Result> { + if args.metadata_only { + let members = if args.recursive { + discover_worktree_members_recursive(path)? } else { - println!("No worktrees found."); - } - return Ok(()); + discover_worktree_members(path)? + }; + return Ok(members.into_iter().map(RepoEntry::Metadata).collect()); } + let repos = if args.recursive { + discover_worktree_repos_recursive(path)? + } else if selected { + discover_worktree_repos(path)? + } else { + discover_worktree_repos(path).unwrap_or_default() + }; + repos + .par_iter() + .map(|repo| { + let status = git_status_summary(&repo.path); + let dirty = if args.recursive || selected { + status?.dirty + } else { + status.map(|s| s.dirty).unwrap_or(false) + }; + Ok(RepoEntry::Observed(ListRepoEntry { + alias: repo.alias.clone(), + branch: repo.branch.clone(), + dirty, + })) + }) + .collect() +} - // Load store data for metadata enrichment - let store_data = store_list().unwrap_or_default(); - let now = Utc::now().timestamp(); - - // Collect directory entries, then process in parallel - let mut dir_entries = Vec::new(); - for entry in std::fs::read_dir(&worktree_root)? { - if args.recursive { - 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); - } +/// A selected set fails closed. All-set listing retains healthy sets and warnings. +pub(crate) fn handle_list(args: ListArgs, _verbose: bool, json: bool) -> Result<()> { + let location = detect_worktree_location(&std::env::current_dir()?); + let selected = match args.name.as_ref() { + Some(name) => { + validate_worktree_name(name)?; + let path = if std::env::var_os("META_WORKTREES").is_none() { + if let Some((_, current)) = &location { + current + .parent() + .expect("worktree location has a parent") + .join(name) + } else { + resolve_existing_worktree(name)?.wt_dir + } + } else { + resolve_existing_worktree(name)?.wt_dir + }; + anyhow::ensure!( + path.is_dir(), + "Worktree '{name}' not found at {}", + path.display() + ); + Some((name.clone(), path)) } + None => location, + }; + if let Some((name, _)) = &selected { + validate_worktree_name(name)?; } - - let results: Vec>> = dir_entries + let is_selected = selected.is_some(); + let sets = if let Some(selected) = selected { + vec![selected] + } else { + let meta_dir = find_meta_dir(); + let root = resolve_worktree_root(meta_dir.as_deref())?; + let mut sets = Vec::new(); + if root.exists() { + for entry in std::fs::read_dir(root)? { + let entry = match entry { + Ok(entry) => entry, + Err(error) if args.recursive || args.metadata_only => return Err(error.into()), + Err(_) => continue, + }; + match entry.file_type() { + Ok(kind) if kind.is_dir() => sets.push(( + entry.file_name().to_string_lossy().into_owned(), + entry.path(), + )), + Err(error) if args.recursive || args.metadata_only => return Err(error.into()), + _ => {} + } + } + } + sets + }; + let store_data = store_list().unwrap_or_default(); + let now = Utc::now().timestamp(); + let results: Vec>> = sets .par_iter() - .map(|entry| -> Result> { - if args.recursive && !entry.file_type()?.is_dir() { + .map(|(name, path)| { + let repos = repo_entries(path, &args, is_selected)?; + if repos.is_empty() { + anyhow::ensure!(!is_selected, "No repos found in worktree '{name}'"); return Ok(None); } - let name = entry.file_name().to_string_lossy().to_string(); - let wt_dir = entry.path(); - - let repos = if args.recursive { - meta_cli::worktree::discover_worktree_repos_recursive(&wt_dir)? + let has_meta_root = repos.iter().any(|repo| match repo { + RepoEntry::Observed(repo) => repo.alias == ".", + RepoEntry::Metadata(repo) => repo.alias == ".", + }); + let (ephemeral, ttl_remaining_seconds, custom) = if let Some(entry) = store_data + .worktrees + .get(&path.to_string_lossy().to_string()) + { + ( + Some(entry.ephemeral), + entry_ttl_remaining(entry, now), + (!entry.custom.is_empty()).then(|| entry.custom.clone()), + ) } else { - discover_worktree_repos(&wt_dir).unwrap_or_default() + (None, None, None) }; - if repos.is_empty() { - 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 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::>>()?; - - // Merge store metadata if available - let wt_key = wt_dir.to_string_lossy().to_string(); - let (ephemeral, ttl_remaining, custom) = - if let Some(store_entry) = store_data.worktrees.get(&wt_key) { - ( - Some(store_entry.ephemeral), - entry_ttl_remaining(store_entry, now), - (!store_entry.custom.is_empty()).then(|| store_entry.custom.clone()), - ) - } else { - (None, None, None) - }; - Ok(Some(ListEntry { - name, - root: wt_dir.display().to_string(), + name: name.clone(), + root: path.display().to_string(), has_meta_root, - repos: repo_entries, + repos, ephemeral, - ttl_remaining_seconds: ttl_remaining, + ttl_remaining_seconds, custom, })) }) .collect(); - - let mut entries = Vec::new(); + let mut worktrees = Vec::new(); let mut warnings = Vec::new(); - for (entry, result) in dir_entries.iter().zip(results) { + for ((name, path), result) in sets.iter().zip(results) { match result { - Ok(Some(entry)) => entries.push(entry), + Ok(Some(entry)) => worktrees.push(entry), Ok(None) => {} - Err(error) if args.recursive => warnings.push(ListWarning { - name: entry.file_name().to_string_lossy().into_owned(), - root: entry.path().display().to_string(), - message: format!("{error:#}"), - }), + Err(error) if !is_selected && (args.recursive || args.metadata_only) => { + warnings.push(ListWarning { + name: name.clone(), + root: path.display().to_string(), + message: format!("{error:#}"), + }) + } Err(error) => return Err(error), } } - - // Sort both collections by name for deterministic output. - entries.sort_by(|a, b| a.name.cmp(&b.name)); + worktrees.sort_by(|a, b| a.name.cmp(&b.name)); warnings.sort_by(|a, b| a.name.cmp(&b.name)); - if json { println!( "{}", - serde_json::to_string_pretty(&ListWithWarnings { - listing: ListOutput { worktrees: entries }, - warnings, + serde_json::to_string_pretty(&ListOutput { + worktrees, + warnings })? ); return Ok(()); } - - if entries.is_empty() { - if warnings.is_empty() { - println!("No worktrees found."); - } else { - println!("No readable worktree sets found."); - } - } else { - for e in &entries { - let mut header = e.name.bold().to_string(); - if e.ephemeral == Some(true) { - header.push_str(&format!(" {}", "[ephemeral]".dimmed())); + if worktrees.is_empty() { + println!( + "{}", + if warnings.is_empty() { + "No worktrees found." + } else { + "No readable worktree sets found." } - if let Some(ttl) = e.ttl_remaining_seconds { + ); + } + for entry in worktrees { + let mut header = entry.name.bold().to_string(); + if entry.ephemeral == Some(true) { + header.push_str(&format!(" {}", "[ephemeral]".dimmed())); + } + if let Some(ttl) = entry.ttl_remaining_seconds { + header.push_str(&format!( + " {}", if ttl > 0 { - header.push_str(&format!( - " {}", - format!("[TTL: {}]", format_duration(ttl)).dimmed() - )); + format!("[TTL: {}]", format_duration(ttl)) + .dimmed() + .to_string() } else { - header.push_str(&format!(" {}", "[expired]".red())); + "[expired]".red().to_string() + } + )); + } + println!("{header}"); + for repo in entry.repos { + match repo { + RepoEntry::Metadata(repo) => println!(" {} {}", repo.alias, repo.path.display()), + RepoEntry::Observed(repo) => { + let status = if repo.dirty { + "modified".yellow().to_string() + } else { + "clean".green().to_string() + }; + println!(" {:12} -> {:20} ({})", repo.alias, repo.branch, status); } } - println!("{header}"); - for r in &e.repos { - let status = if r.dirty { - "modified".yellow().to_string() - } else { - "clean".green().to_string() - }; - println!(" {:12} -> {:20} ({})", r.alias, r.branch, status); - } - println!(); } + println!(); } - for warning in &warnings { + for warning in warnings { eprintln!("Warning: {}: {}", warning.name, warning.message); } - Ok(()) } diff --git a/src/commands/worktree/members.rs b/src/commands/worktree/members.rs deleted file mode 100644 index f99524a..0000000 --- a/src/commands/worktree/members.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Membership-only public projection for background workspace discovery. -use anyhow::Result; -use meta_cli::worktree::{ - detect_worktree_context, discover_worktree_members_recursive, WorktreeMemberInfo, -}; -use meta_git_lib::worktree::helpers::{resolve_existing_worktree, validate_worktree_name}; -use serde::Serialize; - -use super::cli_types::MembersArgs; - -#[derive(Serialize)] -struct MembersOutput { - name: String, - repos: Vec, -} - -pub(crate) fn handle_members(args: MembersArgs, json: bool) -> Result<()> { - let (name, wt_dir) = match args.name { - Some(name) => { - validate_worktree_name(&name)?; - let ctx = resolve_existing_worktree(&name)?; - (name, ctx.wt_dir) - } - None => { - let cwd = std::env::current_dir()?; - let (name, wt_dir, _) = detect_worktree_context(&cwd).ok_or_else(|| { - anyhow::anyhow!( - "Worktree name required outside a realized .worktrees/ directory; \ - specify NAME or run from inside the worktree" - ) - })?; - validate_worktree_name(&name)?; - (name, wt_dir) - } - }; - let repos = discover_worktree_members_recursive(&wt_dir)?; - anyhow::ensure!(!repos.is_empty(), "No repos found in worktree '{}'", name); - if json { - println!( - "{}", - serde_json::to_string_pretty(&MembersOutput { name, repos })? - ); - } else { - for repo in repos { - println!("{} {}", repo.alias, repo.path.display()); - } - } - Ok(()) -} diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index b816c09..091225e 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -6,7 +6,6 @@ mod create; mod diff; mod exec; mod list; -mod members; mod prune; mod remove; mod status; @@ -64,7 +63,7 @@ pub fn execute_worktree_command( let is_create = matches!(clap_args.first().map(String::as_str), Some("create")); let supports_recursive = matches!( clap_args.first().map(String::as_str), - Some("create" | "list" | "status" | "members") + Some("create" | "list" | "status") ); if recursive && supports_recursive && !clap_args.iter().any(|a| a == "--recursive" || a == "-r") { @@ -122,7 +121,6 @@ fn handle_worktree_command( } WorktreeCommands::List(args) => list::handle_list(args, verbose, json), WorktreeCommands::Status(args) => status::handle_status(args, verbose, json), - WorktreeCommands::Members(args) => members::handle_members(args, json), WorktreeCommands::Diff(args) => diff::handle_diff(args, verbose, json), WorktreeCommands::Exec(args) => exec::handle_exec(args, verbose, json), WorktreeCommands::Prune(args) => prune::handle_prune(args, verbose, json, global_strict), @@ -186,12 +184,11 @@ fn write_worktree_help(w: &mut dyn std::io::Write) { let _ = writeln!(w, " create Create a new worktree set"); let _ = writeln!(w, " add Add a repo to an existing worktree set"); let _ = writeln!(w, " remove Remove a worktree set"); - let _ = writeln!(w, " list List all worktree sets"); - let _ = writeln!(w, " status Show detailed status of a worktree set"); let _ = writeln!( w, - " members List realized membership without Git status probes" + " list List worktree sets (current set when inside one)" ); + let _ = writeln!(w, " status Show detailed status of a worktree set"); let _ = writeln!(w, " diff Show cross-repo diff vs base branch"); let _ = writeln!(w, " exec Run a command across worktree repos"); let _ = writeln!(w, " prune Remove expired/orphaned worktrees"); @@ -219,7 +216,7 @@ 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 / MEMBERS OPTIONS:"); + let _ = writeln!(w, "LIST / STATUS OPTIONS:"); let _ = writeln!( w, " -r, --recursive Include repositories nested beneath worktree repos" @@ -227,11 +224,11 @@ fn write_worktree_help(w: &mut dyn std::io::Write) { let _ = writeln!(w); let _ = writeln!( w, - "MEMBERS requires --recursive and omits branch/status probes." + "LIST [NAME] defaults to the enclosing set, or all sets outside a worktree." ); let _ = writeln!( w, - " NAME defaults to the enclosing .worktrees/ set for MEMBERS." + " --metadata-only List membership paths without Git-state probes" ); let _ = writeln!(w, "REMOVE OPTIONS:"); let _ = writeln!( diff --git a/src/main.rs b/src/main.rs index 77e6c2a..6d855d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,7 +73,6 @@ fn main() { "git worktree destroy".to_string(), "git worktree list".to_string(), "git worktree status".to_string(), - "git worktree members".to_string(), "git worktree diff".to_string(), "git worktree exec".to_string(), "git worktree prune".to_string(), diff --git a/tests/recursive_inventory.rs b/tests/recursive_inventory.rs index d7e7a18..a57f7fb 100644 --- a/tests/recursive_inventory.rs +++ b/tests/recursive_inventory.rs @@ -23,11 +23,21 @@ 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 matches!(command, "status" | "members") { + let args = if command == "status" { vec!["example"] } else { vec![] }; + request_args(root, command, args, recursive, explicit) +} + +fn request_args( + root: &Path, + command: &str, + mut args: Vec<&str>, + recursive: bool, + explicit: bool, +) -> std::process::Output { if explicit { args.push("--recursive"); } @@ -105,6 +115,14 @@ fn public_list_status_recursive_protocol_and_explicit_flags() { String::from_utf8_lossy(&out.stderr) ); let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + if command == "list" { + let named = request_args(root, "list", vec!["example"], recursive, explicit); + assert!(named.status.success()); + assert_eq!( + serde_json::from_slice::(&named.stdout).unwrap(), + value + ); + } let rows = if command == "list" { &value["worktrees"][0]["repos"] } else { @@ -173,6 +191,9 @@ fn public_list_status_recursive_protocol_and_explicit_flags() { out.stdout.is_empty(), "named status must not emit an incomplete successful inventory" ); + let selected = request_args(root, "list", vec!["example"], true, false); + assert!(!selected.status.success()); + assert!(selected.stdout.is_empty()); } /// A missing configured root is empty for list and an error for named status. @@ -229,13 +250,20 @@ fn membership_only_protocol_runs_without_git_and_rejects_partial_membership() { .unwrap(); fs::set_permissions(&fake_git, fs::Permissions::from_mode(0o700)).unwrap(); for (recursive, explicit) in [(true, false), (false, true)] { - let out = request(&root, "members", recursive, explicit); + let out = request_args( + &root, + "list", + vec!["example", "--metadata-only"], + recursive, + explicit, + ); assert!( out.status.success(), "{}", String::from_utf8_lossy(&out.stderr) ); let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + let value = &value["worktrees"][0]; assert_eq!(value["name"], "example"); assert_eq!(value["repos"].as_array().unwrap().len(), 1); assert_eq!(value["repos"][0]["alias"], "."); @@ -246,20 +274,32 @@ fn membership_only_protocol_runs_without_git_and_rejects_partial_membership() { assert!(!bin.join("git.calls").exists(), "membership launched Git"); fs::create_dir(set.join("broken")).unwrap(); fs::write(set.join("broken/.git"), "broken").unwrap(); - let out = request(&root, "members", true, false); + let out = request_args( + &root, + "list", + vec!["example", "--metadata-only"], + true, + false, + ); assert!(!out.status.success()); assert!( out.stdout.is_empty(), "must not report a partial successful inventory" ); assert!(!bin.join("git.calls").exists()); + let aggregate = request_args(&root, "list", vec!["--metadata-only"], true, false); + assert!(aggregate.status.success()); + let value: Value = serde_json::from_slice(&aggregate.stdout).unwrap(); + assert!(value["worktrees"].as_array().unwrap().is_empty()); + assert_eq!(value["warnings"].as_array().unwrap().len(), 1); + assert!(!bin.join("git.calls").exists()); } /// Inference selects the enclosing physical set, including from deep descendants, /// without consulting a different configured worktree root or launching Git. #[cfg(unix)] #[test] -fn membership_infers_enclosing_set_without_git_and_requires_context() { +fn list_infers_enclosing_set_and_metadata_only_avoids_git() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().unwrap(); let root = temp.path().canonicalize().unwrap(); @@ -287,6 +327,7 @@ fn membership_infers_enclosing_set_without_git_and_requires_context() { ); let nested = set.join("deep/directory"); fs::create_dir_all(&nested).unwrap(); + fs::write(set.join("untracked"), "dirty").unwrap(); let other = root.join("configured/other"); fs::create_dir_all(other.parent().unwrap()).unwrap(); git( @@ -306,7 +347,14 @@ fn membership_infers_enclosing_set_without_git_and_requires_context() { let mut child = Command::new(env!("CARGO_BIN_EXE_meta-git")) .arg("--meta-plugin-exec") .current_dir(cwd) - .env("PATH", &bin) + .env( + "PATH", + if args.contains(&"--metadata-only") { + bin.clone().into_os_string() + } else { + std::env::var_os("PATH").unwrap() + }, + ) .env("META_WORKTREES", root.join("configured")) .env("META_DATA_DIR", root.join("data")) .stdin(Stdio::piped()) @@ -314,7 +362,7 @@ fn membership_infers_enclosing_set_without_git_and_requires_context() { .stderr(Stdio::piped()) .spawn() .unwrap(); - let request = json!({"command": "git worktree members", "args": args, + let request = json!({"command": "git worktree list", "args": args, "cwd": cwd, "options": {"json_output": true, "recursive": recursive}}); child .stdin @@ -325,7 +373,10 @@ fn membership_infers_enclosing_set_without_git_and_requires_context() { child.wait_with_output().unwrap() }; for cwd in [&set, &nested] { - for (args, recursive) in [(vec![], true), (vec!["--recursive"], false)] { + for (args, recursive) in [ + (vec!["--metadata-only"], true), + (vec!["--metadata-only", "--recursive"], false), + ] { let out = invoke(cwd, args, recursive); assert!( out.status.success(), @@ -333,32 +384,60 @@ fn membership_infers_enclosing_set_without_git_and_requires_context() { String::from_utf8_lossy(&out.stderr) ); let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(value["worktrees"].as_array().unwrap().len(), 1); + let value = &value["worktrees"][0]; assert_eq!(value["name"], "example"); assert_eq!(value["repos"][0]["path"], set.to_str().unwrap()); } } - let explicit = invoke(&nested, vec!["other"], true); + let explicit = invoke(&nested, vec!["other", "--metadata-only"], true); assert!(explicit.status.success()); let value: Value = serde_json::from_slice(&explicit.stdout).unwrap(); - assert_eq!(value["name"], "other"); - assert_eq!(value["repos"][0]["path"], other.to_str().unwrap()); - - let outside = invoke(&source, vec![], true); - assert!(!outside.status.success()); - assert!(outside.stdout.is_empty()); - assert!(String::from_utf8_lossy(&outside.stderr).contains("Worktree name required")); - assert!( - !invoke(&set, vec![], false).status.success(), - "recursive remains required" + assert_eq!(value["worktrees"][0]["name"], "other"); + assert_eq!( + value["worktrees"][0]["repos"][0]["path"], + other.to_str().unwrap() ); + // Outside a worktree, omission still lists all configured sets. + let outside = invoke(&source, vec!["--metadata-only"], true); + assert!(outside.status.success()); + let value: Value = serde_json::from_slice(&outside.stdout).unwrap(); + assert_eq!(value["worktrees"][0]["name"], "other"); + // Metadata-only is independent of recursion; legacy boundaries remain available. + assert!(invoke(&set, vec!["--metadata-only"], false) + .status + .success()); + // Default observed list also infers the set and retains its existing fields. + for cwd in [&set, &nested] { + let out = invoke(cwd, vec![], false); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let value: Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(value["worktrees"].as_array().unwrap().len(), 1); + assert_eq!(value["worktrees"][0]["name"], "example"); + assert_eq!(value["worktrees"][0]["repos"][0]["branch"], "topic"); + assert_eq!(value["worktrees"][0]["repos"][0]["dirty"], true); + } + let missing = invoke(&source, vec!["missing", "--metadata-only"], true); + assert!(!missing.status.success()); + assert!(missing.stdout.is_empty()); + fs::create_dir(set.join("broken")).unwrap(); fs::write(set.join("broken/.git"), "broken").unwrap(); - let malformed = invoke(&nested, vec![], true); + let malformed = invoke(&nested, vec!["--metadata-only"], true); assert!(!malformed.status.success()); assert!( malformed.stdout.is_empty(), "inference must not mask invalid membership" ); assert!(!bin.join("git.calls").exists(), "inference launched Git"); + // An invalid enclosing root must not turn into an all-set or empty success. + fs::write(set.join(".git"), "broken").unwrap(); + let invalid_root = invoke(&nested, vec!["--metadata-only"], true); + assert!(!invalid_root.status.success()); + assert!(invalid_root.stdout.is_empty()); } From 52e13227e94bce345f324be0ccd5079aa86e41a7 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 23 Oct 2026 10:01:35 -0500 Subject: [PATCH 4/5] fix(worktree): select list context from protocol request directory Pass request.cwd through typed dispatch and resolve named/default roots relative to that directory without changing process state. Cover a different process cwd plus nested metadata-only paths with zero Git calls. Refs [[tasks/harmony-2613]] --- src/commands/worktree/list.rs | 42 ++++++++++++++++++++++------------- src/commands/worktree/mod.rs | 29 +++++++++++++++--------- src/lib.rs | 10 +-------- tests/recursive_inventory.rs | 18 +++++++++++++-- 4 files changed, 61 insertions(+), 38 deletions(-) diff --git a/src/commands/worktree/list.rs b/src/commands/worktree/list.rs index 53f4ea4..bb580c7 100644 --- a/src/commands/worktree/list.rs +++ b/src/commands/worktree/list.rs @@ -87,23 +87,34 @@ fn repo_entries(path: &std::path::Path, args: &ListArgs, selected: bool) -> Resu } /// A selected set fails closed. All-set listing retains healthy sets and warnings. -pub(crate) fn handle_list(args: ListArgs, _verbose: bool, json: bool) -> Result<()> { - let location = detect_worktree_location(&std::env::current_dir()?); +pub(crate) fn handle_list( + args: ListArgs, + _verbose: bool, + json: bool, + cwd: &std::path::Path, +) -> Result<()> { + let cwd = cwd.canonicalize()?; + let location = detect_worktree_location(&cwd); + let worktree_root = match (&location, std::env::var_os("META_WORKTREES")) { + (Some((_, root)), None) => root + .parent() + .expect("worktree location has a parent") + .to_path_buf(), + _ => { + let meta_dir = meta_cli::config::find_meta_config(&cwd, None) + .and_then(|(path, _)| path.parent().map(std::path::Path::to_path_buf)); + let root = resolve_worktree_root(Some(meta_dir.as_deref().unwrap_or(&cwd)))?; + if root.is_absolute() { + root + } else { + cwd.join(root) + } + } + }; let selected = match args.name.as_ref() { Some(name) => { validate_worktree_name(name)?; - let path = if std::env::var_os("META_WORKTREES").is_none() { - if let Some((_, current)) = &location { - current - .parent() - .expect("worktree location has a parent") - .join(name) - } else { - resolve_existing_worktree(name)?.wt_dir - } - } else { - resolve_existing_worktree(name)?.wt_dir - }; + let path = worktree_root.join(name); anyhow::ensure!( path.is_dir(), "Worktree '{name}' not found at {}", @@ -120,8 +131,7 @@ pub(crate) fn handle_list(args: ListArgs, _verbose: bool, json: bool) -> Result< let sets = if let Some(selected) = selected { vec![selected] } else { - let meta_dir = find_meta_dir(); - let root = resolve_worktree_root(meta_dir.as_deref())?; + let root = worktree_root; let mut sets = Vec::new(); if root.exists() { for entry in std::fs::read_dir(root)? { diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index 091225e..02a1ebd 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -13,7 +13,8 @@ mod status; use anyhow::Result; use clap::Parser; use colored::*; -use meta_plugin_protocol::CommandResult; +use meta_plugin_protocol::{CommandResult, PluginRequestOptions}; +use std::path::Path; use cli_types::WorktreeCommands; @@ -29,15 +30,12 @@ struct WorktreeParser { /// /// `command` is the matched command string (e.g., "worktree create", "worktree", "git worktree"). /// `args` contains the remaining arguments after the command prefix. -/// `strict` is the global strict mode flag from CLI options. +/// `cwd` is the protocol request directory, independent of the plugin process. pub fn execute_worktree_command( command: &str, args: &[String], - verbose: bool, - json: bool, - strict: bool, - recursive: bool, - dry_run: bool, + options: &PluginRequestOptions, + cwd: &Path, ) -> CommandResult { // Extract the subcommand name from the matched command prefix (if present). // When the plugin registers bare "worktree", the subcommand is in args[0] instead. @@ -65,11 +63,13 @@ pub fn execute_worktree_command( clap_args.first().map(String::as_str), Some("create" | "list" | "status") ); - if recursive && supports_recursive && !clap_args.iter().any(|a| a == "--recursive" || a == "-r") + if options.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") { + if options.dry_run && is_create && !clap_args.iter().any(|a| a == "--dry-run") { clap_args.push("--dry-run".to_string()); } @@ -84,7 +84,13 @@ pub fn execute_worktree_command( } match WorktreeParser::try_parse_from(&clap_args) { - Ok(parsed) => match handle_worktree_command(parsed.command, verbose, json, strict) { + Ok(parsed) => match handle_worktree_command( + parsed.command, + options.verbose, + options.json_output, + options.strict, + cwd, + ) { Ok(()) => CommandResult::Message(String::new()), Err(e) => CommandResult::Error(format!("{e}")), }, @@ -112,6 +118,7 @@ fn handle_worktree_command( verbose: bool, json: bool, global_strict: bool, + cwd: &Path, ) -> Result<()> { match command { WorktreeCommands::Create(args) => create::handle_create(args, verbose, json, global_strict), @@ -119,7 +126,7 @@ fn handle_worktree_command( WorktreeCommands::Remove(args) | WorktreeCommands::Destroy(args) => { remove::handle_remove(args, verbose, json, global_strict) } - WorktreeCommands::List(args) => list::handle_list(args, verbose, json), + WorktreeCommands::List(args) => list::handle_list(args, verbose, json, cwd), WorktreeCommands::Status(args) => status::handle_status(args, verbose, json), WorktreeCommands::Diff(args) => diff::handle_diff(args, verbose, json), WorktreeCommands::Exec(args) => exec::handle_exec(args, verbose, json), diff --git a/src/lib.rs b/src/lib.rs index f1fb8df..3ff4775 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,15 +47,7 @@ pub fn execute_command( // Worktree commands (meta worktree * and meta git worktree *) // Checked before --help intercept so worktree subcommands can handle their own --help via clap if command.starts_with("worktree") || command.starts_with("git worktree") { - return commands::worktree::execute_worktree_command( - command, - args, - options.verbose, - options.json_output, - options.strict, - options.recursive, - options.dry_run, - ); + return commands::worktree::execute_worktree_command(command, args, options, cwd); } if command.starts_with("git snapshot") && help_requested { diff --git a/tests/recursive_inventory.rs b/tests/recursive_inventory.rs index a57f7fb..8b9c5ee 100644 --- a/tests/recursive_inventory.rs +++ b/tests/recursive_inventory.rs @@ -240,6 +240,13 @@ fn membership_only_protocol_runs_without_git_and_rejects_partial_membership() { &["worktree", "add", "-qb", "topic", set.to_str().unwrap()], ); fs::write(set.join("untracked"), "dirty, deliberately unobserved").unwrap(); + let nested = set.join("nested/child"); + fs::create_dir_all(nested.parent().unwrap()).unwrap(); + git( + &source, + &["worktree", "add", "-qb", "nested", nested.to_str().unwrap()], + ); + let bin = root.join("bin"); fs::create_dir(&bin).unwrap(); let fake_git = bin.join("git"); @@ -265,7 +272,14 @@ fn membership_only_protocol_runs_without_git_and_rejects_partial_membership() { let value: Value = serde_json::from_slice(&out.stdout).unwrap(); let value = &value["worktrees"][0]; assert_eq!(value["name"], "example"); - assert_eq!(value["repos"].as_array().unwrap().len(), 1); + assert_eq!(value["repos"].as_array().unwrap().len(), 2); + assert_eq!(value["repos"][1]["alias"], "nested/child"); + assert_eq!(value["repos"][1]["path"], nested.to_str().unwrap()); + for member in value["repos"].as_array().unwrap() { + assert_eq!(member["source_path"], source.to_str().unwrap()); + assert!(member.get("branch").is_none()); + assert!(member.get("dirty").is_none()); + } assert_eq!(value["repos"][0]["alias"], "."); assert_eq!(value["repos"][0]["path"], set.to_str().unwrap()); assert!(value["repos"][0].get("branch").is_none()); @@ -346,7 +360,7 @@ fn list_infers_enclosing_set_and_metadata_only_avoids_git() { let invoke = |cwd: &Path, args: Vec<&str>, recursive: bool| { let mut child = Command::new(env!("CARGO_BIN_EXE_meta-git")) .arg("--meta-plugin-exec") - .current_dir(cwd) + .current_dir(&source) .env( "PATH", if args.contains(&"--metadata-only") { From 9a6bd026f8833fa41fc3da9de43b8649dc0a0e36 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Fri, 23 Oct 2026 10:18:37 -0500 Subject: [PATCH 5/5] docs(worktree): separate list-only help options Give metadata-only its own list section and separate the remove options heading. Refs [[tasks/harmony-2613]] --- src/commands/worktree/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index 02a1ebd..a0dd4b3 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -229,6 +229,7 @@ fn write_worktree_help(w: &mut dyn std::io::Write) { " -r, --recursive Include repositories nested beneath worktree repos" ); let _ = writeln!(w); + let _ = writeln!(w, "LIST OPTIONS:"); let _ = writeln!( w, "LIST [NAME] defaults to the enclosing set, or all sets outside a worktree." @@ -237,6 +238,7 @@ fn write_worktree_help(w: &mut dyn std::io::Write) { w, " --metadata-only List membership paths without Git-state probes" ); + let _ = writeln!(w); let _ = writeln!(w, "REMOVE OPTIONS:"); let _ = writeln!( w,