Skip to main content

repo/
repository_materialization.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tree materialization helpers.
3
4use std::{
5    collections::BTreeSet,
6    fs,
7    num::NonZeroUsize,
8    path::{Path, PathBuf},
9    sync::atomic::{AtomicBool, Ordering},
10    thread,
11    time::Instant,
12};
13
14use objects::{
15    fs_atomic::{enrich_fs_error, is_directory_not_empty},
16    object::{Blob, ContentHash, StateId, Tree, TreeEntryTarget},
17    store::ObjectStore,
18    util::gitlink_placeholder_bytes,
19};
20use sley::ObjectId as GitObjectId;
21use tracing::{debug, instrument};
22
23use super::{HeddleError, Repository, Result};
24use crate::{
25    worktree_index::IndexEntry,
26    worktree_walk::{build_cached_entry, cache_key, validate_symlink_target},
27};
28
29/// State threaded through a single `materialize_write_ops_seeded` call.
30/// Tracks whether filesystem-level reflinks (CoW clones) are viable on
31/// this destination filesystem, so we don't pay the per-blob
32/// `clonefile`/`FICLONE` retry tax once we've seen
33/// `EXDEV`/`EOPNOTSUPP`/`ENOSYS` from one of them. Reflink and copy
34/// counts are emitted at the end for observability.
35///
36/// SAFETY/CORRECTNESS NOTE on isolated blobs:
37///   We materialize blobs via filesystem-level copy-on-write
38///   ("reflink") where supported (`clonefile(2)` on macOS APFS,
39///   `ioctl(FICLONE)` on Linux btrfs/XFS-with-reflinks/ZFS), and via
40///   `fs::copy` everywhere else. **Both paths give the destination
41///   its own inode.** A worktree file is never an alias of the
42///   canonical loose blob nor of any other worktree's file — so an
43///   agent that runs `chmod +w file && echo new > file` only mutates
44///   *that* worktree's bytes. The OS handles the divergence: with a
45///   reflink the kernel forks the underlying allocation on first
46///   write; with a real copy the dest is a separate file from the
47///   start. Either way, no shared-inode hazard exists.
48///
49///   This replaces an earlier hardlink-plus-`chmod 0o444` defense
50///   that turned out to be trivially bypassable. The hardlink made
51///   the worktree file an alias of the canonical loose blob; the
52///   read-only mode was a soft hint that any agent could (and did)
53///   undo with `chmod 644`. The new model is filesystem-level and
54///   not bypassable from userspace.
55struct MaterializationContext {
56    reflink_supported: AtomicBool,
57    reflink_count: std::sync::atomic::AtomicUsize,
58    copy_count: std::sync::atomic::AtomicUsize,
59}
60
61impl MaterializationContext {
62    fn new() -> Self {
63        Self {
64            // Optimistic: try reflink on the first blob; a single
65            // `EXDEV`/`EOPNOTSUPP` flips this for the rest of the batch.
66            reflink_supported: AtomicBool::new(true),
67            reflink_count: std::sync::atomic::AtomicUsize::new(0),
68            copy_count: std::sync::atomic::AtomicUsize::new(0),
69        }
70    }
71
72    fn reflinks_enabled(&self) -> bool {
73        self.reflink_supported.load(Ordering::Relaxed)
74    }
75
76    fn record_reflink(&self) {
77        self.reflink_count.fetch_add(1, Ordering::Relaxed);
78    }
79
80    fn record_copy(&self) {
81        self.copy_count.fetch_add(1, Ordering::Relaxed);
82    }
83
84    /// Disable reflink attempts for the rest of this materialization
85    /// after the kernel told us the filesystem won't ever clone.
86    fn disable_reflinks(&self) {
87        self.reflink_supported.store(false, Ordering::Relaxed);
88    }
89}
90
91const MATERIALIZE_PARALLEL_THRESHOLD: usize = 32;
92const MATERIALIZE_THREADS_ENV: &str = "HEDDLE_MATERIALIZE_THREADS";
93
94struct MaterializationPlan {
95    validation_root: PathBuf,
96    directories: Vec<PathBuf>,
97    directory_contexts: Vec<MaterializedDirectoryContext>,
98    leaves: Vec<WorktreeWriteOp>,
99    file_count: usize,
100    symlink_count: usize,
101}
102
103#[derive(Debug)]
104pub(crate) struct MaterializedTree {
105    pub(crate) file_entries: Vec<SeededWorktreeEntry>,
106    pub(crate) directory_contexts: Vec<MaterializedDirectoryContext>,
107}
108
109#[derive(Debug)]
110pub(crate) struct SeededWorktreeEntry {
111    pub(crate) key: String,
112    pub(crate) entry: IndexEntry,
113}
114
115#[derive(Debug)]
116pub(crate) struct MaterializedDirectoryContext {
117    pub(crate) key: String,
118    pub(crate) path: PathBuf,
119    pub(crate) child_names: Vec<String>,
120    pub(crate) tree_hash: ContentHash,
121}
122
123#[derive(Clone, Debug)]
124pub(crate) enum WorktreeWriteOp {
125    Blob {
126        path: PathBuf,
127        hash: ContentHash,
128        executable: bool,
129    },
130    Symlink {
131        path: PathBuf,
132        hash: ContentHash,
133        validation_root: PathBuf,
134    },
135    GitlinkPlaceholder {
136        path: PathBuf,
137        target: GitObjectId,
138    },
139}
140
141impl WorktreeWriteOp {
142    pub(crate) fn path(&self) -> &Path {
143        match self {
144            Self::Blob { path, .. }
145            | Self::Symlink { path, .. }
146            | Self::GitlinkPlaceholder { path, .. } => path,
147        }
148    }
149
150    pub(crate) fn hash(&self) -> ContentHash {
151        match self {
152            Self::Blob { hash, .. } | Self::Symlink { hash, .. } => *hash,
153            Self::GitlinkPlaceholder { target, .. } => {
154                Blob::new(gitlink_placeholder_bytes(target)).hash()
155            }
156        }
157    }
158
159    pub(crate) fn executable(&self) -> bool {
160        match self {
161            Self::Blob { executable, .. } => *executable,
162            Self::Symlink { .. } | Self::GitlinkPlaceholder { .. } => false,
163        }
164    }
165
166    pub(crate) fn index_kind(&self) -> crate::worktree_index::IndexEntryKind {
167        match self {
168            Self::Blob { .. } | Self::GitlinkPlaceholder { .. } => {
169                crate::worktree_index::IndexEntryKind::File
170            }
171            Self::Symlink { .. } => crate::worktree_index::IndexEntryKind::Symlink,
172        }
173    }
174}
175
176/// Result of `Repository::warm_canonical_store_for_state(s)`.
177///
178/// The reflink-first materializer can only clone from a canonical
179/// loose-uncompressed file. After `pack_objects + prune_loose_objects`
180/// (the steady state for any non-fresh repo) every blob is pack-only
181/// and `loose_blob_path` returns `None`. The warm pass walks a
182/// state's tree(s) and promotes every reachable blob in advance so
183/// the next N materializations of that state across N worktrees all
184/// hit the fast path.
185///
186/// This is the proactive twin of the lazy promotion that already
187/// fires inside `materialize_blob`. Lazy is correct on its own; warm
188/// is a latency optimization for the "I'm about to materialize this
189/// state to N worktrees" case (e.g. `heddle delegate`).
190#[derive(Debug, Default, Clone, Copy)]
191pub struct WarmCanonicalStoreStats {
192    /// Blobs we wrote to the canonical loose-uncompressed path
193    /// because they were either pack-only or compressed-loose.
194    pub promoted: usize,
195    /// Blobs that were already loose+uncompressed; no work done.
196    pub already_loose: usize,
197    /// Blobs we tried to promote but `promote_to_loose_uncompressed`
198    /// returned an error (e.g. the blob isn't in the store, or a
199    /// transient I/O failure during the atomic write). Kept
200    /// non-fatal: the lazy path will retry on materialize, and a
201    /// real corruption shows up there with a louder error.
202    pub errors: usize,
203}
204
205impl WarmCanonicalStoreStats {
206    /// Total blobs visited.
207    pub fn total(&self) -> usize {
208        self.promoted + self.already_loose + self.errors
209    }
210}
211
212impl Repository {
213    /// Promote every reachable blob from `state_id`'s tree(s) into
214    /// the canonical loose-uncompressed store, so a subsequent
215    /// `materialize_tree` (or N parallel materializations) can
216    /// reflink from the canonical store without paying the
217    /// decompress-on-first-clone tax.
218    ///
219    /// Returns counts of work done. Errors per blob are accumulated
220    /// rather than bubbled up so a single corrupt or missing object
221    /// doesn't poison the whole warm pass — the lazy path inside
222    /// `materialize_blob` will surface that loudly when it actually
223    /// matters.
224    #[instrument(skip(self), fields(state_id = %state_id))]
225    pub fn warm_canonical_store_for_state(
226        &self,
227        state_id: &StateId,
228    ) -> Result<WarmCanonicalStoreStats> {
229        self.warm_canonical_store_for_states(std::slice::from_ref(state_id))
230    }
231
232    /// Multi-state variant. Walks each state's tree once, dedupes
233    /// the union of reachable blob hashes across all of them, and
234    /// promotes them. Useful when materializing several sibling
235    /// states from the same parent in quick succession (the
236    /// `heddle delegate`-style flow).
237    #[instrument(skip(self, state_ids), fields(state_count = state_ids.len()))]
238    pub fn warm_canonical_store_for_states(
239        &self,
240        state_ids: &[StateId],
241    ) -> Result<WarmCanonicalStoreStats> {
242        let mut blob_hashes = BTreeSet::new();
243        for state_id in state_ids {
244            let state = self
245                .store
246                .get_state(state_id)?
247                .ok_or_else(|| HeddleError::NotFound(format!("state {} not in store", state_id)))?;
248            let tree = self.store.get_tree(&state.tree)?.ok_or_else(|| {
249                HeddleError::NotFound(format!("tree {} (for state {})", state.tree, state_id))
250            })?;
251            self.collect_blob_hashes(&tree, &mut blob_hashes)?;
252        }
253
254        let mut stats = WarmCanonicalStoreStats::default();
255        for hash in &blob_hashes {
256            match self.store.promote_to_loose_uncompressed(hash) {
257                Ok(true) => stats.promoted += 1,
258                Ok(false) => stats.already_loose += 1,
259                Err(err) => {
260                    debug!(
261                        ?err,
262                        hash = %hash,
263                        "promote_to_loose_uncompressed failed during warm pass"
264                    );
265                    stats.errors += 1;
266                }
267            }
268        }
269
270        debug!(
271            promoted = stats.promoted,
272            already_loose = stats.already_loose,
273            errors = stats.errors,
274            "Warm canonical store pass complete"
275        );
276
277        Ok(stats)
278    }
279
280    fn collect_blob_hashes(&self, tree: &Tree, out: &mut BTreeSet<ContentHash>) -> Result<()> {
281        for entry in tree.entries() {
282            // Symlink targets are stored as blobs too — they're
283            // small, so promotion cost is negligible, and a stored
284            // symlink is materialized via `get_blob` (not hardlink),
285            // so promoting them is technically wasted work. But
286            // skipping symlinks would mean walking the tree with
287            // the same defensive `is_symlink` guard we use in
288            // `plan_materialization`, and the cost of warming a few
289            // tiny symlink-target blobs is dwarfed by the
290            // decompress cost of even one real source file. Keep
291            // it simple: promote everything reachable.
292            match entry.target() {
293                TreeEntryTarget::Blob { hash, .. } | TreeEntryTarget::Symlink { hash } => {
294                    out.insert(*hash);
295                }
296                TreeEntryTarget::Tree { hash } => {
297                    let subtree = self
298                        .store
299                        .get_tree(hash)?
300                        .ok_or_else(|| HeddleError::NotFound(format!("tree {}", hash)))?;
301                    self.collect_blob_hashes(&subtree, out)?;
302                }
303                TreeEntryTarget::Gitlink { .. } => {}
304                // Native child-spool edge: carries no local blob/tree hash to
305                // promote (its target lives in a separate spool graph).
306                TreeEntryTarget::Spoollink { .. } => {}
307            }
308        }
309        Ok(())
310    }
311
312    /// Materialize a tree to the filesystem.
313    ///
314    /// Crate-private on purpose: this blob-keyed primitive carries no
315    /// `StateId`/audience, so it cannot apply the visibility gate. External
316    /// callers serving a *named committed state* to a checkout must go through
317    /// [`Repository::checkout_state_gated`] (gated); callers applying a
318    /// *locally-computed* tree (a merge/cherry-pick result) use
319    /// [`Repository::materialize_computed_tree`]. Keeping this one inside the
320    /// crate is what makes "every state checkout is gated" true by
321    /// construction rather than by remembering to add a check (#316 Finding 2).
322    #[instrument(skip(self, tree), fields(dir = %dir.display(), entries = tree.len()))]
323    pub(crate) fn materialize_tree(&self, tree: &Tree, dir: &Path) -> Result<()> {
324        self.materialize_tree_seeded(tree, dir).map(|_| ())
325    }
326
327    /// Materialize a *locally-computed* tree to `dir` — a merge or cherry-pick
328    /// result that is not a single named committed state and so carries no
329    /// audience to gate against. The operator already holds every byte the
330    /// computation combined; this is a working-tree write, not a serve to an
331    /// audience. For serving a named state to a checkout, use
332    /// [`Repository::checkout_state_gated`] instead.
333    pub fn materialize_computed_tree(&self, tree: &Tree, dir: &Path) -> Result<()> {
334        self.materialize_tree(tree, dir)
335    }
336
337    pub(crate) fn materialize_tree_seeded(
338        &self,
339        tree: &Tree,
340        dir: &Path,
341    ) -> Result<MaterializedTree> {
342        let plan_start = Instant::now();
343        let mut plan = MaterializationPlan {
344            validation_root: dir.to_path_buf(),
345            directories: Vec::new(),
346            directory_contexts: Vec::new(),
347            leaves: Vec::new(),
348            file_count: 0,
349            symlink_count: 0,
350        };
351        self.plan_materialization(tree, Path::new(""), dir, &mut plan)?;
352        let plan_duration_ms = plan_start.elapsed().as_millis();
353
354        let execution_start = Instant::now();
355        let requested_threads = requested_materialization_threads();
356        fs::create_dir_all(dir)
357            .map_err(|e| HeddleError::Io(enrich_fs_error(dir, "creating", e)))?;
358        for directory in &plan.directories {
359            fs::create_dir_all(directory)
360                .map_err(|e| HeddleError::Io(enrich_fs_error(directory, "creating", e)))?;
361        }
362
363        let (worker_count, file_entries) = self.materialize_write_ops_seeded(&plan.leaves)?;
364
365        debug!(
366            directories = plan.directories.len(),
367            files = plan.file_count,
368            symlinks = plan.symlink_count,
369            workers = worker_count,
370            requested_workers = requested_threads.map(NonZeroUsize::get),
371            plan_duration_ms,
372            execution_duration_ms = execution_start.elapsed().as_millis(),
373            parallel = worker_count > 1,
374            "Tree materialization complete"
375        );
376
377        Ok(MaterializedTree {
378            file_entries,
379            directory_contexts: plan.directory_contexts,
380        })
381    }
382
383    fn plan_materialization(
384        &self,
385        tree: &Tree,
386        rel_dir: &Path,
387        dir: &Path,
388        plan: &mut MaterializationPlan,
389    ) -> Result<()> {
390        plan.directory_contexts.push(MaterializedDirectoryContext {
391            key: cache_key(rel_dir),
392            path: dir.to_path_buf(),
393            child_names: tree
394                .entries()
395                .iter()
396                .map(|entry| entry.name().to_string())
397                .collect(),
398            tree_hash: tree.hash(),
399        });
400
401        for entry in tree.entries() {
402            let path = dir.join(entry.name());
403            let rel_path = rel_dir.join(entry.name());
404            match entry.target() {
405                TreeEntryTarget::Blob { hash, executable } => {
406                    plan.file_count += 1;
407                    plan.leaves.push(WorktreeWriteOp::Blob {
408                        path,
409                        hash: *hash,
410                        executable: *executable,
411                    });
412                }
413                TreeEntryTarget::Tree { hash } => {
414                    let subtree = self
415                        .store
416                        .get_tree(hash)?
417                        .ok_or_else(|| HeddleError::NotFound(format!("tree {}", hash)))?;
418                    plan.directories.push(path.clone());
419                    self.plan_materialization(&subtree, &rel_path, &path, plan)?;
420                }
421                TreeEntryTarget::Symlink { hash } => {
422                    plan.symlink_count += 1;
423                    plan.leaves.push(WorktreeWriteOp::Symlink {
424                        path,
425                        hash: *hash,
426                        validation_root: plan.validation_root.clone(),
427                    });
428                }
429                TreeEntryTarget::Gitlink { target } => {
430                    plan.file_count += 1;
431                    plan.leaves.push(WorktreeWriteOp::GitlinkPlaceholder {
432                        path,
433                        target: *target,
434                    });
435                }
436                // Native child-spool edge: not materialized to the worktree
437                // in this phase, so it contributes no write op.
438                TreeEntryTarget::Spoollink { .. } => {}
439            }
440        }
441
442        Ok(())
443    }
444
445    pub(crate) fn materialize_write_ops(&self, writes: &[WorktreeWriteOp]) -> Result<usize> {
446        self.materialize_write_ops_seeded(writes)
447            .map(|(worker_count, _)| worker_count)
448    }
449
450    pub(crate) fn materialize_write_ops_seeded(
451        &self,
452        writes: &[WorktreeWriteOp],
453    ) -> Result<(usize, Vec<SeededWorktreeEntry>)> {
454        prepare_parent_directories(writes)?;
455
456        let requested_threads = requested_materialization_threads();
457        let worker_count = materialization_worker_count(writes.len(), requested_threads);
458
459        // No probe — the per-blob path tries `clonefile`/FICLONE
460        // first and flips a batch-wide flag on the first
461        // `EXDEV`/`EOPNOTSUPP`/`ENOSYS` verdict, so the rest of the
462        // batch falls straight through to `fs::copy` without paying
463        // the syscall tax. The cost of one failed reflink call on a
464        // non-CoW filesystem is one syscall; it's not worth a
465        // dedicated probe.
466        let context = MaterializationContext::new();
467
468        // Drive the repository's live progress handle: one `inc` per
469        // materialized write op. On the common null handle this is a relaxed
470        // atomic add plus a predicted-not-taken branch (no render). When a CLI
471        // command has installed a real sink, this paints a live "checking out
472        // files (n/total)" line. The handle is a cheap `Arc` clone, so it
473        // survives the `thread::scope` seam below — each worker gets its own
474        // clone and increments the shared atomic counter.
475        let progress = self.progress();
476        progress.set_total(writes.len());
477
478        let result = if worker_count <= 1 {
479            let mut seeded = Vec::with_capacity(writes.len());
480            for write in writes {
481                seeded.push(self.materialize_write_op(write, &context)?);
482                progress.inc(1);
483            }
484            Ok((worker_count, seeded))
485        } else {
486            let chunk_size = writes.len().div_ceil(worker_count);
487            let seeded = thread::scope(|scope| -> Result<Vec<SeededWorktreeEntry>> {
488                let mut workers = Vec::new();
489                let context = &context;
490                for chunk in writes.chunks(chunk_size) {
491                    let progress = progress.clone();
492                    workers.push(scope.spawn(move || -> Result<Vec<SeededWorktreeEntry>> {
493                        let mut seeded = Vec::with_capacity(chunk.len());
494                        for write in chunk {
495                            seeded.push(self.materialize_write_op(write, context)?);
496                            progress.inc(1);
497                        }
498                        Ok(seeded)
499                    }));
500                }
501
502                let mut seeded = Vec::with_capacity(writes.len());
503                for worker in workers {
504                    seeded.extend(worker.join().map_err(|_| {
505                        HeddleError::Config("materialization worker panicked".to_string())
506                    })??);
507                }
508
509                Ok(seeded)
510            })?;
511
512            Ok((worker_count, seeded))
513        };
514
515        let reflinks = context.reflink_count.load(Ordering::Relaxed);
516        let copies = context.copy_count.load(Ordering::Relaxed);
517        if reflinks + copies > 0 {
518            debug!(
519                reflinks,
520                copies,
521                reflinks_enabled = context.reflinks_enabled(),
522                "Materialized blobs"
523            );
524        }
525
526        result
527    }
528
529    fn materialize_write_op(
530        &self,
531        write: &WorktreeWriteOp,
532        context: &MaterializationContext,
533    ) -> Result<SeededWorktreeEntry> {
534        match write {
535            WorktreeWriteOp::Blob {
536                path,
537                hash,
538                executable,
539            } => {
540                self.materialize_blob(path, hash, *executable, context)?;
541            }
542            WorktreeWriteOp::Symlink {
543                path,
544                hash,
545                validation_root,
546            } => {
547                let blob = self
548                    .store
549                    .get_blob(hash)?
550                    .ok_or_else(|| HeddleError::NotFound(format!("blob {}", hash)))?;
551                #[cfg(unix)]
552                {
553                    let target = std::str::from_utf8(blob.content()).map_err(|_| {
554                        HeddleError::InvalidObject("invalid symlink target".to_string())
555                    })?;
556                    let target_path = Path::new(target);
557                    let symlink_dir = path.parent().unwrap_or(validation_root);
558                    if !validate_symlink_target(validation_root, symlink_dir, target_path) {
559                        return Err(HeddleError::InvalidSymlinkTarget {
560                            path: path
561                                .strip_prefix(validation_root)
562                                .unwrap_or(path)
563                                .to_path_buf(),
564                            target: target_path.to_path_buf(),
565                        });
566                    }
567                    remove_materialized_leaf(path)?;
568                    std::os::unix::fs::symlink(target, path)?;
569                }
570                // Windows symlink materialization is unimplemented;
571                // the projection layer (ProjFS) handles symlinks
572                // through reparse points instead of native symlinks,
573                // and `heddle materialize` on Windows isn't part of
574                // the daily-use mount story. Suppress the unused
575                // bindings rather than ship a half-implementation.
576                #[cfg(not(unix))]
577                {
578                    let _ = (blob, path, validation_root);
579                }
580            }
581            WorktreeWriteOp::GitlinkPlaceholder { path, target } => {
582                remove_materialized_leaf(path)?;
583                fs::write(path, gitlink_placeholder_bytes(target))
584                    .map_err(|err| HeddleError::Io(enrich_fs_error(path, "writing", err)))?;
585            }
586        }
587
588        let metadata = fs::symlink_metadata(write.path())?;
589        let entry = build_cached_entry(
590            write.hash(),
591            &metadata,
592            write.executable(),
593            write.index_kind(),
594        )
595        .ok_or_else(|| {
596            HeddleError::Config(format!(
597                "seed materialized worktree entry for {}",
598                write.path().display()
599            ))
600        })?;
601
602        Ok(SeededWorktreeEntry {
603            key: cache_key(
604                write
605                    .path()
606                    .strip_prefix(self.root())
607                    .unwrap_or(write.path()),
608            ),
609            entry,
610        })
611    }
612
613    /// Materialize a single blob into the worktree.
614    ///
615    /// Strategy (in order):
616    ///   1. Filesystem reflink (`clonefile(2)` on macOS APFS,
617    ///      `ioctl(FICLONE)` on Linux btrfs/XFS/ZFS) from the
618    ///      canonical loose-uncompressed blob into `dest`. The dest
619    ///      gets its own inode; the kernel forks the underlying
620    ///      allocation on first write to either side. On reflink-
621    ///      capable filesystems this preserves the storage win
622    ///      (~1× disk for N worktrees of the same state) without
623    ///      any shared-inode hazard.
624    ///   2. Lazy promotion + retry. If the canonical loose blob
625    ///      isn't on disk (e.g. post-`pack_objects + prune_loose`),
626    ///      promote it once and retry the reflink.
627    ///   3. `fs::write` of the decompressed blob bytes. Used when the
628    ///      filesystem doesn't support reflinks at all
629    ///      (`EXDEV`/`EOPNOTSUPP`/`ENOSYS`), in which case we flip a
630    ///      batch-wide flag and stop trying for the rest of this
631    ///      materialization.
632    ///
633    /// Permission bits are normalized to `0o644` (or `0o755` for
634    /// executables) on every path. There is no read-only-mode
635    /// defense — agents can `chmod +w` and overwrite freely; the
636    /// filesystem-level isolation is what keeps sibling worktrees
637    /// safe.
638    fn materialize_blob(
639        &self,
640        dest: &Path,
641        hash: &ContentHash,
642        executable: bool,
643        context: &MaterializationContext,
644    ) -> Result<()> {
645        // Redaction short-circuit: if any redaction declares this
646        // blob's bytes off-limits, materialize the human-readable
647        // stub instead. The stub names who redacted it, when, why,
648        // and whether the bytes have already been purged. Safe to
649        // include in worktrees, semantic diffs, and Git projection
650        // exports (which themselves call through `materialize_tree`).
651        // Errors loading the redactions store are propagated rather
652        // than swallowed — a partial redaction read shouldn't
653        // silently leak the original bytes.
654        if let Some(stub) = self
655            .redaction_stub_for_blob(hash)
656            .map_err(|err| HeddleError::Config(format!("redaction lookup failed: {err}")))?
657        {
658            let _ = fs::remove_file(dest);
659            fs::write(dest, stub.as_bytes())?;
660            // Stubs are never executable — overwriting a tracked
661            // executable with a stub correctly drops the +x bit so
662            // operators don't accidentally run the redaction notice.
663            set_file_mode(dest, false)?;
664            // The redaction stub path doesn't reflink/clone — count
665            // it as a copy so observability stays accurate.
666            context.record_copy();
667            let _ = executable;
668            return Ok(());
669        }
670
671        if context.reflinks_enabled() {
672            // First-pass: blob is already loose+uncompressed.
673            if let Some(source) = self.store.loose_blob_path(hash)
674                && self.try_clone(&source, dest, executable, context)?
675            {
676                return Ok(());
677            }
678            // Second-pass: lazy promotion. Pack-resident or
679            // compressed-loose blob — promote it to the canonical
680            // uncompressed-loose path, then retry the reflink.
681            // Without this step `pack_objects + prune_loose_objects`
682            // permanently degrades materialize to slow `fs::write`.
683            //
684            // The first materialize of any given hash pays
685            // decompress + atomic write, but every subsequent one
686            // (other worktrees, future `goto`s) is a single
687            // `clonefile`/FICLONE. Net win for any N > 1
688            // materializations on a CoW filesystem.
689            match self.store.promote_to_loose_uncompressed(hash) {
690                Ok(_) => {
691                    if let Some(source) = self.store.loose_blob_path(hash)
692                        && self.try_clone(&source, dest, executable, context)?
693                    {
694                        return Ok(());
695                    }
696                }
697                Err(err) => {
698                    debug!(
699                        ?err,
700                        hash = %hash,
701                        "promote_to_loose_uncompressed failed; falling back to fs::write"
702                    );
703                }
704            }
705        }
706
707        let blob = self
708            .store
709            .get_blob(hash)?
710            .ok_or_else(|| HeddleError::NotFound(format!("blob {}", hash)))?;
711        // Remove any stale dest before writing. We don't share inodes
712        // with the canonical store anymore (no hardlinks), but a
713        // previous `goto` could still have left an unrelated file
714        // here that we should overwrite cleanly.
715        let _ = fs::remove_file(dest);
716        fs::write(dest, blob.content())?;
717        set_file_mode(dest, executable)?;
718        context.record_copy();
719        Ok(())
720    }
721
722    /// One clone attempt: returns `Ok(true)` on a successful reflink,
723    /// `Ok(false)` when the caller should fall back to the in-memory
724    /// `fs::write` path. The two `Ok(false)` causes are deliberately
725    /// handled differently:
726    ///
727    /// * `ReflinkOutcome::Unsupported` (`EXDEV`/`EOPNOTSUPP`/`ENOSYS`/
728    ///   `EINVAL`) — a filesystem-capability verdict, so the context is
729    ///   flipped (`disable_reflinks`) and the rest of the batch skips
730    ///   straight to `fs::write` without paying the failed-syscall tax.
731    /// * `ReflinkOutcome::SourceVanished` — the loose mirror was pruned
732    ///   mid-flight. A per-blob race, so we fall back for this blob only
733    ///   and leave reflinks ENABLED for the rest of the batch
734    ///   (heddle#571 r3).
735    ///
736    /// Genuine I/O errors bubble up (attributed to the offending side by
737    /// `classify_clone_failure`).
738    fn try_clone(
739        &self,
740        source: &Path,
741        dest: &Path,
742        executable: bool,
743        context: &MaterializationContext,
744    ) -> Result<bool> {
745        // `clonefile`/`FICLONE` fail if `dest` already exists, so
746        // make sure we're starting from a clean slate. A previous
747        // `goto` could have left a regular file or a stale link here.
748        let _ = fs::remove_file(dest);
749        // Reflink is a pure optimization; correctness must never depend on the
750        // loose source still being present at the syscall. `loose_blob_path`
751        // verified it existed, but a concurrent prune or a torn NoSync promote
752        // can remove it before we get here. On macOS, handing `clonefile(2)` a
753        // missing source surfaces as ENOENT — which `reflink_unsupported`
754        // deliberately does NOT swallow (ENOENT means a genuinely missing file,
755        // not "reflink unsupported") — so without this guard the whole
756        // `heddle start` hard-fails (heddle#571). Fall back to the bytes-write
757        // path for THIS blob only by returning `Ok(false)`; do NOT
758        // `disable_reflinks`, so sibling blobs whose mirrors are intact still
759        // clone. The caller (`materialize_blob`) then writes the blob from its
760        // decompressed bytes via `get_blob` — the same path Linux's
761        // ext4-EOPNOTSUPP short-circuit already takes.
762        if !source.exists() {
763            debug!(
764                source = %source.display(),
765                dest = %dest.display(),
766                "loose reflink source missing before clone; falling back to bytes-write for this blob"
767            );
768            return Ok(false);
769        }
770        use objects::fs_clone::ReflinkOutcome;
771        match objects::fs_clone::try_reflink(source, dest) {
772            Ok(ReflinkOutcome::Cloned) => {
773                set_file_mode(dest, executable)?;
774                context.record_reflink();
775                Ok(true)
776            }
777            Ok(ReflinkOutcome::Unsupported) => {
778                // Filesystem doesn't support reflinks. Disable for
779                // the rest of the batch and let the caller fall
780                // through to `fs::write` (which decompresses from
781                // memory rather than reading the loose file twice).
782                debug!(
783                    source = %source.display(),
784                    dest = %dest.display(),
785                    "reflink not supported on this filesystem; switching batch to fs::write fallback"
786                );
787                context.disable_reflinks();
788                Ok(false)
789            }
790            Ok(ReflinkOutcome::SourceVanished) => {
791                // [heddle#571 r3] The loose mirror was pruned out from under
792                // us between the pre-check and the clone. That's a per-blob
793                // race, NOT a filesystem-capability verdict — so degrade to
794                // the bytes-write fallback for THIS blob only and DO NOT
795                // `disable_reflinks`. Sibling blobs whose mirrors are intact
796                // still get the CoW win. A blob genuinely absent from the
797                // store still errors loudly when the bytes-write fallback's
798                // `get_blob` can't find it.
799                debug!(
800                    source = %source.display(),
801                    dest = %dest.display(),
802                    "loose reflink source vanished before clone; falling back to bytes-write for this blob (reflinks stay enabled batch-wide)"
803                );
804                Ok(false)
805            }
806            Err(err) => {
807                debug!(
808                    ?err,
809                    source = %source.display(),
810                    dest = %dest.display(),
811                    "reflink failed with I/O error"
812                );
813                match classify_clone_failure(source, dest, &err) {
814                    // [heddle#571 r2, finding 2] Source vanished mid-flight
815                    // (TOCTOU): degrade to the bytes-write fallback for this
816                    // blob rather than hard-erroring. See `classify_clone_failure`.
817                    None => {
818                        debug!(
819                            source = %source.display(),
820                            dest = %dest.display(),
821                            "loose reflink source vanished between pre-check and clone syscall; falling back to bytes-write for this blob"
822                        );
823                        Ok(false)
824                    }
825                    // [heddle#571 r2, finding 3] Attribute to the offending side.
826                    Some((offender, action)) => {
827                        Err(HeddleError::Io(enrich_fs_error(offender, action, err)))
828                    }
829                }
830            }
831        }
832    }
833}
834
835/// Classify a clone-syscall (`clonefile`/`FICLONE`) I/O failure into either a
836/// bytes-write fallback or an enriched, correctly-attributed error.
837///
838/// * `None` — the loose source vanished between the `!source.exists()`
839///   pre-check and the syscall (concurrent prune / torn NoSync promote), so the
840///   syscall raised `ENOENT`. The caller should degrade to the bytes-write
841///   fallback (`Ok(false)`), which re-reads the authoritative bytes from the
842///   store; a blob genuinely absent from the store still errors there with its
843///   hash. This closes the TOCTOU race rather than blindly masking `ENOENT`
844///   (heddle#571 r2, finding 2).
845/// * `Some((path, action))` — a real failure to surface, attributed to the side
846///   that actually failed. The source survived (the vanished case returned
847///   `None`), so a create/permission/read-only/no-space failure is the
848///   DESTINATION's (read-only checkout dir, unwritable target). We blame the
849///   source only when it is the unreadable party (probed directly). Reporting a
850///   dest-side failure against the blob path would point the user at the wrong
851///   file (heddle#571 r2, finding 3).
852fn classify_clone_failure<'a>(
853    source: &'a Path,
854    dest: &'a Path,
855    err: &std::io::Error,
856) -> Option<(&'a Path, &'static str)> {
857    if err.kind() == std::io::ErrorKind::NotFound && !source.exists() {
858        return None;
859    }
860    if fs::File::open(source).is_ok() {
861        Some((dest, "reflinking into"))
862    } else {
863        Some((source, "reflinking"))
864    }
865}
866
867fn prepare_parent_directories(writes: &[WorktreeWriteOp]) -> Result<()> {
868    let mut parents = BTreeSet::new();
869    for write in writes {
870        if let Some(parent) = write.path().parent() {
871            parents.insert(parent.to_path_buf());
872        }
873    }
874
875    for parent in parents {
876        fs::create_dir_all(&parent)
877            .map_err(|e| HeddleError::Io(enrich_fs_error(&parent, "creating", e)))?;
878    }
879
880    Ok(())
881}
882
883/// Best-effort removal of a leaf path before replacing it with another
884/// materialized leaf, such as a symlink on Unix or a gitlink placeholder
885/// on every platform.
886///
887/// Tolerates `ENOTEMPTY` from `remove_dir` for the same reason the
888/// incremental apply path does: untracked or explicitly ignored siblings
889/// may still occupy the directory after the planner has cleaned out the
890/// tracked children. Without this
891/// tolerance, a `goto` over a real-world worktree that mutates a
892/// tracked directory into a symlink aborts mid-apply with `os error
893/// 66`, leaving HEAD stuck and disk diverged from state.
894///
895fn remove_materialized_leaf(path: &Path) -> Result<()> {
896    match fs::symlink_metadata(path) {
897        Ok(metadata) => {
898            let file_type = metadata.file_type();
899            if file_type.is_symlink() || file_type.is_file() {
900                fs::remove_file(path)
901                    .map_err(|e| HeddleError::Io(enrich_fs_error(path, "removing", e)))?;
902            } else if file_type.is_dir() {
903                match fs::remove_dir(path) {
904                    Ok(()) => {}
905                    Err(error) if is_directory_not_empty(&error) => {}
906                    Err(error) => {
907                        return Err(HeddleError::Io(enrich_fs_error(path, "removing", error)));
908                    }
909                }
910            }
911            Ok(())
912        }
913        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
914        Err(error) => Err(HeddleError::Io(enrich_fs_error(path, "inspecting", error))),
915    }
916}
917
918fn set_file_mode(path: &Path, executable: bool) -> Result<()> {
919    #[cfg(unix)]
920    {
921        use std::os::unix::fs::PermissionsExt;
922
923        // `OpenOptions::mode(0o644)` is still filtered by the
924        // process umask, and reflink/copy paths preserve the source
925        // mode. Normalize the worktree-visible file mode here so
926        // materialized checkouts do not inherit a restrictive object
927        // store mode such as `0o600`.
928        let mode = if executable { 0o755 } else { 0o644 };
929        fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
930    }
931    #[cfg(not(unix))]
932    {
933        let _ = (path, executable);
934    }
935    Ok(())
936}
937
938fn materialization_worker_count(
939    operation_count: usize,
940    requested_threads: Option<NonZeroUsize>,
941) -> usize {
942    if operation_count < MATERIALIZE_PARALLEL_THRESHOLD {
943        return 1;
944    }
945
946    let available = requested_threads.unwrap_or_else(default_materialization_threads);
947    available.get().min(operation_count.max(1))
948}
949
950fn default_materialization_threads() -> NonZeroUsize {
951    std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN)
952}
953
954fn requested_materialization_threads() -> Option<NonZeroUsize> {
955    let raw = std::env::var(MATERIALIZE_THREADS_ENV).ok()?;
956    raw.trim().parse::<usize>().ok().and_then(NonZeroUsize::new)
957}
958
959#[cfg(test)]
960mod tests {
961    use std::{num::NonZeroUsize, path::PathBuf};
962
963    use objects::{fs_clone::filesystem_supports_reflink, object::Blob, store::ObjectStore};
964    use tempfile::TempDir;
965
966    use super::{
967        MaterializationContext, Repository, WorktreeWriteOp, classify_clone_failure,
968        materialization_worker_count, remove_materialized_leaf,
969    };
970
971    /// The generic [`Progress`](objects::Progress) handle installed on a
972    /// repository must survive the `thread::scope` parallel-materialization seam:
973    /// a single shared `Arc` handle, cloned into each worker, whose atomic
974    /// counter ends at exactly the file count no matter how the work was split
975    /// across threads. This is the load-bearing acceptance criterion for #844.
976    #[test]
977    fn progress_handle_survives_parallel_materialization_seam() {
978        use std::sync::{
979            Arc,
980            atomic::{AtomicUsize, Ordering},
981        };
982
983        use objects::{Progress, ProgressSnapshot, Sink};
984
985        /// Active sink that just counts render calls, proving the active path
986        /// ran concurrently across workers without panicking.
987        struct CountingSink(Arc<AtomicUsize>);
988        impl Sink for CountingSink {
989            fn render(&self, _snap: ProgressSnapshot) {
990                self.0.fetch_add(1, Ordering::Relaxed);
991            }
992        }
993
994        let temp_dir = TempDir::new().unwrap();
995        let repo = Repository::init_default(temp_dir.path()).unwrap();
996
997        // Well over MATERIALIZE_PARALLEL_THRESHOLD (32): on any multi-core host
998        // this exercises the `thread::scope` branch; on a single core it
999        // degrades to the serial branch and the counter assertion still holds.
1000        let file_count = 256usize;
1001        for i in 0..file_count {
1002            std::fs::write(
1003                temp_dir.path().join(format!("seam-{i:04}.txt")),
1004                format!("seam payload {i} {}", "y".repeat(48)),
1005            )
1006            .unwrap();
1007        }
1008        let state = repo.snapshot(Some("seam".to_string()), None).unwrap();
1009        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1010
1011        let render_count = Arc::new(AtomicUsize::new(0));
1012        let progress = Progress::with_sink(Box::new(CountingSink(render_count.clone())));
1013        repo.set_progress(progress.clone());
1014
1015        let dest = temp_dir.path().join("materialized");
1016        repo.materialize_tree(&tree, &dest).unwrap();
1017
1018        // Every write op incremented the shared counter exactly once, across
1019        // however many worker threads split the batch.
1020        assert_eq!(
1021            progress.done(),
1022            file_count,
1023            "shared progress counter must equal the file count after the parallel seam"
1024        );
1025        assert_eq!(
1026            progress.total(),
1027            file_count,
1028            "total set from the write batch"
1029        );
1030        // The active handle rendered at least once through the seam (throttled,
1031        // so not once-per-file) — proving the sink is driven across threads.
1032        assert!(
1033            render_count.load(Ordering::Relaxed) > 0,
1034            "active sink should have rendered during materialization"
1035        );
1036    }
1037
1038    /// heddle#571 (round 2, finding 2): a clone syscall that raises ENOENT
1039    /// because the loose source vanished AFTER the pre-check (TOCTOU) must
1040    /// degrade to the bytes-write fallback (`None`), not hard-error.
1041    #[test]
1042    fn classify_clone_failure_vanished_source_falls_back() {
1043        let temp = TempDir::new().unwrap();
1044        let source = temp.path().join("gone.blob");
1045        let dest = temp.path().join("checkout/file");
1046        assert!(!source.exists());
1047
1048        let enoent = std::io::Error::from(std::io::ErrorKind::NotFound);
1049        assert!(
1050            classify_clone_failure(&source, &dest, &enoent).is_none(),
1051            "a vanished-source ENOENT must signal the bytes-write fallback"
1052        );
1053    }
1054
1055    /// heddle#571 (round 2, finding 3): when the source is still present and
1056    /// readable, the clone failed on the DESTINATION side — attribute the error
1057    /// to the dest (checkout) path, not the blob.
1058    #[test]
1059    fn classify_clone_failure_present_source_blames_dest() {
1060        let temp = TempDir::new().unwrap();
1061        let source = temp.path().join("present.blob");
1062        std::fs::write(&source, b"bytes").unwrap();
1063        let dest = temp.path().join("readonly-checkout/file");
1064
1065        // A dest-side failure shape (e.g. read-only checkout dir).
1066        let erofs = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
1067        let attributed = classify_clone_failure(&source, &dest, &erofs);
1068        assert_eq!(
1069            attributed,
1070            Some((dest.as_path(), "reflinking into")),
1071            "a failure with the source still readable must be attributed to dest"
1072        );
1073    }
1074
1075    /// heddle#571 (round 2, finding 3): a non-ENOENT failure whose source is no
1076    /// longer openable is attributed to the SOURCE path (it is the unreadable
1077    /// party), not the destination.
1078    #[test]
1079    fn classify_clone_failure_unreadable_source_blames_source() {
1080        let temp = TempDir::new().unwrap();
1081        let source = temp.path().join("missing.blob"); // not created → unopenable
1082        let dest = temp.path().join("file");
1083
1084        // Not ENOENT (so the vanished-source fast path doesn't fire), but the
1085        // source can't be opened → blame the source.
1086        let other = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
1087        assert_eq!(
1088            classify_clone_failure(&source, &dest, &other),
1089            Some((source.as_path(), "reflinking")),
1090            "an unreadable source must be attributed to the source path"
1091        );
1092    }
1093
1094    /// heddle#571 (round 3): a vanished loose source is a per-blob race, not a
1095    /// filesystem-capability verdict — `try_clone` must degrade to the
1096    /// bytes-write fallback for that blob WITHOUT flipping the batch-wide
1097    /// `reflink_supported` flag. Previously the helper's pre-check returned a
1098    /// bare `Ok(false)`, indistinguishable from "filesystem can't reflink", so
1099    /// one concurrently-pruned mirror needlessly disabled reflinks (forcing
1100    /// copy-writes) for every remaining blob in the batch.
1101    #[test]
1102    fn try_clone_vanished_source_keeps_batch_reflinks_enabled() {
1103        let temp = TempDir::new().unwrap();
1104        let repo = Repository::init_default(temp.path()).unwrap();
1105        let context = MaterializationContext::new();
1106        assert!(context.reflinks_enabled(), "context starts optimistic");
1107
1108        let missing = temp.path().join("pruned.blob");
1109        let dest = temp.path().join("wt/out.txt");
1110        assert!(!missing.exists());
1111
1112        let cloned = repo
1113            .try_clone(&missing, &dest, false, &context)
1114            .expect("a vanished source must fall back, not error");
1115        assert!(!cloned, "a vanished source cannot have been reflinked");
1116        assert!(
1117            context.reflinks_enabled(),
1118            "a vanished source must NOT disable reflinks for the rest of the batch"
1119        );
1120    }
1121
1122    /// Regression: `remove_materialized_leaf` must tolerate `ENOTEMPTY` on
1123    /// the directory branch, mirroring `remove_existing_path` in the
1124    /// incremental apply path. Both tolerances are needed because the
1125    /// apply planner only removes tracked descendants — when the planner asks
1126    /// the materializer to clear a directory whose tracked children are gone
1127    /// but whose untracked or explicitly ignored children remain, `remove_dir` errors
1128    /// with `os error 66` (macOS/BSD) / `39` (Linux). Pre-fix the
1129    /// materialization branch propagated that error and aborted apply
1130    /// mid-walk, leaving HEAD stuck and disk diverged from state.
1131    #[test]
1132    fn remove_materialized_leaf_tolerates_directory_not_empty() {
1133        let temp = TempDir::new().unwrap();
1134        let dir = temp.path().join("web");
1135        std::fs::create_dir_all(dir.join("node_modules/lodash")).unwrap();
1136        std::fs::write(dir.join("node_modules/lodash/index.js"), "ignored").unwrap();
1137
1138        // Pre-fix this would propagate ENOTEMPTY; post-fix it returns Ok
1139        // and leaves the directory (with its ignored content) on disk.
1140        remove_materialized_leaf(&dir).expect("must tolerate ENOTEMPTY");
1141        assert!(
1142            dir.join("node_modules/lodash/index.js").exists(),
1143            "ignored content must survive the tolerated removal"
1144        );
1145    }
1146
1147    /// Regression: empty directories still get cleaned up (the common
1148    /// case). The `ENOTEMPTY` tolerance must not regress the happy path.
1149    #[test]
1150    fn remove_materialized_leaf_removes_empty_directory() {
1151        let temp = TempDir::new().unwrap();
1152        let dir = temp.path().join("emptydir");
1153        std::fs::create_dir(&dir).unwrap();
1154
1155        remove_materialized_leaf(&dir).expect("must remove empty dir");
1156        assert!(!dir.exists(), "empty directory must be removed");
1157    }
1158
1159    /// Regression: missing paths are a no-op (NotFound), not an error.
1160    #[test]
1161    fn remove_materialized_leaf_is_noop_for_missing_path() {
1162        let temp = TempDir::new().unwrap();
1163        remove_materialized_leaf(&temp.path().join("does-not-exist"))
1164            .expect("missing path must be a no-op");
1165    }
1166
1167    /// Regression: regular files are still removed (the common symlink-
1168    /// replacement case where the existing leaf was a tracked file).
1169    #[test]
1170    fn remove_materialized_leaf_removes_regular_file() {
1171        let temp = TempDir::new().unwrap();
1172        let file = temp.path().join("a.txt");
1173        std::fs::write(&file, "content").unwrap();
1174
1175        remove_materialized_leaf(&file).expect("must remove regular file");
1176        assert!(!file.exists(), "regular file must be removed");
1177    }
1178
1179    #[test]
1180    fn materialization_parallelism_stays_sequential_for_small_workloads() {
1181        assert_eq!(materialization_worker_count(31, Some(NonZeroUsize::MIN)), 1);
1182    }
1183
1184    #[test]
1185    fn materialization_parallelism_respects_requested_thread_cap() {
1186        assert_eq!(materialization_worker_count(128, NonZeroUsize::new(4)), 4);
1187    }
1188
1189    #[test]
1190    fn materialize_write_ops_prepares_missing_parent_directories() {
1191        let temp_dir = TempDir::new().unwrap();
1192        let repo = Repository::init_default(temp_dir.path()).unwrap();
1193
1194        let blob = Blob::from("cold pull payload");
1195        let hash = repo.store().put_blob(&blob).unwrap();
1196        let file_path = temp_dir.path().join("nested/deep/file.txt");
1197
1198        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1199            path: file_path.clone(),
1200            hash,
1201            executable: false,
1202        }])
1203        .unwrap();
1204
1205        assert_eq!(
1206            std::fs::read_to_string(&file_path).unwrap(),
1207            "cold pull payload"
1208        );
1209    }
1210
1211    /// Materialized blobs must be writable by default. The
1212    /// previous hardlink+chmod-0o444 approach was a footgun:
1213    /// `chmod 644` then in-place write would mutate the canonical
1214    /// store inode, corrupting every other worktree. The fix is
1215    /// filesystem-level CoW (or full copy), so each worktree gets
1216    /// its own inode and a normal `0o644`/`0o755` mode.
1217    #[test]
1218    #[cfg(unix)]
1219    fn materialized_blob_uses_normal_writable_mode() {
1220        use std::os::unix::fs::PermissionsExt;
1221
1222        let temp_dir = TempDir::new().unwrap();
1223        let repo = Repository::init_default(temp_dir.path()).unwrap();
1224
1225        let blob = Blob::from("normal mode payload");
1226        let hash = repo.store().put_blob(&blob).unwrap();
1227        let regular = temp_dir.path().join("worktree/file.txt");
1228        let exec = temp_dir.path().join("worktree/run.sh");
1229
1230        repo.materialize_write_ops(&[
1231            WorktreeWriteOp::Blob {
1232                path: regular.clone(),
1233                hash,
1234                executable: false,
1235            },
1236            WorktreeWriteOp::Blob {
1237                path: exec.clone(),
1238                hash,
1239                executable: true,
1240            },
1241        ])
1242        .unwrap();
1243
1244        let regular_mode = std::fs::metadata(&regular).unwrap().permissions().mode() & 0o777;
1245        let exec_mode = std::fs::metadata(&exec).unwrap().permissions().mode() & 0o777;
1246        assert_eq!(
1247            regular_mode, 0o644,
1248            "regular blob must be 0o644 (got 0o{:o})",
1249            regular_mode
1250        );
1251        assert_eq!(
1252            exec_mode, 0o755,
1253            "executable blob must be 0o755 (got 0o{:o})",
1254            exec_mode
1255        );
1256
1257        // Sanity: a plain in-place write on the materialized file
1258        // must succeed (no chmod gymnastics required).
1259        std::fs::write(&regular, b"agent edits this").unwrap();
1260        assert_eq!(std::fs::read(&regular).unwrap(), b"agent edits this");
1261    }
1262
1263    /// THE core isolation property. An agent in worktree-A that
1264    /// chmods +w (no-op since we already ship 0o644) and writes
1265    /// in-place must not affect worktree-B's bytes. Under the old
1266    /// hardlink+chmod model this exact sequence corrupted sibling
1267    /// worktrees through the shared inode. Under the new
1268    /// CoW/copy model the worktrees have distinct inodes and the
1269    /// kernel guarantees isolation.
1270    #[test]
1271    #[cfg(unix)]
1272    fn materialize_then_chmod_and_write_does_not_affect_sibling_worktree() {
1273        use std::os::unix::fs::PermissionsExt;
1274
1275        let temp_dir = TempDir::new().unwrap();
1276        let repo = Repository::init_default(temp_dir.path()).unwrap();
1277
1278        let blob = Blob::from("canonical bytes that must never change");
1279        let hash = repo.store().put_blob(&blob).unwrap();
1280
1281        let worktree_a = temp_dir.path().join("wt-a/file.txt");
1282        let worktree_b = temp_dir.path().join("wt-b/file.txt");
1283
1284        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1285            path: worktree_a.clone(),
1286            hash,
1287            executable: false,
1288        }])
1289        .unwrap();
1290        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1291            path: worktree_b.clone(),
1292            hash,
1293            executable: false,
1294        }])
1295        .unwrap();
1296
1297        // Simulate a misbehaving agent: re-assert mode 0o644 (the
1298        // old defense rendered this a no-op for blocking writes),
1299        // then truncate-and-overwrite in place via the shell-style
1300        // `> file` pathway.
1301        std::fs::set_permissions(&worktree_a, std::fs::Permissions::from_mode(0o644)).unwrap();
1302        std::fs::write(&worktree_a, b"AGENT_TAMPERED_WITH_WORKTREE_A").unwrap();
1303
1304        // Sibling worktree's bytes are unchanged.
1305        assert_eq!(
1306            std::fs::read(&worktree_b).unwrap(),
1307            blob.content(),
1308            "sibling worktree must keep canonical bytes despite in-place write to worktree-a"
1309        );
1310        // And the canonical loose blob in the store is untouched.
1311        if let Some(loose) = repo.store().loose_blob_path(&hash) {
1312            assert_eq!(
1313                std::fs::read(&loose).unwrap(),
1314                blob.content(),
1315                "canonical loose blob must keep canonical bytes despite in-place write to worktree-a"
1316            );
1317        }
1318    }
1319
1320    /// Atomic-rename writes (write-tempfile + `rename(2)` over
1321    /// target) must also leave sibling worktrees untouched. This
1322    /// path was always safe under the old model too — proving it
1323    /// keeps working with the new isolation strategy.
1324    #[test]
1325    #[cfg(unix)]
1326    fn materialize_atomic_rename_does_not_affect_sibling_worktree() {
1327        let temp_dir = TempDir::new().unwrap();
1328        let repo = Repository::init_default(temp_dir.path()).unwrap();
1329
1330        let blob = Blob::from("atomic-rename canonical bytes");
1331        let hash = repo.store().put_blob(&blob).unwrap();
1332
1333        let worktree_a = temp_dir.path().join("wt-a/file.txt");
1334        let worktree_b = temp_dir.path().join("wt-b/file.txt");
1335
1336        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1337            path: worktree_a.clone(),
1338            hash,
1339            executable: false,
1340        }])
1341        .unwrap();
1342        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1343            path: worktree_b.clone(),
1344            hash,
1345            executable: false,
1346        }])
1347        .unwrap();
1348
1349        let tmp = temp_dir.path().join("wt-a/file.txt.tmp");
1350        std::fs::write(&tmp, b"NEW_CONTENT_VIA_ATOMIC_RENAME").unwrap();
1351        std::fs::rename(&tmp, &worktree_a).unwrap();
1352
1353        assert_eq!(
1354            std::fs::read(&worktree_a).unwrap(),
1355            b"NEW_CONTENT_VIA_ATOMIC_RENAME"
1356        );
1357        assert_eq!(
1358            std::fs::read(&worktree_b).unwrap(),
1359            blob.content(),
1360            "sibling worktree must keep canonical bytes despite atomic rename in worktree-a"
1361        );
1362    }
1363
1364    /// On a CoW filesystem (APFS, btrfs, XFS-with-reflinks, ZFS)
1365    /// the materialized worktree file must have a **distinct**
1366    /// inode from the canonical loose blob. This is the key
1367    /// correctness assertion that distinguishes reflinks from
1368    /// hardlinks: hardlinks share inodes (the bug we fixed),
1369    /// reflinks do not.
1370    ///
1371    /// Ignored by default because `fs::copy` also gives distinct
1372    /// inodes on non-CoW filesystems while this test specifically
1373    /// targets the reflink path.
1374    #[test]
1375    #[cfg(unix)]
1376    #[ignore = "requires a reflink-capable filesystem"]
1377    fn materialize_uses_reflink_when_filesystem_supports_it() {
1378        use std::os::unix::fs::MetadataExt;
1379
1380        let temp_dir = TempDir::new().unwrap();
1381        assert!(
1382            filesystem_supports_reflink(temp_dir.path()),
1383            "materialize_uses_reflink_when_filesystem_supports_it requires reflink support"
1384        );
1385
1386        let repo = Repository::init_default(temp_dir.path()).unwrap();
1387        let blob = Blob::from("reflink correctness check, kept under compression threshold");
1388        let hash = repo.store().put_blob(&blob).unwrap();
1389        let worktree = temp_dir.path().join("wt/file.txt");
1390
1391        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1392            path: worktree.clone(),
1393            hash,
1394            executable: false,
1395        }])
1396        .unwrap();
1397
1398        let loose = repo
1399            .store()
1400            .loose_blob_path(&hash)
1401            .expect("blob must be loose+uncompressed (under threshold)");
1402        let loose_inode = std::fs::metadata(&loose).unwrap().ino();
1403        let worktree_inode = std::fs::metadata(&worktree).unwrap().ino();
1404        assert_ne!(
1405            loose_inode, worktree_inode,
1406            "reflinked worktree file must have a distinct inode from canonical loose blob (got {} for both — that's a hardlink, the bug we fixed)",
1407            loose_inode
1408        );
1409        // And nlink on the canonical blob is 1: nothing aliases it.
1410        let nlink = std::fs::metadata(&loose).unwrap().nlink();
1411        assert_eq!(
1412            nlink, 1,
1413            "canonical loose blob must not be aliased (nlink={}); reflinks share blocks, not inodes",
1414            nlink
1415        );
1416    }
1417
1418    /// Functional readback after N materializations of the same
1419    /// blob across N worktrees on the same filesystem. Replaces
1420    /// the old "shared inode" assertion which is no longer the
1421    /// correctness model. Now we just assert every worktree reads
1422    /// back the canonical bytes (and they're independent — see
1423    /// the isolation tests above).
1424    #[test]
1425    #[cfg(unix)]
1426    fn materialize_blob_into_two_worktrees_reads_back_canonical_bytes() {
1427        let temp_dir = TempDir::new().unwrap();
1428        let repo = Repository::init_default(temp_dir.path()).unwrap();
1429
1430        let blob = Blob::from("two-worktree readback payload");
1431        let hash = repo.store().put_blob(&blob).unwrap();
1432
1433        let worktree_a = temp_dir.path().join("worktree-a/file.txt");
1434        let worktree_b = temp_dir.path().join("worktree-b/file.txt");
1435
1436        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1437            path: worktree_a.clone(),
1438            hash,
1439            executable: false,
1440        }])
1441        .unwrap();
1442        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1443            path: worktree_b.clone(),
1444            hash,
1445            executable: false,
1446        }])
1447        .unwrap();
1448
1449        assert_eq!(std::fs::read(&worktree_a).unwrap(), blob.content());
1450        assert_eq!(std::fs::read(&worktree_b).unwrap(), blob.content());
1451    }
1452
1453    /// Symlinks are routed through the existing path; introducing
1454    /// hardlinks must not regress the symlink test that lives in
1455    /// `repository_tests.rs`. Locally we just confirm a symlink op
1456    /// still produces a real symlink (not a hardlink to the target
1457    /// blob's loose path).
1458    #[test]
1459    #[cfg(unix)]
1460    fn materialize_symlink_op_produces_real_symlink_not_hardlink() {
1461        let temp_dir = TempDir::new().unwrap();
1462        let repo = Repository::init_default(temp_dir.path()).unwrap();
1463
1464        let symlink_blob = Blob::new(b"../canonical".to_vec());
1465        let symlink_hash = repo.store().put_blob(&symlink_blob).unwrap();
1466        let path = temp_dir.path().join("worktree/link.txt");
1467
1468        repo.materialize_write_ops(&[WorktreeWriteOp::Symlink {
1469            path: path.clone(),
1470            hash: symlink_hash,
1471            validation_root: temp_dir.path().to_path_buf(),
1472        }])
1473        .unwrap();
1474
1475        let meta = std::fs::symlink_metadata(&path).unwrap();
1476        assert!(
1477            meta.file_type().is_symlink(),
1478            "Symlink op must produce a real symlink, not a hardlinked regular file"
1479        );
1480        assert_eq!(
1481            std::fs::read_link(&path).unwrap(),
1482            PathBuf::from("../canonical")
1483        );
1484    }
1485
1486    #[test]
1487    #[cfg(unix)]
1488    fn materialize_symlink_op_replaces_existing_symlink() {
1489        let temp_dir = TempDir::new().unwrap();
1490        let repo = Repository::init_default(temp_dir.path()).unwrap();
1491
1492        let first_hash = repo.store().put_blob(&Blob::from("first")).unwrap();
1493        let second_hash = repo.store().put_blob(&Blob::from("second")).unwrap();
1494        let path = temp_dir.path().join("worktree/link.txt");
1495
1496        repo.materialize_write_ops(&[WorktreeWriteOp::Symlink {
1497            path: path.clone(),
1498            hash: first_hash,
1499            validation_root: temp_dir.path().to_path_buf(),
1500        }])
1501        .unwrap();
1502        repo.materialize_write_ops(&[WorktreeWriteOp::Symlink {
1503            path: path.clone(),
1504            hash: second_hash,
1505            validation_root: temp_dir.path().to_path_buf(),
1506        }])
1507        .unwrap();
1508
1509        assert_eq!(std::fs::read_link(&path).unwrap(), PathBuf::from("second"));
1510    }
1511
1512    #[test]
1513    #[cfg(unix)]
1514    fn materialize_write_ops_reuses_prepared_parent_for_multiple_writes() {
1515        let temp_dir = TempDir::new().unwrap();
1516        let repo = Repository::init_default(temp_dir.path()).unwrap();
1517
1518        let symlink_target = Blob::new(b"../target.txt".to_vec());
1519        let target_hash = repo.store().put_blob(&Blob::from("target")).unwrap();
1520        let symlink_hash = repo.store().put_blob(&symlink_target).unwrap();
1521        let base_dir = temp_dir.path().join("nested/deep");
1522        let target_path = base_dir.join("target.txt");
1523        let link_path = base_dir.join("link.txt");
1524
1525        repo.materialize_write_ops(&[
1526            WorktreeWriteOp::Blob {
1527                path: target_path.clone(),
1528                hash: target_hash,
1529                executable: false,
1530            },
1531            WorktreeWriteOp::Symlink {
1532                path: link_path.clone(),
1533                hash: symlink_hash,
1534                validation_root: temp_dir.path().to_path_buf(),
1535            },
1536        ])
1537        .unwrap();
1538
1539        assert_eq!(std::fs::read_to_string(&target_path).unwrap(), "target");
1540        assert_eq!(
1541            std::fs::read_link(&link_path).unwrap(),
1542            PathBuf::from("../target.txt")
1543        );
1544    }
1545
1546    /// After `pack_objects + prune_loose_objects`, every blob is
1547    /// pack-only. The lazy-promotion path inside `materialize_blob`
1548    /// must (a) succeed without errors, (b) read back the canonical
1549    /// bytes in both worktrees, and (c) leave a real loose
1550    /// uncompressed mirror on disk under
1551    /// `.heddle/objects/blobs/<2-char>/<rest>` so subsequent
1552    /// reflinks have something to clone from.
1553    #[test]
1554    #[cfg(unix)]
1555    fn lazy_promotion_after_pack_and_prune_restores_loose_mirror() {
1556        let temp_dir = TempDir::new().unwrap();
1557        let repo = Repository::init_default(temp_dir.path()).unwrap();
1558
1559        let blob = Blob::from(
1560            "lazy-promotion payload, packed-then-pruned, kept under compression threshold",
1561        );
1562        let hash = repo.store().put_blob(&blob).unwrap();
1563
1564        // Move the loose copy into a packfile, then drop the loose
1565        // copy. The store now has only the pack-resident blob.
1566        repo.store().pack_objects(false).unwrap();
1567        repo.store().prune_loose_objects().unwrap();
1568        assert!(
1569            repo.store().loose_blob_path(&hash).is_none(),
1570            "after pack+prune, the canonical loose path must be empty"
1571        );
1572
1573        let worktree_a = temp_dir.path().join("worktree-a/file.txt");
1574        let worktree_b = temp_dir.path().join("worktree-b/file.txt");
1575        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1576            path: worktree_a.clone(),
1577            hash,
1578            executable: false,
1579        }])
1580        .unwrap();
1581        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1582            path: worktree_b.clone(),
1583            hash,
1584            executable: false,
1585        }])
1586        .unwrap();
1587
1588        // (a)+(b) read back ok.
1589        assert_eq!(std::fs::read(&worktree_a).unwrap(), blob.content());
1590        assert_eq!(std::fs::read(&worktree_b).unwrap(), blob.content());
1591
1592        // (c) the loose-uncompressed mirror exists.
1593        let loose = repo
1594            .store()
1595            .loose_blob_path(&hash)
1596            .expect("after lazy promotion the canonical loose path must exist");
1597        assert_eq!(std::fs::read(&loose).unwrap(), blob.content());
1598    }
1599
1600    /// Proactive warm: walk a state's tree, promote every reachable
1601    /// blob, then materialize. Every blob must be loose-uncompressed
1602    /// after warm so the materialize step can reflink directly
1603    /// without paying the decompress tax. Cross-worktree readback
1604    /// must give the canonical bytes.
1605    #[test]
1606    #[cfg(unix)]
1607    fn proactive_warm_promotes_all_state_blobs() {
1608        let temp_dir = TempDir::new().unwrap();
1609        let repo = Repository::init_default(temp_dir.path()).unwrap();
1610
1611        // Materialize a few files and snapshot.
1612        for i in 0..4 {
1613            std::fs::write(
1614                temp_dir.path().join(format!("file-{i}.txt")),
1615                format!("warm-pass payload {i} {}", "x".repeat(140)),
1616            )
1617            .unwrap();
1618        }
1619        let state = repo
1620            .snapshot(Some("warm-pass test".to_string()), None)
1621            .unwrap();
1622
1623        // Pack + prune so every blob is pack-only.
1624        repo.store().pack_objects(false).unwrap();
1625        repo.store().prune_loose_objects().unwrap();
1626
1627        // Sanity: with a packed-then-pruned store, no canonical loose
1628        // file exists yet for the snapshot's blobs.
1629        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1630        let mut hashes = std::collections::BTreeSet::new();
1631        repo.collect_blob_hashes(&tree, &mut hashes).unwrap();
1632        for hash in &hashes {
1633            assert!(
1634                repo.store().loose_blob_path(hash).is_none(),
1635                "blob {} should be pack-only before warm",
1636                hash
1637            );
1638        }
1639
1640        // Warm: every blob should now be loose-uncompressed.
1641        let stats = repo.warm_canonical_store_for_state(&state.id()).unwrap();
1642        assert_eq!(stats.errors, 0, "warm pass produced errors: {:?}", stats);
1643        assert_eq!(stats.total(), hashes.len());
1644        assert!(
1645            stats.promoted >= hashes.len(),
1646            "expected to promote all {} blobs, got {} (already_loose={})",
1647            hashes.len(),
1648            stats.promoted,
1649            stats.already_loose
1650        );
1651        for hash in &hashes {
1652            assert!(
1653                repo.store().loose_blob_path(hash).is_some(),
1654                "blob {} should be loose+uncompressed after warm",
1655                hash
1656            );
1657        }
1658
1659        // Materialize across two worktrees on the same FS. Reading
1660        // back from each must yield the canonical bytes; isolation
1661        // is guaranteed by filesystem-level CoW (or full copy).
1662        let worktree_a = temp_dir.path().join("wt-a");
1663        let worktree_b = temp_dir.path().join("wt-b");
1664        repo.materialize_tree(&tree, &worktree_a).unwrap();
1665        repo.materialize_tree(&tree, &worktree_b).unwrap();
1666
1667        for entry in tree.entries() {
1668            let path_a = worktree_a.join(entry.name());
1669            let path_b = worktree_b.join(entry.name());
1670            assert_eq!(
1671                std::fs::read(&path_a).unwrap(),
1672                std::fs::read(&path_b).unwrap(),
1673                "{} must read back identically across worktrees",
1674                entry.name()
1675            );
1676        }
1677    }
1678
1679    /// Idempotent warm: a second pass over the same state must not
1680    /// rewrite anything. Every blob is `already_loose`.
1681    #[test]
1682    #[cfg(unix)]
1683    fn warm_canonical_store_is_idempotent() {
1684        let temp_dir = TempDir::new().unwrap();
1685        let repo = Repository::init_default(temp_dir.path()).unwrap();
1686
1687        for i in 0..3 {
1688            std::fs::write(
1689                temp_dir.path().join(format!("idem-{i}.txt")),
1690                format!("idem payload {i} {}", "x".repeat(160)),
1691            )
1692            .unwrap();
1693        }
1694        let state = repo
1695            .snapshot(Some("idempotent warm".to_string()), None)
1696            .unwrap();
1697        repo.store().pack_objects(false).unwrap();
1698        repo.store().prune_loose_objects().unwrap();
1699
1700        let first = repo.warm_canonical_store_for_state(&state.id()).unwrap();
1701        let second = repo.warm_canonical_store_for_state(&state.id()).unwrap();
1702
1703        assert_eq!(first.total(), second.total(), "blob count must be stable");
1704        assert_eq!(
1705            second.promoted, 0,
1706            "second warm must not promote anything (got {})",
1707            second.promoted
1708        );
1709        assert_eq!(
1710            second.already_loose,
1711            second.total(),
1712            "every blob must be already_loose on second pass"
1713        );
1714        assert_eq!(second.errors, 0);
1715    }
1716
1717    /// Storage win after warm + materialize on a CoW filesystem.
1718    /// We can no longer dedupe via inode (reflinks have distinct
1719    /// inodes by design), so on CoW filesystems we instead assert
1720    /// that **every materialized file has its own inode**, distinct
1721    /// from the canonical loose blob — proving the materializer
1722    /// took the reflink path (which gives the storage win on CoW
1723    /// without aliasing) rather than the in-memory `fs::write` path
1724    /// (which costs full duplicates).
1725    ///
1726    /// Ignored by default because the materializer uses `fs::copy`
1727    /// on non-CoW filesystems and the reflink storage win is not
1728    /// available there.
1729    #[test]
1730    #[cfg(unix)]
1731    #[ignore = "requires a reflink-capable filesystem"]
1732    fn packed_repo_storage_win_after_warm_and_materialize() {
1733        use std::{collections::HashSet, os::unix::fs::MetadataExt};
1734
1735        let temp_dir = TempDir::new().unwrap();
1736        assert!(
1737            filesystem_supports_reflink(temp_dir.path()),
1738            "packed_repo_storage_win_after_warm_and_materialize requires reflink support"
1739        );
1740
1741        let repo = Repository::init_default(temp_dir.path()).unwrap();
1742
1743        let blob_count = 5;
1744        for i in 0..blob_count {
1745            std::fs::write(
1746                temp_dir.path().join(format!("file-{i}.txt")),
1747                format!("packed-storage-win payload {i} {}", "x".repeat(140 + i * 8)),
1748            )
1749            .unwrap();
1750        }
1751        let state = repo
1752            .snapshot(Some("packed storage win".to_string()), None)
1753            .unwrap();
1754        // Realistic steady state.
1755        repo.store().pack_objects(false).unwrap();
1756        repo.store().prune_loose_objects().unwrap();
1757
1758        // Warm so the first materialize doesn't pay decompress cost.
1759        let stats = repo.warm_canonical_store_for_state(&state.id()).unwrap();
1760        assert_eq!(stats.errors, 0);
1761
1762        let n_worktrees = 6;
1763        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1764        let mut all_paths = Vec::new();
1765        for w in 0..n_worktrees {
1766            let worktree = temp_dir.path().join(format!("wt-{w}"));
1767            repo.materialize_tree(&tree, &worktree).unwrap();
1768            for i in 0..blob_count {
1769                all_paths.push(worktree.join(format!("file-{i}.txt")));
1770            }
1771        }
1772
1773        // Every materialized file has its own inode (reflinks, not
1774        // hardlinks). Total inodes = files materialized.
1775        let mut inodes = HashSet::new();
1776        for path in &all_paths {
1777            inodes.insert(std::fs::metadata(path).unwrap().ino());
1778        }
1779        assert_eq!(
1780            inodes.len(),
1781            all_paths.len(),
1782            "every reflinked worktree file must have its own inode (got {} for {} files)",
1783            inodes.len(),
1784            all_paths.len()
1785        );
1786
1787        // No materialized file shares an inode with the canonical
1788        // loose blob — that would be the hardlink bug.
1789        let mut canonical_inodes = HashSet::new();
1790        for hash in tree.entries().iter().filter_map(|e| e.blob_hash()) {
1791            if let Some(loose) = repo.store().loose_blob_path(&hash) {
1792                canonical_inodes.insert(std::fs::metadata(&loose).unwrap().ino());
1793            }
1794        }
1795        for inode in &inodes {
1796            assert!(
1797                !canonical_inodes.contains(inode),
1798                "worktree file inode {} aliases the canonical loose blob — that's the hardlink bug",
1799                inode
1800            );
1801        }
1802
1803        eprintln!(
1804            "[packed-storage-win] n_worktrees={} blobs/tree={} reflink_path_confirmed=true",
1805            n_worktrees, blob_count
1806        );
1807    }
1808
1809    /// `promote_to_loose_uncompressed` is idempotent for an already
1810    /// loose+uncompressed blob — fast-path returns `Ok(false)` so a
1811    /// caller can distinguish "no work needed" from "promoted".
1812    #[test]
1813    fn promote_to_loose_uncompressed_idempotent_on_loose_blob() {
1814        let temp_dir = TempDir::new().unwrap();
1815        let repo = Repository::init_default(temp_dir.path()).unwrap();
1816
1817        let blob = Blob::from("idempotent promote payload");
1818        let hash = repo.store().put_blob(&blob).unwrap();
1819        // Already loose+uncompressed (under compression threshold).
1820        assert!(repo.store().loose_blob_path(&hash).is_some());
1821
1822        let did_work = repo.store().promote_to_loose_uncompressed(&hash).unwrap();
1823        assert!(
1824            !did_work,
1825            "promote on already-loose+uncompressed blob must be a no-op"
1826        );
1827    }
1828
1829    /// `promote_to_loose_uncompressed` on a missing blob bubbles a
1830    /// `NotFound`, not a silent success. Callers can degrade
1831    /// gracefully (e.g. lazy-path falls back to `fs::write`), but
1832    /// the failure must not be invisible.
1833    #[test]
1834    fn promote_to_loose_uncompressed_returns_error_for_missing_blob() {
1835        use objects::object::ContentHash;
1836
1837        let temp_dir = TempDir::new().unwrap();
1838        let repo = Repository::init_default(temp_dir.path()).unwrap();
1839
1840        let bogus = ContentHash::compute_typed("blob", b"never-stored");
1841        let result = repo.store().promote_to_loose_uncompressed(&bogus);
1842        assert!(
1843            result.is_err(),
1844            "promote on missing blob must error, got {:?}",
1845            result
1846        );
1847    }
1848}