Skip to main content

qk_cache/
hash.rs

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/// Metadata that changes whenever a file's content can have changed: size,
38/// modification time and, on Unix, change time, inode and mode.
39#[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    /// Membership stamps also use change time: restoring a directory's mtime
65    /// must not allow changes inside a coarse timestamp window to go unnoticed.
66    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    /// A file written within the timestamp resolution window could change again
93    /// without changing its stamp, so recent files are always re-read.
94    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
103/// Digests persisted between runs in the worktree's state, keyed by path and
104/// stamp, so a warm run reads only the files that changed. Stamps include the
105/// inode, so they belong to one worktree.
106fn 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
116/// Workspace state shared by every fingerprint in one run. Inspection uses the
117/// initial candidate list; execution fingerprints refresh membership so files
118/// added or removed during the run cannot escape input verification.
119pub 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    /// Each project by its root, to find the project owning a file.
128    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    /// Whether `digests` gained entries worth saving.
137    digests_changed: std::sync::atomic::AtomicBool,
138    /// Directories already checked not to be symlinks.
139    directories: Mutex<HashSet<PathBuf>>,
140    /// Runtime input results by command and environment, computed once per run like Nx.
141    runtime: Mutex<HashMap<RuntimeKey, Arc<Mutex<Option<Value>>>>>,
142    /// The parsed pnpm lockfile, replaced whenever its content changes.
143    lockfile: Mutex<Option<Arc<Installed>>>,
144    /// Each dependency task's output files, listed when a task first asks and
145    /// dropped once a task whose outputs overlap them is done.
146    dependency_outputs: Mutex<HashMap<String, Arc<DependencyOutputs>>>,
147    /// Counts drops, so a listing taken while a task finished is not kept.
148    outputs_written: std::sync::atomic::AtomicU64,
149}
150
151/// Membership depends on directory entries and Git/ignore listing policy, not
152/// on every source file's contents. Empty directories must also be watched.
153#[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/// A candidate listing and the ignore policy captured with it. Keeping both
172/// alive prevents a concurrent refresh from changing dependency-output selection.
173#[derive(Clone)]
174struct Candidates {
175    files: Arc<BTreeSet<String>>,
176    source_ignore: Arc<SourceIgnore>,
177}
178
179impl Membership {
180    /// Inventory source directories and policy stamps before a new listing.
181    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        // Git lists tracked files even beneath ignored directories. Watching
231        // only the ignore walker would miss deletion/rename of those files.
232        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                // Creation of a new ignore file changes its directory stamp.
245                // Existing rules can be edited without changing the directory.
246                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            // Also detect a newly initialized repository or changed worktree pointer.
254            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        // Capture contents before listing, so a later policy change cannot be
264        // mistaken for the rules that produced that listing.
265        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    /// Names and types that can affect source membership in this directory.
311    /// Output files may be skipped, but partially excluded output directories
312    /// must remain visible because they can contain source files or ignore rules.
313    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            // A policy created between policy discovery and name capture must
358            // get its own stamp before its in-place edits can permit reuse.
359            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            // An empty directory created after the inventory walk would not
366            // appear among source files. Never bless its parent signature until
367            // that directory is itself watched by the inventory.
368            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    /// Check source-relevant directory membership and every listing policy.
396    /// Recent directory stamps require fresh entry names; recent ignore files
397    /// require matching contents, while other listing policies must settle.
398    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                // Output publication changes its parent's timestamp too. Check
437                // source-relevant names rather than relisting the whole workspace.
438                // Recent directories are always read, even with an equal stamp:
439                // coarse timestamps can conceal an addition or removal.
440                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    /// Retain a matched listing and policy for use after releasing the lock.
474    fn candidates(&self) -> Candidates {
475        Candidates {
476            files: self.files.clone(),
477            source_ignore: self.source_ignore.clone(),
478        }
479    }
480}
481
482/// Find Git index, exclusion and config files that can change source visibility,
483/// including absent policy files whose later creation must invalidate reuse.
484fn 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    // Config can include other files. Track every origin used by Git, as
506    // well as currently missing standard config and excludes files.
507    if let Ok(output) = paths::git(root, &["config", "--null", "--show-origin", "--list"])
508        && output.status.success()
509    {
510        // NUL mode emits raw origins, without Git's C-style path quoting,
511        // and separates each origin from its key/newline/value record. Values
512        // may themselves contain newlines (including include/excludes paths).
513        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                    // Git omits missing includes from the origin listing, but
531                    // creating one later can change core.excludesfile.
532                    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
564/// Read a regular ignore policy only when the captured metadata remains stable.
565/// A digest read after a listing must never replace the pre-listing digest.
566fn 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
584/// Stamp a directory or listing policy, including a symlink's resolved target.
585fn 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                // Follow the current chain on every check. In-place target
591                // edits or retargeted intermediate links can change policy.
592                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
612/// One dependency task's output files, and the value of each one a pattern
613/// has selected so far.
614struct DependencyOutputs {
615    anchors: Vec<String>,
616    files: Vec<String>,
617    values: Mutex<HashMap<String, Value>>,
618}
619
620/// One revision of `pnpm-lock.yaml`, with digests of what it installs computed
621/// once and shared by every task that asks.
622struct Installed {
623    content: String,
624    /// `None` when the file cannot be read as a pnpm v9 lockfile.
625    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        // Every early task needs these, so loading saved digests and parsing
651        // the lockfile overlap listing files.
652        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        // Keep the patterns, not only the artifacts present at startup: new
675        // outputs and warm scratch files must be excluded on every refresh.
676        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    /// Restrict directory checks to the positive fileset prefixes a task reads.
732    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    /// Reuse membership while source directories and listing policy are unchanged.
755    /// Content changes are checked separately by the file digest cache.
756    #[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    /// Refresh candidates as needed, returning their policy in the same snapshot.
762    fn current_files_for(&self, root: &Path, prefixes: Option<&[PathBuf]>) -> Result<Candidates> {
763        loop {
764            let previous = self.membership.lock().unwrap().clone();
765            // Immutable entries let independent fingerprints validate their
766            // directory stamps concurrently. Only a refresh needs serialization.
767            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                // Another fingerprint refreshed while this one validated.
782                // Recheck that newer inventory before making any replacement.
783                continue;
784            }
785            drop(previous);
786            // Recent listing policies may conceal edits on coarse filesystems.
787            // Re-list sources while they settle, then rebuild the directory inventory
788            // once before enabling reuse (including any newly created empty dirs).
789            // Recent directories are checked by freshly reading their child names.
790            let rebuilt = !unchanged || settled;
791            let mut membership = if !rebuilt {
792                Arc::unwrap_or_clone(cached.take().unwrap())
793            } else {
794                // Capture directories before listing files, then compare afterward.
795                // An addition during listing must not hide behind a newer stamp.
796                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            // An unstable inventory or recent policy never enables the fast path.
825            // Keep candidates so a rebuild also watches tracked, ignored parents.
826            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    /// Saves the digests of files that still exist, for the next run.
839    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    /// `paths::safe_parents`, checking each directory once per run.
871    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(&current) {
881                continue;
882            }
883            match std::fs::symlink_metadata(&current) {
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    /// Adds paths to the candidate files, such as files deleted since a base
923    /// revision.
924    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        // Re-check after reading so a concurrent write is never recorded as settled.
954        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    /// `task`'s output files that are not directories, as listed since the
967    /// last task that could have written them was done.
968    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    /// Re-list artifacts for verification, without reusing earlier content values.
992    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    /// Drops the listings `task` may have changed, once it is done. Without
1013    /// its outputs, every listing goes.
1014    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            // A dangling link is keyed as such, so the key changes once it resolves.
1052            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            // Selecting the link explicitly reads its target, even when source
1075            // discovery ignores that target or another link is nested inside it.
1076            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        // Only the executable bit, which is all git records: other permission
1095        // bits follow the checkout's umask and would keep machines apart.
1096        let mode = crate::store::mode(&metadata) & 0o111 != 0;
1097        Ok(json!({"content":self.digest(path, &absolute, &metadata)?, "mode":mode}))
1098    }
1099}
1100
1101/// Reads and parses a pnpm lockfile, `None` when there is none. One qk cannot
1102/// read is reported, and kept as unreadable so tasks key its whole content.
1103fn 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
1133/// Apply the same source policy to startup and refreshed file membership.
1134/// Remove declared artifacts and cache storage from a refreshed source listing.
1135fn 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
1151/// Files equal to `prefix` or below it, using the sorted order of the set.
1152impl Snapshot {
1153    /// The project whose root most specifically contains `path`, as Nx assigns
1154    /// files to projects.
1155    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
1180/// Compiled root ignore rules used by input discovery and project watching.
1181pub struct SourceIgnore(ignore::gitignore::Gitignore);
1182
1183impl SourceIgnore {
1184    /// Root ignore rules shared by caching and watch, including tracked files.
1185    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    /// Whether a workspace-relative file or its parent is ignored.
1203    pub fn matches(&self, path: &str) -> bool {
1204        // A file whitelist cannot reinclude it beneath an excluded directory.
1205        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
1214/// Source files visible to Nx, with runner state and root ignore rules removed.
1215pub 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    // Tracked files deleted from the working tree are listed as cached; git
1231    // names them in a second listing, which is cheaper than checking each file.
1232    // Skip-worktree entries are listed whether or not they are on disk and never
1233    // as deleted, so those few are checked; `-v` tags them `S`.
1234    let (listed, deleted) = if root.join(".nxignore").is_file() {
1235        // Nx negations can reinclude Git-ignored files, so this policy needs
1236        // the walker regardless of what Git lists. Avoid launching Git here.
1237        (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    /// The project a project fileset selects the files of.
1356    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    /// Dependency named inputs already expanded for `^` inputs.
1381    expanded: BTreeSet<(String, String)>,
1382    external: BTreeSet<String>,
1383    /// `None` resolves which inputs a task has without evaluating env or
1384    /// runtime inputs.
1385    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            // As in Nx, `^name` is `name` of every project the project depends
1423            // on, directly or not.
1424            Value::String(name) if name.starts_with('^') => {
1425                self.dependencies_named(project, &name[1..])?;
1426            }
1427            // The object spellings of named inputs, as Nx reads them.
1428            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                // Resolved before execution too, so inspection can display the
1605                // selection without reading generated files. dependency_keys
1606                // hashes these files after the dependencies finish.
1607                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        // Nx hashes workspace files and each project's files in separate
1633        // scopes, a project's being only the files it owns: those of a project
1634        // nested in its root are that project's.
1635        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    /// Every project `project` depends on, directly or not.
1678    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[&current] {
1683                if edge.target != project && closure.insert(edge.target.clone()) {
1684                    pending.push(edge.target.clone());
1685                }
1686            }
1687        }
1688        closure
1689    }
1690
1691    /// As in Nx, `^name` is `name` of every project the project depends on.
1692    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    /// A JSON input: the fields of one JSON file, rather than all of it.
1702    /// `fields` keeps only the named dotted paths; `excludeFields` removes
1703    /// them.
1704    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    // Dependency outputs may also be declared sources. Downstream and unrelated
1802    // outputs remain artifacts, so producing them cannot invalidate an upstream task.
1803    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    // Always include workspace resolution and configuration. Dotenv files are
1876    // not keyed, as in Nx: they hold per-machine values and credentials, and a
1877    // task's `env` inputs key the variables it declares.
1878    // A pnpm lockfile qk can read is keyed by what the task's projects install instead.
1879    // The root tsconfig, as Nx hashes it into every task. The root package.json
1880    // is not, as in Nx: what the root importer installs counts for every task
1881    // through the lockfile, and the rest of it is the root project's manifest.
1882    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    // The task's own manifests remain inputs even when filesets exclude them,
1911    // its package scripts and dependency declarations with them. A dependency's
1912    // package.json counts by what it decides for dependents, such as where
1913    // their imports of it resolve; its installs count through the lockfile.
1914    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            // A manifest the task's inputs name counts whole, as they say.
1940            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    // However it was selected, the workspace file's resolution keys reach the
1957    // task through the lockfile when qk can read it.
1958    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        // The root importer's packages resolve from every package in the
1964        // workspace, so they count for every task.
1965        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
1984/// What a task's key is computed from, as JSON: kept by the history so a
1985/// changed key can be explained.
1986pub 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    // Warm state configuration never changes a result.
2065    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
2077/// The key for a task's inputs.
2078pub 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
2102/// Use selected dependency artifacts when output inputs are declared; otherwise
2103/// preserve the normal dependency fingerprints. All dependencies must have
2104/// succeeded and be fingerprintable before this selection is applied.
2105pub(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
2124/// Read dependency artifacts again before accepting an execution as unchanged.
2125pub(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                        // Links depend on paths outside the declared output roots.
2207                        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        // Artifacts created after startup must not change the execution key.
2282        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        // This covers both post-execution verification and a later task's first
2288        // fingerprint against a snapshot created before this source existed.
2289        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        // Recent directory metadata deliberately uses the conservative listing
2329        // path. Waiting lets this test exercise the actual reuse path too.
2330        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            // Policy files must be checked even when directory entries do not
2355            // change (editing an existing ignore file in place).
2356            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            // Same-length content can change while coarse timestamps appear
2384            // identical. Recent policies must still compare their contents.
2385            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        // Model creation between the directory walk and child-name capture:
2427        // the new directory is in its parent's names but not in the inventory.
2428        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        // Model a new policy appearing after discovery but before capturing
2446        // its parent's entries; its content would otherwise have no stamp.
2447        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            // Policies retain their conservative timestamp window. Directory
2474            // names are freshly verified throughout the window instead.
2475            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            // Assertions run after synchronization, so a failed membership
2528            // check cannot strand other workers at a barrier.
2529            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        // Deterministically pause two fingerprints after candidate acquisition,
2586        // then let another fingerprint refresh policy twice before they resolve.
2587        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        // Inspection still uses its original candidate list and original policy.
2611        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        // Git's text output C-quotes these origins and octal-escapes UTF-8.
2781        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}