From 472866a755b9c140864079f5d85935a9477baeec Mon Sep 17 00:00:00 2001 From: Vivien MALEZE Date: Mon, 1 Jun 2026 11:19:06 +0200 Subject: [PATCH] fix(task): honor task_config.includes order so a later include overrides an earlier one PR #9147 introduced an early dedup in merge_file_and_config_tasks and, combined with load_tasks_in_dir loading all non-git includes before all git:: includes, ignored the declared task_config.includes order: a git:: include could no longer be overridden by a later local include (and vice versa), regardless of list position. Load includes in a single ordered pass (mirroring load_file_tasks) and keep the file-task dedup last-wins, so the later include in the list takes precedence, uniformly for directory, toml-file, and git:: includes. To override a remote git:: task with a local one, list the local directory after the git:: entry. The TOML [tasks.*] overlay behavior from #9147 is unchanged. Document the include precedence and add an e2e test covering local-last, git-last, and two same-named local includes. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/tasks/task-configuration.md | 11 ++ .../test_task_local_overrides_git_include | 125 ++++++++++++++++++ src/config/mod.rs | 43 +++--- 3 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 e2e/tasks/test_task_local_overrides_git_include diff --git a/docs/tasks/task-configuration.md b/docs/tasks/task-configuration.md index fe9019867a..4ede82ede9 100644 --- a/docs/tasks/task-configuration.md +++ b/docs/tasks/task-configuration.md @@ -690,6 +690,17 @@ For local and monorepo task discovery, mise uses the nearest config file that de That means a child config's `includes` replaces both the defaults and any `includes` defined by parent configs for that directory. Global config files are loaded independently, so each global config file uses its own `task_config.includes` or the default directories if `includes` is unset. +Entries are evaluated in order, and when more than one include defines a task with the same name the **last** entry in the list wins. +This applies uniformly to directory, toml-file, and `git::` includes, so to override a task coming from a `git::` include with a local one, list the local directory after the `git::` entry: + +```toml +[task_config] +includes = [ + "git::https://github.com/myorg/shared-tasks.git//tasks", # remote task… + ".mise/tasks", # …is overridden by the local one with the same name +] +``` + If using included task toml files, note that they have a different format than the `mise.toml` file. They are just a list of tasks. The file should be the same format as the `[tasks]` section of `mise.toml` but without the `[task]` prefix: diff --git a/e2e/tasks/test_task_local_overrides_git_include b/e2e/tasks/test_task_local_overrides_git_include new file mode 100644 index 0000000000..9a6f285572 --- /dev/null +++ b/e2e/tasks/test_task_local_overrides_git_include @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Regression test for #9147: task_config.includes precedence. +# +# Includes are loaded in their declared order and the LAST include that +# defines a given task name wins, uniformly for local and git:: includes. +# #9147 added an early dedup combined with loading all local includes before +# all git:: includes, which ignored the declared order and stopped a later +# local include from overriding a same-named remote task. This test pins down +# the fixed behavior. + +################################################################################# +# Setup: local git HTTP server serving repo.git with xtasks/lint/ripgrep +################################################################################# + +TMP_BASE="${TMPDIR:-/tmp}" +PORT_FILE="$TMP_BASE/mise_git_http_port" +READY_FILE="$TMP_BASE/mise_git_http_ready" +INFO_FILE="$TMP_BASE/mise_git_http_info" + +rm -f "$PORT_FILE" "$READY_FILE" "$INFO_FILE" + +cleanup() { + if [ -n "${SERVER_PID:-}" ]; then + kill "$SERVER_PID" 2>/dev/null || true + fi + rm -f "$PORT_FILE" "$READY_FILE" "$INFO_FILE" +} + +MISE_GIT_HTTP_PORT_FILE="$PORT_FILE" \ + MISE_GIT_HTTP_READY_FILE="$READY_FILE" \ + MISE_GIT_HTTP_INFO_FILE="$INFO_FILE" \ + python3 "${TEST_ROOT}/helpers/scripts/git_http_backend_server.py" 0 & +SERVER_PID=$! +trap cleanup EXIT + +wait_for_server() { + local max_attempts=30 + local attempt=1 + while [ $attempt -le $max_attempts ]; do + if [ -f "$READY_FILE" ] && [ -f "$PORT_FILE" ]; then + return 0 + fi + sleep 1 + attempt=$((attempt + 1)) + done + echo "ERROR: Git HTTP server failed to start within 30 seconds" + kill "$SERVER_PID" 2>/dev/null || true + exit 1 +} + +wait_for_server + +SERVER_PORT=$(cat "$PORT_FILE") +LOCAL_GIT_URL="http://localhost:${SERVER_PORT}/repo.git" + +git init + +# The remote repo's ripgrep task prints "ripgrep task executed". +# Create a local task of the same name that prints something distinct. +mkdir -p .mise/tasks +cat <<'EOF' >.mise/tasks/ripgrep +#!/usr/bin/env bash +echo "local ripgrep wins" +EOF +chmod +x .mise/tasks/ripgrep + +################################################################################# +# Local include listed LAST -> the local task wins over the remote one. +################################################################################# + +cat <mise.toml +[task_config] +includes = [ + "git::${LOCAL_GIT_URL}//xtasks/lint", + ".mise/tasks", +] +EOF + +assert "mise run ripgrep" "local ripgrep wins" +assert_contains "mise tasks info ripgrep" ".mise/tasks/ripgrep" + +mise cache clear + +################################################################################# +# git include listed LAST -> the remote task wins (order is respected). +################################################################################# + +cat <mise.toml +[task_config] +includes = [ + ".mise/tasks", + "git::${LOCAL_GIT_URL}//xtasks/lint", +] +EOF + +assert "mise run ripgrep" "ripgrep task executed" + +mise cache clear + +################################################################################# +# Two local includes: the LAST one defining the name wins. +################################################################################# + +mkdir -p first second +cat <<'EOF' >first/greet +#!/usr/bin/env bash +echo "first loses" +EOF +cat <<'EOF' >second/greet +#!/usr/bin/env bash +echo "second wins" +EOF +chmod +x first/greet second/greet + +cat <mise.toml +[task_config] +includes = [ + "first", + "second", +] +EOF + +assert "mise run greet" "second wins" + +echo "All tests passed!" diff --git a/src/config/mod.rs b/src/config/mod.rs index ffddf0d56d..581976c074 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2241,6 +2241,12 @@ async fn load_config_and_file_tasks( /// /// When a name appears in both: the file task stays as the base and the TOML /// block is overlaid via [`Task::merge_toml_overlay`]. Otherwise both are kept. +/// +/// When the same name appears in more than one file task (e.g. a local +/// `.mise/tasks` script and a same-named task from a `git::` include), the +/// last one wins. Callers load `file_tasks` in declared `task_config.includes` +/// order, so the later include in the list takes precedence — see +/// `load_tasks_in_dir`. fn merge_file_and_config_tasks(file_tasks: Vec, config_tasks: Vec) -> Vec { let mut by_name: IndexMap = IndexMap::new(); for t in file_tasks { @@ -2472,13 +2478,15 @@ pub async fn load_tasks_in_dir( ) -> Result> { let configs = configs_at_root(dir, config_files); - let git_includes: Vec = configs + let (includes, resolve_dir) = configs .iter() - .find_map(|cf| cf.task_config().includes.clone()) - .unwrap_or_default() - .into_iter() - .filter(|p| p.starts_with("git::")) - .collect(); + .find_map(|cf| { + cf.task_config() + .includes + .clone() + .map(|includes| (includes, cf.config_root())) + }) + .unwrap_or_else(|| (default_task_includes(), dir.to_path_buf())); let mut config_tasks = vec![]; for cf in &configs { @@ -2490,18 +2498,19 @@ pub async fn load_tasks_in_dir( let task_config_dir = configs.iter().find_map(|cf| cf.task_config().dir.clone()); let mut file_tasks = vec![]; - for p in task_includes_for_dir(dir, config_files) { - let mut loaded = load_tasks_includes(config, &p, dir, &task_config_dir).await?; - if is_global_task_include_path(&p) { - mark_tasks_as_global(&mut loaded); + for include in &includes { + let paths = if include.starts_with("git::") { + vec![resolve_git_url_to_path(include).await?] + } else { + expand_task_include(&resolve_dir, include) + }; + for p in paths { + let mut loaded = load_tasks_includes(config, &p, dir, &task_config_dir).await?; + if is_global_task_include_path(&p) { + mark_tasks_as_global(&mut loaded); + } + file_tasks.extend(loaded); } - file_tasks.extend(loaded); - } - - for include in git_includes { - let resolved = resolve_git_url_to_path(&include).await?; - let loaded = load_tasks_includes(config, &resolved, dir, &task_config_dir).await?; - file_tasks.extend(loaded); } let mut tasks = merge_file_and_config_tasks(file_tasks, config_tasks)