1use 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
29struct 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 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 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#[derive(Debug, Default, Clone, Copy)]
191pub struct WarmCanonicalStoreStats {
192 pub promoted: usize,
195 pub already_loose: usize,
197 pub errors: usize,
203}
204
205impl WarmCanonicalStoreStats {
206 pub fn total(&self) -> usize {
208 self.promoted + self.already_loose + self.errors
209 }
210}
211
212impl Repository {
213 #[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 #[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 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 TreeEntryTarget::Spoollink { .. } => {}
307 }
308 }
309 Ok(())
310 }
311
312 #[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 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 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 let context = MaterializationContext::new();
467
468 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 #[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 fn materialize_blob(
639 &self,
640 dest: &Path,
641 hash: &ContentHash,
642 executable: bool,
643 context: &MaterializationContext,
644 ) -> Result<()> {
645 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 set_file_mode(dest, false)?;
664 context.record_copy();
667 let _ = executable;
668 return Ok(());
669 }
670
671 if context.reflinks_enabled() {
672 if let Some(source) = self.store.loose_blob_path(hash)
674 && self.try_clone(&source, dest, executable, context)?
675 {
676 return Ok(());
677 }
678 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 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 fn try_clone(
739 &self,
740 source: &Path,
741 dest: &Path,
742 executable: bool,
743 context: &MaterializationContext,
744 ) -> Result<bool> {
745 let _ = fs::remove_file(dest);
749 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 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 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 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 Some((offender, action)) => {
827 Err(HeddleError::Io(enrich_fs_error(offender, action, err)))
828 }
829 }
830 }
831 }
832 }
833}
834
835fn 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
883fn 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 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 #[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 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 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 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 assert!(
1033 render_count.load(Ordering::Relaxed) > 0,
1034 "active sink should have rendered during materialization"
1035 );
1036 }
1037
1038 #[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 #[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 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 #[test]
1079 fn classify_clone_failure_unreadable_source_blames_source() {
1080 let temp = TempDir::new().unwrap();
1081 let source = temp.path().join("missing.blob"); let dest = temp.path().join("file");
1083
1084 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 #[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 #[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 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 #[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 #[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 #[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 #[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(®ular).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 std::fs::write(®ular, b"agent edits this").unwrap();
1260 assert_eq!(std::fs::read(®ular).unwrap(), b"agent edits this");
1261 }
1262
1263 #[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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 assert_eq!(std::fs::read(&worktree_a).unwrap(), blob.content());
1590 assert_eq!(std::fs::read(&worktree_b).unwrap(), blob.content());
1591
1592 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 #[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 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 repo.store().pack_objects(false).unwrap();
1625 repo.store().prune_loose_objects().unwrap();
1626
1627 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 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 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 #[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 #[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 repo.store().pack_objects(false).unwrap();
1756 repo.store().prune_loose_objects().unwrap();
1757
1758 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 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 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 #[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 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 #[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}