Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions e2e/tasks/test_task_monorepo_wildcard_inherited
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Test that wildcard patterns (//...:task) do NOT match inherited tasks
# Only tasks explicitly defined in a subdirectory should match wildcards
# See: https://github.com/jdx/mise/discussions/6564#discussioncomment-15607613

export MISE_EXPERIMENTAL=1

# Create monorepo root with a "test" task
cat <<'TOML' >mise.toml
experimental_monorepo_root = true

[monorepo]
config_roots = ["packages/*"]

[tasks.test]
run = 'echo "root test"'

[tasks.root-only]
run = 'echo "root only task"'
TOML

# Create package-a WITH its own test task (should match //...:test)
mkdir -p packages/package-a
cat <<'TOML' >packages/package-a/mise.toml
[tasks.test]
run = 'echo "package-a test"'
TOML

# Create package-b WITHOUT its own test task (should NOT match //...:test due to inheritance)
mkdir -p packages/package-b
cat <<'TOML' >packages/package-b/mise.toml
[tasks.build]
run = 'echo "package-b build"'
TOML

# Create package-c WITH its own test task (should match //...:test)
mkdir -p packages/package-c
cat <<'TOML' >packages/package-c/mise.toml
[tasks.test]
run = 'echo "package-c test"'
TOML

mise trust --all

# --- Test 1: Wildcard should only match explicitly defined tasks ---
echo "=== Test 1: Wildcard should only match package-a and package-c (not package-b) ==="

# Should include package-a and package-c (they define test explicitly)
assert_contains "mise run '//packages/...:test'" "package-a test"
assert_contains "mise run '//packages/...:test'" "package-c test"

# Should NOT include package-b (its test is inherited from root)
assert_not_contains "mise run '//packages/...:test'" "root test"

# --- Test 2: Explicit paths still work for inherited tasks ---
echo "=== Test 2: Explicit path should work for inherited task ==="
assert_contains "mise run //packages/package-b:test" "root test"

# --- Test 3: Root wildcard with root task should still work ---
echo "=== Test 3: Root task still visible ==="
assert_contains "mise run //:test" "root test"

# --- Test 4: Task that only exists at root should NOT match //packages/...: wildcard ---
echo "=== Test 4: Root-only task should not match subdirectory wildcard ==="
# This should fail with "task not found" since no package explicitly defines root-only
assert_fail "mise run '//packages/...:root-only'"

# --- Test 5: Inherited tasks still visible in task list (with --all) ---
echo "=== Test 5: Inherited tasks visible in task list ==="
# Inherited tasks should still be listed
assert_contains "mise tasks --all" "//packages/package-b:test"
assert_contains "mise tasks --all" "//packages/package-b:root-only"

# --- Test 6: Circular dependency should NOT occur ---
# This was the original bug: root defines test = { depends = ['//...:test'] }
# which would create a cycle through inherited tasks
echo "=== Test 6: No circular dependency with wildcard depends ==="
cat <<'TOML' >mise.toml
experimental_monorepo_root = true

[monorepo]
config_roots = ["packages/*"]

[tasks.test]
depends = ['//packages/...:test']
run = 'echo "root test complete"'
description = 'run all tests'
TOML

# This should work without circular dependency error
assert_contains "mise run //:test" "package-a test"
assert_contains "mise run //:test" "package-c test"
assert_contains "mise run //:test" "root test complete"
8 changes: 8 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1726,6 +1726,14 @@ async fn load_local_tasks_with_context(
// dir = "{{cwd}}" if they want the task to run from wherever it's invoked.
let mut all_tasks: Vec<Task> = task_map.into_values().collect();

// Mark tasks as inherited if they come from a parent config
// (their config_root differs from the subdir we're loading for)
for task in all_tasks.iter_mut() {
if let Some(ref config_root) = task.config_root {
task.inherited = config_root != &subdir;
Comment on lines +1732 to +1733

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tasks without a config_root (when task.config_root is None) will have inherited remain as false by default. If tasks can legitimately have None as their config_root and should be considered inherited in certain scenarios, this logic may incorrectly classify them. Consider whether tasks with None config_root should have explicit handling.

Suggested change
if let Some(ref config_root) = task.config_root {
task.inherited = config_root != &subdir;
match task.config_root {
Some(ref config_root) => {
task.inherited = config_root != &subdir;
}
None => {
task.inherited = false;
}

Copilot uses AI. Check for mistakes.
}
}

prefix_monorepo_task_names(&mut all_tasks, &subdir, &monorepo_root);

Ok::<Vec<Task>, eyre::Report>(all_tasks)
Expand Down
8 changes: 8 additions & 0 deletions src/task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ pub struct Task {
pub hide: bool,
#[serde(default)]
pub global: bool,
/// Whether this task was inherited from a parent config in a monorepo
/// (vs explicitly defined at this path). Used to filter wildcard matches.
#[serde(skip)]
pub inherited: bool,
#[serde(default)]
pub raw: bool,
#[serde(default)]
Expand Down Expand Up @@ -1070,9 +1074,12 @@ fn match_tasks_with_context(
parent_task: Option<&Task>,
) -> Result<Vec<Task>> {
let resolved_pattern = resolve_task_pattern(&td.task, parent_task);
// When using wildcard patterns (containing "..."), filter out inherited tasks
let filter_inherited = resolved_pattern.contains("...");

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wildcard pattern detection logic is duplicated here and in task_list.rs. Consider extracting this into a shared helper function to maintain consistency and reduce duplication.

Copilot uses AI. Check for mistakes.
let matches = tasks
.get_matching(&resolved_pattern)?
.into_iter()
.filter(|t| !filter_inherited || !t.inherited)
.map(|t| {
let mut t = (*t).clone();
t.args = td.args.clone();
Expand Down Expand Up @@ -1139,6 +1146,7 @@ impl Default for Task {
dir: None,
hide: false,
global: false,
inherited: false,
raw: false,
sources: vec![],
outputs: Default::default(),
Expand Down
5 changes: 5 additions & 0 deletions src/task/task_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ pub async fn get_task_lists(
let cur_tasks = tasks_with_aliases
.get_matching(&t)?
.into_iter()
// When using wildcard patterns (containing "..."), filter out inherited tasks
// to avoid matching tasks that exist only due to inheritance from parent configs.
// This prevents issues like circular dependencies when root defines
// `test = { depends = ['//...:test'] }` and subdirs inherit that task.
.filter(|task| !t.contains("...") || !task.inherited)

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern detection logic t.contains(\"...\") is duplicated across multiple locations (here and in match_tasks_with_context). Consider extracting this into a helper function like is_wildcard_pattern to improve maintainability and ensure consistency if the wildcard detection logic needs to change.

Copilot uses AI. Check for mistakes.
.cloned()
.collect_vec();
if cur_tasks.is_empty() {
Expand Down
Loading