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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

- **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)).
- **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)).
- **Fixed** Reinstalling dependencies with pnpm, or installing them at another path, no longer causes cache misses, and tasks that run `pnpm run` or `pnpm exec` no longer miss the cache after pnpm rechecks dependencies ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824), [#825](https://github.com/voidzero-dev/vite-task/pull/825)).
- **Fixed** Reinstalling dependencies with pnpm, or installing them at another path or with another pnpm version, no longer causes cache misses, and tasks that run `pnpm run` or `pnpm exec` no longer miss the cache after pnpm rechecks dependencies ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824), [#825](https://github.com/voidzero-dev/vite-task/pull/825), [#850](https://github.com/voidzero-dev/vite-task/pull/850)).
- **Added** Vite+ now builds and runs on platforms without file-access tracking support, such as FreeBSD. On those platforms, automatic input and output tracking is unavailable, so tasks that rely on it report that tracking is unsupported and must configure their `input` and `output` manually to be cached ([#829](https://github.com/voidzero-dev/vite-task/pull/829)).
- **Changed** The `vp run` summary now reads like `3/4 cache hit (75%), 9/10 successful, 2s saved.` The cache hit count leaves out tasks with caching disabled, and the count of successful tasks out of all tasks replaces the count of failed tasks. When no task has caching enabled, the summary shows only the successful count. In `vp run --verbose` and `vp run --last-details`, the cache hit rate also leaves out tasks with caching disabled, and says `no task has caching enabled` when there are none ([#821](https://github.com/voidzero-dev/vite-task/pull/821)).
- **Perf** On Linux, tasks with automatic file-access tracking start faster ([#817](https://github.com/voidzero-dev/vite-task/pull/817)).
Expand Down
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion crates/vt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ rusqlite = { workspace = true, features = ["bundled"] }
rustc-hash = { workspace = true }
serde = { workspace = true, features = ["derive", "rc"] }
serde_json = { workspace = true }
serde_norway = { workspace = true }
supports-color = { workspace = true }
thiserror = { workspace = true }
tar = { workspace = true }
Expand Down
63 changes: 8 additions & 55 deletions crates/vt/src/session/execute/hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::{hash::Hasher as _, io};
use twox_hash::XxHash3_64;
use vt_path::AbsolutePath;

use super::redact::{UnexpectedFormat, node_modules_bin, pnpm_modules_manifest};
use super::redact::{node_modules_bin, pnpm_modules_manifest};

/// Hash content using 8 KiB buffered `xxHash3_64`.
pub(super) fn hash_content(mut stream: impl io::Read) -> io::Result<u64> {
Expand All @@ -19,85 +19,38 @@ pub(super) fn hash_content(mut stream: impl io::Read) -> io::Result<u64> {
Ok(hasher.finish())
}

/// Hash the content of the file at `path`, read from `stream`. Files with
/// noise in their content are hashed without it; see [`super::redact`].
/// Hash the content of the file at `path`, read from `stream`. Files whose
/// content is noise hash as empty, so only whether they exist is
/// fingerprinted; see [`super::redact`].
pub(super) fn hash_file_content(path: &AbsolutePath, stream: impl io::Read) -> io::Result<u64> {
if pnpm_modules_manifest::matches(path) {
return hash_redacted(stream, pnpm_modules_manifest::redact);
}
if node_modules_bin::matches(path) {
return hash_redacted(stream, node_modules_bin::redact);
if pnpm_modules_manifest::matches(path) || node_modules_bin::matches(path) {
return hash_content(io::empty());
}
hash_content(stream)
}

/// Hash what `redact` writes for the content of `stream`, or the raw content
/// when it is not in the format `redact` expects.
fn hash_redacted(
mut stream: impl io::Read,
redact: impl FnOnce(&[u8], &mut HashWriter) -> Result<(), UnexpectedFormat>,
) -> io::Result<u64> {
let mut content = Vec::new();
stream.read_to_end(&mut content)?;
let mut hasher = HashWriter::default();
match redact(&content, &mut hasher) {
Ok(()) => Ok(hasher.finish()),
Err(UnexpectedFormat) => hash_content(content.as_slice()),
}
}

/// Hashes the bytes written to it with `xxHash3_64`.
#[derive(Default)]
struct HashWriter(XxHash3_64);

impl HashWriter {
fn finish(&self) -> u64 {
self.0.finish()
}
}

impl io::Write for HashWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.write(buf);
Ok(buf.len())
}

fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}

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

const MANIFEST: &[u8] =
br#"{ "packageManager": "pnpm@11.24.0", "prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT" }"#;
const MANIFEST_REINSTALLED: &[u8] =
br#"{ "packageManager": "pnpm@11.24.0", "prunedAt": "Fri, 09 Oct 2026 03:13:58 GMT" }"#;
br#"{ "packageManager": "pnpm@11.25.0", "prunedAt": "Fri, 09 Oct 2026 03:13:58 GMT" }"#;

fn hash_at(relative_path: &str, content: &[u8]) -> u64 {
let path = vt_path::current_dir().unwrap().join(relative_path);
hash_file_content(&path, content).unwrap()
}

#[test]
fn redacted_file_hashes_without_noise() {
fn redacted_file_hashes_without_content() {
assert_eq!(
hash_at("node_modules/.modules.yaml", MANIFEST),
hash_at("node_modules/.modules.yaml", MANIFEST_REINSTALLED)
);
}

#[test]
fn unparsable_redacted_file_hashes_raw_content() {
let content = b"{ \"prunedAt\": ";
assert_eq!(
hash_at("node_modules/.modules.yaml", content),
hash_content(&content[..]).unwrap()
);
}

#[test]
fn other_files_hash_raw_content() {
assert_eq!(hash_at(".modules.yaml", MANIFEST), hash_content(MANIFEST).unwrap());
Expand Down
30 changes: 3 additions & 27 deletions crates/vt/src/session/execute/redact/mod.rs
Original file line number Diff line number Diff line change
@@ -1,33 +1,9 @@
//! Files whose content carries noise — timestamps, machine-specific paths —
//! that would otherwise cause cache misses without any meaningful change.
//! Only whether they exist is fingerprinted.
//!
//! To redact another file, add a module here with a `matches` condition and a
//! `redact` action, and call them from
//! [`hash_file_content`](super::hash::hash_file_content).
//! To add another file, add a module here with a `matches` condition, and
//! check it in [`hash_file_content`](super::hash::hash_file_content).

pub(super) mod node_modules_bin;
pub(super) mod pnpm_modules_manifest;

use std::io;

use serde_json::Value;

/// The content is not in the format a redaction expects, so the raw content is
/// hashed instead.
#[derive(Debug, PartialEq, Eq)]
pub struct UnexpectedFormat;

/// Writes `value` as JSON with every object's keys sorted, so the output does
/// not depend on the file's key order.
///
/// Serializing a JSON value fails only when `out` does, which the in-memory
/// writers used here never do.
fn write_canonical_json<W: io::Write>(
mut value: Value,
out: &mut W,
) -> Result<(), UnexpectedFormat> {
// serde_json's `preserve_order` feature is enabled in this workspace, so
// objects keep the file's key order until sorted.
value.sort_all_objects();
serde_json::to_writer(out, &value).map_err(|_| UnexpectedFormat)
}
10 changes: 0 additions & 10 deletions crates/vt/src/session/execute/redact/node_modules_bin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,13 @@
//! Only whether one exists is fingerprinted. What it runs is tracked through
//! the package files the task reads.

use std::io;

use vt_path::AbsolutePath;

use super::UnexpectedFormat;

/// Whether `path` is directly inside a `node_modules/.bin` directory.
pub fn matches(path: &AbsolutePath) -> bool {
path.parent().is_some_and(|dir| dir.ends_with("node_modules/.bin"))
}

/// Writes nothing, so every executable in `node_modules/.bin` hashes the same.
#[expect(clippy::unnecessary_wraps, reason = "matches the signature of the other redactions")]
pub const fn redact<W: io::Write>(_content: &[u8], _out: &mut W) -> Result<(), UnexpectedFormat> {
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
134 changes: 4 additions & 130 deletions crates/vt/src/session/execute/redact/pnpm_modules_manifest.rs
Original file line number Diff line number Diff line change
@@ -1,79 +1,21 @@
//! pnpm's `node_modules/.modules.yaml`, which records how `node_modules` was
//! installed.
//! installed, including timestamps, absolute paths, and pnpm's version.
//!
//! Only whether it exists is fingerprinted. The installation it describes is
//! tracked through the files in `node_modules` the task reads.

use std::io;

use serde_json::Value;
use vt_path::AbsolutePath;

use super::{UnexpectedFormat, write_canonical_json};

/// Fields that vary with time or with the machine rather than with what is
/// installed:
///
/// - `prunedAt`: when pnpm last pruned the virtual store.
/// - `storeDir`: absolute path of pnpm's store.
/// - `virtualStoreDir`: absolute on Windows, and inside pnpm's store with the
/// global virtual store.
/// - `linkedSkills`: absolute for skill entries outside the workspace.
const UNSTABLE_FIELDS: [&str; 4] = ["prunedAt", "storeDir", "virtualStoreDir", "linkedSkills"];

/// Whether `path` is pnpm's modules manifest. Matches whole path components,
/// with either separator on Windows.
pub fn matches(path: &AbsolutePath) -> bool {
path.ends_with("node_modules/.modules.yaml")
}

/// Writes the manifest without [`UNSTABLE_FIELDS`] to `out`. Fails when
/// `content` is neither a JSON nor a YAML object.
pub fn redact<W: io::Write>(content: &[u8], out: &mut W) -> Result<(), UnexpectedFormat> {
let mut manifest = parse_json_or_yaml(content)?;
let fields = manifest.as_object_mut().ok_or(UnexpectedFormat)?;
for field in UNSTABLE_FIELDS {
fields.remove(field);
}
write_canonical_json(manifest, out)
}

/// pnpm 10.29 and later write JSON; earlier versions write YAML. JSON is tried
/// first because it parses faster, and because the YAML parser rejects keys
/// longer than 1024 characters, which long dependency paths exceed.
fn parse_json_or_yaml(content: &[u8]) -> Result<Value, UnexpectedFormat> {
serde_json::from_slice(content)
.or_else(|_| serde_norway::from_slice(content))
.map_err(|_| UnexpectedFormat)
}

#[cfg(test)]
mod tests {
use serde_json::json;

use super::*;

fn manifest() -> Value {
json!({
"hoistedDependencies": {
"debug@4.4.0": { "debug": "private" },
"ms@2.1.3": { "ms": "private" },
},
"packageManager": "pnpm@11.24.0",
"prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT",
"storeDir": "/Users/alice/Library/pnpm/store/v11",
"virtualStoreDir": ".pnpm",
"linkedSkills": [".claude/skills/a"],
})
}

fn redact_manifest(content: &[u8]) -> Result<Vec<u8>, UnexpectedFormat> {
let mut out = Vec::new();
redact(content, &mut out)?;
Ok(out)
}

fn redact_manifest_value(manifest: &Value) -> Vec<u8> {
redact_manifest(&serde_json::to_vec_pretty(manifest).unwrap()).unwrap()
}

#[test]
fn matches_only_inside_node_modules() {
let cwd = vt_path::current_dir().unwrap();
Expand All @@ -84,72 +26,4 @@ mod tests {
#[cfg(windows)]
assert!(matches(&cwd.join(r"packages\a\node_modules\.modules.yaml")));
}

#[test]
fn unstable_fields_do_not_change_redacted_manifest() {
let mut changed = manifest();
changed["prunedAt"] = json!("Fri, 09 Oct 2026 03:13:58 GMT");
changed["storeDir"] = json!("/tmp/store/v11");
changed["virtualStoreDir"] = json!("../../tmp/store/v11/links");
changed["linkedSkills"] = json!(["/opt/skills/a"]);
assert_eq!(redact_manifest_value(&manifest()), redact_manifest_value(&changed));
}

#[test]
fn other_fields_change_redacted_manifest() {
let mut changed = manifest();
changed["packageManager"] = json!("pnpm@11.25.0");
assert_ne!(redact_manifest_value(&manifest()), redact_manifest_value(&changed));
}

#[test]
fn key_order_does_not_change_redacted_manifest() {
let reordered = json!({
"linkedSkills": [".claude/skills/a"],
"virtualStoreDir": ".pnpm",
"storeDir": "/Users/alice/Library/pnpm/store/v11",
"prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT",
"packageManager": "pnpm@11.24.0",
"hoistedDependencies": {
"ms@2.1.3": { "ms": "private" },
"debug@4.4.0": { "debug": "private" },
},
});
assert_ne!(
serde_json::to_vec(&manifest()).unwrap(),
serde_json::to_vec(&reordered).unwrap()
);
assert_eq!(redact_manifest_value(&manifest()), redact_manifest_value(&reordered));
}

#[test]
fn yaml_manifest_redacts_like_json_manifest() {
let yaml = b"hoistedDependencies:
debug@4.4.0:
debug: private
ms@2.1.3:
ms: private
linkedSkills:
- /opt/skills/a
packageManager: pnpm@11.24.0
prunedAt: Fri, 09 Oct 2026 03:13:58 GMT
storeDir: /tmp/store/v11
virtualStoreDir: ../../tmp/store/v11/links
";
assert_eq!(redact_manifest(yaml).unwrap(), redact_manifest_value(&manifest()));
}

#[test]
fn json_manifest_with_long_keys_is_redacted() {
let mut manifest = manifest();
manifest["hoistedDependencies"]["a".repeat(2000)] = json!({ "a": "private" });
let mut changed = manifest.clone();
changed["prunedAt"] = json!("Fri, 09 Oct 2026 03:13:58 GMT");
assert_eq!(redact_manifest_value(&manifest), redact_manifest_value(&changed));
}

#[test]
fn unparsable_manifest_is_not_redacted() {
assert_eq!(redact_manifest(b"{ \"prunedAt\": "), Err(UnexpectedFormat));
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[[e2e]]
name = "pnpm_modules_manifest_unstable_fields"
name = "pnpm_modules_manifest_content_ignored"
comment = """
Fields of pnpm's `node_modules/.modules.yaml` that vary with time or with the machine are left out of its fingerprint, so changing them keeps the cache hit. Changing any other field is a cache miss.
Only whether pnpm's `node_modules/.modules.yaml` exists is part of the fingerprint, so changing its content keeps the cache hit. Removing it is a cache miss.
"""
steps = [
[
Expand Down Expand Up @@ -32,38 +32,22 @@ steps = [
"vtt",
"replace-file-content",
"node_modules/.modules.yaml",
"/home/alice/.local/share/pnpm/store/v11",
"/home/bob/.local/share/pnpm/store/v11",
], comment = "another machine: new storeDir" },
{ argv = [
"vtt",
"replace-file-content",
"node_modules/.modules.yaml",
"\".pnpm\"",
"\"../../home/bob/.local/share/pnpm/store/v11/links\"",
], comment = "global virtual store: new virtualStoreDir" },
{ argv = [
"vtt",
"replace-file-content",
"node_modules/.modules.yaml",
"\".claude/skills/a\"",
"\"/home/bob/skills/a\"",
], comment = "skill outside the workspace: new linkedSkills" },
"pnpm@11.24.0",
"pnpm@11.25.0",
], comment = "install with another pnpm version: new packageManager" },
{ argv = [
"vt",
"run",
"read-manifest",
], comment = "cache hit" },
{ argv = [
"vtt",
"replace-file-content",
"rm",
"node_modules/.modules.yaml",
"pnpm@11.24.0",
"pnpm@11.25.0",
], comment = "new packageManager" },
], comment = "remove the manifest" },
{ argv = [
"vt",
"run",
"read-manifest",
], comment = "cache miss: 'node_modules/.modules.yaml' modified" },
], comment = "cache miss: input removed" },
]
Loading
Loading