heddle-objects 0.15.0

An AI-native version control system
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
// SPDX-License-Identifier: Apache-2.0
//! Pack and prune operations for FsStore.

use std::{
    fs,
    num::NonZeroUsize,
    path::{Path, PathBuf},
    sync::Arc,
};

use super::{
    FsStore,
    fs_impl::validate_state_serialized,
    fs_io::{list_hashes_from_dir, list_state_ids_from_dir, read_file_bytes},
    fs_paths::{blobs_dir, hash_path, packs_dir, state_path, states_dir, trees_dir},
};
use crate::{
    object::{ContentHash, State, StateAttachment, StateAttachmentId, Tree},
    store::{
        FsRepackOperation, HeddleError, ObjectStore, RepackPolicy, RepackResourceLimits,
        RepackSchedule, RepackScheduler, Result, SnapshotCommitArtifact, SnapshotCommitDescriptor,
        codec,
        pack::{ObjectType as PackObjectType, PackBuilder, PackObjectId, PackReader},
        snapshot_commit::snapshot_commit_marker_path,
    },
};

/// Paths of `*.pack` files in `packs_dir` that have no matching `*.idx`.
///
/// L8 residual: crash between durable pack and index publish can leave an
/// unpaired pack that [`FsStore::reload_packs`] ignores. Listing supports
/// optional GC (design: `docs/program/L8_PACK_INSTALL_JOURNAL.md` Option D).
/// Does not delete anything.
pub(crate) fn list_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<Vec<PathBuf>> {
    if !packs_dir.exists() {
        return Ok(Vec::new());
    }
    let mut unpaired = Vec::new();
    for entry in fs::read_dir(packs_dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("pack") {
            continue;
        }
        let idx = path.with_extension("idx");
        if !idx.exists() {
            unpaired.push(path);
        }
    }
    unpaired.sort();
    Ok(unpaired)
}

/// Remove unpaired `*.pack` files (no matching `*.idx`) under `packs_dir`.
///
/// Safe for correctness: loaders never open unpaired packs. Bounds L8 disk
/// leak. Returns `(removed_count, bytes_freed)`.
pub(crate) fn prune_unpaired_pack_files(packs_dir: &Path) -> std::io::Result<(u64, u64)> {
    let mut removed = 0u64;
    let mut bytes_freed = 0u64;
    for path in list_unpaired_pack_files(packs_dir)? {
        let bytes = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
        match fs::remove_file(&path) {
            Ok(()) => {
                removed += 1;
                bytes_freed = bytes_freed.saturating_add(bytes);
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
            Err(e) => return Err(e),
        }
    }
    Ok((removed, bytes_freed))
}

fn remove_file_ignore_missing(path: &std::path::Path) -> Result<()> {
    match fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(HeddleError::from(e)),
    }
}

fn remove_file_counted(path: &Path) -> Result<Option<u64>> {
    let metadata = match fs::metadata(path) {
        Ok(metadata) => metadata,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(HeddleError::from(error)),
    };
    match fs::remove_file(path) {
        Ok(()) => Ok(Some(metadata.len())),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(error) => Err(HeddleError::from(error)),
    }
}

impl FsStore {
    /// Rewrite all packs without `hash`, remove its loose copy, and verify that
    /// neither local nor external object lookup can still serve the bytes.
    pub fn remove_blob_everywhere(&self, hash: &ContentHash) -> Result<bool> {
        let was_present = ObjectStore::has_blob_locally(self, hash)?;
        if was_present {
            let scheduler = RepackScheduler::new(
                RepackPolicy::default(),
                RepackResourceLimits::new(NonZeroUsize::MIN),
            );
            let operation = Arc::new(FsRepackOperation::new(self.clone()).excluding_blob(*hash));
            let RepackSchedule::Started(handle) = scheduler
                .repack_now(operation)
                .map_err(|error| HeddleError::InvalidObject(error.to_string()))?
            else {
                return Err(HeddleError::InvalidObject(
                    "exclusive purge repack did not start".to_string(),
                ));
            };
            handle
                .wait()
                .map_err(|error| HeddleError::InvalidObject(error.to_string()))?;

            // The repack operation owns a clone of this store, so its atomic
            // cutover updates that clone's in-memory pack manager. Reload the
            // caller's manager from the newly published generation before
            // checking whether the purged object is still reachable.
            self.reload_packs()?;

            let loose = hash_path(&blobs_dir(&self.root), hash);
            match fs::remove_file(&loose) {
                Ok(()) => {
                    if let Some(parent) = loose.parent() {
                        crate::fs_atomic::sync_directory(parent)?;
                    }
                }
                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
                Err(error) => return Err(error.into()),
            }
        }
        self.clear_recent_object_caches();
        if ObjectStore::has_blob_locally(self, hash)?
            || ObjectStore::get_blob(self, hash)?.is_some()
            || ObjectStore::get_blob_bytes(self, hash)?.is_some()
        {
            return Err(HeddleError::InvalidObject(format!(
                "purged blob {} remains readable after pack rewrite",
                hash.short()
            )));
        }
        Ok(was_present)
    }

