Skip to content
Draft
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
78 changes: 71 additions & 7 deletions crates/vite_task/src/session/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -727,13 +727,16 @@ pub async fn execute_spawn(
);
}

// `ignoreInput`/`ignoreOutput` are accepted over IPC but not yet
// applied — runner-aware output tracking (which consumes them) lands
// in a follow-up. Treat the reported ignore sets as empty so they
// have no effect on input/output inference or the read-write overlap
// check.
let ignored_input_rels: FxHashSet<RelativePathBuf> = FxHashSet::default();
let ignored_output_rels: FxHashSet<RelativePathBuf> = FxHashSet::default();
// Tool-reported paths to exclude from auto-tracking. Absolute paths
// are normalized to workspace-relative; anything outside is dropped.
let ignored_input_rels: FxHashSet<RelativePathBuf> = reports
.as_ref()
.map(|r| normalize_ignored_paths(&r.ignored_inputs, cache_base_path))
.unwrap_or_default();
let ignored_output_rels: FxHashSet<RelativePathBuf> = reports
.as_ref()
.map(|r| normalize_ignored_paths(&r.ignored_outputs, cache_base_path))
.unwrap_or_default();

// Post-execution summary of what fspy observed. `Some` iff fspy was
// both requested (`tracking.is_some()` => input or output auto) and
Expand Down Expand Up @@ -907,6 +910,67 @@ pub async fn execute_spawn(
SpawnOutcome::Spawned(outcome.exit_status)
}

/// Normalize tool-reported absolute paths to workspace-relative. Paths outside
/// the workspace are dropped — they can't contribute to inputs or outputs.
fn normalize_ignored_paths(
paths: &FxHashSet<Arc<AbsolutePath>>,
workspace_root: &AbsolutePath,
) -> FxHashSet<RelativePathBuf> {
// On Windows, `workspace_root` may carry a `\\?\` extended-path prefix
// (it does when the runner derived it from `std::fs::canonicalize`)
// while a tool's `current_dir()`-based ignoreInput/ignoreOutput path
// doesn't. `Path::strip_prefix` is a byte-exact comparison so the
// prefix mismatch silently drops every tool-reported path. Pre-build
// an alternate workspace root with the `\\?\` / `\\.\` / `\??\`
// prefix dropped and try it as a fallback. `fspy_shared::NativePath::
// strip_path_prefix` does the inverse (strips `\\?\` from incoming
// fspy paths) so each side stays agnostic to how the other side
// canonicalised.
#[cfg(windows)]
let workspace_root_stripped: Option<vite_path::AbsolutePathBuf> =
windows_strip_verbatim_prefix(workspace_root.as_path().as_os_str());

paths
.iter()
.filter_map(|p| {
if let Some(rel) = p.strip_prefix(workspace_root).ok().flatten() {
return Some(rel);
}
#[cfg(windows)]
if let Some(alt_root) = workspace_root_stripped.as_ref() {
if let Some(rel) = p.strip_prefix(alt_root).ok().flatten() {
return Some(rel);
}
}
None
})
.collect()
}

/// Build an alternate workspace-root path by dropping a `\\?\`, `\\.\`,
/// or `\??\` prefix if present. Returns `None` when the input is already
/// in plain `C:\...` form (no fallback needed). Mirrors
/// `fspy_shared::NativePath::strip_path_prefix`'s helper so the inputs of
/// `strip_prefix` can match across `current_dir`-derived and
/// `canonicalize`-derived paths.
#[cfg(windows)]
#[expect(
clippy::disallowed_types,
reason = "OsStr-level prefix matching for Windows extended-path normalization"
)]
fn windows_strip_verbatim_prefix(p: &std::ffi::OsStr) -> Option<vite_path::AbsolutePathBuf> {
use std::os::windows::ffi::{OsStrExt, OsStringExt};
let wide: Vec<u16> = p.encode_wide().collect();
for prefix in [r"\\?\", r"\\.\", r"\??\"] {
let prefix_wide: Vec<u16> = prefix.encode_utf16().collect();
if wide.starts_with(prefix_wide.as_slice()) {
let stripped = std::ffi::OsString::from_wide(&wide[prefix_wide.len()..]);
return vite_path::AbsolutePathBuf::new(std::path::PathBuf::from(stripped));
}
}
None
}

