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 paths;
7mod remote;
8mod store;
9pub mod warm;
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::sync::OnceLock;
14use std::sync::atomic::{AtomicBool, Ordering};
15
16use anyhow::{Context, Result};
17use qk_config::Workspace;
18use qk_executor::{Capture, Outcome, PreparedTask, execute, execute_captured};
19use qk_taskgraph::{Task, TaskGraph};
20use serde_json::{Value, json};
21
22pub use evict::{Pruned, max_size, parse_size, prune};
23pub use glob::Pattern;
24pub use hash::{Resolved, SourceIgnore, source_files, without_resolution};
25pub use paths::{
26    Outputs, cache_directory, cache_location, clear_worktree_state, resolved_outputs,
27    worktree_state,
28};
29
30/// The cache for one run; its workspace snapshot is taken on first use.
31pub struct Cache {
32    pub root: PathBuf,
33    snapshot: OnceLock<std::result::Result<hash::Snapshot, String>>,
34    remote: Option<std::sync::Arc<remote::Remote>>,
35    /// Whether results and warm state may be restored or published.
36    reuse: bool,
37    /// The tasks whose outputs this run left exactly as they were.
38    kept: std::sync::Mutex<std::collections::BTreeSet<String>>,
39    /// Warm state being saved in the background.
40    saves: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>>,
41}
42
43/// A task's output fingerprint for its dependents' keys, or why it has none:
44/// `"<task id>: <reason>"` for the task where fingerprinting first failed.
45pub type Fingerprint = std::result::Result<String, String>;
46
47/// Where a task's result came from.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum CacheStatus {
51    LocalHit,
52    RemoteHit,
53    Miss,
54    /// Ran without consulting the cache: not cacheable, bypassed or disabled.
55    Uncached,
56}
57
58/// One actual keyed execution, independent of reusable cache entries.
59pub struct Execution {
60    pub log: qk_executor::RecordedLog,
61    pub inputs_unchanged: bool,
62}
63
64pub struct TaskResult {
65    pub outcome: Outcome,
66    pub fingerprint: Fingerprint,
67    pub cache: CacheStatus,
68    /// The task's key and what it was computed from, when it has one.
69    pub key: Option<(String, Value)>,
70    /// What warm state did, for targets that keep it.
71    pub warm: Option<warm::WarmReport>,
72    pub execution: Option<Execution>,
73}
74
75impl TaskResult {
76    pub fn uncached(outcome: Outcome, reason: String) -> Self {
77        Self {
78            outcome,
79            fingerprint: Err(reason),
80            cache: CacheStatus::Uncached,
81            key: None,
82            warm: None,
83            execution: None,
84        }
85    }
86}
87
88/// Each task's resolved inputs, for affected selection. `extra` paths are
89/// candidates beside the workspace's files, so a deleted file still matches
90/// the inputs that named it. Env and runtime inputs are not evaluated.
91pub fn resolve_tasks(
92    workspace: &Workspace,
93    graph: &TaskGraph,
94    extra: &[String],
95) -> Result<BTreeMap<String, Resolved>> {
96    let cache = paths::cache_location(workspace);
97    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?.with_candidates(extra);
98    let cancelled = AtomicBool::new(false);
99    graph
100        .tasks
101        .iter()
102        .map(|(id, task)| {
103            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
104                .with_context(|| format!("cannot resolve the inputs of {id}"))?;
105            Ok((id.clone(), resolved))
106        })
107        .collect()
108}
109
110/// Every task's resolved inputs, or why they could not be resolved, with the
111/// workspace files they were chosen from.
112pub struct Resolution {
113    pub candidates: std::collections::BTreeSet<String>,
114    pub tasks: BTreeMap<String, std::result::Result<Resolved, String>>,
115}
116
117pub fn resolve_each(workspace: &Workspace, graph: &TaskGraph) -> Result<Resolution> {
118    let cache = paths::cache_location(workspace);
119    let snapshot = hash::Snapshot::new(workspace, graph, &cache)?;
120    let cancelled = AtomicBool::new(false);
121    let tasks = graph
122        .tasks
123        .iter()
124        .map(|(id, task)| {
125            let resolved = hash::resolve(&snapshot, workspace, task, None, &cancelled)
126                .map_err(|error| format!("{error:#}"));
127            (id.clone(), resolved)
128        })
129        .collect();
130    Ok(Resolution {
131        candidates: snapshot.files.clone(),
132        tasks,
133    })
134}
135
136impl Cache {
137    pub fn new(root: PathBuf) -> Self {
138        Self {
139            root,
140            snapshot: OnceLock::new(),
141            remote: None,
142            reuse: true,
143            kept: Default::default(),
144            saves: Default::default(),
145        }
146    }
147
148    /// Records verified executions without configuring or using result stores.
149    pub fn for_observation(workspace: &Workspace) -> Self {
150        Self {
151            reuse: false,
152            ..Self::new(paths::cache_location(workspace))
153        }
154    }
155
156    /// The workspace's cache, with the remote store its nx.json `s3` key
157    /// configures, if any. Credentials and modes are read from `environment`,
158    /// which includes the workspace's dotenv files. A remote store that cannot
159    /// be used is reported and left out; the local cache still works.
160    pub fn for_workspace(
161        workspace: &Workspace,
162        environment: &BTreeMap<std::ffi::OsString, std::ffi::OsString>,
163    ) -> Self {
164        let root = paths::cache_location(workspace);
165        let remote = match remote::configure(workspace.config.extra.get("s3"), environment) {
166            Ok(remote) => remote.map(std::sync::Arc::new),
167            Err(error) => {
168                qk_executor::status!("qk: remote cache disabled: {error:#}");
169                None
170            }
171        };
172        if let Some(remote) = &remote {
173            remote.read_index(&root);
174        }
175        Self {
176            remote,
177            ..Self::new(root)
178        }
179    }
180
181    /// Saves what later runs reuse and waits for background uploads to the
182    /// remote store, reporting failures.
183    pub fn finish(&self, workspace: &Workspace) {
184        if !self.reuse {
185            return;
186        }
187        if let Some(Ok(snapshot)) = self.snapshot.get() {
188            snapshot.save_digests(&workspace.root);
189        }
190        // Before the uploads, which a save may add to.
191        for save in std::mem::take(&mut *self.saves.lock().unwrap()) {
192            let _ = save.join();
193        }
194        if let Some(remote) = &self.remote {
195            let failures = remote.finish();
196            for failure in &failures {
197                qk_executor::status!("qk: remote cache upload failed for {failure}");
198            }
199        }
200    }
201
202    fn snapshot(
203        &self,
204        workspace: &Workspace,
205        graph: &TaskGraph,
206    ) -> std::result::Result<&hash::Snapshot, String> {
207        self.snapshot
208            .get_or_init(|| {
209                hash::Snapshot::new(workspace, graph, &self.root)
210                    .map_err(|error| format!("cannot snapshot the workspace: {error:#}"))
211            })
212            .as_ref()
213            .map_err(Clone::clone)
214    }
215
216    pub fn run(
217        &self,
218        workspace: &Workspace,
219        graph: &TaskGraph,
220        task: &Task,
221        prepared: &PreparedTask,
222        dependencies: &BTreeMap<String, Fingerprint>,
223        cancelled: &AtomicBool,
224    ) -> Result<TaskResult> {
225        let result = self.run_task(workspace, graph, task, prepared, dependencies, cancelled);
226        if let Some(Ok(snapshot)) = self.snapshot.get() {
227            snapshot.outputs_written(workspace, task);
228        }
229        result
230    }
231
232    fn run_task(
233        &self,
234        workspace: &Workspace,
235        graph: &TaskGraph,
236        task: &Task,
237        prepared: &PreparedTask,
238        dependencies: &BTreeMap<String, Fingerprint>,
239        cancelled: &AtomicBool,
240    ) -> Result<TaskResult> {
241        let cacheable = task.definition.cache == Some(true);
242        // Held until the task is done, whether it ran or came from the cache.
243        let _kept = self
244            .reuse
245            .then(|| warm::keep_across(workspace, graph, task));
246        let warm = if self.reuse {
247            match warm::config(workspace, task) {
248                Ok(warm) => warm,
249                Err(error) => {
250                    qk_executor::status!("qk: {}: warm state ignored ({error:#})", task.id);
251                    None
252                }
253            }
254        } else {
255            None
256        };
257        // The warm variables point tools at their state; they are not inputs,
258        // so the key is computed from the task without them.
259        let mut running = prepared.clone();
260        if let Some(warm) = &warm {
261            for (name, value) in &warm.env {
262                running.execution.insert(name.into(), value.into());
263            }
264        }
265        // What was restored, and which groups were on disk already.
266        let before = |running: &PreparedTask| -> (Option<warm::Restored>, Vec<String>) {
267            let Some(warm) = warm.as_ref() else {
268                return (None, Vec::new());
269            };
270            match self
271                .initialize()
272                .and_then(|()| self.restore_warm(workspace, task, warm, running))
273            {
274                Ok((restored, present)) => {
275                    ((!restored.groups.is_empty()).then_some(restored), present)
276                }
277                Err(error) => {
278                    qk_executor::status!("qk: {}: warm state not restored ({error:#})", task.id);
279                    (None, Vec::new())
280                }
281            }
282        };
283        let after = |outcome: Outcome,
284                     (restored, present): (Option<warm::Restored>, Vec<String>),
285                     running: &PreparedTask|
286         -> Option<warm::WarmReport> {
287            let warm = warm.as_ref()?;
288            let started = std::time::Instant::now();
289            let mut background = false;
290            let save_ms = if outcome != Outcome::Success {
291                None
292            } else if warm.background {
293                match self.save_warm_in_background(workspace, task, warm, running) {
294                    Ok(()) => background = true,
295                    Err(error) => {
296                        qk_executor::status!("qk: {}: warm state not saved ({error:#})", task.id);
297                    }
298                }
299                None
300            } else {
301                match self.save_warm(workspace, task, warm, running) {
302                    Ok(()) => Some(started.elapsed().as_millis() as u64),
303                    Err(error) => {
304                        qk_executor::status!("qk: {}: warm state not saved ({error:#})", task.id);
305                        None
306                    }
307                }
308            };
309            Some(warm::WarmReport {
310                restored,
311                present,
312                save_ms,
313                background,
314            })
315        };
316        let fallback = |reason: String| {
317            execute(prepared, cancelled).map(|outcome| TaskResult::uncached(outcome, reason))
318        };
319        // Reports why this task runs uncached, attributing the reason to this task.
320        let bypass = |reason: String| {
321            if cacheable {
322                qk_executor::status!("qk: {}: cache bypassed ({reason})", task.id);
323            }
324            fallback(format!("{}: {reason}", task.id))
325        };
326        let dependencies = match dependencies
327            .iter()
328            .map(|(id, key)| key.clone().map(|key| (id.clone(), key)))
329            .collect::<std::result::Result<BTreeMap<_, _>, _>>()
330        {
331            Ok(dependencies) => dependencies,
332            Err(root) => {
333                // Keep the original task and reason so chains stay readable.
334                if cacheable {
335                    qk_executor::status!("qk: {}: cache bypassed (depends on {root})", task.id);
336                }
337                return fallback(root);
338            }
339        };
340        let snapshot = match self.snapshot(workspace, graph) {
341            Ok(snapshot) => snapshot,
342            Err(reason) => return bypass(reason),
343        };
344        let dependencies =
345            match hash::dependency_keys(snapshot, workspace, graph, task, &dependencies, cancelled)
346            {
347                Ok(dependencies) => dependencies,
348                Err(error) => return bypass(format!("{error:#}")),
349            };
350        let (key, inputs) = match hash::inputs(
351            snapshot,
352            workspace,
353            task,
354            prepared,
355            &dependencies,
356            cancelled,
357        )
358        .and_then(|inputs| Ok((hash::key(&inputs)?, inputs)))
359        {
360            Ok(key) => key,
361            Err(error) => return bypass(format!("{error:#}")),
362        };
363        let keyed = Some((key.clone(), inputs));
364        // As in Nx, the task sees its hash.
365        running
366            .execution
367            .insert("NX_TASK_HASH".into(), key.clone().into());
368        let running = &running;
369        let outputs = match paths::Outputs::new(workspace, task) {
370            Ok(outputs) => outputs,
371            Err(error) => return bypass(format!("{error:#}")),
372        };
373        if !cacheable {
374            let restored = before(running);
375            let outcome = execute(running, cancelled)?;
376            let warm_report = after(outcome, restored, running);
377            let fingerprint = if self.unchanged(
378                snapshot,
379                workspace,
380                task,
381                prepared,
382                &dependencies,
383                &key,
384                outcome,
385                cancelled,
386            ) {
387                outputs_fingerprint(task, &workspace.root, &outputs, &key)
388            } else {
389                Err(format!("{}: did not complete unchanged", task.id))
390            };
391            return Ok(TaskResult {
392                outcome,
393                fingerprint,
394                cache: CacheStatus::Uncached,
395                key: keyed,
396                warm: warm_report,
397                execution: None,
398            });
399        }
400        if !self.reuse {
401            let capture = Capture::new(None, prepared.display.clone()).retain_log();
402            let outcome = execute_captured(running, cancelled, Some(&capture))?;
403            let inputs_unchanged = self.inputs_match(
404                snapshot,
405                workspace,
406                task,
407                prepared,
408                &dependencies,
409                &key,
410                cancelled,
411            );
412            let fingerprint = if outcome == Outcome::Success && inputs_unchanged {
413                outputs_fingerprint(task, &workspace.root, &outputs, &key)
414            } else {
415                Err(format!("{}: did not complete unchanged", task.id))
416            };
417            return Ok(TaskResult {
418                outcome,
419                fingerprint,
420                cache: CacheStatus::Uncached,
421                key: keyed,
422                warm: None,
423                execution: capture.take_log().map(|log| Execution {
424                    log,
425                    inputs_unchanged,
426                }),
427            });
428        }
429        if let Err(error) = self.initialize() {
430            return bypass(format!("cache unavailable: {error}"));
431        }
432        let (_lock, waited) = match self.lock(&key, cancelled) {
433            Ok(Some(lock)) => lock,
434            Ok(None) => {
435                return Ok(TaskResult::uncached(
436                    Outcome::Cancelled,
437                    format!("{}: cancelled", task.id),
438                ));
439            }
440            Err(error) => return bypass(format!("cache lock unavailable: {error}")),
441        };
442        // Inputs may have changed while another worktree held this key's lock.
443        if waited
444            && hash::fingerprint(
445                snapshot,
446                workspace,
447                task,
448                prepared,
449                &dependencies,
450                cancelled,
451            )
452            .ok()
453            .as_ref()
454                != Some(&key)
455        {
456            return bypass("inputs changed while waiting for another run".into());
457        }
458        match self.restore(
459            &workspace.root,
460            &task.id,
461            &task.dependencies,
462            &key,
463            &outputs,
464            &prepared.display,
465            qk_executor::Shown::LocalCache,
466        ) {
467            Ok(Some(fingerprint)) => {
468                if let Some(remote) = &self.remote {
469                    remote.held(&key);
470                }
471                qk_executor::report::event(qk_executor::report::Event::Cache {
472                    id: task.id.clone(),
473                    cached: qk_executor::report::Cached::Hit,
474                });
475                return Ok(TaskResult {
476                    outcome: Outcome::Success,
477                    fingerprint: Ok(fingerprint),
478                    cache: CacheStatus::LocalHit,
479                    key: keyed,
480                    warm: None,
481                    execution: None,
482                });
483            }
484            Ok(None) => {}
485            Err(error) => {
486                qk_executor::status!("qk: {}: ignoring unusable cache entry ({error})", task.id)
487            }
488        }
489        if let Some(remote) = &self.remote {
490            let fetched = remote.fetch(&self.root, &key).and_then(|found| {
491                if !found {
492                    return Ok(None);
493                }
494                self.restore(
495                    &workspace.root,
496                    &task.id,
497                    &task.dependencies,
498                    &key,
499                    &outputs,
500                    &prepared.display,
501                    qk_executor::Shown::RemoteCache,
502                )
503            });
504            match fetched {
505                Ok(Some(fingerprint)) => {
506                    qk_executor::report::event(qk_executor::report::Event::Cache {
507                        id: task.id.clone(),
508                        cached: qk_executor::report::Cached::RemoteHit,
509                    });
510                    return Ok(TaskResult {
511                        outcome: Outcome::Success,
512                        fingerprint: Ok(fingerprint),
513                        cache: CacheStatus::RemoteHit,
514                        key: keyed,
515                        warm: None,
516                        execution: None,
517                    });
518                }
519                Ok(None) => {}
520                Err(error) => {
521                    qk_executor::status!("qk: {}: remote cache unavailable ({error:#})", task.id)
522                }
523            }
524        }
525        qk_executor::report::event(qk_executor::report::Event::Cache {
526            id: task.id.clone(),
527            cached: qk_executor::report::Cached::Miss,
528        });
529        let log = match tempfile::NamedTempFile::new_in(self.root.join("tmp")) {
530            Ok(log) => log,
531            Err(error) => return bypass(format!("cache log unavailable: {error}")),
532        };
533        let capture =
534            Capture::new(Some(log.as_file().try_clone()?), prepared.display.clone()).retain_log();
535        let restored = before(running);
536        let outcome = execute_captured(running, cancelled, Some(&capture))?;
537        let warm_report = after(outcome, restored, running);
538        let inputs_unchanged = self.inputs_match(
539            snapshot,
540            workspace,
541            task,
542            prepared,
543            &dependencies,
544            &key,
545            cancelled,
546        );
547        let execution = capture.take_log().map(|log| Execution {
548            log,
549            inputs_unchanged,
550        });
551        let fingerprint = if outcome != Outcome::Success || !inputs_unchanged {
552            Err(format!("{}: did not complete unchanged", task.id))
553        } else if !capture.healthy() {
554            outputs_fingerprint(task, &workspace.root, &outputs, &key)
555        } else {
556            // The saved manifest already hashes every output; reuse it.
557            match self.publish(&workspace.root, &key, &outputs, log.path()) {
558                Ok(fingerprint) => {
559                    store::record_outputs(&workspace.root, &task.id, &key, &outputs);
560                    if let Some(remote) = &self.remote {
561                        remote.upload(&self.root, &key);
562                    }
563                    Ok(fingerprint)
564                }
565                Err(error) => {
566                    qk_executor::status!("qk: {}: could not save cache entry ({error})", task.id);
567                    outputs_fingerprint(task, &workspace.root, &outputs, &key)
568                }
569            }
570        };
571        Ok(TaskResult {
572            outcome,
573            fingerprint,
574            cache: CacheStatus::Miss,
575            key: keyed,
576            warm: warm_report,
577            execution,
578        })
579    }
580
581    /// Whether a successful task's inputs still match the key it ran under.
582    #[allow(clippy::too_many_arguments)]
583    fn unchanged(
584        &self,
585        snapshot: &hash::Snapshot,
586        workspace: &Workspace,
587        task: &Task,
588        prepared: &PreparedTask,
589        dependencies: &BTreeMap<String, String>,
590        before: &str,
591        outcome: Outcome,
592        cancelled: &AtomicBool,
593    ) -> bool {
594        if outcome != Outcome::Success || cancelled.load(Ordering::SeqCst) {
595            return false;
596        }
597        self.inputs_match(
598            snapshot,
599            workspace,
600            task,
601            prepared,
602            dependencies,
603            before,
604            cancelled,
605        )
606    }
607
608    /// Rechecks declared inputs for successful and failed executions alike.
609    #[allow(clippy::too_many_arguments)]
610    fn inputs_match(
611        &self,
612        snapshot: &hash::Snapshot,
613        workspace: &Workspace,
614        task: &Task,
615        prepared: &PreparedTask,
616        dependencies: &BTreeMap<String, String>,
617        before: &str,
618        cancelled: &AtomicBool,
619    ) -> bool {
620        if cancelled.load(Ordering::SeqCst) {
621            return false;
622        }
623        let after = hash::fingerprint(snapshot, workspace, task, prepared, dependencies, cancelled);
624        if after.as_deref().ok() != Some(before) {
625            if task.definition.cache == Some(true) {
626                if self.reuse {
627                    qk_executor::status!(
628                        "qk: {}: not caching because inputs changed during execution",
629                        task.id
630                    );
631                } else {
632                    qk_executor::status!(
633                        "qk: {}: excluding execution from flaky results because inputs changed during execution",
634                        task.id
635                    );
636                }
637            }
638            return false;
639        }
640        true
641    }
642}
643
644/// Per-output values for dependents' keys. Disk and manifest must agree exactly,
645/// so a dependent's key is the same whether this task was restored or executed.
646pub(crate) fn file_output(content: &str, mode: u32) -> Value {
647    json!({"content": content, "mode": mode})
648}
649
650pub(crate) fn link_output(target: &str) -> Value {
651    json!({"link": target})
652}
653
654pub(crate) fn directory_output() -> Value {
655    json!("directory")
656}
657
658/// What a task's dependents are keyed on. A task that declares outputs is
659/// known to them only through those outputs, so a run that reproduces them
660/// leaves its dependents cached however its own inputs changed. A task
661/// without declared outputs may affect its dependents in ways qk cannot see,
662/// so they are keyed on its inputs.
663pub(crate) fn combine_outputs(
664    input: &str,
665    files: &BTreeMap<String, Value>,
666    declared: bool,
667) -> Result<String> {
668    let identity = if declared { "declared outputs" } else { input };
669    Ok(blake3::hash(&serde_json::to_vec(&(identity, files))?)
670        .to_hex()
671        .to_string())
672}
673
674fn outputs_fingerprint(
675    task: &Task,
676    root: &Path,
677    outputs: &paths::Outputs,
678    input: &str,
679) -> Fingerprint {
680    output_fingerprint(root, outputs, input)
681        .map_err(|error| format!("{}: cannot fingerprint outputs: {error:#}", task.id))
682}
683
684fn output_fingerprint(root: &Path, outputs: &paths::Outputs, input: &str) -> Result<String> {
685    let mut files = BTreeMap::new();
686    for entry in outputs.entries(root)? {
687        let (path, metadata) = entry?;
688        paths::safe_parents(root, &path)?;
689        let absolute = root.join(&path);
690        let value = if metadata.file_type().is_symlink() {
691            link_output(
692                std::fs::read_link(absolute)?
693                    .to_str()
694                    .context("symlink target must be UTF-8")?,
695            )
696        } else if metadata.is_file() {
697            file_output(&hash::digest_file(&absolute)?, store::mode(&metadata))
698        } else {
699            directory_output()
700        };
701        files.insert(path, value);
702    }
703    combine_outputs(input, &files, outputs.declared())
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    /// Content the store holds already is reused, not copied over again.
711    #[cfg(unix)]
712    #[test]
713    fn a_blob_the_store_holds_is_not_written_again() {
714        use std::os::unix::fs::MetadataExt;
715        let workspace = tempfile::tempdir().unwrap();
716        let root = workspace.path();
717        let cache = Cache::new(root.join(".qk/cache"));
718        cache.initialize().unwrap();
719        std::fs::write(root.join("first"), "same").unwrap();
720        std::fs::write(root.join("second"), "same").unwrap();
721        std::fs::write(root.join("other"), "different").unwrap();
722        let first = cache.put_blob(&root.join("first")).unwrap();
723        let blob = cache.root.join("blobs").join(&first);
724        let stored = std::fs::metadata(&blob).unwrap().ino();
725        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
726        assert_eq!(std::fs::metadata(&blob).unwrap().ino(), stored);
727        // A stored blob whose content no longer matches is stored anew.
728        std::fs::write(&blob, "corrupt").unwrap();
729        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
730        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
731        // So is one that cannot be read.
732        {
733            use std::os::unix::fs::PermissionsExt;
734            std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
735        }
736        assert_eq!(cache.put_blob(&root.join("second")).unwrap(), first);
737        assert_eq!(std::fs::read(&blob).unwrap(), b"same");
738        let other = cache.put_blob(&root.join("other")).unwrap();
739        assert_ne!(other, first);
740        assert_eq!(
741            std::fs::read(cache.root.join("blobs").join(other)).unwrap(),
742            b"different"
743        );
744    }
745
746    /// Complete-directory restore preserves contents, links, modes, timestamps
747    /// and fingerprints, and the subsequent hit keeps restored files in place.
748    #[test]
749    fn manifest_and_disk_output_fingerprints_agree() {
750        let workspace = tempfile::tempdir().unwrap();
751        let root = workspace.path();
752        std::fs::create_dir_all(root.join("dist/nested/empty")).unwrap();
753        std::fs::write(root.join("dist/nested/a.txt"), "a").unwrap();
754        std::fs::write(root.join("dist/b.bin"), [0, 1, 2]).unwrap();
755        std::fs::create_dir_all(root.join("bundle/empty")).unwrap();
756        std::fs::write(root.join("bundle/readonly"), "readonly").unwrap();
757        #[cfg(unix)]
758        {
759            use std::os::unix::fs::PermissionsExt;
760            let script = root.join("dist/run.sh");
761            std::fs::write(&script, "#!/bin/sh\n").unwrap();
762            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
763            std::os::unix::fs::symlink("nested/a.txt", root.join("dist/link")).unwrap();
764            std::fs::set_permissions(
765                root.join("dist/nested"),
766                std::fs::Permissions::from_mode(0o500),
767            )
768            .unwrap();
769            std::fs::set_permissions(
770                root.join("bundle/readonly"),
771                std::fs::Permissions::from_mode(0o444),
772            )
773            .unwrap();
774        }
775        let log = root.join("log");
776        std::fs::write(&log, "").unwrap();
777
778        let cache = Cache::new(root.join(".qk/cache"));
779        cache.initialize().unwrap();
780        let outputs = paths::Outputs::from_patterns(&["dist", "bundle"]);
781        let key = "0".repeat(64);
782        let saved = cache.publish(root, &key, &outputs, &log).unwrap();
783        assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
784
785        #[cfg(unix)]
786        {
787            use std::os::unix::fs::PermissionsExt;
788            // The manifest retains the restrictive mode; cleanup needs write
789            // permission on the old directory to remove its files.
790            std::fs::set_permissions(
791                root.join("dist/nested"),
792                std::fs::Permissions::from_mode(0o755),
793            )
794            .unwrap();
795        }
796        std::fs::remove_dir_all(root.join("dist")).unwrap();
797        std::fs::remove_dir_all(root.join("bundle")).unwrap();
798        let before = std::time::SystemTime::now();
799        let restored = cache
800            .restore(
801                root,
802                "app:build",
803                &Default::default(),
804                &key,
805                &outputs,
806                &qk_executor::Display::Stream,
807                qk_executor::Shown::LocalCache,
808            )
809            .unwrap();
810        assert_eq!(restored, Some(saved.clone()));
811        assert_eq!(saved, output_fingerprint(root, &outputs, &key).unwrap());
812        assert!(root.join("dist/nested/empty").is_dir());
813        assert!(root.join("bundle/empty").is_dir());
814        assert_eq!(
815            std::fs::read_to_string(root.join("bundle/readonly")).unwrap(),
816            "readonly"
817        );
818        #[cfg(unix)]
819        {
820            assert_eq!(
821                std::fs::read_link(root.join("dist/link")).unwrap(),
822                Path::new("nested/a.txt")
823            );
824            assert_eq!(
825                store::mode(&std::fs::metadata(root.join("dist/run.sh")).unwrap()),
826                0o755
827            );
828            assert_eq!(
829                store::mode(&std::fs::metadata(root.join("dist/nested")).unwrap()),
830                0o500
831            );
832            assert_eq!(
833                store::mode(&std::fs::metadata(root.join("bundle/readonly")).unwrap()),
834                0o444
835            );
836        }
837        let path = root.join("dist/nested/a.txt");
838        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
839        assert!(modified >= before);
840        let kept = cache
841            .restore(
842                root,
843                "app:build",
844                &Default::default(),
845                &key,
846                &outputs,
847                &qk_executor::Display::Hidden,
848                qk_executor::Shown::LocalCache,
849            )
850            .unwrap();
851        assert_eq!(kept, Some(saved));
852        assert_eq!(
853            std::fs::metadata(&path).unwrap().modified().unwrap(),
854            modified
855        );
856        #[cfg(unix)]
857        {
858            use std::os::unix::fs::PermissionsExt;
859            std::fs::set_permissions(
860                root.join("dist/nested"),
861                std::fs::Permissions::from_mode(0o755),
862            )
863            .unwrap();
864        }
865    }
866}