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

- **Fixed** Reinstalling dependencies with pnpm no longer causes cache misses in tasks that run Vite or Vitest ([#823](https://github.com/voidzero-dev/vite-task/pull/823)).
- **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: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions crates/vt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ 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
2 changes: 1 addition & 1 deletion crates/vt/src/session/execute/fingerprint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ pub fn fingerprint_path(
return process_directory(std_path, path_read);
}
}
Ok(PathFingerprint::FileContentHash(super::hash::hash_content(reader)?))
Ok(PathFingerprint::FileContentHash(super::hash::hash_file_content(path, reader)?))
}

/// Process a directory on Windows using `std::fs::read_dir`
Expand Down
2 changes: 1 addition & 1 deletion crates/vt/src/session/execute/glob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ fn strip_root(
}

fn hash_file_content(path: &AbsolutePath) -> io::Result<u64> {
super::hash::hash_content(io::BufReader::new(File::open(path.as_path())?))
super::hash::hash_file_content(path, io::BufReader::new(File::open(path.as_path())?))
}

#[cfg(test)]
Expand Down
89 changes: 88 additions & 1 deletion crates/vt/src/session/execute/hash.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
use std::{hash::Hasher as _, io};

use twox_hash::XxHash3_64;
use vt_path::AbsolutePath;

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

/// Hash content using 8 KiB buffered `xxHash3_64`.
pub(super) fn hash_content(mut stream: impl io::Read) -> io::Result<u64> {
let mut hasher = twox_hash::XxHash3_64::default();
let mut hasher = XxHash3_64::default();
let mut buf = [0u8; 8192];
loop {
let n = stream.read(&mut buf)?;
Expand All @@ -13,3 +18,85 @@ 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`].
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);
}
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" }"#;

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() {
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());
}
}
1 change: 1 addition & 0 deletions crates/vt/src/session/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod fingerprint;
pub mod glob;
mod hash;
pub mod pipe;
mod redact;
mod scheduler;
pub mod spawn;
#[cfg(fspy)]
Expand Down
32 changes: 32 additions & 0 deletions crates/vt/src/session/execute/redact/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! Files whose content carries noise — timestamps, machine-specific paths —
//! that would otherwise cause cache misses without any meaningful change.
//!
//! 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).

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)
}
155 changes: 155 additions & 0 deletions crates/vt/src/session/execute/redact/pnpm_modules_manifest.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! pnpm's `node_modules/.modules.yaml`, which records how `node_modules` was
//! installed.

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();
assert!(matches(&cwd.join("node_modules/.modules.yaml")));
assert!(matches(&cwd.join("packages/a/node_modules/.modules.yaml")));
assert!(!matches(&cwd.join(".modules.yaml")));
assert!(!matches(&cwd.join("node_modules/.modules.yaml.bak")));
#[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
@@ -0,0 +1,7 @@
{
"packageManager": "pnpm@11.24.0",
"prunedAt": "Thu, 08 Oct 2026 08:18:18 GMT",
"storeDir": "/home/alice/.local/share/pnpm/store/v11",
"virtualStoreDir": ".pnpm",
"linkedSkills": [".claude/skills/a"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "pnpm-modules-manifest",
"private": true
}
Loading
Loading