From a6f53bfe6b6ae1fcf39bec67e2f928c3a17b0191 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 14:16:25 -0700 Subject: [PATCH 1/2] fix: make plugin plan execution policy generic [[tasks/meta-cargo-command-forwarding]] --- src/main.rs | 117 ++++++++++++++-- src/subprocess_plugins.rs | 116 +++++++++++---- tests/plugin_plan_policy.rs | 273 ++++++++++++++++++++++++++++++++++++ 3 files changed, 472 insertions(+), 34 deletions(-) create mode 100644 tests/plugin_plan_policy.rs diff --git a/src/main.rs b/src/main.rs index b7beca9..dd8ca25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -538,12 +538,17 @@ fn main() -> Result<()> { let mut args = args; extract_global_flags(&mut args, &mut cli); + let has_forwarded_help = contains_help_before_separator(&args); + if cli.help && !has_forwarded_help { + print_help_with_plugins(&subprocess_plugins, false); + return Ok(()); + } + // Keep root plugin help fast and plugin-aware, but let nested help // requests reach the matched plugin command implementation. if let Some(first) = args.first() { - let wants_help = args.iter().any(|a| a == "--help" || a == "-h"); let is_bare = args.len() == 1; - let is_root_help = wants_help + let is_root_help = has_forwarded_help && args.len() == 2 && matches!(args.get(1).map(String::as_str), Some("--help" | "-h")); @@ -561,7 +566,7 @@ fn main() -> Result<()> { } } - if wants_help { + if has_forwarded_help { let command_str = args.join(" "); let options = PluginRequestOptions { json_output: cli.json, @@ -785,10 +790,11 @@ fn handle_command_dispatch( return Ok(()); } - // No config found — degraded legacy path with warning + // No config found — worktree paths are still authoritative for + // plugin dispatch, but config-backed tags/dependencies are unavailable. if cli.verbose { eprintln!( - "{} No .meta config found for worktree '{}'. Tags, plugins, and dependency features unavailable.", + "{} No .meta config found for worktree '{}'. Tags and dependency features unavailable.", "warning:".yellow().bold(), task_name ); @@ -801,10 +807,10 @@ fn handle_command_dispatch( let exclude_opt = none_if_empty(exclude_filters); let config = loop_lib::LoopConfig { - directories, + directories: directories.clone(), ignore: vec![], - include_filters: include_opt, - exclude_filters: exclude_opt, + include_filters: include_opt.clone(), + exclude_filters: exclude_opt.clone(), verbose: cli.verbose, silent: cli.silent, parallel, // Use the determined parallel mode, not hardcoded false @@ -817,7 +823,36 @@ fn handle_command_dispatch( root_dir: None, // Worktree paths don't use "." convention }; - run(&config, &command_str)?; + let subprocess_options = PluginRequestOptions { + json_output: cli.json, + verbose: cli.verbose, + parallel, + dry_run, + silent: cli.silent, + recursive, + depth, + include_filters: include_opt, + exclude_filters: exclude_opt, + strict: cli.strict, + }; + + if plugins.execute( + &command_str, + &command_args, + &directories, + subprocess_options, + )? { + if cli.verbose { + println!( + "{}", + "Command handled by subprocess plugin (worktree without config).".green() + ); + } + } else if is_explicit_exec { + run(&config, &command_str)?; + } else { + unrecognized_command_error(&command_args, &command_str, plugins); + } return Ok(()); } } @@ -920,10 +955,11 @@ fn handle_command_dispatch( strict: cli.strict, }; - if plugins.execute( + if plugins.execute_with_root( &command_str, &command_args, &project_paths, + Some(meta_dir), subprocess_options, )? { log::info!("Command was handled by subprocess plugin"); @@ -1256,7 +1292,16 @@ fn handle_plugin_command( /// subcommands (e.g. `worktree prune --dry-run`, `worktree exec --parallel`) /// define their own versions and need to see them. fn extract_global_flags(args: &mut Vec, cli: &mut Cli) { + let mut after_separator = false; args.retain(|arg| { + if after_separator { + return true; + } + if arg == "--" { + after_separator = true; + return true; + } + match arg.as_str() { "--json" => { cli.json = true; @@ -1287,6 +1332,13 @@ fn extract_global_flags(args: &mut Vec, cli: &mut Cli) { }); } +/// Whether a help flag appears before the command's `--` separator. +fn contains_help_before_separator(args: &[String]) -> bool { + args.iter() + .take_while(|arg| arg.as_str() != "--") + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) +} + /// Check whether a project's tags match a comma-separated tag filter string. fn matches_tag_filter(tags: &[String], filter: &str) -> bool { let requested: Vec<&str> = filter.split(',').map(|s| s.trim()).collect(); @@ -1423,6 +1475,51 @@ mod tests { use std::io::Write; use tempfile::NamedTempFile; + fn empty_cli() -> Cli { + Cli::try_parse_from(["meta"]).unwrap() + } + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn test_external_global_extraction_stops_at_separator() { + let mut cli = empty_cli(); + let mut args = strings(&[ + "tool", + "run", + "--verbose", + "--recursive", + "--", + "--json", + "--strict", + "--recursive", + ]); + + extract_global_flags(&mut args, &mut cli); + + assert!(cli.verbose); + assert!(cli.recursive); + assert!(!cli.json); + assert!(!cli.strict); + assert_eq!( + args, + strings(&["tool", "run", "--", "--json", "--strict", "--recursive"]) + ); + } + + #[test] + fn test_forwarded_help_classification_stops_at_separator() { + assert!(contains_help_before_separator(&strings(&[ + "tool", "run", "--help", "--", "payload" + ]))); + assert!(contains_help_before_separator(&strings(&["tool", "-h"]))); + assert!(!contains_help_before_separator(&strings(&[ + "tool", "run", "--", "--help" + ]))); + } + #[test] fn test_parse_meta_config_valid_simple_format() { let mut file = NamedTempFile::new().unwrap(); diff --git a/src/subprocess_plugins.rs b/src/subprocess_plugins.rs index ed8a2f9..2655bfa 100644 --- a/src/subprocess_plugins.rs +++ b/src/subprocess_plugins.rs @@ -10,8 +10,8 @@ use std::process::{Command, Stdio}; #[allow(unused_imports)] pub use meta_plugin_protocol::{ - ExecutionPlan, PlanResponse as PluginResponse, PlannedCommand, PluginHelp, PluginInfo, - PluginRequest, PluginRequestOptions, + ExecutionPlan, PlanExecutionPolicy, PlanResponse as PluginResponse, PlannedCommand, PluginHelp, + PluginInfo, PluginRequest, PluginRequestOptions, HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1, }; /// A discovered subprocess plugin @@ -27,6 +27,19 @@ pub struct SubprocessPluginManager { verbose: bool, } +/// Apply the execution behavior selected by a plugin plan. +fn options_for_plan_execution( + policy: PlanExecutionPolicy, + options: &PluginRequestOptions, +) -> PluginRequestOptions { + let mut execution_options = options.clone(); + if !policy.apply_host_filters { + execution_options.include_filters = None; + execution_options.exclude_filters = None; + } + execution_options +} + impl Default for SubprocessPluginManager { fn default() -> Self { Self::new() @@ -202,6 +215,18 @@ impl SubprocessPluginManager { args: &[String], projects: &[String], options: PluginRequestOptions, + ) -> Result { + self.execute_with_root(command, args, projects, None, options) + } + + /// Execute a command while preserving the caller's actual Meta root. + pub fn execute_with_root( + &self, + command: &str, + args: &[String], + projects: &[String], + root_dir: Option<&Path>, + options: PluginRequestOptions, ) -> Result { let cmd_parts: Vec<&str> = command.split_whitespace().collect(); if cmd_parts.is_empty() { @@ -236,7 +261,7 @@ impl SubprocessPluginManager { } if let Some((plugin, matched_cmd)) = best_match { - return self.execute_plugin(plugin, matched_cmd, args, projects, &options); + return self.execute_plugin(plugin, matched_cmd, args, projects, root_dir, &options); } Ok(false) @@ -249,6 +274,7 @@ impl SubprocessPluginManager { command: &str, args: &[String], projects: &[String], + root_dir: Option<&Path>, options: &PluginRequestOptions, ) -> Result { // Extract the remaining args after the matched command @@ -262,6 +288,7 @@ impl SubprocessPluginManager { args: remaining_args, projects: projects.to_vec(), cwd: std::env::current_dir()?.to_string_lossy().to_string(), + host_capabilities: vec![HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1.to_string()], options: options.clone(), }; @@ -313,7 +340,7 @@ impl SubprocessPluginManager { match serde_json::from_str::(&stdout_str) { Ok(response) => { // Plugin returned an execution plan - execute it via loop_lib - self.execute_plan(&response.plan, options) + self.execute_plan(&response.plan, options, root_dir, response.execution_policy) } Err(_) => { // Couldn't parse as our protocol - print output as-is (legacy behavior) @@ -324,8 +351,22 @@ impl SubprocessPluginManager { } /// Execute an execution plan via loop_lib - fn execute_plan(&self, plan: &ExecutionPlan, options: &PluginRequestOptions) -> Result { - use loop_lib::{run_commands, DirCommand, LoopConfig}; + fn execute_plan( + &self, + plan: &ExecutionPlan, + options: &PluginRequestOptions, + root_dir: Option<&Path>, + policy: PlanExecutionPolicy, + ) -> Result { + use loop_lib::{run_commands, run_commands_without_loop_aliases, DirCommand, LoopConfig}; + + let execution_options = options_for_plan_execution(policy, options); + let run_plan_commands: fn(&LoopConfig, &[DirCommand]) -> Result<()> = + if policy.expand_loop_aliases { + run_commands + } else { + run_commands_without_loop_aliases + }; // Phase 1: Run pre_commands sequentially (setup tasks like SSH ControlMaster) if !plan.pre_commands.is_empty() { @@ -337,7 +378,7 @@ impl SubprocessPluginManager { let pre_config = LoopConfig { directories: vec![], ignore: vec![], - verbose: options.verbose, + verbose: execution_options.verbose, silent: true, // Pre-commands run silently unless verbose add_aliases_to_global_looprc: false, include_filters: None, @@ -359,8 +400,8 @@ impl SubprocessPluginManager { }; // Ignore failures for pre_commands (e.g., SSH socket already exists) // The main commands will fail if setup was actually needed - if let Err(e) = run_commands(&pre_config, &[cmd]) { - if options.verbose { + if let Err(e) = run_plan_commands(&pre_config, &[cmd]) { + if execution_options.verbose { eprintln!("Pre-command failed (continuing): {e}"); } } @@ -379,27 +420,24 @@ impl SubprocessPluginManager { }) .collect(); - // The first command's directory is the meta root (should display as ".") - let root_dir = commands.first().map(|c| PathBuf::from(&c.dir)); - let config = LoopConfig { directories: vec![], ignore: vec![], - verbose: options.verbose, - silent: options.silent, + verbose: execution_options.verbose, + silent: execution_options.silent, add_aliases_to_global_looprc: false, - include_filters: options.include_filters.clone(), - exclude_filters: options.exclude_filters.clone(), - parallel: plan.parallel.unwrap_or(options.parallel), - dry_run: options.dry_run, - json_output: options.json_output, + include_filters: execution_options.include_filters.clone(), + exclude_filters: execution_options.exclude_filters.clone(), + parallel: plan.parallel.unwrap_or(execution_options.parallel), + dry_run: execution_options.dry_run, + json_output: execution_options.json_output, spawn_stagger_ms: plan.spawn_stagger_ms.unwrap_or(0), env: None, max_parallel: plan.max_parallel, - root_dir, + root_dir: root_dir.map(Path::to_path_buf), }; - run_commands(&config, &commands)?; + run_plan_commands(&config, &commands)?; } // Phase 3: Run post_commands sequentially (cleanup tasks) @@ -407,7 +445,7 @@ impl SubprocessPluginManager { let post_config = LoopConfig { directories: vec![], ignore: vec![], - verbose: options.verbose, + verbose: execution_options.verbose, silent: true, add_aliases_to_global_looprc: false, include_filters: None, @@ -427,8 +465,8 @@ impl SubprocessPluginManager { cmd: post_cmd.cmd.clone(), env: post_cmd.env.clone(), }; - if let Err(e) = run_commands(&post_config, &[cmd]) { - if options.verbose { + if let Err(e) = run_plan_commands(&post_config, &[cmd]) { + if execution_options.verbose { eprintln!("Post-command failed: {e}"); } } @@ -714,6 +752,7 @@ mod tests { args: vec!["--verbose".to_string()], projects: vec!["project1".to_string(), "project2".to_string()], cwd: "/home/user/workspace".to_string(), + host_capabilities: vec![HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1.to_string()], options: PluginRequestOptions { json_output: true, verbose: false, @@ -727,6 +766,10 @@ mod tests { assert!(json.contains("\"command\":\"git status\"")); assert!(json.contains("\"json_output\":true")); assert!(json.contains("\"parallel\":true")); + assert_eq!( + request.host_capabilities, + vec![HOST_CAPABILITY_PLAN_EXECUTION_POLICY_V1.to_string()] + ); } #[test] @@ -741,6 +784,29 @@ mod tests { assert!(options.exclude_filters.is_none()); } + #[test] + fn test_plan_execution_policy_controls_host_filters() { + let options = PluginRequestOptions { + include_filters: Some(vec!["selected".to_string()]), + exclude_filters: Some(vec!["ignored".to_string()]), + ..Default::default() + }; + + let legacy = options_for_plan_execution(PlanExecutionPolicy::default(), &options); + assert_eq!(legacy.include_filters, options.include_filters); + assert_eq!(legacy.exclude_filters, options.exclude_filters); + + let plugin_owned = options_for_plan_execution( + PlanExecutionPolicy { + expand_loop_aliases: false, + apply_host_filters: false, + }, + &options, + ); + assert!(plugin_owned.include_filters.is_none()); + assert!(plugin_owned.exclude_filters.is_none()); + } + #[test] fn test_handles_command_matching() { let mut manager = SubprocessPluginManager::new(); @@ -1085,6 +1151,7 @@ mod tests { args: vec![], projects: vec!["proj1".to_string()], cwd: "/workspace".to_string(), + host_capabilities: vec![], options: PluginRequestOptions { json_output: false, verbose: false, @@ -1105,6 +1172,7 @@ mod tests { args: vec!["--release".to_string()], projects: vec![], cwd: ".".to_string(), + host_capabilities: vec![], options: PluginRequestOptions { json_output: true, verbose: true, diff --git a/tests/plugin_plan_policy.rs b/tests/plugin_plan_policy.rs new file mode 100644 index 0000000..d5a438b --- /dev/null +++ b/tests/plugin_plan_policy.rs @@ -0,0 +1,273 @@ +#![cfg(unix)] + +use std::ffi::OsString; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::tempdir; + +fn write_executable(path: &Path, contents: &str) { + fs::write(path, contents).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +fn path_with_plugin(plugin_dir: &Path) -> OsString { + let mut paths = vec![plugin_dir.to_path_buf()]; + if let Some(existing) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&existing)); + } + std::env::join_paths(paths).unwrap() +} + +fn meta_command(current_dir: &Path, plugin_dir: &Path, home: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_meta")); + command + .current_dir(current_dir) + .env("PATH", path_with_plugin(plugin_dir)) + .env("HOME", home) + .env("META_DATA_DIR", home.join("meta-data")); + command +} + +fn create_plugin_dir(root: &Path) -> PathBuf { + let plugin_dir = root.join("bin"); + fs::create_dir(&plugin_dir).unwrap(); + plugin_dir +} + +#[test] +fn prefix_help_does_not_execute_plugin_commands() { + let temp = tempdir().unwrap(); + let plugin_dir = create_plugin_dir(temp.path()); + let marker = temp.path().join("plugin-executed"); + + write_executable( + &plugin_dir.join("meta-tool"), + r#"#!/bin/sh +if [ "$1" = "--meta-plugin-info" ]; then + printf '%s\n' '{"name":"tool","version":"1.0.0","commands":["tool"]}' + exit 0 +fi +if [ "$1" = "--meta-plugin-exec" ]; then + IFS= read -r request || : + : > "$META_TEST_MARKER" + printf '%s\n' '{"plan":{"commands":[]}}' + exit 0 +fi +exit 1 +"#, + ); + + for args in [ + &["--help", "tool", "run"][..], + &["--help", "tool", "run", "payload"][..], + ] { + let output = meta_command(temp.path(), &plugin_dir, temp.path()) + .env("META_TEST_MARKER", &marker) + .args(args) + .output() + .unwrap(); + assert!(output.status.success(), "args: {args:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:")); + assert!(!marker.exists(), "prefix help executed {args:?}"); + } + + fs::write(temp.path().join(".meta"), r#"{"projects":{}}"#).unwrap(); + let output = meta_command(temp.path(), &plugin_dir, temp.path()) + .env("META_TEST_MARKER", &marker) + .args(["--help", "tool", "run"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(!marker.exists(), "prefix help executed in a Meta workspace"); +} + +#[test] +fn separator_payload_and_policy_capability_reach_the_plugin_unchanged() { + let temp = tempdir().unwrap(); + let plugin_dir = create_plugin_dir(temp.path()); + let request_path = temp.path().join("plugin-request.json"); + fs::write(temp.path().join(".meta"), r#"{"projects":{}}"#).unwrap(); + + write_executable( + &plugin_dir.join("meta-tool"), + r#"#!/bin/sh +if [ "$1" = "--meta-plugin-info" ]; then + printf '%s\n' '{"name":"tool","version":"1.0.0","commands":["tool"]}' + exit 0 +fi +if [ "$1" = "--meta-plugin-exec" ]; then + IFS= read -r request || : + printf '%s\n' "$request" > "$META_TEST_REQUEST" + printf '%s\n' '{"plan":{"commands":[]}}' + exit 0 +fi +exit 1 +"#, + ); + + let output = meta_command(temp.path(), &plugin_dir, temp.path()) + .env("META_TEST_REQUEST", &request_path) + .args(["tool", "run", "--", "--help", "--recursive", "--json"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let request: serde_json::Value = + serde_json::from_slice(&fs::read(&request_path).unwrap()).unwrap(); + assert_eq!(request["command"], "tool"); + assert_eq!( + request["args"], + serde_json::json!(["run", "--", "--help", "--recursive", "--json"]) + ); + assert_eq!( + request["host_capabilities"], + serde_json::json!(["plan-execution-policy-v1"]) + ); + assert_eq!(request["options"]["recursive"], false); + assert_eq!(request["options"]["json_output"], false); +} + +#[test] +fn no_config_worktree_still_dispatches_to_plugins() { + let temp = tempdir().unwrap(); + let plugin_dir = create_plugin_dir(temp.path()); + let task_dir = temp.path().join(".worktrees").join("synthetic-task"); + let repo_dir = task_dir.join("repo"); + let marker = temp.path().join("plugin-executed"); + fs::create_dir_all(&repo_dir).unwrap(); + fs::write( + repo_dir.join(".git"), + format!( + "gitdir: {}\n", + temp.path() + .join("source") + .join(".git") + .join("worktrees") + .join("synthetic-task") + .display() + ), + ) + .unwrap(); + + write_executable( + &plugin_dir.join("meta-tool"), + r#"#!/bin/sh +if [ "$1" = "--meta-plugin-info" ]; then + printf '%s\n' '{"name":"tool","version":"1.0.0","commands":["tool"]}' + exit 0 +fi +if [ "$1" = "--meta-plugin-exec" ]; then + IFS= read -r request || : + : > "$META_TEST_MARKER" + printf '%s\n' '{"plan":{"commands":[]}}' + exit 0 +fi +exit 1 +"#, + ); + + let output = meta_command(&repo_dir, &plugin_dir, temp.path()) + .env("META_TEST_MARKER", &marker) + .args(["tool", "run"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(marker.exists(), "worktree command bypassed the plugin"); +} + +#[test] +fn child_only_plan_uses_the_actual_meta_root_for_output_labels() { + let temp = tempdir().unwrap(); + let plugin_dir = create_plugin_dir(temp.path()); + let child = temp.path().join("child"); + fs::create_dir(&child).unwrap(); + fs::write( + temp.path().join(".meta"), + r#"{"projects":{"child":{"repo":"https://example.invalid/child.git","path":"child"}}}"#, + ) + .unwrap(); + + write_executable( + &plugin_dir.join("meta-tool"), + r#"#!/bin/sh +if [ "$1" = "--meta-plugin-info" ]; then + printf '%s\n' '{"name":"tool","version":"1.0.0","commands":["tool"]}' + exit 0 +fi +if [ "$1" = "--meta-plugin-exec" ]; then + IFS= read -r request || : + printf '{"plan":{"commands":[{"dir":"%s","cmd":"printf child-output"}],"parallel":false}}\n' "$META_TEST_CHILD" + exit 0 +fi +exit 1 +"#, + ); + + let output = meta_command(temp.path(), &plugin_dir, temp.path()) + .env("META_TEST_CHILD", &child) + .args(["--sequential", "--include", "child", "tool", "check"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("✓ child"), "stdout: {stdout}"); + assert!(!stdout.contains("✓ . (child)"), "stdout: {stdout}"); +} + +#[test] +fn plan_execution_policy_controls_aliases_and_host_filters() { + let temp = tempdir().unwrap(); + let plugin_dir = create_plugin_dir(temp.path()); + let marker = temp.path().join("policy-marker"); + fs::write(temp.path().join(".meta"), r#"{"projects":{}}"#).unwrap(); + fs::write( + temp.path().join(".looprc"), + r#"{"aliases":{"true":"false"}}"#, + ) + .unwrap(); + + write_executable( + &plugin_dir.join("meta-tool"), + r#"#!/bin/sh +if [ "$1" = "--meta-plugin-info" ]; then + printf '%s\n' '{"name":"tool","version":"1.0.0","commands":["tool"]}' + exit 0 +fi +if [ "$1" = "--meta-plugin-exec" ]; then + IFS= read -r request || : + printf '{"plan":{"commands":[{"dir":"%s","cmd":"true && printf policy-ok > policy-marker"}],"parallel":false},"execution_policy":{"expand_loop_aliases":false,"apply_host_filters":false}}\n' "$META_TEST_ROOT" + exit 0 +fi +exit 1 +"#, + ); + + let output = meta_command(temp.path(), &plugin_dir, temp.path()) + .env("META_TEST_ROOT", temp.path()) + .args(["--sequential", "--include", "does-not-match", "tool", "run"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(fs::read_to_string(marker).unwrap(), "policy-ok"); +} From ba1faad671e002e0f6a325d876807e8d92770ce2 Mon Sep 17 00:00:00 2001 From: Maksim Soltan Date: Tue, 14 Jul 2026 14:25:09 -0700 Subject: [PATCH 2/2] fix: keep plugin root help side-effect free [[tasks/meta-cargo-command-forwarding]] --- src/main.rs | 31 +++++++++++++++++++------------ tests/plugin_plan_policy.rs | 26 +++++++++++++++++++++++--- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/main.rs b/src/main.rs index dd8ca25..9f087ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -462,8 +462,16 @@ fn main() -> Result<()> { log::debug!("cli.json = {}", cli.json); - // Check for orphaned nested meta repo and warn the user - check_and_warn_orphan(); + // Help for an external command is metadata-only: do not inspect workspace + // configuration before plugin help has had a chance to return. + let external_help_request = matches!( + cli.command.as_ref(), + Some(Commands::External(args)) + if cli.help || args.len() == 1 || contains_help_before_separator(args) + ); + if !external_help_request { + check_and_warn_orphan(); + } // Discover plugins early to handle --help requests and plugin listing let mut subprocess_plugins = SubprocessPluginManager::new(); @@ -540,26 +548,25 @@ fn main() -> Result<()> { let has_forwarded_help = contains_help_before_separator(&args); if cli.help && !has_forwarded_help { + if let Some(first) = args.first() { + if let Some(help_text) = subprocess_plugins.get_plugin_help(first) { + println!("{help_text}"); + return Ok(()); + } + } print_help_with_plugins(&subprocess_plugins, false); return Ok(()); } - // Keep root plugin help fast and plugin-aware, but let nested help - // requests reach the matched plugin command implementation. + // Keep root and bare plugin help fast and plugin-aware, while + // nested help reaches the plugin command implementation below. if let Some(first) = args.first() { let is_bare = args.len() == 1; let is_root_help = has_forwarded_help && args.len() == 2 && matches!(args.get(1).map(String::as_str), Some("--help" | "-h")); - let promoted_commands: Vec = subprocess_plugins - .get_promoted_commands() - .iter() - .map(|(name, _, _)| name.clone()) - .collect(); - let is_promoted = promoted_commands.contains(&first.to_string()); - - if is_root_help || (is_bare && !is_promoted) { + if is_bare || is_root_help { if let Some(help_text) = subprocess_plugins.get_plugin_help(first) { println!("{help_text}"); return Ok(()); diff --git a/tests/plugin_plan_policy.rs b/tests/plugin_plan_policy.rs index d5a438b..0235e45 100644 --- a/tests/plugin_plan_policy.rs +++ b/tests/plugin_plan_policy.rs @@ -39,7 +39,7 @@ fn create_plugin_dir(root: &Path) -> PathBuf { } #[test] -fn prefix_help_does_not_execute_plugin_commands() { +fn help_before_separator_does_not_read_config_or_execute_plugin_commands() { let temp = tempdir().unwrap(); let plugin_dir = create_plugin_dir(temp.path()); let marker = temp.path().join("plugin-executed"); @@ -48,7 +48,7 @@ fn prefix_help_does_not_execute_plugin_commands() { &plugin_dir.join("meta-tool"), r#"#!/bin/sh if [ "$1" = "--meta-plugin-info" ]; then - printf '%s\n' '{"name":"tool","version":"1.0.0","commands":["tool"]}' + printf '%s\n' '{"name":"suite","version":"1.0.0","commands":["tool"]}' exit 0 fi if [ "$1" = "--meta-plugin-exec" ]; then @@ -71,7 +71,9 @@ exit 1 .output() .unwrap(); assert!(output.status.success(), "args: {args:?}"); - assert!(String::from_utf8_lossy(&output.stdout).contains("Usage:")); + assert!(String::from_utf8_lossy(&output.stdout) + .to_ascii_lowercase() + .contains("usage:")); assert!(!marker.exists(), "prefix help executed {args:?}"); } @@ -83,6 +85,24 @@ exit 1 .unwrap(); assert!(output.status.success()); assert!(!marker.exists(), "prefix help executed in a Meta workspace"); + + fs::remove_file(temp.path().join(".meta")).unwrap(); + fs::write(temp.path().join(".meta.yaml"), "projects: [malformed").unwrap(); + // `tool` is promoted because it differs from the plugin name (`suite`). + // Bare promoted roots and their root help must still avoid config parsing. + for args in [&["tool"][..], &["tool", "--help"][..]] { + let output = meta_command(temp.path(), &plugin_dir, temp.path()) + .env("META_TEST_MARKER", &marker) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "args: {args:?}; stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(!marker.exists(), "help executed {args:?}"); + } } #[test]