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, 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
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: &ChangeId,
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: &[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 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(target_path.to_path_buf()));
560 }
561 remove_materialized_leaf(path)?;
562 std::os::unix::fs::symlink(target, path)?;
563 }
564 #[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 fn materialize_blob(
633 &self,
634 dest: &Path,
635 hash: &ContentHash,
636 executable: bool,
637 context: &MaterializationContext,
638 ) -> Result<()> {
639 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 set_file_mode(dest, false)?;
658 context.record_copy();
661 let _ = executable;
662 return Ok(());
663 }
664
665 if context.reflinks_enabled() {
666 if let Some(source) = self.store.loose_blob_path(hash)
668 && self.try_clone(&source, dest, executable, context)?
669 {
670 return Ok(());
671 }
672 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 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 fn try_clone(
733 &self,
734 source: &Path,
735 dest: &Path,
736 executable: bool,
737 context: &MaterializationContext,
738 ) -> Result<bool> {
739 let _ = fs::remove_file(dest);
743 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 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 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 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 Some((offender, action)) => {
821 Err(HeddleError::Io(enrich_fs_error(offender, action, err)))
822 }
823 }
824 }
825 }
826 }
827}
828
829fn 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
877fn 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 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 #[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 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 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 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 assert!(
1027 render_count.load(Ordering::Relaxed) > 0,
1028 "active sink should have rendered during materialization"
1029 );
1030 }
1031
1032 #[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 #[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 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 #[test]
1073 fn classify_clone_failure_unreadable_source_blames_source() {
1074 let temp = TempDir::new().unwrap();
1075 let source = temp.path().join("missing.blob"); let dest = temp.path().join("file");
1077
1078 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 #[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 #[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 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 #[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 #[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 #[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 #[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(®ular).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 std::fs::write(®ular, b"agent edits this").unwrap();
1254 assert_eq!(std::fs::read(®ular).unwrap(), b"agent edits this");
1255 }
1256
1257 #[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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 assert_eq!(std::fs::read(&worktree_a).unwrap(), blob.content());
1586 assert_eq!(std::fs::read(&worktree_b).unwrap(), blob.content());
1587
1588 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 #[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 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 repo.store().pack_objects(false).unwrap();
1621 repo.store().prune_loose_objects().unwrap();
1622
1623 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 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 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 #[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 #[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 repo.store().pack_objects(false).unwrap();
1760 repo.store().prune_loose_objects().unwrap();
1761
1762 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 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 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 #[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 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 #[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}