Skip to main content

objects/store/fs/
fs_pack.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pack and prune operations for FsStore.
3
4use std::{
5    fs,
6    path::{Path, PathBuf},
7};
8
9use super::{
10    FsStore,
11    fs_io::{list_hashes_from_dir, read_file_bytes},
12    fs_paths::{blobs_dir, hash_path, packs_dir, trees_dir},
13};
14use crate::{
15    object::{ContentHash, State, StateAttachment, StateAttachmentId, Tree},
16    store::{
17        HeddleError, ObjectStore, Result, SnapshotCommitArtifact, SnapshotCommitDescriptor, codec,
18        pack::{ObjectType as PackObjectType, PackBuilder, PackObjectId},
19        snapshot_commit::snapshot_commit_marker_path,
20    },
21};
22
23/// Paths of `*.pack` files in `packs_dir` that have no matching `*.idx`.
24///
25/// L8 residual: crash between durable pack and index publish can leave an
26/// unpaired pack that [`FsStore::reload_packs`] ignores. Listing supports
27/// optional GC (design: `docs/program/L8_PACK_INSTALL_JOURNAL.md` Option D).
28/// Does not delete anything.
29pub(crate) fn list_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<Vec<PathBuf>> {
30    if !packs_dir.exists() {
31        return Ok(Vec::new());
32    }
33    let mut unpaired = Vec::new();
34    for entry in fs::read_dir(packs_dir)? {
35        let entry = entry?;
36        let path = entry.path();
37        if path.extension().and_then(|e| e.to_str()) != Some("pack") {
38            continue;
39        }
40        let idx = path.with_extension("idx");
41        if !idx.exists() {
42            unpaired.push(path);
43        }
44    }
45    unpaired.sort();
46    Ok(unpaired)
47}
48
49/// Remove unpaired `*.pack` files (no matching `*.idx`) under `packs_dir`.
50///
51/// Safe for correctness: loaders never open unpaired packs. Bounds L8 disk
52/// leak. Returns `(removed_count, bytes_freed)`.
53pub(crate) fn prune_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<(u64, u64)> {
54    let mut removed = 0u64;
55    let mut bytes_freed = 0u64;
56    for path in list_unpaired_pack_files(packs_dir)? {
57        let bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
58        match fs::remove_file(&path) {
59            Ok(()) => {
60                removed += 1;
61                bytes_freed = bytes_freed.saturating_add(bytes);
62            }
63            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
64            Err(e) => return Err(e),
65        }
66    }
67    Ok((removed, bytes_freed))
68}
69
70fn remove_file_ignore_missing(path: &std::path::Path) -> Result<()> {
71    match fs::remove_file(path) {
72        Ok(()) => Ok(()),
73        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
74        Err(e) => Err(HeddleError::from(e)),
75    }
76}
77
78impl FsStore {
79    pub(crate) fn put_committed_snapshot_objects_packed_impl(
80        &self,
81        blobs: Vec<(ContentHash, Vec<u8>)>,
82        tree: &Tree,
83        state: &State,
84        attachments: Vec<StateAttachment>,
85        artifact: SnapshotCommitArtifact,
86    ) -> Result<SnapshotCommitDescriptor> {
87        self.put_snapshot_objects_packed_impl(blobs, tree, state, attachments, Some(artifact))?
88            .ok_or_else(|| {
89                HeddleError::InvalidObject(
90                    "committed snapshot pack did not expose its artifact descriptor".to_string(),
91                )
92            })
93    }
94
95    /// Install blobs, root tree, state, and immutable authored attachments
96    /// through one pack publication. Ordinary callers treat the pack as
97    /// pre-oplog staging; committed structured snapshots add their local trust
98    /// marker in the same directory barrier and make the pack authoritative.
99    pub(super) fn put_snapshot_objects_packed_impl(
100        &self,
101        blobs: Vec<(ContentHash, Vec<u8>)>,
102        tree: &Tree,
103        state: &State,
104        attachments: Vec<StateAttachment>,
105        commit_artifact: Option<SnapshotCommitArtifact>,
106    ) -> Result<Option<SnapshotCommitDescriptor>> {
107        // A committed snapshot artifact is installed only after exact-once and
108        // isolation validation. Its freshly-authored StateId cannot be a retry
109        // (dedup returns before this callback), so avoid an expected-negative
110        // pack-directory rescan on every native capture.
111        let state_was_present = if commit_artifact.is_some() {
112            false
113        } else {
114            <Self as ObjectStore>::has_state(self, &state.id())?
115        };
116        let mut compression = self.compression;
117        compression.max_delta_size = 0;
118        let mut builder = PackBuilder::new(compression);
119        let mut staged_blobs = Vec::with_capacity(blobs.len());
120
121        for (hash, data) in blobs {
122            if commit_artifact.is_none() && ObjectStore::has_blob_locally(self, &hash)? {
123                continue;
124            }
125            staged_blobs.push((hash, data.clone()));
126            builder.add(hash, PackObjectType::Blob, data);
127        }
128
129        let tree_hash = tree.hash();
130        if commit_artifact.is_some() || !ObjectStore::has_tree_locally(self, &tree_hash)? {
131            builder.add(
132                tree_hash,
133                PackObjectType::Tree,
134                rmp_serde::to_vec_named(tree)?,
135            );
136        }
137
138        let state_id = state.id();
139        builder.add_id(
140            PackObjectId::StateId(state_id),
141            PackObjectType::State,
142            rmp_serde::to_vec_named(state)?,
143        );
144        let attachment_ids = attachments
145            .iter()
146            .map(|attachment| {
147                if attachment.state_id != state_id {
148                    return Err(HeddleError::InvalidObject(
149                        "snapshot attachment targets a different state".to_string(),
150                    ));
151                }
152                let id = attachment.id();
153                builder.add(
154                    *id.as_hash(),
155                    PackObjectType::StateAttachment,
156                    rmp_serde::to_vec_named(attachment)?,
157                );
158                Ok(id)
159            })
160            .collect::<Result<Vec<StateAttachmentId>>>()?;
161        let artifact_id = commit_artifact.as_ref().map(SnapshotCommitArtifact::id);
162        if let Some(artifact) = &commit_artifact {
163            artifact.validate()?;
164            builder.add(
165                artifact.id(),
166                PackObjectType::SnapshotCommit,
167                rmp_serde::to_vec_named(artifact)?,
168            );
169        }
170
171        let (pack_data, index_data, _stats) = builder.build()?;
172        let packs = packs_dir(&self.root);
173        let installed_pack_name = if commit_artifact.is_some() {
174            super::pack_install_journal::install_committed_snapshot_pack_bytes(
175                &packs,
176                pack_data,
177                index_data,
178                artifact_id.expect("commit artifact id is present"),
179            )?
180        } else {
181            super::pack_install_journal::install_snapshot_pack_bytes(&packs, pack_data, index_data)?
182        };
183        {
184            let mut manager = self.pack_manager().write().map_err(|_| {
185                HeddleError::Config("Failed to acquire pack manager lock".to_string())
186            })?;
187            manager.add_pack(
188                packs.join(format!("{installed_pack_name}.pack")),
189                packs.join(format!("{installed_pack_name}.idx")),
190            )?;
191        }
192        self.materialize_packed_attachment_index(&state_id, &attachment_ids, state_was_present)?;
193
194        if let Ok(mut cache) = self.recent_blobs.write() {
195            for (hash, data) in staged_blobs {
196                cache.insert(hash, crate::object::Blob::from_slice(&data));
197            }
198        }
199        if let Ok(mut cache) = self.recent_trees.write() {
200            cache.insert(tree_hash, tree.clone());
201        }
202        if let Ok(mut cache) = self.recent_states.write() {
203            let mut cached = state.clone();
204            cached.state_id = state_id;
205            cache.insert(state_id, cached);
206        }
207        let descriptor = if let Some(artifact_id) = artifact_id {
208            self.pack_manager()
209                .read()
210                .map_err(|_| {
211                    HeddleError::Config("Failed to acquire pack manager lock".to_string())
212                })?
213                .snapshot_commit_descriptor_for_state(&state_id)?
214                .filter(|descriptor| descriptor.artifact.id() == artifact_id)
215        } else {
216            None
217        };
218        Ok(descriptor)
219    }
220
221    /// Bulk-install many blobs as a single packfile. Two fsyncs total
222    /// (one for `.pack`, one for `.idx`) regardless of blob count —
223    /// vs. N×fsync if each blob were written loose. Used by the
224    /// snapshot hot path; called at the end of the tree walk with
225    /// every new blob accumulated in memory.
226    ///
227    /// Skips blobs already in the store (whether loose or packed) so
228    /// re-snapshotting an unchanged worktree doesn't churn the pack
229    /// directory. With every blob already known, this is a no-op.
230    pub(super) fn put_blobs_packed_impl(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
231        if blobs.is_empty() {
232            return Ok(());
233        }
234        // Snapshot-time pack: skip the sliding-window delta search.
235        // It's a CPU win on similar-content files (the GC packer
236        // benefits) but for a single snapshot the inputs are
237        // unrelated content (random binaries, small text, etc.) and
238        // every pair-wise delta estimate runs across the full
239        // payloads — for 16×4MB blobs that's tens of seconds of
240        // hashing for ~zero compression benefit. GC's
241        // `pack_objects_impl` keeps the full delta search; this
242        // path only optimizes durability + write throughput.
243        let mut compression = self.compression;
244        compression.max_delta_size = 0;
245        let mut builder = PackBuilder::new(compression);
246        let mut staged: Vec<(ContentHash, Vec<u8>)> = Vec::with_capacity(blobs.len());
247        for (hash, data) in blobs {
248            if ObjectStore::has_blob_locally(self, &hash)? {
249                continue;
250            }
251            staged.push((hash, data.clone()));
252            builder.add(hash, PackObjectType::Blob, data);
253        }
254        if staged.is_empty() {
255            return Ok(());
256        }
257        let (pack_data, index_data, _stats) = builder.build()?;
258
259        // Install the pack files. `install_pack_files` clears the
260        // recent-objects caches because a generic pack install (e.g.
261        // received over the network) might shadow loose objects we
262        // didn't write. For our locally-built pack we know exactly
263        // what we just installed, so we re-populate `recent_blobs`
264        // with the staged contents immediately afterwards. Without
265        // this the snapshot hot path takes a cache miss on every
266        // blob it just wrote, and `seed_large_repository` style
267        // benchmarks that snapshot-many-times-in-a-loop end up
268        // re-reading every parent state from disk between
269        // iterations.
270        self.install_pack_files(&pack_data, &index_data)?;
271        if let Ok(mut cache) = self.recent_blobs.write() {
272            for (hash, data) in staged {
273                cache.insert(hash, crate::object::Blob::from_slice(&data));
274            }
275        }
276        Ok(())
277    }
278
279    /// Consolidate the object store into a single pack.
280    ///
281    /// GC must *shrink* the set of places a reader has to look, not grow
282    /// it. The naive "pack the loose objects into a fresh pack" strategy
283    /// regressed read performance badly: every `maintenance gc` minted a
284    /// brand-new pack *alongside* the existing pack(s) and (by default)
285    /// left the now-redundant loose copies in place. The result was an
286    /// object store with strictly MORE sources to search — loose objects
287    /// plus an ever-growing fleet of packs — and `PackManager::get_object`
288    /// probes every pack linearly, so each extra pack roughly doubled the
289    /// cost of the object lookups that `status`/`diff`/verification do.
290    ///
291    /// This implementation does a true repack: it folds every object
292    /// already living in a pack *together with* the loose blobs and trees
293    /// into one new consolidated pack, installs it, and then deletes the
294    /// superseded packs. Combined with the caller's
295    /// `prune_loose_objects`, the store ends a GC with exactly one pack
296    /// and no loose duplicates — strictly fewer read sources than it
297    /// started with. Running GC again over an already-consolidated store
298    /// is a no-op (nothing loose, one pack already covers everything).
299    ///
300    pub(super) fn pack_objects_impl(&self, aggressive: bool) -> Result<(u64, u64)> {
301        let loose_blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
302        let loose_trees = list_hashes_from_dir(&trees_dir(&self.root))?;
303
304        // Snapshot what the existing packs already hold, plus the file
305        // paths we'll retire once the consolidated pack is installed.
306        let (existing_ids, old_pack_files, commit_artifact_ids) = {
307            let manager = self.pack_manager().read().map_err(|_| {
308                HeddleError::Config("Failed to acquire pack manager lock".to_string())
309            })?;
310            let ids = manager.list_all_ids()?;
311            let commit_artifact_ids = manager
312                .snapshot_commit_descriptors()?
313                .into_iter()
314                .map(|descriptor| descriptor.artifact.id())
315                .collect::<Vec<_>>();
316            let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = manager
317                .pack_file_paths()
318                .into_iter()
319                .map(|(pack, index)| (pack.to_path_buf(), index.to_path_buf()))
320                .collect();
321            (ids, files, commit_artifact_ids)
322        };
323
324        // Nothing loose and at most one pack already — the store is
325        // already consolidated; don't churn a fresh identical pack.
326        if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
327            return Ok((0, 0));
328        }
329
330        // Consolidation packs every object that's already packed plus the
331        // loose ones. The default path SKIPS the sliding-window delta
332        // search: it runs across the full payloads of every object and on
333        // a large native store (tens of MB across thousands of objects)
334        // costs minutes for a near-zero size win, because the carried-
335        // forward objects are already zstd-compressed. `--aggressive`
336        // opts back into the full delta search for the rare "shrink the
337        // pack at all costs" case. This mirrors the snapshot hot path
338        // (`put_blobs_packed_impl`), which disables delta for the same
339        // reason.
340        let mut compression = self.compression;
341        if !aggressive {
342            compression.max_delta_size = 0;
343        }
344        let mut builder = PackBuilder::new(compression);
345        let loose_tree_set: std::collections::HashSet<ContentHash> =
346            loose_trees.iter().copied().collect();
347        let mut seen: std::collections::HashSet<crate::store::pack::PackObjectId> =
348            std::collections::HashSet::new();
349
350        // 1. Carry forward everything already in a pack so the old packs
351        //    can be retired. `get_object` resolves the body + type for
352        //    any id (blob/tree/state/action), and `add_id` preserves
353        //    content-addressed state objects.
354        for id in existing_ids {
355            if !seen.insert(id) {
356                continue;
357            }
358            let obj_type = {
359                let manager = self.pack_manager().read().map_err(|_| {
360                    HeddleError::Config("Failed to acquire pack manager lock".to_string())
361                })?;
362                manager.get_object(&id)?
363            };
364            if let Some((obj_type, mut data)) = obj_type {
365                if let crate::store::pack::PackObjectId::Hash(hash) = id
366                    && obj_type == PackObjectType::Tree
367                    && loose_tree_set.contains(&hash)
368                    && let Some(loose_data) = ObjectStore::get_tree_serialized(self, &hash)?
369                {
370                    data = loose_data;
371                }
372                builder.add_id(id, obj_type, data);
373            }
374        }
375
376        // 2. Fold in the loose blobs and trees. Skip any whose hash is
377        //    already covered by a carried-forward pack entry.
378        for hash in &loose_blobs {
379            let id = crate::store::pack::PackObjectId::Hash(*hash);
380            if seen.contains(&id) {
381                continue;
382            }
383            if let Some(blob) = ObjectStore::get_blob(self, hash)? {
384                seen.insert(id);
385                builder.add(*hash, PackObjectType::Blob, blob.content().to_vec());
386            }
387        }
388        for hash in &loose_trees {
389            let id = crate::store::pack::PackObjectId::Hash(*hash);
390            if seen.contains(&id) {
391                continue;
392            }
393            if let Some(tree) = ObjectStore::get_tree(self, hash)? {
394                let data = rmp_serde::to_vec(&tree)?;
395                seen.insert(id);
396                builder.add(*hash, PackObjectType::Tree, data);
397            }
398        }
399
400        if seen.is_empty() {
401            return Ok((0, 0));
402        }
403
404        let (pack_data, index_data, stats) = builder.build()?;
405        let new_pack_name = blake3::hash(&pack_data).to_hex();
406        if commit_artifact_ids.is_empty() {
407            self.install_pack_files(&pack_data, &index_data)?;
408        } else {
409            super::pack_install_journal::install_snapshot_pack_bytes_with_commit_markers(
410                &packs_dir(&self.root),
411                pack_data,
412                index_data,
413                &commit_artifact_ids,
414            )?;
415            self.reload_packs()?;
416        }
417        // GC packs *replace* loose objects (followed by
418        // `prune_loose_objects`). Bust the recent-objects caches so
419        // a subsequent get_* doesn't return a stale `Blob`/`Tree`
420        // pointing at a path we're about to delete. The snapshot hot
421        // path doesn't go through here — it calls
422        // `install_pack_files` directly via `put_blobs_packed_impl`,
423        // which keeps its caches warm.
424        self.clear_recent_object_caches();
425
426        // Retire the superseded packs now that the consolidated pack is
427        // durably installed and every object they held has been carried
428        // forward. The consolidated pack is content-addressed, so if it
429        // happened to hash-collide with an old pack (a store that was
430        // already a single consolidated pack) that file is excluded here.
431        // Stack hex digest; compare as &str — no format!/String intermediate.
432        for (pack_path, index_path) in &old_pack_files {
433            let is_new_pack = pack_path
434                .file_stem()
435                .and_then(|stem| stem.to_str())
436                .map(|stem| stem == new_pack_name.as_str())
437                .unwrap_or(false);
438            if is_new_pack {
439                continue;
440            }
441            remove_file_ignore_missing(pack_path)?;
442            remove_file_ignore_missing(index_path)?;
443            for artifact_id in &commit_artifact_ids {
444                remove_file_ignore_missing(&snapshot_commit_marker_path(pack_path, artifact_id))?;
445            }
446        }
447        // Disk shrank (packs removed), so `reload_if_disk_grew` would
448        // not pick this up — force a full reload of the pack list.
449        self.reload_packs()?;
450        self.clear_recent_object_caches();
451
452        let saved = stats.total_uncompressed - stats.total_compressed;
453        Ok((stats.object_count, saved))
454    }
455
456    pub(super) fn install_pack_files(&self, pack_data: &[u8], index_data: &[u8]) -> Result<()> {
457        let packs = packs_dir(&self.root);
458        // L8 A+: durable staging + intent journal for in-memory pack install
459        // (same crash-safety as install_pack_files_streaming).
460        // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
461        let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
462            &packs, pack_data, index_data,
463        )?;
464        // Pack manager picks up the new files. We do *not* clear the
465        // recent-object caches here — every caller that follows this
466        // with a destructive prune is responsible for clearing them
467        // explicitly. Snapshot installs rely on cache stickiness to
468        // keep tight snapshot loops fast (see
469        // `put_blobs_packed_impl`).
470        self.reload_packs()?;
471        Ok(())
472    }
473
474    /// Move a pack and its index already on disk into the store's
475    /// pack directory, computing the pack's content-hash by streaming
476    /// the file (constant memory regardless of pack size). Pairs with
477    /// `StreamingPackBuilder`: pack data, the index, *and* this
478    /// installation step never load the full pack or index into
479    /// memory.
480    ///
481    /// Sources are staged then published via the L8 A+ install journal
482    /// ([`super::pack_install_journal`]): durable staging + intent, then
483    /// pack/index publish with crash recovery on reload.
484    pub(super) fn install_pack_files_streaming(
485        &self,
486        src_pack_path: &std::path::Path,
487        src_index_path: &std::path::Path,
488    ) -> Result<()> {
489        use std::io::Read;
490
491        let packs = packs_dir(&self.root);
492        crate::fs_atomic::create_dir_all_durable(&packs)?;
493
494        // Stream-hash the pack file to derive its name. 64 KiB chunks
495        // keep the hasher's working set tiny.
496        let mut hasher = blake3::Hasher::new();
497        let mut file = fs::File::open(src_pack_path)?;
498        let mut buf = vec![0u8; 64 * 1024];
499        loop {
500            let n = file.read(&mut buf)?;
501            if n == 0 {
502                break;
503            }
504            hasher.update(&buf[..n]);
505        }
506        drop(file);
507        // Native digest for potential callers; hex String only for the journal
508        // path/name boundary (filenames + intent JSON).
509        let pack_hash = hasher.finalize();
510        let pack_name = pack_hash.to_hex().to_string();
511
512        // L8 A+: durable staging + intent journal, then pack/index publish.
513        // Recovery on reload finishes or aborts incomplete installs.
514        // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
515        super::pack_install_journal::install_pack_files_journaled(
516            &packs,
517            src_pack_path,
518            src_index_path,
519            &pack_name,
520        )?;
521
522        self.clear_recent_object_caches();
523        self.reload_packs()?;
524        Ok(())
525    }
526
527    /// Remove L8 orphan packs (`.pack` without `.idx`) from this store.
528    pub fn prune_unpaired_packs(&self) -> Result<(u64, u64)> {
529        let packs = packs_dir(&self.root);
530        Ok(prune_unpaired_pack_files(&packs)?)
531    }
532
533    pub(super) fn prune_loose_objects_impl(&self) -> Result<(u64, u64)> {
534        let mut removed = 0u64;
535        let mut bytes_freed = 0u64;
536
537        let blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
538        let trees = list_hashes_from_dir(&trees_dir(&self.root))?;
539
540        let pack_manager = self
541            .pack_manager()
542            .read()
543            .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;
544
545        for hash in &blobs {
546            if pack_manager.get_hashed_object(hash)?.is_some() {
547                let path = hash_path(&blobs_dir(&self.root), hash);
548                match fs::metadata(&path) {
549                    Ok(metadata) => match fs::remove_file(&path) {
550                        Ok(()) => {
551                            bytes_freed += metadata.len();
552                            removed += 1;
553                        }
554                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
555                        Err(e) => return Err(HeddleError::from(e)),
556                    },
557                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
558                    Err(e) => return Err(HeddleError::from(e)),
559                }
560            }
561        }
562
563        for hash in &trees {
564            let Some((obj_type, packed_data)) = pack_manager.get_hashed_object(hash)? else {
565                continue;
566            };
567            if obj_type != PackObjectType::Tree {
568                continue;
569            }
570            let path = hash_path(&trees_dir(&self.root), hash);
571            let Some(loose_data) = read_file_bytes(&path)? else {
572                continue;
573            };
574            let loose_data = codec::decode_tree_body(loose_data.as_slice())?;
575            if packed_data == loose_data {
576                match fs::metadata(&path) {
577                    Ok(metadata) => match fs::remove_file(&path) {
578                        Ok(()) => {
579                            bytes_freed += metadata.len();
580                            removed += 1;
581                        }
582                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
583                        Err(e) => return Err(HeddleError::from(e)),
584                    },
585                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
586                    Err(e) => return Err(HeddleError::from(e)),
587                }
588            }
589        }
590
591        Ok((removed, bytes_freed))
592    }
593}
594
595#[cfg(test)]
596mod unpaired_pack_tests {
597    use std::fs;
598
599    use super::{list_unpaired_pack_files, prune_unpaired_pack_files};
600
601    #[test]
602    fn list_and_prune_unpaired_packs() {
603        let dir = tempfile::tempdir().unwrap();
604        let packs = dir.path();
605        fs::write(packs.join("aaa.pack"), b"pack-only").unwrap();
606        fs::write(packs.join("bbb.pack"), b"paired-pack").unwrap();
607        fs::write(packs.join("bbb.idx"), b"paired-idx").unwrap();
608        fs::write(packs.join("ccc.idx"), b"index-only").unwrap();
609
610        let listed = list_unpaired_pack_files(packs).unwrap();
611        assert_eq!(listed.len(), 1);
612        assert!(listed[0].ends_with("aaa.pack"));
613
614        let (removed, bytes) = prune_unpaired_pack_files(packs).unwrap();
615        assert_eq!(removed, 1);
616        assert_eq!(bytes, b"pack-only".len() as u64);
617        assert!(!packs.join("aaa.pack").exists());
618        assert!(packs.join("bbb.pack").exists());
619        assert!(packs.join("bbb.idx").exists());
620        assert!(packs.join("ccc.idx").exists());
621        assert!(list_unpaired_pack_files(packs).unwrap().is_empty());
622    }
623
624    #[test]
625    fn missing_packs_dir_is_empty() {
626        let dir = tempfile::tempdir().unwrap();
627        let missing = dir.path().join("nope");
628        assert!(list_unpaired_pack_files(&missing).unwrap().is_empty());
629        assert_eq!(prune_unpaired_pack_files(&missing).unwrap(), (0, 0));
630    }
631}