    pub(crate) fn put_committed_snapshot_objects_packed_impl(
        &self,
        blobs: Vec<(ContentHash, Vec<u8>)>,
        trees: Vec<Tree>,
        tree: &Tree,
        state: &State,
        attachments: Vec<StateAttachment>,
        artifact: SnapshotCommitArtifact,
    ) -> Result<SnapshotCommitDescriptor> {
        self.put_snapshot_objects_packed_impl(
            blobs,
            trees,
            tree,
            state,
            attachments,
            Some(artifact),
        )?
        .ok_or_else(|| {
            HeddleError::InvalidObject(
                "committed snapshot pack did not expose its artifact descriptor".to_string(),
            )
        })
    }

    /// Install blobs, root tree, state, and immutable authored attachments
    /// through one pack publication. Ordinary callers treat the pack as
    /// pre-oplog staging; committed structured snapshots add their local trust
    /// marker in the same directory barrier and make the pack authoritative.
    pub(super) fn put_snapshot_objects_packed_impl(
        &self,
        blobs: Vec<(ContentHash, Vec<u8>)>,
        trees: Vec<Tree>,
        tree: &Tree,
        state: &State,
        attachments: Vec<StateAttachment>,
        commit_artifact: Option<SnapshotCommitArtifact>,
    ) -> Result<Option<SnapshotCommitDescriptor>> {
        // A committed snapshot artifact is installed only after exact-once and
        // isolation validation. Its freshly-authored StateId cannot be a retry
        // (dedup returns before this callback), so avoid an expected-negative
        // pack-directory rescan on every native capture.
        let state_was_present = if commit_artifact.is_some() {
            false
        } else {
            <Self as ObjectStore>::has_state(self, &state.id())?
        };
        let mut compression = self.compression;
        if !self.snapshot_delta_search {
            compression.max_delta_size = 0;
        }
        let mut builder = PackBuilder::new(compression);
        let mut staged_blobs = Vec::with_capacity(blobs.len());

        for (hash, data) in blobs {
            if commit_artifact.is_none() && ObjectStore::has_blob_locally(self, &hash)? {
                continue;
            }
            staged_blobs.push((hash, data.clone()));
            builder.add(hash, PackObjectType::Blob, data);
        }

        let tree_hash = tree.hash();
        let mut staged_trees = Vec::with_capacity(trees.len() + 1);
        let mut seen_trees = std::collections::HashSet::with_capacity(trees.len() + 1);
        for authored_tree in trees {
            let authored_hash = authored_tree.hash();
            if seen_trees.insert(authored_hash)
                && (commit_artifact.is_some()
                    || !ObjectStore::has_tree_locally(self, &authored_hash)?)
            {
                builder.add(
                    authored_hash,
                    PackObjectType::Tree,
                    rmp_serde::to_vec_named(&authored_tree)?,
                );
                staged_trees.push((authored_hash, authored_tree));
            }
        }
        if (commit_artifact.is_some() || !ObjectStore::has_tree_locally(self, &tree_hash)?)
            && seen_trees.insert(tree_hash)
        {
            builder.add(
                tree_hash,
                PackObjectType::Tree,
                rmp_serde::to_vec_named(tree)?,
            );
            staged_trees.push((tree_hash, tree.clone()));
        }

        let state_id = state.id();
        builder.add_id(
            PackObjectId::StateId(state_id),
            PackObjectType::State,
            rmp_serde::to_vec_named(state)?,
        );
        let attachment_ids = attachments
            .iter()
            .map(|attachment| {
                if attachment.state_id != state_id {
                    return Err(HeddleError::InvalidObject(
                        "snapshot attachment targets a different state".to_string(),
                    ));
                }
                let id = attachment.id();
                builder.add(
                    *id.as_hash(),
                    PackObjectType::StateAttachment,
                    rmp_serde::to_vec_named(attachment)?,
                );
                Ok(id)
            })
            .collect::<Result<Vec<StateAttachmentId>>>()?;
        let artifact_id = commit_artifact.as_ref().map(SnapshotCommitArtifact::id);
        let artifact_bytes = commit_artifact
            .as_ref()
            .map(rmp_serde::to_vec_named)
            .transpose()?;
        if let Some(artifact) = &commit_artifact {
            artifact.validate()?;
            builder.add(
                artifact.id(),
                PackObjectType::SnapshotCommit,
                artifact_bytes.clone().expect("artifact bytes are present"),
            );
        }

        let (pack_data, index_data, _stats) = builder.build()?;
        let packs = packs_dir(&self.root);
        let installed_pack_name = if commit_artifact.is_some() {
            super::pack_install_journal::install_committed_snapshot_pack_bytes(
                &packs,
                pack_data,
                index_data,
                artifact_id.expect("commit artifact id is present"),
                artifact_bytes.expect("commit artifact bytes are present"),
            )?
        } else {
            super::pack_install_journal::install_snapshot_pack_bytes(&packs, pack_data, index_data)?
        };
        {
            let mut manager = self.pack_manager().write().map_err(|_| {
                HeddleError::Config("Failed to acquire pack manager lock".to_string())
            })?;
            manager.add_pack(
                packs.join(format!("{installed_pack_name}.pack")),
                packs.join(format!("{installed_pack_name}.idx")),
            )?;
        }
        self.materialize_packed_attachment_index(&state_id, &attachment_ids, state_was_present)?;

        if let Ok(mut cache) = self.recent_blobs.write() {
            for (hash, data) in staged_blobs {
                cache.insert(hash, crate::object::Blob::from_slice(&data));
            }
        }
        if let Ok(mut cache) = self.recent_trees.write() {
            for (hash, authored_tree) in staged_trees {
                cache.insert(hash, authored_tree);
            }
        }
        if let Ok(mut cache) = self.recent_states.write() {
            let mut cached = state.clone();
            cached.state_id = state_id;
            cache.insert(state_id, cached);
        }
        let descriptor = if let Some(artifact) = commit_artifact {
            let pack_path = packs.join(format!("{installed_pack_name}.pack"));
            let index_path = packs.join(format!("{installed_pack_name}.idx"));
            let object_ids = PackReader::open(&pack_path, &index_path)?.list_ids()?;
            Some(SnapshotCommitDescriptor {
                artifact,
                pack_name: installed_pack_name,
                pack_path,
                object_ids,
            })
        } else {
            None
        };
        Ok(descriptor)
    }

