Skip to content

Commit 4aeca61

Browse files
committed
feat(tasks): add extend mode for task includes
1 parent d68ba51 commit 4aeca61

7 files changed

Lines changed: 196 additions & 37 deletions

File tree

docs/tasks/file-tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ In addition to defining tasks through the configuration, they can also be define
44

55
- `mise-tasks/:task_name`
66
- `.mise-tasks/:task_name`
7-
- `mise/tasks/:task_name`
87
- `.mise/tasks/:task_name`
98
- `.config/mise/tasks/:task_name`
9+
- `mise/tasks/:task_name`
1010

1111
These are the default file-task directories. If [`task_config.includes`](/tasks/task-configuration.html#task-config-includes)
1212
is set for the current config scope, mise searches only the paths listed there instead.

docs/tasks/task-configuration.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,19 @@ For local and monorepo task discovery, mise uses the nearest config file that de
641641
That means a child config's `includes` replaces both the defaults and any `includes` defined by parent configs for that directory.
642642
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.
643643

644+
Set `task_config.includes_mode = "extend"` on the config file whose `task_config.includes` is selected to add `includes` to the defaults and the other config files for the same task scope:
645+
646+
```toml
647+
[task_config]
648+
includes = [
649+
"mytasks",
650+
"tasks.toml",
651+
]
652+
includes_mode = "extend"
653+
```
654+
655+
With `includes_mode = "extend"`, mise keeps the default file-task directories and merges the `task_config.includes` entries from every config file in that task scope. If `includes_mode = "extend"` is only set on a config file whose `includes` are replaced by another config file, it has no effect.
656+
644657
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.
645658
The file should be the same format as the `[tasks]` section of `mise.toml` but without the `[task]` prefix:
646659

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env bash
2+
3+
cat <<'TOML' >mise.toml
4+
[task_config]
5+
includes = ["tasks.toml", "root-tasks"]
6+
includes_mode = "extend"
7+
TOML
8+
9+
cat <<'TOML' >tasks.toml
10+
[root-file-task]
11+
run = 'echo "root file task"'
12+
TOML
13+
14+
mkdir -p root-tasks
15+
cat <<'SCRIPT' >root-tasks/root-dir-task
16+
#!/usr/bin/env bash
17+
echo "root dir task"
18+
SCRIPT
19+
chmod +x root-tasks/root-dir-task
20+
21+
mkdir -p .mise/tasks
22+
cat <<'SCRIPT' >.mise/tasks/default-task
23+
#!/usr/bin/env bash
24+
echo "default task"
25+
SCRIPT
26+
chmod +x .mise/tasks/default-task
27+
28+
cat <<'TOML' >mise.local.toml
29+
[task_config]
30+
includes = ["tasks.local.toml", "local-tasks"]
31+
TOML
32+
33+
cat <<'TOML' >tasks.local.toml
34+
[local-file-task]
35+
run = 'echo "local file task"'
36+
TOML
37+
38+
mkdir -p local-tasks
39+
cat <<'SCRIPT' >local-tasks/local-dir-task
40+
#!/usr/bin/env bash
41+
echo "local dir task"
42+
SCRIPT
43+
chmod +x local-tasks/local-dir-task
44+
45+
assert_contains "mise tasks ls" "default-task"
46+
assert_contains "mise tasks ls" "root-file-task"
47+
assert_contains "mise tasks ls" "root-dir-task"
48+
assert_contains "mise tasks ls" "local-file-task"
49+
assert_contains "mise tasks ls" "local-dir-task"
50+
51+
cat <<'TOML' >mise.toml
52+
[task_config]
53+
includes = ["tasks.toml", "root-tasks"]
54+
TOML
55+
56+
assert_not_contains "mise tasks ls" "default-task"
57+
assert_contains "mise tasks ls" "root-file-task"
58+
assert_contains "mise tasks ls" "root-dir-task"
59+
assert_not_contains "mise tasks ls" "local-file-task"
60+
assert_not_contains "mise tasks ls" "local-dir-task"
61+
62+
cat <<'TOML' >mise.local.toml
63+
[task_config]
64+
includes = ["tasks.local.toml", "local-tasks"]
65+
includes_mode = "extend"
66+
TOML
67+
68+
assert_not_contains "mise tasks ls" "default-task"
69+
assert_contains "mise tasks ls" "root-file-task"
70+
assert_contains "mise tasks ls" "root-dir-task"
71+
assert_not_contains "mise tasks ls" "local-file-task"
72+
assert_not_contains "mise tasks ls" "local-dir-task"

e2e/tasks/test_task_remote_git_includes

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,4 +131,31 @@ assert_contains "mise tasks" "ripgrep"
131131
assert_succeed "mise run local_task"
132132
assert_succeed "mise run ripgrep"
133133

134+
mise cache clear # Clear cache to force redownload
135+
136+
#################################################################################
137+
# Test extend mode with local override and parent remote git include
138+
#################################################################################
139+
140+
cat <<EOF >mise.toml
141+
[task_config]
142+
includes = [
143+
"git::${LOCAL_GIT_URL}//xtasks/lint"
144+
]
145+
includes_mode = "extend"
146+
EOF
147+
148+
cat <<'EOF' >mise.local.toml
149+
[task_config]
150+
includes = [
151+
"local-tasks"
152+
]
153+
EOF
154+
155+
# Both local and parent remote tasks should be available in extend mode
156+
assert_contains "mise tasks" "local_task"
157+
assert_contains "mise tasks" "ripgrep"
158+
assert_succeed "mise run local_task"
159+
assert_succeed "mise run ripgrep"
160+
134161
echo "All tests passed!"

src/config/config_file/mise_toml.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -940,7 +940,10 @@ impl Debug for MiseToml {
940940
if !self.plugins.is_empty() {
941941
d.field("plugins", &self.plugins);
942942
}
943-
if self.task_config.includes.is_some() {
943+
if self.task_config.includes.is_some()
944+
|| self.task_config.includes_mode != Default::default()
945+
|| self.task_config.dir.is_some()
946+
{
944947
d.field("task_config", &self.task_config);
945948
}
946949
d.finish()

src/config/config_file/mod.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,9 +607,20 @@ impl Hash for dyn ConfigFile {
607607
}
608608
}
609609

610+
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
611+
#[serde(rename_all = "lowercase")]
612+
pub enum TaskIncludesMode {
613+
#[default]
614+
Replace,
615+
#[serde(alias = "additive")]
616+
Extend,
617+
}
618+
610619
#[derive(Clone, Debug, Default, Deserialize)]
611620
pub struct TaskConfig {
612621
pub includes: Option<Vec<String>>,
622+
#[serde(default)]
623+
pub includes_mode: TaskIncludesMode,
613624
pub dir: Option<String>,
614625
}
615626

src/config/mod.rs

Lines changed: 68 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use crate::cli::version;
2020
use crate::config::config_file::idiomatic_version::IdiomaticVersionFile;
2121
use crate::config::config_file::min_version::MinVersionSpec;
2222
use crate::config::config_file::mise_toml::{MiseToml, Tasks};
23-
use crate::config::config_file::{ConfigFile, config_trust_root};
23+
use crate::config::config_file::{ConfigFile, TaskIncludesMode, config_trust_root};
2424
use crate::config::env_directive::{EnvResolveOptions, EnvResults, ToolsFilter};
2525
use crate::config::tracking::Tracker;
2626
use crate::env::{MISE_DEFAULT_CONFIG_FILENAME, MISE_DEFAULT_TOOL_VERSIONS_FILENAME};
@@ -2333,11 +2333,16 @@ async fn load_file_tasks(
23332333
cf: Arc<dyn ConfigFile>,
23342334
config_root: &Path,
23352335
) -> Result<Vec<Task>> {
2336-
let includes = cf
2337-
.task_config()
2338-
.includes
2339-
.clone()
2340-
.unwrap_or_else(default_task_includes);
2336+
let includes = match cf.task_config().includes.clone() {
2337+
Some(includes) if cf.task_config().includes_mode == TaskIncludesMode::Extend => {
2338+
default_task_includes()
2339+
.into_iter()
2340+
.chain(includes)
2341+
.collect_vec()
2342+
}
2343+
Some(includes) => includes,
2344+
None => default_task_includes(),
2345+
};
23412346

23422347
let mut tasks = vec![];
23432348
let config_root = Arc::new(config_root.to_path_buf());
@@ -2363,27 +2368,9 @@ async fn load_file_tasks(
23632368
}
23642369

23652370
pub fn task_includes_for_dir(dir: &Path, config_files: &ConfigMap) -> Vec<PathBuf> {
2366-
let configs = configs_at_root(dir, config_files);
2367-
2368-
// Find the first config that has explicit task_config.includes
2369-
// and resolve paths relative to that config file's directory
2370-
let (includes, resolve_dir) = configs
2371-
.iter()
2372-
.rev()
2373-
.find_map(|cf| {
2374-
cf.task_config().includes.clone().map(|includes| {
2375-
// Resolve relative paths from the config root, not the config file's directory
2376-
(includes, cf.config_root())
2377-
})
2378-
})
2379-
.unwrap_or_else(|| {
2380-
// Default includes should be resolved relative to the search directory
2381-
(default_task_includes(), dir.to_path_buf())
2382-
});
2383-
2384-
includes
2371+
task_include_specs_for_dir(dir, config_files)
23852372
.into_iter()
2386-
.flat_map(|p| {
2373+
.flat_map(|(resolve_dir, p)| {
23872374
// Git URLs are handled by load_file_tasks, not here
23882375
if p.starts_with("git::") {
23892376
return vec![];
@@ -2394,22 +2381,68 @@ pub fn task_includes_for_dir(dir: &Path, config_files: &ConfigMap) -> Vec<PathBu
23942381
.collect::<Vec<_>>()
23952382
}
23962383

2384+
fn task_include_specs_for_dir(dir: &Path, config_files: &ConfigMap) -> Vec<(PathBuf, String)> {
2385+
let configs = configs_at_root(dir, config_files);
2386+
2387+
match configs
2388+
.iter()
2389+
.rev()
2390+
.find(|cf| cf.task_config().includes.is_some())
2391+
{
2392+
Some(cf) if cf.task_config().includes_mode == TaskIncludesMode::Extend => {
2393+
let mut includes = default_task_includes()
2394+
.into_iter()
2395+
.map(|p| (dir.to_path_buf(), p))
2396+
.collect_vec();
2397+
2398+
for cf in configs.iter().rev() {
2399+
if let Some(cf_includes) = cf.task_config().includes.as_ref() {
2400+
let resolve_dir = cf.config_root();
2401+
includes.extend(
2402+
cf_includes
2403+
.iter()
2404+
.cloned()
2405+
.map(|p| (resolve_dir.clone(), p)),
2406+
);
2407+
}
2408+
}
2409+
2410+
includes
2411+
}
2412+
Some(cf) => {
2413+
let resolve_dir = cf.config_root();
2414+
cf.task_config()
2415+
.includes
2416+
.clone()
2417+
.unwrap_or_default()
2418+
.into_iter()
2419+
.map(|p| (resolve_dir.clone(), p))
2420+
.collect_vec()
2421+
}
2422+
None => default_task_includes()
2423+
.into_iter()
2424+
.map(|p| (dir.to_path_buf(), p))
2425+
.collect_vec(),
2426+
}
2427+
}
2428+
2429+
fn task_git_includes_for_dir(dir: &Path, config_files: &ConfigMap) -> Vec<String> {
2430+
task_include_specs_for_dir(dir, config_files)
2431+
.into_iter()
2432+
.map(|(_, p)| p)
2433+
.filter(|p| p.starts_with("git::"))
2434+
.unique()
2435+
.collect()
2436+
}
2437+
23972438
pub async fn load_tasks_in_dir(
23982439
config: &Arc<Config>,
23992440
dir: &Path,
24002441
config_files: &ConfigMap,
24012442
templates: &IndexMap<String, TaskTemplate>,
24022443
) -> Result<Vec<Task>> {
24032444
let configs = configs_at_root(dir, config_files);
2404-
2405-
let git_includes: Vec<String> = configs
2406-
.iter()
2407-
.rev()
2408-
.find_map(|cf| cf.task_config().includes.clone())
2409-
.unwrap_or_default()
2410-
.into_iter()
2411-
.filter(|p| p.starts_with("git::"))
2412-
.collect();
2445+
let git_includes = task_git_includes_for_dir(dir, config_files);
24132446

24142447
let mut config_tasks = vec![];
24152448
for cf in &configs {

0 commit comments

Comments
 (0)