From 98892a14a5df6888b21250d8ebe51a0688fbdcea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 21:01:04 +0000 Subject: [PATCH] feat(task): support exclusion patterns in task sources Tasks now accept exclusion patterns so files like tests no longer invalidate caches when only the build is meant to rebuild. Entries in `sources` prefixed with `!` are excluded, matching the convention used by gitignore, watchexec, and rsync. Patterns are evaluated in order and the latest match wins, so a later non-negated entry can re-include a file an earlier `!` excluded. Exclusions apply uniformly to source freshness checks, the `task_source_files` template function, and `mise watch` (via watchexec `--ignore`). The task's own config file is always treated as a source even if matched by an exclude, so a stray `!mise.toml` cannot silently disable invalidation. A literal `!` prefix in a path can be escaped as `\!`. Implemented on top of `ignore::gitignore::Gitignore`, which handles `!` negation, `\!` literal-escape, and order-dependent re-inclusion natively. https://claude.ai/code/session_014rVkYvsuYoP5Z3zHDQk18S --- docs/tasks/task-configuration.md | 21 +++ e2e/tasks/test_task_run_sources_negation | 80 +++++++++ e2e/tasks/test_task_validate | 20 +++ schema/mise-task.json | 12 +- schema/mise.json | 18 +- src/cli/tasks/validate.rs | 16 +- src/cli/watch.rs | 115 ++++++++++-- src/task/task_script_parser.rs | 36 +++- src/task/task_source_checker.rs | 214 ++++++++++++++++++++++- 9 files changed, 490 insertions(+), 42 deletions(-) create mode 100644 e2e/tasks/test_task_run_sources_negation diff --git a/docs/tasks/task-configuration.md b/docs/tasks/task-configuration.md index f6d3db92b3..fe9019867a 100644 --- a/docs/tasks/task-configuration.md +++ b/docs/tasks/task-configuration.md @@ -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 diff --git a/e2e/tasks/test_task_run_sources_negation b/e2e/tasks/test_task_run_sources_negation new file mode 100644 index 0000000000..bb2e19716f --- /dev/null +++ b/e2e/tasks/test_task_run_sources_negation @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +# Negation prefix in `sources` +cat <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 <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 <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 <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" diff --git a/e2e/tasks/test_task_validate b/e2e/tasks/test_task_validate index 4ccbadfc3c..8c15c3709b 100755 --- a/e2e/tasks/test_task_validate +++ b/e2e/tasks/test_task_validate @@ -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 <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 <mise.toml [tasks.valid] diff --git a/schema/mise-task.json b/schema/mise-task.json index c1d067593e..1706638e30 100644 --- a/schema/mise-task.json +++ b/schema/mise-task.json @@ -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" } ] @@ -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" } ] diff --git a/schema/mise.json b/schema/mise.json index d4a0cd5896..dca9c7563f 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -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" } ] @@ -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" } ] @@ -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" } ] diff --git a/src/cli/tasks/validate.rs b/src/cli/tasks/validate.rs index 3de6f2bb14..b293daff1c 100644 --- a/src/cli/tasks/validate.rs +++ b/src/cli/tasks/validate.rs @@ -488,17 +488,25 @@ impl TasksValidate { fn validate_source_patterns(&self, task: &Task) -> Vec { 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| { + // 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 diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 08377a3715..eb0d271534 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -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::>() + 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::>() + 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::>()); } + if !ignores.is_empty() { + args.push("--ignore".to_string()); + args.extend( + itertools::intersperse(ignores, "--ignore".to_string()).collect::>(), + ); + } args.extend([ "--".to_string(), env::MISE_BIN.to_string_lossy().to_string(), @@ -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, Vec) +where + I: IntoIterator, +{ + let mut inc = Vec::new(); + let mut exc_candidates: Vec = 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 = inc.into_iter().unique().collect(); + let exc: Vec = exc_candidates + .into_iter() + .unique() + .filter(|pat| !inc.contains(pat)) + .collect(); + (inc, exc) +} + static AFTER_LONG_HELP: &str = color_print::cstr!( r#"Examples: @@ -1220,3 +1256,56 @@ pub enum ColourMode { Never, } //endregion + +#[cfg(test)] +mod tests { + use super::merge_watch_patterns; + + fn s(v: &[&str]) -> Vec { + 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:?}", + ); + } +} diff --git a/src/task/task_script_parser.rs b/src/task/task_script_parser.rs index 20011d353c..38eaa436ff 100644 --- a/src/task/task_script_parser.rs +++ b/src/task/task_script_parser.rs @@ -500,17 +500,30 @@ impl TaskScriptParser { }); tera.register_function("task_source_files", { - let sources = Arc::new(task.sources.clone()); + let glob_patterns = Arc::new( + crate::task::task_source_checker::source_glob_patterns(&task.sources), + ); + // Anchor the matcher at the process cwd. `is_source` handles + // absolute paths outside this root by trusting the glob result, + // so absolute outside-cwd patterns (e.g. workspace-root paths) + // still flow through. + let cwd = crate::dirs::CWD + .clone() + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let matcher = Arc::new(crate::task::task_source_checker::build_source_matcher( + &cwd, + &task.sources, + )); move |_: &HashMap| -> tera::Result { - if sources.is_empty() { + if glob_patterns.is_empty() { trace!("tera::render::resolve_task_sources `task_source_files` called in task with empty sources array"); return Ok(tera::Value::Array(Default::default())); }; - let mut resolved = Vec::with_capacity(sources.len()); + let mut resolved = Vec::with_capacity(glob_patterns.len()); - for pattern in sources.iter() { + for pattern in glob_patterns.iter() { // pattern is considered a tera template string if it contains opening tags: // - "{#" for comments // - "{{" for expressions @@ -545,6 +558,15 @@ impl TaskScriptParser { match path { Ok(path) => { + if !crate::task::task_source_checker::is_source( + &matcher, &path, + ) { + trace!( + "tera::render::resolve_task_sources excluded '{}' due to !-pattern", + path.display() + ); + continue; + } let source = path.display(); trace!( "tera::render::resolve_task_sources resolved source from pattern '{pattern}': {source}" @@ -1255,6 +1277,12 @@ mod tests { "/README.md; ", ), ), + // `!` excludes a previously matched file + ( + &["**/filetask", "!**/filetask"], + "echo {{ task_source_files() }}", + "echo []", + ), ]; for (sources, template, expected) in cases { diff --git a/src/task/task_source_checker.rs b/src/task/task_source_checker.rs index 5ca3edce39..9bbf8d63ec 100644 --- a/src/task/task_source_checker.rs +++ b/src/task/task_source_checker.rs @@ -5,6 +5,7 @@ use crate::hash; use crate::task::Task; use eyre::{Result, eyre}; use glob::glob; +use ignore::overrides::{Override, OverrideBuilder}; use itertools::Itertools; use std::collections::BTreeMap; use std::fs; @@ -20,6 +21,91 @@ pub fn is_glob_pattern(path: &str) -> bool { path.chars().any(|c| glob_chars.contains(&c)) } +/// Build an [`Override`] matcher for a task's `sources` patterns. +/// +/// Patterns use gitignore syntax with `!` inverted (the [`Override`] convention, +/// see `ignore::overrides`): a non-negated entry marks a file as a *source*, +/// and a `!`-prefixed entry *excludes* it. `\!` escapes a literal leading `!`, +/// and order matters — a later non-negated entry can re-include a file an +/// earlier `!` excluded. +/// +/// Patterns that are absolute paths under `root` are rewritten to be relative, +/// matching the convention used by `Override` itself (see its tests) and the +/// `ignore::WalkBuilder`: matchers receive root-relative patterns and let +/// `matched()` strip the root from incoming paths automatically. +pub(crate) fn build_source_matcher(root: &Path, sources: &[String]) -> Override { + let mut builder = OverrideBuilder::new(root); + for s in sources { + let normalized = relativize_pattern(root, s); + if let Err(e) = builder.add(&normalized) { + warn!("invalid source pattern {s:?}: {e}"); + } + } + builder.build().unwrap_or_else(|e| { + warn!("failed to build source matcher: {e}"); + Override::empty() + }) +} + +/// If `pattern`'s body is an absolute path under `root`, rewrite it as a +/// root-relative path so the matcher can use gitignore's relative-path +/// semantics. The `!` / `\!` prefix is preserved as-is. +fn relativize_pattern(root: &Path, pattern: &str) -> String { + let (prefix, body) = if pattern.starts_with("\\!") { + // `\!body` is a literal include of a path beginning with `!`. Don't + // peek past the escape — `OverrideBuilder::add` handles it. + return pattern.to_string(); + } else if let Some(rest) = pattern.strip_prefix('!') { + ("!", rest) + } else { + ("", pattern) + }; + let body_path = Path::new(body); + if body_path.is_absolute() + && let Ok(rel) = body_path.strip_prefix(root) + && let Some(rel_str) = rel.to_str() + { + return format!("{prefix}{rel_str}"); + } + pattern.to_string() +} + +/// Returns true iff `path` is selected as a source by `matcher`. With +/// [`Override`]'s inverted semantics, a non-negated user pattern produces +/// `Match::Whitelist` for matching paths. +/// +/// Absolute paths that don't fall under the matcher's root are out of +/// gitignore's domain — `Override::matched` would return `Match::None` and, +/// when positive patterns are present, promote that to `Match::Ignore`, +/// silently dropping a file the glob legitimately included. Trust the glob +/// in that case (matching pre-PR behavior for workspace-root paths +/// referenced from sub-package tasks, etc.). +pub(crate) fn is_source(matcher: &Override, path: &Path) -> bool { + if path.is_absolute() && !path.starts_with(matcher.path()) { + return true; + } + matcher.matched(path, false).is_whitelist() +} + +/// Returns the include-side glob patterns from `sources`, suitable for file +/// enumeration via `glob`. `!`-prefixed entries are dropped (they only +/// constrain matching, not enumeration); `\!`-prefixed entries have the +/// escape removed so they can be globbed as literal `!`-prefixed paths. +pub(crate) fn source_glob_patterns(sources: &[String]) -> Vec { + sources + .iter() + .filter_map(|s| { + if s.starts_with('!') { + None + } else if let Some(rest) = s.strip_prefix("\\!") { + Some(format!("!{rest}")) + } else { + Some(s.clone()) + } + }) + .collect() +} + /// Get the last modified time from a list of paths pub(crate) fn last_modified_path(root: &Path, paths: &[&String]) -> Result> { let files = paths.iter().map(|p| { @@ -42,11 +128,12 @@ pub(crate) fn last_modified_glob_match( if patterns.is_empty() { return Ok(None); } + let root_ref = root.as_ref(); let files = patterns .iter() .flat_map(|pattern| { glob( - root.as_ref() + root_ref .join(pattern) .to_str() .expect("Conversion to string path failed"), @@ -110,9 +197,22 @@ pub async fn sources_are_fresh(task: &Task, config: &Arc) -> Result Result { let root = task_cwd(task, config).await?; - let mut sources = task.sources.clone(); - sources.push(task.config_source.to_string_lossy().to_string()); - let source_metadatas = get_file_metadatas(&root, &sources)?; + let matcher = build_source_matcher(&root, &task.sources); + let glob_patterns = source_glob_patterns(&task.sources); + let mut source_metadatas = get_file_metadatas(&root, &glob_patterns, &matcher)?; + // Always include the task's own config file as a source, regardless of + // any excludes — a stray `!mise.toml` must not silently disable invalidation. + let config_path = if task.config_source.is_absolute() { + task.config_source.clone() + } else { + root.join(&task.config_source) + }; + if let Ok(meta) = config_path.metadata() + && meta.is_file() + && !source_metadatas.iter().any(|(p, _)| p == &config_path) + { + source_metadatas.push((config_path, meta)); + } // Check if sources resolved to no files (likely a config mistake) if source_metadatas.is_empty() { @@ -246,10 +346,12 @@ fn source_existing_hash(task: &Task, root: &Path, content_hash: bool) -> Option< } } -/// Get file metadata for a list of patterns or paths +/// Get file metadata for a list of include-side patterns or paths, retaining +/// only files that `matcher` selects as a source. fn get_file_metadatas( root: &Path, patterns_or_paths: &[String], + matcher: &Override, ) -> Result> { if patterns_or_paths.is_empty() { return Ok(vec![]); @@ -277,6 +379,7 @@ fn get_file_metadatas( let metadatas = metadatas .into_iter() .filter(|(_, m)| m.is_file()) + .filter(|(p, _)| is_source(matcher, p)) .collect_vec(); Ok(metadatas) @@ -305,7 +408,8 @@ fn get_last_modified_from_metadatas(metadatas: &[(PathBuf, fs::Metadata)]) -> Op metadatas.iter().flat_map(|(_, m)| m.modified()).max() } -/// Get the last modified time from a list of patterns or paths +/// Get the last modified time from a list of patterns or paths. Used for +/// task *outputs*, which do not support exclusion patterns. fn get_last_modified(root: &Path, patterns_or_paths: &[String]) -> Result> { if patterns_or_paths.is_empty() { return Ok(None); @@ -324,3 +428,101 @@ fn get_last_modified(root: &Path, patterns_or_paths: &[String]) -> Result