/// Whether `path` is covered by any `ignored` entry. An ignored entry matches
/// itself (exact file) and everything under it (directory subtree).
fn is_ignored(path: &RelativePathBuf, ignored: &FxHashSet<RelativePathBuf>) -> bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ When auto-inference is disabled (explicit globs only), the task process should n

```
$ vtt print-env FSPY
(undefined)
1
```
Original file line number Diff line number Diff line change
@@ -1,3 +1,61 @@
[[e2e]]
name = "ignore_input_keeps_cache_valid"
comment = """
Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`.
The runner treats `cache_like/` as non-input, so mutations to it between
runs do not invalidate the cache.
"""
ignore = true
steps = [
{ argv = [
"vt",
"run",
"ignore-input",
], comment = "populate the cache" },
{ argv = [
"vtt",
"write-file",
"cache_like/other.txt",
"after",
], comment = "mutate the ignored directory — would invalidate if tracked" },
{ argv = [
"vt",
"run",
"ignore-input",
], comment = "cache hit: cache_like/ was ignored via ignoreInput" },
]

[[e2e]]
name = "ignore_output_allows_read_write_overlap"
comment = """
Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`;
without the ignore the runner's read-write overlap check would refuse to
cache the run ("read and wrote 'sidecar/tmp.txt'").
"""
ignore = true
steps = [
{ argv = [
"vt",
"run",
"ignore-output",
], comment = "first run populates the cache" },
{ argv = [
"vtt",
"rm",
"dist/out.txt",
], comment = "remove the real output so the cache-hit restore is observable" },
{ argv = [
"vt",
"run",
"ignore-output",
], comment = "cache hit: sidecar/ writes were ignored" },
{ argv = [
"vtt",
"print-file",
"dist/out.txt",
], comment = "restored from the cache archive" },
]

[[e2e]]
name = "disable_cache_forces_reexecution"
comment = """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# ignore_input_keeps_cache_valid

Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`.
The runner treats `cache_like/` as non-input, so mutations to it between
runs do not invalidate the cache.

## `vt run ignore-input`

populate the cache

```
$ node scripts/ignore_input.mjs
```

## `vtt write-file cache_like/other.txt after`

mutate the ignored directory — would invalidate if tracked

```
```

## `vt run ignore-input`

cache hit: cache_like/ was ignored via ignoreInput

```
$ node scripts/ignore_input.mjs ◉ cache hit, replaying

---
vt run: cache hit.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# ignore_output_allows_read_write_overlap

Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`;
without the ignore the runner's read-write overlap check would refuse to
cache the run ("read and wrote 'sidecar/tmp.txt'").

## `vt run ignore-output`

first run populates the cache

```
$ node scripts/ignore_output.mjs
```

## `vtt rm dist/out.txt`

remove the real output so the cache-hit restore is observable

```
```

## `vt run ignore-output`

cache hit: sidecar/ writes were ignored

```
$ node scripts/ignore_output.mjs ◉ cache hit, replaying

---
vt run: cache hit.
```

## `vtt print-file dist/out.txt`

restored from the cache archive

```
ok
```
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
[[e2e]]
name = "vite_build_caches_and_restores_outputs"
comment = """
`vt run --cache build` must produce a cache hit on the second run without any manual input/output configuration. Vite reports `ignoreInput(outDir)` + `ignoreInput/Output(cacheDir)` via `@voidzero-dev/vite-task-client`, so fspy-detected reads of `dist/` and writes to `node_modules/.vite/` don't poison the cache.
"""
ignore = true
steps = [
{ argv = [
"vt",
"run",
"--cache",
"build",
], comment = "first run: cache miss, emits dist/" },
{ argv = [
"vtt",
"stat-file",
"dist/assets/main.js",
], comment = "existence check — content would drift across Vite versions" },
{ argv = [
"vtt",
"rm",
"dist/assets/main.js",
], comment = "remove the artefact so the cache-hit restore is observable" },
{ argv = [
"vt",
"run",
"--cache",
"build",
], comment = "cache hit: outputs restored without manual config" },
{ argv = [
"vtt",
"stat-file",
"dist/assets/main.js",
], comment = "restored from the cache archive" },
]

[[e2e]]
name = "vite_prefix_env_change_invalidates_cache"
comment = """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# vite_build_caches_and_restores_outputs

