1use std::collections::{BTreeMap, BTreeSet};
2use std::ffi::OsString;
3use std::fs::File;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::sync::atomic::AtomicBool;
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, Instant, SystemTime};
9
10use anyhow::{Context, Result, bail};
11use foldhash::{HashMap, HashMapExt, HashSet};
12use qk_config::Workspace;
13use qk_executor::{Capture, Display, Outcome, PreparedTask, execute_captured, read_capture};
14use qk_graph::ProjectGraph;
15use qk_lockfile::Lockfile;
16use qk_taskgraph::{Task, TaskGraph};
17use serde_json::{Value, json};
18
19use crate::glob::is_literal;
20use crate::inputs::{Resolved, manifest_for_dependents, workspace_without_resolution};
21use crate::paths::{self, Outputs, Pattern};
22
23pub fn digest_file(path: &Path) -> Result<String> {
24 let mut hasher = blake3::Hasher::new();
25 let mut reader = File::open(path)?;
26 let mut buffer = [0; 64 * 1024];
27 loop {
28 let count = reader.read(&mut buffer)?;
29 if count == 0 {
30 break;
31 }
32 hasher.update(&buffer[..count]);
33 }
34 Ok(hasher.finalize().to_hex().to_string())
35}
36
37#[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)]
40struct Stamp(Vec<i64>);
41
42impl Stamp {
43 fn new(metadata: &std::fs::Metadata) -> Self {
44 let modified = metadata
45 .modified()
46 .ok()
47 .and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
48 .map_or(-1, |duration| duration.as_nanos() as i64);
49 #[allow(unused_mut)]
50 let mut values = vec![metadata.len() as i64, modified];
51 #[cfg(unix)]
52 {
53 use std::os::unix::fs::MetadataExt;
54 values.extend([
55 metadata.ctime(),
56 metadata.ctime_nsec(),
57 metadata.ino() as i64,
58 i64::from(metadata.mode()),
59 ]);
60 }
61 Self(values)
62 }
63
64 fn membership_settled(&self) -> bool {
67 #[cfg(unix)]
68 let width = 6;
69 #[cfg(not(unix))]
70 let width = 2;
71 self.0.chunks_exact(width).all(|values| {
72 let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(values[1].max(0) as u64);
73 let settled = |time| {
74 SystemTime::now()
75 .duration_since(time)
76 .is_ok_and(|age| age > Duration::from_secs(2))
77 };
78 if values[1] < 0 || !settled(modified) {
79 return false;
80 }
81 #[cfg(unix)]
82 {
83 let changed = SystemTime::UNIX_EPOCH
84 + Duration::new(values[2].max(0) as u64, values[3].max(0) as u32);
85 values[2] >= 0 && settled(changed)
86 }
87 #[cfg(not(unix))]
88 true
89 })
90 }
91
92 fn settled(&self) -> bool {
95 let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(self.0[1].max(0) as u64);
96 self.0[1] >= 0
97 && SystemTime::now()
98 .duration_since(modified)
99 .is_ok_and(|age| age > Duration::from_secs(2))
100 }
101}
102
103fn digests_path(root: &Path) -> PathBuf {
107 paths::worktree_state(root).join("digests.json")
108}
109
110#[derive(PartialEq, Eq, Hash)]
111struct RuntimeKey {
112 command: String,
113 environment: BTreeMap<OsString, OsString>,
114}
115
116pub struct Snapshot {
120 pub(crate) files: BTreeSet<String>,
121 extra_candidates: BTreeSet<String>,
122 membership: Mutex<Option<Arc<Membership>>>,
123 membership_prefixes: Mutex<HashMap<String, Arc<Vec<PathBuf>>>>,
124 source_exclusions: Vec<Outputs>,
125 cache_relative: Option<PathBuf>,
126 projects: ProjectGraph,
127 roots: BTreeMap<String, String>,
129 generated_tasks: Vec<(Task, Outputs)>,
130 task_dependencies: BTreeMap<String, BTreeSet<String>>,
131 source_ignore: Arc<SourceIgnore>,
132 canonical_root: PathBuf,
133 workspace_prefix: Option<PathBuf>,
134 patterns: Mutex<HashMap<(String, bool), Arc<Pattern>>>,
135 digests: Mutex<HashMap<String, (Stamp, String)>>,
136 digests_changed: std::sync::atomic::AtomicBool,
138 directories: Mutex<HashSet<PathBuf>>,
140 runtime: Mutex<HashMap<RuntimeKey, Arc<Mutex<Option<Value>>>>>,
142 lockfile: Mutex<Option<Arc<Installed>>>,
144 dependency_outputs: Mutex<HashMap<String, Arc<DependencyOutputs>>>,
147 outputs_written: std::sync::atomic::AtomicU64,
149}
150
151#[derive(Clone)]
154struct Membership {
155 files: Arc<BTreeSet<String>>,
156 source_ignore: Arc<SourceIgnore>,
157 stamps: BTreeMap<PathBuf, Option<Stamp>>,
158 directories: BTreeSet<PathBuf>,
159 directory_entries: BTreeMap<PathBuf, BTreeSet<(OsString, u8)>>,
160 root: PathBuf,
161 outputs: Vec<Outputs>,
162 output_anchors: BTreeMap<PathBuf, Vec<usize>>,
163 excluded_roots: BTreeSet<PathBuf>,
164 cache: Option<PathBuf>,
165 policies: BTreeSet<PathBuf>,
166 git_policies: BTreeSet<PathBuf>,
167 ignore_contents: BTreeMap<PathBuf, String>,
168 settled: bool,
169 listed: Option<Instant>,
171}
172
173#[derive(Clone)]
176struct Candidates {
177 files: Arc<BTreeSet<String>>,
178 source_ignore: Arc<SourceIgnore>,
179}
180
181impl Membership {
182 fn new(
184 root: &Path,
185 files: &BTreeSet<String>,
186 source_ignore: &Arc<SourceIgnore>,
187 outputs: &[Outputs],
188 cache: Option<&Path>,
189 git_policies: Option<&BTreeSet<PathBuf>>,
190 ) -> Result<Self> {
191 let excluded: BTreeSet<_> = outputs
192 .iter()
193 .flat_map(Outputs::complete_roots)
194 .map(PathBuf::from)
195 .collect();
196 let excluded_roots = excluded.clone();
197 let walk_root = root.to_owned();
198 let walk_cache = cache.map(PathBuf::from);
199 let mut directories = BTreeSet::new();
200 let mut stamps = BTreeMap::new();
201 for entry in ignore::WalkBuilder::new(root)
202 .hidden(false)
203 .parents(false)
204 .git_global(false)
205 .require_git(false)
206 .ignore(false)
207 .git_ignore(false)
208 .git_exclude(false)
209 .follow_links(false)
210 .filter_entry(move |entry| {
211 let relative = entry
212 .path()
213 .strip_prefix(&walk_root)
214 .unwrap_or(entry.path());
215 !matches!(
216 entry.file_name().to_str(),
217 Some(".git" | ".qk" | "node_modules")
218 ) && walk_cache
219 .as_ref()
220 .is_none_or(|cache| !relative.starts_with(cache))
221 && !relative.ancestors().any(|path| excluded.contains(path))
222 })
223 .build()
224 {
225 let entry = entry?;
226 if entry.file_type().is_some_and(|kind| kind.is_dir()) {
227 let directory = entry.path().to_owned();
228 stamps.insert(directory.clone(), membership_stamp(&directory)?);
229 directories.insert(directory);
230 }
231 }
232 for path in files {
235 for parent in Path::new(path).ancestors().skip(1) {
236 let directory = root.join(parent);
237 if directories.insert(directory.clone()) {
238 stamps.insert(directory.clone(), membership_stamp(&directory)?);
239 }
240 }
241 }
242 let mut policies = BTreeSet::new();
243 for directory in &directories {
244 for name in [".gitignore", ".nxignore"] {
245 let path = directory.join(name);
246 if membership_stamp(&path)?.is_some() {
249 policies.insert(path);
250 }
251 }
252 }
253 for directory in root.ancestors() {
254 policies.insert(directory.join(".gitignore"));
255 policies.insert(directory.join(".git"));
257 }
258 let git_policies = git_policies
259 .cloned()
260 .unwrap_or_else(|| git_membership_policies(root));
261 policies.extend(git_policies.iter().cloned());
262 for policy in &policies {
263 stamps.insert(policy.clone(), membership_stamp(policy)?);
264 }
265 let mut ignore_contents = BTreeMap::new();
268 for policy in &policies {
269 if matches!(
270 policy.file_name().and_then(|name| name.to_str()),
271 Some(".gitignore" | ".nxignore")
272 ) && let Some(content) = ignore_content(policy, &stamps[policy])?
273 {
274 ignore_contents.insert(policy.clone(), content);
275 }
276 }
277 let mut output_anchors: BTreeMap<PathBuf, Vec<usize>> = BTreeMap::new();
278 for (index, outputs) in outputs.iter().enumerate() {
279 for anchor in outputs.anchors() {
280 output_anchors
281 .entry(PathBuf::from(anchor))
282 .or_default()
283 .push(index);
284 }
285 }
286 let mut membership = Self {
287 files: Arc::new(BTreeSet::new()),
288 source_ignore: source_ignore.clone(),
289 stamps,
290 directories,
291 directory_entries: BTreeMap::new(),
292 root: root.to_owned(),
293 outputs: outputs.to_vec(),
294 output_anchors,
295 excluded_roots,
296 cache: cache.map(PathBuf::from),
297 policies,
298 git_policies,
299 ignore_contents,
300 settled: false,
301 listed: None,
302 };
303 for directory in &membership.directories {
304 if let Some(entries) = membership.entries(directory)? {
305 membership
306 .directory_entries
307 .insert(directory.clone(), entries);
308 }
309 }
310 Ok(membership)
311 }
312
313 fn entries(&self, directory: &Path) -> Result<Option<BTreeSet<(OsString, u8)>>> {
317 let entries = match std::fs::read_dir(directory) {
318 Ok(entries) => entries,
319 Err(error)
320 if matches!(
321 error.kind(),
322 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
323 ) =>
324 {
325 return Ok(None);
326 }
327 Err(error) => return Err(error.into()),
328 };
329 let relative_directory = directory.strip_prefix(&self.root).unwrap_or(directory);
330 let mut outputs = BTreeSet::new();
331 for ancestor in relative_directory.ancestors() {
332 if let Some(indices) = self.output_anchors.get(ancestor) {
333 outputs.extend(indices.iter().copied());
334 }
335 }
336 for (_, indices) in self
337 .output_anchors
338 .range(relative_directory.to_owned()..)
339 .take_while(|(anchor, _)| anchor.starts_with(relative_directory))
340 {
341 outputs.extend(indices.iter().copied());
342 }
343 let mut names = BTreeSet::new();
344 for entry in entries {
345 let entry = entry?;
346 let name = entry.file_name();
347 let path = entry.path();
348 let relative = path.strip_prefix(&self.root).unwrap_or(&path);
349 if matches!(name.to_str(), Some(".git" | ".qk" | "node_modules"))
350 || self
351 .cache
352 .as_ref()
353 .is_some_and(|cache| relative.starts_with(cache))
354 || relative
355 .ancestors()
356 .any(|root| self.excluded_roots.contains(root))
357 {
358 continue;
359 }
360 if matches!(name.to_str(), Some(".gitignore" | ".nxignore"))
363 && !self.policies.contains(&path)
364 {
365 return Ok(None);
366 }
367 let kind = entry.file_type()?;
368 if kind.is_dir() && !self.directories.contains(&path) {
372 return Ok(None);
373 }
374 if !kind.is_dir()
375 && !matches!(name.to_str(), Some(".gitignore" | ".nxignore"))
376 && relative.to_str().is_some_and(|path| {
377 outputs
378 .iter()
379 .any(|index| self.outputs[*index].matches(path))
380 })
381 {
382 continue;
383 }
384 names.insert((
385 name,
386 if kind.is_dir() {
387 1
388 } else if kind.is_symlink() {
389 2
390 } else {
391 0
392 },
393 ));
394 }
395 Ok(Some(names))
396 }
397
398 fn state(&self, prefixes: Option<&[PathBuf]>) -> Result<(bool, bool)> {
402 let mut settled = true;
403 let selected = prefixes.map(|prefixes| {
404 let mut selected = BTreeSet::new();
405 for prefix in prefixes {
406 for ancestor in prefix.ancestors() {
407 if let Some(directory) = self.directories.get(ancestor) {
408 selected.insert(directory);
409 }
410 }
411 selected.extend(
412 self.directories
413 .range(prefix.clone()..)
414 .take_while(|directory| directory.starts_with(prefix)),
415 );
416 }
417 selected
418 });
419 let paths: Box<dyn Iterator<Item = (&PathBuf, bool)>> = match selected {
420 Some(selected) => Box::new(
421 selected
422 .into_iter()
423 .map(|path| (path, true))
424 .chain(self.policies.iter().map(|path| (path, false))),
425 ),
426 None => Box::new(
427 self.stamps
428 .keys()
429 .map(|path| (path, self.directories.contains(path))),
430 ),
431 };
432 for (path, directory) in paths {
433 let before = &self.stamps[path];
434 let after = membership_stamp(path)?;
435 if directory {
436 if after.is_some() && !self.directory_entries.contains_key(path) {
437 return Ok((false, false));
438 }
439 if *before != after
444 || after
445 .as_ref()
446 .is_some_and(|stamp| !stamp.membership_settled())
447 {
448 let entries = self.entries(path)?;
449 if entries.as_ref() != self.directory_entries.get(path)
450 || membership_stamp(path)? != after
451 {
452 return Ok((false, false));
453 }
454 }
455 } else {
456 let recent = after
457 .as_ref()
458 .is_some_and(|stamp| !stamp.membership_settled());
459 if (*before != after || recent)
460 && let Some(content) = self.ignore_contents.get(path)
461 {
462 if ignore_content(path, &after)?.as_ref() != Some(content) {
463 return Ok((false, false));
464 }
465 continue;
466 }
467 if *before != after {
468 return Ok((false, false));
469 }
470 settled &= !recent;
471 }
472 }
473 Ok((true, settled))
474 }
475
476 fn candidates(&self) -> Candidates {
478 Candidates {
479 files: self.files.clone(),
480 source_ignore: self.source_ignore.clone(),
481 }
482 }
483}
484
485fn git_membership_policies(root: &Path) -> BTreeSet<PathBuf> {
488 let mut policies = BTreeSet::new();
489 if let Ok(output) = paths::git(
490 root,
491 &[
492 "rev-parse",
493 "--path-format=absolute",
494 "--git-path",
495 "index",
496 "--git-path",
497 "info/exclude",
498 "--git-path",
499 "config",
500 "--git-path",
501 "config.worktree",
502 ],
503 ) && output.status.success()
504 && let Ok(paths) = String::from_utf8(output.stdout)
505 {
506 policies.extend(paths.lines().map(PathBuf::from));
507 }
508 if let Ok(output) = paths::git(root, &["config", "--null", "--show-origin", "--list"])
511 && output.status.success()
512 {
513 let output = String::from_utf8_lossy(&output.stdout);
517 let mut fields = output.split_terminator('\0');
518 while let (Some(origin), Some(entry)) = (fields.next(), fields.next()) {
519 if let Some(path) = origin.strip_prefix("file:") {
520 let origin = root.join(path);
521 if let Some((name, value)) = entry.split_once('\n')
522 && name.starts_with("include")
523 && name.ends_with(".path")
524 {
525 let included = if let Some(relative) = value.strip_prefix("~/") {
526 std::env::var_os("HOME")
527 .map(PathBuf::from)
528 .unwrap_or_default()
529 .join(relative)
530 } else {
531 origin.parent().unwrap_or(root).join(value)
532 };
533 policies.insert(included);
536 }
537 policies.insert(origin);
538 }
539 }
540 }
541 if let Ok(output) = paths::git(
542 root,
543 &["config", "--null", "--path", "--get", "core.excludesfile"],
544 ) && output.status.success()
545 && let Ok(path) = String::from_utf8(output.stdout)
546 {
547 policies.insert(root.join(path.strip_suffix('\0').unwrap_or(&path)));
548 }
549 for variable in ["GIT_CONFIG_SYSTEM", "GIT_CONFIG_GLOBAL"] {
550 if let Some(path) = std::env::var_os(variable) {
551 policies.insert(PathBuf::from(path));
552 }
553 }
554 policies.insert(PathBuf::from("/etc/gitconfig"));
555 if let Some(home) = std::env::var_os("HOME") {
556 let home = PathBuf::from(home);
557 policies.insert(home.join(".gitconfig"));
558 let config = std::env::var_os("XDG_CONFIG_HOME")
559 .map(PathBuf::from)
560 .unwrap_or_else(|| home.join(".config"));
561 policies.insert(config.join("git/config"));
562 policies.insert(config.join("git/ignore"));
563 }
564 policies
565}
566
567fn ignore_content(path: &Path, expected: &Option<Stamp>) -> Result<Option<String>> {
570 if expected.is_none() || membership_stamp(path)? != *expected {
571 return Ok(None);
572 }
573 match std::fs::symlink_metadata(path) {
574 Ok(metadata) if metadata.is_file() => {}
575 Ok(_) => return Ok(None),
576 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
577 Err(error) => return Err(error.into()),
578 }
579 let content = match std::fs::read(path) {
580 Ok(content) => content,
581 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
582 Err(error) => return Err(error.into()),
583 };
584 Ok((membership_stamp(path)? == *expected).then(|| blake3::hash(&content).to_hex().to_string()))
585}
586
587fn membership_stamp(path: &Path) -> Result<Option<Stamp>> {
589 match std::fs::symlink_metadata(path) {
590 Ok(metadata) => {
591 let mut stamp = Stamp::new(&metadata);
592 if metadata.is_symlink() {
593 match std::fs::metadata(path) {
596 Ok(target) => stamp.0.extend(Stamp::new(&target).0),
597 Err(error) if error.kind() == std::io::ErrorKind::NotFound => stamp.0[1] = -1,
598 Err(error) => return Err(error.into()),
599 }
600 }
601 Ok(Some(stamp))
602 }
603 Err(error)
604 if matches!(
605 error.kind(),
606 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
607 ) =>
608 {
609 Ok(None)
610 }
611 Err(error) => Err(error).with_context(|| format!("cannot inspect {}", path.display())),
612 }
613}
614
615struct DependencyOutputs {
618 anchors: Vec<String>,
619 files: Vec<String>,
620 values: Mutex<HashMap<String, Value>>,
621}
622
623struct Installed {
626 content: String,
627 lockfile: Option<Lockfile>,
629 digests: Mutex<HashMap<String, String>>,
630}
631
632impl Installed {
633 fn digest(&self, key: &str, fingerprints: impl FnOnce() -> BTreeSet<String>) -> String {
634 if let Some(digest) = self.digests.lock().unwrap().get(key) {
635 return digest.clone();
636 }
637 let mut hasher = blake3::Hasher::new();
638 for fingerprint in fingerprints() {
639 hasher.update(fingerprint.as_bytes());
640 hasher.update(b"\n");
641 }
642 let digest = hasher.finalize().to_hex().to_string();
643 self.digests
644 .lock()
645 .unwrap()
646 .insert(key.to_owned(), digest.clone());
647 digest
648 }
649}
650
651impl Snapshot {
652 pub fn new(workspace: &Workspace, graph: &TaskGraph, cache_path: &Path) -> Result<Self> {
653 let canonical_root = workspace.root.canonicalize()?;
656 let (files, lockfile, (digests, workspace_prefix)) = std::thread::scope(|scope| {
657 let lockfile = scope.spawn(|| parse_lockfile(&workspace.root.join("pnpm-lock.yaml")));
658 let digests = scope.spawn(|| {
659 let digests = load_digests(&workspace.root);
660 let workspace_prefix = paths::git_path(&workspace.root, "--show-toplevel")
661 .and_then(|root| root.canonicalize().ok())
662 .and_then(|root| canonical_root.strip_prefix(root).ok().map(PathBuf::from));
663 (digests, workspace_prefix)
664 });
665 let files = source_files(&workspace.root);
666 (
667 files,
668 lockfile.join().expect("lockfile thread panicked"),
669 digests.join().expect("digest thread panicked"),
670 )
671 });
672 let mut files = files?;
673 let cache_relative = cache_path
674 .strip_prefix(&workspace.root)
675 .ok()
676 .map(PathBuf::from);
677 let mut source_exclusions: Vec<_> = graph
680 .tasks
681 .values()
682 .filter_map(|task| Outputs::new(workspace, task).ok())
683 .filter(Outputs::is_explicit)
684 .collect();
685 for task in graph.tasks.values() {
686 let Ok(entries) = crate::warm::config(workspace, task) else {
687 continue;
688 };
689 if let Ok(paths) = Outputs::from_paths(&crate::warm::kept_paths(&entries)) {
690 source_exclusions.push(paths);
691 }
692 }
693 exclude_artifacts(&mut files, &source_exclusions, cache_relative.as_deref());
694 Ok(Self {
695 files,
696 extra_candidates: BTreeSet::new(),
697 membership: Mutex::default(),
698 membership_prefixes: Mutex::default(),
699 source_exclusions,
700 cache_relative,
701 projects: ProjectGraph::build(workspace)?,
702 roots: workspace
703 .projects
704 .values()
705 .map(|project| (project.root.clone(), project.name.clone()))
706 .collect(),
707 source_ignore: Arc::new(SourceIgnore::new(&workspace.root)?),
708 generated_tasks: graph
709 .tasks
710 .values()
711 .filter_map(|task| {
712 let outputs = Outputs::new(workspace, task).ok()?;
713 outputs.is_explicit().then(|| (task.clone(), outputs))
714 })
715 .collect(),
716 task_dependencies: graph
717 .tasks
718 .iter()
719 .map(|(id, task)| (id.clone(), task.dependencies.clone()))
720 .collect(),
721 canonical_root,
722 workspace_prefix,
723 patterns: Mutex::default(),
724 digests: Mutex::new(digests),
725 digests_changed: Default::default(),
726 directories: Mutex::default(),
727 runtime: Mutex::default(),
728 lockfile: Mutex::new(lockfile),
729 dependency_outputs: Mutex::default(),
730 outputs_written: Default::default(),
731 })
732 }
733
734 fn membership_prefixes(&self, workspace: &Workspace, task: &Task) -> Result<Arc<Vec<PathBuf>>> {
736 let mut prefixes = self.membership_prefixes.lock().unwrap();
737 if let Some(prefixes) = prefixes.get(&task.id) {
738 return Ok(prefixes.clone());
739 }
740 let empty = BTreeSet::new();
741 let cancelled = AtomicBool::new(false);
742 let mut resolver = Resolver::new(self, workspace, None, &cancelled, &empty);
743 resolver.task_inputs(task)?;
744 let paths = Arc::new(
745 resolver
746 .scopes
747 .values()
748 .flat_map(|scope| &scope.patterns)
749 .filter(|pattern| !pattern.excluded)
750 .map(|pattern| workspace.root.join(&pattern.prefix))
751 .collect(),
752 );
753 prefixes.insert(task.id.clone(), Arc::clone(&paths));
754 Ok(paths)
755 }
756
757 #[cfg(test)]
760 fn current_files(&self, root: &Path) -> Result<Arc<BTreeSet<String>>> {
761 Ok(self.current_files_for(root, None, "test")?.files)
762 }
763
764 fn current_files_for(
766 &self,
767 root: &Path,
768 prefixes: Option<&[PathBuf]>,
769 task: &str,
770 ) -> Result<Candidates> {
771 let requested = Instant::now();
772 loop {
773 let previous = self.membership.lock().unwrap().clone();
774 let (unchanged, settled) = match &previous {
777 Some(entry) => entry.state(prefixes)?,
778 None => (false, false),
779 };
780 if settled && previous.as_ref().unwrap().settled {
781 return Ok(previous.as_ref().unwrap().candidates());
782 }
783 let waiting = crate::profile::span(task, "inventory_wait");
786 let mut cached = self.membership.lock().unwrap();
787 drop(waiting);
788 if let Some(entry) = &*cached
793 && entry.listed.is_some_and(|listed| listed >= requested)
794 {
795 return Ok(entry.candidates());
796 }
797 let current = match (&previous, &*cached) {
798 (Some(previous), Some(current)) => Arc::ptr_eq(previous, current),
799 (None, None) => true,
800 _ => false,
801 };
802 if !current {
803 continue;
806 }
807 drop(previous);
808 let _listing = crate::profile::span(task, "inventory_list");
809 let listed = Instant::now();
810 let rebuilt = !unchanged || settled;
815 let mut membership = if !rebuilt {
816 Arc::unwrap_or_clone(cached.take().unwrap())
817 } else {
818 Membership::new(
821 root,
822 cached.as_ref().map_or(&self.files, |entry| &entry.files),
823 &self.source_ignore,
824 &self.source_exclusions,
825 self.cache_relative.as_deref(),
826 cached
827 .as_ref()
828 .filter(|entry| unchanged && entry.settled)
829 .map(|entry| &entry.git_policies),
830 )?
831 };
832 let mut files = source_files(root)?;
833 exclude_artifacts(
834 &mut files,
835 &self.source_exclusions,
836 self.cache_relative.as_deref(),
837 );
838 let ignore = SourceIgnore::new(root)?;
839 files.extend(
840 self.extra_candidates
841 .iter()
842 .filter(|path| !ignore.matches(path))
843 .cloned(),
844 );
845 membership.source_ignore = Arc::new(ignore);
846 membership.files = Arc::new(files);
847 let candidates = membership.candidates();
848 let covered = candidates.files.iter().all(|path| {
851 Path::new(path)
852 .ancestors()
853 .skip(1)
854 .all(|parent| membership.stamps.contains_key(&root.join(parent)))
855 });
856 membership.settled = rebuilt && covered && membership.state(None)?.1;
857 membership.listed = Some(listed);
858 *cached = Some(Arc::new(membership));
859 return Ok(candidates);
860 }
861 }
862
863 pub fn save_digests(&self, root: &Path) {
865 if !self
866 .digests_changed
867 .load(std::sync::atomic::Ordering::Relaxed)
868 {
869 return;
870 }
871 let digests = self.digests.lock().unwrap();
872 let entries: BTreeMap<&String, (&Stamp, &String)> = digests
873 .iter()
874 .filter(|(path, _)| self.files.contains(*path))
875 .map(|(path, (stamp, digest))| (path, (stamp, digest)))
876 .collect();
877 let path = digests_path(root);
878 let saved = (|| -> Result<()> {
879 let directory = path.parent().context("digests have a directory")?;
880 std::fs::create_dir_all(directory)?;
881 let mut file = tempfile::NamedTempFile::new_in(directory)?;
882 {
883 let mut writer = std::io::BufWriter::new(file.as_file_mut());
884 serde_json::to_writer(&mut writer, &json!({"version": 1, "entries": entries}))?;
885 writer.flush()?;
886 }
887 file.persist(&path)?;
888 Ok(())
889 })();
890 if let Err(error) = saved {
891 qk_executor::status!("qk: could not save file digests: {error:#}");
892 }
893 }
894
895 fn safe_parents(&self, root: &Path, path: &str) -> Result<()> {
897 paths::validate_path(path)?;
898 let mut current = root.to_owned();
899 for part in Path::new(path)
900 .parent()
901 .into_iter()
902 .flat_map(Path::components)
903 {
904 current.push(part);
905 if self.directories.lock().unwrap().contains(¤t) {
906 continue;
907 }
908 match std::fs::symlink_metadata(¤t) {
909 Ok(metadata) if metadata.file_type().is_symlink() => {
910 bail!("input parent is a symlink: {}", current.display())
911 }
912 Ok(metadata) if !metadata.is_dir() => {
913 bail!("input parent is not a directory: {}", current.display())
914 }
915 Ok(_) => {
916 self.directories.lock().unwrap().insert(current.clone());
917 }
918 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
919 Err(error) => return Err(error.into()),
920 }
921 }
922 Ok(())
923 }
924
925 fn installed(&self, root: &Path, task: &str) -> Result<Option<Arc<Installed>>> {
926 let absolute = root.join("pnpm-lock.yaml");
927 let Ok(metadata) = std::fs::symlink_metadata(&absolute) else {
928 return Ok(None);
929 };
930 if !metadata.is_file() {
931 return Ok(None);
932 }
933 let content = self.digest("pnpm-lock.yaml", &absolute, &metadata)?;
934 let mut current = self.lockfile.lock().unwrap();
935 if let Some(installed) = &*current
936 && installed.content == content
937 {
938 return Ok(Some(installed.clone()));
939 }
940 let parsing = crate::profile::span(task, "lockfile_parse");
941 let parsed = parse_lockfile(&absolute);
942 drop(parsing);
943 let Some(installed) = parsed else {
944 return Ok(None);
945 };
946 *current = Some(installed.clone());
947 Ok(Some(installed))
948 }
949
950 pub fn with_candidates(mut self, paths: &[String]) -> Self {
953 self.files.extend(
954 paths
955 .iter()
956 .filter(|path| !self.source_ignore.matches(path))
957 .cloned(),
958 );
959 self.extra_candidates.extend(paths.iter().cloned());
960 self
961 }
962
963 fn pattern(&self, pattern: &str, negated: bool) -> Result<Arc<Pattern>> {
964 let key = (pattern.to_owned(), negated);
965 if let Some(pattern) = self.patterns.lock().unwrap().get(&key) {
966 return Ok(pattern.clone());
967 }
968 let compiled = Arc::new(Pattern::new(pattern, negated)?);
969 self.patterns.lock().unwrap().insert(key, compiled.clone());
970 Ok(compiled)
971 }
972
973 fn digest(&self, path: &str, absolute: &Path, metadata: &std::fs::Metadata) -> Result<String> {
974 let stamp = Stamp::new(metadata);
975 if let Some((known, digest)) = self.digests.lock().unwrap().get(path)
976 && *known == stamp
977 {
978 return Ok(digest.clone());
979 }
980 let digest = digest_file(absolute)?;
981 let after = Stamp::new(&std::fs::symlink_metadata(absolute)?);
983 if after == stamp && stamp.settled() {
984 self.digests
985 .lock()
986 .unwrap()
987 .insert(path.to_owned(), (stamp, digest.clone()));
988 self.digests_changed
989 .store(true, std::sync::atomic::Ordering::Relaxed);
990 }
991 Ok(digest)
992 }
993
994 fn dependency_outputs(
997 &self,
998 workspace: &Workspace,
999 task: &Task,
1000 ) -> Result<Arc<DependencyOutputs>> {
1001 if let Some(outputs) = self.dependency_outputs.lock().unwrap().get(&task.id) {
1002 return Ok(outputs.clone());
1003 }
1004 let written = self
1005 .outputs_written
1006 .load(std::sync::atomic::Ordering::SeqCst);
1007 let outputs = Arc::new(self.fresh_dependency_outputs(workspace, task)?);
1008 let mut listed = self.dependency_outputs.lock().unwrap();
1009 if self
1010 .outputs_written
1011 .load(std::sync::atomic::Ordering::SeqCst)
1012 != written
1013 {
1014 return Ok(outputs);
1015 }
1016 Ok(listed.entry(task.id.clone()).or_insert(outputs).clone())
1017 }
1018
1019 fn fresh_dependency_outputs(
1021 &self,
1022 workspace: &Workspace,
1023 task: &Task,
1024 ) -> Result<DependencyOutputs> {
1025 let declared = Outputs::new(workspace, task)?;
1026 let mut files = Vec::new();
1027 for entry in declared.entries(&workspace.root)? {
1028 let (path, metadata) = entry?;
1029 if !metadata.is_dir() {
1030 files.push(path);
1031 }
1032 }
1033 Ok(DependencyOutputs {
1034 anchors: declared.anchors().map(str::to_owned).collect(),
1035 files,
1036 values: Mutex::default(),
1037 })
1038 }
1039
1040 pub(crate) fn outputs_written(&self, workspace: &Workspace, task: &Task) {
1043 let anchors: Option<Vec<String>> = Outputs::new(workspace, task)
1044 .ok()
1045 .filter(Outputs::is_explicit)
1046 .map(|outputs| outputs.anchors().map(str::to_owned).collect());
1047 let overlap = |a: &str, b: &str| {
1048 a == b
1049 || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
1050 || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
1051 };
1052 let mut listed = self.dependency_outputs.lock().unwrap();
1053 self.outputs_written
1054 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1055 listed.retain(|_, outputs| {
1056 anchors.as_ref().is_some_and(|written| {
1057 !written
1058 .iter()
1059 .any(|a| outputs.anchors.iter().any(|b| overlap(a, b)))
1060 })
1061 });
1062 }
1063
1064 fn file_value(&self, root: &Path, path: &str) -> Result<Value> {
1065 self.file_value_with_links(root, path, &mut BTreeSet::new())
1066 }
1067
1068 fn file_value_with_links(
1069 &self,
1070 root: &Path,
1071 path: &str,
1072 visiting: &mut BTreeSet<PathBuf>,
1073 ) -> Result<Value> {
1074 self.safe_parents(root, path)?;
1075 let absolute = root.join(path);
1076 let metadata = std::fs::symlink_metadata(&absolute)?;
1077 if metadata.file_type().is_symlink() {
1078 let target = std::fs::read_link(&absolute)?;
1079 let Ok(resolved) = absolute.canonicalize() else {
1081 return Ok(json!({"link":target, "dangling":true}));
1082 };
1083 let Ok(relative) = resolved.strip_prefix(&self.canonical_root) else {
1084 bail!("input symlink resolves outside the workspace: {path}");
1085 };
1086 if resolved.is_file() {
1087 let metadata = std::fs::metadata(&resolved)?;
1088 let key = paths::relative(Path::new(""), relative)?;
1089 return Ok(
1090 json!({"link":target, "content":self.digest(&key, &resolved, &metadata)?,
1091 "mode":crate::store::mode(&metadata) & 0o111 != 0}),
1092 );
1093 }
1094 let relative = paths::relative(Path::new(""), relative)?;
1095 if relative.is_empty() {
1096 bail!("input symlink resolves to the workspace root: {path}");
1097 }
1098 if !visiting.insert(resolved.clone()) {
1099 bail!("directory input symlink cycle: {path}");
1100 }
1101 let mut contents = BTreeMap::new();
1102 for entry in walkdir::WalkDir::new(&resolved).follow_links(false) {
1105 let entry = entry?;
1106 if entry.file_type().is_dir() {
1107 continue;
1108 }
1109 let file = paths::relative(&self.canonical_root, entry.path())?;
1110 let value = self.file_value_with_links(&self.canonical_root, &file, visiting)?;
1111 contents.insert(
1112 file.strip_prefix(&relative).unwrap_or(&file).to_owned(),
1113 value,
1114 );
1115 }
1116 visiting.remove(&resolved);
1117 return Ok(json!({"link":target, "directory":contents}));
1118 }
1119 if !metadata.is_file() {
1120 bail!("input is not a regular file: {path}");
1121 }
1122 let mode = crate::store::mode(&metadata) & 0o111 != 0;
1125 Ok(json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}))
1126 }
1127}
1128
1129fn parse_lockfile(path: &Path) -> Option<Arc<Installed>> {
1132 let text = std::fs::read_to_string(path).ok()?;
1133 let lockfile = match Lockfile::parse(&text) {
1134 Ok(lockfile) => Some(lockfile),
1135 Err(error) => {
1136 qk_executor::status!("qk: pnpm-lock.yaml: keying tasks by the whole file ({error:#})");
1137 None
1138 }
1139 };
1140 Some(Arc::new(Installed {
1141 content: blake3::hash(text.as_bytes()).to_hex().to_string(),
1142 lockfile,
1143 digests: Mutex::default(),
1144 }))
1145}
1146
1147fn load_digests(root: &Path) -> HashMap<String, (Stamp, String)> {
1148 #[derive(serde::Deserialize)]
1149 struct Saved {
1150 version: u32,
1151 entries: HashMap<String, (Stamp, String)>,
1152 }
1153 std::fs::read(digests_path(root))
1154 .ok()
1155 .and_then(|bytes| serde_json::from_slice::<Saved>(&bytes).ok())
1156 .filter(|saved| saved.version == 1)
1157 .map(|saved| saved.entries)
1158 .unwrap_or_default()
1159}
1160
1161fn exclude_artifacts(files: &mut BTreeSet<String>, outputs: &[Outputs], cache: Option<&Path>) {
1164 let mut generated = BTreeSet::new();
1165 for outputs in outputs {
1166 for anchor in outputs.anchors() {
1167 generated.extend(
1168 under(files, anchor)
1169 .filter(|path| outputs.matches(path))
1170 .cloned(),
1171 );
1172 }
1173 }
1174 files.retain(|path| {
1175 !generated.contains(path) && cache.is_none_or(|cache| !Path::new(path).starts_with(cache))
1176 });
1177}
1178
1179impl Snapshot {
1181 fn owner(&self, path: &str) -> Option<&str> {
1184 let mut path = path;
1185 loop {
1186 if let Some(project) = self.roots.get(path) {
1187 return Some(project);
1188 }
1189 match path.rfind('/') {
1190 Some(slash) => path = &path[..slash],
1191 None if path != "." => path = ".",
1192 None => return None,
1193 }
1194 }
1195 }
1196}
1197
1198fn under<'a>(files: &'a BTreeSet<String>, prefix: &'a str) -> impl Iterator<Item = &'a String> {
1199 files
1200 .range::<str, _>((
1201 std::ops::Bound::Included(prefix),
1202 std::ops::Bound::Unbounded,
1203 ))
1204 .take_while(move |path| path.starts_with(prefix))
1205 .filter(move |path| path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/')
1206}
1207
1208pub struct SourceIgnore(ignore::gitignore::Gitignore);
1210
1211impl SourceIgnore {
1212 pub fn new(root: &Path) -> Result<Self> {
1214 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
1215 for name in [".gitignore", ".nxignore"] {
1216 let path = root.join(name);
1217 if path.is_file()
1218 && let Some(error) = builder.add(path)
1219 {
1220 return Err(error.into());
1221 }
1222 }
1223 Ok(Self(builder.build()?))
1224 }
1225
1226 fn matches_directory(&self, path: &str) -> bool {
1227 self.matches(path) || self.0.matched(path, true).is_ignore()
1228 }
1229
1230 pub fn matches(&self, path: &str) -> bool {
1232 Path::new(path)
1234 .ancestors()
1235 .skip(1)
1236 .take_while(|parent| !parent.as_os_str().is_empty())
1237 .any(|parent| self.0.matched(parent, true).is_ignore())
1238 || self.0.matched(path, false).is_ignore()
1239 }
1240}
1241
1242pub fn source_files(root: &Path) -> Result<BTreeSet<String>> {
1244 let lines =
1245 |output: std::io::Result<std::process::Output>| -> Option<Result<BTreeSet<String>>> {
1246 let output = output.ok().filter(|output| output.status.success())?;
1247 Some(
1248 output
1249 .stdout
1250 .split(|byte| *byte == 0)
1251 .filter(|path| !path.is_empty())
1252 .map(|path| {
1253 String::from_utf8(path.to_vec()).context("input paths must be UTF-8")
1254 })
1255 .collect(),
1256 )
1257 };
1258 let (listed, deleted) = if root.join(".nxignore").is_file() {
1263 (None, None)
1266 } else {
1267 std::thread::scope(|scope| {
1268 let deleted =
1269 scope.spawn(|| paths::git(root, &["ls-files", "--deleted", "-z", "--", "."]));
1270 let listed = paths::git(
1271 root,
1272 &[
1273 "ls-files",
1274 "-v",
1275 "--cached",
1276 "--others",
1277 "--exclude-standard",
1278 "-z",
1279 "--",
1280 ".",
1281 ],
1282 );
1283 (
1284 lines(listed),
1285 lines(deleted.join().expect("git listing thread panicked")),
1286 )
1287 })
1288 };
1289 let candidates = match (listed, deleted) {
1290 (Some(listed), Some(deleted)) => {
1291 let deleted = deleted?;
1292 let mut files = BTreeSet::new();
1293 for entry in listed? {
1294 let Some((tag, path)) = entry.split_once(' ') else {
1295 bail!("unexpected git ls-files entry {entry:?}");
1296 };
1297 let absent =
1298 matches!(tag, "S" | "s") && std::fs::symlink_metadata(root.join(path)).is_err();
1299 if !absent && !deleted.contains(path) {
1300 files.insert(path.to_owned());
1301 }
1302 }
1303 files
1304 }
1305 _ => {
1306 let mut files = BTreeSet::new();
1307 for entry in ignore::WalkBuilder::new(root)
1308 .hidden(false)
1309 .parents(false)
1310 .git_global(false)
1311 .require_git(false)
1312 .ignore(false)
1313 .add_custom_ignore_filename(".nxignore")
1314 .follow_links(false)
1315 .filter_entry(|entry| {
1316 !matches!(
1317 entry.file_name().to_str(),
1318 Some(".git" | ".qk" | "node_modules")
1319 )
1320 })
1321 .build()
1322 {
1323 let entry = entry?;
1324 if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
1325 files.insert(paths::relative(root, entry.path())?);
1326 }
1327 }
1328 files
1329 }
1330 };
1331 let ignore = SourceIgnore::new(root)?;
1332 Ok(candidates
1333 .into_iter()
1334 .filter(|path| {
1335 !ignore.matches(path)
1336 && !path
1337 .split('/')
1338 .any(|part| matches!(part, ".git" | ".qk" | "node_modules"))
1339 })
1340 .collect())
1341}
1342
1343fn json_path<'v>(value: &'v Value, path: &str) -> Option<&'v Value> {
1344 path.split('.')
1345 .try_fold(value, |value, part| value.get(part))
1346}
1347
1348fn set_json_path(target: &mut Value, path: &str, value: Value) {
1349 let mut parts = path.split('.').peekable();
1350 let mut current = target;
1351 while let Some(part) = parts.next() {
1352 let Value::Object(object) = current else {
1353 return;
1354 };
1355 if parts.peek().is_none() {
1356 object.insert(part.to_owned(), value);
1357 return;
1358 }
1359 current = object.entry(part).or_insert_with(|| json!({}));
1360 }
1361}
1362
1363fn remove_json_path(target: &mut Value, path: &str) {
1364 let (parent, last) = match path.rsplit_once('.') {
1365 Some((parent, last)) => (Some(parent), last),
1366 None => (None, path),
1367 };
1368 let parent = match parent {
1369 Some(parent) => parent
1370 .split('.')
1371 .try_fold(&mut *target, |value, part| value.get_mut(part)),
1372 None => Some(target),
1373 };
1374 if let Some(Value::Object(object)) = parent {
1375 object.remove(last);
1376 }
1377}
1378
1379struct FilePattern {
1380 matcher: Arc<Pattern>,
1381 prefix: String,
1382 excluded: bool,
1383 owner: Option<String>,
1385}
1386
1387#[derive(Default)]
1388struct FileSelection {
1389 patterns: Vec<FilePattern>,
1390 included: BTreeSet<String>,
1391 excluded: BTreeSet<String>,
1392}
1393
1394#[derive(PartialEq, Eq, PartialOrd, Ord)]
1395enum FileScope {
1396 Workspace,
1397 Project(String),
1398}
1399
1400struct Resolver<'a> {
1401 workspace: &'a Workspace,
1402 snapshot: &'a Snapshot,
1403 candidates: &'a BTreeSet<String>,
1404 selected: BTreeSet<String>,
1405 scopes: BTreeMap<FileScope, FileSelection>,
1406 values: BTreeMap<String, Value>,
1407 named_stack: Vec<(String, String)>,
1408 expanded: BTreeSet<(String, String)>,
1410 external: BTreeSet<String>,
1411 prepared: Option<&'a PreparedTask>,
1414 cancelled: &'a AtomicBool,
1415}
1416
1417impl<'a> Resolver<'a> {
1418 fn new(
1419 snapshot: &'a Snapshot,
1420 workspace: &'a Workspace,
1421 prepared: Option<&'a PreparedTask>,
1422 cancelled: &'a AtomicBool,
1423 candidates: &'a BTreeSet<String>,
1424 ) -> Self {
1425 Self {
1426 workspace,
1427 snapshot,
1428 candidates,
1429 selected: BTreeSet::new(),
1430 scopes: BTreeMap::new(),
1431 values: BTreeMap::new(),
1432 named_stack: Vec::new(),
1433 expanded: BTreeSet::new(),
1434 external: BTreeSet::new(),
1435 prepared,
1436 cancelled,
1437 }
1438 }
1439
1440 fn task_inputs(&mut self, task: &Task) -> Result<()> {
1441 let default = vec![json!("default"), json!("^default")];
1442 for input in task.definition.inputs.as_ref().unwrap_or(&default) {
1443 self.input(&task.project, input)?;
1444 }
1445 Ok(())
1446 }
1447
1448 fn input(&mut self, project: &str, input: &Value) -> Result<()> {
1449 match input {
1450 Value::String(name) if name.starts_with('^') => {
1453 self.dependencies_named(project, &name[1..])?;
1454 }
1455 Value::Object(object)
1457 if object.contains_key("input") && !object.contains_key("fileset") =>
1458 {
1459 let name = object["input"]
1460 .as_str()
1461 .context("input must name a named input")?;
1462 let projects = object.get("projects");
1463 let dependencies = object.get("dependencies") == Some(&json!(true))
1464 || projects == Some(&json!("dependencies"));
1465 let listed = projects.filter(|projects| {
1466 **projects != json!("self") && **projects != json!("dependencies")
1467 });
1468 if (dependencies || listed.is_some()) && !self.named_stack.is_empty() {
1469 bail!("named inputs can only refer to named inputs of their own project");
1470 }
1471 if dependencies {
1472 self.dependencies_named(project, name)?;
1473 } else if let Some(listed) = listed {
1474 let patterns: Vec<String> = match listed {
1475 Value::String(pattern) => vec![pattern.clone()],
1476 other => serde_json::from_value(other.clone())
1477 .context("input projects must be a string or an array")?,
1478 };
1479 for selected in
1480 qk_graph::select_projects(&self.workspace.projects, &patterns, &[])?
1481 {
1482 if self.expanded.insert((selected.clone(), name.to_owned())) {
1483 self.named(&selected, name)?;
1484 }
1485 }
1486 } else {
1487 self.named(project, name)?;
1488 }
1489 }
1490 Value::Object(object)
1491 if object.contains_key("fileset")
1492 && object.get("dependencies") == Some(&json!(true)) =>
1493 {
1494 if !self.named_stack.is_empty() {
1495 bail!("named inputs can only refer to named inputs of their own project");
1496 }
1497 let pattern = object["fileset"]
1498 .as_str()
1499 .context("fileset input must be a glob")?
1500 .to_owned();
1501 for dependency in self.dependency_closure(project) {
1502 self.fileset(&dependency, &pattern)?;
1503 }
1504 }
1505 Value::Object(object)
1506 if object.len() == 1 && object.contains_key("workingDirectory") =>
1507 {
1508 let mode = object["workingDirectory"].as_str().unwrap_or_default();
1509 let directory = std::env::current_dir()?;
1510 let value = match mode {
1511 "absolute" => directory.to_string_lossy().into_owned(),
1512 "relative" => {
1513 let root = self.workspace.root.canonicalize()?;
1514 let directory = directory.canonicalize()?;
1515 paths::relative(&root, &directory)
1516 .unwrap_or_else(|_| directory.to_string_lossy().into_owned())
1517 }
1518 _ => bail!("workingDirectory must be \"relative\" or \"absolute\""),
1519 };
1520 self.values
1521 .insert(format!("workingDirectory:{mode}"), json!(value));
1522 }
1523 Value::Object(object) if object.contains_key("json") => {
1524 self.json(project, object)?;
1525 }
1526 Value::String(name)
1527 if !name.contains("{projectRoot}") && !name.contains("{workspaceRoot}") =>
1528 {
1529 self.named(project, name)?
1530 }
1531 Value::Object(object) if object.len() == 1 && object.contains_key("fileset") => {
1532 let pattern = object["fileset"]
1533 .as_str()
1534 .context("fileset input must be a glob")?;
1535 self.fileset(project, pattern)?;
1536 }
1537 Value::String(pattern) => self.fileset(project, pattern)?,
1538 Value::Object(object) if object.len() == 1 && object.contains_key("env") => {
1539 let name = object["env"]
1540 .as_str()
1541 .context("env input must name a variable")?;
1542 let Some(prepared) = self.prepared else {
1543 self.values.insert(format!("env:{name}"), Value::Null);
1544 return Ok(());
1545 };
1546 let value = prepared
1547 .env
1548 .get(std::ffi::OsStr::new(name))
1549 .map(|value| {
1550 value
1551 .to_str()
1552 .map(str::to_owned)
1553 .context("declared env input must be UTF-8")
1554 })
1555 .transpose()?;
1556 self.values.insert(format!("env:{name}"), json!(value));
1557 }
1558 Value::Object(object) if object.len() == 1 && object.contains_key("runtime") => {
1559 let command = object["runtime"]
1560 .as_str()
1561 .context("runtime input must be a command")?;
1562 let Some(prepared) = self.prepared else {
1563 self.values
1564 .insert(format!("runtime:{command}"), Value::Null);
1565 return Ok(());
1566 };
1567 let memo = RuntimeKey {
1568 command: command.into(),
1569 environment: prepared.runtime_env.clone(),
1570 };
1571 let slot = self
1572 .snapshot
1573 .runtime
1574 .lock()
1575 .unwrap()
1576 .entry(memo)
1577 .or_default()
1578 .clone();
1579 let mut saved = slot.lock().unwrap();
1580 if let Some(value) = saved.as_ref() {
1581 self.values
1582 .insert(format!("runtime:{command}"), value.clone());
1583 return Ok(());
1584 }
1585 let _profile = crate::profile::span(command, "runtime");
1586 let mut prepared = prepared.clone();
1587 prepared.env = prepared.runtime_env.clone();
1588 prepared.execution.clear();
1589 prepared.node_path = None;
1590 prepared.commands = vec![command.into()];
1591 prepared.cwd = self.workspace.root.clone();
1592 prepared.parallel = false;
1593 let log = tempfile::NamedTempFile::new()?;
1594 let capture = Capture::new(Some(log.as_file().try_clone()?), Display::Hidden);
1595 if execute_captured(&prepared, self.cancelled, Some(&capture))? != Outcome::Success
1596 {
1597 bail!("runtime input did not succeed");
1598 }
1599 let mut stdout = blake3::Hasher::new();
1600 let mut stderr = blake3::Hasher::new();
1601 read_capture(File::open(log.path())?, |error, bytes| {
1602 if error {
1603 stderr.update(bytes);
1604 } else {
1605 stdout.update(bytes);
1606 }
1607 Ok(())
1608 })?;
1609 let value = json!([
1610 stdout.finalize().to_hex().to_string(),
1611 stderr.finalize().to_hex().to_string()
1612 ]);
1613 *saved = Some(value.clone());
1614 self.values.insert(format!("runtime:{command}"), value);
1615 }
1616 Value::Object(object) if object.contains_key("dependentTasksOutputFiles") => {
1617 if object
1618 .keys()
1619 .any(|key| !matches!(key.as_str(), "dependentTasksOutputFiles" | "transitive"))
1620 {
1621 bail!("unsupported output input field");
1622 }
1623 let pattern = object["dependentTasksOutputFiles"]
1624 .as_str()
1625 .context("dependentTasksOutputFiles must be a glob")?;
1626 self.snapshot.pattern(pattern, false)?;
1627 let transitive = object
1628 .get("transitive")
1629 .map(|value| value.as_bool().context("transitive must be boolean"))
1630 .transpose()?
1631 .unwrap_or(false);
1632 let value = self
1636 .values
1637 .entry(format!("dependentTasksOutputFiles:{pattern}"))
1638 .or_insert(json!(false));
1639 if transitive {
1640 *value = json!(true);
1641 }
1642 }
1643 Value::Object(object)
1644 if object.len() == 1 && object.contains_key("externalDependencies") =>
1645 {
1646 let names: Vec<String> =
1647 serde_json::from_value(object["externalDependencies"].clone())?;
1648 self.external.extend(names);
1649 }
1650 _ => bail!("unsupported task input declaration"),
1651 }
1652 Ok(())
1653 }
1654
1655 fn fileset(&mut self, project: &str, pattern: &str) -> Result<()> {
1656 let (exclude, pattern) = pattern
1657 .strip_prefix('!')
1658 .map(|pattern| (true, pattern))
1659 .unwrap_or((false, pattern));
1660 let (scope, owner) = if pattern.contains("{workspaceRoot}") {
1664 (FileScope::Workspace, None)
1665 } else {
1666 (
1667 FileScope::Project(project.to_owned()),
1668 Some(project.to_owned()),
1669 )
1670 };
1671 let pattern = paths::expand(self.workspace, project, pattern)?;
1672 let matcher = self.snapshot.pattern(&pattern, exclude)?;
1673 let prefix = pattern
1674 .split('/')
1675 .take_while(|part| is_literal(part))
1676 .collect::<Vec<_>>()
1677 .join("/");
1678 let candidates: Box<dyn Iterator<Item = &String>> = if prefix.is_empty() {
1679 Box::new(self.candidates.iter())
1680 } else {
1681 Box::new(under(self.candidates, &prefix))
1682 };
1683 let snapshot = self.snapshot;
1684 let selection = self.scopes.entry(scope).or_default();
1685 let selected = if exclude {
1686 &mut selection.excluded
1687 } else {
1688 &mut selection.included
1689 };
1690 selected.extend(
1691 candidates
1692 .filter(|file| matcher.is_match(file))
1693 .filter(|file| owner.is_none() || snapshot.owner(file) == owner.as_deref())
1694 .cloned(),
1695 );
1696 selection.patterns.push(FilePattern {
1697 matcher,
1698 prefix,
1699 excluded: exclude,
1700 owner,
1701 });
1702 Ok(())
1703 }
1704
1705 fn dependency_closure(&self, project: &str) -> BTreeSet<String> {
1707 let mut closure = BTreeSet::new();
1708 let mut pending = vec![project.to_owned()];
1709 while let Some(current) = pending.pop() {
1710 for edge in &self.snapshot.projects.dependencies[¤t] {
1711 if edge.target != project && closure.insert(edge.target.clone()) {
1712 pending.push(edge.target.clone());
1713 }
1714 }
1715 }
1716 closure
1717 }
1718
1719 fn dependencies_named(&mut self, project: &str, name: &str) -> Result<()> {
1721 for dependency in self.dependency_closure(project) {
1722 if self.expanded.insert((dependency.clone(), name.to_owned())) {
1723 self.named(&dependency, name)?;
1724 }
1725 }
1726 Ok(())
1727 }
1728
1729 fn json(&mut self, project: &str, object: &serde_json::Map<String, Value>) -> Result<()> {
1733 for key in object.keys() {
1734 if !matches!(key.as_str(), "json" | "fields" | "excludeFields") {
1735 bail!("unsupported json input field {key:?}");
1736 }
1737 }
1738 let path = object["json"]
1739 .as_str()
1740 .context("json input must name a file")?;
1741 let path = paths::expand(self.workspace, project, path)?;
1742 paths::validate_path(&path)?;
1743 let list = |name: &str| -> Result<Option<Vec<String>>> {
1744 object
1745 .get(name)
1746 .map(|value| {
1747 serde_json::from_value(value.clone())
1748 .with_context(|| format!("json input {name} must be an array of strings"))
1749 })
1750 .transpose()
1751 };
1752 let fields = list("fields")?;
1753 let exclude = list("excludeFields")?.unwrap_or_default();
1754 let value = match std::fs::read_to_string(self.workspace.root.join(&path)) {
1755 Ok(text) => {
1756 let mut value: Value =
1757 jsonc_parser::parse_to_serde_value(&text, &Default::default())
1758 .with_context(|| format!("cannot parse json input {path}"))?;
1759 if let Some(fields) = &fields {
1760 let mut kept = json!({});
1761 for field in fields {
1762 if let Some(found) = json_path(&value, field) {
1763 set_json_path(&mut kept, field, found.clone());
1764 }
1765 }
1766 value = kept;
1767 }
1768 for field in &exclude {
1769 remove_json_path(&mut value, field);
1770 }
1771 value
1772 }
1773 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Null,
1774 Err(error) => return Err(error.into()),
1775 };
1776 self.values.insert(format!("json:{path}"), value);
1777 Ok(())
1778 }
1779
1780 fn named(&mut self, project: &str, name: &str) -> Result<()> {
1781 let identity = (project.to_owned(), name.to_owned());
1782 if self.named_stack.contains(&identity) {
1783 bail!("named input cycle at {project}:{name}");
1784 }
1785 self.named_stack.push(identity);
1786 let inputs = self.workspace.projects[project]
1787 .named_inputs
1788 .get(name)
1789 .cloned()
1790 .or_else(|| (name == "default").then(|| vec![json!("{projectRoot}/**/*")]))
1791 .with_context(|| format!("unknown named input {name:?}"))?;
1792 for input in &inputs {
1793 self.input(project, input)?;
1794 }
1795 self.named_stack.pop();
1796 Ok(())
1797 }
1798}
1799
1800pub fn resolve(
1801 snapshot: &Snapshot,
1802 workspace: &Workspace,
1803 task: &Task,
1804 prepared: Option<&PreparedTask>,
1805 cancelled: &AtomicBool,
1806) -> Result<Resolved> {
1807 resolve_candidates(
1808 snapshot,
1809 workspace,
1810 task,
1811 prepared,
1812 cancelled,
1813 &snapshot.files,
1814 &snapshot.source_ignore,
1815 )
1816}
1817
1818fn resolve_candidates(
1819 snapshot: &Snapshot,
1820 workspace: &Workspace,
1821 task: &Task,
1822 prepared: Option<&PreparedTask>,
1823 cancelled: &AtomicBool,
1824 candidates: &BTreeSet<String>,
1825 source_ignore: &SourceIgnore,
1826) -> Result<Resolved> {
1827 let mut resolver = Resolver::new(snapshot, workspace, prepared, cancelled, candidates);
1828 resolver.task_inputs(task)?;
1829 let mut upstream = BTreeSet::new();
1832 let mut pending: Vec<_> = task.dependencies.iter().cloned().collect();
1833 while let Some(id) = pending.pop() {
1834 if upstream.insert(id.clone())
1835 && let Some(dependencies) = snapshot.task_dependencies.get(&id)
1836 {
1837 pending.extend(dependencies.iter().cloned());
1838 }
1839 }
1840 let own_outputs = Outputs::new(workspace, task)
1841 .ok()
1842 .filter(Outputs::is_explicit);
1843 for (producer, declared) in &snapshot.generated_tasks {
1844 if producer.id == task.id || !upstream.contains(&producer.id) {
1845 continue;
1846 }
1847 let overlaps = |a: &str, b: &str| {
1848 a.is_empty()
1849 || a == b
1850 || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
1851 || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
1852 };
1853 let relevant = resolver
1854 .scopes
1855 .values()
1856 .flat_map(|scope| &scope.patterns)
1857 .filter(|pattern| !pattern.excluded)
1858 .any(|pattern| {
1859 declared.anchors().any(|anchor| {
1860 overlaps(&pattern.prefix, anchor)
1861 && (!source_ignore.matches_directory(anchor)
1862 || !workspace.root.join(anchor).is_dir())
1863 })
1864 });
1865 if !relevant {
1866 continue;
1867 }
1868 let outputs = snapshot.dependency_outputs(workspace, producer)?;
1869 for path in &outputs.files {
1870 if source_ignore.matches(path)
1871 || own_outputs.as_ref().is_some_and(|own| own.matches(path))
1872 {
1873 continue;
1874 }
1875 for selection in resolver.scopes.values_mut() {
1876 for pattern in &selection.patterns {
1877 if pattern.matcher.is_match(path)
1878 && (pattern.owner.is_none()
1879 || snapshot.owner(path) == pattern.owner.as_deref())
1880 {
1881 if pattern.excluded {
1882 selection.excluded.insert(path.clone());
1883 } else {
1884 selection.included.insert(path.clone());
1885 }
1886 }
1887 }
1888 }
1889 }
1890 }
1891 for selection in resolver.scopes.values() {
1892 resolver.selected.extend(
1893 selection
1894 .included
1895 .difference(&selection.excluded)
1896 .filter(|path| own_outputs.as_ref().is_none_or(|own| !own.matches(path)))
1897 .cloned(),
1898 );
1899 }
1900 let readable = snapshot
1901 .installed(&workspace.root, &task.id)?
1902 .is_some_and(|installed| installed.lockfile.is_some());
1903 let tsconfig = ["tsconfig.base.json", "tsconfig.json"]
1911 .into_iter()
1912 .find(|name| {
1913 workspace.root.join(name).is_file() || snapshot.extra_candidates.contains(*name)
1914 });
1915 let mut mandatory = BTreeSet::new();
1916 for path in tsconfig.into_iter().chain([
1917 "nx.json",
1918 ".gitignore",
1919 ".nxignore",
1920 qk_config::LOCAL_WORKSPACE,
1921 "pnpm-workspace.yaml",
1922 "pnpm-lock.yaml",
1923 "package-lock.json",
1924 "yarn.lock",
1925 "bun.lock",
1926 ]) {
1927 if (workspace.root.join(path).is_file() || snapshot.extra_candidates.contains(path))
1928 && !(path == "pnpm-lock.yaml" && readable)
1929 {
1930 resolver.selected.insert(path.into());
1931 mandatory.insert(path.into());
1932 }
1933 }
1934 if let Some(extended) = &workspace.extended {
1935 resolver.selected.insert(extended.clone());
1936 mandatory.insert(extended.clone());
1937 }
1938 let mut packages = BTreeSet::from([task.project.clone()]);
1943 let mut pending = packages.clone();
1944 while let Some(project) = pending.pop_first() {
1945 for edge in &snapshot.projects.dependencies[&project] {
1946 if packages.insert(edge.target.clone()) {
1947 pending.insert(edge.target.clone());
1948 }
1949 }
1950 }
1951 for project in &packages {
1952 let own = *project == task.project;
1953 for name in ["project.json", qk_config::LOCAL_OVERRIDES, "package.json"] {
1954 if !own && name != "package.json" {
1955 continue;
1956 }
1957 let path = Path::new(&workspace.projects[project].root).join(name);
1958 let path = path.strip_prefix(".").unwrap_or(&path).to_owned();
1959 if !workspace.root.join(&path).is_file() {
1960 continue;
1961 }
1962 let path = path
1963 .to_str()
1964 .context("project path must be UTF-8")?
1965 .replace('\\', "/");
1966 mandatory.insert(path.clone());
1967 if own || resolver.selected.contains(&path) {
1969 resolver.selected.insert(path);
1970 continue;
1971 }
1972 let text = std::fs::read_to_string(workspace.root.join(&path))
1973 .with_context(|| format!("cannot read {path}"))?;
1974 match manifest_for_dependents(&text, readable) {
1975 Some(value) => {
1976 resolver.values.insert(format!("manifest:{path}"), value);
1977 }
1978 None => {
1979 resolver.selected.insert(path);
1980 }
1981 }
1982 }
1983 }
1984 let workspace_file = readable && resolver.selected.remove("pnpm-workspace.yaml");
1987 if workspace_file {
1988 mandatory.remove("pnpm-workspace.yaml");
1989 }
1990 let lockfile = readable.then(|| {
1991 let importers = std::iter::once(".".to_owned())
1994 .chain(
1995 packages
1996 .iter()
1997 .map(|project| workspace.projects[project].root.clone()),
1998 )
1999 .collect();
2000 (importers, resolver.external.clone())
2001 });
2002 Ok(Resolved {
2003 files: resolver.selected,
2004 mandatory,
2005 values: resolver.values,
2006 lockfile,
2007 workspace_file,
2008 external: resolver.external,
2009 })
2010}
2011
2012pub fn inputs(
2015 snapshot: &Snapshot,
2016 workspace: &Workspace,
2017 task: &Task,
2018 prepared: &PreparedTask,
2019 dependencies: &BTreeMap<String, String>,
2020 cancelled: &AtomicBool,
2021) -> Result<Value> {
2022 let prefixes = snapshot.membership_prefixes(workspace, task)?;
2023 let candidates = snapshot.current_files_for(&workspace.root, Some(&prefixes), &task.id)?;
2024 let Resolved {
2025 files,
2026 mut values,
2027 lockfile,
2028 workspace_file,
2029 ..
2030 } = resolve_candidates(
2031 snapshot,
2032 workspace,
2033 task,
2034 Some(prepared),
2035 cancelled,
2036 &candidates.files,
2037 &candidates.source_ignore,
2038 )?;
2039 let installed = snapshot.installed(&workspace.root, &task.id)?;
2040 if let (Some((importers, external)), Some(installed)) = (lockfile, &installed)
2041 && let Some(lockfile) = &installed.lockfile
2042 {
2043 let importers: BTreeMap<_, _> = importers
2044 .iter()
2045 .filter(|importer| lockfile.has_importer(importer))
2046 .map(|importer| {
2047 let digest = installed.digest(&format!("importer:{importer}"), || {
2048 lockfile.installed(importer).unwrap_or_default()
2049 });
2050 (importer.clone(), digest)
2051 })
2052 .collect();
2053 let external: BTreeMap<_, _> = external
2054 .iter()
2055 .map(|name| {
2056 let digest =
2057 installed.digest(&format!("package:{name}"), || lockfile.package(name));
2058 (name.clone(), digest)
2059 })
2060 .collect();
2061 values.insert(
2062 "lockfile".into(),
2063 json!({
2064 "global": installed.digest("global", || BTreeSet::from([lockfile.global().to_string()])),
2065 "importers": importers,
2066 "external": external,
2067 }),
2068 );
2069 }
2070 let mut files = files
2071 .iter()
2072 .map(|path| {
2073 let value = snapshot
2074 .file_value(&workspace.root, path)
2075 .with_context(|| format!("cannot read input {path}"))?;
2076 Ok((path.clone(), value))
2077 })
2078 .collect::<Result<BTreeMap<_, _>>>()?;
2079 if workspace_file {
2080 match workspace_without_resolution(&workspace.root) {
2081 Some(value) => {
2082 values.insert("pnpm-workspace".into(), value);
2083 }
2084 None => {
2085 files.insert(
2086 "pnpm-workspace.yaml".into(),
2087 snapshot.file_value(&workspace.root, "pnpm-workspace.yaml")?,
2088 );
2089 }
2090 }
2091 }
2092 let mut definition = task.definition.clone();
2094 definition.extra.remove("qk:warm");
2095 definition.extra.remove("qk:threads");
2096 let hash = json!({
2097 "schema":"qk-local-v2", "qk":env!("CARGO_PKG_VERSION"),
2098 "platform":[std::env::consts::OS, std::env::consts::ARCH], "workspace":snapshot.workspace_prefix,
2099 "id":task.id, "args":task.args, "definition":definition, "packageManager":workspace.package_manager,
2100 "files":files, "values":values, "dependencies":dependencies,
2101 });
2102 Ok(hash)
2103}
2104
2105pub fn key(inputs: &Value) -> Result<String> {
2107 Ok(blake3::hash(&serde_json::to_vec(inputs)?)
2108 .to_hex()
2109 .to_string())
2110}
2111
2112pub fn fingerprint(
2113 snapshot: &Snapshot,
2114 workspace: &Workspace,
2115 task: &Task,
2116 prepared: &PreparedTask,
2117 dependencies: &BTreeMap<String, String>,
2118 cancelled: &AtomicBool,
2119) -> Result<String> {
2120 key(&inputs(
2121 snapshot,
2122 workspace,
2123 task,
2124 prepared,
2125 dependencies,
2126 cancelled,
2127 )?)
2128}
2129
2130pub(crate) fn dependency_keys(
2134 snapshot: &Snapshot,
2135 workspace: &Workspace,
2136 graph: &TaskGraph,
2137 task: &Task,
2138 dependencies: &BTreeMap<String, String>,
2139 cancelled: &AtomicBool,
2140) -> Result<BTreeMap<String, String>> {
2141 dependency_keys_impl(
2142 snapshot,
2143 workspace,
2144 graph,
2145 task,
2146 dependencies,
2147 cancelled,
2148 false,
2149 )
2150}
2151
2152pub(crate) fn recheck_dependency_keys(
2154 snapshot: &Snapshot,
2155 workspace: &Workspace,
2156 graph: &TaskGraph,
2157 task: &Task,
2158 dependencies: &BTreeMap<String, String>,
2159 cancelled: &AtomicBool,
2160) -> Result<BTreeMap<String, String>> {
2161 dependency_keys_impl(
2162 snapshot,
2163 workspace,
2164 graph,
2165 task,
2166 dependencies,
2167 cancelled,
2168 true,
2169 )
2170}
2171
2172fn dependency_keys_impl(
2173 snapshot: &Snapshot,
2174 workspace: &Workspace,
2175 graph: &TaskGraph,
2176 task: &Task,
2177 dependencies: &BTreeMap<String, String>,
2178 cancelled: &AtomicBool,
2179 fresh: bool,
2180) -> Result<BTreeMap<String, String>> {
2181 let resolved = resolve(snapshot, workspace, task, None, cancelled)?;
2182 let selections: Vec<_> = resolved.dependency_output_inputs().collect();
2183 if selections.is_empty() {
2184 if fresh {
2185 return dependencies
2186 .iter()
2187 .map(|(id, key)| {
2188 let outputs = Outputs::new(workspace, &graph.tasks[id])?;
2189 let key = if outputs.declared() {
2190 crate::output_fingerprint(&workspace.root, &outputs, "")?
2191 } else {
2192 key.clone()
2193 };
2194 Ok((id.clone(), key))
2195 })
2196 .collect();
2197 }
2198 return Ok(dependencies.clone());
2199 }
2200 let mut keys = BTreeMap::new();
2201 let mut fresh_outputs = HashMap::new();
2202 for (pattern, transitive) in selections {
2203 let matcher = snapshot.pattern(pattern, false)?;
2204 let mut selected = task.dependencies.clone();
2205 if transitive {
2206 let mut pending = selected.clone();
2207 while let Some(id) = pending.pop_first() {
2208 for dependency in &graph.tasks[&id].dependencies {
2209 if selected.insert(dependency.clone()) {
2210 pending.insert(dependency.clone());
2211 }
2212 }
2213 }
2214 }
2215 for id in selected {
2216 let outputs = if fresh {
2217 if !fresh_outputs.contains_key(&id) {
2218 fresh_outputs.insert(
2219 id.clone(),
2220 Arc::new(snapshot.fresh_dependency_outputs(workspace, &graph.tasks[&id])?),
2221 );
2222 }
2223 fresh_outputs[&id].clone()
2224 } else {
2225 snapshot.dependency_outputs(workspace, &graph.tasks[&id])?
2226 };
2227 let mut files = BTreeMap::new();
2228 for path in outputs.files.iter().filter(|path| matcher.is_match(path)) {
2229 let known = outputs.values.lock().unwrap().get(path).cloned();
2230 let value = match known {
2231 Some(value) => value,
2232 None => {
2233 let value = snapshot.file_value(&workspace.root, path)?;
2234 if value.get("link").is_none() {
2236 outputs
2237 .values
2238 .lock()
2239 .unwrap()
2240 .insert(path.clone(), value.clone());
2241 }
2242 value
2243 }
2244 };
2245 files.insert(path.clone(), value);
2246 }
2247 keys.insert(format!("{id}:{pattern}"), key(&json!(files))?);
2248 }
2249 }
2250 Ok(keys)
2251}
2252
2253#[cfg(test)]
2254mod tests {
2255 use super::*;
2256 use qk_taskgraph::Request;
2257
2258 fn fixture(outputs: Option<Value>) -> (tempfile::TempDir, Workspace, TaskGraph) {
2259 let root = tempfile::tempdir().unwrap();
2260 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
2261 let mut post = json!({"command": "true"});
2262 if let Some(outputs) = outputs {
2263 post["outputs"] = outputs;
2264 }
2265 std::fs::write(root.path().join("project.json"), json!({"name":"app", "targets": {
2266 "gen": {"command":"true", "outputs":["generated"]},
2267 "post": post,
2268 "build": {"command":"true", "dependsOn":["gen", "post"], "inputs":[{"dependentTasksOutputFiles":"**/*"}]}
2269 }}).to_string()).unwrap();
2270 std::fs::create_dir(root.path().join("generated")).unwrap();
2271 std::fs::write(root.path().join("generated/value"), "one").unwrap();
2272 let workspace = Workspace::load(root.path()).unwrap();
2273 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2274 (root, workspace, graph)
2275 }
2276
2277 #[test]
2278 fn execution_fingerprints_refresh_sources_and_preserve_artifact_exclusions() {
2279 let (root, mut workspace, _) = fixture(Some(json!(["dist"])));
2280 let definition = workspace
2281 .projects
2282 .get_mut("app")
2283 .unwrap()
2284 .targets
2285 .get_mut("post")
2286 .unwrap();
2287 definition
2288 .extra
2289 .insert("qk:warm".into(), json!({"paths": ["scratch"]}));
2290 std::fs::write(root.path().join(".gitignore"), "ignored/\n").unwrap();
2291 std::fs::create_dir(root.path().join("src")).unwrap();
2292 std::fs::write(root.path().join("src/original"), "one").unwrap();
2293 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2294 let task = &graph.tasks["app:post"];
2295 let prepared = qk_executor::prepare(&workspace, task, &BTreeMap::new()).unwrap();
2296 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2297 let inputs = || {
2298 super::inputs(
2299 &snapshot,
2300 &workspace,
2301 task,
2302 &prepared,
2303 &BTreeMap::new(),
2304 &AtomicBool::new(false),
2305 )
2306 .unwrap()
2307 };
2308 let before = inputs();
2309 for directory in ["generated", "dist", "scratch", "cache", "ignored"] {
2311 std::fs::create_dir_all(root.path().join(directory)).unwrap();
2312 std::fs::write(root.path().join(directory).join("new"), "artifact").unwrap();
2313 }
2314 assert_eq!(before, inputs());
2315 std::fs::write(root.path().join("src/added"), "two").unwrap();
2318 let after = inputs();
2319 assert!(after["files"].get("src/added").is_some());
2320 assert_ne!(key(&before).unwrap(), key(&after).unwrap());
2321 std::fs::remove_file(root.path().join("src/added")).unwrap();
2322 assert_eq!(before, inputs());
2323 std::fs::remove_file(root.path().join("src/original")).unwrap();
2324 assert!(inputs()["files"].get("src/original").is_none());
2325 }
2326
2327 #[test]
2328 fn membership_reuses_settled_candidates_and_detects_directory_changes() {
2329 let mut fixtures = Vec::new();
2330 for git in [false, true] {
2331 let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2332 for directory in ["src/empty", "dist", "cache", "ignored"] {
2333 std::fs::create_dir_all(root.path().join(directory)).unwrap();
2334 }
2335 std::fs::write(root.path().join(".gitignore"), "ignored/\n").unwrap();
2336 std::fs::write(root.path().join("src/original"), "one").unwrap();
2337 if git {
2338 assert!(
2339 paths::git(root.path(), &["init", "-q"])
2340 .unwrap()
2341 .status
2342 .success()
2343 );
2344 assert!(
2345 paths::git(root.path(), &["add", "."])
2346 .unwrap()
2347 .status
2348 .success()
2349 );
2350 }
2351 let snapshot =
2352 Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2353 snapshot.current_files(root.path()).unwrap();
2354 fixtures.push((root, snapshot));
2355 }
2356 std::thread::sleep(Duration::from_millis(2100));
2359 for (root, snapshot) in fixtures {
2360 let files = || snapshot.current_files(root.path()).unwrap();
2361 let before = files();
2362 assert!(Arc::ptr_eq(&before, &files()));
2363 for directory in ["generated", "dist", "cache"] {
2364 std::fs::write(root.path().join(directory).join("new"), "artifact").unwrap();
2365 }
2366 assert!(Arc::ptr_eq(&before, &files()));
2367 std::fs::write(root.path().join("src/empty/added"), "two").unwrap();
2368 assert!(files().contains("src/empty/added"));
2369 std::fs::create_dir_all(root.path().join("src/new/nested")).unwrap();
2370 std::fs::write(root.path().join("src/new/nested/added"), "three").unwrap();
2371 assert!(files().contains("src/new/nested/added"));
2372 std::fs::rename(
2373 root.path().join("src/original"),
2374 root.path().join("src/renamed"),
2375 )
2376 .unwrap();
2377 let renamed = files();
2378 assert!(!renamed.contains("src/original"));
2379 assert!(renamed.contains("src/renamed"));
2380 std::fs::remove_file(root.path().join("src/renamed")).unwrap();
2381 assert!(!files().contains("src/renamed"));
2382 std::fs::write(root.path().join(".gitignore"), "ignored/\nsrc/empty/\n").unwrap();
2385 assert!(!files().contains("src/empty/added"));
2386 std::fs::write(root.path().join(".gitignore"), "ignored/\n").unwrap();
2387 assert!(files().contains("src/empty/added"));
2388 }
2389 }
2390
2391 #[test]
2392 fn identical_ignore_rewrites_reuse_candidates_and_changed_rules_refresh() {
2393 for name in [".gitignore", ".nxignore"] {
2394 let (root, workspace, graph) = fixture(None);
2395 let policy = root.path().join(name);
2396 std::fs::write(root.path().join("alpha"), "a").unwrap();
2397 std::fs::write(root.path().join("bravo"), "b").unwrap();
2398 std::fs::write(&policy, "alpha\n").unwrap();
2399 let snapshot =
2400 Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2401 let original = snapshot.current_files(root.path()).unwrap();
2402 assert!(!original.contains("alpha"));
2403 assert!(original.contains("bravo"));
2404 for _ in 0..3 {
2405 std::fs::write(&policy, "alpha\n").unwrap();
2406 assert!(Arc::ptr_eq(
2407 &original,
2408 &snapshot.current_files(root.path()).unwrap()
2409 ));
2410 }
2411 std::fs::write(&policy, "bravo\n").unwrap();
2414 {
2415 let mut cached = snapshot.membership.lock().unwrap();
2416 Arc::make_mut(cached.as_mut().unwrap())
2417 .stamps
2418 .insert(policy.clone(), membership_stamp(&policy).unwrap());
2419 }
2420 let changed = snapshot.current_files(root.path()).unwrap();
2421 assert!(!Arc::ptr_eq(&original, &changed));
2422 assert!(changed.contains("alpha"));
2423 assert!(!changed.contains("bravo"));
2424 std::fs::remove_file(&policy).unwrap();
2425 assert!(
2426 snapshot
2427 .current_files(root.path())
2428 .unwrap()
2429 .contains("bravo")
2430 );
2431 }
2432 }
2433
2434 #[test]
2435 fn ignore_contents_changed_after_inventory_cannot_bless_a_listing() {
2436 let (root, workspace, graph) = fixture(None);
2437 let policy = root.path().join(".nxignore");
2438 std::fs::write(&policy, "alpha\n").unwrap();
2439 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2440 snapshot.current_files(root.path()).unwrap();
2441 let membership = snapshot.membership.lock().unwrap().clone().unwrap();
2442 std::fs::write(&policy, "bravo\n").unwrap();
2443 assert!(!membership.state(None).unwrap().0);
2444 }
2445
2446 #[test]
2447 fn a_directory_missed_by_the_inventory_cannot_enable_reuse() {
2448 let (root, workspace, graph) = fixture(None);
2449 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2450 snapshot.current_files(root.path()).unwrap();
2451 std::fs::create_dir(root.path().join("new-empty")).unwrap();
2452 let mut cached = snapshot.membership.lock().unwrap();
2453 let membership = Arc::make_mut(cached.as_mut().unwrap());
2454 membership
2457 .directory_entries
2458 .get_mut(root.path())
2459 .unwrap()
2460 .insert((OsString::from("new-empty"), 1));
2461 assert!(membership.entries(root.path()).unwrap().is_none());
2462 assert!(!membership.state(None).unwrap().0);
2463 }
2464
2465 #[test]
2466 fn a_policy_missed_by_discovery_cannot_enable_reuse() {
2467 let (root, workspace, graph) = fixture(None);
2468 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2469 snapshot.current_files(root.path()).unwrap();
2470 std::fs::write(root.path().join(".nxignore"), "hidden\n").unwrap();
2471 let mut cached = snapshot.membership.lock().unwrap();
2472 let membership = Arc::make_mut(cached.as_mut().unwrap());
2473 membership
2476 .directory_entries
2477 .get_mut(root.path())
2478 .unwrap()
2479 .insert((OsString::from(".nxignore"), 0));
2480 assert!(membership.entries(root.path()).unwrap().is_none());
2481 assert!(!membership.state(None).unwrap().0);
2482 }
2483
2484 #[test]
2485 fn output_publication_reuses_candidates_but_source_names_still_refresh() {
2486 for partial in [false, true] {
2487 let outputs = if partial {
2488 json!(["dist", "!dist/keep"])
2489 } else {
2490 json!(["dist"])
2491 };
2492 let (root, workspace, graph) = fixture(Some(outputs));
2493 std::fs::create_dir_all(root.path().join("src")).unwrap();
2494 std::fs::write(root.path().join("src/original"), "one").unwrap();
2495 if partial {
2496 std::fs::create_dir_all(root.path().join("dist/keep")).unwrap();
2497 }
2498 let snapshot =
2499 Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2500 snapshot.current_files(root.path()).unwrap();
2501 std::thread::sleep(Duration::from_millis(2100));
2504 let before = snapshot.current_files(root.path()).unwrap();
2505 std::fs::create_dir_all(root.path().join("dist")).unwrap();
2506 std::fs::write(root.path().join("dist/restored"), "artifact").unwrap();
2507 let restored = snapshot.current_files(root.path()).unwrap();
2508 assert!(Arc::ptr_eq(&before, &restored));
2509 std::fs::remove_file(root.path().join("dist/restored")).unwrap();
2510 assert!(Arc::ptr_eq(
2511 &before,
2512 &snapshot.current_files(root.path()).unwrap()
2513 ));
2514 let source = if partial { "dist/keep/new" } else { "src/new" };
2515 std::fs::write(root.path().join(source), "source").unwrap();
2516 let after = snapshot.current_files(root.path()).unwrap();
2517 assert!(after.contains(source));
2518 assert!(!Arc::ptr_eq(&before, &after));
2519 std::fs::remove_file(root.path().join(source)).unwrap();
2520 assert!(
2521 !snapshot
2522 .current_files(root.path())
2523 .unwrap()
2524 .contains(source)
2525 );
2526 }
2527 }
2528
2529 #[test]
2530 fn concurrent_fingerprints_refresh_without_replacing_newer_membership() {
2531 let (root, workspace, graph) = fixture(None);
2532 std::fs::create_dir_all(root.path().join("src")).unwrap();
2533 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2534 let original = snapshot.current_files(root.path()).unwrap();
2535 let barrier = std::sync::Barrier::new(9);
2536 std::thread::scope(|scope| {
2537 let workers: Vec<_> = (0..8)
2538 .map(|_| {
2539 scope.spawn(|| {
2540 let mut rounds = Vec::new();
2541 for _ in 0..6 {
2542 barrier.wait();
2543 rounds.push(snapshot.current_files(root.path()));
2544 barrier.wait();
2545 }
2546 rounds
2547 })
2548 })
2549 .collect();
2550 for round in 0..6 {
2551 std::fs::write(root.path().join(format!("src/new{round}")), "source").unwrap();
2552 barrier.wait();
2553 barrier.wait();
2554 }
2555 for worker in workers {
2558 for (round, files) in worker.join().unwrap().into_iter().enumerate() {
2559 let files = files.unwrap();
2560 for added in 0..=round {
2561 assert!(files.contains(&format!("src/new{added}")));
2562 }
2563 }
2564 }
2565 });
2566 assert!(!original.contains("src/new0"));
2567 assert!(
2568 snapshot
2569 .current_files(root.path())
2570 .unwrap()
2571 .contains("src/new5")
2572 );
2573 }
2574
2575 #[test]
2576 fn retained_candidates_keep_their_dependency_output_ignore_policy() {
2577 let (root, mut workspace, _) = fixture(None);
2578 workspace
2579 .projects
2580 .get_mut("app")
2581 .unwrap()
2582 .targets
2583 .get_mut("build")
2584 .unwrap()
2585 .inputs = Some(vec![json!("{workspaceRoot}/**/*.txt")]);
2586 std::fs::create_dir(root.path().join("src")).unwrap();
2587 for name in ["alpha", "beta", "gamma"] {
2588 for directory in ["src", "generated"] {
2589 std::fs::write(root.path().join(format!("{directory}/{name}.txt")), name).unwrap();
2590 }
2591 }
2592 let policy = |keep: &str| {
2593 let ignored: String = ["alpha", "beta", "gamma"]
2594 .into_iter()
2595 .filter(|name| *name != keep)
2596 .flat_map(|name| {
2597 [
2598 format!("src/{name}.txt\n"),
2599 format!("generated/{name}.txt\n"),
2600 ]
2601 })
2602 .collect();
2603 std::fs::write(root.path().join(".nxignore"), ignored).unwrap();
2604 };
2605 policy("alpha");
2606 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2607 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
2608 let first = snapshot
2609 .current_files_for(root.path(), None, "test")
2610 .unwrap();
2611 policy("beta");
2612 let second = snapshot
2613 .current_files_for(root.path(), None, "test")
2614 .unwrap();
2615 policy("gamma");
2616 let third = snapshot
2617 .current_files_for(root.path(), None, "test")
2618 .unwrap();
2619 let cancelled = AtomicBool::new(false);
2622 let expected = |name: &str| {
2623 BTreeSet::from([
2624 ".nxignore".into(),
2625 "nx.json".into(),
2626 "project.json".into(),
2627 format!("src/{name}.txt"),
2628 format!("generated/{name}.txt"),
2629 ])
2630 };
2631 for (candidates, name) in [(first, "alpha"), (second, "beta"), (third, "gamma")] {
2632 let resolved = resolve_candidates(
2633 &snapshot,
2634 &workspace,
2635 &graph.tasks["app:build"],
2636 None,
2637 &cancelled,
2638 &candidates.files,
2639 &candidates.source_ignore,
2640 )
2641 .unwrap();
2642 assert_eq!(resolved.files, expected(name));
2643 }
2644 let inspected = resolve(
2646 &snapshot,
2647 &workspace,
2648 &graph.tasks["app:build"],
2649 None,
2650 &cancelled,
2651 )
2652 .unwrap();
2653 assert_eq!(inspected.files, expected("alpha"));
2654 }
2655
2656 #[test]
2657 fn scoped_membership_defers_unrelated_changes_until_a_matching_task_asks() {
2658 let (root, mut workspace, _) = fixture(Some(json!(["dist"])));
2659 let targets = &mut workspace.projects.get_mut("app").unwrap().targets;
2660 targets.get_mut("post").unwrap().inputs =
2661 Some(vec![json!("{workspaceRoot}/left/**/*.txt")]);
2662 targets.get_mut("gen").unwrap().inputs =
2663 Some(vec![json!("{workspaceRoot}/right/**/*.txt")]);
2664 targets.get_mut("build").unwrap().inputs = Some(vec![json!("{workspaceRoot}/**/*.txt")]);
2665 for directory in ["left/empty", "right/empty"] {
2666 std::fs::create_dir_all(root.path().join(directory)).unwrap();
2667 }
2668 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2669 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2670 snapshot.current_files(&workspace.root).unwrap();
2671 std::thread::sleep(Duration::from_millis(2100));
2672 let left = snapshot
2673 .membership_prefixes(&workspace, &graph.tasks["app:post"])
2674 .unwrap();
2675 let right = snapshot
2676 .membership_prefixes(&workspace, &graph.tasks["app:gen"])
2677 .unwrap();
2678 let all = snapshot
2679 .membership_prefixes(&workspace, &graph.tasks["app:build"])
2680 .unwrap();
2681 let before = snapshot
2682 .current_files_for(&workspace.root, Some(&left), "test")
2683 .unwrap();
2684 std::fs::write(root.path().join("right/empty/new.txt"), "right").unwrap();
2685 let unrelated = snapshot
2686 .current_files_for(&workspace.root, Some(&left), "test")
2687 .unwrap();
2688 assert!(Arc::ptr_eq(&before.files, &unrelated.files));
2689 assert!(!unrelated.files.contains("right/empty/new.txt"));
2690 assert!(
2691 snapshot
2692 .current_files_for(&workspace.root, Some(&right), "test")
2693 .unwrap()
2694 .files
2695 .contains("right/empty/new.txt")
2696 );
2697 std::fs::create_dir_all(root.path().join("left/empty/nested")).unwrap();
2698 std::fs::write(root.path().join("left/empty/nested/new.txt"), "left").unwrap();
2699 assert!(
2700 snapshot
2701 .current_files_for(&workspace.root, Some(&left), "test")
2702 .unwrap()
2703 .files
2704 .contains("left/empty/nested/new.txt")
2705 );
2706 std::fs::write(root.path().join("right/empty/another.txt"), "right").unwrap();
2707 assert!(
2708 snapshot
2709 .current_files_for(&workspace.root, Some(&all), "test")
2710 .unwrap()
2711 .files
2712 .contains("right/empty/another.txt")
2713 );
2714 }
2715
2716 #[test]
2717 fn membership_watches_git_ignored_tracked_parents_and_index_policy() {
2718 let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2719 std::fs::create_dir_all(root.path().join("tracked/nested")).unwrap();
2720 std::fs::write(root.path().join("tracked/.gitignore"), "nested/\n").unwrap();
2721 std::fs::write(root.path().join("tracked/nested/source"), "one").unwrap();
2722 assert!(
2723 paths::git(root.path(), &["init", "-q"])
2724 .unwrap()
2725 .status
2726 .success()
2727 );
2728 assert!(
2729 paths::git(root.path(), &["add", "-f", "tracked/nested/source"])
2730 .unwrap()
2731 .status
2732 .success()
2733 );
2734 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2735 let files = || snapshot.current_files(root.path()).unwrap();
2736 assert!(files().contains("tracked/nested/source"));
2737 std::fs::remove_file(root.path().join("tracked/nested/source")).unwrap();
2738 assert!(!files().contains("tracked/nested/source"));
2739 std::fs::write(root.path().join("tracked/nested/another"), "two").unwrap();
2740 assert!(!files().contains("tracked/nested/another"));
2741 assert!(
2742 paths::git(root.path(), &["add", "-f", "tracked/nested/another"])
2743 .unwrap()
2744 .status
2745 .success()
2746 );
2747 assert!(files().contains("tracked/nested/another"));
2748 std::fs::write(root.path().join("untracked"), "three").unwrap();
2749 assert!(files().contains("untracked"));
2750 std::fs::write(root.path().join(".git/info/exclude"), "untracked\n").unwrap();
2751 assert!(!files().contains("untracked"));
2752 }
2753
2754 #[test]
2755 fn a_new_git_config_include_invalidates_settled_listing_policy() {
2756 let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2757 let policy = tempfile::tempdir().unwrap();
2758 let included = policy.path().join("included.config");
2759 let excludes = policy.path().join("excludes");
2760 std::fs::write(&excludes, "hidden\n").unwrap();
2761 std::fs::write(root.path().join("hidden"), "source").unwrap();
2762 assert!(
2763 paths::git(root.path(), &["init", "-q"])
2764 .unwrap()
2765 .status
2766 .success()
2767 );
2768 assert!(
2769 paths::git(
2770 root.path(),
2771 &["config", "include.path", included.to_str().unwrap()]
2772 )
2773 .unwrap()
2774 .status
2775 .success()
2776 );
2777 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2778 snapshot.current_files(&workspace.root).unwrap();
2779 std::thread::sleep(Duration::from_millis(2100));
2780 let before = snapshot.current_files(&workspace.root).unwrap();
2781 assert!(before.contains("hidden"));
2782 assert!(Arc::ptr_eq(
2783 &before,
2784 &snapshot.current_files(&workspace.root).unwrap()
2785 ));
2786 assert!(
2787 paths::git(
2788 root.path(),
2789 &[
2790 "config",
2791 "--file",
2792 included.to_str().unwrap(),
2793 "core.excludesfile",
2794 excludes.to_str().unwrap(),
2795 ]
2796 )
2797 .unwrap()
2798 .status
2799 .success()
2800 );
2801 assert!(
2802 !snapshot
2803 .current_files(&workspace.root)
2804 .unwrap()
2805 .contains("hidden")
2806 );
2807 }
2808
2809 #[cfg(unix)]
2810 #[test]
2811 fn git_policy_paths_preserve_quoted_origins_and_multiline_values() {
2812 let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2813 let policy = tempfile::tempdir().unwrap();
2814 let included = policy.path().join("included\\é\t\n.config");
2816 let excludes = policy.path().join("excludes\\é\t\n");
2817 let replacement = policy.path().join("replacement\n");
2818 std::fs::write(&excludes, "hidden\n").unwrap();
2819 std::fs::write(&replacement, "visible\n").unwrap();
2820 for file in ["hidden", "visible"] {
2821 std::fs::write(root.path().join(file), "source").unwrap();
2822 }
2823 let git = |args: &[&str]| {
2824 let output = paths::git(root.path(), args).unwrap();
2825 assert!(output.status.success(), "{output:?}");
2826 output
2827 };
2828 git(&["init", "-q"]);
2829 git(&["config", "include.path", included.to_str().unwrap()]);
2830 git(&[
2831 "config",
2832 "--file",
2833 included.to_str().unwrap(),
2834 "core.excludesfile",
2835 excludes.to_str().unwrap(),
2836 ]);
2837 let text = git(&["config", "--show-origin", "--list"]);
2838 let text = String::from_utf8_lossy(&text.stdout);
2839 let quoted_origin = text
2840 .lines()
2841 .find_map(|line| {
2842 line.strip_prefix("file:\"")
2843 .and_then(|_| line.split_once('\t'))
2844 })
2845 .unwrap()
2846 .0
2847 .strip_prefix("file:")
2848 .unwrap();
2849 let policies = git_membership_policies(root.path());
2850 assert!(!policies.contains(&root.path().join(quoted_origin)));
2851 assert!(policies.contains(&included));
2852 assert!(policies.contains(&excludes));
2853 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2854 let initial = snapshot.current_files(&workspace.root).unwrap();
2855 assert!(!initial.contains("hidden"));
2856 assert!(initial.contains("visible"));
2857 std::thread::sleep(Duration::from_millis(2100));
2858 let settled = snapshot.current_files(&workspace.root).unwrap();
2859 assert!(Arc::ptr_eq(
2860 &settled,
2861 &snapshot.current_files(&workspace.root).unwrap()
2862 ));
2863 git(&[
2864 "config",
2865 "--file",
2866 included.to_str().unwrap(),
2867 "core.excludesfile",
2868 replacement.to_str().unwrap(),
2869 ]);
2870 let changed = snapshot.current_files(&workspace.root).unwrap();
2871 assert!(changed.contains("hidden"));
2872 assert!(!changed.contains("visible"));
2873 assert!(git_membership_policies(root.path()).contains(&replacement));
2874 }
2875
2876 #[cfg(unix)]
2877 #[test]
2878 fn symlinked_ignore_policy_never_reuses_stale_membership() {
2879 let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2880 let policy = tempfile::NamedTempFile::new().unwrap();
2881 std::fs::write(policy.path(), "hidden\n").unwrap();
2882 std::os::unix::fs::symlink(policy.path(), root.path().join(".gitignore")).unwrap();
2883 std::fs::write(root.path().join("hidden"), "source").unwrap();
2884 let snapshot = Snapshot::new(&workspace, &graph, &workspace.root.join("cache")).unwrap();
2885 assert!(
2886 !snapshot
2887 .current_files(root.path())
2888 .unwrap()
2889 .contains("hidden")
2890 );
2891 assert!(
2892 !snapshot
2893 .membership
2894 .lock()
2895 .unwrap()
2896 .as_ref()
2897 .unwrap()
2898 .settled
2899 );
2900 std::thread::sleep(Duration::from_millis(2100));
2901 let before = snapshot.current_files(root.path()).unwrap();
2902 assert!(Arc::ptr_eq(
2903 &before,
2904 &snapshot.current_files(root.path()).unwrap()
2905 ));
2906 std::fs::write(policy.path(), "").unwrap();
2907 assert!(
2908 snapshot
2909 .current_files(root.path())
2910 .unwrap()
2911 .contains("hidden")
2912 );
2913 }
2914
2915 #[cfg(unix)]
2916 #[test]
2917 fn recent_change_time_prevents_membership_reuse_despite_old_mtime() {
2918 use std::os::unix::fs::MetadataExt;
2919 let root = tempfile::tempdir().unwrap();
2920 let old = SystemTime::UNIX_EPOCH + Duration::from_secs(1);
2921 File::open(root.path())
2922 .unwrap()
2923 .set_times(std::fs::FileTimes::new().set_modified(old))
2924 .unwrap();
2925 let metadata = std::fs::metadata(root.path()).unwrap();
2926 assert!(metadata.ctime() > 1);
2927 let stamp = Stamp::new(&metadata);
2928 assert!(stamp.settled());
2929 assert!(!stamp.membership_settled());
2930 }
2931
2932 #[test]
2933 fn inspection_keeps_deleted_candidates() {
2934 let (root, workspace, graph) = fixture(Some(json!(["dist"])));
2935 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache"))
2936 .unwrap()
2937 .with_candidates(&["src/deleted".into()]);
2938 let resolved = resolve(
2939 &snapshot,
2940 &workspace,
2941 &graph.tasks["app:post"],
2942 None,
2943 &AtomicBool::new(false),
2944 )
2945 .unwrap();
2946 assert!(resolved.files.contains("src/deleted"));
2947 }
2948
2949 #[test]
2950 fn undeclared_writes_invalidate_dependency_values() {
2951 for outputs in [None, Some(json!(["generated"]))] {
2952 let (root, workspace, graph) = fixture(outputs);
2953 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
2954 let keys = || {
2955 dependency_keys(
2956 &snapshot,
2957 &workspace,
2958 &graph,
2959 &graph.tasks["app:build"],
2960 &BTreeMap::new(),
2961 &AtomicBool::new(false),
2962 )
2963 .unwrap()
2964 };
2965 let before = keys();
2966 std::fs::write(root.path().join("generated/value"), "different").unwrap();
2967 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
2968 assert_ne!(before, keys());
2969 }
2970 }
2971
2972 #[cfg(unix)]
2973 #[test]
2974 fn dependency_symlinks_recheck_targets_outside_output_roots() {
2975 let (root, mut workspace, _) = fixture(Some(json!(["generated"])));
2976 workspace
2977 .projects
2978 .get_mut("app")
2979 .unwrap()
2980 .targets
2981 .get_mut("gen")
2982 .unwrap()
2983 .outputs = Some(vec!["links".into()]);
2984 std::fs::create_dir(root.path().join("links")).unwrap();
2985 std::os::unix::fs::symlink("../generated/value", root.path().join("links/value")).unwrap();
2986 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
2987 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
2988 let keys = || {
2989 dependency_keys(
2990 &snapshot,
2991 &workspace,
2992 &graph,
2993 &graph.tasks["app:build"],
2994 &BTreeMap::new(),
2995 &AtomicBool::new(false),
2996 )
2997 .unwrap()
2998 };
2999 let before = keys();
3000 std::fs::write(root.path().join("generated/value"), "different").unwrap();
3001 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
3002 assert_ne!(before["app:gen:**/*"], keys()["app:gen:**/*"]);
3003 }
3004}