    /// Bulk-install many blobs as a single packfile. Two fsyncs total
    /// (one for `.pack`, one for `.idx`) regardless of blob count —
    /// vs. N×fsync if each blob were written loose. Used by the
    /// snapshot hot path; called at the end of the tree walk with
    /// every new blob accumulated in memory.
    ///
    /// Skips blobs already in the store (whether loose or packed) so
    /// re-snapshotting an unchanged worktree doesn't churn the pack
    /// directory. With every blob already known, this is a no-op.
    pub(super) fn put_blobs_packed_impl(&self, blobs: Vec<(ContentHash, Vec<u8>)>) -> Result<()> {
        if blobs.is_empty() {
            return Ok(());
        }
        // Snapshot-time pack: skip the sliding-window delta search.
        // It's a CPU win on similar-content files (the GC packer
        // benefits) but for a single snapshot the inputs are
        // unrelated content (random binaries, small text, etc.) and
        // every pair-wise delta estimate runs across the full
        // payloads — for 16×4MB blobs that's tens of seconds of
        // hashing for ~zero compression benefit. GC's
        // `pack_objects_impl` keeps the full delta search; this
        // path only optimizes durability + write throughput.
        let mut compression = self.compression;
        if !self.snapshot_delta_search {
            compression.max_delta_size = 0;
        }
        let mut builder = PackBuilder::new(compression);
        let mut staged: Vec<(ContentHash, Vec<u8>)> = Vec::with_capacity(blobs.len());
        for (hash, data) in blobs {
            if ObjectStore::has_blob_locally(self, &hash)? {
                continue;
            }
            staged.push((hash, data.clone()));
            builder.add(hash, PackObjectType::Blob, data);
        }
        if staged.is_empty() {
            return Ok(());
        }
        let (pack_data, index_data, _stats) = builder.build()?;

        // Install the pack files. `install_pack_files` clears the
        // recent-objects caches because a generic pack install (e.g.
        // received over the network) might shadow loose objects we
        // didn't write. For our locally-built pack we know exactly
        // what we just installed, so we re-populate `recent_blobs`
        // with the staged contents immediately afterwards. Without
        // this the snapshot hot path takes a cache miss on every
        // blob it just wrote, and `seed_large_repository` style
        // benchmarks that snapshot-many-times-in-a-loop end up
        // re-reading every parent state from disk between
        // iterations.
        self.install_pack_files(&pack_data, &index_data)?;
        if let Ok(mut cache) = self.recent_blobs.write() {
            for (hash, data) in staged {
                cache.insert(hash, crate::object::Blob::from_slice(&data));
            }
        }
        Ok(())
    }