`vt run --cache build` must produce a cache hit on the second run without any manual input/output configuration. Vite reports `ignoreInput(outDir)` + `ignoreInput/Output(cacheDir)` via `@voidzero-dev/vite-task-client`, so fspy-detected reads of `dist/` and writes to `node_modules/.vite/` don't poison the cache.

## `vt run --cache build`

first run: cache miss, emits dist/

```
$ vite build
```

## `vtt stat-file dist/assets/main.js`

existence check — content would drift across Vite versions

```
dist/assets/main.js: exists
```

## `vtt rm dist/assets/main.js`

remove the artefact so the cache-hit restore is observable

```
```

## `vt run --cache build`

cache hit: outputs restored without manual config

```
$ vite build ◉ cache hit, replaying

---
vt run: cache hit.
```

## `vtt stat-file dist/assets/main.js`

restored from the cache archive

```
dist/assets/main.js: exists
```
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,6 @@
// No `"env": [...]` — vite's patched `loadEnv` asks the runner for
// every `VITE_*` env via `getEnvs`, so the glob + match-set are
// fingerprinted automatically.
//
// Auto output tracking (which lets vite `ignoreInput`/`ignoreOutput` the
// out dir and the dirs it writes-then-reads) is not implemented yet, so
// excluding them from auto input inference keeps `vite build` cacheable:
// it stops fspy's reads of the build's own outputs (`dist/`) and vite's
// write-then-read config-timestamp temp files (`node_modules/.vite-temp/`)
// from being treated as inputs, which would otherwise trip the read-write
// overlap check.
"input": ["!dist/**", "!node_modules/.vite-temp/**", { "auto": true }],
"cache": true
}
}
Expand Down
24 changes: 5 additions & 19 deletions crates/vite_task_graph/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,11 @@ impl ResolvedTaskOptions {
workspace_root,
)?;

// Auto output restoration is not implemented yet — it lands with
// runner-aware output tracking (which consumes `ignoreOutput`).
// Until then `output: None` defaults to disabled rather than
// auto-inference, so a cache hit never restores an unverified,
// fspy-inferred output set.
let output_config = match enabled_cache_config.output.as_ref() {
None => ResolvedGlobConfig::disabled(),
output => ResolvedGlobConfig::from_user_config(output, dir, workspace_root)?,
};
let output_config = ResolvedGlobConfig::from_user_config(
enabled_cache_config.output.as_ref(),
dir,
workspace_root,
)?;

Some(CacheConfig {
env_config: EnvConfig {
Expand Down Expand Up @@ -144,16 +140,6 @@ impl ResolvedGlobConfig {
}
}

/// Disabled configuration: no auto-inference and no explicit patterns.
#[must_use]
pub const fn disabled() -> Self {
Self {
includes_auto: false,
positive_globs: BTreeSet::new(),
negative_globs: BTreeSet::new(),
}
}

/// Resolve from user configuration, making glob patterns workspace-root-relative.
///
/// - `None`: defaults to auto-inference (`[{auto: true}]`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
"negative_globs": []
},
"output_config": {
"includes_auto": false,
"includes_auto": true,
"positive_globs": [],
"negative_globs": []
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"negative_globs": []
},
"output_config": {
"includes_auto": false,
"includes_auto": true,
"positive_globs": [],
"negative_globs": []
}
Expand Down Expand Up @@ -71,7 +71,7 @@
"negative_globs": []
},
"output_config": {
"includes_auto": false,
"includes_auto": true,
"positive_globs": [],
"negative_globs": []
}
Expand Down
Loading
Loading