1mod evict;
4pub mod expectations;
5mod glob;
6mod hash;
7mod ignored;
8mod inputs;
9mod paths;
10pub mod profile;
11mod record;
12pub mod recorded;
13mod remote;
14mod store;
15pub mod ultracache;
16pub mod warm;
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::PathBuf;
20use std::sync::OnceLock;
21use std::sync::atomic::{AtomicBool, Ordering};
22
23use anyhow::Result;
24use qk_config::Workspace;
25use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
26use qk_taskgraph::{Task, TaskGraph};
27use serde_json::Value;
28
29pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
30pub use glob::Pattern;
31pub use hash::{
32 DependencyGlob, Selects, SourceIgnore, dependency_globs, keyed_definition, source_files,
33 task_configuration,
34};
35pub use inputs::{
36 InputChange, InputChanges, JsonFields, ROOT_TSCONFIG_FILES, Resolution, Resolved, TsConfig,
37 manifest_for_dependents, resolve_each, resolve_tasks, selectively_hash_tsconfig,
38 without_resolution,
39};
40pub use paths::expand as expand_project_path;
42pub use paths::git as workspace_git;
43pub use paths::{
44 Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
45 worktree_state,
46};
47
48fn remote_settings(workspace: &Workspace) -> (Option<&Value>, Option<&Value>) {
53 let extra = &workspace.config.extra;
54 match extra.get("qk:storeServer") {
55 Some(server) => (None, Some(server)),
56 None => (extra.get("s3"), extra.get("storeServer")),
57 }
58}
59
60pub struct Cache {
62 pub root: PathBuf,
63 snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
64 remote: Option<std::sync::Arc<remote::Remote>>,
65 reuse: bool,
67 kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
69 saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
71 cleanup: std::sync::Mutex<Option<store::Cleanup>>,
72 workspace_outputs: OnceLock<recorded::WorkspaceOutputs>,
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78#[serde(rename_all = "kebab-case")]
79pub enum CacheStatus {
80 LocalHit,
81 RemoteHit,
82 Miss,
83 Uncached,
85}
86
87pub struct Execution {
89 pub log: qk_executor::RecordedLog,
90 pub inputs_unchanged: bool,
91}
92
93pub struct TaskResult {
94 pub outcome: Outcome,
95 pub cache: CacheStatus,
96 pub key: Option<(String, Value)>,
98 pub warm: Option<warm::WarmReport>,
100 pub execution: Option<Execution>,
101 pub recorded: Option<Recorded>,
103 pub shadow: Option<Shadow>,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
110#[serde(rename_all = "camelCase", tag = "kind")]
111pub enum Shadow {
112 WouldHit { key: String },
114 WouldMiss,
116 Unrecorded,
118}
119
120enum Shadowing {
124 Settled(Shadow),
127 Fetching {
130 keys: Vec<String>,
131 fetch: std::thread::JoinHandle<Result<Option<Vec<u8>>>>,
132 },
133}
134
135fn recording_key(
138 weak: &str,
139 set: &recorded::PathSet,
140 snapshot: &hash::Snapshot,
141 workspace: &Workspace,
142 unlisted: &recorded::Unlisted,
143 task: &Task,
144) -> Option<String> {
145 recorded::strong_key(weak, set, snapshot, &workspace.root, unlisted)
146 .inspect_err(|error| {
147 qk_executor::status!("qk: {}: ignoring a path set ({error:#})", task.id);
148 })
149 .ok()
150}
151
152enum Found {
154 Local(String),
156 Remote(String),
158 Nothing,
159}
160
161#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
163#[serde(rename_all = "camelCase", tag = "kind")]
164pub enum Recorded {
165 Hit { key: String },
167 Saved { key: String },
169 Missed,
171 Unrecorded,
174 Declared { key: String },
177 Failed { reason: String },
179}
180
181impl TaskResult {
182 pub fn uncached(outcome: Outcome) -> Self {
183 Self {
184 outcome,
185 cache: CacheStatus::Uncached,
186 key: None,
187 warm: None,
188 execution: None,
189 recorded: None,
190 shadow: None,
191 }
192 }
193}
194
195impl Cache {
196 pub fn new(root: PathBuf) -> Self {
197 Self {
198 root,
199 snapshot: OnceLock::new(),
200 remote: None,
201 reuse: true,
202 kept: Default::default(),
203 saves: Default::default(),
204 cleanup: Default::default(),
205 workspace_outputs: OnceLock::new(),
206 }
207 }
208
209 pub fn for_observation(workspace: &Workspace) -> Self {
211 Self {
212 reuse: false,
213 ..Self::new(paths::cache_location(workspace))
214 }
215 }
216
217 pub fn for_workspace(
223 workspace: &Workspace,
224 environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
225 ) -> Self {
226 let root = paths::cache_location(workspace);
227 let (s3, server) = remote_settings(workspace);
228 let remote = match remote::configure(s3, server, environment) {
229 Ok(remote) => remote.map(std::sync::Arc::new),
230 Err(error) => {
231 qk_executor::status!("qk: remote cache disabled: {error:#}");
232 None
233 }
234 };
235 if let Some(remote) = &remote {
236 remote.read_index(&root);
237 }
238 Self {
239 remote,
240 ..Self::new(root)
241 }
242 }
243
244 pub fn finish(&self, workspace: &Workspace) {
247 if !self.reuse {
248 return;
249 }
250 if let Some(Ok(snapshot)) = self.snapshot.get() {
251 snapshot.save_digests(&workspace.root);
252 }
253 for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
255 let _ = save.join();
256 }
257 if let Some(remote) = &self.remote {
258 let failures = remote.finish();
259 for failure in &failures {
260 qk_executor::status!("qk: remote cache upload failed for {failure}");
261 }
262 }
263 let _profile = profile::span("cache", "restore_cleanup_wait");
264 self.cleanup.lock().unwrap().take();
265 }
266
267 fn snapshot(
268 &self,
269 workspace: &Workspace,
270 graph: &TaskGraph,
271 ) -> std::result::Result<&hash::Snapshot, String> {
272 self.snapshot
273 .get_or_init(|| {
274 hash::Snapshot::new(workspace, graph, &self.root)
275 .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
276 })
277 .as_ref()
278 .map_err(Clone::clone)
279 }
280
281 pub fn run(
282 &self,
283 workspace: &Workspace,
284 graph: &TaskGraph,
285 task: &Task,
286 prepared: &PreparedTask,
287 cancelled: &AtomicBool,
288 ) -> Result<TaskResult> {
289 let result = self.run_task(workspace, graph, task, prepared, cancelled);
290 if let Some(Ok(snapshot)) = self.snapshot.get() {
291 snapshot.outputs_written(workspace, task);
292 }
293 result
294 }
295
296 fn run_task(
297 &self,
298 workspace: &Workspace,
299 graph: &TaskGraph,
300 task: &Task,
301 prepared: &PreparedTask,
302 cancelled: &AtomicBool,
303 ) -> Result<TaskResult> {
304 let cacheable = task.definition.cache == Some(true);
305 let _kept = self
307 .reuse
308 .then(|| warm::keep_across(workspace, graph, task));
309 let entries = if self.reuse {
310 warm::config(workspace, task).unwrap_or_else(|error| {
311 qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
312 Vec::new()
313 })
314 } else {
315 Vec::new()
316 };
317 let entry_label = |warm: &warm::Warm| match warm.group() {
319 Some(group) => format!(" (group {group})"),
320 None => String::new(),
321 };
322 let mut running = prepared.clone();
325 for warm in &entries {
326 for (name, value) in &warm.env {
327 running.execution.insert(name.into(), value.into());
328 }
329 }
330 let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
332 let (mut restored, mut present) = (Vec::new(), Vec::new());
333 for warm in &entries {
334 match self
335 .initialize()
336 .and_then(|()| self.restore_warm(workspace, task, warm, running))
337 {
338 Ok((entry, groups)) => {
339 if !entry.groups.is_empty() {
340 restored.push(entry);
341 }
342 present.extend(groups);
343 }
344 Err(error) => qk_executor::status!(
345 "qk: {}: warm state{} not restored ({error:#})",
346 task.id,
347 entry_label(warm)
348 ),
349 }
350 }
351 (restored, present)
352 };
353 let after = |outcome: Outcome,
354 (restored, present): (Vec<warm::Restored>, Vec<String>),
355 running: &PreparedTask|
356 -> Option<warm::WarmReport> {
357 if entries.is_empty() {
358 return None;
359 }
360 let mut save_ms = None;
361 let mut background = false;
362 for warm in entries.iter().filter(|_| outcome == Outcome::Success) {
363 let started = std::time::Instant::now();
364 let saved = if warm.background {
365 self.save_warm_in_background(workspace, task, warm, running)
366 } else {
367 self.save_warm(workspace, task, warm, running)
368 };
369 match saved {
370 Ok(()) if warm.background => background = true,
371 Ok(()) => {
372 *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
373 }
374 Err(error) => qk_executor::status!(
375 "qk: {}: warm state{} not saved ({error:#})",
376 task.id,
377 entry_label(warm)
378 ),
379 }
380 }
381 Some(warm::WarmReport {
382 restored,
383 present,
384 save_ms,
385 background,
386 })
387 };
388 let bypass = |reason: String| {
390 if cacheable {
391 qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
392 }
393 let _profile = profile::span(&task.id, "execute");
394 execute(prepared, cancelled).map(TaskResult::uncached)
395 };
396 let snapshot = match self.snapshot(workspace, graph) {
397 Ok(snapshot) => snapshot,
398 Err(reason) => return bypass(reason),
399 };
400 let dependency_profile = profile::span(&task.id, "dependency_inputs");
401 let dependencies = match hash::dependency_keys(snapshot, workspace, graph, task, cancelled)
402 {
403 Ok(dependencies) => dependencies,
404 Err(error) => return bypass(format!("{error:#}")),
405 };
406 drop(dependency_profile);
407 let input_profile = profile::span(&task.id, "inputs");
408 let (key, inputs) = match hash::inputs(
409 snapshot,
410 workspace,
411 task,
412 prepared,
413 &dependencies,
414 cancelled,
415 )
416 .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
417 {
418 Ok(key) => key,
419 Err(error) => return bypass(format!("{error:#}")),
420 };
421 drop(input_profile);
422 let keyed = Some((key.clone(), inputs));
423 running
425 .execution
426 .insert("NX_TASK_HASH".into(), key.clone().into());
427 let running = &running;
428 let outputs = match paths::Outputs::new(workspace, task) {
429 Ok(outputs) => outputs,
430 Err(error) => return bypass(format!("{error:#}")),
431 };
432 if !cacheable {
433 let restored = before(running);
434 let executing = profile::span(&task.id, "execute");
435 let outcome = execute(running, cancelled)?;
436 drop(executing);
437 let warm_report = after(outcome, restored, running);
438 return Ok(TaskResult {
439 outcome,
440 cache: CacheStatus::Uncached,
441 key: keyed,
442 warm: warm_report,
443 execution: None,
444 recorded: None,
445 shadow: None,
446 });
447 }
448 if !self.reuse {
449 let capture = Capture::new(None, prepared.display.clone()).retain_log();
450 let executing = profile::span(&task.id, "execute");
451 let outcome = execute_captured(running, cancelled, Some(&capture))?;
452 drop(executing);
453 let inputs_unchanged =
454 self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
455 return Ok(TaskResult {
456 outcome,
457 cache: CacheStatus::Uncached,
458 key: keyed,
459 warm: None,
460 execution: capture.take_log().map(|log| Execution {
461 log,
462 inputs_unchanged,
463 }),
464 recorded: None,
465 shadow: None,
466 });
467 }
468 if let Err(error) = self.initialize() {
469 return bypass(format!("cache unavailable: {error}"));
470 }
471 let (_lock, waited) = match self.lock(&key, cancelled) {
472 Ok(Some(lock)) => lock,
473 Ok(None) => {
474 return Ok(TaskResult::uncached(Outcome::Cancelled));
475 }
476 Err(error) => return bypass(format!("cache lock unavailable: {error}")),
477 };
478 if waited
480 && hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
481 .and_then(|dependencies| {
482 hash::fingerprint(
483 snapshot,
484 workspace,
485 task,
486 prepared,
487 &dependencies,
488 cancelled,
489 )
490 })
491 .ok()
492 .as_ref()
493 != Some(&key)
494 {
495 return bypass("inputs changed while waiting for another run".into());
496 }
497 let settings = ultracache::Settings::of(task).unwrap_or_else(|error| {
500 qk_executor::status!("qk: {}: qk:ultracache ignored ({error:#})", task.id);
501 None
502 });
503 let warm_outputs = paths::Outputs::from_paths(&warm::kept_paths(&entries)).ok();
504 let own: Vec<&paths::Outputs> = std::iter::once(&outputs)
505 .chain(warm_outputs.as_ref())
506 .collect();
507 let others = settings.as_ref().map(|_| recorded::Others {
511 outputs: self
512 .workspace_outputs
513 .get_or_init(|| workspace_outputs(workspace)),
514 upstream: upstream(graph, task),
515 });
516 let ignore: Vec<Pattern> = settings
517 .as_ref()
518 .map(|settings| {
519 settings
520 .ignore_reads
521 .iter()
522 .filter_map(|glob| Pattern::new(glob, false).ok())
523 .collect()
524 })
525 .unwrap_or_default();
526 let unlisted = recorded::Unlisted {
527 own: own.clone(),
528 others,
529 ignore: &ignore,
530 };
531 let inputs = &keyed.as_ref().expect("a cacheable task is keyed").1;
532 let mut recordings = settings.and_then(|settings| {
533 let weak = hash::weak_key(
534 snapshot,
535 workspace,
536 graph,
537 task,
538 prepared,
539 inputs,
540 &settings.ignore_reads,
541 cancelled,
542 );
543 match weak {
544 Ok(weak) => Some((recorded::load(&self.root, &weak), weak, settings)),
545 Err(error) => {
546 qk_executor::status!(
547 "qk: {}: not keyed by its recordings ({error:#})",
548 task.id
549 );
550 None
551 }
552 }
553 });
554 let mut shadowing = None;
555 if let Some((sets, weak, settings)) = &mut recordings {
556 if settings.mode == ultracache::Mode::Record {
557 let _lookup = profile::span(&task.id, "recorded_lookup");
558 shadowing =
559 Some(self.start_shadow(workspace, task, snapshot, &unlisted, sets, weak));
560 } else {
561 let recorded_profile = profile::span(&task.id, "recorded_restore");
562 let found = self.by_recordings(
563 workspace, task, prepared, snapshot, &outputs, &unlisted, sets, weak, &key,
564 cancelled,
565 );
566 drop(recorded_profile);
567 if let found @ (Found::Local(_) | Found::Remote(_)) = found {
568 let (strong, remote) = match found {
569 Found::Local(strong) => {
570 if let Some(remote) = &self.remote {
571 remote.held(&strong);
572 }
573 (strong, None)
574 }
575 Found::Remote(strong) => (strong, Some(())),
576 Found::Nothing => unreachable!(),
577 };
578 recorded::touch(&self.root, weak);
579 qk_executor::report::event(qk_executor::report::Event::Cache {
580 id: task.id.clone(),
581 cached: if remote.is_some() {
582 qk_executor::report::Cached::RemoteHit
583 } else {
584 qk_executor::report::Cached::Hit
585 },
586 });
587 return Ok(TaskResult {
588 outcome: Outcome::Success,
589 cache: if remote.is_some() {
590 CacheStatus::RemoteHit
591 } else {
592 CacheStatus::LocalHit
593 },
594 key: keyed,
595 warm: None,
596 execution: None,
597 recorded: Some(Recorded::Hit { key: strong }),
598 shadow: None,
599 });
600 }
601 }
602 }
603 let mut settle = |recordings: &mut Option<(Vec<recorded::PathSet>, String, _)>| {
605 let shadowing = shadowing.take()?;
606 let (sets, weak, _) = recordings.as_mut()?;
607 let _wait = profile::span(&task.id, "recorded_wait");
608 Some(self.settle_shadow(shadowing, workspace, task, snapshot, &unlisted, sets, weak))
609 };
610 let declared = recordings.as_ref().is_none_or(|(sets, _, settings)| {
615 settings.mode == ultracache::Mode::Record
616 || sets.is_empty()
617 || hash::coverage(snapshot, workspace, graph, task, prepared, cancelled).is_ok_and(
618 |coverage| {
619 let outputs = self
620 .workspace_outputs
621 .get_or_init(|| workspace_outputs(workspace));
622 let output = |path: &str| outputs.matches(path);
623 sets.iter()
624 .all(|set| coverage.covers(snapshot, &workspace.root, set, &output))
625 },
626 )
627 });
628 let unrecorded = recordings
630 .as_ref()
631 .filter(|(_, _, settings)| settings.mode == ultracache::Mode::On)
632 .map(|(sets, _, _)| {
633 if sets.is_empty() {
634 Recorded::Unrecorded
635 } else {
636 Recorded::Declared { key: key.clone() }
637 }
638 });
639 let prefetched = declared
642 && self.remote.as_ref().is_some_and(|remote| {
643 remote.resolved(&key);
644 remote.prefetched(&key)
645 });
646 let local_profile = profile::span(&task.id, "local_restore");
647 let restored = if declared {
648 self.restore(
649 &workspace.root,
650 &task.id,
651 &task.dependencies,
652 &key,
653 &outputs,
654 &prepared.display,
655 if prefetched {
656 qk_executor::Shown::RemoteCache
657 } else {
658 qk_executor::Shown::LocalCache
659 },
660 )
661 } else {
662 Ok(false)
663 };
664 match restored {
665 Ok(true) => {
666 let claimed = match &self.remote {
667 Some(remote) if prefetched && remote.claim(&key) => true,
668 Some(remote) => {
669 remote.held(&key);
670 false
671 }
672 None => false,
673 };
674 qk_executor::report::event(qk_executor::report::Event::Cache {
675 id: task.id.clone(),
676 cached: if claimed {
677 qk_executor::report::Cached::RemoteHit
678 } else {
679 qk_executor::report::Cached::Hit
680 },
681 });
682 return Ok(TaskResult {
683 outcome: Outcome::Success,
684 cache: if claimed {
685 CacheStatus::RemoteHit
686 } else {
687 CacheStatus::LocalHit
688 },
689 key: keyed,
690 warm: None,
691 execution: None,
692 recorded: unrecorded,
693 shadow: settle(&mut recordings),
694 });
695 }
696 Ok(false) => {}
697 Err(error) => {
698 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
699 }
700 }
701 drop(local_profile);
702 if let Some(remote) = self.remote.as_ref().filter(|_| declared) {
703 let fetch_profile = profile::span(&task.id, "remote_fetch");
704 let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
705 drop(fetch_profile);
706 let fetched = fetched.and_then(|found| {
707 if !found {
708 return Ok(false);
709 }
710 self.restore(
711 &workspace.root,
712 &task.id,
713 &task.dependencies,
714 &key,
715 &outputs,
716 &prepared.display,
717 qk_executor::Shown::RemoteCache,
718 )
719 });
720 match fetched {
721 Ok(true) => {
722 qk_executor::report::event(qk_executor::report::Event::Cache {
723 id: task.id.clone(),
724 cached: qk_executor::report::Cached::RemoteHit,
725 });
726 return Ok(TaskResult {
727 outcome: Outcome::Success,
728 cache: CacheStatus::RemoteHit,
729 key: keyed,
730 warm: None,
731 execution: None,
732 recorded: unrecorded,
733 shadow: settle(&mut recordings),
734 });
735 }
736 Ok(false) => {}
737 Err(error) => {
738 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
739 }
740 }
741 }
742 let shadow = settle(&mut recordings);
744 qk_executor::report::event(qk_executor::report::Event::Cache {
745 id: task.id.clone(),
746 cached: qk_executor::report::Cached::Miss,
747 });
748 let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
749 Ok(log) => log,
750 Err(error) => return bypass(format!("cache log unavailable: {error}")),
751 };
752 let capture =
753 Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
754 let restored = before(running);
755 let listed = match (&recordings, &prepared.recording) {
756 (Some(_), Some(_)) => {
757 let project_root = workspace
758 .projects
759 .get(&task.project)
760 .map_or("", |project| project.root.as_str());
761 recorded::Before::take(
762 &workspace.root,
763 unlisted.directories(project_root),
764 &unlisted,
765 )
766 }
767 _ => recorded::Before::default(),
768 };
769 let started = recorded::now(&self.root.join("tmp"));
770 let executing = profile::span(&task.id, "execute");
771 let outcome = execute_captured(running, cancelled, Some(&capture))?;
772 drop(executing);
773 let warm_report = after(outcome, restored, running);
774 let inputs_unchanged =
775 self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
776 let execution = capture.take_log().map(|log| Execution {
777 log,
778 inputs_unchanged,
779 });
780 let mut recorded = unrecorded.map(|unrecorded| match &recordings {
781 Some((sets, _, _)) if !sets.is_empty() => Recorded::Missed,
782 _ => unrecorded,
783 });
784 let path_set = match (&recordings, &prepared.recording) {
785 (Some((_, _, settings)), Some(recording)) => Some(
786 self.path_set(workspace, recording, prepared, settings, &own)
787 .and_then(|set| {
788 if set.is_empty() {
790 return Err("its trace named no path in the workspace".to_owned());
791 }
792 match set
793 .changed_since(&workspace.root, started, &unlisted, &listed)
794 .map(|changed| changed.to_string())
795 {
796 Some(reason) => Err(reason),
797 None => Ok(set),
798 }
799 }),
800 ),
801 _ => None,
802 };
803 if let Some(Err(reason)) = &path_set {
804 qk_executor::status!("qk: {}: not recorded ({reason})", task.id);
805 recorded = Some(Recorded::Failed {
806 reason: reason.clone(),
807 });
808 }
809 if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
810 let publishing = profile::span(&task.id, "publish");
811 let published = self.publish(&workspace.root, &key, &outputs, log.path());
812 drop(publishing);
813 match published {
814 Ok(()) => {
815 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
816 if let Some(remote) = &self.remote {
817 remote.upload(&self.root, &key);
818 }
819 }
820 Err(error) => {
821 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
822 }
823 }
824 if let (Some((_, weak, _)), Some(Ok(set))) = (&recordings, path_set) {
826 let saved = recorded::strong_key(weak, &set, snapshot, &workspace.root, &unlisted)
827 .and_then(|strong| {
828 self.publish(&workspace.root, &strong, &outputs, log.path())?;
829 recorded::save(&self.root, weak, set)?;
830 if let Some(remote) = &self.remote {
833 remote.upload(&self.root, &strong);
834 if let Some(record) = recorded::record(&self.root, weak) {
835 remote.upload_path_sets(&self.root, weak, record);
836 }
837 }
838 Ok(strong)
839 });
840 recorded = Some(match saved {
841 Ok(key) => Recorded::Saved { key },
842 Err(error) => {
843 qk_executor::status!("qk: {}: recording not saved ({error:#})", task.id);
844 Recorded::Failed {
845 reason: format!("{error:#}"),
846 }
847 }
848 });
849 }
850 }
851 Ok(TaskResult {
852 outcome,
853 cache: CacheStatus::Miss,
854 key: keyed,
855 warm: warm_report,
856 execution,
857 recorded,
858 shadow,
859 })
860 }
861
862 #[allow(clippy::too_many_arguments)]
867 fn by_recordings(
868 &self,
869 workspace: &Workspace,
870 task: &Task,
871 prepared: &PreparedTask,
872 snapshot: &hash::Snapshot,
873 outputs: &paths::Outputs,
874 unlisted: &recorded::Unlisted,
875 sets: &mut Vec<recorded::PathSet>,
876 weak: &str,
877 declared: &str,
878 cancelled: &AtomicBool,
879 ) -> Found {
880 let strong =
881 |set: &recorded::PathSet| recording_key(weak, set, snapshot, workspace, unlisted, task);
882 let restored = |strong: &str, shown| {
883 self.restore(
884 &workspace.root,
885 &task.id,
886 &task.dependencies,
887 strong,
888 outputs,
889 &prepared.display,
890 shown,
891 )
892 .unwrap_or_else(|error| {
893 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id);
894 false
895 })
896 };
897 let local = |strong: &str| restored(strong, qk_executor::Shown::LocalCache);
898 let mut keys = Vec::new();
899 for set in sets.iter() {
900 if let Some(strong) = strong(set) {
901 if local(&strong) {
902 return Found::Local(strong);
903 }
904 keys.push(strong);
905 }
906 }
907 let Some(remote) = &self.remote else {
908 return Found::Nothing;
909 };
910 if sets.is_empty()
914 && self
915 .root
916 .join("entries")
917 .join(format!("{declared}.json"))
918 .is_file()
919 {
920 return Found::Nothing;
921 }
922 let fetched = remote
924 .fetch_path_sets(&self.root, weak, &task.id)
925 .and_then(|record| match record {
926 Some(record) => recorded::merge(&self.root, weak, &record),
927 None => Ok(Vec::new()),
928 });
929 match fetched {
930 Ok(added) => {
931 for set in added {
932 if let Some(strong) = strong(&set) {
933 if local(&strong) {
934 sets.push(set);
935 return Found::Local(strong);
936 }
937 keys.push(strong);
938 }
939 sets.push(set);
940 }
941 }
942 Err(error) => {
943 qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
944 }
945 }
946 for strong in keys {
947 let found = remote
948 .fetch(&self.root, &strong, &task.id, cancelled)
949 .unwrap_or_else(|error| {
950 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id);
951 false
952 })
953 && restored(&strong, qk_executor::Shown::RemoteCache);
954 if found {
955 return Found::Remote(strong);
956 }
957 }
958 Found::Nothing
959 }
960
961 fn start_shadow(
966 &self,
967 workspace: &Workspace,
968 task: &Task,
969 snapshot: &hash::Snapshot,
970 unlisted: &recorded::Unlisted,
971 sets: &[recorded::PathSet],
972 weak: &str,
973 ) -> Shadowing {
974 let mut keys = Vec::new();
975 for set in sets {
976 if let Some(strong) = recording_key(weak, set, snapshot, workspace, unlisted, task) {
977 if self.holds_entry(&strong) {
978 return Shadowing::Settled(Shadow::WouldHit { key: strong });
979 }
980 keys.push(strong);
981 }
982 }
983 let Some(remote) = &self.remote else {
984 return Shadowing::Settled(if sets.is_empty() {
985 Shadow::Unrecorded
986 } else {
987 Shadow::WouldMiss
988 });
989 };
990 let (remote, root, weak, id) = (
991 std::sync::Arc::clone(remote),
992 self.root.clone(),
993 weak.to_owned(),
994 task.id.clone(),
995 );
996 Shadowing::Fetching {
997 keys,
998 fetch: std::thread::spawn(move || remote.fetch_path_sets(&root, &weak, &id)),
999 }
1000 }
1001
1002 #[allow(clippy::too_many_arguments)]
1006 fn settle_shadow(
1007 &self,
1008 shadowing: Shadowing,
1009 workspace: &Workspace,
1010 task: &Task,
1011 snapshot: &hash::Snapshot,
1012 unlisted: &recorded::Unlisted,
1013 sets: &mut Vec<recorded::PathSet>,
1014 weak: &str,
1015 ) -> Shadow {
1016 let (mut keys, fetch) = match shadowing {
1017 Shadowing::Settled(shadow) => return shadow,
1018 Shadowing::Fetching { keys, fetch } => (keys, fetch),
1019 };
1020 let fetched = fetch
1021 .join()
1022 .unwrap_or_else(|_| Err(anyhow::anyhow!("fetching them panicked")))
1023 .and_then(|record| match record {
1024 Some(record) => recorded::merge(&self.root, weak, &record),
1025 None => Ok(Vec::new()),
1026 });
1027 match fetched {
1028 Ok(added) => {
1029 for set in added {
1030 if let Some(strong) =
1031 recording_key(weak, &set, snapshot, workspace, unlisted, task)
1032 {
1033 if self.holds_entry(&strong) {
1034 sets.push(set);
1035 return Shadow::WouldHit { key: strong };
1036 }
1037 keys.push(strong);
1038 }
1039 sets.push(set);
1040 }
1041 }
1042 Err(error) => {
1043 qk_executor::status!("qk: {}: remote path sets unavailable ({error:#})", task.id)
1044 }
1045 }
1046 let remote = self
1047 .remote
1048 .as_ref()
1049 .expect("only a remote store is fetched from");
1050 keys.into_iter()
1051 .find(|strong| remote.holds(strong).unwrap_or(false))
1052 .map_or(
1053 if sets.is_empty() {
1054 Shadow::Unrecorded
1055 } else {
1056 Shadow::WouldMiss
1057 },
1058 |key| Shadow::WouldHit { key },
1059 )
1060 }
1061
1062 fn holds_entry(&self, key: &str) -> bool {
1064 self.root
1065 .join("entries")
1066 .join(format!("{key}.json"))
1067 .is_file()
1068 }
1069
1070 fn path_set(
1072 &self,
1073 workspace: &Workspace,
1074 recording: &qk_executor::Recording,
1075 prepared: &PreparedTask,
1076 settings: &ultracache::Settings,
1077 own: &[&paths::Outputs],
1078 ) -> std::result::Result<recorded::PathSet, String> {
1079 let failed = |error: anyhow::Error| format!("{error:#}");
1080 let canonical = workspace
1081 .root
1082 .canonicalize()
1083 .map_err(|e| failed(e.into()))?;
1084 let keep = |path: &std::path::Path| {
1087 [&workspace.root, &canonical].iter().any(|root| {
1088 path.strip_prefix(root).is_ok_and(|relative| {
1089 !relative.components().any(|component| {
1090 matches!(
1091 component.as_os_str().to_str(),
1092 Some("node_modules" | ".git")
1093 )
1094 })
1095 })
1096 })
1097 };
1098 let limits = qk_input_analysis::strace::Limits {
1099 records: 1_000_000,
1100 bytes: 256 * 1024 * 1024,
1101 accesses: 500_000,
1102 keep: Some(&keep),
1103 };
1104 recording.settle().map_err(failed)?;
1105 let (accesses, diagnostics) =
1106 qk_input_analysis::strace::collect_within(&recording.directory, &prepared.cwd, &limits)
1107 .map_err(failed)?;
1108 if let Some(diagnostic) = diagnostics.first() {
1110 return Err(diagnostic.clone());
1111 }
1112 let ignore = settings
1113 .ignore_reads
1114 .iter()
1115 .map(|glob| Pattern::new(glob, false))
1116 .collect::<Result<Vec<_>>>()
1117 .map_err(failed)?;
1118 let state = [self.root.clone(), worktree_state(&workspace.root)]
1120 .iter()
1121 .filter_map(|path| {
1122 [&workspace.root, &canonical]
1123 .iter()
1124 .find_map(|root| path.strip_prefix(root).ok())
1125 .and_then(|relative| relative.to_str())
1126 .map(|relative| relative.replace('\\', "/"))
1127 })
1128 .collect();
1129 let exclusions = recorded::Exclusions {
1130 roots: [&workspace.root, &canonical],
1131 outputs: own,
1132 ignore,
1133 state,
1134 };
1135 Ok(recorded::PathSet::from_accesses(
1136 &accesses,
1137 &exclusions,
1138 &canonical,
1139 ))
1140 }
1141
1142 #[allow(clippy::too_many_arguments)]
1144 fn inputs_match(
1145 &self,
1146 snapshot: &hash::Snapshot,
1147 workspace: &Workspace,
1148 graph: &TaskGraph,
1149 task: &Task,
1150 prepared: &PreparedTask,
1151 before: &str,
1152 cancelled: &AtomicBool,
1153 ) -> bool {
1154 if cancelled.load(Ordering::SeqCst) {
1155 return false;
1156 }
1157 let _profile = profile::span(&task.id, "inputs_recheck");
1158 let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
1159 .and_then(|dependencies| {
1160 hash::fingerprint(
1161 snapshot,
1162 workspace,
1163 task,
1164 prepared,
1165 &dependencies,
1166 cancelled,
1167 )
1168 });
1169 if after.as_deref().ok() != Some(before) {
1170 if task.definition.cache == Some(true) {
1171 if self.reuse {
1172 qk_executor::status!(
1173 "qk: {}: not caching because inputs changed during execution",
1174 task.id
1175 );
1176 } else {
1177 qk_executor::status!(
1178 "qk: {}: excluding execution from flaky results because inputs changed during execution",
1179 task.id
1180 );
1181 }
1182 }
1183 return false;
1184 }
1185 true
1186 }
1187}
1188
1189pub type Keyed = std::result::Result<(String, Value), String>;
1191
1192pub fn keys(
1196 workspace: &Workspace,
1197 graph: &TaskGraph,
1198 prepared: &BTreeMap<String, PreparedTask>,
1199 cancelled: &AtomicBool,
1200) -> Result<BTreeMap<String, Keyed>> {
1201 let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
1202 Ok(graph
1203 .tasks
1204 .values()
1205 .map(|task| {
1206 let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
1207 (task.id.clone(), keyed)
1208 })
1209 .collect())
1210}
1211
1212fn key_now(
1213 snapshot: &hash::Snapshot,
1214 workspace: &Workspace,
1215 graph: &TaskGraph,
1216 task: &Task,
1217 prepared: &BTreeMap<String, PreparedTask>,
1218 cancelled: &AtomicBool,
1219) -> Keyed {
1220 let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
1221 let dependencies =
1222 hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
1223 let inputs = hash::inputs(
1224 snapshot,
1225 workspace,
1226 task,
1227 &prepared[&task.id],
1228 &dependencies,
1229 cancelled,
1230 )
1231 .map_err(failed)?;
1232 Ok((hash::key(&inputs).map_err(failed)?, inputs))
1233}
1234
1235#[doc(hidden)]
1237pub fn run_upload_worker() -> anyhow::Result<()> {
1238 remote::background::run()
1239}
1240
1241fn workspace_outputs(workspace: &Workspace) -> recorded::WorkspaceOutputs {
1245 let mut outputs = Vec::new();
1246 for (name, project) in &workspace.projects {
1247 for (target, definition) in &project.targets {
1248 let configured = definition
1249 .configurations
1250 .iter()
1251 .map(|(configuration, options)| {
1252 let mut definition = definition.clone();
1253 definition.options.extend(options.clone());
1254 (Some(configuration.clone()), definition)
1255 });
1256 for (configuration, definition) in
1257 std::iter::once((None, definition.clone())).chain(configured)
1258 {
1259 let task = Task {
1260 id: format!("{name}:{target}"),
1261 project: name.clone(),
1262 target: target.clone(),
1263 configuration: configuration.clone(),
1264 args: Vec::new(),
1265 definition,
1266 dependencies: Default::default(),
1267 edges: Default::default(),
1268 };
1269 if let Some(declared) = paths::Outputs::new(workspace, &task)
1270 .ok()
1271 .filter(paths::Outputs::declared)
1272 {
1273 outputs.push(((name.clone(), target.clone(), configuration), declared));
1274 }
1275 }
1276 }
1277 }
1278 let roots = workspace
1279 .projects
1280 .iter()
1281 .map(|(name, project)| (name.clone(), project.root.clone()))
1282 .collect();
1283 recorded::WorkspaceOutputs::new(outputs, &roots)
1284}
1285
1286fn upstream(graph: &TaskGraph, task: &Task) -> BTreeSet<recorded::TargetOf> {
1290 let of = |task: &Task| {
1291 (
1292 task.project.clone(),
1293 task.target.clone(),
1294 task.configuration.clone(),
1295 )
1296 };
1297 let mut targets = BTreeSet::new();
1298 let mut seen = BTreeSet::new();
1299 let mut pending: Vec<&String> = task.dependencies.iter().collect();
1300 while let Some(id) = pending.pop() {
1301 if !seen.insert(id) {
1302 continue;
1303 }
1304 if let Some(dependency) = graph.tasks.get(id) {
1305 targets.insert(of(dependency));
1306 pending.extend(&dependency.dependencies);
1307 }
1308 }
1309 targets
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314 use super::*;
1315
1316 #[test]
1317 fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
1318 let workspace = tempfile::tempdir().unwrap();
1319 let root = workspace.path();
1320 let cache = Cache::new(root.join("cache"));
1321 cache.initialize().unwrap();
1322 let mut sources = Vec::new();
1323 for index in 0..130 {
1324 let source = root.join(format!("source-{index}"));
1325 std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
1326 sources.push(source);
1327 }
1328 let digest = cache.put_blob(&sources[0]).unwrap();
1329 std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
1330 std::fs::remove_file(&sources[31]).unwrap();
1331 let results = cache.put_blobs(&sources);
1332 assert_eq!(results.len(), sources.len());
1333 for (index, result) in results.into_iter().enumerate() {
1334 if index == 31 {
1335 assert_eq!(
1336 result
1337 .unwrap_err()
1338 .downcast_ref::<std::io::Error>()
1339 .unwrap()
1340 .kind(),
1341 std::io::ErrorKind::NotFound
1342 );
1343 continue;
1344 }
1345 let digest = result.unwrap();
1346 let held = cache.root.join("blobs").join(&digest);
1347 assert_eq!(
1348 std::fs::read(&held).unwrap(),
1349 format!("content-{}", index % 65).as_bytes()
1350 );
1351 assert_eq!(hash::digest_file(&held).unwrap(), digest);
1352 }
1353 }
1354
1355 #[cfg(unix)]
1357 #[test]
1358 fn a_blob_the_store_holds_is_not_written_again() {
1359 use std::os::unix::fs::MetadataExt;
1360 let workspace = tempfile::tempdir().unwrap();
1361 let root = workspace.path();
1362 let cache = Cache::new(root.join(".qk/cache"));
1363 cache.initialize().unwrap();
1364 std::fs::write(root.join("first"), "same").unwrap();
1365 std::fs::write(root.join("second"), "same").unwrap();
1366 std::fs::write(root.join("other"), "different").unwrap();
1367 let first = cache.put_blob(&root.join("first")).unwrap();
1368 let blob = cache.root.join("blobs").join(&first);
1369 let stored = std::fs::metadata(&blob).unwrap().ino();
1370 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1371 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
1372 std::fs::write(&blob, "corrupt").unwrap();
1374 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1375 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1376 {
1378 use std::os::unix::fs::PermissionsExt;
1379 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1380 }
1381 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
1382 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
1383 let other = cache.put_blob(&root.join("other")).unwrap();
1384 assert_ne!(other, first);
1385 assert_eq!(
1386 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
1387 b"different"
1388 );
1389 }
1390
1391 #[test]
1394 fn complete_directory_restores_preserve_outputs() {
1395 let workspace = tempfile::tempdir().unwrap();
1396 let root = workspace.path();
1397 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
1398 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
1399 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
1400 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
1401 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
1402 #[cfg(unix)]
1403 {
1404 use std::os::unix::fs::PermissionsExt;
1405 let script = root.join("dist/run.sh");
1406 std::fs::write(&script, "#!/bin/sh\n").unwrap();
1407 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
1408 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
1409 std::fs::set_permissions(
1410 root.join("dist/nested"),
1411 std::fs::Permissions::from_mode(0o500),
1412 )
1413 .unwrap();
1414 std::fs::set_permissions(
1415 root.join("bundle/readonly"),
1416 std::fs::Permissions::from_mode(0o444),
1417 )
1418 .unwrap();
1419 }
1420 let log = root.join("log");
1421 std::fs::write(&log, "").unwrap();
1422
1423 let cache = Cache::new(root.join(".qk/cache"));
1424 cache.initialize().unwrap();
1425 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
1426 let key = "0".repeat(64);
1427 cache.publish(root, &key, &outputs, &log).unwrap();
1428
1429 #[cfg(unix)]
1430 {
1431 use std::os::unix::fs::PermissionsExt;
1432 std::fs::set_permissions(
1435 root.join("dist/nested"),
1436 std::fs::Permissions::from_mode(0o755),
1437 )
1438 .unwrap();
1439 }
1440 std::fs::remove_dir_all(root.join("dist")).unwrap();
1441 std::fs::remove_dir_all(root.join("bundle")).unwrap();
1442 let before = std::time::SystemTime::now();
1443 let restored = cache
1444 .restore(
1445 root,
1446 "app:build",
1447 &Default::default(),
1448 &key,
1449 &outputs,
1450 &qk_executor::Display::Stream,
1451 qk_executor::Shown::LocalCache,
1452 )
1453 .unwrap();
1454 assert!(restored);
1455 assert!(root.join("dist/nested/empty").is_dir());
1456 assert!(root.join("bundle/empty").is_dir());
1457 assert_eq!(
1458 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
1459 "readonly"
1460 );
1461 #[cfg(unix)]
1462 {
1463 assert_eq!(
1464 std::fs::read_link(root.join("dist/link")).unwrap(),
1465 std::path::Path::new("nested/a.txt")
1466 );
1467 assert_eq!(
1468 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
1469 0o755
1470 );
1471 assert_eq!(
1472 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
1473 0o500
1474 );
1475 assert_eq!(
1476 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
1477 0o444
1478 );
1479 }
1480 let path = root.join("dist/nested/a.txt");
1481 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
1482 assert!(modified >= before);
1483 let kept = cache
1484 .restore(
1485 root,
1486 "app:build",
1487 &Default::default(),
1488 &key,
1489 &outputs,
1490 &qk_executor::Display::Hidden,
1491 qk_executor::Shown::LocalCache,
1492 )
1493 .unwrap();
1494 assert!(kept);
1495 assert_eq!(
1496 std::fs::metadata(&path).unwrap().modified().unwrap(),
1497 modified
1498 );
1499 #[cfg(unix)]
1500 {
1501 use std::os::unix::fs::PermissionsExt;
1502 std::fs::set_permissions(
1503 root.join("dist/nested"),
1504 std::fs::Permissions::from_mode(0o755),
1505 )
1506 .unwrap();
1507 }
1508 }
1509
1510 #[test]
1511 fn listings_leave_out_other_targets_outputs_but_the_upstreams() {
1512 let temp = tempfile::tempdir().unwrap();
1513 let root = temp.path();
1514 std::fs::write(root.join("nx.json"), "{}").unwrap();
1515 std::fs::create_dir_all(root.join("app")).unwrap();
1516 std::fs::create_dir_all(root.join("lib")).unwrap();
1517 std::fs::write(
1520 root.join("project.json"),
1521 r#"{"name":"workspace","targets":{"cache":{"command":"true",
1522 "outputs":["{projectRoot}/.cache/build","{projectRoot}/app/extra/out"]}}}"#,
1523 )
1524 .unwrap();
1525 std::fs::write(
1526 root.join("app/project.json"),
1527 r#"{"name":"app","root":"./app","targets":{
1528 "build":{"command":"true","outputs":["{options.outputPath}"],
1529 "options":{"outputPath":"app/dist"},
1530 "configurations":{"production":{"outputPath":"app/dist-production"}}},
1531 "lint":{"command":"true"},
1532 "cover":{"command":"true","outputs":["{projectRoot}/.coverage/unit"]},
1533 "cover-browser":{"command":"true","outputs":["{projectRoot}/.coverage/browser"]},
1534 "wasm":{"command":"true","outputs":["{projectRoot}/target/wasm/release/*.wasm"]},
1535 "test":{"command":"true","dependsOn":[{"projects":["lib"],"target":"build"}],
1536 "configurations":{"production":{}}}}}"#,
1537 )
1538 .unwrap();
1539 std::fs::write(
1540 root.join("lib/project.json"),
1541 r#"{"name":"lib","targets":{"build":{"command":"true","outputs":["{options.outputPath}"],
1542 "options":{"outputPath":"lib/dist"},
1543 "configurations":{"production":{"outputPath":"lib/dist-production"}}}}}"#,
1544 )
1545 .unwrap();
1546 let workspace = Workspace::load(root).unwrap();
1547 let graph = qk_taskgraph::TaskGraph::build(
1548 &workspace,
1549 &[
1550 qk_taskgraph::Request::parse("app:lint").unwrap(),
1551 qk_taskgraph::Request::parse("app:cover").unwrap(),
1552 qk_taskgraph::Request::parse("app:test").unwrap(),
1553 qk_taskgraph::Request::parse("app:test:production").unwrap(),
1554 ],
1555 )
1556 .unwrap();
1557 let outputs = workspace_outputs(&workspace);
1558 let others = |task: &str| recorded::Others {
1559 outputs: &outputs,
1560 upstream: upstream(&graph, &graph.tasks[task]),
1561 };
1562 let lint = others("app:lint");
1563 assert!(lint.matches("app/dist/main.js"));
1564 assert!(lint.matches("app/dist-production/main.js"));
1565 assert!(lint.matches("lib/dist"));
1566 assert!(!lint.matches("app/src/main.ts"));
1567 assert!(lint.matches("app/target"));
1569 assert!(lint.matches("app/target/wasm"));
1570 assert!(!lint.matches("app"));
1571 assert!(lint.matches(".cache"));
1573 assert!(lint.matches("app/extra"));
1574 assert!(!lint.matches("lib"));
1575 let unlisted = recorded::Unlisted {
1578 own: Vec::new(),
1579 others: Some(lint),
1580 ignore: &[],
1581 };
1582 let directories = unlisted.directories("lib");
1583 for directory in [".", ".cache", "app", "app/extra"] {
1584 assert!(directories.contains(directory), "{directory}");
1585 }
1586 assert!(!others("app:test").matches("lib/dist"));
1589 assert!(others("app:test").matches("lib/dist-production"));
1590 assert!(others("app:cover").matches("app/.coverage"));
1593 let production = others("app:test:production");
1594 assert!(!production.matches("lib/dist-production"));
1595 assert!(production.matches("lib/dist"));
1596 }
1597}