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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)).
Expand Down
212 changes: 193 additions & 19 deletions crates/vt/src/session/reporter/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -109,7 +109,7 @@ pub enum SpawnOutcome {
infra_error: Option<SavedError>,
/// 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<Str>,
input_modified: Option<InputModified>,
/// `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.
Expand Down Expand Up @@ -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<Str>,
}

/// Why a cache miss occurred.
#[derive(Serialize, Deserialize)]
pub enum SavedCacheMissReason {
Expand Down Expand Up @@ -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 { .. } => {}
Expand Down Expand Up @@ -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<std::process::ExitStatus>,
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,
};
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -383,7 +396,7 @@ impl TaskResult {
fn spawn_outcome_from_execution(
exit_status: Option<std::process::ExitStatus>,
saved_error: Option<&SavedError>,
input_modified_path: Option<Str>,
input_modified: Option<InputModified>,
fspy_unsupported: bool,
ipc_server_error: Option<SavedError>,
tool_disabled_cache: bool,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -837,6 +857,10 @@ pub fn format_full_summary(summary: &LastRunSummary) -> Vec<u8> {
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() {
Expand Down Expand Up @@ -897,6 +921,61 @@ fn write_causes(buf: &mut Vec<u8>, 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<u8>, 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<Str> {
// `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)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Expand Down Expand Up @@ -1046,6 +1125,8 @@ fn format_upload_failed_notice(buf: &mut Vec<u8>, failures: &[UploadFailure]) {

#[cfg(test)]
mod tests {
use vt_path::RelativePathBuf;

use super::*;
use crate::session::event::ExecutionError;

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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<TaskSummary>) -> Str {
strip(&format_compact_summary(&LastRunSummary { tasks, exit_code: 0 }, "vp"))
}
Expand All @@ -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!(
Expand Down
2 changes: 2 additions & 0 deletions crates/vt/src/session/reporter/summary_reporter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
original
Loading
Loading