    /// Consolidate the object store into a single pack.
    ///
    /// GC must *shrink* the set of places a reader has to look, not grow
    /// it. The naive "pack the loose objects into a fresh pack" strategy
    /// regressed read performance badly: every `maintenance gc` minted a
    /// brand-new pack *alongside* the existing pack(s) and (by default)
    /// left the now-redundant loose copies in place. The result was an
    /// object store with strictly MORE sources to search — loose objects
    /// plus an ever-growing fleet of packs — and `PackManager::get_object`
    /// probes every pack linearly, so each extra pack roughly doubled the
    /// cost of the object lookups that `status`/`diff`/verification do.
    ///
    /// This implementation does a true repack: it folds every object
    /// already living in a pack *together with* the loose blobs and trees
    /// into one new consolidated pack, installs it, and then deletes the
    /// superseded packs. Combined with the caller's
    /// `prune_loose_objects`, the store ends a GC with exactly one pack
    /// and no loose duplicates — strictly fewer read sources than it
    /// started with. Running GC again over an already-consolidated store
    /// is a no-op (nothing loose, one pack already covers everything).
    ///
    pub(super) fn pack_objects_impl(&self, delta_search: bool) -> Result<(u64, u64)> {
        // Serialize every source-pack-retiring path with background repack,
        // including callers in another process. Ordinary immutable pack
        // installs remain concurrent and are preserved at scheduler cutover.
        let _repack_lock = super::repack::acquire_repack_lock_blocking(&packs_dir(&self.root))?;
        let loose_blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
        let loose_trees = list_hashes_from_dir(&trees_dir(&self.root))?;

        // Snapshot what the existing packs already hold, plus the file
        // paths we'll retire once the consolidated pack is installed.
        let (existing_ids, old_pack_files, commit_artifact_ids) = {
            let manager = self.pack_manager().read().map_err(|_| {
                HeddleError::Config("Failed to acquire pack manager lock".to_string())
            })?;
            let ids = manager.list_all_ids()?;
            let commit_artifact_ids = manager
                .snapshot_commit_descriptors()?
                .into_iter()
                .map(|descriptor| descriptor.artifact.id())
                .collect::<Vec<_>>();
            let files: Vec<(std::path::PathBuf, std::path::PathBuf)> = manager
                .pack_file_paths()
                .into_iter()
                .map(|(pack, index)| (pack.to_path_buf(), index.to_path_buf()))
                .collect();
            (ids, files, commit_artifact_ids)
        };

        // Nothing loose and at most one pack already — the store is
        // already consolidated; don't churn a fresh identical pack.
        if loose_blobs.is_empty() && loose_trees.is_empty() && old_pack_files.len() <= 1 {
            return Ok((0, 0));
        }

        // Consolidation packs every object that's already packed plus the
        // loose ones. The default path skips the sliding-window delta search
        // to keep foreground GC latency bounded: it searches the full payloads
        // of every object and can turn a seconds-long consolidation into
        // minutes. The caller resolves the repository's GC policy and the
        // `--aggressive` override into the `delta_search` argument. This
        // mirrors the snapshot hot path, whose policy is held by the store.
        let mut compression = self.compression;
        if !delta_search {
            compression.max_delta_size = 0;
        }
        let mut builder = PackBuilder::new(compression);
        let loose_tree_set: std::collections::HashSet<ContentHash> =
            loose_trees.iter().copied().collect();
        let mut seen: std::collections::HashSet<crate::store::pack::PackObjectId> =
            std::collections::HashSet::new();

        // 1. Carry forward everything already in a pack so the old packs
        //    can be retired. `get_object` resolves the body + type for
        //    any id (blob/tree/state/action), and `add_id` preserves
        //    content-addressed state objects.
        for id in existing_ids {
            if !seen.insert(id) {
                continue;
            }
            let obj_type = {
                let manager = self.pack_manager().read().map_err(|_| {
                    HeddleError::Config("Failed to acquire pack manager lock".to_string())
                })?;
                manager.get_object(&id)?
            };
            if let Some((obj_type, mut data)) = obj_type {
                if let crate::store::pack::PackObjectId::Hash(hash) = id
                    && obj_type == PackObjectType::Tree
                    && loose_tree_set.contains(&hash)
                    && let Some(loose_data) = ObjectStore::get_tree_serialized(self, &hash)?
                {
                    data = loose_data;
                }
                builder.add_id(id, obj_type, data);
            }
        }

        // 2. Fold in the loose blobs and trees. Skip any whose hash is
        //    already covered by a carried-forward pack entry.
        for hash in &loose_blobs {
            let id = crate::store::pack::PackObjectId::Hash(*hash);
            if seen.contains(&id) {
                continue;
            }
            if let Some(blob) = ObjectStore::get_blob(self, hash)? {
                seen.insert(id);
                builder.add(*hash, PackObjectType::Blob, blob.content().to_vec());
            }
        }
        for hash in &loose_trees {
            let id = crate::store::pack::PackObjectId::Hash(*hash);
            if seen.contains(&id) {
                continue;
            }
            if let Some(tree) = ObjectStore::get_tree(self, hash)? {
                let data = rmp_serde::to_vec(&tree)?;
                seen.insert(id);
                builder.add(*hash, PackObjectType::Tree, data);
            }
        }

        if seen.is_empty() {
            return Ok((0, 0));
        }

        let (pack_data, index_data, stats) = builder.build()?;
        let new_pack_name = blake3::hash(&pack_data).to_hex();
        if commit_artifact_ids.is_empty() {
            self.install_pack_files(&pack_data, &index_data)?;
        } else {
            super::pack_install_journal::install_snapshot_pack_bytes_with_commit_markers(
                &packs_dir(&self.root),
                pack_data,
                index_data,
                &commit_artifact_ids,
            )?;
            self.reload_packs()?;
        }
        // GC packs *replace* loose objects (followed by
        // `prune_loose_objects`). Bust the recent-objects caches so
        // a subsequent get_* doesn't return a stale `Blob`/`Tree`
        // pointing at a path we're about to delete. The snapshot hot
        // path doesn't go through here — it calls
        // `install_pack_files` directly via `put_blobs_packed_impl`,
        // which keeps its caches warm.
        self.clear_recent_object_caches();

        // Retire the superseded packs now that the consolidated pack is
        // durably installed and every object they held has been carried
        // forward. The consolidated pack is content-addressed, so if it
        // happened to hash-collide with an old pack (a store that was
        // already a single consolidated pack) that file is excluded here.
        // Stack hex digest; compare as &str — no format!/String intermediate.
        for (pack_path, index_path) in &old_pack_files {
            let is_new_pack = pack_path
                .file_stem()
                .and_then(|stem| stem.to_str())
                .map(|stem| stem == new_pack_name.as_str())
                .unwrap_or(false);
            if is_new_pack {
                continue;
            }
            remove_file_ignore_missing(pack_path)?;
            remove_file_ignore_missing(index_path)?;
            for artifact_id in &commit_artifact_ids {
                remove_file_ignore_missing(&snapshot_commit_marker_path(pack_path, artifact_id))?;
            }
        }
        // Retiring source packs requires a full reload of the pack list.
        self.reload_packs()?;
        self.clear_recent_object_caches();

        let saved = stats.total_uncompressed - stats.total_compressed;
        Ok((stats.object_count, saved))
    }

