1use std::{
5 fs,
6 num::NonZeroUsize,
7 path::{Path, PathBuf},
8 sync::Arc,
9};
10
11use super::{
12 FsStore,
13 fs_impl::validate_state_serialized,
14 fs_io::{list_hashes_from_dir, list_state_ids_from_dir, read_file_bytes},
15 fs_paths::{blobs_dir, hash_path, packs_dir, state_path, states_dir, trees_dir},
16};
17use crate::{
18 object::{ContentHash, State, StateAttachment, StateAttachmentId},
19 store::{
20 FsRepackOperation, HeddleError, ObjectStore, RepackPolicy, RepackResourceLimits,
21 RepackSchedule, RepackScheduler, Result, SnapshotCommitArtifact, SnapshotCommitDescriptor,
22 TreeWrite, codec,
23 pack::{ObjectType as PackObjectType, PackBuilder, PackObjectId, PackReader},
24 snapshot_commit::snapshot_commit_marker_path,
25 },
26};
27
28pub(crate) fn list_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<Vec<PathBuf>> {
35 if !packs_dir.exists() {
36 return Ok(Vec::new());
37 }
38 let mut unpaired = Vec::new();
39 for entry in fs::read_dir(packs_dir)? {
40 let entry = entry?;
41 let path = entry.path();
42 if path.extension().and_then(|e| e.to_str()) != Some("pack") {
43 continue;
44 }
45 let idx = path.with_extension("idx");
46 if !idx.exists() {
47 unpaired.push(path);
48 }
49 }
50 unpaired.sort();
51 Ok(unpaired)
52}
53
54pub(crate) fn prune_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<(u64, u64)> {
59 let mut removed = 0u64;
60 let mut bytes_freed = 0u64;
61 for path in list_unpaired_pack_files(packs_dir)? {
62 let bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
63 match fs::remove_file(&path) {
64 Ok(()) => {
65 removed += 1;
66 bytes_freed = bytes_freed.saturating_add(bytes);
67 }
68 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
69 Err(e) => return Err(e),
70 }
71 }
72 Ok((removed, bytes_freed))
73}
74
75fn remove_file_ignore_missing(path: &std::path::Path) -> Result<()> {
76 match fs::remove_file(path) {
77 Ok(()) => Ok(()),
78 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
79 Err(e) => Err(HeddleError::from(e)),
80 }
81}
82
83fn remove_file_counted(path: &Path) -> Result<Option<u64>> {
84 let metadata = match fs::metadata(path) {
85 Ok(metadata) => metadata,
86 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
87 Err(error) => return Err(HeddleError::from(error)),
88 };
89 match fs::remove_file(path) {
90 Ok(()) => Ok(Some(metadata.len())),
91 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
92 Err(error) => Err(HeddleError::from(error)),
93 }
94}
95
96impl FsStore {
97 pub fn remove_blob_everywhere(&self, hash: &ContentHash) -> Result<bool> {
100 let was_present = ObjectStore::has_blob_locally(self, hash)?;
101 if was_present {
102 let scheduler = RepackScheduler::new(
103 RepackPolicy::default(),
104 RepackResourceLimits::new(NonZeroUsize::MIN),
105 );
106 let operation = Arc::new(FsRepackOperation::new(self.clone()).excluding_blob(*hash));
107 let RepackSchedule::Started(handle) = scheduler
108 .repack_now(operation)
109 .map_err(|error| HeddleError::InvalidObject(error.to_string()))?
110 else {
111 return Err(HeddleError::InvalidObject(
112 "exclusive purge repack did not start".to_string(),
113 ));
114 };
115 handle
116 .wait()
117 .map_err(|error| HeddleError::InvalidObject(error.to_string()))?;
118
119 self.reload_packs()?;
124
125 let loose = hash_path(&blobs_dir(&self.root), hash);
126 match fs::remove_file(&loose) {
127 Ok(()) => {
128 if let Some(parent) = loose.parent() {
129 crate::fs_atomic::sync_directory(parent)?;
130 }
131 }
132 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
133 Err(error) => return Err(error.into()),
134 }
135 }
136 self.clear_recent_object_caches();
137 if ObjectStore::has_blob_locally(self, hash)?
138 || ObjectStore::get_blob(self, hash)?.is_some()
139 || ObjectStore::get_blob_bytes(self, hash)?.is_some()
140 {
141 return Err(HeddleError::InvalidObject(format!(
142 "purged blob {} remains readable after pack rewrite",
143 hash.short()
144 )));
145 }
146 Ok(was_present)
147 }
148
149 #[doc(hidden)]
152 pub fn put_committed_snapshot_objects_packed(
153 &self,
154 blobs: Vec<(ContentHash, Vec<u8>)>,
155 trees: Vec<TreeWrite>,
156 tree: &TreeWrite,
157 state: &State,
158 attachments: Vec<StateAttachment>,
159 artifact: SnapshotCommitArtifact,
160 ) -> Result<SnapshotCommitDescriptor> {
161 self.put_snapshot_objects_packed_impl(
162 blobs,
163 trees,
164 tree,
165 state,
166 attachments,
167 Some(artifact),
168 )?
169 .ok_or_else(|| {
170 HeddleError::InvalidObject(
171 "committed snapshot pack did not expose its artifact descriptor".to_string(),
172 )
173 })
174 }
175
176 pub(super) fn put_snapshot_objects_packed_impl(
181 &self,
182 blobs: Vec<(ContentHash, Vec<u8>)>,
183 trees: Vec<TreeWrite>,
184 tree: &TreeWrite,
185 state: &State,
186 attachments: Vec<StateAttachment>,
187 commit_artifact: Option<SnapshotCommitArtifact>,
188 ) -> Result<Option<SnapshotCommitDescriptor>> {
189 let state_was_present = if commit_artifact.is_some() {
194 false
195 } else {
196 <Self as ObjectStore>::has_state(self, &state.id())?
197 };
198 let mut compression = self.compression;
199 if !self.snapshot_delta_search {
200 compression.max_delta_size = 0;
201 }
202 let mut builder = PackBuilder::new(compression);
203
204 for (hash, data) in blobs {
205 if commit_artifact.is_none() && ObjectStore::has_blob_locally(self, &hash)? {
206 continue;
207 }
208 builder.add(hash, PackObjectType::Blob, data);
209 }
210
211 let tree_hash = tree.tree.hash();
212 let mut staged_trees = Vec::with_capacity(trees.len() + 1);
213 let mut staged_encodings = Vec::with_capacity(trees.len() + 1);
214 let mut seen_trees = std::collections::HashSet::with_capacity(trees.len() + 1);
215 for authored_tree in trees {
216 let authored_hash = authored_tree.tree.hash();
217 let reuses_materialized = self
218 .try_get_tree_serialized_once(&authored_hash)?
219 .is_some_and(|body| !crate::object::is_delta_tree(&body));
220 if seen_trees.insert(authored_hash)
221 && !reuses_materialized
222 && (commit_artifact.is_some()
223 || !ObjectStore::has_tree_locally(self, &authored_hash)?)
224 {
225 let encoded = self.encode_tree_write(&authored_tree)?;
226 if matches!(
227 encoded.kind,
228 crate::store::codec::TreeEncodingKind::Delta { anchor, .. }
229 if anchor == authored_hash
230 ) {
231 return Err(HeddleError::InvalidObject(
232 "HDC1 result id must differ from its anchor id".to_string(),
233 ));
234 }
235 builder.add(authored_hash, PackObjectType::Tree, encoded.data);
236 staged_encodings.push((authored_hash, encoded.kind));
237 staged_trees.push((authored_hash, authored_tree.tree));
238 }
239 }
240 let reuses_materialized = self
241 .try_get_tree_serialized_once(&tree_hash)?
242 .is_some_and(|body| !crate::object::is_delta_tree(&body));
243 if !reuses_materialized
244 && (commit_artifact.is_some() || !ObjectStore::has_tree_locally(self, &tree_hash)?)
245 && seen_trees.insert(tree_hash)
246 {
247 let encoded = self.encode_tree_write(tree)?;
248 if matches!(
249 encoded.kind,
250 crate::store::codec::TreeEncodingKind::Delta { anchor, .. }
251 if anchor == tree_hash
252 ) {
253 return Err(HeddleError::InvalidObject(
254 "HDC1 result id must differ from its anchor id".to_string(),
255 ));
256 }
257 builder.add(tree_hash, PackObjectType::Tree, encoded.data);
258 staged_encodings.push((tree_hash, encoded.kind));
259 staged_trees.push((tree_hash, tree.tree.clone()));
260 }
261
262 let state_id = state.id();
263 builder.add_id(
264 PackObjectId::StateId(state_id),
265 PackObjectType::State,
266 rmp_serde::to_vec_named(state)?,
267 );
268 let attachment_ids = attachments
269 .iter()
270 .map(|attachment| {
271 if attachment.state_id != state_id {
272 return Err(HeddleError::InvalidObject(
273 "snapshot attachment targets a different state".to_string(),
274 ));
275 }
276 let id = attachment.id();
277 builder.add(
278 *id.as_hash(),
279 PackObjectType::StateAttachment,
280 rmp_serde::to_vec_named(attachment)?,
281 );
282 Ok(id)
283 })
284 .collect::<Result<Vec<StateAttachmentId>>>()?;
285 let artifact_id = commit_artifact.as_ref().map(SnapshotCommitArtifact::id);
286 let artifact_bytes = commit_artifact
287 .as_ref()
288 .map(rmp_serde::to_vec_named)
289 .transpose()?;
290 if let Some(artifact) = &commit_artifact {
291 artifact.validate()?;
292 let bytes = artifact_bytes.as_ref().ok_or_else(|| {
293 HeddleError::InvalidObject(
294 "snapshot commit artifact bytes were not encoded".to_string(),
295 )
296 })?;
297 builder.add(artifact.id(), PackObjectType::SnapshotCommit, bytes.clone());
298 }
299
300 let (pack_data, index_data, _stats, retained_objects) =
301 builder.build_retaining_objects()?;
302 let packs = packs_dir(&self.root);
303 let installed_pack_name = if commit_artifact.is_some() {
304 let (Some(artifact_id), Some(artifact_bytes)) = (artifact_id, artifact_bytes) else {
305 return Err(HeddleError::InvalidObject(
306 "snapshot commit artifact metadata is incomplete".to_string(),
307 ));
308 };
309 super::pack_install_journal::install_committed_snapshot_pack_bytes(
310 &packs,
311 pack_data,
312 index_data,
313 artifact_id,
314 artifact_bytes,
315 )?
316 } else {
317 super::pack_install_journal::install_snapshot_pack_bytes(&packs, pack_data, index_data)?
318 };
319 {
320 let mut manager = self.pack_manager().write().map_err(|_| {
321 HeddleError::Config("Failed to acquire pack manager lock".to_string())
322 })?;
323 manager.add_pack(
324 packs.join(format!("{installed_pack_name}.pack")),
325 packs.join(format!("{installed_pack_name}.idx")),
326 )?;
327 }
328 for (hash, kind) in staged_encodings {
329 self.remember_tree_encoding(hash, kind)?;
330 }
331 self.materialize_packed_attachment_index(&state_id, &attachment_ids, state_was_present)?;
332
333 if let Ok(mut cache) = self.recent_blobs.write() {
334 for (id, object_type, data) in retained_objects {
335 if let (PackObjectId::Hash(hash), PackObjectType::Blob) = (id, object_type) {
336 cache.insert(hash, crate::object::Blob::new(data));
337 }
338 }
339 }
340 if let Ok(mut cache) = self.recent_trees.write() {
341 for (hash, authored_tree) in staged_trees {
342 cache.insert(hash, authored_tree);
343 }
344 }
345 if let Ok(mut cache) = self.recent_states.write() {
346 let mut cached = state.clone();
347 cached.state_id = state_id;
348 cache.insert(state_id, cached);
349 }
350 let descriptor = if let Some(artifact) = commit_artifact {
351 let pack_path = packs.join(format!("{installed_pack_name}.pack"));
352 let index_path = packs.join(format!("{installed_pack_name}.idx"));
353 let object_ids = PackReader::open(&pack_path, &index_path)?.list_ids()?;
354 Some(SnapshotCommitDescriptor {
355 artifact,
356 pack_name: installed_pack_name,
357 pack_path,
358 object_ids,
359 })
360 } else {
361 None
362 };
363 Ok(descriptor)
364 }
365
366 pub(super) fn put_blobs_packed_impl(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
376 if blobs.is_empty() {
377 return Ok(());
378 }
379 let mut compression = self.compression;
389 if !self.snapshot_delta_search {
390 compression.max_delta_size = 0;
391 }
392 let mut builder = PackBuilder::new(compression);
393 let mut added = 0usize;
394 for (hash, data) in blobs {
395 if ObjectStore::has_blob_locally(self, &hash)? {
396 continue;
397 }
398 builder.add(hash, PackObjectType::Blob, data);
399 added += 1;
400 }
401 if added == 0 {
402 return Ok(());
403 }
404 let (pack_data, index_data, _stats, retained_objects) =
405 builder.build_retaining_objects()?;
406
407 self.install_pack_files(&pack_data, &index_data)?;
412 if let Ok(mut cache) = self.recent_blobs.write() {
413 for (id, object_type, data) in retained_objects {
414 if let (PackObjectId::Hash(hash), PackObjectType::Blob) = (id, object_type) {
415 cache.insert(hash, crate::object::Blob::new(data));
416 }
417 }
418 }
419 Ok(())
420 }
421
422 pub(super) fn pack_objects_impl(&self, delta_search: bool) -> Result<(u64, u64)> {
444 let _repack_lock = super::repack::acquire_repack_lock_blocking(&packs_dir(&self.root))?;
448 let loose_blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
449 let loose_trees = list_hashes_from_dir(&trees_dir(&self.root))?;
450
451 let (existing_ids, old_pack_files, commit_artifact_ids) = {
454 let manager = self.pack_manager().read().map_err(|_| {
455 HeddleError::Config("Failed to acquire pack manager lock".to_string())
456 })?;
457 let ids = manager.list_all_ids()?;
458 let commit_artifact_ids = manager
459 .snapshot_commit_descriptors()?
460 .into_iter()
461 .map(|descriptor| descriptor.artifact.id())
462 .collect::<Vec<_>>();
463 let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = manager
464 .pack_file_paths()
465 .into_iter()
466 .map(|(pack, index)| (pack.to_path_buf(), index.to_path_buf()))
467 .collect();
468 (ids, files, commit_artifact_ids)
469 };
470
471 if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
474 return Ok((0, 0));
475 }
476
477 let mut compression = self.compression;
485 if !delta_search {
486 compression.max_delta_size = 0;
487 }
488 let mut builder = PackBuilder::new(compression);
489 let loose_tree_set: std::collections::HashSet<ContentHash> =
490 loose_trees.iter().copied().collect();
491 let mut seen: std::collections::HashSet<crate::store::pack::PackObjectId> =
492 std::collections::HashSet::new();
493
494 for id in existing_ids {
499 if !seen.insert(id) {
500 continue;
501 }
502 let obj_type = {
503 let manager = self.pack_manager().read().map_err(|_| {
504 HeddleError::Config("Failed to acquire pack manager lock".to_string())
505 })?;
506 manager.get_object(&id)?
507 };
508 if let Some((obj_type, mut data)) = obj_type {
509 if let crate::store::pack::PackObjectId::Hash(hash) = id
510 && obj_type == PackObjectType::Tree
511 && loose_tree_set.contains(&hash)
512 && let Some(loose_data) = ObjectStore::get_tree_serialized(self, &hash)?
513 {
514 data = loose_data;
515 }
516 builder.add_id(id, obj_type, data);
517 }
518 }
519
520 for hash in &loose_blobs {
523 let id = crate::store::pack::PackObjectId::Hash(*hash);
524 if seen.contains(&id) {
525 continue;
526 }
527 if let Some(blob) = ObjectStore::get_blob(self, hash)? {
528 seen.insert(id);
529 builder.add(*hash, PackObjectType::Blob, blob.content().to_vec());
530 }
531 }
532 for hash in &loose_trees {
533 let id = crate::store::pack::PackObjectId::Hash(*hash);
534 if seen.contains(&id) {
535 continue;
536 }
537 if let Some(tree) = ObjectStore::get_tree(self, hash)? {
538 let data = tree.encode_canonical()?;
539 seen.insert(id);
540 builder.add(*hash, PackObjectType::Tree, data);
541 }
542 }
543
544 if seen.is_empty() {
545 return Ok((0, 0));
546 }
547
548 let (pack_data, index_data, stats) = builder.build()?;
549 let new_pack_name = blake3::hash(&pack_data).to_hex();
550 if commit_artifact_ids.is_empty() {
551 self.install_pack_files(&pack_data, &index_data)?;
552 } else {
553 super::pack_install_journal::install_snapshot_pack_bytes_with_commit_markers(
554 &packs_dir(&self.root),
555 pack_data,
556 index_data,
557 &commit_artifact_ids,
558 )?;
559 self.reload_packs()?;
560 }
561 self.clear_recent_object_caches();
569
570 for (pack_path, index_path) in &old_pack_files {
577 let is_new_pack = pack_path
578 .file_stem()
579 .and_then(|stem| stem.to_str())
580 .map(|stem| stem == new_pack_name.as_str())
581 .unwrap_or(false);
582 if is_new_pack {
583 continue;
584 }
585 remove_file_ignore_missing(pack_path)?;
586 remove_file_ignore_missing(index_path)?;
587 for artifact_id in &commit_artifact_ids {
588 remove_file_ignore_missing(&snapshot_commit_marker_path(pack_path, artifact_id))?;
589 }
590 }
591 self.reload_packs()?;
593 self.clear_recent_object_caches();
594
595 let saved = stats.total_uncompressed - stats.total_compressed;
596 Ok((stats.object_count, saved))
597 }
598
599 pub(super) fn install_pack_files(&self, pack_data: &[u8], index_data: &[u8]) -> Result<()> {
600 let packs = packs_dir(&self.root);
601 let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
605 &packs, pack_data, index_data,
606 )?;
607 self.reload_packs()?;
614 Ok(())
615 }
616
617 pub(super) fn install_pack_files_streaming(
628 &self,
629 src_pack_path: &std::path::Path,
630 src_index_path: &std::path::Path,
631 ) -> Result<()> {
632 use std::io::Read;
633
634 let packs = packs_dir(&self.root);
635 crate::fs_atomic::create_dir_all_durable(&packs)?;
636
637 let mut hasher = blake3::Hasher::new();
640 let mut file = fs::File::open(src_pack_path)?;
641 let mut buf = vec![0u8; 64 * 1024];
642 loop {
643 let n = file.read(&mut buf)?;
644 if n == 0 {
645 break;
646 }
647 hasher.update(&buf[..n]);
648 }
649 drop(file);
650 let pack_hash = hasher.finalize();
653 let pack_name = pack_hash.to_hex().to_string();
654
655 super::pack_install_journal::install_pack_files_journaled(
659 &packs,
660 src_pack_path,
661 src_index_path,
662 &pack_name,
663 )?;
664
665 self.clear_recent_object_caches();
666 self.reload_packs()?;
667 Ok(())
668 }
669
670 pub fn prune_unpaired_packs(&self) -> Result<(u64, u64)> {
672 let packs = packs_dir(&self.root);
673 Ok(prune_unpaired_pack_files(&packs)?)
674 }
675
676 pub(super) fn prune_loose_objects_impl(&self) -> Result<(u64, u64)> {
677 let mut removed = 0u64;
678 let mut bytes_freed = 0u64;
679
680 let blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
681 let trees = list_hashes_from_dir(&trees_dir(&self.root))?;
682 let states = list_state_ids_from_dir(&states_dir(&self.root))?;
683
684 let pack_manager = self
685 .pack_manager()
686 .read()
687 .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
688
689 for hash in &blobs {
690 if pack_manager.get_hashed_object(hash)?.is_some() {
691 let path = hash_path(&blobs_dir(&self.root), hash);
692 if let Some(bytes) = remove_file_counted(&path)? {
693 bytes_freed = bytes_freed.saturating_add(bytes);
694 removed += 1;
695 }
696 }
697 }
698
699 for hash in &trees {
700 let Some((obj_type, packed_data)) = pack_manager.get_hashed_object(hash)? else {
701 continue;
702 };
703 if obj_type != PackObjectType::Tree {
704 continue;
705 }
706 let path = hash_path(&trees_dir(&self.root), hash);
707 let Some(loose_data) = read_file_bytes(&path)? else {
708 continue;
709 };
710 let loose_body = codec::decode_tree_body(loose_data.as_slice())?;
711 if crate::object::is_delta_tree(&loose_body) {
712 continue;
713 }
714 let loose_tree = codec::decode_tree_serialized_with_key(&loose_body, *hash, None)?;
715 let found = loose_tree.hash();
716 if found != *hash {
717 return Err(HeddleError::Corruption {
718 expected: *hash,
719 found,
720 });
721 }
722 if crate::object::is_delta_tree(&packed_data) {
726 continue;
727 }
728 let Ok(packed_tree) = codec::decode_tree_serialized_with_key(&packed_data, *hash, None)
729 else {
730 continue;
731 };
732 let packed_found = packed_tree.hash();
733 if packed_found != *hash {
734 return Err(HeddleError::Corruption {
735 expected: *hash,
736 found: packed_found,
737 });
738 }
739 if packed_tree == loose_tree
740 && let Some(bytes) = remove_file_counted(&path)?
741 {
742 bytes_freed = bytes_freed.saturating_add(bytes);
743 removed += 1;
744 }
745 }
746
747 for id in &states {
748 let Some((obj_type, packed_data)) =
749 pack_manager.get_object(&PackObjectId::StateId(*id))?
750 else {
751 continue;
752 };
753 if obj_type != PackObjectType::State {
754 continue;
755 }
756 let path = state_path(&self.root, id);
757 let Some(loose_data) = read_file_bytes(&path)? else {
758 continue;
759 };
760 let loose_state = codec::decode_state(loose_data.as_slice())?;
761 let packed_state = validate_state_serialized(&packed_data, *id)?;
762 if !loose_state.accepts_stored_id(id) {
763 return Err(HeddleError::InvalidObject(format!(
764 "loose state id mismatch while pruning: expected {id}, computed {}",
765 loose_state.id()
766 )));
767 }
768 if packed_state == loose_state
769 && let Some(bytes) = remove_file_counted(&path)?
770 {
771 bytes_freed = bytes_freed.saturating_add(bytes);
772 removed += 1;
773 }
774 }
775
776 Ok((removed, bytes_freed))
777 }
778}
779
780#[cfg(test)]
781mod unpaired_pack_tests {
782 use std::fs;
783
784 use super::{list_unpaired_pack_files, prune_unpaired_pack_files};
785
786 #[test]
787 fn list_and_prune_unpaired_packs() {
788 let dir = tempfile::tempdir().unwrap();
789 let packs = dir.path();
790 fs::write(packs.join("aaa.pack"), b"pack-only").unwrap();
791 fs::write(packs.join("bbb.pack"), b"paired-pack").unwrap();
792 fs::write(packs.join("bbb.idx"), b"paired-idx").unwrap();
793 fs::write(packs.join("ccc.idx"), b"index-only").unwrap();
794
795 let listed = list_unpaired_pack_files(packs).unwrap();
796 assert_eq!(listed.len(), 1);
797 assert!(listed[0].ends_with("aaa.pack"));
798
799 let (removed, bytes) = prune_unpaired_pack_files(packs).unwrap();
800 assert_eq!(removed, 1);
801 assert_eq!(bytes, b"pack-only".len() as u64);
802 assert!(!packs.join("aaa.pack").exists());
803 assert!(packs.join("bbb.pack").exists());
804 assert!(packs.join("bbb.idx").exists());
805 assert!(packs.join("ccc.idx").exists());
806 assert!(list_unpaired_pack_files(packs).unwrap().is_empty());
807 }
808
809 #[test]
810 fn missing_packs_dir_is_empty() {
811 let dir = tempfile::tempdir().unwrap();
812 let missing = dir.path().join("nope");
813 assert!(list_unpaired_pack_files(&missing).unwrap().is_empty());
814 assert_eq!(prune_unpaired_pack_files(&missing).unwrap(), (0, 0));
815 }
816}