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