    pub(super) fn install_pack_files(&self, pack_data: &[u8], index_data: &[u8]) -> Result<()> {
        let packs = packs_dir(&self.root);
        // L8 A+: durable staging + intent journal for in-memory pack install
        // (same crash-safety as install_pack_files_streaming).
        // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
        let _pack_name = super::pack_install_journal::install_pack_bytes_journaled(
            &packs, pack_data, index_data,
        )?;
        // Pack manager picks up the new files. We do *not* clear the
        // recent-object caches here — every caller that follows this
        // with a destructive prune is responsible for clearing them
        // explicitly. Snapshot installs rely on cache stickiness to
        // keep tight snapshot loops fast (see
        // `put_blobs_packed_impl`).
        self.reload_packs()?;
        Ok(())
    }

    /// Move a pack and its index already on disk into the store's
    /// pack directory, computing the pack's content-hash by streaming
    /// the file (constant memory regardless of pack size). Pairs with
    /// `StreamingPackBuilder`: pack data, the index, *and* this
    /// installation step never load the full pack or index into
    /// memory.
    ///
    /// Sources are staged then published via the L8 A+ install journal
    /// ([`super::pack_install_journal`]): durable staging + intent, then
    /// pack/index publish with crash recovery on reload.
    pub(super) fn install_pack_files_streaming(
        &self,
        src_pack_path: &std::path::Path,
        src_index_path: &std::path::Path,
    ) -> Result<()> {
        use std::io::Read;

        let packs = packs_dir(&self.root);
        crate::fs_atomic::create_dir_all_durable(&packs)?;

        // Stream-hash the pack file to derive its name. 64 KiB chunks
        // keep the hasher's working set tiny.
        let mut hasher = blake3::Hasher::new();
        let mut file = fs::File::open(src_pack_path)?;
        let mut buf = vec![0u8; 64 * 1024];
        loop {
            let n = file.read(&mut buf)?;
            if n == 0 {
                break;
            }
            hasher.update(&buf[..n]);
        }
        drop(file);
        // Native digest for potential callers; hex String only for the journal
        // path/name boundary (filenames + intent JSON).
        let pack_hash = hasher.finalize();
        let pack_name = pack_hash.to_hex().to_string();

        // L8 A+: durable staging + intent journal, then pack/index publish.
        // Recovery on reload finishes or aborts incomplete installs.
        // Design: docs/program/L8_PACK_INSTALL_JOURNAL.md
        super::pack_install_journal::install_pack_files_journaled(
            &packs,
            src_pack_path,
            src_index_path,
            &pack_name,
        )?;

        self.clear_recent_object_caches();
        self.reload_packs()?;
        Ok(())
    }

