33
44use std:: { collections:: BTreeMap , sync:: Arc , time:: Duration } ;
55
6+ use rustc_hash:: FxHashSet ;
67use vite_path:: { AbsolutePath , RelativePathBuf } ;
78use vite_str:: Str ;
89use vite_task_plan:: cache_metadata:: CacheMetadata ;
@@ -27,6 +28,9 @@ use crate::{
2728/// value is only ever `Some` when tracking happened (see [`observe_fspy`]).
2829struct TrackingOutcome {
2930 path_reads : HashMap < RelativePathBuf , PathRead > ,
31+ /// All paths the task wrote to. Consumed by `collect_and_archive_outputs`
32+ /// when `output_config.includes_auto` is set.
33+ path_writes : FxHashSet < RelativePathBuf > ,
3034 /// First path that was both read and written during execution, if any.
3135 /// A non-empty value means caching this task is unsound.
3236 read_write_overlap : Option < RelativePathBuf > ,
@@ -64,6 +68,15 @@ pub(super) async fn update_cache(
6468 return ( CacheUpdateStatus :: NotUpdated ( CacheNotUpdatedReason :: ToolRequested ) , None ) ;
6569 }
6670
71+ // Tool-reported paths to exclude from auto-tracking. Absolute paths
72+ // are normalized to workspace-relative; anything outside is dropped.
73+ let ignored_input_rels: FxHashSet < RelativePathBuf > = reports
74+ . map ( |r| normalize_ignored_paths ( & r. ignored_inputs , workspace_root) )
75+ . unwrap_or_default ( ) ;
76+ let ignored_output_rels: FxHashSet < RelativePathBuf > = reports
77+ . map ( |r| normalize_ignored_paths ( & r. ignored_outputs , workspace_root) )
78+ . unwrap_or_default ( ) ;
79+
6780 if cancelled {
6881 // Cancelled (Ctrl-C or sibling failure) — result is untrustworthy.
6982 return ( CacheUpdateStatus :: NotUpdated ( CacheNotUpdatedReason :: Cancelled ) , None ) ;
@@ -74,7 +87,14 @@ pub(super) async fn update_cache(
7487 return ( CacheUpdateStatus :: NotUpdated ( CacheNotUpdatedReason :: NonZeroExitStatus ) , None ) ;
7588 }
7689
77- let fspy_outcome = observe_fspy ( outcome, input_negative_globs, workspace_root) ;
90+ let fspy_outcome = observe_fspy (
91+ outcome,
92+ metadata,
93+ input_negative_globs,
94+ & ignored_input_rels,
95+ & ignored_output_rels,
96+ workspace_root,
97+ ) ;
7898
7999 if let Some ( TrackingOutcome { read_write_overlap : Some ( path) , .. } ) = & fspy_outcome {
80100 // fspy-inferred read-write overlap: the task wrote to a file it also
@@ -124,7 +144,15 @@ pub(super) async fn update_cache(
124144 }
125145 } ;
126146
127- let output_archive = match collect_and_archive_outputs ( metadata, workspace_root, cache_dir) {
147+ // Collect output files and create archive. Tool-reported `ignoreOutput`
148+ // paths are excluded from archiving too.
149+ let output_archive = match collect_and_archive_outputs (
150+ metadata,
151+ fspy_outcome. as_ref ( ) ,
152+ & ignored_output_rels,
153+ workspace_root,
154+ cache_dir,
155+ ) {
128156 Ok ( archive) => archive,
129157 Err ( err) => {
130158 return (
@@ -151,32 +179,142 @@ pub(super) async fn update_cache(
151179}
152180
153181/// Summarize the run's fspy observations. `Some` iff tracking was both
154- /// requested (`input_negative_globs .is_some()`) and compiled in (`cfg(fspy)`). On a
182+ /// requested (`tracking .is_some()`) and compiled in (`cfg(fspy)`). On a
155183/// `cfg(not(fspy))` build this is always `None`, and [`update_cache`]
156184/// short-circuits to `FspyUnsupported` when tracking was needed.
185+ ///
186+ /// `path_reads` is gated on `input_config.includes_auto`, filtered by
187+ /// user-configured input negatives, and by tool-reported `ignoreInput`
188+ /// paths. `path_writes` is NOT filtered here — output negatives and
189+ /// `ignoreOutput` are applied later inside `collect_and_archive_outputs`.
190+ /// Keeping the two sides separate avoids `input: ["!dist/**"]` accidentally
191+ /// dropping writes to `dist/**`, which would break archive restoration.
157192fn observe_fspy (
158193 outcome : & ChildOutcome ,
194+ metadata : & CacheMetadata ,
159195 input_negative_globs : Option < & [ wax:: Glob < ' static > ] > ,
196+ ignored_input_rels : & FxHashSet < RelativePathBuf > ,
197+ ignored_output_rels : & FxHashSet < RelativePathBuf > ,
160198 workspace_root : & AbsolutePath ,
161199) -> Option < TrackingOutcome > {
162200 #[ cfg( fspy) ]
163201 {
202+ use wax:: Program as _;
203+
164204 use super :: tracked_accesses:: TrackedPathAccesses ;
165205
166- outcome. path_accesses . as_ref ( ) . zip ( input_negative_globs) . map ( |( raw, negatives) | {
167- let tracked = TrackedPathAccesses :: from_raw ( raw, workspace_root, negatives) ;
168- let read_write_overlap =
169- tracked. path_reads . keys ( ) . find ( |p| tracked. path_writes . contains ( * p) ) . cloned ( ) ;
170- TrackingOutcome { path_reads : tracked. path_reads , read_write_overlap }
206+ outcome. path_accesses . as_ref ( ) . map ( |raw| {
207+ let tracked = TrackedPathAccesses :: from_raw ( raw, workspace_root) ;
208+ let path_reads: HashMap < RelativePathBuf , PathRead > =
209+ if metadata. input_config . includes_auto
210+ && let Some ( negatives) = input_negative_globs
211+ {
212+ tracked
213+ . path_reads
214+ . iter ( )
215+ . filter ( |( path, _) | {
216+ !negatives. iter ( ) . any ( |neg| neg. is_match ( path. as_str ( ) ) )
217+ && !is_ignored ( path, ignored_input_rels)
218+ } )
219+ . map ( |( path, read) | ( path. clone ( ) , * read) )
220+ . collect ( )
221+ } else {
222+ HashMap :: default ( )
223+ } ;
224+ let read_write_overlap = path_reads
225+ . keys ( )
226+ . find ( |p| tracked. path_writes . contains ( * p) && !is_ignored ( p, ignored_output_rels) )
227+ . cloned ( ) ;
228+ TrackingOutcome { path_reads, path_writes : tracked. path_writes , read_write_overlap }
171229 } )
172230 }
173231 #[ cfg( not( fspy) ) ]
174232 {
175- let _ = ( outcome, input_negative_globs, workspace_root) ;
233+ let _ = (
234+ outcome,
235+ metadata,
236+ input_negative_globs,
237+ ignored_input_rels,
238+ ignored_output_rels,
239+ workspace_root,
240+ ) ;
176241 None
177242 }
178243}
179244
245+ /// Normalize tool-reported absolute paths to workspace-relative. Paths outside
246+ /// the workspace are dropped — they can't contribute to inputs or outputs.
247+ fn normalize_ignored_paths (
248+ paths : & FxHashSet < Arc < AbsolutePath > > ,
249+ workspace_root : & AbsolutePath ,
250+ ) -> FxHashSet < RelativePathBuf > {
251+ // On Windows, `workspace_root` may carry a `\\?\` extended-path prefix
252+ // (it does when the runner derived it from `std::fs::canonicalize`)
253+ // while a tool's `current_dir()`-based ignoreInput/ignoreOutput path
254+ // doesn't. `Path::strip_prefix` is a byte-exact comparison so the
255+ // prefix mismatch silently drops every tool-reported path. Pre-build
256+ // an alternate workspace root with the `\\?\` / `\\.\` / `\??\`
257+ // prefix dropped and try it as a fallback. `fspy_shared::NativePath::
258+ // strip_path_prefix` does the inverse (strips `\\?\` from incoming
259+ // fspy paths) so each side stays agnostic to how the other side
260+ // canonicalised.
261+ #[ cfg( windows) ]
262+ let workspace_root_stripped: Option < vite_path:: AbsolutePathBuf > =
263+ windows_strip_verbatim_prefix ( workspace_root. as_path ( ) . as_os_str ( ) ) ;
264+
265+ paths
266+ . iter ( )
267+ . filter_map ( |p| {
268+ if let Some ( rel) = p. strip_prefix ( workspace_root) . ok ( ) . flatten ( ) {
269+ return Some ( rel) ;
270+ }
271+ #[ cfg( windows) ]
272+ if let Some ( alt_root) = workspace_root_stripped. as_ref ( ) {
273+ if let Some ( rel) = p. strip_prefix ( alt_root) . ok ( ) . flatten ( ) {
274+ return Some ( rel) ;
275+ }
276+ }
277+ None
278+ } )
279+ . collect ( )
280+ }
281+
282+ /// Build an alternate workspace-root path by dropping a `\\?\`, `\\.\`,
283+ /// or `\??\` prefix if present. Returns `None` when the input is already
284+ /// in plain `C:\...` form (no fallback needed). Mirrors
285+ /// `fspy_shared::NativePath::strip_path_prefix`'s helper so the inputs of
286+ /// `strip_prefix` can match across `current_dir`-derived and
287+ /// `canonicalize`-derived paths.
288+ #[ cfg( windows) ]
289+ #[ expect(
290+ clippy:: disallowed_types,
291+ reason = "OsStr-level prefix matching for Windows extended-path normalization"
292+ ) ]
293+ fn windows_strip_verbatim_prefix ( p : & std:: ffi:: OsStr ) -> Option < vite_path:: AbsolutePathBuf > {
294+ use std:: os:: windows:: ffi:: { OsStrExt , OsStringExt } ;
295+ let wide: Vec < u16 > = p. encode_wide ( ) . collect ( ) ;
296+ for prefix in [ r"\\?\" , r"\\.\" , r"\??\" ] {
297+ let prefix_wide: Vec < u16 > = prefix. encode_utf16 ( ) . collect ( ) ;
298+ if wide. starts_with ( prefix_wide. as_slice ( ) ) {
299+ let stripped = std:: ffi:: OsString :: from_wide ( & wide[ prefix_wide. len ( ) ..] ) ;
300+ return vite_path:: AbsolutePathBuf :: new ( std:: path:: PathBuf :: from ( stripped) ) ;
301+ }
302+ }
303+ None
304+ }
305+
306+ /// Whether `path` is covered by any `ignored` entry. An ignored entry matches
307+ /// itself (exact file) and everything under it (directory subtree).
308+ fn is_ignored ( path : & RelativePathBuf , ignored : & FxHashSet < RelativePathBuf > ) -> bool {
309+ if ignored. is_empty ( ) {
310+ return false ;
311+ }
312+ if ignored. contains ( path) {
313+ return true ;
314+ }
315+ ignored. iter ( ) . any ( |ig| path. strip_prefix ( ig) . is_some ( ) )
316+ }
317+
180318/// Select tool-reported env records to embed in the post-run fingerprint.
181319/// Only `tracked: true` records are included, and names that the user already
182320/// declared as fingerprinted are skipped.
@@ -220,36 +358,70 @@ fn collect_tracked_env_globs(reports: &Reports) -> BTreeMap<Str, BTreeMap<Str, S
220358 . collect ( )
221359}
222360
223- /// Collect output files matching the configured globs and create a tar.zst
224- /// archive in the cache directory.
361+ /// Collect output files and create a tar.zst archive in the cache directory.
362+ ///
363+ /// Output files are determined by:
364+ /// - fspy-tracked writes (when `output_config.includes_auto` is true)
365+ /// - Positive output globs (always, if configured)
366+ /// - Filtered by negative output globs
367+ /// - Filtered by tool-reported `ignoreOutput` paths (auto writes only)
225368///
226- /// Returns `Some(archive_filename)` if files were archived, `None` if the
227- /// output config has no positive globs or no files matched.
369+ /// Returns `Some(archive_filename)` if files were archived, `None` if no output files.
228370fn collect_and_archive_outputs (
229371 cache_metadata : & CacheMetadata ,
372+ tracking : Option < & TrackingOutcome > ,
373+ ignored_output_rels : & FxHashSet < RelativePathBuf > ,
230374 workspace_root : & AbsolutePath ,
231375 cache_dir : & AbsolutePath ,
232376) -> anyhow:: Result < Option < Str > > {
377+ use wax:: Program as _;
378+
233379 let output_config = & cache_metadata. output_config ;
234380
235- if output_config. positive_globs . is_empty ( ) {
236- return Ok ( None ) ;
381+ // Collect output files from auto-detection (fspy writes), excluding
382+ // anything the tool reported via `ignoreOutput`.
383+ let mut output_files: FxHashSet < RelativePathBuf > = FxHashSet :: default ( ) ;
384+
385+ if output_config. includes_auto
386+ && let Some ( t) = tracking
387+ {
388+ output_files
389+ . extend ( t. path_writes . iter ( ) . filter ( |p| !is_ignored ( p, ignored_output_rels) ) . cloned ( ) ) ;
237390 }
238391
239- let output_files = glob:: collect_glob_paths (
240- workspace_root,
241- & output_config. positive_globs ,
242- & output_config. negative_globs ,
243- ) ?;
392+ // Collect output files from positive globs
393+ if !output_config. positive_globs . is_empty ( ) {
394+ let glob_paths = glob:: collect_glob_paths (
395+ workspace_root,
396+ & output_config. positive_globs ,
397+ & output_config. negative_globs ,
398+ ) ?;
399+ output_files. extend ( glob_paths) ;
400+ }
401+
402+ // Apply negative globs to auto-detected files
403+ if output_config. includes_auto && !output_config. negative_globs . is_empty ( ) {
404+ let negatives: Vec < wax:: Glob < ' static > > = output_config
405+ . negative_globs
406+ . iter ( )
407+ . map ( |p| Ok ( wax:: Glob :: new ( p. as_str ( ) ) ?. into_owned ( ) ) )
408+ . collect :: < anyhow:: Result < _ > > ( ) ?;
409+ output_files. retain ( |path| !negatives. iter ( ) . any ( |neg| neg. is_match ( path. as_str ( ) ) ) ) ;
410+ }
244411
245412 if output_files. is_empty ( ) {
246413 return Ok ( None ) ;
247414 }
248415
416+ // Sort for deterministic archive content
417+ let mut sorted_files: Vec < RelativePathBuf > = output_files. into_iter ( ) . collect ( ) ;
418+ sorted_files. sort ( ) ;
419+
420+ // Create archive with UUID filename
249421 let archive_name: Str = vite_str:: format!( "{}.tar.zst" , uuid:: Uuid :: new_v4( ) ) ;
250422 let archive_path = cache_dir. join ( archive_name. as_str ( ) ) ;
251423
252- archive:: create_output_archive ( workspace_root, & output_files , & archive_path) ?;
424+ archive:: create_output_archive ( workspace_root, & sorted_files , & archive_path) ?;
253425
254426 Ok ( Some ( archive_name) )
255427}
0 commit comments