diff --git a/TASK_CACHE_PARITY.md b/TASK_CACHE_PARITY.md index 6b58076fbd6..1d3eda89bd1 100644 --- a/TASK_CACHE_PARITY.md +++ b/TASK_CACHE_PARITY.md @@ -84,7 +84,7 @@ outside this tracker. - [x] Cache useful results for tasks with no filesystem outputs - [x] Support reusable and global input groups - [x] Support global environment inputs and pass-through environment variables -- [ ] Support negative input and output patterns +- [x] Support negative input and output patterns - [ ] Support runtime-command inputs - [ ] Include external dependency and lockfile state through explicit input configuration - [ ] Add per-run cache read/write controls, including local-only and cache-disabled modes diff --git a/docs/tasks/task-configuration.md b/docs/tasks/task-configuration.md index 073512c9aaf..f44b45c385f 100644 --- a/docs/tasks/task-configuration.md +++ b/docs/tasks/task-configuration.md @@ -472,6 +472,21 @@ otherwise `sources` on the dependent task would be effectively useless. The counterpart to `sources`, these are the files or directories that the task will create/modify after it executes. +Entries prefixed with `!` exclude matching outputs. As with `sources`, entries +are evaluated in order, a later entry can re-include a path, and `\!` escapes a +literal leading bang. + +```mise-toml +[tasks.build] +run = "npm run build" +sources = ["src/**"] +outputs = ["dist", "!dist/**/*.map", "!dist/.vite/**"] +``` + +Excluded files do not participate in output freshness checks and are not +stored in task-cache artifacts. If excluded files already exist beneath an +output directory when a cached artifact is restored, mise preserves them. + `auto = true` is an alternative to specifying output files manually. In that case, mise will touch an internally tracked file based on the hash of the task definition (stored in `~/.local/state/mise/task-outputs/` if you're curious). This is useful if you want `mise run` to execute when sources change but don't want to have to manually `touch` @@ -498,8 +513,8 @@ to reproduce. Artifact caching requires [`experimental`](/configuration/settings.html#experimental), at least one matching `source`, and either explicit output paths or `outputs = []`. -`outputs = { auto = true }`, absolute outputs, and outputs that escape the task directory are not -supported. +`outputs = { auto = true }`, absolute outputs, and output patterns (including +the body of an exclusion) that escape the task directory are not supported. ```mise-toml [settings] diff --git a/e2e/tasks/test_task_artifact_cache_negative_patterns b/e2e/tasks/test_task_artifact_cache_negative_patterns new file mode 100644 index 00000000000..1c88aed9800 --- /dev/null +++ b/e2e/tasks/test_task_artifact_cache_negative_patterns @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +cat <<'EOF' >mise.toml +[settings] +experimental = true +task.source_freshness_hash_contents = true + +[tasks.build] +run = ''' +mkdir -p dist/private +cp src/input.txt dist/result.txt +printf 'generated map\n' >dist/result.map +printf 'generated token\n' >dist/private/token +printf 'ran\n' >>runs.txt +''' +sources = ["src/**/*", "!src/ignored.txt"] +outputs = ["dist", "!dist/**/*.map", "!dist/private/**"] +cache = { enabled = true } +EOF + +mkdir -p src +printf 'one\n' >src/input.txt +printf 'ignored one\n' >src/ignored.txt +mise cache clear + +mise run build +assert "wc -l src/ignored.txt +printf 'local map\n' >dist/result.map +printf 'local token\n' >dist/private/token +rm dist/result.txt +assert_contains "mise run build 2>&1" "restored outputs from cache" +assert "wc -l &1" "restored outputs from cache" +assert "cat dist/result.txt" "one" +assert_fail "test -e dist/result.map" +assert_fail "test -e dist/private/token" + +# Included inputs still invalidate the cache. +printf 'two changed\n' >src/input.txt +rm -rf dist +mise run build +assert "wc -l mise.toml +[settings] +experimental = true + +[tasks.build] +run = "mkdir -p dist" +sources = ["input"] +outputs = ["dist", "!../out"] +cache = { enabled = true } +EOF + +assert_fail_contains "mise run build" "cache output must stay within" + +cat <<'EOF' >mise.toml +[settings] +experimental = true + +[tasks.build] +run = "mkdir -p dist" +sources = ["input"] +outputs = ["!dist/**"] +cache = { enabled = true } +EOF + +assert_fail_contains "mise run build" "outputs require at least one non-excluded pattern" + mkdir outside ln -s outside build cat <<'EOF' >mise.toml diff --git a/schema/mise-task.json b/schema/mise-task.json index 9d06b9e63c3..2eefcacf95a 100644 --- a/schema/mise-task.json +++ b/schema/mise-task.json @@ -160,15 +160,15 @@ "outputs": { "oneOf": [ { - "description": "files created by this task", + "description": "files created by this task (entries starting with `!` are excluded; use `\\!` to escape a literal `!` prefix)", "items": { - "description": "glob pattern or path to files created by this task", + "description": "glob pattern or path to files created by this task (entries starting with `!` are excluded)", "type": "string" }, "type": "array" }, { - "description": "glob pattern or path to files created by this task", + "description": "glob pattern or path to files created by this task (entries starting with `!` are excluded)", "type": "string" }, { diff --git a/schema/mise.json b/schema/mise.json index 431e7d1343b..bcca079bed6 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -2927,15 +2927,15 @@ "outputs": { "oneOf": [ { - "description": "files created by this task", + "description": "files created by this task (entries starting with `!` are excluded; use `\\!` to escape a literal `!` prefix)", "items": { - "description": "glob pattern or path to files created by this task", + "description": "glob pattern or path to files created by this task (entries starting with `!` are excluded)", "type": "string" }, "type": "array" }, { - "description": "glob pattern or path to files created by this task", + "description": "glob pattern or path to files created by this task (entries starting with `!` are excluded)", "type": "string" }, { diff --git a/src/task/task_cache.rs b/src/task/task_cache.rs index a6d326aae64..6557771e831 100644 --- a/src/task/task_cache.rs +++ b/src/task/task_cache.rs @@ -2,11 +2,14 @@ use crate::config::{Config, Settings}; use crate::dirs; use crate::file::{self, ExtractOptions, ExtractionFormat}; use crate::hash; -use crate::task::task_source_checker::{task_cache_inputs, task_cwd}; +use crate::task::task_source_checker::{ + build_output_matcher, is_output, output_glob_patterns, task_cache_inputs, task_cwd, +}; use crate::task::{RunEntry, Task}; use crate::toolset::Toolset; use eyre::{Context, Report, Result, bail, eyre}; use glob::glob; +use ignore::overrides::Override; use jdx_tar::{Builder, EntryType, Header}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -243,7 +246,14 @@ impl TaskArtifactCache { for rel in &remove { ensure_no_symlink_ancestors(&self.root, rel)?; } - install_transactionally(&self.root, staging.path(), &manifest.roots, &remove)?; + let output_matcher = build_output_matcher(&self.root, &task.outputs.patterns())?; + install_transactionally( + &self.root, + staging.path(), + &manifest.roots, + &remove, + Some(&output_matcher), + )?; if let Err(err) = file::touch_file(&archive_path) { warn!("failed to update task cache archive access time: {err}"); } @@ -285,7 +295,13 @@ impl TaskArtifactCache { output: output.to_vec(), }; if !manifest.roots.is_empty() { - write_archive(&archive_partial, &self.root, &manifest.roots)?; + let output_matcher = build_output_matcher(&self.root, &task.outputs.patterns())?; + write_archive( + &archive_partial, + &self.root, + &manifest.roots, + &output_matcher, + )?; } fs::write(&manifest_partial, serde_json::to_vec(&manifest)?)?; if !manifest.roots.is_empty() { @@ -325,9 +341,22 @@ fn validate_config(task: &Task, root: &Path) -> Result<()> { task.name ); } - for output in task.outputs.patterns() { - let path = Path::new(&output); - ensure_safe_relative(path).wrap_err_with(|| { + let patterns = task.outputs.patterns(); + if !patterns.is_empty() && output_glob_patterns(&patterns).is_empty() { + bail!( + "task {} cache outputs require at least one non-excluded pattern", + task.name + ); + } + for output in &patterns { + let path = if let Some(body) = output.strip_prefix('!') { + PathBuf::from(body) + } else if let Some(body) = output.strip_prefix("\\!") { + PathBuf::from(format!("!{body}")) + } else { + PathBuf::from(&output) + }; + ensure_safe_relative(&path).wrap_err_with(|| { format!( "task {} cache output must stay within {}: {output}", task.name, @@ -335,6 +364,8 @@ fn validate_config(task: &Task, root: &Path) -> Result<()> { ) })?; } + build_output_matcher(root, &patterns) + .wrap_err_with(|| format!("task {} has an invalid cache output pattern", task.name))?; Ok(()) } @@ -358,26 +389,35 @@ fn ensure_safe_relative(path: &Path) -> Result<()> { fn resolve_output_roots(task: &Task, root: &Path, require_matches: bool) -> Result> { let mut resolved = BTreeSet::new(); - for output in task.outputs.patterns() { + let patterns = task.outputs.patterns(); + let matcher = build_output_matcher(root, &patterns)?; + for output in output_glob_patterns(&patterns) { ensure_safe_relative(Path::new(&output))?; if crate::task::task_source_checker::is_glob_pattern(&output) { - let mut matched = false; + let mut glob_matched = false; for entry in glob(root.join(&output).to_str().unwrap_or_default())? { let path = entry?; + glob_matched = true; let rel = path.strip_prefix(root)?.to_path_buf(); ensure_safe_relative(&rel)?; - resolved.insert(rel); - matched = true; + let is_dir = fs::symlink_metadata(&path)?.is_dir(); + if is_output(&matcher, &path, is_dir) { + resolved.insert(rel); + } } - if require_matches && !matched { + if require_matches && !glob_matched { bail!("output pattern {output:?} matched no files"); } } else { let rel = PathBuf::from(&output); - if require_matches - && !root.join(&rel).exists() - && fs::symlink_metadata(root.join(&rel)).is_err() - { + let abs = root.join(&rel); + let is_dir = fs::symlink_metadata(&abs) + .map(|metadata| metadata.is_dir()) + .unwrap_or(false); + if !is_output(&matcher, &abs, is_dir) { + continue; + } + if require_matches && !abs.exists() && fs::symlink_metadata(&abs).is_err() { bail!("output {} does not exist", rel.display()); } resolved.insert(rel); @@ -424,6 +464,7 @@ fn install_transactionally( staging: &Path, install_roots: &[PathBuf], remove_roots: &[PathBuf], + output_matcher: Option<&Override>, ) -> Result<()> { let backup = tempfile::Builder::new() .prefix(".mise-task-cache-backup-") @@ -455,6 +496,12 @@ fn install_transactionally( backed_up.push(rel.clone()); } + if let Some(matcher) = output_matcher + && let Err(err) = copy_excluded_outputs(backup.path(), staging, &backed_up, matcher) + { + return rollback_after_error(backup, root, &[], &backed_up, err); + } + let mut installed = Vec::new(); for rel in install_roots { let from = staging.join(rel); @@ -475,6 +522,64 @@ fn install_transactionally( Ok(()) } +/// Merge files excluded from the cache back into the staged artifact before +/// replacing output roots. This keeps local-only output files intact on a +/// cache hit while selected files still come entirely from the artifact. +fn copy_excluded_outputs( + backup: &Path, + staging: &Path, + roots: &[PathBuf], + matcher: &Override, +) -> Result<()> { + let mut directories = Vec::new(); + for root in roots { + let backup_root = backup.join(root); + for entry in WalkDir::new(&backup_root).follow_links(false) { + let entry = entry?; + let from = entry.path(); + let rel = from.strip_prefix(backup)?; + let metadata = fs::symlink_metadata(from)?; + if is_output(matcher, &matcher.path().join(rel), metadata.is_dir()) { + continue; + } + let to = staging.join(rel); + ensure_no_symlink_ancestors(staging, rel)?; + if metadata.is_dir() { + file::create_dir_all(&to)?; + directories.push(( + to, + metadata.permissions(), + filetime::FileTime::from_last_access_time(&metadata), + filetime::FileTime::from_last_modification_time(&metadata), + )); + } else if metadata.file_type().is_symlink() { + if let Some(parent) = to.parent() { + file::create_dir_all(parent)?; + } + if fs::symlink_metadata(&to).is_err() { + file::make_symlink(&fs::read_link(from)?, &to)?; + } + } else if metadata.is_file() { + if let Some(parent) = to.parent() { + file::create_dir_all(parent)?; + } + fs::copy(from, &to)?; + fs::set_permissions(&to, metadata.permissions())?; + filetime::set_file_times( + &to, + filetime::FileTime::from_last_access_time(&metadata), + filetime::FileTime::from_last_modification_time(&metadata), + )?; + } + } + } + for (path, permissions, accessed, modified) in directories.into_iter().rev() { + fs::set_permissions(&path, permissions)?; + filetime::set_file_times(&path, accessed, modified)?; + } + Ok(()) +} + fn rollback_after_error( backup: tempfile::TempDir, root: &Path, @@ -532,7 +637,12 @@ fn rollback_install( complete } -fn write_archive(path: &Path, root: &Path, roots: &[PathBuf]) -> Result<()> { +fn write_archive( + path: &Path, + root: &Path, + roots: &[PathBuf], + output_matcher: &Override, +) -> Result<()> { let file = File::create(path)?; let encoder = zstd::Encoder::new(file, 0)?; let mut archive = Builder::new(encoder); @@ -544,7 +654,9 @@ fn write_archive(path: &Path, root: &Path, roots: &[PathBuf]) -> Result<()> { let abs = entry.path().to_path_buf(); let rel = abs.strip_prefix(root)?.to_path_buf(); ensure_safe_relative(&rel)?; - entries.insert(rel, abs); + if is_output(output_matcher, &abs, entry.file_type().is_dir()) { + entries.insert(rel, abs); + } } } @@ -632,6 +744,25 @@ mod tests { ); } + #[test] + fn output_roots_allow_glob_matches_that_are_all_excluded() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("dist")).unwrap(); + fs::write(root.path().join("dist/vendor.js"), "vendor").unwrap(); + let task = Task { + outputs: crate::task::task_sources::TaskOutputs::Files(vec![ + "dist/*.js".to_string(), + "!dist/vendor.js".to_string(), + ]), + ..Default::default() + }; + + assert_eq!( + resolve_output_roots(&task, root.path(), true).unwrap(), + Vec::::new() + ); + } + #[test] fn failed_install_restores_previous_outputs() { let root = tempfile::tempdir().unwrap(); @@ -647,6 +778,7 @@ mod tests { staging.path(), &[PathBuf::from("dist"), PathBuf::from("missing")], &[PathBuf::from("dist")], + None, ) .is_err() ); @@ -656,6 +788,49 @@ mod tests { ); } + #[test] + fn install_preserves_outputs_excluded_from_cache() { + let root = tempfile::tempdir().unwrap(); + let staging = tempfile::tempdir_in(root.path()).unwrap(); + fs::create_dir_all(root.path().join("dist/private")).unwrap(); + fs::write(root.path().join("dist/result.txt"), "old").unwrap(); + fs::write(root.path().join("dist/result.map"), "local map").unwrap(); + fs::write(root.path().join("dist/private/token"), "local token").unwrap(); + fs::create_dir(staging.path().join("dist")).unwrap(); + fs::write(staging.path().join("dist/result.txt"), "cached").unwrap(); + let matcher = build_output_matcher( + root.path(), + &[ + "dist".to_string(), + "!dist/**/*.map".to_string(), + "!dist/private/**".to_string(), + ], + ) + .unwrap(); + + install_transactionally( + root.path(), + staging.path(), + &[PathBuf::from("dist")], + &[PathBuf::from("dist")], + Some(&matcher), + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(root.path().join("dist/result.txt")).unwrap(), + "cached" + ); + assert_eq!( + fs::read_to_string(root.path().join("dist/result.map")).unwrap(), + "local map" + ); + assert_eq!( + fs::read_to_string(root.path().join("dist/private/token")).unwrap(), + "local token" + ); + } + #[test] fn incomplete_rollback_preserves_backup_directory() { let root = tempfile::tempdir().unwrap(); diff --git a/src/task/task_source_checker.rs b/src/task/task_source_checker.rs index fa898ba4437..78cd5e96cac 100644 --- a/src/task/task_source_checker.rs +++ b/src/task/task_source_checker.rs @@ -4,7 +4,7 @@ use crate::file::{self, display_path}; use crate::hash; use crate::rand::random_string; use crate::task::Task; -use eyre::{Result, eyre}; +use eyre::Result; use flate2::Compression; use flate2::read::ZlibDecoder; use flate2::write::ZlibEncoder; @@ -151,11 +151,42 @@ pub(crate) fn source_glob_patterns(sources: &[String]) -> Vec { .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| resolve_task_path(root, p)); +/// Build an ordered matcher for task output patterns. +/// +/// Output entries use the same syntax as sources: `!` excludes, `\!` escapes +/// a literal leading bang, and the last matching pattern wins. Each pattern +/// also applies to descendants because a matched output directory is handled +/// recursively by freshness checks and artifact caching. +pub(crate) fn build_output_matcher(root: &Path, outputs: &[String]) -> Result { + let mut builder = OverrideBuilder::new(root); + for output in outputs { + let output = normalize_pattern(root, root, output); + builder.add(&output)?; + let descendant = if let Some(body) = output.strip_prefix('!') { + format!("!{body}/**") + } else if let Some(body) = output.strip_prefix("\\!") { + format!("\\!{body}/**") + } else { + format!("{output}/**") + }; + if !output.ends_with("/**") { + builder.add(&descendant)?; + } + } + Ok(builder.build()?) +} - last_modified_file(files) +/// Return the include-side output patterns used to enumerate output roots. +pub(crate) fn output_glob_patterns(outputs: &[String]) -> Vec { + source_glob_patterns(outputs) +} + +/// Returns true when an output path is selected by the ordered matcher. +pub(crate) fn is_output(matcher: &Override, path: &Path, is_dir: bool) -> bool { + if path.is_absolute() && !path.starts_with(matcher.path()) { + return true; + } + matcher.matched(path, is_dir).is_whitelist() } fn resolve_task_path(root: &Path, path: impl AsRef) -> PathBuf { @@ -167,52 +198,6 @@ fn resolve_task_path(root: &Path, path: impl AsRef) -> PathBuf { } } -/// Get the last modified time from files matching glob patterns -pub(crate) fn last_modified_glob_match( - root: impl AsRef, - patterns: &[&String], -) -> Result> { - if patterns.is_empty() { - return Ok(None); - } - let root_ref = root.as_ref(); - let files = patterns - .iter() - .flat_map(|pattern| { - let pattern = resolve_task_path(root_ref, pattern); - glob(pattern.to_str().expect("Conversion to string path failed")).unwrap() - }) - .filter_map(|e| e.ok()) - .filter(|e| { - e.metadata() - .expect("Metadata call failed") - .file_type() - .is_file() - }); - - last_modified_file(files) -} - -/// Get the last modified time from an iterator of file paths -pub(crate) fn last_modified_file( - files: impl IntoIterator, -) -> Result> { - Ok(files - .into_iter() - .unique() - .filter(|p| p.exists()) - .map(|p| { - p.metadata() - .map_err(|err| eyre!("{}: {}", display_path(p), err)) - }) - .collect::>>()? - .into_iter() - .map(|m| m.modified().map_err(|err| eyre!(err))) - .collect::>>()? - .into_iter() - .max()) -} - /// Get the working directory for a task pub async fn task_cwd(task: &Task, config: &Arc) -> Result { if let Some(d) = task.dir(config).await? { @@ -478,7 +463,7 @@ pub async fn save_checksum(task: &Task, config: &Arc) -> Result<()> { } } else { // Warn if any explicitly declared output was not generated. - for output in task.outputs.paths(task, &root) { + for output in output_glob_patterns(&task.outputs.paths(task, &root)) { let output_exists = if is_glob_pattern(&output) { let pattern = root.join(&output); glob(pattern.to_str().unwrap_or_default()) @@ -603,7 +588,9 @@ fn output_existing_hash(task: &Task, root: &Path) -> Option { /// outputs that `(path, size)` or `(path, size, mtime)` would miss. /// Directory outputs (static or glob-matched) are walked recursively. fn compute_output_hash(task: &Task, root: &Path) -> Result> { - let patterns_or_paths = task.outputs.paths(task, root); + let raw_patterns = task.outputs.paths(task, root); + let matcher = build_output_matcher(root, &raw_patterns)?; + let patterns_or_paths = output_glob_patterns(&raw_patterns); if patterns_or_paths.is_empty() { return Ok(None); } @@ -623,7 +610,11 @@ fn compute_output_hash(task: &Task, root: &Path) -> Result> { /// so that additions/deletions of empty nested directories are detected. /// Symlinked directories are followed so content changes inside them are /// caught. Returns `true` when at least one entry was found. - fn push_dir_entries(dir: &Path, entries: &mut Vec<(PathBuf, String)>) -> Result { + fn push_dir_entries( + dir: &Path, + entries: &mut Vec<(PathBuf, String)>, + matcher: &Override, + ) -> Result { let mut found_any = false; for entry in WalkDir::new(dir).follow_links(true).into_iter() { let entry = entry?; @@ -631,6 +622,9 @@ fn compute_output_hash(task: &Task, root: &Path) -> Result> { if path == dir { continue; // skip the root directory itself } + if !is_output(matcher, path, entry.file_type().is_dir()) { + continue; + } if entry.file_type().is_file() { entries.push(hash_file(path)?); found_any = true; @@ -652,54 +646,76 @@ fn compute_output_hash(task: &Task, root: &Path) -> Result> { } }; match path.metadata() { - Ok(m) if m.is_file() => entries.push(hash_file(&path)?), + Ok(m) if m.is_file() => { + if is_output(&matcher, &path, false) { + entries.push(hash_file(&path)?); + } else { + continue; + } + } Ok(m) if m.is_dir() => { - if !push_dir_entries(&path, &mut entries)? { + if !push_dir_entries(&path, &mut entries, &matcher)? + && is_output(&matcher, &path, true) + { // Empty directory — sentinel so its deletion is detected. entries.push((path, "empty-dir".to_string())); } } - Ok(_) => entries.push((path, "other".to_string())), - Err(_) => return Ok(None), // missing → outputs incomplete + Ok(_) => { + if is_output(&matcher, &path, false) { + entries.push((path, "other".to_string())); + } + } + Err(_) => { + if is_output(&matcher, &path, false) || is_output(&matcher, &path, true) { + return Ok(None); // selected and missing → outputs incomplete + } + } } } for pattern_str in glob_pats { let full = root.join(pattern_str.as_str()); - let mut matched = false; + let mut glob_matched = false; for entry in glob(full.to_str().unwrap_or_default())? { // Propagate glob resolution errors (OS errors during directory // reads) rather than silently skipping them — a partial result // could produce the same hash as a complete one. let path = entry?; - match path.metadata() { - Ok(m) if m.is_file() => { + glob_matched = true; + let metadata = match path.metadata() { + Ok(metadata) => metadata, + Err(_) => { + if !is_output(&matcher, &path, false) && !is_output(&matcher, &path, true) { + continue; + } + return Ok(None); // selected and unreadable → outputs incomplete + } + }; + if !is_output(&matcher, &path, metadata.is_dir()) { + continue; + } + match metadata { + m if m.is_file() => { entries.push(hash_file(&path)?); - matched = true; } - Ok(m) if m.is_dir() => { - if !push_dir_entries(&path, &mut entries)? { + m if m.is_dir() => { + let found = push_dir_entries(&path, &mut entries, &matcher)?; + if !found { entries.push((path, "empty-dir".to_string())); } - matched = true; } - Ok(_) => { + _ => { entries.push((path, "other".to_string())); - matched = true; } - Err(_) => return Ok(None), // unreadable entry → treat as incomplete } } // A glob that matches nothing means expected outputs are missing. - if !matched { + if !glob_matched { return Ok(None); } } - if entries.is_empty() { - return Ok(None); - } - entries.sort_by(|(a, _), (b, _)| a.cmp(b)); Ok(Some(hash::hash_to_str(&entries))) } @@ -856,19 +872,43 @@ 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. Used for -/// task *outputs*, which do not support exclusion patterns. +/// Get the last modified time from selected task outputs. fn get_last_modified(root: &Path, patterns_or_paths: &[String]) -> Result> { if patterns_or_paths.is_empty() { return Ok(None); } - let (patterns, paths): (Vec<&String>, Vec<&String>) = - patterns_or_paths.iter().partition(|p| is_glob_pattern(p)); - - let last_mod = std::cmp::max( - last_modified_glob_match(root, &patterns)?, - last_modified_path(root, &paths)?, - ); + let matcher = build_output_matcher(root, patterns_or_paths)?; + let mut file_modified = Vec::new(); + let mut directory_modified = Vec::new(); + for pattern in output_glob_patterns(patterns_or_paths) { + let candidates = if is_glob_pattern(&pattern) { + glob( + resolve_task_path(root, &pattern) + .to_str() + .unwrap_or_default(), + )? + .collect::, _>>()? + } else { + vec![resolve_task_path(root, &pattern)] + }; + for candidate in candidates { + if fs::symlink_metadata(&candidate).is_err() { + continue; + } + for entry in WalkDir::new(candidate).follow_links(true) { + let entry = entry?; + let metadata = entry.metadata()?; + if is_output(&matcher, entry.path(), metadata.is_dir()) { + if metadata.is_dir() { + directory_modified.push(metadata.modified()?); + } else { + file_modified.push(metadata.modified()?); + } + } + } + } + } + let last_mod = file_modified.into_iter().chain(directory_modified).max(); trace!( "last_modified of {}: {last_mod:?}", @@ -888,6 +928,93 @@ mod tests { is_source(&matcher, Path::new(path)) } + #[test] + fn output_matcher_excludes_and_reincludes_descendants() { + let root = Path::new("/project"); + let patterns = vec![ + "dist".to_string(), + "!dist/**/*.map".to_string(), + "dist/keep.map".to_string(), + ]; + let matcher = build_output_matcher(root, &patterns).unwrap(); + + assert!(is_output(&matcher, &root.join("dist/app.js"), false)); + assert!(!is_output(&matcher, &root.join("dist/app.map"), false)); + assert!(is_output(&matcher, &root.join("dist/nested/app.js"), false)); + assert!(is_output(&matcher, &root.join("dist/keep.map"), false)); + } + + #[test] + fn output_matcher_normalizes_absolute_patterns_under_root() { + let root = tempfile::tempdir().unwrap(); + let output = root.path().join("dist/result.txt"); + let patterns = vec![format!("{}/dist/**/*", root.path().display())]; + let matcher = build_output_matcher(root.path(), &patterns).unwrap(); + + assert!(is_output(&matcher, &output, false)); + } + + #[test] + fn output_globs_ignore_excludes_and_unescape_literal_bangs() { + assert_eq!( + output_glob_patterns(&[ + "dist".to_string(), + "!dist/**/*.map".to_string(), + "\\!important".to_string(), + ]), + ["dist", "!important"] + ); + } + + #[test] + fn output_mtime_includes_selected_directories() { + let root = tempfile::tempdir().unwrap(); + let dist = root.path().join("dist"); + let output = dist.join("result.txt"); + fs::create_dir(&dist).unwrap(); + fs::write(&output, "result").unwrap(); + let file_mtime = filetime::FileTime::from_unix_time(100, 0); + let directory_mtime = filetime::FileTime::from_unix_time(200, 0); + filetime::set_file_mtime(&output, file_mtime).unwrap(); + filetime::set_file_mtime(&dist, directory_mtime).unwrap(); + + let modified = get_last_modified(root.path(), &["dist".to_string()]) + .unwrap() + .unwrap(); + + assert_eq!(modified, SystemTime::from(directory_mtime)); + } + + #[test] + fn output_hash_allows_missing_excluded_static_paths() { + let root = tempfile::tempdir().unwrap(); + let task = Task { + outputs: crate::task::task_sources::TaskOutputs::Files(vec![ + "missing.txt".to_string(), + "!missing.txt".to_string(), + ]), + ..Default::default() + }; + + assert!(compute_output_hash(&task, root.path()).unwrap().is_some()); + } + + #[test] + fn output_hash_allows_glob_matches_that_are_all_excluded() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("dist")).unwrap(); + fs::write(root.path().join("dist/vendor.js"), "vendor").unwrap(); + let task = Task { + outputs: crate::task::task_sources::TaskOutputs::Files(vec![ + "dist/*.js".to_string(), + "!dist/vendor.js".to_string(), + ]), + ..Default::default() + }; + + assert!(compute_output_hash(&task, root.path()).unwrap().is_some()); + } + #[test] fn task_state_key_includes_all_definition_sources() { let root = Path::new("/project");