    /// Remove L8 orphan packs (`.pack` without `.idx`) from this store.
    pub fn prune_unpaired_packs(&self) -> Result<(u64, u64)> {
        let packs = packs_dir(&self.root);
        Ok(prune_unpaired_pack_files(&packs)?)
    }

    pub(super) fn prune_loose_objects_impl(&self) -> Result<(u64, u64)> {
        let mut removed = 0u64;
        let mut bytes_freed = 0u64;

        let blobs = list_hashes_from_dir(&blobs_dir(&self.root))?;
        let trees = list_hashes_from_dir(&trees_dir(&self.root))?;
        let states = list_state_ids_from_dir(&states_dir(&self.root))?;

        let pack_manager = self
            .pack_manager()
            .read()
            .map_err(|_| HeddleError::Config("Failed to acquire pack manager lock".to_string()))?;

        for hash in &blobs {
            if pack_manager.get_hashed_object(hash)?.is_some() {
                let path = hash_path(&blobs_dir(&self.root), hash);
                if let Some(bytes) = remove_file_counted(&path)? {
                    bytes_freed = bytes_freed.saturating_add(bytes);
                    removed += 1;
                }
            }
        }

        for hash in &trees {
            let Some((obj_type, packed_data)) = pack_manager.get_hashed_object(hash)? else {
                continue;
            };
            if obj_type != PackObjectType::Tree {
                continue;
            }
            let path = hash_path(&trees_dir(&self.root), hash);
            let Some(loose_data) = read_file_bytes(&path)? else {
                continue;
            };
            let loose_tree = codec::decode_tree(loose_data.as_slice())?;
            let found = loose_tree.hash();
            if found != *hash {
                return Err(HeddleError::Corruption {
                    expected: *hash,
                    found,
                });
            }
            // A loose current tree can intentionally shadow an older packed
            // schema at the same semantic hash. Preserve that migration copy
            // until consolidation replaces the legacy body.
            let Ok(packed_tree) = codec::decode_tree_serialized(&packed_data) else {
                continue;
            };
            let packed_found = packed_tree.hash();
            if packed_found != *hash {
                return Err(HeddleError::Corruption {
                    expected: *hash,
                    found: packed_found,
                });
            }
            if packed_tree == loose_tree
                && let Some(bytes) = remove_file_counted(&path)?
            {
                bytes_freed = bytes_freed.saturating_add(bytes);
                removed += 1;
            }
        }

        for id in &states {
            let Some((obj_type, packed_data)) =
                pack_manager.get_object(&PackObjectId::StateId(*id))?
            else {
                continue;
            };
            if obj_type != PackObjectType::State {
                continue;
            }
            let path = state_path(&self.root, id);
            let Some(loose_data) = read_file_bytes(&path)? else {
                continue;
            };
            let loose_state = codec::decode_state(loose_data.as_slice())?;
            let packed_state = validate_state_serialized(&packed_data, *id)?;
            if loose_state.id() != *id {
                return Err(HeddleError::InvalidObject(format!(
                    "loose state id mismatch while pruning: expected {id}, computed {}",
                    loose_state.id()
                )));
            }
            if packed_state == loose_state
                && let Some(bytes) = remove_file_counted(&path)?
            {
                bytes_freed = bytes_freed.saturating_add(bytes);
                removed += 1;
            }
        }

        Ok((removed, bytes_freed))
    }
}

