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
21 changes: 21 additions & 0 deletions docs/tasks/task-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,27 @@ has changed since the last build.
The [`task_source_files`](../templates.md#task-source-files) function can be used to iterate over a task's
`sources` within its template context.

#### Excluding sources

Entries in `sources` prefixed with `!` are excluded, matching the convention
used by gitignore, watchexec, and rsync. Exclusions affect the freshness
check, the `task_source_files` template function, and which files
`mise watch` watches for changes.

```mise-toml
[tasks.build]
sources = ["src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.spec.ts", "tsconfig.json"]
run = "npm run build"
```

Entries are evaluated in order, and the latest matching entry wins. A later
non-negated entry can re-include a file an earlier `!` excluded — for example,
`["src/**/*.ts", "!src/**/*.test.ts", "src/keep.test.ts"]` excludes all
`*.test.ts` files except `src/keep.test.ts`.

To include a literal path that begins with `!`, escape the prefix as `\!`
(e.g. `"\\!important.txt"` in TOML).

#### Dependency invalidation

When a task depends on another task that also has `sources` defined, and the dependency runs because
Expand Down
80 changes: 80 additions & 0 deletions e2e/tasks/test_task_run_sources_negation
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash

# Negation prefix in `sources`
cat <<EOF >mise.toml
[tasks.build]
sources = ["src/**/*.txt", "!src/**/*.test.txt"]
outputs = ["out.txt"]
run = "echo built > out.txt && echo built"
EOF

mkdir -p src
touch src/a.txt
assert "mise -q build" "built"
assert_empty "mise -q build"

# Editing an excluded file does NOT trigger a rebuild.
# Use `sleep 1` between mtime-sensitive operations so the test is reliable on
# filesystems with 1-second mtime granularity (older Linux kernels, HFS+, etc).
sleep 1
touch src/a.test.txt
assert_empty "mise -q build"

# Editing an included file DOES trigger a rebuild
sleep 1
touch src/a.txt
assert "mise -q build" "built"

# Multiple `!`-prefixed entries compose as a union of exclusions
cat <<EOF >mise.toml
[tasks.build]
sources = ["src/**/*.txt", "!src/**/*.test.txt", "!src/**/*.spec.txt"]
outputs = ["out.txt"]
run = "echo built > out.txt && echo built"
EOF

assert "mise -q build" "built"
assert_empty "mise -q build"
sleep 1
touch src/a.test.txt
assert_empty "mise -q build"
sleep 1
touch src/a.spec.txt
assert_empty "mise -q build"
sleep 1
touch src/a.txt
assert "mise -q build" "built"

# `!mise.toml` must not silently disable invalidation: editing the config still re-runs
cat <<EOF >mise.toml
[tasks.build]
sources = ["src/**/*.txt", "!mise.toml"]
outputs = ["out.txt"]
run = "echo built > out.txt && echo built"
EOF

assert "mise -q build" "built"
assert_empty "mise -q build"
sleep 1
touch mise.toml
assert "mise -q build" "built"

# A later non-negated entry re-includes a file an earlier `!` excluded
# (gitignore-style ordering: latest matching rule wins).
cat <<EOF >mise.toml
[tasks.build]
sources = ["src/**/*.txt", "!src/**/*.test.txt", "src/keep.test.txt"]
outputs = ["out.txt"]
run = "echo built > out.txt && echo built"
EOF

assert "mise -q build" "built"
assert_empty "mise -q build"
# Editing a still-excluded test file does NOT trigger a rebuild
sleep 1
touch src/a.test.txt
assert_empty "mise -q build"
# Editing the re-included file DOES trigger a rebuild
sleep 1
touch src/keep.test.txt
assert "mise -q build" "built"
20 changes: 20 additions & 0 deletions e2e/tasks/test_task_validate
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,26 @@ EOF
assert_contains "mise tasks validate 2>&1 || true" "Invalid source glob pattern"
assert_contains "mise tasks validate 2>&1 || true" "invalid-glob-pattern"

# Negation (`!`-prefixed) and escaped (`\!`-prefixed) source patterns are valid
# once the prefix is stripped, and must not be reported as invalid.
cat <<'EOF' >mise.toml
[tasks.good-exclusions]
run = "echo test"
sources = ["src/**/*.ts", "!src/**/*.test.ts", "!src/**/*.spec.ts", "\\!literal.txt"]
EOF

assert_contains "mise tasks validate" "✓ All 1 task(s) validated successfully"

# A `!`-prefixed pattern that is otherwise invalid should still be reported.
cat <<EOF >mise.toml
[tasks.bad-exclusion]
run = "echo test"
sources = ["!{[bad"]
EOF

assert_contains "mise tasks validate 2>&1 || true" "Invalid source glob pattern"
assert_contains "mise tasks validate 2>&1 || true" "invalid-glob-pattern"

# Test JSON output
cat <<EOF >mise.toml
[tasks.valid]
Expand Down
12 changes: 6 additions & 6 deletions schema/mise-task.json
Original file line number Diff line number Diff line change
Expand Up @@ -288,15 +288,15 @@
"sources": {
"oneOf": [
{
"description": "files that this task depends on",
"description": "files that this task depends on (entries starting with `!` are excluded; use `\\!` to escape a literal `!` prefix)",
"items": {
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
},
"type": "array"
},
{
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
}
]
Expand Down Expand Up @@ -1072,15 +1072,15 @@
"sources": {
"oneOf": [
{
"description": "files that this task depends on",
"description": "files that this task depends on (entries starting with `!` are excluded; use `\\!` to escape a literal `!` prefix)",
"items": {
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
},
"type": "array"
},
{
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
}
]
Expand Down
18 changes: 9 additions & 9 deletions schema/mise.json
Original file line number Diff line number Diff line change
Expand Up @@ -2063,15 +2063,15 @@
"sources": {
"oneOf": [
{
"description": "files that this task depends on",
"description": "files that this task depends on (entries starting with `!` are excluded; use `\\!` to escape a literal `!` prefix)",
"items": {
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
},
"type": "array"
},
{
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
}
]
Expand Down Expand Up @@ -2727,15 +2727,15 @@
"sources": {
"oneOf": [
{
"description": "files that this task depends on",
"description": "files that this task depends on (entries starting with `!` are excluded; use `\\!` to escape a literal `!` prefix)",
"items": {
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
},
"type": "array"
},
{
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
}
]
Expand Down Expand Up @@ -3043,15 +3043,15 @@
"sources": {
"oneOf": [
{
"description": "files that this task depends on",
"description": "files that this task depends on (entries starting with `!` are excluded; use `\\!` to escape a literal `!` prefix)",
"items": {
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
},
"type": "array"
},
{
"description": "glob pattern or path to files that this task depends on",
"description": "glob pattern or path to files that this task depends on (entries starting with `!` are excluded)",
"type": "string"
}
]
Expand Down
16 changes: 12 additions & 4 deletions src/cli/tasks/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -488,17 +488,25 @@ impl TasksValidate {
fn validate_source_patterns(&self, task: &Task) -> Vec<ValidationIssue> {
let mut issues = Vec::new();

for source in &task.sources {
// Try to compile as glob pattern
if let Err(e) = globset::GlobBuilder::new(source).build() {
let validate = |raw: &str, issues: &mut Vec<ValidationIssue>| {
// Strip `!` prefix (negation) or `\!` escape before validating.
let pattern = raw
.strip_prefix('!')
.or_else(|| raw.strip_prefix("\\!"))
.unwrap_or(raw);
if let Err(e) = globset::GlobBuilder::new(pattern).build() {
issues.push(ValidationIssue {
task: task.name.clone(),
severity: Severity::Error,
category: "invalid-glob-pattern".to_string(),
message: format!("Invalid source glob pattern: '{}'", source),
message: format!("Invalid source glob pattern: '{}'", raw),
details: Some(format!("{}", e)),
});
}
};

for source in &task.sources {
validate(source, &mut issues);
}

issues
Expand Down
115 changes: 102 additions & 13 deletions src/cli/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,25 +180,27 @@ impl Watch {
args.push("--watch-file".to_string());
args.push(watch_file.to_string_lossy().to_string());
}
let globs = if !self.glob.is_empty() {
self.glob.clone()
} else if self.skip_deps {
tasks
.iter()
.flat_map(|t| t.sources.iter().cloned())
.unique()
.collect::<Vec<_>>()
let (globs, ignores) = if !self.glob.is_empty() {
(self.glob.clone(), Vec::new())
} else {
let deps = Deps::new(&config, tasks.clone()).await?;
deps.all()
.flat_map(|t| t.sources.iter().cloned())
.unique()
.collect::<Vec<_>>()
let collected: Vec<_> = if self.skip_deps {
tasks.to_vec()
} else {
let deps = Deps::new(&config, tasks.clone()).await?;
deps.all().cloned().collect()
};
merge_watch_patterns(collected.iter().map(|t| t.sources.as_slice()))
};
if !globs.is_empty() {
args.push("-f".to_string());
args.extend(itertools::intersperse(globs, "-f".to_string()).collect::<Vec<_>>());
}
if !ignores.is_empty() {
args.push("--ignore".to_string());
args.extend(
itertools::intersperse(ignores, "--ignore".to_string()).collect::<Vec<_>>(),
);
}
args.extend([
"--".to_string(),
env::MISE_BIN.to_string_lossy().to_string(),
Expand Down Expand Up @@ -256,6 +258,40 @@ impl Watch {
}
}

/// Merge each task's `sources` into the (filter, ignore) pair watchexec
/// expects.
///
/// Watchexec doesn't model gitignore-style re-inclusion, so the per-task
/// semantics from `task_source_checker` collapse here into a flat union.
/// To avoid one task's `!pat` silently suppressing another task's literal
/// positive `pat`, an exclude is dropped from the global ignore list when
/// the same pattern appears as a positive include in any watched task.
fn merge_watch_patterns<'a, I>(task_sources: I) -> (Vec<String>, Vec<String>)
where
I: IntoIterator<Item = &'a [String]>,
{
let mut inc = Vec::new();
let mut exc_candidates: Vec<String> = Vec::new();
for sources in task_sources {
for s in sources {
if let Some(rest) = s.strip_prefix('!') {
exc_candidates.push(rest.to_string());
} else if let Some(rest) = s.strip_prefix("\\!") {
inc.push(format!("!{rest}"));
} else {
inc.push(s.clone());
}
}
}
let inc: Vec<String> = inc.into_iter().unique().collect();
let exc: Vec<String> = exc_candidates
.into_iter()
.unique()
.filter(|pat| !inc.contains(pat))
.collect();
(inc, exc)
}

static AFTER_LONG_HELP: &str = color_print::cstr!(
r#"<bold><underline>Examples:</underline></bold>

Expand Down Expand Up @@ -1220,3 +1256,56 @@ pub enum ColourMode {
Never,
}
//endregion

#[cfg(test)]
mod tests {
use super::merge_watch_patterns;

fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
}

#[test]
fn merge_single_task_splits_pos_and_neg() {
let task = s(&["src/**/*.ts", "!src/**/*.test.ts"]);
let (inc, exc) = merge_watch_patterns(std::iter::once(task.as_slice()));
assert_eq!(inc, vec!["src/**/*.ts"]);
assert_eq!(exc, vec!["src/**/*.test.ts"]);
}

#[test]
fn merge_unescapes_literal_bang() {
let task = s(&["\\!keep.txt"]);
let (inc, exc) = merge_watch_patterns(std::iter::once(task.as_slice()));
assert_eq!(inc, vec!["!keep.txt"]);
assert!(exc.is_empty());
}

#[test]
fn merge_dedupes_across_tasks() {
let a = s(&["src/**/*.ts", "!src/**/*.test.ts"]);
let b = s(&["src/**/*.ts", "!src/**/*.test.ts"]);
let (inc, exc) = merge_watch_patterns([a.as_slice(), b.as_slice()]);
assert_eq!(inc, vec!["src/**/*.ts"]);
assert_eq!(exc, vec!["src/**/*.test.ts"]);
}

/// Regression: one task's `!pat` must not suppress another task's
/// positive `pat`. Watchexec's `--ignore` runs after `--filter`, so a
/// pattern that is positively wanted by any task is removed from the
/// global ignore list.
#[test]
fn merge_does_not_let_one_task_exclude_anothers_include() {
let a = s(&["src/**/*.ts", "!src/**/*.test.ts"]);
let b = s(&["src/**/*.test.ts"]);
let (inc, exc) = merge_watch_patterns([a.as_slice(), b.as_slice()]);
assert!(inc.contains(&"src/**/*.ts".to_string()));
assert!(inc.contains(&"src/**/*.test.ts".to_string()));
// `src/**/*.test.ts` is positively included by task B, so it must not
// appear in the ignore list — even though task A asks to exclude it.
assert!(
!exc.contains(&"src/**/*.test.ts".to_string()),
"exc should not contain a pattern that any task positively includes; got {exc:?}",
);
}
}
Loading
Loading