Skip to content
Merged
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
11 changes: 11 additions & 0 deletions docs/tasks/task-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
125 changes: 125 additions & 0 deletions e2e/tasks/test_task_local_overrides_git_include
Original file line number Diff line number Diff line change
@@ -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 <<EOF >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 <<EOF >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 <<EOF >mise.toml
[task_config]
includes = [
"first",
"second",
]
EOF

assert "mise run greet" "second wins"

echo "All tests passed!"
43 changes: 26 additions & 17 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Task>, config_tasks: Vec<Task>) -> Vec<Task> {
let mut by_name: IndexMap<String, Task> = IndexMap::new();
for t in file_tasks {
Expand Down Expand Up @@ -2472,13 +2478,15 @@ pub async fn load_tasks_in_dir(
) -> Result<Vec<Task>> {
let configs = configs_at_root(dir, config_files);

let git_includes: Vec<String> = 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 {
Expand All @@ -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)
Expand Down
Loading