Skip to main content

qk_cache/
lib.rs

1//! Local content-addressed cache shared by linked Git worktrees.
2
3mod 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::{DependencyGlob, Selects, SourceIgnore, dependency_globs, 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};
34/// Expands `{projectRoot}` and `{workspaceRoot}` in a project's path or glob.
35pub 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
41/// The cache for one run; its workspace snapshot is taken on first use.
42pub struct Cache {
43    pub root: PathBuf,
44    snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
45    remote: Option<std::sync::Arc<remote::Remote>>,
46    /// Whether results and warm state may be restored or published.
47    reuse: bool,
48    /// The tasks whose outputs this run left exactly as they were.
49    kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
50    /// Warm state being saved in the background.
51    saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
52    cleanup: std::sync::Mutex<Option<store::Cleanup>>,
53}
54
55/// Where a task's result came from.
56#[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    /// Ran without consulting the cache: not cacheable, bypassed or disabled.
63    Uncached,
64}
65
66/// One actual keyed execution, independent of reusable cache entries.
67pub 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    /// The task's key and what it was computed from, when it has one.
76    pub key: Option<(String, Value)>,
77    /// What warm state did, for targets that keep it.
78    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    /// Records verified executions without configuring or using result stores.
108    pub fn for_observation(workspace: &Workspace) -> Self {
109        Self {
110            reuse: false,
111            ..Self::new(paths::cache_location(workspace))
112        }
113    }
114
115    /// The workspace's cache, with the remote store its nx.json `s3` key
116    /// configures, if any. Credentials and modes are read from `environment`,
117    /// which includes the workspace's dotenv files. A remote store that cannot
118    /// be used is reported and left out; the local cache still works.
119    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    /// Saves what later runs reuse and waits for background saves, uploads
145    /// and retired-output cleanup, reporting failures.
146    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        // Before the uploads, which a save may add to.
154        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        // Held until the task is done, whether it ran or came from the cache.
206        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        // Names an entry in messages: the group it shares, if any.
218        let entry_label = |warm: &warm::Warm| match warm.group() {
219            Some(group) => format!(" (group {group})"),
220            None => String::new(),
221        };
222        // The warm variables point tools at their state; they are not inputs,
223        // so the key is computed from the task without them.
224        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        // What each entry restored, and which groups were on disk already.
231        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        // Reports why this task runs uncached.
289        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        // As in Nx, the task sees its hash.
324        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        // Inputs may have changed while another worktree held this key's lock.
375        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        // An entry fetched ahead of this task is local, but it came from the
394        // remote store, and is reported as such.
395        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    /// Rechecks declared inputs for successful and failed executions alike.
533    #[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
579/// A task's key, and the inputs it is computed from, or why it has none.
580pub type Keyed = std::result::Result<(String, Value), String>;
581
582/// The key each task in `graph` would be cached under if it ran now, and what
583/// it is computed from, without running a task. Runtime inputs are executed,
584/// and the dependency outputs a task reads are taken as they are on disk.
585pub 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/// Runs the private detached-upload protocol used by the qk executable.
626#[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    /// Content the store holds already is reused, not copied over again.
675    #[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        // A stored blob whose content no longer matches is stored anew.
692        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        // So is one that cannot be read.
696        {
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    /// Complete-directory restore preserves contents, links, modes and
711    /// timestamps, and the subsequent hit keeps restored files in place.
712    #[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            // The manifest retains the restrictive mode; cleanup needs write
752            // permission on the old directory to remove its files.
753            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}