diff --git a/e2e/tasks/test_task_wildcard_no_self_match b/e2e/tasks/test_task_wildcard_no_self_match new file mode 100644 index 0000000000..dd0cf43fdb --- /dev/null +++ b/e2e/tasks/test_task_wildcard_no_self_match @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# Test that task glob pattern "test:*" does not match the "test" task itself. +# This prevents circular invocation when a parent task delegates to its children. +# See: https://github.com/jdx/mise/discussions/8138 + +cat <<'EOF' >mise.toml +[tasks."test:foo"] +run = 'echo foo' + +[tasks."test:bar"] +run = 'echo bar' + +[tasks.test] +run = [ + { task = "test:*" }, +] +EOF + +# "mise run test" should run test:foo and test:bar without circular dependency +assert_contains "mise run test" "foo" +assert_contains "mise run test" "bar" diff --git a/src/task/mod.rs b/src/task/mod.rs index ab9a720183..cfbd50254f 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -1335,6 +1335,7 @@ where // Split pattern into path and task parts // Pattern format: //path/...:task* or //path:task* let parts: Vec<&str> = normalized_pat.splitn(2, ':').collect(); + let has_explicit_task_glob = parts.len() > 1; let (path_pattern, task_pattern) = match parts.as_slice() { [path, task] => (*path, *task), [path] => (*path, "*"), @@ -1415,8 +1416,13 @@ where }; // Match task part with asterisk support and extension stripping + // When the pattern explicitly uses a wildcard after `:` (e.g., "test:*"), + // require the key to actually have a task part (i.e., contain a `:` + // separator). This prevents "test" from matching "test:*", which would + // cause circular dependencies. Implicit wildcards (bare names like "test") + // should still match the exact task. let task_matches = if task_glob == "*" { - true + !has_explicit_task_glob || !key_task.is_empty() } else if let Some(ref matcher) = task_matcher { // Check exact match OR match without extension matcher.is_match(key_task) || matcher.is_match(strip_extension(key_task)) @@ -1441,7 +1447,7 @@ where }; let rel_task_matches = if task_glob == "*" { - true + !has_explicit_task_glob || !stripped_task.is_empty() } else if let Some(ref matcher) = rel_task_matcher { // Check exact match OR match without extension matcher.is_match(stripped_task) @@ -2341,4 +2347,27 @@ echo "test" assert_eq!(task.tools.get("git-cliff").unwrap(), "1.0"); assert_eq!(task.tools.get("1password-cli").unwrap(), "2.0"); } + + #[test] + fn test_get_matching_wildcard_does_not_match_parent() { + use std::collections::BTreeMap; + + use super::GetMatchingExt; + + let mut tasks: BTreeMap = BTreeMap::new(); + tasks.insert("test".to_string(), "test".to_string()); + tasks.insert("test:foo".to_string(), "test:foo".to_string()); + tasks.insert("test:bar".to_string(), "test:bar".to_string()); + + // "test:*" should match "test:foo" and "test:bar" but NOT "test" itself + let matches = tasks.get_matching("test:*").unwrap(); + assert_eq!( + matches, + vec![&"test:bar".to_string(), &"test:foo".to_string()] + ); + + // Bare name "test" should still match the "test" task (implicit wildcard) + let matches = tasks.get_matching("test").unwrap(); + assert!(matches.contains(&&"test".to_string())); + } }