diff --git a/src/main.rs b/src/main.rs index b7beca9..6df8fdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -462,13 +462,24 @@ fn main() -> Result<()> { log::debug!("cli.json = {}", cli.json); - // Check for orphaned nested meta repo and warn the user - check_and_warn_orphan(); - // Discover plugins early to handle --help requests and plugin listing let mut subprocess_plugins = SubprocessPluginManager::new(); subprocess_plugins.discover_plugins(cli.verbose)?; + // External help is metadata-only. Let the matched plugin answer it before + // workspace discovery so malformed or absent config cannot hide help. + let external_help_request = matches!( + cli.command.as_ref(), + Some(Commands::External(args)) + if cli.help + || contains_help_before_separator(args) + || (args.len() == 1 + && subprocess_plugins.is_bare_help_command(&args[0])) + ); + if !external_help_request { + check_and_warn_orphan(); + } + // Handle --help flag at top level if cli.help && cli.command.is_none() { print_help_with_plugins(&subprocess_plugins, false); @@ -538,12 +549,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")); @@ -554,14 +570,15 @@ fn main() -> Result<()> { .collect(); let is_promoted = promoted_commands.contains(&first.to_string()); - if is_root_help || (is_bare && !is_promoted) { + let declared_bare_help = subprocess_plugins.is_bare_help_command(first); + if is_root_help || (is_bare && (!is_promoted || declared_bare_help)) { if let Some(help_text) = subprocess_plugins.get_plugin_help(first) { println!("{help_text}"); return Ok(()); } } - if wants_help { + if has_forwarded_help { let command_str = args.join(" "); let options = PluginRequestOptions { json_output: cli.json, @@ -785,10 +802,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 +819,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 +835,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 +967,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 +1304,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 +1344,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 +1487,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..fa680c8 100644 --- a/src/subprocess_plugins.rs +++ b/src/subprocess_plugins.rs @@ -4,14 +4,18 @@ //! This approach provides better isolation, language flexibility, and simpler debugging. use anyhow::{Context, Result}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; 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, +}; +use meta_plugin_protocol::{ + PlanResponseWithPolicy as HostPluginResponse, + PluginRequestWithCapabilities as HostPluginRequest, }; /// A discovered subprocess plugin @@ -24,9 +28,29 @@ pub struct SubprocessPlugin { /// Manager for subprocess-based plugins pub struct SubprocessPluginManager { plugins: HashMap, + bare_help_commands: HashSet, verbose: bool, } +#[derive(Default, serde::Deserialize)] +struct PluginDiscoveryExtensions { + #[serde(default)] + bare_help_commands: Vec, +} + +/// 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() @@ -37,6 +61,7 @@ impl SubprocessPluginManager { pub fn new() -> Self { Self { plugins: HashMap::new(), + bare_help_commands: HashSet::new(), verbose: false, } } @@ -144,6 +169,8 @@ impl SubprocessPluginManager { Ok(info) => info, Err(_) => return Ok(()), // Not a valid plugin, skip silently }; + let extensions: PluginDiscoveryExtensions = + serde_json::from_slice(&output.stdout).unwrap_or_default(); if self.verbose { println!( @@ -156,6 +183,12 @@ impl SubprocessPluginManager { // Don't override if already loaded (first one wins) if !self.plugins.contains_key(&info.name) { + self.bare_help_commands.extend( + extensions + .bare_help_commands + .into_iter() + .filter(|command| info.commands.contains(command)), + ); self.plugins.insert( info.name.clone(), SubprocessPlugin { @@ -172,6 +205,11 @@ impl SubprocessPluginManager { Ok(()) } + /// Whether a plugin declares a bare command root as metadata-only help. + pub fn is_bare_help_command(&self, command: &str) -> bool { + self.bare_help_commands.contains(command) + } + /// Check if any plugin handles the given command #[allow(dead_code)] pub fn handles_command(&self, command: &str) -> bool { @@ -202,6 +240,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 +286,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 +299,7 @@ impl SubprocessPluginManager { command: &str, args: &[String], projects: &[String], + root_dir: Option<&Path>, options: &PluginRequestOptions, ) -> Result { // Extract the remaining args after the matched command @@ -257,11 +308,12 @@ impl SubprocessPluginManager { let cmd_word_count = command.split_whitespace().count(); let remaining_args: Vec = args.iter().skip(cmd_word_count).cloned().collect(); - let request = PluginRequest { + let request = HostPluginRequest { command: command.to_string(), 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(), }; @@ -310,10 +362,10 @@ impl SubprocessPluginManager { } // Parse the plugin response - match serde_json::from_str::(&stdout_str) { + 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 +376,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 +403,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 +425,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 +445,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 +470,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 +490,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}"); } } @@ -709,11 +772,12 @@ mod tests { #[test] fn test_plugin_request_serialization() { - let request = PluginRequest { + let request = HostPluginRequest { command: "git status".to_string(), 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 +791,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 +809,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(); @@ -1080,11 +1171,12 @@ mod tests { #[test] fn test_plugin_request_with_dry_run() { - let request = PluginRequest { + let request = HostPluginRequest { command: "git status".to_string(), args: vec![], projects: vec!["proj1".to_string()], cwd: "/workspace".to_string(), + host_capabilities: vec![], options: PluginRequestOptions { json_output: false, verbose: false, @@ -1100,11 +1192,12 @@ mod tests { #[test] fn test_plugin_request_all_options_enabled() { - let request = PluginRequest { + let request = HostPluginRequest { command: "build".to_string(), 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..4f9e3c6 --- /dev/null +++ b/tests/plugin_plan_policy.rs @@ -0,0 +1,390 @@ +#![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 nested_plugin_help_skips_workspace_config_and_reaches_plugin() { + 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.yaml"), "projects: [malformed").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' 'tool-owned nested help' + 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"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "tool-owned nested help" + ); + + 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"])); + assert_eq!(request["projects"], serde_json::json!([])); +} + +#[test] +fn declared_bare_help_is_metadata_only_without_reclassifying_other_commands() { + let temp = tempdir().unwrap(); + let plugin_dir = create_plugin_dir(temp.path()); + let marker = temp.path().join("plugin-executed"); + fs::write(temp.path().join(".meta.yaml"), "projects: [malformed").unwrap(); + + write_executable( + &plugin_dir.join("meta-suite"), + r#"#!/bin/sh +if [ "$1" = "--meta-plugin-info" ]; then + printf '%s\n' '{"name":"suite","version":"1.0.0","commands":["tool"],"bare_help_commands":["tool"],"help":{"usage":"meta 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(temp.path(), &plugin_dir, temp.path()) + .env("META_TEST_MARKER", &marker) + .arg("tool") + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("meta tool ")); + assert!( + !marker.exists(), + "declared bare help executed the plugin command" + ); + + fs::remove_file(temp.path().join(".meta.yaml")).unwrap(); + fs::write(temp.path().join(".meta"), r#"{"projects":{}}"#).unwrap(); + write_executable( + &plugin_dir.join("meta-runner"), + r#"#!/bin/sh +if [ "$1" = "--meta-plugin-info" ]; then + printf '%s\n' '{"name":"runner","version":"1.0.0","commands":["action"]}' + 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(temp.path(), &plugin_dir, temp.path()) + .env("META_TEST_MARKER", &marker) + .arg("action") + .output() + .unwrap(); + assert!(output.status.success()); + assert!( + marker.exists(), + "unlisted promoted command was reclassified as help" + ); +} + +#[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"); +}