diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e456d69..51ed141ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Changed** When a task isn't cached because it wrote a file it also read, `vp run --last-details` now says the task read and wrote the file, and shows the `cache: { input, output }` exclusions that let the task be cached ([#784](https://github.com/voidzero-dev/vite-task/pull/784)). - **Changed** The run summary now says a task that wrote a file it also read was `not cached because it modified its inputs`, and the statistics in `vp run --verbose` and `vp run --last-details` use the singular for a count of one, e.g. `1 task • 1 cache miss` ([#783](https://github.com/voidzero-dev/vite-task/pull/783)). - **Fixed** An invalid glob in `--filter` no longer shows its error message twice ([#763](https://github.com/voidzero-dev/vite-task/pull/763)). - **Changed** The detailed summary from `vp run --verbose` and `vp run --last-details` now shows each underlying cause of an error on its own line ([#761](https://github.com/voidzero-dev/vite-task/pull/761)). diff --git a/crates/vt/src/session/reporter/summary.rs b/crates/vt/src/session/reporter/summary.rs index 21c75d5e4..36e89a05c 100644 --- a/crates/vt/src/session/reporter/summary.rs +++ b/crates/vt/src/session/reporter/summary.rs @@ -11,7 +11,7 @@ use std::{fmt::Display, io::Write, num::NonZeroI32, time::Duration}; use owo_colors::Style; use serde::{Deserialize, Serialize}; -use vt_path::AbsolutePath; +use vt_path::{AbsolutePath, RelativePath}; use vt_str::Str; use super::{CACHE_MISS_STYLE, COMMAND_STYLE, ColorizeExt}; @@ -109,7 +109,7 @@ pub enum SpawnOutcome { infra_error: Option, /// First path that was both read and written, causing cache to be skipped. /// Only set when fspy detected a read-write overlap. - input_modified_path: Option, + input_modified: Option, /// `true` when the task required fspy auto-inference but the binary was /// built without `cfg(fspy)` (e.g., cross-compiled to an unsupported OS). /// Task ran successfully but cache was not updated. @@ -140,6 +140,16 @@ pub enum SpawnOutcome { SpawnError(SavedError), } +/// A path that a task both read and wrote. +#[derive(Serialize, Deserialize)] +pub struct InputModified { + /// Relative to the workspace root. + path: Str, + /// Relative to the task's package directory, or `None` if the path is + /// outside it. + path_in_package: Option, +} + /// Why a cache miss occurred. #[derive(Serialize, Deserialize)] pub enum SavedCacheMissReason { @@ -229,7 +239,7 @@ impl SummaryStats { SpawnOutcome::Success { infra_error: Some(_), .. } | SpawnOutcome::Failed { .. } | SpawnOutcome::SpawnError(_) => stats.failed += 1, - SpawnOutcome::Success { input_modified_path: Some(_), .. } => { + SpawnOutcome::Success { input_modified: Some(_), .. } => { stats.input_modified_task_names.push(task.format_task_display()); } SpawnOutcome::Success { .. } => {} @@ -302,15 +312,18 @@ impl TaskResult { /// `exit_status`: the process exit status, or `None` for cache hit / in-process. /// `saved_error`: an optional pre-converted execution error. /// `cache_update_status`: the post-execution cache update result. + /// `package_path`, `workspace_path`: locate a modified input in the task's package. pub fn from_execution( cache_status: &CacheStatus, exit_status: Option, saved_error: Option<&SavedError>, cache_update_status: &CacheUpdateStatus, + package_path: &AbsolutePath, + workspace_path: &AbsolutePath, ) -> Self { - let input_modified_path = match cache_update_status { + let input_modified = match cache_update_status { CacheUpdateStatus::NotUpdated(CacheNotUpdatedReason::InputModified { path }) => { - Some(Str::from(path.as_str())) + Some(InputModified::new(path, package_path, workspace_path)) } _ => None, }; @@ -348,7 +361,7 @@ impl TaskResult { outcome: spawn_outcome_from_execution( exit_status, saved_error, - input_modified_path, + input_modified, fspy_unsupported, ipc_server_error, tool_disabled_cache, @@ -363,7 +376,7 @@ impl TaskResult { outcome: spawn_outcome_from_execution( exit_status, saved_error, - input_modified_path, + input_modified, fspy_unsupported, ipc_server_error, tool_disabled_cache, @@ -383,7 +396,7 @@ impl TaskResult { fn spawn_outcome_from_execution( exit_status: Option, saved_error: Option<&SavedError>, - input_modified_path: Option, + input_modified: Option, fspy_unsupported: bool, ipc_server_error: Option, tool_disabled_cache: bool, @@ -396,7 +409,7 @@ fn spawn_outcome_from_execution( // Process exited successfully, possible infra error (Some(status), _) if status.success() => SpawnOutcome::Success { infra_error: saved_error.cloned(), - input_modified_path, + input_modified, fspy_unsupported, ipc_server_error, tool_disabled_cache, @@ -418,7 +431,7 @@ fn spawn_outcome_from_execution( // If we somehow get here, treat as success. (None, None) => SpawnOutcome::Success { infra_error: None, - input_modified_path: None, + input_modified: None, fspy_unsupported: false, ipc_server_error: None, tool_disabled_cache: false, @@ -547,13 +560,10 @@ impl TaskResult { return (Str::from("→ Not cached: the task opted out of caching"), &[]); } - // Check for input modification next — it overrides the cache miss reason - if let Self::Spawned { - outcome: SpawnOutcome::Success { input_modified_path: Some(path), .. }, - .. - } = self - { - return (vt_str::format!("→ Not cached: read and wrote '{path}'"), &[]); + // Check for input modification next — it overrides the cache miss reason. + // The caller shows how to exclude the path below this line. + if let Some(InputModified { path, .. }) = self.input_modified() { + return (vt_str::format!("→ Not cached: the task read and wrote '{path}'"), &[]); } // Tracking came up short, so the inferred inputs and outputs would // have been a subset of what the task touched. @@ -641,6 +651,16 @@ impl TaskResult { } } + /// The path the task both read and wrote, which kept it from being cached. + const fn input_modified(&self) -> Option<&InputModified> { + match self { + Self::Spawned { outcome: SpawnOutcome::Success { input_modified, .. }, .. } => { + input_modified.as_ref() + } + _ => None, + } + } + /// The IPC server error that caused the cache to be skipped, if any. const fn ipc_server_error(&self) -> Option<&SavedError> { match self { @@ -837,6 +857,10 @@ pub fn format_full_summary(summary: &LastRunSummary) -> Vec { let (cache_detail, causes) = task.result.format_cache_detail(); let _ = writeln!(buf, " {}", cache_detail.style(detail_style)); write_causes(&mut buf, causes, detail_style); + if let Some(entry) = task.result.input_modified().and_then(InputModified::exclude_entry) + { + write_input_modified_hint(&mut buf, &entry); + } } if let Some(error) = task.result.upload_error() { @@ -897,6 +921,61 @@ fn write_causes(buf: &mut Vec, causes: &[Str], style: Style) { } } +/// Write the `cache` settings that exclude a path the task read and wrote, +/// below a task detail line. `entry` is from [`InputModified::exclude_entry`]. +/// Both lists need `{ auto: true }`: without it, a list of exclusions alone +/// would turn off automatic tracking. +fn write_input_modified_hint(buf: &mut Vec, entry: &str) { + let _ = writeln!( + buf, + " {}", + "If this file is temporary or shouldn't affect caching, exclude it (or a glob matching it) in the task's `cache` config:" + .style(Style::new().bright_black()) + ); + for field in ["input", "output"] { + let _ = writeln!( + buf, + " {}", + vt_str::format!("{field}: [{{ auto: true }}, {entry}],").style(COMMAND_STYLE) + ); + } +} + +impl InputModified { + /// `path` is relative to `workspace_path`. + fn new( + path: &RelativePath, + package_path: &AbsolutePath, + workspace_path: &AbsolutePath, + ) -> Self { + let path_in_package = + package_path.strip_prefix(workspace_path).ok().flatten().and_then(|package_dir| { + path.strip_prefix(&package_dir).map(|p| Str::from(p.as_str())) + }); + Self { path: Str::from(path.as_str()), path_in_package } + } + + /// The `input`/`output` entry that excludes this path, written as a JS + /// value. Paths outside the package, and the package directory itself, + /// need the workspace as their base. + /// + /// `None` for the workspace root, which a task reads and writes when it + /// opens the root directory for both. The empty pattern for it would + /// resolve to `**` and exclude every file. + fn exclude_entry(&self) -> Option { + // `serde_json` quotes the pattern as a string literal that is also valid JS. + let quote = |path: &str| { + let pattern = vt_str::format!("!{}", wax::escape(path)); + vt_str::format!("{}", serde_json::Value::from(pattern.as_str())) + }; + match self.path_in_package.as_deref() { + Some(path) if !path.is_empty() => Some(quote(path)), + _ if self.path.is_empty() => None, + _ => Some(vt_str::format!("{{ pattern: {}, base: \"workspace\" }}", quote(&self.path))), + } + } +} + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Compact summary rendering (default mode) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -1046,6 +1125,8 @@ fn format_upload_failed_notice(buf: &mut Vec, failures: &[UploadFailure]) { #[cfg(test)] mod tests { + use vt_path::RelativePathBuf; + use super::*; use crate::session::event::ExecutionError; @@ -1066,7 +1147,7 @@ mod tests { cache_status: SpawnedCacheStatus::Miss(SavedCacheMissReason::NotFound), outcome: SpawnOutcome::Success { infra_error: None, - input_modified_path: None, + input_modified: None, fspy_unsupported: false, ipc_server_error: None, tracking_incomplete: false, @@ -1101,7 +1182,7 @@ mod tests { cache_status: SpawnedCacheStatus::Miss(SavedCacheMissReason::NotFound), outcome: SpawnOutcome::Success { infra_error: None, - input_modified_path: None, + input_modified: None, fspy_unsupported: false, ipc_server_error: None, tracking_incomplete: false, @@ -1112,6 +1193,29 @@ mod tests { } } + /// A task in the package at `package_dir` that read and wrote `path`, both + /// relative to the workspace root. + fn input_modified_task(path: &str, package_dir: &str) -> TaskSummary { + #[cfg(unix)] + let workspace = AbsolutePath::new("/ws").unwrap(); + #[cfg(windows)] + let workspace = AbsolutePath::new(r"C:\ws").unwrap(); + let package = if package_dir.is_empty() { + workspace.to_absolute_path_buf() + } else { + workspace.join(package_dir) + }; + let mut task = cache_miss_task("a"); + if let TaskResult::Spawned { + outcome: SpawnOutcome::Success { input_modified, .. }, .. + } = &mut task.result + { + *input_modified = + Some(InputModified::new(&RelativePathBuf::new(path).unwrap(), &package, workspace)); + } + task + } + fn compact_summary(tasks: Vec) -> Str { strip(&format_compact_summary(&LastRunSummary { tasks, exit_code: 0 }, "vp")) } @@ -1120,6 +1224,76 @@ mod tests { strip(&format_full_summary(&LastRunSummary { tasks, exit_code: 0 })) } + #[test] + fn compact_summary_says_a_task_modified_its_inputs() { + assert_eq!( + compact_summary(vec![input_modified_task("src/data.txt", "")]).as_str(), + "---\nvp run: pkg#a not cached because it modified its inputs. \ + (Run `vp run --last-details` for full details)\n" + ); + } + + #[test] + fn full_summary_shows_how_to_exclude_a_modified_input() { + let summary = + full_summary(vec![input_modified_task("packages/a/src/data.txt", "packages/a")]); + assert!( + summary.as_str().contains( + "\n → Not cached: the task read and wrote 'packages/a/src/data.txt'\n \ + If this file is temporary or shouldn't affect caching, exclude it (or a glob \ + matching it) in the task's `cache` config:\n \ + input: [{ auto: true }, \"!src/data.txt\"],\n \ + output: [{ auto: true }, \"!src/data.txt\"],\n" + ), + "{summary}" + ); + } + + #[test] + fn modified_input_outside_the_package_is_excluded_from_the_workspace() { + let summary = + full_summary(vec![input_modified_task("node_modules/.cache/x", "packages/a")]); + assert!( + summary.as_str().contains( + "\n input: [{ auto: true }, \ + { pattern: \"!node_modules/.cache/x\", base: \"workspace\" }],\n" + ), + "{summary}" + ); + } + + #[test] + fn modified_package_directory_is_excluded_from_the_workspace() { + let summary = full_summary(vec![input_modified_task("packages/a", "packages/a")]); + assert!( + summary.as_str().contains( + "\n input: [{ auto: true }, \ + { pattern: \"!packages/a\", base: \"workspace\" }],\n" + ), + "{summary}" + ); + } + + /// An empty pattern would resolve to `**` and exclude every file. + #[test] + fn modified_workspace_root_has_no_exclusion() { + for package_dir in ["", "packages/a"] { + let summary = full_summary(vec![input_modified_task("", package_dir)]); + assert!(summary.as_str().contains("→ Not cached: the task read and wrote ''\n")); + assert!(!summary.as_str().contains("exclude it"), "{summary}"); + assert!(!summary.as_str().contains("auto: true"), "{summary}"); + } + } + + #[test] + fn modified_input_exclusion_is_escaped_and_quoted() { + let summary = full_summary(vec![input_modified_task("app/[id]/\"x\".ts", "")]); + assert!( + summary.as_str().contains(r#"input: [{ auto: true }, "!app/\\[id\\]/\"x\".ts"],"#), + "{summary}" + ); + } + #[test] fn compact_summary_names_remote_hits() { assert_eq!( diff --git a/crates/vt/src/session/reporter/summary_reporter.rs b/crates/vt/src/session/reporter/summary_reporter.rs index 2b8c94993..1ab95de50 100644 --- a/crates/vt/src/session/reporter/summary_reporter.rs +++ b/crates/vt/src/session/reporter/summary_reporter.rs @@ -198,6 +198,8 @@ impl LeafExecutionReporter for SummaryLeafReporter { status, saved_error.as_ref(), &cache_update_status, + &self.display.task_display.package_path, + &self.workspace_path, ), }; diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/packages/rw-pkg/vite-task.json b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/packages/rw-pkg/vite-task.json index 3f7ab78ff..66e459f9d 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/packages/rw-pkg/vite-task.json +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/packages/rw-pkg/vite-task.json @@ -2,6 +2,23 @@ "tasks": { "task": { "command": "vtt replace-file-content src/data.txt i !" + }, + "task-outside": { + "command": "vtt replace-file-content ../../shared.txt i !" + }, + "task-excluded": { + "command": "vtt replace-file-content src/data.txt i !", + "cache": { + "input": [{ "auto": true }, "!src/data.txt"], + "output": [{ "auto": true }, "!src/data.txt"] + } + }, + "task-outside-excluded": { + "command": "vtt replace-file-content ../../shared.txt i !", + "cache": { + "input": [{ "auto": true }, { "pattern": "!shared.txt", "base": "workspace" }], + "output": [{ "auto": true }, { "pattern": "!shared.txt", "base": "workspace" }] + } } } } diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/shared.txt b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/shared.txt new file mode 100644 index 000000000..94f3610c0 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/shared.txt @@ -0,0 +1 @@ +original \ No newline at end of file diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots.toml b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots.toml index bb892fa4a..dd6536297 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots.toml +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots.toml @@ -1,10 +1,10 @@ [[e2e]] name = "single_read_write_task_shows_not_cached_message" comment = """ -A single task that reads and writes the same file (fspy sees both ops) should be flagged as "not cached because it modified its inputs" in the compact summary. +A single task that reads and writes the same file (fspy sees both ops) should be flagged as "not cached because it modified its inputs" in the compact summary. `--last-details` then names the file and shows the `cache` settings that exclude it, relative to the package. """ cwd = "packages/rw-pkg" -steps = [["vt", "run", "task"], ["vt", "run", "task"]] +steps = [["vt", "run", "task"], ["vt", "run", "task"], ["vt", "run", "--last-details"]] [[e2e]] name = "multi_task_with_read_write_shows_not_cached_in_summary" @@ -21,6 +21,43 @@ Under `-v`, the full summary should list the exact overlapping path that caused cwd = "packages/rw-pkg" steps = [["vt", "run", "-v", "task"]] +[[e2e]] +name = "read_write_outside_package_suggests_workspace_base" +comment = """ +When the overlapping file is outside the task's package, the suggested exclusion uses the workspace as its base, because a plain pattern is relative to the package. +""" +cwd = "packages/rw-pkg" +steps = [["vt", "run", "task-outside"], ["vt", "run", "--last-details"]] + +[[e2e]] +name = "suggested_exclusions_make_read_write_task_cacheable" +comment = """ +Applying the suggested `cache` settings, in both the package-relative and the workspace-based form, lets the task be cached: each second run is a cache hit. +""" +cwd = "packages/rw-pkg" +steps = [ + [ + "vt", + "run", + "task-excluded", + ], + [ + "vt", + "run", + "task-excluded", + ], + [ + "vt", + "run", + "task-outside-excluded", + ], + [ + "vt", + "run", + "task-outside-excluded", + ], +] + [[e2e]] name = "single_O_RDWR_open_is_not_cached" comment = """ diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/read_write_outside_package_suggests_workspace_base.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/read_write_outside_package_suggests_workspace_base.md new file mode 100644 index 000000000..2235fc841 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/read_write_outside_package_suggests_workspace_base.md @@ -0,0 +1,33 @@ +# read_write_outside_package_suggests_workspace_base + +When the overlapping file is outside the task's package, the suggested exclusion uses the workspace as its base, because a plain pattern is relative to the package. + +## `vt run task-outside` + +``` +~/packages/rw-pkg$ vtt replace-file-content ../../shared.txt i ! + +--- +vt run: @test/rw-pkg#task-outside not cached because it modified its inputs. (Run `vt run --last-details` for full details) +``` + +## `vt run --last-details` + +``` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 task • 0 cache hits • 1 cache miss +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] @test/rw-pkg#task-outside: ~/packages/rw-pkg$ vtt replace-file-content ../../shared.txt i ! ✓ + → Not cached: the task read and wrote 'shared.txt' + If this file is temporary or shouldn't affect caching, exclude it (or a glob matching it) in the task's `cache` config: + input: [{ auto: true }, { pattern: "!shared.txt", base: "workspace" }], + output: [{ auto: true }, { pattern: "!shared.txt", base: "workspace" }], +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/single_read_write_task_shows_not_cached_message.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/single_read_write_task_shows_not_cached_message.md index f1656b62b..561683b15 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/single_read_write_task_shows_not_cached_message.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/single_read_write_task_shows_not_cached_message.md @@ -1,6 +1,6 @@ # single_read_write_task_shows_not_cached_message -A single task that reads and writes the same file (fspy sees both ops) should be flagged as "not cached because it modified its inputs" in the compact summary. +A single task that reads and writes the same file (fspy sees both ops) should be flagged as "not cached because it modified its inputs" in the compact summary. `--last-details` then names the file and shows the `cache` settings that exclude it, relative to the package. ## `vt run task` @@ -19,3 +19,24 @@ vt run: @test/rw-pkg#task not cached because it modified its inputs. (Run `vt ru --- vt run: @test/rw-pkg#task not cached because it modified its inputs. (Run `vt run --last-details` for full details) ``` + +## `vt run --last-details` + +``` + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Vite+ Task Runner • Execution Summary +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Statistics: 1 task • 0 cache hits • 1 cache miss +Performance: 0% cache hit rate + +Task Details: +──────────────────────────────────────────────── + [1] @test/rw-pkg#task: ~/packages/rw-pkg$ vtt replace-file-content src/data.txt i ! ✓ + → Not cached: the task read and wrote 'packages/rw-pkg/src/data.txt' + If this file is temporary or shouldn't affect caching, exclude it (or a glob matching it) in the task's `cache` config: + input: [{ auto: true }, "!src/data.txt"], + output: [{ auto: true }, "!src/data.txt"], +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/suggested_exclusions_make_read_write_task_cacheable.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/suggested_exclusions_make_read_write_task_cacheable.md new file mode 100644 index 000000000..980b44210 --- /dev/null +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/suggested_exclusions_make_read_write_task_cacheable.md @@ -0,0 +1,33 @@ +# suggested_exclusions_make_read_write_task_cacheable + +Applying the suggested `cache` settings, in both the package-relative and the workspace-based form, lets the task be cached: each second run is a cache hit. + +## `vt run task-excluded` + +``` +~/packages/rw-pkg$ vtt replace-file-content src/data.txt i ! +``` + +## `vt run task-excluded` + +``` +~/packages/rw-pkg$ vtt replace-file-content src/data.txt i ! ◉ cache hit, replaying + +--- +vt run: cache hit. +``` + +## `vt run task-outside-excluded` + +``` +~/packages/rw-pkg$ vtt replace-file-content ../../shared.txt i ! +``` + +## `vt run task-outside-excluded` + +``` +~/packages/rw-pkg$ vtt replace-file-content ../../shared.txt i ! ◉ cache hit, replaying + +--- +vt run: cache hit. +``` diff --git a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/verbose_read_write_task_shows_path_in_full_summary.md b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/verbose_read_write_task_shows_path_in_full_summary.md index 8f6b5ac35..91f39dd6a 100644 --- a/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/verbose_read_write_task_shows_path_in_full_summary.md +++ b/crates/vt_bin/tests/e2e_snapshots/fixtures/input_read_write_not_cached/snapshots/verbose_read_write_task_shows_path_in_full_summary.md @@ -18,6 +18,9 @@ Performance: 0% cache hit rate Task Details: ──────────────────────────────────────────────── [1] @test/rw-pkg#task: ~/packages/rw-pkg$ vtt replace-file-content src/data.txt i ! ✓ - → Not cached: read and wrote 'packages/rw-pkg/src/data.txt' + → Not cached: the task read and wrote 'packages/rw-pkg/src/data.txt' + If this file is temporary or shouldn't affect caching, exclude it (or a glob matching it) in the task's `cache` config: + input: [{ auto: true }, "!src/data.txt"], + output: [{ auto: true }, "!src/data.txt"], ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ``` diff --git a/docs/inputs.md b/docs/inputs.md index da1cd59c7..d9f48b807 100644 --- a/docs/inputs.md +++ b/docs/inputs.md @@ -231,6 +231,23 @@ If you encounter a case where fspy fails to detect a file read, please [report t 2. The file(s) that weren't detected 3. Steps to reproduce +### Tasks That Modify Files They Read + +A task is not cached when it writes to a file that it also read, because the file no longer matches what the task read. The run summary says the task was `not cached because it modified its inputs`, and `vp run --last-details` names the file and shows the settings that exclude it. + +If the file is temporary or shouldn't affect caching, exclude it from both `input` and `output`. Keep `{ "auto": true }` in each list, since a list without it turns off automatic tracking: + +```json +{ + "cache": { + "input": [{ "auto": true }, "!node_modules/.tmp-*/**"], + "output": [{ "auto": true }, "!node_modules/.tmp-*/**"] + } +} +``` + +For a file outside the package directory, use the object form with `"base": "workspace"`. + ### Cache Disabled `input` is part of the `cache` object, so it cannot be used when caching is disabled with `cache: false`.