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 settled(&self) -> bool {
67 let modified = SystemTime::UNIX_EPOCH + Duration::from_nanos(self.0[1].max(0) as u64);
68 self.0[1] >= 0
69 && SystemTime::now()
70 .duration_since(modified)
71 .is_ok_and(|age| age > Duration::from_secs(2))
72 }
73}
74
75fn digests_path(root: &Path) -> PathBuf {
79 paths::worktree_state(root).join("digests.json")
80}
81
82#[derive(PartialEq, Eq, Hash)]
83struct RuntimeKey {
84 command: String,
85 environment: BTreeMap<OsString, OsString>,
86}
87
88pub struct Snapshot {
92 pub(crate) files: BTreeSet<String>,
93 extra_candidates: BTreeSet<String>,
94 projects: ProjectGraph,
95 roots: BTreeMap<String, String>,
97 generated_tasks: Vec<(Task, Outputs)>,
98 task_dependencies: BTreeMap<String, BTreeSet<String>>,
99 source_ignore: SourceIgnore,
100 canonical_root: PathBuf,
101 workspace_prefix: Option<PathBuf>,
102 patterns: Mutex<HashMap<(String, bool), Arc<Pattern>>>,
103 digests: Mutex<HashMap<String, (Stamp, String)>>,
104 digests_changed: std::sync::atomic::AtomicBool,
106 directories: Mutex<HashSet<PathBuf>>,
108 runtime: Mutex<HashMap<RuntimeKey, Arc<Mutex<Option<Value>>>>>,
110 lockfile: Mutex<Option<Arc<Installed>>>,
112 dependency_outputs: Mutex<HashMap<String, Arc<DependencyOutputs>>>,
115 outputs_written: std::sync::atomic::AtomicU64,
117}
118
119struct DependencyOutputs {
122 anchors: Vec<String>,
123 files: Vec<String>,
124 values: Mutex<HashMap<String, Value>>,
125}
126
127struct Installed {
130 content: String,
131 lockfile: Option<Lockfile>,
133 digests: Mutex<HashMap<String, String>>,
134}
135
136impl Installed {
137 fn digest(&self, key: &str, fingerprints: impl FnOnce() -> BTreeSet<String>) -> String {
138 if let Some(digest) = self.digests.lock().unwrap().get(key) {
139 return digest.clone();
140 }
141 let mut hasher = blake3::Hasher::new();
142 for fingerprint in fingerprints() {
143 hasher.update(fingerprint.as_bytes());
144 hasher.update(b"\n");
145 }
146 let digest = hasher.finalize().to_hex().to_string();
147 self.digests
148 .lock()
149 .unwrap()
150 .insert(key.to_owned(), digest.clone());
151 digest
152 }
153}
154
155impl Snapshot {
156 pub fn new(workspace: &Workspace, graph: &TaskGraph, cache_path: &Path) -> Result<Self> {
157 let canonical_root = workspace.root.canonicalize()?;
160 let (files, lockfile, (digests, workspace_prefix)) = std::thread::scope(|scope| {
161 let lockfile = scope.spawn(|| parse_lockfile(&workspace.root.join("pnpm-lock.yaml")));
162 let digests = scope.spawn(|| {
163 let digests = load_digests(&workspace.root);
164 let workspace_prefix = paths::git_path(&workspace.root, "--show-toplevel")
165 .and_then(|root| root.canonicalize().ok())
166 .and_then(|root| canonical_root.strip_prefix(root).ok().map(PathBuf::from));
167 (digests, workspace_prefix)
168 });
169 let files = source_files(&workspace.root);
170 (
171 files,
172 lockfile.join().expect("lockfile thread panicked"),
173 digests.join().expect("digest thread panicked"),
174 )
175 });
176 let mut files = files?;
177 if let Ok(cache_relative) = cache_path.strip_prefix(&workspace.root) {
178 files.retain(|path| !Path::new(path).starts_with(cache_relative));
179 }
180 let mut generated = BTreeSet::new();
184 for outputs in graph
185 .tasks
186 .values()
187 .filter_map(|task| Outputs::new(workspace, task).ok())
188 .filter(Outputs::is_explicit)
189 {
190 for anchor in outputs.anchors() {
191 generated.extend(
192 under(&files, anchor)
193 .filter(|path| outputs.matches(path))
194 .cloned(),
195 );
196 }
197 }
198 for task in graph.tasks.values() {
200 let Ok(entries) = crate::warm::config(workspace, task) else {
201 continue;
202 };
203 if let Ok(paths) = Outputs::from_paths(&crate::warm::kept_paths(&entries)) {
204 for anchor in paths.anchors() {
205 generated.extend(
206 under(&files, anchor)
207 .filter(|path| paths.matches(path))
208 .cloned(),
209 );
210 }
211 }
212 }
213 files.retain(|path| !generated.contains(path));
214 Ok(Self {
215 files,
216 extra_candidates: BTreeSet::new(),
217 projects: ProjectGraph::build(workspace)?,
218 roots: workspace
219 .projects
220 .values()
221 .map(|project| (project.root.clone(), project.name.clone()))
222 .collect(),
223 source_ignore: SourceIgnore::new(&workspace.root)?,
224 generated_tasks: graph
225 .tasks
226 .values()
227 .filter_map(|task| {
228 let outputs = Outputs::new(workspace, task).ok()?;
229 outputs.is_explicit().then(|| (task.clone(), outputs))
230 })
231 .collect(),
232 task_dependencies: graph
233 .tasks
234 .iter()
235 .map(|(id, task)| (id.clone(), task.dependencies.clone()))
236 .collect(),
237 canonical_root,
238 workspace_prefix,
239 patterns: Mutex::default(),
240 digests: Mutex::new(digests),
241 digests_changed: Default::default(),
242 directories: Mutex::default(),
243 runtime: Mutex::default(),
244 lockfile: Mutex::new(lockfile),
245 dependency_outputs: Mutex::default(),
246 outputs_written: Default::default(),
247 })
248 }
249
250 pub fn save_digests(&self, root: &Path) {
252 if !self
253 .digests_changed
254 .load(std::sync::atomic::Ordering::Relaxed)
255 {
256 return;
257 }
258 let digests = self.digests.lock().unwrap();
259 let entries: BTreeMap<&String, (&Stamp, &String)> = digests
260 .iter()
261 .filter(|(path, _)| self.files.contains(*path))
262 .map(|(path, (stamp, digest))| (path, (stamp, digest)))
263 .collect();
264 let path = digests_path(root);
265 let saved = (|| -> Result<()> {
266 let directory = path.parent().context("digests have a directory")?;
267 std::fs::create_dir_all(directory)?;
268 let mut file = tempfile::NamedTempFile::new_in(directory)?;
269 {
270 let mut writer = std::io::BufWriter::new(file.as_file_mut());
271 serde_json::to_writer(&mut writer, &json!({"version": 1, "entries": entries}))?;
272 writer.flush()?;
273 }
274 file.persist(&path)?;
275 Ok(())
276 })();
277 if let Err(error) = saved {
278 qk_executor::status!("qk: could not save file digests: {error:#}");
279 }
280 }
281
282 fn safe_parents(&self, root: &Path, path: &str) -> Result<()> {
284 paths::validate_path(path)?;
285 let mut current = root.to_owned();
286 for part in Path::new(path)
287 .parent()
288 .into_iter()
289 .flat_map(Path::components)
290 {
291 current.push(part);
292 if self.directories.lock().unwrap().contains(¤t) {
293 continue;
294 }
295 match std::fs::symlink_metadata(¤t) {
296 Ok(metadata) if metadata.file_type().is_symlink() => {
297 bail!("input parent is a symlink: {}", current.display())
298 }
299 Ok(metadata) if !metadata.is_dir() => {
300 bail!("input parent is not a directory: {}", current.display())
301 }
302 Ok(_) => {
303 self.directories.lock().unwrap().insert(current.clone());
304 }
305 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
306 Err(error) => return Err(error.into()),
307 }
308 }
309 Ok(())
310 }
311
312 fn installed(&self, root: &Path) -> Result<Option<Arc<Installed>>> {
313 let absolute = root.join("pnpm-lock.yaml");
314 let Ok(metadata) = std::fs::symlink_metadata(&absolute) else {
315 return Ok(None);
316 };
317 if !metadata.is_file() {
318 return Ok(None);
319 }
320 let content = self.digest("pnpm-lock.yaml", &absolute, &metadata)?;
321 let mut current = self.lockfile.lock().unwrap();
322 if let Some(installed) = &*current
323 && installed.content == content
324 {
325 return Ok(Some(installed.clone()));
326 }
327 let Some(installed) = parse_lockfile(&absolute) else {
328 return Ok(None);
329 };
330 *current = Some(installed.clone());
331 Ok(Some(installed))
332 }
333
334 pub fn with_candidates(mut self, paths: &[String]) -> Self {
337 self.files.extend(
338 paths
339 .iter()
340 .filter(|path| !self.source_ignore.matches(path))
341 .cloned(),
342 );
343 self.extra_candidates.extend(paths.iter().cloned());
344 self
345 }
346
347 fn pattern(&self, pattern: &str, negated: bool) -> Result<Arc<Pattern>> {
348 let key = (pattern.to_owned(), negated);
349 if let Some(pattern) = self.patterns.lock().unwrap().get(&key) {
350 return Ok(pattern.clone());
351 }
352 let compiled = Arc::new(Pattern::new(pattern, negated)?);
353 self.patterns.lock().unwrap().insert(key, compiled.clone());
354 Ok(compiled)
355 }
356
357 fn digest(&self, path: &str, absolute: &Path, metadata: &std::fs::Metadata) -> Result<String> {
358 let stamp = Stamp::new(metadata);
359 if let Some((known, digest)) = self.digests.lock().unwrap().get(path)
360 && *known == stamp
361 {
362 return Ok(digest.clone());
363 }
364 let digest = digest_file(absolute)?;
365 let after = Stamp::new(&std::fs::symlink_metadata(absolute)?);
367 if after == stamp && stamp.settled() {
368 self.digests
369 .lock()
370 .unwrap()
371 .insert(path.to_owned(), (stamp, digest.clone()));
372 self.digests_changed
373 .store(true, std::sync::atomic::Ordering::Relaxed);
374 }
375 Ok(digest)
376 }
377
378 fn dependency_outputs(
381 &self,
382 workspace: &Workspace,
383 task: &Task,
384 ) -> Result<Arc<DependencyOutputs>> {
385 if let Some(outputs) = self.dependency_outputs.lock().unwrap().get(&task.id) {
386 return Ok(outputs.clone());
387 }
388 let written = self
389 .outputs_written
390 .load(std::sync::atomic::Ordering::SeqCst);
391 let outputs = Arc::new(self.fresh_dependency_outputs(workspace, task)?);
392 let mut listed = self.dependency_outputs.lock().unwrap();
393 if self
394 .outputs_written
395 .load(std::sync::atomic::Ordering::SeqCst)
396 != written
397 {
398 return Ok(outputs);
399 }
400 Ok(listed.entry(task.id.clone()).or_insert(outputs).clone())
401 }
402
403 fn fresh_dependency_outputs(
405 &self,
406 workspace: &Workspace,
407 task: &Task,
408 ) -> Result<DependencyOutputs> {
409 let declared = Outputs::new(workspace, task)?;
410 let mut files = Vec::new();
411 for entry in declared.entries(&workspace.root)? {
412 let (path, metadata) = entry?;
413 if !metadata.is_dir() {
414 files.push(path);
415 }
416 }
417 Ok(DependencyOutputs {
418 anchors: declared.anchors().map(str::to_owned).collect(),
419 files,
420 values: Mutex::default(),
421 })
422 }
423
424 pub(crate) fn outputs_written(&self, workspace: &Workspace, task: &Task) {
427 let anchors: Option<Vec<String>> = Outputs::new(workspace, task)
428 .ok()
429 .filter(Outputs::is_explicit)
430 .map(|outputs| outputs.anchors().map(str::to_owned).collect());
431 let overlap = |a: &str, b: &str| {
432 a == b
433 || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
434 || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
435 };
436 let mut listed = self.dependency_outputs.lock().unwrap();
437 self.outputs_written
438 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
439 listed.retain(|_, outputs| {
440 anchors.as_ref().is_some_and(|written| {
441 !written
442 .iter()
443 .any(|a| outputs.anchors.iter().any(|b| overlap(a, b)))
444 })
445 });
446 }
447
448 fn file_value(&self, root: &Path, path: &str) -> Result<Value> {
449 self.file_value_with_links(root, path, &mut BTreeSet::new())
450 }
451
452 fn file_value_with_links(
453 &self,
454 root: &Path,
455 path: &str,
456 visiting: &mut BTreeSet<PathBuf>,
457 ) -> Result<Value> {
458 self.safe_parents(root, path)?;
459 let absolute = root.join(path);
460 let metadata = std::fs::symlink_metadata(&absolute)?;
461 if metadata.file_type().is_symlink() {
462 let target = std::fs::read_link(&absolute)?;
463 let Ok(resolved) = absolute.canonicalize() else {
465 return Ok(json!({"link":target, "dangling":true}));
466 };
467 let Ok(relative) = resolved.strip_prefix(&self.canonical_root) else {
468 bail!("input symlink resolves outside the workspace: {path}");
469 };
470 if resolved.is_file() {
471 let metadata = std::fs::metadata(&resolved)?;
472 let key = paths::relative(Path::new(""), relative)?;
473 return Ok(
474 json!({"link":target, "content":self.digest(&key, &resolved, &metadata)?,
475 "mode":crate::store::mode(&metadata) & 0o111 != 0}),
476 );
477 }
478 let relative = paths::relative(Path::new(""), relative)?;
479 if relative.is_empty() {
480 bail!("input symlink resolves to the workspace root: {path}");
481 }
482 if !visiting.insert(resolved.clone()) {
483 bail!("directory input symlink cycle: {path}");
484 }
485 let mut contents = BTreeMap::new();
486 for entry in walkdir::WalkDir::new(&resolved).follow_links(false) {
489 let entry = entry?;
490 if entry.file_type().is_dir() {
491 continue;
492 }
493 let file = paths::relative(&self.canonical_root, entry.path())?;
494 let value = self.file_value_with_links(&self.canonical_root, &file, visiting)?;
495 contents.insert(
496 file.strip_prefix(&relative).unwrap_or(&file).to_owned(),
497 value,
498 );
499 }
500 visiting.remove(&resolved);
501 return Ok(json!({"link":target, "directory":contents}));
502 }
503 if !metadata.is_file() {
504 bail!("input is not a regular file: {path}");
505 }
506 let mode = crate::store::mode(&metadata) & 0o111 != 0;
509 Ok(json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}))
510 }
511}
512
513fn parse_lockfile(path: &Path) -> Option<Arc<Installed>> {
516 let text = std::fs::read_to_string(path).ok()?;
517 let lockfile = match Lockfile::parse(&text) {
518 Ok(lockfile) => Some(lockfile),
519 Err(error) => {
520 qk_executor::status!("qk: pnpm-lock.yaml: keying tasks by the whole file ({error:#})");
521 None
522 }
523 };
524 Some(Arc::new(Installed {
525 content: blake3::hash(text.as_bytes()).to_hex().to_string(),
526 lockfile,
527 digests: Mutex::default(),
528 }))
529}
530
531fn load_digests(root: &Path) -> HashMap<String, (Stamp, String)> {
532 #[derive(serde::Deserialize)]
533 struct Saved {
534 version: u32,
535 entries: HashMap<String, (Stamp, String)>,
536 }
537 std::fs::read(digests_path(root))
538 .ok()
539 .and_then(|bytes| serde_json::from_slice::<Saved>(&bytes).ok())
540 .filter(|saved| saved.version == 1)
541 .map(|saved| saved.entries)
542 .unwrap_or_default()
543}
544
545impl Snapshot {
547 fn owner(&self, path: &str) -> Option<&str> {
550 let mut path = path;
551 loop {
552 if let Some(project) = self.roots.get(path) {
553 return Some(project);
554 }
555 match path.rfind('/') {
556 Some(slash) => path = &path[..slash],
557 None if path != "." => path = ".",
558 None => return None,
559 }
560 }
561 }
562}
563
564fn under<'a>(files: &'a BTreeSet<String>, prefix: &'a str) -> impl Iterator<Item = &'a String> {
565 files
566 .range::<str, _>((
567 std::ops::Bound::Included(prefix),
568 std::ops::Bound::Unbounded,
569 ))
570 .take_while(move |path| path.starts_with(prefix))
571 .filter(move |path| path.len() == prefix.len() || path.as_bytes()[prefix.len()] == b'/')
572}
573
574pub struct SourceIgnore(ignore::gitignore::Gitignore);
576
577impl SourceIgnore {
578 pub fn new(root: &Path) -> Result<Self> {
580 let mut builder = ignore::gitignore::GitignoreBuilder::new(root);
581 for name in [".gitignore", ".nxignore"] {
582 let path = root.join(name);
583 if path.is_file()
584 && let Some(error) = builder.add(path)
585 {
586 return Err(error.into());
587 }
588 }
589 Ok(Self(builder.build()?))
590 }
591
592 fn matches_directory(&self, path: &str) -> bool {
593 self.matches(path) || self.0.matched(path, true).is_ignore()
594 }
595
596 pub fn matches(&self, path: &str) -> bool {
598 Path::new(path)
600 .ancestors()
601 .skip(1)
602 .take_while(|parent| !parent.as_os_str().is_empty())
603 .any(|parent| self.0.matched(parent, true).is_ignore())
604 || self.0.matched(path, false).is_ignore()
605 }
606}
607
608pub fn source_files(root: &Path) -> Result<BTreeSet<String>> {
610 let lines =
611 |output: std::io::Result<std::process::Output>| -> Option<Result<BTreeSet<String>>> {
612 let output = output.ok().filter(|output| output.status.success())?;
613 Some(
614 output
615 .stdout
616 .split(|byte| *byte == 0)
617 .filter(|path| !path.is_empty())
618 .map(|path| {
619 String::from_utf8(path.to_vec()).context("input paths must be UTF-8")
620 })
621 .collect(),
622 )
623 };
624 let (listed, deleted) = if root.join(".nxignore").is_file() {
629 (None, None)
632 } else {
633 std::thread::scope(|scope| {
634 let deleted =
635 scope.spawn(|| paths::git(root, &["ls-files", "--deleted", "-z", "--", "."]));
636 let listed = paths::git(
637 root,
638 &[
639 "ls-files",
640 "-v",
641 "--cached",
642 "--others",
643 "--exclude-standard",
644 "-z",
645 "--",
646 ".",
647 ],
648 );
649 (
650 lines(listed),
651 lines(deleted.join().expect("git listing thread panicked")),
652 )
653 })
654 };
655 let candidates = match (listed, deleted) {
656 (Some(listed), Some(deleted)) => {
657 let deleted = deleted?;
658 let mut files = BTreeSet::new();
659 for entry in listed? {
660 let Some((tag, path)) = entry.split_once(' ') else {
661 bail!("unexpected git ls-files entry {entry:?}");
662 };
663 let absent =
664 matches!(tag, "S" | "s") && std::fs::symlink_metadata(root.join(path)).is_err();
665 if !absent && !deleted.contains(path) {
666 files.insert(path.to_owned());
667 }
668 }
669 files
670 }
671 _ => {
672 let mut files = BTreeSet::new();
673 for entry in ignore::WalkBuilder::new(root)
674 .hidden(false)
675 .parents(false)
676 .git_global(false)
677 .require_git(false)
678 .ignore(false)
679 .add_custom_ignore_filename(".nxignore")
680 .follow_links(false)
681 .filter_entry(|entry| {
682 !matches!(
683 entry.file_name().to_str(),
684 Some(".git" | ".qk" | "node_modules")
685 )
686 })
687 .build()
688 {
689 let entry = entry?;
690 if entry.file_type().is_some_and(|kind| !kind.is_dir()) {
691 files.insert(paths::relative(root, entry.path())?);
692 }
693 }
694 files
695 }
696 };
697 let ignore = SourceIgnore::new(root)?;
698 Ok(candidates
699 .into_iter()
700 .filter(|path| {
701 !ignore.matches(path)
702 && !path
703 .split('/')
704 .any(|part| matches!(part, ".git" | ".qk" | "node_modules"))
705 })
706 .collect())
707}
708
709fn json_path<'v>(value: &'v Value, path: &str) -> Option<&'v Value> {
710 path.split('.')
711 .try_fold(value, |value, part| value.get(part))
712}
713
714fn set_json_path(target: &mut Value, path: &str, value: Value) {
715 let mut parts = path.split('.').peekable();
716 let mut current = target;
717 while let Some(part) = parts.next() {
718 let Value::Object(object) = current else {
719 return;
720 };
721 if parts.peek().is_none() {
722 object.insert(part.to_owned(), value);
723 return;
724 }
725 current = object.entry(part).or_insert_with(|| json!({}));
726 }
727}
728
729fn remove_json_path(target: &mut Value, path: &str) {
730 let (parent, last) = match path.rsplit_once('.') {
731 Some((parent, last)) => (Some(parent), last),
732 None => (None, path),
733 };
734 let parent = match parent {
735 Some(parent) => parent
736 .split('.')
737 .try_fold(&mut *target, |value, part| value.get_mut(part)),
738 None => Some(target),
739 };
740 if let Some(Value::Object(object)) = parent {
741 object.remove(last);
742 }
743}
744
745struct FilePattern {
746 matcher: Arc<Pattern>,
747 prefix: String,
748 excluded: bool,
749 owner: Option<String>,
751}
752
753#[derive(Default)]
754struct FileSelection {
755 patterns: Vec<FilePattern>,
756 included: BTreeSet<String>,
757 excluded: BTreeSet<String>,
758}
759
760#[derive(PartialEq, Eq, PartialOrd, Ord)]
761enum FileScope {
762 Workspace,
763 Project(String),
764}
765
766struct Resolver<'a> {
767 workspace: &'a Workspace,
768 snapshot: &'a Snapshot,
769 selected: BTreeSet<String>,
770 scopes: BTreeMap<FileScope, FileSelection>,
771 values: BTreeMap<String, Value>,
772 named_stack: Vec<(String, String)>,
773 expanded: BTreeSet<(String, String)>,
775 external: BTreeSet<String>,
776 prepared: Option<&'a PreparedTask>,
779 cancelled: &'a AtomicBool,
780}
781
782impl Resolver<'_> {
783 fn input(&mut self, project: &str, input: &Value) -> Result<()> {
784 match input {
785 Value::String(name) if name.starts_with('^') => {
788 self.dependencies_named(project, &name[1..])?;
789 }
790 Value::Object(object)
792 if object.contains_key("input") && !object.contains_key("fileset") =>
793 {
794 let name = object["input"]
795 .as_str()
796 .context("input must name a named input")?;
797 let projects = object.get("projects");
798 let dependencies = object.get("dependencies") == Some(&json!(true))
799 || projects == Some(&json!("dependencies"));
800 let listed = projects.filter(|projects| {
801 **projects != json!("self") && **projects != json!("dependencies")
802 });
803 if (dependencies || listed.is_some()) && !self.named_stack.is_empty() {
804 bail!("named inputs can only refer to named inputs of their own project");
805 }
806 if dependencies {
807 self.dependencies_named(project, name)?;
808 } else if let Some(listed) = listed {
809 let patterns: Vec<String> = match listed {
810 Value::String(pattern) => vec![pattern.clone()],
811 other => serde_json::from_value(other.clone())
812 .context("input projects must be a string or an array")?,
813 };
814 for selected in
815 qk_graph::select_projects(&self.workspace.projects, &patterns, &[])?
816 {
817 if self.expanded.insert((selected.clone(), name.to_owned())) {
818 self.named(&selected, name)?;
819 }
820 }
821 } else {
822 self.named(project, name)?;
823 }
824 }
825 Value::Object(object)
826 if object.contains_key("fileset")
827 && object.get("dependencies") == Some(&json!(true)) =>
828 {
829 if !self.named_stack.is_empty() {
830 bail!("named inputs can only refer to named inputs of their own project");
831 }
832 let pattern = object["fileset"]
833 .as_str()
834 .context("fileset input must be a glob")?
835 .to_owned();
836 for dependency in self.dependency_closure(project) {
837 self.fileset(&dependency, &pattern)?;
838 }
839 }
840 Value::Object(object)
841 if object.len() == 1 && object.contains_key("workingDirectory") =>
842 {
843 let mode = object["workingDirectory"].as_str().unwrap_or_default();
844 let directory = std::env::current_dir()?;
845 let value = match mode {
846 "absolute" => directory.to_string_lossy().into_owned(),
847 "relative" => {
848 let root = self.workspace.root.canonicalize()?;
849 let directory = directory.canonicalize()?;
850 paths::relative(&root, &directory)
851 .unwrap_or_else(|_| directory.to_string_lossy().into_owned())
852 }
853 _ => bail!("workingDirectory must be \"relative\" or \"absolute\""),
854 };
855 self.values
856 .insert(format!("workingDirectory:{mode}"), json!(value));
857 }
858 Value::Object(object) if object.contains_key("json") => {
859 self.json(project, object)?;
860 }
861 Value::String(name)
862 if !name.contains("{projectRoot}") && !name.contains("{workspaceRoot}") =>
863 {
864 self.named(project, name)?
865 }
866 Value::Object(object) if object.len() == 1 && object.contains_key("fileset") => {
867 let pattern = object["fileset"]
868 .as_str()
869 .context("fileset input must be a glob")?;
870 self.fileset(project, pattern)?;
871 }
872 Value::String(pattern) => self.fileset(project, pattern)?,
873 Value::Object(object) if object.len() == 1 && object.contains_key("env") => {
874 let name = object["env"]
875 .as_str()
876 .context("env input must name a variable")?;
877 let Some(prepared) = self.prepared else {
878 self.values.insert(format!("env:{name}"), Value::Null);
879 return Ok(());
880 };
881 let value = prepared
882 .env
883 .get(std::ffi::OsStr::new(name))
884 .map(|value| {
885 value
886 .to_str()
887 .map(str::to_owned)
888 .context("declared env input must be UTF-8")
889 })
890 .transpose()?;
891 self.values.insert(format!("env:{name}"), json!(value));
892 }
893 Value::Object(object) if object.len() == 1 && object.contains_key("runtime") => {
894 let command = object["runtime"]
895 .as_str()
896 .context("runtime input must be a command")?;
897 let Some(prepared) = self.prepared else {
898 self.values
899 .insert(format!("runtime:{command}"), Value::Null);
900 return Ok(());
901 };
902 let memo = RuntimeKey {
903 command: command.into(),
904 environment: prepared.runtime_env.clone(),
905 };
906 let slot = self
907 .snapshot
908 .runtime
909 .lock()
910 .unwrap()
911 .entry(memo)
912 .or_default()
913 .clone();
914 let mut saved = slot.lock().unwrap();
915 if let Some(value) = saved.as_ref() {
916 self.values
917 .insert(format!("runtime:{command}"), value.clone());
918 return Ok(());
919 }
920 let _profile = crate::profile::span(command, "runtime");
921 let mut prepared = prepared.clone();
922 prepared.env = prepared.runtime_env.clone();
923 prepared.execution.clear();
924 prepared.node_path = None;
925 prepared.commands = vec![command.into()];
926 prepared.cwd = self.workspace.root.clone();
927 prepared.parallel = false;
928 let log = tempfile::NamedTempFile::new()?;
929 let capture = Capture::new(Some(log.as_file().try_clone()?), Display::Hidden);
930 if execute_captured(&prepared, self.cancelled, Some(&capture))? != Outcome::Success
931 {
932 bail!("runtime input did not succeed");
933 }
934 let mut stdout = blake3::Hasher::new();
935 let mut stderr = blake3::Hasher::new();
936 read_capture(File::open(log.path())?, |error, bytes| {
937 if error {
938 stderr.update(bytes);
939 } else {
940 stdout.update(bytes);
941 }
942 Ok(())
943 })?;
944 let value = json!([
945 stdout.finalize().to_hex().to_string(),
946 stderr.finalize().to_hex().to_string()
947 ]);
948 *saved = Some(value.clone());
949 self.values.insert(format!("runtime:{command}"), value);
950 }
951 Value::Object(object) if object.contains_key("dependentTasksOutputFiles") => {
952 if object
953 .keys()
954 .any(|key| !matches!(key.as_str(), "dependentTasksOutputFiles" | "transitive"))
955 {
956 bail!("unsupported output input field");
957 }
958 let pattern = object["dependentTasksOutputFiles"]
959 .as_str()
960 .context("dependentTasksOutputFiles must be a glob")?;
961 self.snapshot.pattern(pattern, false)?;
962 let transitive = object
963 .get("transitive")
964 .map(|value| value.as_bool().context("transitive must be boolean"))
965 .transpose()?
966 .unwrap_or(false);
967 let value = self
971 .values
972 .entry(format!("dependentTasksOutputFiles:{pattern}"))
973 .or_insert(json!(false));
974 if transitive {
975 *value = json!(true);
976 }
977 }
978 Value::Object(object)
979 if object.len() == 1 && object.contains_key("externalDependencies") =>
980 {
981 let names: Vec<String> =
982 serde_json::from_value(object["externalDependencies"].clone())?;
983 self.external.extend(names);
984 }
985 _ => bail!("unsupported task input declaration"),
986 }
987 Ok(())
988 }
989
990 fn fileset(&mut self, project: &str, pattern: &str) -> Result<()> {
991 let (exclude, pattern) = pattern
992 .strip_prefix('!')
993 .map(|pattern| (true, pattern))
994 .unwrap_or((false, pattern));
995 let (scope, owner) = if pattern.contains("{workspaceRoot}") {
999 (FileScope::Workspace, None)
1000 } else {
1001 (
1002 FileScope::Project(project.to_owned()),
1003 Some(project.to_owned()),
1004 )
1005 };
1006 let pattern = paths::expand(self.workspace, project, pattern)?;
1007 let matcher = self.snapshot.pattern(&pattern, exclude)?;
1008 let prefix = pattern
1009 .split('/')
1010 .take_while(|part| is_literal(part))
1011 .collect::<Vec<_>>()
1012 .join("/");
1013 let candidates: Box<dyn Iterator<Item = &String>> = if prefix.is_empty() {
1014 Box::new(self.snapshot.files.iter())
1015 } else {
1016 Box::new(under(&self.snapshot.files, &prefix))
1017 };
1018 let snapshot = self.snapshot;
1019 let selection = self.scopes.entry(scope).or_default();
1020 let selected = if exclude {
1021 &mut selection.excluded
1022 } else {
1023 &mut selection.included
1024 };
1025 selected.extend(
1026 candidates
1027 .filter(|file| matcher.is_match(file))
1028 .filter(|file| owner.is_none() || snapshot.owner(file) == owner.as_deref())
1029 .cloned(),
1030 );
1031 selection.patterns.push(FilePattern {
1032 matcher,
1033 prefix,
1034 excluded: exclude,
1035 owner,
1036 });
1037 Ok(())
1038 }
1039
1040 fn dependency_closure(&self, project: &str) -> BTreeSet<String> {
1042 let mut closure = BTreeSet::new();
1043 let mut pending = vec![project.to_owned()];
1044 while let Some(current) = pending.pop() {
1045 for edge in &self.snapshot.projects.dependencies[¤t] {
1046 if edge.target != project && closure.insert(edge.target.clone()) {
1047 pending.push(edge.target.clone());
1048 }
1049 }
1050 }
1051 closure
1052 }
1053
1054 fn dependencies_named(&mut self, project: &str, name: &str) -> Result<()> {
1056 for dependency in self.dependency_closure(project) {
1057 if self.expanded.insert((dependency.clone(), name.to_owned())) {
1058 self.named(&dependency, name)?;
1059 }
1060 }
1061 Ok(())
1062 }
1063
1064 fn json(&mut self, project: &str, object: &serde_json::Map<String, Value>) -> Result<()> {
1068 for key in object.keys() {
1069 if !matches!(key.as_str(), "json" | "fields" | "excludeFields") {
1070 bail!("unsupported json input field {key:?}");
1071 }
1072 }
1073 let path = object["json"]
1074 .as_str()
1075 .context("json input must name a file")?;
1076 let path = paths::expand(self.workspace, project, path)?;
1077 paths::validate_path(&path)?;
1078 let list = |name: &str| -> Result<Option<Vec<String>>> {
1079 object
1080 .get(name)
1081 .map(|value| {
1082 serde_json::from_value(value.clone())
1083 .with_context(|| format!("json input {name} must be an array of strings"))
1084 })
1085 .transpose()
1086 };
1087 let fields = list("fields")?;
1088 let exclude = list("excludeFields")?.unwrap_or_default();
1089 let value = match std::fs::read_to_string(self.workspace.root.join(&path)) {
1090 Ok(text) => {
1091 let mut value: Value =
1092 jsonc_parser::parse_to_serde_value(&text, &Default::default())
1093 .with_context(|| format!("cannot parse json input {path}"))?;
1094 if let Some(fields) = &fields {
1095 let mut kept = json!({});
1096 for field in fields {
1097 if let Some(found) = json_path(&value, field) {
1098 set_json_path(&mut kept, field, found.clone());
1099 }
1100 }
1101 value = kept;
1102 }
1103 for field in &exclude {
1104 remove_json_path(&mut value, field);
1105 }
1106 value
1107 }
1108 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Value::Null,
1109 Err(error) => return Err(error.into()),
1110 };
1111 self.values.insert(format!("json:{path}"), value);
1112 Ok(())
1113 }
1114
1115 fn named(&mut self, project: &str, name: &str) -> Result<()> {
1116 let identity = (project.to_owned(), name.to_owned());
1117 if self.named_stack.contains(&identity) {
1118 bail!("named input cycle at {project}:{name}");
1119 }
1120 self.named_stack.push(identity);
1121 let inputs = self.workspace.projects[project]
1122 .named_inputs
1123 .get(name)
1124 .cloned()
1125 .or_else(|| (name == "default").then(|| vec![json!("{projectRoot}/**/*")]))
1126 .with_context(|| format!("unknown named input {name:?}"))?;
1127 for input in &inputs {
1128 self.input(project, input)?;
1129 }
1130 self.named_stack.pop();
1131 Ok(())
1132 }
1133}
1134
1135pub fn resolve(
1136 snapshot: &Snapshot,
1137 workspace: &Workspace,
1138 task: &Task,
1139 prepared: Option<&PreparedTask>,
1140 cancelled: &AtomicBool,
1141) -> Result<Resolved> {
1142 let mut resolver = Resolver {
1143 workspace,
1144 snapshot,
1145 selected: BTreeSet::new(),
1146 scopes: BTreeMap::new(),
1147 values: BTreeMap::new(),
1148 named_stack: Vec::new(),
1149 expanded: BTreeSet::new(),
1150 external: BTreeSet::new(),
1151 prepared,
1152 cancelled,
1153 };
1154 let default = vec![json!("default"), json!("^default")];
1155 for input in task.definition.inputs.as_ref().unwrap_or(&default) {
1156 resolver.input(&task.project, input)?;
1157 }
1158 let mut upstream = BTreeSet::new();
1161 let mut pending: Vec<_> = task.dependencies.iter().cloned().collect();
1162 while let Some(id) = pending.pop() {
1163 if upstream.insert(id.clone())
1164 && let Some(dependencies) = snapshot.task_dependencies.get(&id)
1165 {
1166 pending.extend(dependencies.iter().cloned());
1167 }
1168 }
1169 let own_outputs = Outputs::new(workspace, task)
1170 .ok()
1171 .filter(Outputs::is_explicit);
1172 for (producer, declared) in &snapshot.generated_tasks {
1173 if producer.id == task.id || !upstream.contains(&producer.id) {
1174 continue;
1175 }
1176 let overlaps = |a: &str, b: &str| {
1177 a.is_empty()
1178 || a == b
1179 || a.strip_prefix(b).is_some_and(|rest| rest.starts_with('/'))
1180 || b.strip_prefix(a).is_some_and(|rest| rest.starts_with('/'))
1181 };
1182 let relevant = resolver
1183 .scopes
1184 .values()
1185 .flat_map(|scope| &scope.patterns)
1186 .filter(|pattern| !pattern.excluded)
1187 .any(|pattern| {
1188 declared.anchors().any(|anchor| {
1189 overlaps(&pattern.prefix, anchor)
1190 && (!snapshot.source_ignore.matches_directory(anchor)
1191 || !workspace.root.join(anchor).is_dir())
1192 })
1193 });
1194 if !relevant {
1195 continue;
1196 }
1197 let outputs = snapshot.dependency_outputs(workspace, producer)?;
1198 for path in &outputs.files {
1199 if snapshot.source_ignore.matches(path)
1200 || own_outputs.as_ref().is_some_and(|own| own.matches(path))
1201 {
1202 continue;
1203 }
1204 for selection in resolver.scopes.values_mut() {
1205 for pattern in &selection.patterns {
1206 if pattern.matcher.is_match(path)
1207 && (pattern.owner.is_none()
1208 || snapshot.owner(path) == pattern.owner.as_deref())
1209 {
1210 if pattern.excluded {
1211 selection.excluded.insert(path.clone());
1212 } else {
1213 selection.included.insert(path.clone());
1214 }
1215 }
1216 }
1217 }
1218 }
1219 }
1220 for selection in resolver.scopes.values() {
1221 resolver.selected.extend(
1222 selection
1223 .included
1224 .difference(&selection.excluded)
1225 .filter(|path| own_outputs.as_ref().is_none_or(|own| !own.matches(path)))
1226 .cloned(),
1227 );
1228 }
1229 let readable = snapshot
1230 .installed(&workspace.root)?
1231 .is_some_and(|installed| installed.lockfile.is_some());
1232 let tsconfig = ["tsconfig.base.json", "tsconfig.json"]
1240 .into_iter()
1241 .find(|name| {
1242 workspace.root.join(name).is_file() || snapshot.extra_candidates.contains(*name)
1243 });
1244 let mut mandatory = BTreeSet::new();
1245 for path in tsconfig.into_iter().chain([
1246 "nx.json",
1247 ".gitignore",
1248 ".nxignore",
1249 qk_config::LOCAL_WORKSPACE,
1250 "pnpm-workspace.yaml",
1251 "pnpm-lock.yaml",
1252 "package-lock.json",
1253 "yarn.lock",
1254 "bun.lock",
1255 ]) {
1256 if (workspace.root.join(path).is_file() || snapshot.extra_candidates.contains(path))
1257 && !(path == "pnpm-lock.yaml" && readable)
1258 {
1259 resolver.selected.insert(path.into());
1260 mandatory.insert(path.into());
1261 }
1262 }
1263 if let Some(extended) = &workspace.extended {
1264 resolver.selected.insert(extended.clone());
1265 mandatory.insert(extended.clone());
1266 }
1267 let mut packages = BTreeSet::from([task.project.clone()]);
1272 let mut pending = packages.clone();
1273 while let Some(project) = pending.pop_first() {
1274 for edge in &snapshot.projects.dependencies[&project] {
1275 if packages.insert(edge.target.clone()) {
1276 pending.insert(edge.target.clone());
1277 }
1278 }
1279 }
1280 for project in &packages {
1281 let own = *project == task.project;
1282 for name in ["project.json", qk_config::LOCAL_OVERRIDES, "package.json"] {
1283 if !own && name != "package.json" {
1284 continue;
1285 }
1286 let path = Path::new(&workspace.projects[project].root).join(name);
1287 let path = path.strip_prefix(".").unwrap_or(&path).to_owned();
1288 if !workspace.root.join(&path).is_file() {
1289 continue;
1290 }
1291 let path = path
1292 .to_str()
1293 .context("project path must be UTF-8")?
1294 .replace('\\', "/");
1295 mandatory.insert(path.clone());
1296 if own || resolver.selected.contains(&path) {
1298 resolver.selected.insert(path);
1299 continue;
1300 }
1301 let text = std::fs::read_to_string(workspace.root.join(&path))
1302 .with_context(|| format!("cannot read {path}"))?;
1303 match manifest_for_dependents(&text, readable) {
1304 Some(value) => {
1305 resolver.values.insert(format!("manifest:{path}"), value);
1306 }
1307 None => {
1308 resolver.selected.insert(path);
1309 }
1310 }
1311 }
1312 }
1313 let workspace_file = readable && resolver.selected.remove("pnpm-workspace.yaml");
1316 if workspace_file {
1317 mandatory.remove("pnpm-workspace.yaml");
1318 }
1319 let lockfile = readable.then(|| {
1320 let importers = std::iter::once(".".to_owned())
1323 .chain(
1324 packages
1325 .iter()
1326 .map(|project| workspace.projects[project].root.clone()),
1327 )
1328 .collect();
1329 (importers, resolver.external.clone())
1330 });
1331 Ok(Resolved {
1332 files: resolver.selected,
1333 mandatory,
1334 values: resolver.values,
1335 lockfile,
1336 workspace_file,
1337 external: resolver.external,
1338 })
1339}
1340
1341pub fn inputs(
1344 snapshot: &Snapshot,
1345 workspace: &Workspace,
1346 task: &Task,
1347 prepared: &PreparedTask,
1348 dependencies: &BTreeMap<String, String>,
1349 cancelled: &AtomicBool,
1350) -> Result<Value> {
1351 let Resolved {
1352 files,
1353 mut values,
1354 lockfile,
1355 workspace_file,
1356 ..
1357 } = resolve(snapshot, workspace, task, Some(prepared), cancelled)?;
1358 let installed = snapshot.installed(&workspace.root)?;
1359 if let (Some((importers, external)), Some(installed)) = (lockfile, &installed)
1360 && let Some(lockfile) = &installed.lockfile
1361 {
1362 let importers: BTreeMap<_, _> = importers
1363 .iter()
1364 .filter(|importer| lockfile.has_importer(importer))
1365 .map(|importer| {
1366 let digest = installed.digest(&format!("importer:{importer}"), || {
1367 lockfile.installed(importer).unwrap_or_default()
1368 });
1369 (importer.clone(), digest)
1370 })
1371 .collect();
1372 let external: BTreeMap<_, _> = external
1373 .iter()
1374 .map(|name| {
1375 let digest =
1376 installed.digest(&format!("package:{name}"), || lockfile.package(name));
1377 (name.clone(), digest)
1378 })
1379 .collect();
1380 values.insert(
1381 "lockfile".into(),
1382 json!({
1383 "global": installed.digest("global", || BTreeSet::from([lockfile.global().to_string()])),
1384 "importers": importers,
1385 "external": external,
1386 }),
1387 );
1388 }
1389 let mut files = files
1390 .iter()
1391 .map(|path| {
1392 let value = snapshot
1393 .file_value(&workspace.root, path)
1394 .with_context(|| format!("cannot read input {path}"))?;
1395 Ok((path.clone(), value))
1396 })
1397 .collect::<Result<BTreeMap<_, _>>>()?;
1398 if workspace_file {
1399 match workspace_without_resolution(&workspace.root) {
1400 Some(value) => {
1401 values.insert("pnpm-workspace".into(), value);
1402 }
1403 None => {
1404 files.insert(
1405 "pnpm-workspace.yaml".into(),
1406 snapshot.file_value(&workspace.root, "pnpm-workspace.yaml")?,
1407 );
1408 }
1409 }
1410 }
1411 let mut definition = task.definition.clone();
1413 definition.extra.remove("qk:warm");
1414 definition.extra.remove("qk:threads");
1415 let hash = json!({
1416 "schema":"qk-local-v2", "qk":env!("CARGO_PKG_VERSION"),
1417 "platform":[std::env::consts::OS, std::env::consts::ARCH], "workspace":snapshot.workspace_prefix,
1418 "id":task.id, "args":task.args, "definition":definition, "packageManager":workspace.package_manager,
1419 "files":files, "values":values, "dependencies":dependencies,
1420 });
1421 Ok(hash)
1422}
1423
1424pub fn key(inputs: &Value) -> Result<String> {
1426 Ok(blake3::hash(&serde_json::to_vec(inputs)?)
1427 .to_hex()
1428 .to_string())
1429}
1430
1431pub fn fingerprint(
1432 snapshot: &Snapshot,
1433 workspace: &Workspace,
1434 task: &Task,
1435 prepared: &PreparedTask,
1436 dependencies: &BTreeMap<String, String>,
1437 cancelled: &AtomicBool,
1438) -> Result<String> {
1439 key(&inputs(
1440 snapshot,
1441 workspace,
1442 task,
1443 prepared,
1444 dependencies,
1445 cancelled,
1446 )?)
1447}
1448
1449pub(crate) fn dependency_keys(
1453 snapshot: &Snapshot,
1454 workspace: &Workspace,
1455 graph: &TaskGraph,
1456 task: &Task,
1457 dependencies: &BTreeMap<String, String>,
1458 cancelled: &AtomicBool,
1459) -> Result<BTreeMap<String, String>> {
1460 dependency_keys_impl(
1461 snapshot,
1462 workspace,
1463 graph,
1464 task,
1465 dependencies,
1466 cancelled,
1467 false,
1468 )
1469}
1470
1471pub(crate) fn recheck_dependency_keys(
1473 snapshot: &Snapshot,
1474 workspace: &Workspace,
1475 graph: &TaskGraph,
1476 task: &Task,
1477 dependencies: &BTreeMap<String, String>,
1478 cancelled: &AtomicBool,
1479) -> Result<BTreeMap<String, String>> {
1480 dependency_keys_impl(
1481 snapshot,
1482 workspace,
1483 graph,
1484 task,
1485 dependencies,
1486 cancelled,
1487 true,
1488 )
1489}
1490
1491fn dependency_keys_impl(
1492 snapshot: &Snapshot,
1493 workspace: &Workspace,
1494 graph: &TaskGraph,
1495 task: &Task,
1496 dependencies: &BTreeMap<String, String>,
1497 cancelled: &AtomicBool,
1498 fresh: bool,
1499) -> Result<BTreeMap<String, String>> {
1500 let resolved = resolve(snapshot, workspace, task, None, cancelled)?;
1501 let selections: Vec<_> = resolved.dependency_output_inputs().collect();
1502 if selections.is_empty() {
1503 if fresh {
1504 return dependencies
1505 .iter()
1506 .map(|(id, key)| {
1507 let outputs = Outputs::new(workspace, &graph.tasks[id])?;
1508 let key = if outputs.declared() {
1509 crate::output_fingerprint(&workspace.root, &outputs, "")?
1510 } else {
1511 key.clone()
1512 };
1513 Ok((id.clone(), key))
1514 })
1515 .collect();
1516 }
1517 return Ok(dependencies.clone());
1518 }
1519 let mut keys = BTreeMap::new();
1520 let mut fresh_outputs = HashMap::new();
1521 for (pattern, transitive) in selections {
1522 let matcher = snapshot.pattern(pattern, false)?;
1523 let mut selected = task.dependencies.clone();
1524 if transitive {
1525 let mut pending = selected.clone();
1526 while let Some(id) = pending.pop_first() {
1527 for dependency in &graph.tasks[&id].dependencies {
1528 if selected.insert(dependency.clone()) {
1529 pending.insert(dependency.clone());
1530 }
1531 }
1532 }
1533 }
1534 for id in selected {
1535 let outputs = if fresh {
1536 if !fresh_outputs.contains_key(&id) {
1537 fresh_outputs.insert(
1538 id.clone(),
1539 Arc::new(snapshot.fresh_dependency_outputs(workspace, &graph.tasks[&id])?),
1540 );
1541 }
1542 fresh_outputs[&id].clone()
1543 } else {
1544 snapshot.dependency_outputs(workspace, &graph.tasks[&id])?
1545 };
1546 let mut files = BTreeMap::new();
1547 for path in outputs.files.iter().filter(|path| matcher.is_match(path)) {
1548 let known = outputs.values.lock().unwrap().get(path).cloned();
1549 let value = match known {
1550 Some(value) => value,
1551 None => {
1552 let value = snapshot.file_value(&workspace.root, path)?;
1553 if value.get("link").is_none() {
1555 outputs
1556 .values
1557 .lock()
1558 .unwrap()
1559 .insert(path.clone(), value.clone());
1560 }
1561 value
1562 }
1563 };
1564 files.insert(path.clone(), value);
1565 }
1566 keys.insert(format!("{id}:{pattern}"), key(&json!(files))?);
1567 }
1568 }
1569 Ok(keys)
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574 use super::*;
1575 use qk_taskgraph::Request;
1576
1577 fn fixture(outputs: Option<Value>) -> (tempfile::TempDir, Workspace, TaskGraph) {
1578 let root = tempfile::tempdir().unwrap();
1579 std::fs::write(root.path().join("nx.json"), "{}").unwrap();
1580 let mut post = json!({"command": "true"});
1581 if let Some(outputs) = outputs {
1582 post["outputs"] = outputs;
1583 }
1584 std::fs::write(root.path().join("project.json"), json!({"name":"app", "targets": {
1585 "gen": {"command":"true", "outputs":["generated"]},
1586 "post": post,
1587 "build": {"command":"true", "dependsOn":["gen", "post"], "inputs":[{"dependentTasksOutputFiles":"**/*"}]}
1588 }}).to_string()).unwrap();
1589 std::fs::create_dir(root.path().join("generated")).unwrap();
1590 std::fs::write(root.path().join("generated/value"), "one").unwrap();
1591 let workspace = Workspace::load(root.path()).unwrap();
1592 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1593 (root, workspace, graph)
1594 }
1595
1596 #[test]
1597 fn undeclared_writes_invalidate_dependency_values() {
1598 for outputs in [None, Some(json!(["generated"]))] {
1599 let (root, workspace, graph) = fixture(outputs);
1600 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1601 let keys = || {
1602 dependency_keys(
1603 &snapshot,
1604 &workspace,
1605 &graph,
1606 &graph.tasks["app:build"],
1607 &BTreeMap::new(),
1608 &AtomicBool::new(false),
1609 )
1610 .unwrap()
1611 };
1612 let before = keys();
1613 std::fs::write(root.path().join("generated/value"), "different").unwrap();
1614 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1615 assert_ne!(before, keys());
1616 }
1617 }
1618
1619 #[cfg(unix)]
1620 #[test]
1621 fn dependency_symlinks_recheck_targets_outside_output_roots() {
1622 let (root, mut workspace, _) = fixture(Some(json!(["generated"])));
1623 workspace
1624 .projects
1625 .get_mut("app")
1626 .unwrap()
1627 .targets
1628 .get_mut("gen")
1629 .unwrap()
1630 .outputs = Some(vec!["links".into()]);
1631 std::fs::create_dir(root.path().join("links")).unwrap();
1632 std::os::unix::fs::symlink("../generated/value", root.path().join("links/value")).unwrap();
1633 let graph = TaskGraph::build(&workspace, &[Request::parse("app:build").unwrap()]).unwrap();
1634 let snapshot = Snapshot::new(&workspace, &graph, &root.path().join("cache")).unwrap();
1635 let keys = || {
1636 dependency_keys(
1637 &snapshot,
1638 &workspace,
1639 &graph,
1640 &graph.tasks["app:build"],
1641 &BTreeMap::new(),
1642 &AtomicBool::new(false),
1643 )
1644 .unwrap()
1645 };
1646 let before = keys();
1647 std::fs::write(root.path().join("generated/value"), "different").unwrap();
1648 snapshot.outputs_written(&workspace, &graph.tasks["app:post"]);
1649 assert_ne!(before["app:gen:**/*"], keys()["app:gen:**/*"]);
1650 }
1651}