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 state.encode_current_msgpack()?,
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 attachment.encode_current_msgpack()?,
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 =
354 PackReader::open(&pack_path, &index_path, &self.root.join("tmp"))?.list_ids()?;
355 Some(SnapshotCommitDescriptor {
356 artifact,
357 pack_name: installed_pack_name,
358 pack_path,
359 object_ids,
360 })
361 } else {
362 None
363 };
364 Ok(descriptor)
365 }
366
367 pub(super) fn put_blobs_packed_impl(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
377 if blobs.is_empty() {
378 return Ok(());
379 }
380 let mut compression = self.compression;
390 if !self.snapshot_delta_search {
391 compression.max_delta_size = 0;
392 }
393 let mut builder = PackBuilder::new(compression);
394 let mut added = 0usize;
395 for (hash, data) in blobs {
396 if ObjectStore::has_blob_locally(self, &hash)? {
397 continue;
398 }
399 builder.add(hash, PackObjectType::Blob, data);
400 added += 1;
401 }
402 if added == 0 {
403 return Ok(());
404 }
405 let (pack_data, index_data, _stats, retained_objects) =
406 builder.build_retaining_objects()?;
407
408 self.install_pack_files(&pack_data, &index_data)?;
413 if let Ok(mut cache) = self.recent_blobs.write() {
414 for (id, object_type, data) in retained_objects {
415 if let (PackObjectId::Hash(hash), PackObjectType::Blob) = (id, object_type) {
416 cache.insert(hash, crate::object::Blob::new(data));
417 }
418 }
419 }
420 Ok(())
421 }
422
423 pub(super) fn pack_objects_impl(&self, delta_search: bool) -> Result<(u64, u64)> {
445 let _repack_lock = super::repack::acquire_repack_lock_blocking(&packs_dir(&self.root))?;
449 let loose_blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
450 let loose_trees = list_hashes_from_dir(&trees_dir(&self.root))?;
451
452 let (existing_ids, old_pack_files, commit_artifact_ids) = {
455 let manager = self.pack_manager().read().map_err(|_| {
456 HeddleError::Config("Failed to acquire pack manager lock".to_string())
457 })?;
458 let ids = manager.list_all_ids()?;
459 let commit_artifact_ids = manager
460 .snapshot_commit_descriptors()?
461 .into_iter()
462 .map(|descriptor| descriptor.artifact.id())
463 .collect::<Vec<_>>();
464 let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = manager
465 .pack_file_paths()
466 .into_iter()
467 .map(|(pack, index)| (pack.to_path_buf(), index.to_path_buf()))
468 .collect();
469 (ids, files, commit_artifact_ids)
470 };
471
472 if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
475 return Ok((0, 0));
476 }
477
478 let mut compression = self.compression;
486 if !delta_search {
487 compression.max_delta_size = 0;
488 }
489 let mut builder = PackBuilder::new(compression);
490 let loose_tree_set: std::collections::HashSet<ContentHash> =
491 loose_trees.iter().copied().collect();
492 let mut seen: std::collections::HashSet<crate::store::pack::PackObjectId> =
493 std::collections::HashSet::new();
494
495 for id in existing_ids {
500 if !seen.insert(id) {
501 continue;
502 }
503 let obj_type = {
504 let manager = self.pack_manager().read().map_err(|_| {
505 HeddleError::Config("Failed to acquire pack manager lock".to_string())
506 })?;
507 manager.get_object(&id)?
508 };
509 if let Some((obj_type, mut data)) = obj_type {
510 if let crate::store::pack::PackObjectId::Hash(hash) = id
511 && obj_type == PackObjectType::Tree
512 && loose_tree_set.contains(&hash)
513 && let Some(loose_data) = ObjectStore::get_tree_serialized(self, &hash)?
514 {
515 data = loose_data;
516 }
517 builder.add_id(id, obj_type, data);
518 }
519 }
520
521 for hash in &loose_blobs {
524 let id = crate::store::pack::PackObjectId::Hash(*hash);
525 if seen.contains(&id) {
526 continue;
527 }
528 if let Some(blob) = ObjectStore::get_blob(self, hash)? {
529 seen.insert(id);
530 builder.add(*hash, PackObjectType::Blob, blob.content().to_vec());
531 }
532 }
533 for hash in &loose_trees {
534 let id = crate::store::pack::PackObjectId::Hash(*hash);
535 if seen.contains(&id) {
536 continue;
537 }
538 if let Some(tree) = ObjectStore::get_tree(self, hash)? {
539 let data = tree.encode_canonical()?;
540 seen.insert(id);
541 builder.add(*hash, PackObjectType::Tree, data);
542 }
543 }
544
545 if seen.is_empty() {
546 return Ok((0, 0));
547 }
548
549 let (pack_data, index_data, stats) = builder.build()?;
550 let new_pack_name = blake3::hash(&pack_data).to_hex();
551 if commit_artifact_ids.is_empty() {
552 self.install_pack_files(&pack_data, &index_data)?;
553 } else {
554 super::pack_install_journal::install_snapshot_pack_bytes_with_commit_markers(
555 &packs_dir(&self.root),
556 pack_data,
557 index_data,
558 &commit_artifact_ids,
559 )?;
560 self.reload_packs()?;
561 }
562 self.clear_recent_object_caches();
570
571 for (pack_path, index_path) in &old_pack_files {
578 let is_new_pack = pack_path
579 .file_stem()
580 .and_then(|stem| stem.to_str())
581 .map(|stem| stem == new_pack_name.as_str())
582 .unwrap_or(false);
583 if is_new_pack {
584 continue;
585 }
586 remove_file_ignore_missing(pack_path)?;
587 remove_file_ignore_missing(index_path)?;
588 for artifact_id in &commit_artifact_ids {
589 remove_file_ignore_missing(&snapshot_commit_marker_path(pack_path, artifact_id))?;
590 }
591 }
592 self.reload_packs()?;
594 self.clear_recent_object_caches();
595
596 let saved = stats.total_uncompressed - stats.total_compressed;
597 Ok((stats.object_count, saved))
598 }
599
600 pub(super) fn install_pack_files(&self, pack_data: &[u8], index_data: &[u8]) -> Result<()> {
601 let packs = packs_dir(&self.root);
602 let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
606 &packs, pack_data, index_data,
607 )?;
608 self.reload_packs()?;
615 Ok(())
616 }
617
618 pub(super) fn install_pack_files_streaming(
629 &self,
630 src_pack_path: &std::path::Path,
631 src_index_path: &std::path::Path,
632 pack_name: &str,
633 ) -> Result<()> {
634 let packs = packs_dir(&self.root);
635 crate::fs_atomic::create_dir_all_durable(&packs)?;
636
637 super::pack_install_journal::install_pack_files_journaled(
641 &packs,
642 src_pack_path,
643 src_index_path,
644 pack_name,
645 )?;
646
647 self.clear_recent_object_caches();
648 self.reload_packs()?;
649 Ok(())
650 }
651
652 pub fn prune_unpaired_packs(&self) -> Result<(u64, u64)> {
654 let packs = packs_dir(&self.root);
655 Ok(prune_unpaired_pack_files(&packs)?)
656 }
657
658 pub(super) fn prune_loose_objects_impl(&self) -> Result<(u64, u64)> {
659 let mut removed = 0u64;
660 let mut bytes_freed = 0u64;
661
662 let blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
663 let trees = list_hashes_from_dir(&trees_dir(&self.root))?;
664 let states = list_state_ids_from_dir(&states_dir(&self.root))?;
665
666 for hash in &blobs {
667 let packed = self
668 .pack_manager()
669 .read()
670 .map_err(|_| {
671 HeddleError::Config("Failed to acquire pack manager lock".to_string())
672 })?
673 .get_hashed_object(hash)?
674 .is_some();
675 if packed {
676 let path = hash_path(&blobs_dir(&self.root), hash);
677 if let Some(bytes) = remove_file_counted(&path)? {
678 bytes_freed = bytes_freed.saturating_add(bytes);
679 removed += 1;
680 }
681 }
682 }
683
684 for hash in &trees {
685 let path = hash_path(&trees_dir(&self.root), hash);
686 let Some(loose_data) = read_file_bytes(&path)? else {
687 continue;
688 };
689 let loose_body = codec::decode_tree_body(loose_data.as_slice())?;
690 let loose_is_delta = crate::object::is_delta_tree(&loose_body);
691 let loose_tree = self.decode_tree_storage_body(*hash, &loose_body)?;
692 let found = loose_tree.hash();
693 if found != *hash {
694 return Err(HeddleError::Corruption {
695 expected: *hash,
696 found,
697 });
698 }
699 let npk1_tree = self
700 .npk1_manager()
701 .read()
702 .map_err(|_| {
703 HeddleError::Config("Failed to acquire NPK1 manager lock".to_string())
704 })?
705 .get_tree(hash)?;
706 if npk1_tree.as_ref() == Some(&loose_tree) {
707 if let Some(bytes) = remove_file_counted(&path)? {
708 bytes_freed = bytes_freed.saturating_add(bytes);
709 removed += 1;
710 }
711 continue;
712 }
713 if loose_is_delta {
716 continue;
717 }
718 let packed = self
719 .pack_manager()
720 .read()
721 .map_err(|_| {
722 HeddleError::Config("Failed to acquire pack manager lock".to_string())
723 })?
724 .get_hashed_object(hash)?;
725 let Some((obj_type, packed_data)) = packed else {
726 continue;
727 };
728 if obj_type != PackObjectType::Tree {
729 continue;
730 }
731 if crate::object::is_delta_tree(&packed_data) {
735 continue;
736 }
737 let Ok(packed_tree) = codec::decode_tree_serialized_with_key(&packed_data, *hash, None)
738 else {
739 continue;
740 };
741 let packed_found = packed_tree.hash();
742 if packed_found != *hash {
743 return Err(HeddleError::Corruption {
744 expected: *hash,
745 found: packed_found,
746 });
747 }
748 if packed_tree == loose_tree
749 && let Some(bytes) = remove_file_counted(&path)?
750 {
751 bytes_freed = bytes_freed.saturating_add(bytes);
752 removed += 1;
753 }
754 }
755
756 for id in &states {
757 let packed = self
758 .pack_manager()
759 .read()
760 .map_err(|_| {
761 HeddleError::Config("Failed to acquire pack manager lock".to_string())
762 })?
763 .get_object(&PackObjectId::StateId(*id))?;
764 let Some((obj_type, packed_data)) = packed else {
765 continue;
766 };
767 if obj_type != PackObjectType::State {
768 continue;
769 }
770 let path = state_path(&self.root, id);
771 let Some(loose_data) = read_file_bytes(&path)? else {
772 continue;
773 };
774 let loose_state = codec::decode_state(loose_data.as_slice())?;
775 let packed_state = validate_state_serialized(&packed_data, *id)?;
776 if !loose_state.accepts_stored_id(id) {
777 return Err(HeddleError::InvalidObject(format!(
778 "loose state id mismatch while pruning: expected {id}, computed {}",
779 loose_state.id()
780 )));
781 }
782 if packed_state == loose_state
783 && let Some(bytes) = remove_file_counted(&path)?
784 {
785 bytes_freed = bytes_freed.saturating_add(bytes);
786 removed += 1;
787 }
788 }
789
790 Ok((removed, bytes_freed))
791 }
792}
793
794#[cfg(test)]
795mod unpaired_pack_tests {
796 use std::fs;
797
798 use super::{list_unpaired_pack_files, prune_unpaired_pack_files};
799
800 #[test]
801 fn list_and_prune_unpaired_packs() {
802 let dir = tempfile::tempdir().unwrap();
803 let packs = dir.path();
804 fs::write(packs.join("aaa.pack"), b"pack-only").unwrap();
805 fs::write(packs.join("bbb.pack"), b"paired-pack").unwrap();
806 fs::write(packs.join("bbb.idx"), b"paired-idx").unwrap();
807 fs::write(packs.join("ccc.idx"), b"index-only").unwrap();
808
809 let listed = list_unpaired_pack_files(packs).unwrap();
810 assert_eq!(listed.len(), 1);
811 assert!(listed[0].ends_with("aaa.pack"));
812
813 let (removed, bytes) = prune_unpaired_pack_files(packs).unwrap();
814 assert_eq!(removed, 1);
815 assert_eq!(bytes, b"pack-only".len() as u64);
816 assert!(!packs.join("aaa.pack").exists());
817 assert!(packs.join("bbb.pack").exists());
818 assert!(packs.join("bbb.idx").exists());
819 assert!(packs.join("ccc.idx").exists());
820 assert!(list_unpaired_pack_files(packs).unwrap().is_empty());
821 }
822
823 #[test]
824 fn missing_packs_dir_is_empty() {
825 let dir = tempfile::tempdir().unwrap();
826 let missing = dir.path().join("nope");
827 assert!(list_unpaired_pack_files(&missing).unwrap().is_empty());
828 assert_eq!(prune_unpaired_pack_files(&missing).unwrap(), (0, 0));
829 }
830}