#[cfg(test)]
mod unpaired_pack_tests {
    use std::fs;

    use super::{list_unpaired_pack_files, prune_unpaired_pack_files};

    #[test]
    fn list_and_prune_unpaired_packs() {
        let dir = tempfile::tempdir().unwrap();
        let packs = dir.path();
        fs::write(packs.join("aaa.pack"), b"pack-only").unwrap();
        fs::write(packs.join("bbb.pack"), b"paired-pack").unwrap();
        fs::write(packs.join("bbb.idx"), b"paired-idx").unwrap();
        fs::write(packs.join("ccc.idx"), b"index-only").unwrap();

        let listed = list_unpaired_pack_files(packs).unwrap();
        assert_eq!(listed.len(), 1);
        assert!(listed[0].ends_with("aaa.pack"));

        let (removed, bytes) = prune_unpaired_pack_files(packs).unwrap();
        assert_eq!(removed, 1);
        assert_eq!(bytes, b"pack-only".len() as u64);
        assert!(!packs.join("aaa.pack").exists());
        assert!(packs.join("bbb.pack").exists());
        assert!(packs.join("bbb.idx").exists());
        assert!(packs.join("ccc.idx").exists());
        assert!(list_unpaired_pack_files(packs).unwrap().is_empty());
    }

    #[test]
    fn missing_packs_dir_is_empty() {
        let dir = tempfile::tempdir().unwrap();
        let missing = dir.path().join("nope");
        assert!(list_unpaired_pack_files(&missing).unwrap().is_empty());
        assert_eq!(prune_unpaired_pack_files(&missing).unwrap(), (0, 0));
    }
}