1mod evict;
4mod glob;
5mod hash;
6mod ignored;
7mod inputs;
8mod paths;
9pub mod profile;
10mod record;
11mod remote;
12mod store;
13pub mod warm;
14
15use std::collections::BTreeMap;
16use std::path::PathBuf;
17use std::sync::OnceLock;
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use anyhow::Result;
21use qk_config::Workspace;
22use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
23use qk_taskgraph::{Task, TaskGraph};
24use serde_json::Value;
25
26pub use evict::{Pruned, max_size, parse_size, prune, prune_automatic};
27pub use glob::Pattern;
28pub use hash::{SourceIgnore, source_files};
29pub use inputs::{
30 InputChange, InputChanges, JsonFields, ROOT_TSCONFIG_FILES, Resolution, Resolved, TsConfig,
31 manifest_for_dependents, resolve_each, resolve_tasks, selectively_hash_tsconfig,
32 without_resolution,
33};
34pub use paths::expand as expand_project_path;
36pub use paths::{
37 Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
38 worktree_state,
39};
40
41pub struct Cache {
43 pub root: PathBuf,
44 snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
45 remote: Option<std::sync::Arc<remote::Remote>>,
46 reuse: bool,
48 kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
50 saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
52 cleanup: std::sync::Mutex<Option<store::Cleanup>>,
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
57#[serde(rename_all = "kebab-case")]
58pub enum CacheStatus {
59 LocalHit,
60 RemoteHit,
61 Miss,
62 Uncached,
64}
65
66pub struct Execution {
68 pub log: qk_executor::RecordedLog,
69 pub inputs_unchanged: bool,
70}
71
72pub struct TaskResult {
73 pub outcome: Outcome,
74 pub cache: CacheStatus,
75 pub key: Option<(String, Value)>,
77 pub warm: Option<warm::WarmReport>,
79 pub execution: Option<Execution>,
80}
81
82impl TaskResult {
83 pub fn uncached(outcome: Outcome) -> Self {
84 Self {
85 outcome,
86 cache: CacheStatus::Uncached,
87 key: None,
88 warm: None,
89 execution: None,
90 }
91 }
92}
93
94impl Cache {
95 pub fn new(root: PathBuf) -> Self {
96 Self {
97 root,
98 snapshot: OnceLock::new(),
99 remote: None,
100 reuse: true,
101 kept: Default::default(),
102 saves: Default::default(),
103 cleanup: Default::default(),
104 }
105 }
106
107 pub fn for_observation(workspace: &Workspace) -> Self {
109 Self {
110 reuse: false,
111 ..Self::new(paths::cache_location(workspace))
112 }
113 }
114
115 pub fn for_workspace(
120 workspace: &Workspace,
121 environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
122 ) -> Self {
123 let root = paths::cache_location(workspace);
124 let remote = match remote::configure(
125 workspace.config.extra.get("s3"),
126 workspace.config.extra.get("storeServer"),
127 environment,
128 ) {
129 Ok(remote) => remote.map(std::sync::Arc::new),
130 Err(error) => {
131 qk_executor::status!("qk: remote cache disabled: {error:#}");
132 None
133 }
134 };
135 if let Some(remote) = &remote {
136 remote.read_index(&root);
137 }
138 Self {
139 remote,
140 ..Self::new(root)
141 }
142 }
143
144 pub fn finish(&self, workspace: &Workspace) {
147 if !self.reuse {
148 return;
149 }
150 if let Some(Ok(snapshot)) = self.snapshot.get() {
151 snapshot.save_digests(&workspace.root);
152 }
153 for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
155 let _ = save.join();
156 }
157 if let Some(remote) = &self.remote {
158 let failures = remote.finish();
159 for failure in &failures {
160 qk_executor::status!("qk: remote cache upload failed for {failure}");
161 }
162 }
163 let _profile = profile::span("cache", "restore_cleanup_wait");
164 self.cleanup.lock().unwrap().take();
165 }
166
167 fn snapshot(
168 &self,
169 workspace: &Workspace,
170 graph: &TaskGraph,
171 ) -> std::result::Result<&hash::Snapshot, String> {
172 self.snapshot
173 .get_or_init(|| {
174 hash::Snapshot::new(workspace, graph, &self.root)
175 .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
176 })
177 .as_ref()
178 .map_err(Clone::clone)
179 }
180
181 pub fn run(
182 &self,
183 workspace: &Workspace,
184 graph: &TaskGraph,
185 task: &Task,
186 prepared: &PreparedTask,
187 cancelled: &AtomicBool,
188 ) -> Result<TaskResult> {
189 let result = self.run_task(workspace, graph, task, prepared, cancelled);
190 if let Some(Ok(snapshot)) = self.snapshot.get() {
191 snapshot.outputs_written(workspace, task);
192 }
193 result
194 }
195
196 fn run_task(
197 &self,
198 workspace: &Workspace,
199 graph: &TaskGraph,
200 task: &Task,
201 prepared: &PreparedTask,
202 cancelled: &AtomicBool,
203 ) -> Result<TaskResult> {
204 let cacheable = task.definition.cache == Some(true);
205 let _kept = self
207 .reuse
208 .then(|| warm::keep_across(workspace, graph, task));
209 let entries = if self.reuse {
210 warm::config(workspace, task).unwrap_or_else(|error| {
211 qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
212 Vec::new()
213 })
214 } else {
215 Vec::new()
216 };
217 let entry_label = |warm: &warm::Warm| match warm.group() {
219 Some(group) => format!(" (group {group})"),
220 None => String::new(),
221 };
222 let mut running = prepared.clone();
225 for warm in &entries {
226 for (name, value) in &warm.env {
227 running.execution.insert(name.into(), value.into());
228 }
229 }
230 let before = |running: &PreparedTask| -> (Vec<warm::Restored>, Vec<String>) {
232 let (mut restored, mut present) = (Vec::new(), Vec::new());
233 for warm in &entries {
234 match self
235 .initialize()
236 .and_then(|()| self.restore_warm(workspace, task, warm, running))
237 {
238 Ok((entry, groups)) => {
239 if !entry.groups.is_empty() {
240 restored.push(entry);
241 }
242 present.extend(groups);
243 }
244 Err(error) => qk_executor::status!(
245 "qk: {}: warm state{} not restored ({error:#})",
246 task.id,
247 entry_label(warm)
248 ),
249 }
250 }
251 (restored, present)
252 };
253 let after = |outcome: Outcome,
254 (restored, present): (Vec<warm::Restored>, Vec<String>),
255 running: &PreparedTask|
256 -> Option<warm::WarmReport> {
257 if entries.is_empty() {
258 return None;
259 }
260 let mut save_ms = None;
261 let mut background = false;
262 for warm in entries.iter().filter(|_| outcome == Outcome::Success) {
263 let started = std::time::Instant::now();
264 let saved = if warm.background {
265 self.save_warm_in_background(workspace, task, warm, running)
266 } else {
267 self.save_warm(workspace, task, warm, running)
268 };
269 match saved {
270 Ok(()) if warm.background => background = true,
271 Ok(()) => {
272 *save_ms.get_or_insert(0) += started.elapsed().as_millis() as u64;
273 }
274 Err(error) => qk_executor::status!(
275 "qk: {}: warm state{} not saved ({error:#})",
276 task.id,
277 entry_label(warm)
278 ),
279 }
280 }
281 Some(warm::WarmReport {
282 restored,
283 present,
284 save_ms,
285 background,
286 })
287 };
288 let bypass = |reason: String| {
290 if cacheable {
291 qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
292 }
293 let _profile = profile::span(&task.id, "execute");
294 execute(prepared, cancelled).map(TaskResult::uncached)
295 };
296 let snapshot = match self.snapshot(workspace, graph) {
297 Ok(snapshot) => snapshot,
298 Err(reason) => return bypass(reason),
299 };
300 let dependency_profile = profile::span(&task.id, "dependency_inputs");
301 let dependencies = match hash::dependency_keys(snapshot, workspace, graph, task, cancelled)
302 {
303 Ok(dependencies) => dependencies,
304 Err(error) => return bypass(format!("{error:#}")),
305 };
306 drop(dependency_profile);
307 let input_profile = profile::span(&task.id, "inputs");
308 let (key, inputs) = match hash::inputs(
309 snapshot,
310 workspace,
311 task,
312 prepared,
313 &dependencies,
314 cancelled,
315 )
316 .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
317 {
318 Ok(key) => key,
319 Err(error) => return bypass(format!("{error:#}")),
320 };
321 drop(input_profile);
322 let keyed = Some((key.clone(), inputs));
323 running
325 .execution
326 .insert("NX_TASK_HASH".into(), key.clone().into());
327 let running = &running;
328 let outputs = match paths::Outputs::new(workspace, task) {
329 Ok(outputs) => outputs,
330 Err(error) => return bypass(format!("{error:#}")),
331 };
332 if !cacheable {
333 let restored = before(running);
334 let executing = profile::span(&task.id, "execute");
335 let outcome = execute(running, cancelled)?;
336 drop(executing);
337 let warm_report = after(outcome, restored, running);
338 return Ok(TaskResult {
339 outcome,
340 cache: CacheStatus::Uncached,
341 key: keyed,
342 warm: warm_report,
343 execution: None,
344 });
345 }
346 if !self.reuse {
347 let capture = Capture::new(None, prepared.display.clone()).retain_log();
348 let executing = profile::span(&task.id, "execute");
349 let outcome = execute_captured(running, cancelled, Some(&capture))?;
350 drop(executing);
351 let inputs_unchanged =
352 self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
353 return Ok(TaskResult {
354 outcome,
355 cache: CacheStatus::Uncached,
356 key: keyed,
357 warm: None,
358 execution: capture.take_log().map(|log| Execution {
359 log,
360 inputs_unchanged,
361 }),
362 });
363 }
364 if let Err(error) = self.initialize() {
365 return bypass(format!("cache unavailable: {error}"));
366 }
367 let (_lock, waited) = match self.lock(&key, cancelled) {
368 Ok(Some(lock)) => lock,
369 Ok(None) => {
370 return Ok(TaskResult::uncached(Outcome::Cancelled));
371 }
372 Err(error) => return bypass(format!("cache lock unavailable: {error}")),
373 };
374 if waited
376 && hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
377 .and_then(|dependencies| {
378 hash::fingerprint(
379 snapshot,
380 workspace,
381 task,
382 prepared,
383 &dependencies,
384 cancelled,
385 )
386 })
387 .ok()
388 .as_ref()
389 != Some(&key)
390 {
391 return bypass("inputs changed while waiting for another run".into());
392 }
393 let prefetched = self.remote.as_ref().is_some_and(|remote| {
396 remote.resolved(&key);
397 remote.prefetched(&key)
398 });
399 let local_profile = profile::span(&task.id, "local_restore");
400 match self.restore(
401 &workspace.root,
402 &task.id,
403 &task.dependencies,
404 &key,
405 &outputs,
406 &prepared.display,
407 if prefetched {
408 qk_executor::Shown::RemoteCache
409 } else {
410 qk_executor::Shown::LocalCache
411 },
412 ) {
413 Ok(true) => {
414 let claimed = match &self.remote {
415 Some(remote) if prefetched && remote.claim(&key) => true,
416 Some(remote) => {
417 remote.held(&key);
418 false
419 }
420 None => false,
421 };
422 qk_executor::report::event(qk_executor::report::Event::Cache {
423 id: task.id.clone(),
424 cached: if claimed {
425 qk_executor::report::Cached::RemoteHit
426 } else {
427 qk_executor::report::Cached::Hit
428 },
429 });
430 return Ok(TaskResult {
431 outcome: Outcome::Success,
432 cache: if claimed {
433 CacheStatus::RemoteHit
434 } else {
435 CacheStatus::LocalHit
436 },
437 key: keyed,
438 warm: None,
439 execution: None,
440 });
441 }
442 Ok(false) => {}
443 Err(error) => {
444 qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
445 }
446 }
447 drop(local_profile);
448 if let Some(remote) = &self.remote {
449 let fetch_profile = profile::span(&task.id, "remote_fetch");
450 let fetched = remote.fetch(&self.root, &key, &task.id, cancelled);
451 drop(fetch_profile);
452 let fetched = fetched.and_then(|found| {
453 if !found {
454 return Ok(false);
455 }
456 self.restore(
457 &workspace.root,
458 &task.id,
459 &task.dependencies,
460 &key,
461 &outputs,
462 &prepared.display,
463 qk_executor::Shown::RemoteCache,
464 )
465 });
466 match fetched {
467 Ok(true) => {
468 qk_executor::report::event(qk_executor::report::Event::Cache {
469 id: task.id.clone(),
470 cached: qk_executor::report::Cached::RemoteHit,
471 });
472 return Ok(TaskResult {
473 outcome: Outcome::Success,
474 cache: CacheStatus::RemoteHit,
475 key: keyed,
476 warm: None,
477 execution: None,
478 });
479 }
480 Ok(false) => {}
481 Err(error) => {
482 qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
483 }
484 }
485 }
486 qk_executor::report::event(qk_executor::report::Event::Cache {
487 id: task.id.clone(),
488 cached: qk_executor::report::Cached::Miss,
489 });
490 let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
491 Ok(log) => log,
492 Err(error) => return bypass(format!("cache log unavailable: {error}")),
493 };
494 let capture =
495 Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
496 let restored = before(running);
497 let executing = profile::span(&task.id, "execute");
498 let outcome = execute_captured(running, cancelled, Some(&capture))?;
499 drop(executing);
500 let warm_report = after(outcome, restored, running);
501 let inputs_unchanged =
502 self.inputs_match(snapshot, workspace, graph, task, prepared, &key, cancelled);
503 let execution = capture.take_log().map(|log| Execution {
504 log,
505 inputs_unchanged,
506 });
507 if outcome == Outcome::Success && inputs_unchanged && capture.healthy() {
508 let publishing = profile::span(&task.id, "publish");
509 let published = self.publish(&workspace.root, &key, &outputs, log.path());
510 drop(publishing);
511 match published {
512 Ok(()) => {
513 store::record_outputs(&workspace.root, &task.id, &key, &outputs);
514 if let Some(remote) = &self.remote {
515 remote.upload(&self.root, &key);
516 }
517 }
518 Err(error) => {
519 qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
520 }
521 }
522 }
523 Ok(TaskResult {
524 outcome,
525 cache: CacheStatus::Miss,
526 key: keyed,
527 warm: warm_report,
528 execution,
529 })
530 }
531
532 #[allow(clippy::too_many_arguments)]
534 fn inputs_match(
535 &self,
536 snapshot: &hash::Snapshot,
537 workspace: &Workspace,
538 graph: &TaskGraph,
539 task: &Task,
540 prepared: &PreparedTask,
541 before: &str,
542 cancelled: &AtomicBool,
543 ) -> bool {
544 if cancelled.load(Ordering::SeqCst) {
545 return false;
546 }
547 let _profile = profile::span(&task.id, "inputs_recheck");
548 let after = hash::recheck_dependency_keys(snapshot, workspace, graph, task, cancelled)
549 .and_then(|dependencies| {
550 hash::fingerprint(
551 snapshot,
552 workspace,
553 task,
554 prepared,
555 &dependencies,
556 cancelled,
557 )
558 });
559 if after.as_deref().ok() != Some(before) {
560 if task.definition.cache == Some(true) {
561 if self.reuse {
562 qk_executor::status!(
563 "qk: {}: not caching because inputs changed during execution",
564 task.id
565 );
566 } else {
567 qk_executor::status!(
568 "qk: {}: excluding execution from flaky results because inputs changed during execution",
569 task.id
570 );
571 }
572 }
573 return false;
574 }
575 true
576 }
577}
578
579pub type Keyed = std::result::Result<(String, Value), String>;
581
582pub fn keys(
586 workspace: &Workspace,
587 graph: &TaskGraph,
588 prepared: &BTreeMap<String, PreparedTask>,
589 cancelled: &AtomicBool,
590) -> Result<BTreeMap<String, Keyed>> {
591 let snapshot = hash::Snapshot::new(workspace, graph, &paths::cache_location(workspace))?;
592 Ok(graph
593 .tasks
594 .values()
595 .map(|task| {
596 let keyed = key_now(&snapshot, workspace, graph, task, prepared, cancelled);
597 (task.id.clone(), keyed)
598 })
599 .collect())
600}
601
602fn key_now(
603 snapshot: &hash::Snapshot,
604 workspace: &Workspace,
605 graph: &TaskGraph,
606 task: &Task,
607 prepared: &BTreeMap<String, PreparedTask>,
608 cancelled: &AtomicBool,
609) -> Keyed {
610 let failed = |error: anyhow::Error| format!("{}: {error:#}", task.id);
611 let dependencies =
612 hash::dependency_keys(snapshot, workspace, graph, task, cancelled).map_err(failed)?;
613 let inputs = hash::inputs(
614 snapshot,
615 workspace,
616 task,
617 &prepared[&task.id],
618 &dependencies,
619 cancelled,
620 )
621 .map_err(failed)?;
622 Ok((hash::key(&inputs).map_err(failed)?, inputs))
623}
624
625#[doc(hidden)]
627pub fn run_upload_worker() -> anyhow::Result<()> {
628 remote::background::run()
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
636 fn concurrent_blob_writes_keep_results_ordered_and_repair_corruption() {
637 let workspace = tempfile::tempdir().unwrap();
638 let root = workspace.path();
639 let cache = Cache::new(root.join("cache"));
640 cache.initialize().unwrap();
641 let mut sources = Vec::new();
642 for index in 0..130 {
643 let source = root.join(format!("source-{index}"));
644 std::fs::write(&source, format!("content-{}", index % 65)).unwrap();
645 sources.push(source);
646 }
647 let digest = cache.put_blob(&sources[0]).unwrap();
648 std::fs::write(cache.root.join("blobs").join(&digest), "corrupt").unwrap();
649 std::fs::remove_file(&sources[31]).unwrap();
650 let results = cache.put_blobs(&sources);
651 assert_eq!(results.len(), sources.len());
652 for (index, result) in results.into_iter().enumerate() {
653 if index == 31 {
654 assert_eq!(
655 result
656 .unwrap_err()
657 .downcast_ref::<std::io::Error>()
658 .unwrap()
659 .kind(),
660 std::io::ErrorKind::NotFound
661 );
662 continue;
663 }
664 let digest = result.unwrap();
665 let held = cache.root.join("blobs").join(&digest);
666 assert_eq!(
667 std::fs::read(&held).unwrap(),
668 format!("content-{}", index % 65).as_bytes()
669 );
670 assert_eq!(hash::digest_file(&held).unwrap(), digest);
671 }
672 }
673
674 #[cfg(unix)]
676 #[test]
677 fn a_blob_the_store_holds_is_not_written_again() {
678 use std::os::unix::fs::MetadataExt;
679 let workspace = tempfile::tempdir().unwrap();
680 let root = workspace.path();
681 let cache = Cache::new(root.join(".qk/cache"));
682 cache.initialize().unwrap();
683 std::fs::write(root.join("first"), "same").unwrap();
684 std::fs::write(root.join("second"), "same").unwrap();
685 std::fs::write(root.join("other"), "different").unwrap();
686 let first = cache.put_blob(&root.join("first")).unwrap();
687 let blob = cache.root.join("blobs").join(&first);
688 let stored = std::fs::metadata(&blob).unwrap().ino();
689 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
690 assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
691 std::fs::write(&blob, "corrupt").unwrap();
693 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
694 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
695 {
697 use std::os::unix::fs::PermissionsExt;
698 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
699 }
700 assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
701 assert_eq!(std::fs::read(&blob).unwrap(), b"same");
702 let other = cache.put_blob(&root.join("other")).unwrap();
703 assert_ne!(other, first);
704 assert_eq!(
705 std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
706 b"different"
707 );
708 }
709
710 #[test]
713 fn complete_directory_restores_preserve_outputs() {
714 let workspace = tempfile::tempdir().unwrap();
715 let root = workspace.path();
716 std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
717 std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
718 std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
719 std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
720 std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
721 #[cfg(unix)]
722 {
723 use std::os::unix::fs::PermissionsExt;
724 let script = root.join("dist/run.sh");
725 std::fs::write(&script, "#!/bin/sh\n").unwrap();
726 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
727 std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
728 std::fs::set_permissions(
729 root.join("dist/nested"),
730 std::fs::Permissions::from_mode(0o500),
731 )
732 .unwrap();
733 std::fs::set_permissions(
734 root.join("bundle/readonly"),
735 std::fs::Permissions::from_mode(0o444),
736 )
737 .unwrap();
738 }
739 let log = root.join("log");
740 std::fs::write(&log, "").unwrap();
741
742 let cache = Cache::new(root.join(".qk/cache"));
743 cache.initialize().unwrap();
744 let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
745 let key = "0".repeat(64);
746 cache.publish(root, &key, &outputs, &log).unwrap();
747
748 #[cfg(unix)]
749 {
750 use std::os::unix::fs::PermissionsExt;
751 std::fs::set_permissions(
754 root.join("dist/nested"),
755 std::fs::Permissions::from_mode(0o755),
756 )
757 .unwrap();
758 }
759 std::fs::remove_dir_all(root.join("dist")).unwrap();
760 std::fs::remove_dir_all(root.join("bundle")).unwrap();
761 let before = std::time::SystemTime::now();
762 let restored = cache
763 .restore(
764 root,
765 "app:build",
766 &Default::default(),
767 &key,
768 &outputs,
769 &qk_executor::Display::Stream,
770 qk_executor::Shown::LocalCache,
771 )
772 .unwrap();
773 assert!(restored);
774 assert!(root.join("dist/nested/empty").is_dir());
775 assert!(root.join("bundle/empty").is_dir());
776 assert_eq!(
777 std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
778 "readonly"
779 );
780 #[cfg(unix)]
781 {
782 assert_eq!(
783 std::fs::read_link(root.join("dist/link")).unwrap(),
784 std::path::Path::new("nested/a.txt")
785 );
786 assert_eq!(
787 store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
788 0o755
789 );
790 assert_eq!(
791 store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
792 0o500
793 );
794 assert_eq!(
795 store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
796 0o444
797 );
798 }
799 let path = root.join("dist/nested/a.txt");
800 let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
801 assert!(modified >= before);
802 let kept = cache
803 .restore(
804 root,
805 "app:build",
806 &Default::default(),
807 &key,
808 &outputs,
809 &qk_executor::Display::Hidden,
810 qk_executor::Shown::LocalCache,
811 )
812 .unwrap();
813 assert!(kept);
814 assert_eq!(
815 std::fs::metadata(&path).unwrap().modified().unwrap(),
816 modified
817 );
818 #[cfg(unix)]
819 {
820 use std::os::unix::fs::PermissionsExt;
821 std::fs::set_permissions(
822 root.join("dist/nested"),
823 std::fs::Permissions::from_mode(0o755),
824 )
825 .unwrap();
826 }
827 }
828}