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, ChangeId, ContentHash, 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: &ChangeId,
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: &[ChangeId],
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    /// `ChangeId`/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(target_path.to_path_buf()));
560                    }
561                    remove_materialized_leaf(path)?;
562                    std::os::unix::fs::symlink(target, path)?;
563                }
564                // Windows symlink materialization is unimplemented;
565                // the projection layer (ProjFS) handles symlinks
566                // through reparse points instead of native symlinks,
567                // and `heddle materialize` on Windows isn't part of
568                // the daily-use mount story. Suppress the unused
569                // bindings rather than ship a half-implementation.
570                #[cfg(not(unix))]
571                {
572                    let _ = (blob, path, validation_root);
573                }
574            }
575            WorktreeWriteOp::GitlinkPlaceholder { path, target } => {
576                remove_materialized_leaf(path)?;
577                fs::write(path, gitlink_placeholder_bytes(target))
578                    .map_err(|err| HeddleError::Io(enrich_fs_error(path, "writing", err)))?;
579            }
580        }
581
582        let metadata = fs::symlink_metadata(write.path())?;
583        let entry = build_cached_entry(
584            write.hash(),
585            &metadata,
586            write.executable(),
587            write.index_kind(),
588        )
589        .ok_or_else(|| {
590            HeddleError::Config(format!(
591                "seed materialized worktree entry for {}",
592                write.path().display()
593            ))
594        })?;
595
596        Ok(SeededWorktreeEntry {
597            key: cache_key(
598                write
599                    .path()
600                    .strip_prefix(self.root())
601                    .unwrap_or(write.path()),
602            ),
603            entry,
604        })
605    }
606
607    /// Materialize a single blob into the worktree.
608    ///
609    /// Strategy (in order):
610    ///   1. Filesystem reflink (`clonefile(2)` on macOS APFS,
611    ///      `ioctl(FICLONE)` on Linux btrfs/XFS/ZFS) from the
612    ///      canonical loose-uncompressed blob into `dest`. The dest
613    ///      gets its own inode; the kernel forks the underlying
614    ///      allocation on first write to either side. On reflink-
615    ///      capable filesystems this preserves the storage win
616    ///      (~1× disk for N worktrees of the same state) without
617    ///      any shared-inode hazard.
618    ///   2. Lazy promotion + retry. If the canonical loose blob
619    ///      isn't on disk (e.g. post-`pack_objects + prune_loose`),
620    ///      promote it once and retry the reflink.
621    ///   3. `fs::write` of the decompressed blob bytes. Used when the
622    ///      filesystem doesn't support reflinks at all
623    ///      (`EXDEV`/`EOPNOTSUPP`/`ENOSYS`), in which case we flip a
624    ///      batch-wide flag and stop trying for the rest of this
625    ///      materialization.
626    ///
627    /// Permission bits are normalized to `0o644` (or `0o755` for
628    /// executables) on every path. There is no read-only-mode
629    /// defense — agents can `chmod +w` and overwrite freely; the
630    /// filesystem-level isolation is what keeps sibling worktrees
631    /// safe.
632    fn materialize_blob(
633        &self,
634        dest: &Path,
635        hash: &ContentHash,
636        executable: bool,
637        context: &MaterializationContext,
638    ) -> Result<()> {
639        // Redaction short-circuit: if any redaction declares this
640        // blob's bytes off-limits, materialize the human-readable
641        // stub instead. The stub names who redacted it, when, why,
642        // and whether the bytes have already been purged. Safe to
643        // include in worktrees, semantic diffs, and bridge-git
644        // exports (which themselves call through `materialize_tree`).
645        // Errors loading the redactions store are propagated rather
646        // than swallowed — a partial redaction read shouldn't
647        // silently leak the original bytes.
648        if let Some(stub) = self
649            .redaction_stub_for_blob(hash)
650            .map_err(|err| HeddleError::Config(format!("redaction lookup failed: {err}")))?
651        {
652            let _ = fs::remove_file(dest);
653            fs::write(dest, stub.as_bytes())?;
654            // Stubs are never executable — overwriting a tracked
655            // executable with a stub correctly drops the +x bit so
656            // operators don't accidentally run the redaction notice.
657            set_file_mode(dest, false)?;
658            // The redaction stub path doesn't reflink/clone — count
659            // it as a copy so observability stays accurate.
660            context.record_copy();
661            let _ = executable;
662            return Ok(());
663        }
664
665        if context.reflinks_enabled() {
666            // First-pass: blob is already loose+uncompressed.
667            if let Some(source) = self.store.loose_blob_path(hash)
668                && self.try_clone(&source, dest, executable, context)?
669            {
670                return Ok(());
671            }
672            // Second-pass: lazy promotion. Pack-resident or
673            // compressed-loose blob — promote it to the canonical
674            // uncompressed-loose path, then retry the reflink.
675            // Without this step `pack_objects + prune_loose_objects`
676            // permanently degrades materialize to slow `fs::write`.
677            //
678            // The first materialize of any given hash pays
679            // decompress + atomic write, but every subsequent one
680            // (other worktrees, future `goto`s) is a single
681            // `clonefile`/FICLONE. Net win for any N > 1
682            // materializations on a CoW filesystem.
683            match self.store.promote_to_loose_uncompressed(hash) {
684                Ok(_) => {
685                    if let Some(source) = self.store.loose_blob_path(hash)
686                        && self.try_clone(&source, dest, executable, context)?
687                    {
688                        return Ok(());
689                    }
690                }
691                Err(err) => {
692                    debug!(
693                        ?err,
694                        hash = %hash,
695                        "promote_to_loose_uncompressed failed; falling back to fs::write"
696                    );
697                }
698            }
699        }
700
701        let blob = self
702            .store
703            .get_blob(hash)?
704            .ok_or_else(|| HeddleError::NotFound(format!("blob {}", hash)))?;
705        // Remove any stale dest before writing. We don't share inodes
706        // with the canonical store anymore (no hardlinks), but a
707        // previous `goto` could still have left an unrelated file
708        // here that we should overwrite cleanly.
709        let _ = fs::remove_file(dest);
710        fs::write(dest, blob.content())?;
711        set_file_mode(dest, executable)?;
712        context.record_copy();
713        Ok(())
714    }
715
716    /// One clone attempt: returns `Ok(true)` on a successful reflink,
717    /// `Ok(false)` when the caller should fall back to the in-memory
718    /// `fs::write` path. The two `Ok(false)` causes are deliberately
719    /// handled differently:
720    ///
721    /// * `ReflinkOutcome::Unsupported` (`EXDEV`/`EOPNOTSUPP`/`ENOSYS`/
722    ///   `EINVAL`) — a filesystem-capability verdict, so the context is
723    ///   flipped (`disable_reflinks`) and the rest of the batch skips
724    ///   straight to `fs::write` without paying the failed-syscall tax.
725    /// * `ReflinkOutcome::SourceVanished` — the loose mirror was pruned
726    ///   mid-flight. A per-blob race, so we fall back for this blob only
727    ///   and leave reflinks ENABLED for the rest of the batch
728    ///   (heddle#571 r3).
729    ///
730    /// Genuine I/O errors bubble up (attributed to the offending side by
731    /// `classify_clone_failure`).
732    fn try_clone(
733        &self,
734        source: &Path,
735        dest: &Path,
736        executable: bool,
737        context: &MaterializationContext,
738    ) -> Result<bool> {
739        // `clonefile`/`FICLONE` fail if `dest` already exists, so
740        // make sure we're starting from a clean slate. A previous
741        // `goto` could have left a regular file or a stale link here.
742        let _ = fs::remove_file(dest);
743        // Reflink is a pure optimization; correctness must never depend on the
744        // loose source still being present at the syscall. `loose_blob_path`
745        // verified it existed, but a concurrent prune or a torn NoSync promote
746        // can remove it before we get here. On macOS, handing `clonefile(2)` a
747        // missing source surfaces as ENOENT — which `reflink_unsupported`
748        // deliberately does NOT swallow (ENOENT means a genuinely missing file,
749        // not "reflink unsupported") — so without this guard the whole
750        // `heddle start` hard-fails (heddle#571). Fall back to the bytes-write
751        // path for THIS blob only by returning `Ok(false)`; do NOT
752        // `disable_reflinks`, so sibling blobs whose mirrors are intact still
753        // clone. The caller (`materialize_blob`) then writes the blob from its
754        // decompressed bytes via `get_blob` — the same path Linux's
755        // ext4-EOPNOTSUPP short-circuit already takes.
756        if !source.exists() {
757            debug!(
758                source = %source.display(),
759                dest = %dest.display(),
760                "loose reflink source missing before clone; falling back to bytes-write for this blob"
761            );
762            return Ok(false);
763        }
764        use objects::fs_clone::ReflinkOutcome;
765        match objects::fs_clone::try_reflink(source, dest) {
766            Ok(ReflinkOutcome::Cloned) => {
767                set_file_mode(dest, executable)?;
768                context.record_reflink();
769                Ok(true)
770            }
771            Ok(ReflinkOutcome::Unsupported) => {
772                // Filesystem doesn't support reflinks. Disable for
773                // the rest of the batch and let the caller fall
774                // through to `fs::write` (which decompresses from
775                // memory rather than reading the loose file twice).
776                debug!(
777                    source = %source.display(),
778                    dest = %dest.display(),
779                    "reflink not supported on this filesystem; switching batch to fs::write fallback"
780                );
781                context.disable_reflinks();
782                Ok(false)
783            }
784            Ok(ReflinkOutcome::SourceVanished) => {
785                // [heddle#571 r3] The loose mirror was pruned out from under
786                // us between the pre-check and the clone. That's a per-blob
787                // race, NOT a filesystem-capability verdict — so degrade to
788                // the bytes-write fallback for THIS blob only and DO NOT
789                // `disable_reflinks`. Sibling blobs whose mirrors are intact
790                // still get the CoW win. A blob genuinely absent from the
791                // store still errors loudly when the bytes-write fallback's
792                // `get_blob` can't find it.
793                debug!(
794                    source = %source.display(),
795                    dest = %dest.display(),
796                    "loose reflink source vanished before clone; falling back to bytes-write for this blob (reflinks stay enabled batch-wide)"
797                );
798                Ok(false)
799            }
800            Err(err) => {
801                debug!(
802                    ?err,
803                    source = %source.display(),
804                    dest = %dest.display(),
805                    "reflink failed with I/O error"
806                );
807                match classify_clone_failure(source, dest, &err) {
808                    // [heddle#571 r2, finding 2] Source vanished mid-flight
809                    // (TOCTOU): degrade to the bytes-write fallback for this
810                    // blob rather than hard-erroring. See `classify_clone_failure`.
811                    None => {
812                        debug!(
813                            source = %source.display(),
814                            dest = %dest.display(),
815                            "loose reflink source vanished between pre-check and clone syscall; falling back to bytes-write for this blob"
816                        );
817                        Ok(false)
818                    }
819                    // [heddle#571 r2, finding 3] Attribute to the offending side.
820                    Some((offender, action)) => {
821                        Err(HeddleError::Io(enrich_fs_error(offender, action, err)))
822                    }
823                }
824            }
825        }
826    }
827}
828
829/// Classify a clone-syscall (`clonefile`/`FICLONE`) I/O failure into either a
830/// bytes-write fallback or an enriched, correctly-attributed error.
831///
832/// * `None` — the loose source vanished between the `!source.exists()`
833///   pre-check and the syscall (concurrent prune / torn NoSync promote), so the
834///   syscall raised `ENOENT`. The caller should degrade to the bytes-write
835///   fallback (`Ok(false)`), which re-reads the authoritative bytes from the
836///   store; a blob genuinely absent from the store still errors there with its
837///   hash. This closes the TOCTOU race rather than blindly masking `ENOENT`
838///   (heddle#571 r2, finding 2).
839/// * `Some((path, action))` — a real failure to surface, attributed to the side
840///   that actually failed. The source survived (the vanished case returned
841///   `None`), so a create/permission/read-only/no-space failure is the
842///   DESTINATION's (read-only checkout dir, unwritable target). We blame the
843///   source only when it is the unreadable party (probed directly). Reporting a
844///   dest-side failure against the blob path would point the user at the wrong
845///   file (heddle#571 r2, finding 3).
846fn classify_clone_failure<'a>(
847    source: &'a Path,
848    dest: &'a Path,
849    err: &std::io::Error,
850) -> Option<(&'a Path, &'static str)> {
851    if err.kind() == std::io::ErrorKind::NotFound && !source.exists() {
852        return None;
853    }
854    if fs::File::open(source).is_ok() {
855        Some((dest, "reflinking into"))
856    } else {
857        Some((source, "reflinking"))
858    }
859}
860
861fn prepare_parent_directories(writes: &[WorktreeWriteOp]) -> Result<()> {
862    let mut parents = BTreeSet::new();
863    for write in writes {
864        if let Some(parent) = write.path().parent() {
865            parents.insert(parent.to_path_buf());
866        }
867    }
868
869    for parent in parents {
870        fs::create_dir_all(&parent)
871            .map_err(|e| HeddleError::Io(enrich_fs_error(&parent, "creating", e)))?;
872    }
873
874    Ok(())
875}
876
877/// Best-effort removal of a leaf path before replacing it with another
878/// materialized leaf, such as a symlink on Unix or a gitlink placeholder
879/// on every platform.
880///
881/// Tolerates `ENOTEMPTY` from `remove_dir` for the same reason the
882/// incremental apply path does: untracked or explicitly ignored siblings
883/// may still occupy the directory after the planner has cleaned out the
884/// tracked children. Without this
885/// tolerance, a `goto` over a real-world worktree that mutates a
886/// tracked directory into a symlink aborts mid-apply with `os error
887/// 66`, leaving HEAD stuck and disk diverged from state.
888///
889fn remove_materialized_leaf(path: &Path) -> Result<()> {
890    match fs::symlink_metadata(path) {
891        Ok(metadata) => {
892            let file_type = metadata.file_type();
893            if file_type.is_symlink() || file_type.is_file() {
894                fs::remove_file(path)
895                    .map_err(|e| HeddleError::Io(enrich_fs_error(path, "removing", e)))?;
896            } else if file_type.is_dir() {
897                match fs::remove_dir(path) {
898                    Ok(()) => {}
899                    Err(error) if is_directory_not_empty(&error) => {}
900                    Err(error) => {
901                        return Err(HeddleError::Io(enrich_fs_error(path, "removing", error)));
902                    }
903                }
904            }
905            Ok(())
906        }
907        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
908        Err(error) => Err(HeddleError::Io(enrich_fs_error(path, "inspecting", error))),
909    }
910}
911
912fn set_file_mode(path: &Path, executable: bool) -> Result<()> {
913    #[cfg(unix)]
914    {
915        use std::os::unix::fs::PermissionsExt;
916
917        // `OpenOptions::mode(0o644)` is still filtered by the
918        // process umask, and reflink/copy paths preserve the source
919        // mode. Normalize the worktree-visible file mode here so
920        // materialized checkouts do not inherit a restrictive object
921        // store mode such as `0o600`.
922        let mode = if executable { 0o755 } else { 0o644 };
923        fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
924    }
925    #[cfg(not(unix))]
926    {
927        let _ = (path, executable);
928    }
929    Ok(())
930}
931
932fn materialization_worker_count(
933    operation_count: usize,
934    requested_threads: Option<NonZeroUsize>,
935) -> usize {
936    if operation_count < MATERIALIZE_PARALLEL_THRESHOLD {
937        return 1;
938    }
939
940    let available = requested_threads.unwrap_or_else(default_materialization_threads);
941    available.get().min(operation_count.max(1))
942}
943
944fn default_materialization_threads() -> NonZeroUsize {
945    std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN)
946}
947
948fn requested_materialization_threads() -> Option<NonZeroUsize> {
949    let raw = std::env::var(MATERIALIZE_THREADS_ENV).ok()?;
950    raw.trim().parse::<usize>().ok().and_then(NonZeroUsize::new)
951}
952
953#[cfg(test)]
954mod tests {
955    use std::{num::NonZeroUsize, path::PathBuf};
956
957    use objects::{fs_clone::filesystem_supports_reflink, object::Blob, store::ObjectStore};
958    use tempfile::TempDir;
959
960    use super::{
961        MaterializationContext, Repository, WorktreeWriteOp, classify_clone_failure,
962        materialization_worker_count, remove_materialized_leaf,
963    };
964
965    /// The generic [`Progress`](objects::Progress) handle installed on a
966    /// repository must survive the `thread::scope` parallel-materialization seam:
967    /// a single shared `Arc` handle, cloned into each worker, whose atomic
968    /// counter ends at exactly the file count no matter how the work was split
969    /// across threads. This is the load-bearing acceptance criterion for #844.
970    #[test]
971    fn progress_handle_survives_parallel_materialization_seam() {
972        use std::sync::{
973            Arc,
974            atomic::{AtomicUsize, Ordering},
975        };
976
977        use objects::{Progress, ProgressSnapshot, Sink};
978
979        /// Active sink that just counts render calls, proving the active path
980        /// ran concurrently across workers without panicking.
981        struct CountingSink(Arc<AtomicUsize>);
982        impl Sink for CountingSink {
983            fn render(&self, _snap: ProgressSnapshot) {
984                self.0.fetch_add(1, Ordering::Relaxed);
985            }
986        }
987
988        let temp_dir = TempDir::new().unwrap();
989        let repo = Repository::init_default(temp_dir.path()).unwrap();
990
991        // Well over MATERIALIZE_PARALLEL_THRESHOLD (32): on any multi-core host
992        // this exercises the `thread::scope` branch; on a single core it
993        // degrades to the serial branch and the counter assertion still holds.
994        let file_count = 256usize;
995        for i in 0..file_count {
996            std::fs::write(
997                temp_dir.path().join(format!("seam-{i:04}.txt")),
998                format!("seam payload {i} {}", "y".repeat(48)),
999            )
1000            .unwrap();
1001        }
1002        let state = repo.snapshot(Some("seam".to_string()), None).unwrap();
1003        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1004
1005        let render_count = Arc::new(AtomicUsize::new(0));
1006        let progress = Progress::with_sink(Box::new(CountingSink(render_count.clone())));
1007        repo.set_progress(progress.clone());
1008
1009        let dest = temp_dir.path().join("materialized");
1010        repo.materialize_tree(&tree, &dest).unwrap();
1011
1012        // Every write op incremented the shared counter exactly once, across
1013        // however many worker threads split the batch.
1014        assert_eq!(
1015            progress.done(),
1016            file_count,
1017            "shared progress counter must equal the file count after the parallel seam"
1018        );
1019        assert_eq!(
1020            progress.total(),
1021            file_count,
1022            "total set from the write batch"
1023        );
1024        // The active handle rendered at least once through the seam (throttled,
1025        // so not once-per-file) — proving the sink is driven across threads.
1026        assert!(
1027            render_count.load(Ordering::Relaxed) > 0,
1028            "active sink should have rendered during materialization"
1029        );
1030    }
1031
1032    /// heddle#571 (round 2, finding 2): a clone syscall that raises ENOENT
1033    /// because the loose source vanished AFTER the pre-check (TOCTOU) must
1034    /// degrade to the bytes-write fallback (`None`), not hard-error.
1035    #[test]
1036    fn classify_clone_failure_vanished_source_falls_back() {
1037        let temp = TempDir::new().unwrap();
1038        let source = temp.path().join("gone.blob");
1039        let dest = temp.path().join("checkout/file");
1040        assert!(!source.exists());
1041
1042        let enoent = std::io::Error::from(std::io::ErrorKind::NotFound);
1043        assert!(
1044            classify_clone_failure(&source, &dest, &enoent).is_none(),
1045            "a vanished-source ENOENT must signal the bytes-write fallback"
1046        );
1047    }
1048
1049    /// heddle#571 (round 2, finding 3): when the source is still present and
1050    /// readable, the clone failed on the DESTINATION side — attribute the error
1051    /// to the dest (checkout) path, not the blob.
1052    #[test]
1053    fn classify_clone_failure_present_source_blames_dest() {
1054        let temp = TempDir::new().unwrap();
1055        let source = temp.path().join("present.blob");
1056        std::fs::write(&source, b"bytes").unwrap();
1057        let dest = temp.path().join("readonly-checkout/file");
1058
1059        // A dest-side failure shape (e.g. read-only checkout dir).
1060        let erofs = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
1061        let attributed = classify_clone_failure(&source, &dest, &erofs);
1062        assert_eq!(
1063            attributed,
1064            Some((dest.as_path(), "reflinking into")),
1065            "a failure with the source still readable must be attributed to dest"
1066        );
1067    }
1068
1069    /// heddle#571 (round 2, finding 3): a non-ENOENT failure whose source is no
1070    /// longer openable is attributed to the SOURCE path (it is the unreadable
1071    /// party), not the destination.
1072    #[test]
1073    fn classify_clone_failure_unreadable_source_blames_source() {
1074        let temp = TempDir::new().unwrap();
1075        let source = temp.path().join("missing.blob"); // not created → unopenable
1076        let dest = temp.path().join("file");
1077
1078        // Not ENOENT (so the vanished-source fast path doesn't fire), but the
1079        // source can't be opened → blame the source.
1080        let other = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
1081        assert_eq!(
1082            classify_clone_failure(&source, &dest, &other),
1083            Some((source.as_path(), "reflinking")),
1084            "an unreadable source must be attributed to the source path"
1085        );
1086    }
1087
1088    /// heddle#571 (round 3): a vanished loose source is a per-blob race, not a
1089    /// filesystem-capability verdict — `try_clone` must degrade to the
1090    /// bytes-write fallback for that blob WITHOUT flipping the batch-wide
1091    /// `reflink_supported` flag. Previously the helper's pre-check returned a
1092    /// bare `Ok(false)`, indistinguishable from "filesystem can't reflink", so
1093    /// one concurrently-pruned mirror needlessly disabled reflinks (forcing
1094    /// copy-writes) for every remaining blob in the batch.
1095    #[test]
1096    fn try_clone_vanished_source_keeps_batch_reflinks_enabled() {
1097        let temp = TempDir::new().unwrap();
1098        let repo = Repository::init_default(temp.path()).unwrap();
1099        let context = MaterializationContext::new();
1100        assert!(context.reflinks_enabled(), "context starts optimistic");
1101
1102        let missing = temp.path().join("pruned.blob");
1103        let dest = temp.path().join("wt/out.txt");
1104        assert!(!missing.exists());
1105
1106        let cloned = repo
1107            .try_clone(&missing, &dest, false, &context)
1108            .expect("a vanished source must fall back, not error");
1109        assert!(!cloned, "a vanished source cannot have been reflinked");
1110        assert!(
1111            context.reflinks_enabled(),
1112            "a vanished source must NOT disable reflinks for the rest of the batch"
1113        );
1114    }
1115
1116    /// Regression: `remove_materialized_leaf` must tolerate `ENOTEMPTY` on
1117    /// the directory branch, mirroring `remove_existing_path` in the
1118    /// incremental apply path. Both tolerances are needed because the
1119    /// apply planner only removes tracked descendants — when the planner asks
1120    /// the materializer to clear a directory whose tracked children are gone
1121    /// but whose untracked or explicitly ignored children remain, `remove_dir` errors
1122    /// with `os error 66` (macOS/BSD) / `39` (Linux). Pre-fix the
1123    /// materialization branch propagated that error and aborted apply
1124    /// mid-walk, leaving HEAD stuck and disk diverged from state.
1125    #[test]
1126    fn remove_materialized_leaf_tolerates_directory_not_empty() {
1127        let temp = TempDir::new().unwrap();
1128        let dir = temp.path().join("web");
1129        std::fs::create_dir_all(dir.join("node_modules/lodash")).unwrap();
1130        std::fs::write(dir.join("node_modules/lodash/index.js"), "ignored").unwrap();
1131
1132        // Pre-fix this would propagate ENOTEMPTY; post-fix it returns Ok
1133        // and leaves the directory (with its ignored content) on disk.
1134        remove_materialized_leaf(&dir).expect("must tolerate ENOTEMPTY");
1135        assert!(
1136            dir.join("node_modules/lodash/index.js").exists(),
1137            "ignored content must survive the tolerated removal"
1138        );
1139    }
1140
1141    /// Regression: empty directories still get cleaned up (the common
1142    /// case). The `ENOTEMPTY` tolerance must not regress the happy path.
1143    #[test]
1144    fn remove_materialized_leaf_removes_empty_directory() {
1145        let temp = TempDir::new().unwrap();
1146        let dir = temp.path().join("emptydir");
1147        std::fs::create_dir(&dir).unwrap();
1148
1149        remove_materialized_leaf(&dir).expect("must remove empty dir");
1150        assert!(!dir.exists(), "empty directory must be removed");
1151    }
1152
1153    /// Regression: missing paths are a no-op (NotFound), not an error.
1154    #[test]
1155    fn remove_materialized_leaf_is_noop_for_missing_path() {
1156        let temp = TempDir::new().unwrap();
1157        remove_materialized_leaf(&temp.path().join("does-not-exist"))
1158            .expect("missing path must be a no-op");
1159    }
1160
1161    /// Regression: regular files are still removed (the common symlink-
1162    /// replacement case where the existing leaf was a tracked file).
1163    #[test]
1164    fn remove_materialized_leaf_removes_regular_file() {
1165        let temp = TempDir::new().unwrap();
1166        let file = temp.path().join("a.txt");
1167        std::fs::write(&file, "content").unwrap();
1168
1169        remove_materialized_leaf(&file).expect("must remove regular file");
1170        assert!(!file.exists(), "regular file must be removed");
1171    }
1172
1173    #[test]
1174    fn materialization_parallelism_stays_sequential_for_small_workloads() {
1175        assert_eq!(materialization_worker_count(31, Some(NonZeroUsize::MIN)), 1);
1176    }
1177
1178    #[test]
1179    fn materialization_parallelism_respects_requested_thread_cap() {
1180        assert_eq!(materialization_worker_count(128, NonZeroUsize::new(4)), 4);
1181    }
1182
1183    #[test]
1184    fn materialize_write_ops_prepares_missing_parent_directories() {
1185        let temp_dir = TempDir::new().unwrap();
1186        let repo = Repository::init_default(temp_dir.path()).unwrap();
1187
1188        let blob = Blob::from("cold pull payload");
1189        let hash = repo.store().put_blob(&blob).unwrap();
1190        let file_path = temp_dir.path().join("nested/deep/file.txt");
1191
1192        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1193            path: file_path.clone(),
1194            hash,
1195            executable: false,
1196        }])
1197        .unwrap();
1198
1199        assert_eq!(
1200            std::fs::read_to_string(&file_path).unwrap(),
1201            "cold pull payload"
1202        );
1203    }
1204
1205    /// Materialized blobs must be writable by default. The
1206    /// previous hardlink+chmod-0o444 approach was a footgun:
1207    /// `chmod 644` then in-place write would mutate the canonical
1208    /// store inode, corrupting every other worktree. The fix is
1209    /// filesystem-level CoW (or full copy), so each worktree gets
1210    /// its own inode and a normal `0o644`/`0o755` mode.
1211    #[test]
1212    #[cfg(unix)]
1213    fn materialized_blob_uses_normal_writable_mode() {
1214        use std::os::unix::fs::PermissionsExt;
1215
1216        let temp_dir = TempDir::new().unwrap();
1217        let repo = Repository::init_default(temp_dir.path()).unwrap();
1218
1219        let blob = Blob::from("normal mode payload");
1220        let hash = repo.store().put_blob(&blob).unwrap();
1221        let regular = temp_dir.path().join("worktree/file.txt");
1222        let exec = temp_dir.path().join("worktree/run.sh");
1223
1224        repo.materialize_write_ops(&[
1225            WorktreeWriteOp::Blob {
1226                path: regular.clone(),
1227                hash,
1228                executable: false,
1229            },
1230            WorktreeWriteOp::Blob {
1231                path: exec.clone(),
1232                hash,
1233                executable: true,
1234            },
1235        ])
1236        .unwrap();
1237
1238        let regular_mode = std::fs::metadata(&regular).unwrap().permissions().mode() & 0o777;
1239        let exec_mode = std::fs::metadata(&exec).unwrap().permissions().mode() & 0o777;
1240        assert_eq!(
1241            regular_mode, 0o644,
1242            "regular blob must be 0o644 (got 0o{:o})",
1243            regular_mode
1244        );
1245        assert_eq!(
1246            exec_mode, 0o755,
1247            "executable blob must be 0o755 (got 0o{:o})",
1248            exec_mode
1249        );
1250
1251        // Sanity: a plain in-place write on the materialized file
1252        // must succeed (no chmod gymnastics required).
1253        std::fs::write(&regular, b"agent edits this").unwrap();
1254        assert_eq!(std::fs::read(&regular).unwrap(), b"agent edits this");
1255    }
1256
1257    /// THE core isolation property. An agent in worktree-A that
1258    /// chmods +w (no-op since we already ship 0o644) and writes
1259    /// in-place must not affect worktree-B's bytes. Under the old
1260    /// hardlink+chmod model this exact sequence corrupted sibling
1261    /// worktrees through the shared inode. Under the new
1262    /// CoW/copy model the worktrees have distinct inodes and the
1263    /// kernel guarantees isolation.
1264    #[test]
1265    #[cfg(unix)]
1266    fn materialize_then_chmod_and_write_does_not_affect_sibling_worktree() {
1267        use std::os::unix::fs::PermissionsExt;
1268
1269        let temp_dir = TempDir::new().unwrap();
1270        let repo = Repository::init_default(temp_dir.path()).unwrap();
1271
1272        let blob = Blob::from("canonical bytes that must never change");
1273        let hash = repo.store().put_blob(&blob).unwrap();
1274
1275        let worktree_a = temp_dir.path().join("wt-a/file.txt");
1276        let worktree_b = temp_dir.path().join("wt-b/file.txt");
1277
1278        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1279            path: worktree_a.clone(),
1280            hash,
1281            executable: false,
1282        }])
1283        .unwrap();
1284        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1285            path: worktree_b.clone(),
1286            hash,
1287            executable: false,
1288        }])
1289        .unwrap();
1290
1291        // Simulate a misbehaving agent: re-assert mode 0o644 (the
1292        // old defense rendered this a no-op for blocking writes),
1293        // then truncate-and-overwrite in place via the shell-style
1294        // `> file` pathway.
1295        std::fs::set_permissions(&worktree_a, std::fs::Permissions::from_mode(0o644)).unwrap();
1296        std::fs::write(&worktree_a, b"AGENT_TAMPERED_WITH_WORKTREE_A").unwrap();
1297
1298        // Sibling worktree's bytes are unchanged.
1299        assert_eq!(
1300            std::fs::read(&worktree_b).unwrap(),
1301            blob.content(),
1302            "sibling worktree must keep canonical bytes despite in-place write to worktree-a"
1303        );
1304        // And the canonical loose blob in the store is untouched.
1305        if let Some(loose) = repo.store().loose_blob_path(&hash) {
1306            assert_eq!(
1307                std::fs::read(&loose).unwrap(),
1308                blob.content(),
1309                "canonical loose blob must keep canonical bytes despite in-place write to worktree-a"
1310            );
1311        }
1312    }
1313
1314    /// Atomic-rename writes (write-tempfile + `rename(2)` over
1315    /// target) must also leave sibling worktrees untouched. This
1316    /// path was always safe under the old model too — proving it
1317    /// keeps working with the new isolation strategy.
1318    #[test]
1319    #[cfg(unix)]
1320    fn materialize_atomic_rename_does_not_affect_sibling_worktree() {
1321        let temp_dir = TempDir::new().unwrap();
1322        let repo = Repository::init_default(temp_dir.path()).unwrap();
1323
1324        let blob = Blob::from("atomic-rename canonical bytes");
1325        let hash = repo.store().put_blob(&blob).unwrap();
1326
1327        let worktree_a = temp_dir.path().join("wt-a/file.txt");
1328        let worktree_b = temp_dir.path().join("wt-b/file.txt");
1329
1330        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1331            path: worktree_a.clone(),
1332            hash,
1333            executable: false,
1334        }])
1335        .unwrap();
1336        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1337            path: worktree_b.clone(),
1338            hash,
1339            executable: false,
1340        }])
1341        .unwrap();
1342
1343        let tmp = temp_dir.path().join("wt-a/file.txt.tmp");
1344        std::fs::write(&tmp, b"NEW_CONTENT_VIA_ATOMIC_RENAME").unwrap();
1345        std::fs::rename(&tmp, &worktree_a).unwrap();
1346
1347        assert_eq!(
1348            std::fs::read(&worktree_a).unwrap(),
1349            b"NEW_CONTENT_VIA_ATOMIC_RENAME"
1350        );
1351        assert_eq!(
1352            std::fs::read(&worktree_b).unwrap(),
1353            blob.content(),
1354            "sibling worktree must keep canonical bytes despite atomic rename in worktree-a"
1355        );
1356    }
1357
1358    /// On a CoW filesystem (APFS, btrfs, XFS-with-reflinks, ZFS)
1359    /// the materialized worktree file must have a **distinct**
1360    /// inode from the canonical loose blob. This is the key
1361    /// correctness assertion that distinguishes reflinks from
1362    /// hardlinks: hardlinks share inodes (the bug we fixed),
1363    /// reflinks do not.
1364    ///
1365    /// On non-CoW filesystems the test soft-skips — `fs::copy`
1366    /// also gives distinct inodes, but the test is targeted at
1367    /// the reflink path specifically.
1368    #[test]
1369    #[cfg(unix)]
1370    fn materialize_uses_reflink_when_filesystem_supports_it() {
1371        use std::os::unix::fs::MetadataExt;
1372
1373        let temp_dir = TempDir::new().unwrap();
1374        if !filesystem_supports_reflink(temp_dir.path()) {
1375            eprintln!(
1376                "[skip] filesystem at {:?} does not advertise reflink support",
1377                temp_dir.path()
1378            );
1379            return;
1380        }
1381
1382        let repo = Repository::init_default(temp_dir.path()).unwrap();
1383        let blob = Blob::from("reflink correctness check, kept under compression threshold");
1384        let hash = repo.store().put_blob(&blob).unwrap();
1385        let worktree = temp_dir.path().join("wt/file.txt");
1386
1387        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1388            path: worktree.clone(),
1389            hash,
1390            executable: false,
1391        }])
1392        .unwrap();
1393
1394        let loose = repo
1395            .store()
1396            .loose_blob_path(&hash)
1397            .expect("blob must be loose+uncompressed (under threshold)");
1398        let loose_inode = std::fs::metadata(&loose).unwrap().ino();
1399        let worktree_inode = std::fs::metadata(&worktree).unwrap().ino();
1400        assert_ne!(
1401            loose_inode, worktree_inode,
1402            "reflinked worktree file must have a distinct inode from canonical loose blob (got {} for both — that's a hardlink, the bug we fixed)",
1403            loose_inode
1404        );
1405        // And nlink on the canonical blob is 1: nothing aliases it.
1406        let nlink = std::fs::metadata(&loose).unwrap().nlink();
1407        assert_eq!(
1408            nlink, 1,
1409            "canonical loose blob must not be aliased (nlink={}); reflinks share blocks, not inodes",
1410            nlink
1411        );
1412    }
1413
1414    /// Functional readback after N materializations of the same
1415    /// blob across N worktrees on the same filesystem. Replaces
1416    /// the old "shared inode" assertion which is no longer the
1417    /// correctness model. Now we just assert every worktree reads
1418    /// back the canonical bytes (and they're independent — see
1419    /// the isolation tests above).
1420    #[test]
1421    #[cfg(unix)]
1422    fn materialize_blob_into_two_worktrees_reads_back_canonical_bytes() {
1423        let temp_dir = TempDir::new().unwrap();
1424        let repo = Repository::init_default(temp_dir.path()).unwrap();
1425
1426        let blob = Blob::from("two-worktree readback payload");
1427        let hash = repo.store().put_blob(&blob).unwrap();
1428
1429        let worktree_a = temp_dir.path().join("worktree-a/file.txt");
1430        let worktree_b = temp_dir.path().join("worktree-b/file.txt");
1431
1432        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1433            path: worktree_a.clone(),
1434            hash,
1435            executable: false,
1436        }])
1437        .unwrap();
1438        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1439            path: worktree_b.clone(),
1440            hash,
1441            executable: false,
1442        }])
1443        .unwrap();
1444
1445        assert_eq!(std::fs::read(&worktree_a).unwrap(), blob.content());
1446        assert_eq!(std::fs::read(&worktree_b).unwrap(), blob.content());
1447    }
1448
1449    /// Symlinks are routed through the existing path; introducing
1450    /// hardlinks must not regress the symlink test that lives in
1451    /// `repository_tests.rs`. Locally we just confirm a symlink op
1452    /// still produces a real symlink (not a hardlink to the target
1453    /// blob's loose path).
1454    #[test]
1455    #[cfg(unix)]
1456    fn materialize_symlink_op_produces_real_symlink_not_hardlink() {
1457        let temp_dir = TempDir::new().unwrap();
1458        let repo = Repository::init_default(temp_dir.path()).unwrap();
1459
1460        let symlink_blob = Blob::new(b"../canonical".to_vec());
1461        let symlink_hash = repo.store().put_blob(&symlink_blob).unwrap();
1462        let path = temp_dir.path().join("worktree/link.txt");
1463
1464        repo.materialize_write_ops(&[WorktreeWriteOp::Symlink {
1465            path: path.clone(),
1466            hash: symlink_hash,
1467            validation_root: temp_dir.path().to_path_buf(),
1468        }])
1469        .unwrap();
1470
1471        let meta = std::fs::symlink_metadata(&path).unwrap();
1472        assert!(
1473            meta.file_type().is_symlink(),
1474            "Symlink op must produce a real symlink, not a hardlinked regular file"
1475        );
1476        assert_eq!(
1477            std::fs::read_link(&path).unwrap(),
1478            PathBuf::from("../canonical")
1479        );
1480    }
1481
1482    #[test]
1483    #[cfg(unix)]
1484    fn materialize_symlink_op_replaces_existing_symlink() {
1485        let temp_dir = TempDir::new().unwrap();
1486        let repo = Repository::init_default(temp_dir.path()).unwrap();
1487
1488        let first_hash = repo.store().put_blob(&Blob::from("first")).unwrap();
1489        let second_hash = repo.store().put_blob(&Blob::from("second")).unwrap();
1490        let path = temp_dir.path().join("worktree/link.txt");
1491
1492        repo.materialize_write_ops(&[WorktreeWriteOp::Symlink {
1493            path: path.clone(),
1494            hash: first_hash,
1495            validation_root: temp_dir.path().to_path_buf(),
1496        }])
1497        .unwrap();
1498        repo.materialize_write_ops(&[WorktreeWriteOp::Symlink {
1499            path: path.clone(),
1500            hash: second_hash,
1501            validation_root: temp_dir.path().to_path_buf(),
1502        }])
1503        .unwrap();
1504
1505        assert_eq!(std::fs::read_link(&path).unwrap(), PathBuf::from("second"));
1506    }
1507
1508    #[test]
1509    #[cfg(unix)]
1510    fn materialize_write_ops_reuses_prepared_parent_for_multiple_writes() {
1511        let temp_dir = TempDir::new().unwrap();
1512        let repo = Repository::init_default(temp_dir.path()).unwrap();
1513
1514        let symlink_target = Blob::new(b"../target.txt".to_vec());
1515        let target_hash = repo.store().put_blob(&Blob::from("target")).unwrap();
1516        let symlink_hash = repo.store().put_blob(&symlink_target).unwrap();
1517        let base_dir = temp_dir.path().join("nested/deep");
1518        let target_path = base_dir.join("target.txt");
1519        let link_path = base_dir.join("link.txt");
1520
1521        repo.materialize_write_ops(&[
1522            WorktreeWriteOp::Blob {
1523                path: target_path.clone(),
1524                hash: target_hash,
1525                executable: false,
1526            },
1527            WorktreeWriteOp::Symlink {
1528                path: link_path.clone(),
1529                hash: symlink_hash,
1530                validation_root: temp_dir.path().to_path_buf(),
1531            },
1532        ])
1533        .unwrap();
1534
1535        assert_eq!(std::fs::read_to_string(&target_path).unwrap(), "target");
1536        assert_eq!(
1537            std::fs::read_link(&link_path).unwrap(),
1538            PathBuf::from("../target.txt")
1539        );
1540    }
1541
1542    /// After `pack_objects + prune_loose_objects`, every blob is
1543    /// pack-only. The lazy-promotion path inside `materialize_blob`
1544    /// must (a) succeed without errors, (b) read back the canonical
1545    /// bytes in both worktrees, and (c) leave a real loose
1546    /// uncompressed mirror on disk under
1547    /// `.heddle/objects/blobs/<2-char>/<rest>` so subsequent
1548    /// reflinks have something to clone from.
1549    #[test]
1550    #[cfg(unix)]
1551    fn lazy_promotion_after_pack_and_prune_restores_loose_mirror() {
1552        let temp_dir = TempDir::new().unwrap();
1553        let repo = Repository::init_default(temp_dir.path()).unwrap();
1554
1555        let blob = Blob::from(
1556            "lazy-promotion payload, packed-then-pruned, kept under compression threshold",
1557        );
1558        let hash = repo.store().put_blob(&blob).unwrap();
1559
1560        // Move the loose copy into a packfile, then drop the loose
1561        // copy. The store now has only the pack-resident blob.
1562        repo.store().pack_objects(false).unwrap();
1563        repo.store().prune_loose_objects().unwrap();
1564        assert!(
1565            repo.store().loose_blob_path(&hash).is_none(),
1566            "after pack+prune, the canonical loose path must be empty"
1567        );
1568
1569        let worktree_a = temp_dir.path().join("worktree-a/file.txt");
1570        let worktree_b = temp_dir.path().join("worktree-b/file.txt");
1571        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1572            path: worktree_a.clone(),
1573            hash,
1574            executable: false,
1575        }])
1576        .unwrap();
1577        repo.materialize_write_ops(&[WorktreeWriteOp::Blob {
1578            path: worktree_b.clone(),
1579            hash,
1580            executable: false,
1581        }])
1582        .unwrap();
1583
1584        // (a)+(b) read back ok.
1585        assert_eq!(std::fs::read(&worktree_a).unwrap(), blob.content());
1586        assert_eq!(std::fs::read(&worktree_b).unwrap(), blob.content());
1587
1588        // (c) the loose-uncompressed mirror exists.
1589        let loose = repo
1590            .store()
1591            .loose_blob_path(&hash)
1592            .expect("after lazy promotion the canonical loose path must exist");
1593        assert_eq!(std::fs::read(&loose).unwrap(), blob.content());
1594    }
1595
1596    /// Proactive warm: walk a state's tree, promote every reachable
1597    /// blob, then materialize. Every blob must be loose-uncompressed
1598    /// after warm so the materialize step can reflink directly
1599    /// without paying the decompress tax. Cross-worktree readback
1600    /// must give the canonical bytes.
1601    #[test]
1602    #[cfg(unix)]
1603    fn proactive_warm_promotes_all_state_blobs() {
1604        let temp_dir = TempDir::new().unwrap();
1605        let repo = Repository::init_default(temp_dir.path()).unwrap();
1606
1607        // Materialize a few files and snapshot.
1608        for i in 0..4 {
1609            std::fs::write(
1610                temp_dir.path().join(format!("file-{i}.txt")),
1611                format!("warm-pass payload {i} {}", "x".repeat(140)),
1612            )
1613            .unwrap();
1614        }
1615        let state = repo
1616            .snapshot(Some("warm-pass test".to_string()), None)
1617            .unwrap();
1618
1619        // Pack + prune so every blob is pack-only.
1620        repo.store().pack_objects(false).unwrap();
1621        repo.store().prune_loose_objects().unwrap();
1622
1623        // Sanity: with a packed-then-pruned store, no canonical loose
1624        // file exists yet for the snapshot's blobs.
1625        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1626        let mut hashes = std::collections::BTreeSet::new();
1627        repo.collect_blob_hashes(&tree, &mut hashes).unwrap();
1628        for hash in &hashes {
1629            assert!(
1630                repo.store().loose_blob_path(hash).is_none(),
1631                "blob {} should be pack-only before warm",
1632                hash
1633            );
1634        }
1635
1636        // Warm: every blob should now be loose-uncompressed.
1637        let stats = repo
1638            .warm_canonical_store_for_state(&state.change_id)
1639            .unwrap();
1640        assert_eq!(stats.errors, 0, "warm pass produced errors: {:?}", stats);
1641        assert_eq!(stats.total(), hashes.len());
1642        assert!(
1643            stats.promoted >= hashes.len(),
1644            "expected to promote all {} blobs, got {} (already_loose={})",
1645            hashes.len(),
1646            stats.promoted,
1647            stats.already_loose
1648        );
1649        for hash in &hashes {
1650            assert!(
1651                repo.store().loose_blob_path(hash).is_some(),
1652                "blob {} should be loose+uncompressed after warm",
1653                hash
1654            );
1655        }
1656
1657        // Materialize across two worktrees on the same FS. Reading
1658        // back from each must yield the canonical bytes; isolation
1659        // is guaranteed by filesystem-level CoW (or full copy).
1660        let worktree_a = temp_dir.path().join("wt-a");
1661        let worktree_b = temp_dir.path().join("wt-b");
1662        repo.materialize_tree(&tree, &worktree_a).unwrap();
1663        repo.materialize_tree(&tree, &worktree_b).unwrap();
1664
1665        for entry in tree.entries() {
1666            let path_a = worktree_a.join(entry.name());
1667            let path_b = worktree_b.join(entry.name());
1668            assert_eq!(
1669                std::fs::read(&path_a).unwrap(),
1670                std::fs::read(&path_b).unwrap(),
1671                "{} must read back identically across worktrees",
1672                entry.name()
1673            );
1674        }
1675    }
1676
1677    /// Idempotent warm: a second pass over the same state must not
1678    /// rewrite anything. Every blob is `already_loose`.
1679    #[test]
1680    #[cfg(unix)]
1681    fn warm_canonical_store_is_idempotent() {
1682        let temp_dir = TempDir::new().unwrap();
1683        let repo = Repository::init_default(temp_dir.path()).unwrap();
1684
1685        for i in 0..3 {
1686            std::fs::write(
1687                temp_dir.path().join(format!("idem-{i}.txt")),
1688                format!("idem payload {i} {}", "x".repeat(160)),
1689            )
1690            .unwrap();
1691        }
1692        let state = repo
1693            .snapshot(Some("idempotent warm".to_string()), None)
1694            .unwrap();
1695        repo.store().pack_objects(false).unwrap();
1696        repo.store().prune_loose_objects().unwrap();
1697
1698        let first = repo
1699            .warm_canonical_store_for_state(&state.change_id)
1700            .unwrap();
1701        let second = repo
1702            .warm_canonical_store_for_state(&state.change_id)
1703            .unwrap();
1704
1705        assert_eq!(first.total(), second.total(), "blob count must be stable");
1706        assert_eq!(
1707            second.promoted, 0,
1708            "second warm must not promote anything (got {})",
1709            second.promoted
1710        );
1711        assert_eq!(
1712            second.already_loose,
1713            second.total(),
1714            "every blob must be already_loose on second pass"
1715        );
1716        assert_eq!(second.errors, 0);
1717    }
1718
1719    /// Storage win after warm + materialize on a CoW filesystem.
1720    /// We can no longer dedupe via inode (reflinks have distinct
1721    /// inodes by design), so on CoW filesystems we instead assert
1722    /// that **every materialized file has its own inode**, distinct
1723    /// from the canonical loose blob — proving the materializer
1724    /// took the reflink path (which gives the storage win on CoW
1725    /// without aliasing) rather than the in-memory `fs::write` path
1726    /// (which costs full duplicates).
1727    ///
1728    /// On non-CoW filesystems the test soft-skips. The materializer
1729    /// will use `fs::copy` and the storage win is not recoverable
1730    /// without reflink support.
1731    #[test]
1732    #[cfg(unix)]
1733    fn packed_repo_storage_win_after_warm_and_materialize() {
1734        use std::{collections::HashSet, os::unix::fs::MetadataExt};
1735
1736        let temp_dir = TempDir::new().unwrap();
1737        if !filesystem_supports_reflink(temp_dir.path()) {
1738            eprintln!(
1739                "[skip] filesystem at {:?} does not support reflinks; storage-win test is reflink-specific",
1740                temp_dir.path()
1741            );
1742            return;
1743        }
1744
1745        let repo = Repository::init_default(temp_dir.path()).unwrap();
1746
1747        let blob_count = 5;
1748        for i in 0..blob_count {
1749            std::fs::write(
1750                temp_dir.path().join(format!("file-{i}.txt")),
1751                format!("packed-storage-win payload {i} {}", "x".repeat(140 + i * 8)),
1752            )
1753            .unwrap();
1754        }
1755        let state = repo
1756            .snapshot(Some("packed storage win".to_string()), None)
1757            .unwrap();
1758        // Realistic steady state.
1759        repo.store().pack_objects(false).unwrap();
1760        repo.store().prune_loose_objects().unwrap();
1761
1762        // Warm so the first materialize doesn't pay decompress cost.
1763        let stats = repo
1764            .warm_canonical_store_for_state(&state.change_id)
1765            .unwrap();
1766        assert_eq!(stats.errors, 0);
1767
1768        let n_worktrees = 6;
1769        let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
1770        let mut all_paths = Vec::new();
1771        for w in 0..n_worktrees {
1772            let worktree = temp_dir.path().join(format!("wt-{w}"));
1773            repo.materialize_tree(&tree, &worktree).unwrap();
1774            for i in 0..blob_count {
1775                all_paths.push(worktree.join(format!("file-{i}.txt")));
1776            }
1777        }
1778
1779        // Every materialized file has its own inode (reflinks, not
1780        // hardlinks). Total inodes = files materialized.
1781        let mut inodes = HashSet::new();
1782        for path in &all_paths {
1783            inodes.insert(std::fs::metadata(path).unwrap().ino());
1784        }
1785        assert_eq!(
1786            inodes.len(),
1787            all_paths.len(),
1788            "every reflinked worktree file must have its own inode (got {} for {} files)",
1789            inodes.len(),
1790            all_paths.len()
1791        );
1792
1793        // No materialized file shares an inode with the canonical
1794        // loose blob — that would be the hardlink bug.
1795        let mut canonical_inodes = HashSet::new();
1796        for hash in tree.entries().iter().filter_map(|e| e.blob_hash()) {
1797            if let Some(loose) = repo.store().loose_blob_path(&hash) {
1798                canonical_inodes.insert(std::fs::metadata(&loose).unwrap().ino());
1799            }
1800        }
1801        for inode in &inodes {
1802            assert!(
1803                !canonical_inodes.contains(inode),
1804                "worktree file inode {} aliases the canonical loose blob — that's the hardlink bug",
1805                inode
1806            );
1807        }
1808
1809        eprintln!(
1810            "[packed-storage-win] n_worktrees={} blobs/tree={} reflink_path_confirmed=true",
1811            n_worktrees, blob_count
1812        );
1813    }
1814
1815    /// `promote_to_loose_uncompressed` is idempotent for an already
1816    /// loose+uncompressed blob — fast-path returns `Ok(false)` so a
1817    /// caller can distinguish "no work needed" from "promoted".
1818    #[test]
1819    fn promote_to_loose_uncompressed_idempotent_on_loose_blob() {
1820        let temp_dir = TempDir::new().unwrap();
1821        let repo = Repository::init_default(temp_dir.path()).unwrap();
1822
1823        let blob = Blob::from("idempotent promote payload");
1824        let hash = repo.store().put_blob(&blob).unwrap();
1825        // Already loose+uncompressed (under compression threshold).
1826        assert!(repo.store().loose_blob_path(&hash).is_some());
1827
1828        let did_work = repo.store().promote_to_loose_uncompressed(&hash).unwrap();
1829        assert!(
1830            !did_work,
1831            "promote on already-loose+uncompressed blob must be a no-op"
1832        );
1833    }
1834
1835    /// `promote_to_loose_uncompressed` on a missing blob bubbles a
1836    /// `NotFound`, not a silent success. Callers can degrade
1837    /// gracefully (e.g. lazy-path falls back to `fs::write`), but
1838    /// the failure must not be invisible.
1839    #[test]
1840    fn promote_to_loose_uncompressed_returns_error_for_missing_blob() {
1841        use objects::object::ContentHash;
1842
1843        let temp_dir = TempDir::new().unwrap();
1844        let repo = Repository::init_default(temp_dir.path()).unwrap();
1845
1846        let bogus = ContentHash::compute_typed("blob", b"never-stored");
1847        let result = repo.store().promote_to_loose_uncompressed(&bogus);
1848        assert!(
1849            result.is_err(),
1850            "promote on missing blob must error, got {:?}",
1851            result
1852        );
1853    }
1854}