Skip to main content

sley_odb/
pack.rs

1//! Pack-backed object storage: [`FileObjectDatabase`], in-memory [`ObjectDatabase`],
2//! and the shared decode/index caches that back packed reads.
3//!
4//! ## Cache invariants (thread safety)
5//!
6//! A [`FileObjectDatabase`] is [`Clone`] via `Arc` on every cache map. Cloned handles
7//! share the same caches and may be used from multiple threads concurrently.
8//!
9//! * **Read-mostly maps** (`pack_indexes`, `pack_bytes`, `pack_reverse_indexes`,
10//!   `multi_pack_indexes`, `multi_pack_oid_lookups`) use [`RwLock`]: concurrent readers
11//!   take shared locks; writers take exclusive locks only on insert/clear. Lookups never
12//!   hold a lock across decode or I/O.
13//! * **Mutation-heavy maps** (`decoded`, `pack_deltas`, `pack_header_types`, `pack_registry`)
14//!   stay behind [`Mutex`] because inserts and LRU eviction interleave reads and writes on
15//!   the same critical section.
16//! * **Per-pack state** on [`RegisteredPack`](crate::registry::RegisteredPack) mirrors the
17//!   split: parsed indexes are [`RwLock`]-cached; delta-base LRU caches stay [`Mutex`]-backed.
18//! * **`refresh_read_cache`** clears every shared map so the next read sees packs installed
19//!   out-of-band; callers must not hold a cache guard across it.
20
21use parking_lot::RwLock;
22use sley_core::{GitError, MissingObjectContext, ObjectFormat, ObjectId, Result};
23use sley_object::{Commit, EncodedObject, ObjectType, Tag, TreeEntries};
24use sley_pack::{
25    MultiPackIndex, MultiPackIndexOidLookup, PackIndex, PackIndexByteSource, PackIndexViewData,
26    PackReverseIndex,
27};
28use smallvec::SmallVec;
29use std::collections::{HashMap, HashSet};
30use std::path::{Path, PathBuf};
31use std::sync::Mutex;
32use std::sync::{Arc, OnceLock};
33use std::{env, fs};
34
35use crate::{ObjectReader, ObjectWriter, ReusablePackCandidate, implied_empty_tree_object};
36
37use crate::install::{ObjectPrefixResolution, ObjectStorageInfo};
38use crate::loose::LooseObjectStore;
39use crate::reachability::{
40    PackDeltaBase, PackIndexOffsetInfo, pack_entry_delta_base, scan_pack_index_offsets,
41    scan_pack_offsets_without_index,
42};
43use crate::registry::{
44    ObjectPresenceChecker, PackLookup, PackRegistryCache, PackRegistrySnapshot,
45    alternate_object_dirs, object_ids_in_objects_dir, object_ids_with_prefix_in_objects_dir,
46    read_incremental_midx_chain, repository_objects_dir, same_registered_pack_set,
47    scan_pack_registry, validate_object_id_prefix,
48};
49
50pub struct ObjectDatabase {
51    pub(crate) format: ObjectFormat,
52    // Behind a `Mutex` so `write_object` can take `&self` (matching the
53    // `ObjectWriter` trait) and a single handle can interleave reads and writes
54    // without a `&mut` borrow — the same shared-by-`&` shape the file-backed
55    // database uses for its caches. Removes the need for callers to wrap this in
56    // a `RefCell`/`&mut` just to write (see sley-fetch's former `RefCell` dance).
57    pub(crate) objects: Mutex<HashMap<ObjectId, Arc<EncodedObject>>>,
58    pub(crate) promisor: bool,
59}
60
61impl ObjectDatabase {
62    pub fn new(format: ObjectFormat) -> Self {
63        Self {
64            format,
65            objects: Mutex::new(HashMap::new()),
66            promisor: false,
67        }
68    }
69
70    pub fn with_promisor(mut self, promisor: bool) -> Self {
71        self.promisor = promisor;
72        self
73    }
74
75    pub fn contains(&self, oid: &ObjectId) -> bool {
76        self.objects
77            .lock()
78            .map(|objects| objects.contains_key(oid))
79            .unwrap_or(false)
80    }
81
82    pub fn validate(&self, oid: &ObjectId) -> Result<()> {
83        let object = self.read_object(oid)?;
84        let actual = object.object_id(self.format)?;
85        if &actual == oid {
86            Ok(())
87        } else {
88            Err(GitError::InvalidObject(format!(
89                "object id mismatch: expected {oid}, got {actual}"
90            )))
91        }
92    }
93}
94
95impl ObjectReader for ObjectDatabase {
96    fn read_object(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
97        self.objects
98            .lock()
99            .map_err(|_| GitError::object_not_found_in(*oid, MissingObjectContext::Read))?
100            .get(oid)
101            .map(Arc::clone)
102            .or_else(|| implied_empty_tree_object(self.format, oid))
103            .ok_or_else(|| GitError::object_not_found_in(*oid, MissingObjectContext::Read))
104    }
105}
106
107impl ObjectWriter for ObjectDatabase {
108    fn write_object(&self, object: EncodedObject) -> Result<ObjectId> {
109        let oid = object.object_id(self.format)?;
110        self.objects
111            .lock()
112            .map_err(|_| GitError::Io("object cache lock poisoned".into()))?
113            .entry(oid)
114            .or_insert_with(|| Arc::new(object));
115        Ok(oid)
116    }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Alternate {
121    pub path: std::path::PathBuf,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct PartialClonePolicy {
126    pub promisor_remote: Option<String>,
127    pub allow_missing_promised_objects: bool,
128}
129
130/// Raw pack-file bytes keyed by pack path, shared across cloned handles. Loaded
131/// once so individual objects can be decoded at their offsets (see
132/// [`sley_pack::read_object_at`]) without re-reading the whole file per read.
133pub(crate) type PackBytesCache = Arc<RwLock<HashMap<PathBuf, Arc<PackData>>>>;
134
135/// Backing bytes of a pack file: either memory-mapped (under the `mmap` feature)
136/// or read into the heap. Both deref to `&[u8]`, so the decode path is identical.
137#[derive(Debug)]
138pub(crate) enum PackData {
139    #[cfg(feature = "mmap")]
140    Mapped(sley_mmap::MappedFile),
141    Heap(Vec<u8>),
142}
143
144impl std::ops::Deref for PackData {
145    type Target = [u8];
146
147    fn deref(&self) -> &[u8] {
148        match self {
149            #[cfg(feature = "mmap")]
150            Self::Mapped(mapped) => mapped,
151            Self::Heap(bytes) => bytes,
152        }
153    }
154}
155
156/// Load a pack file's bytes: memory-mapped when the `mmap` feature is on (falling
157/// back to a heap read if the map fails), otherwise read into the heap.
158#[cfg(feature = "mmap")]
159pub(crate) fn load_pack_data(pack_path: &Path) -> Result<PackData> {
160    match sley_mmap::MappedFile::open_pack(pack_path) {
161        Ok(mapped) => Ok(PackData::Mapped(mapped)),
162        Err(_) => Ok(PackData::Heap(fs::read(pack_path)?)),
163    }
164}
165
166#[cfg(not(feature = "mmap"))]
167pub(crate) fn load_pack_data(pack_path: &Path) -> Result<PackData> {
168    Ok(PackData::Heap(fs::read(pack_path)?))
169}
170
171#[cfg(feature = "mmap")]
172pub(crate) fn load_pack_index_data(index_path: &Path) -> Result<Arc<dyn PackIndexByteSource>> {
173    match sley_mmap::MappedFile::open_pack(index_path) {
174        Ok(mapped) => Ok(Arc::new(mapped)),
175        Err(_) => Ok(Arc::new(fs::read(index_path)?)),
176    }
177}
178
179#[cfg(not(feature = "mmap"))]
180pub(crate) fn load_pack_index_data(index_path: &Path) -> Result<Arc<dyn PackIndexByteSource>> {
181    Ok(Arc::new(fs::read(index_path)?))
182}
183
184#[cfg(feature = "mmap")]
185fn load_multi_pack_index_lookup_data(midx_path: &Path) -> Result<Arc<dyn PackIndexByteSource>> {
186    match sley_mmap::MappedFile::open_multi_pack_index(midx_path) {
187        Ok(mapped) => Ok(Arc::new(mapped)),
188        Err(_) => Ok(Arc::new(fs::read(midx_path)?)),
189    }
190}
191
192#[cfg(not(feature = "mmap"))]
193fn load_multi_pack_index_lookup_data(midx_path: &Path) -> Result<Arc<dyn PackIndexByteSource>> {
194    Ok(Arc::new(fs::read(midx_path)?))
195}
196
197/// Memory-capped LRU of recently decoded objects, shared across cloned handles,
198/// so hot delta bases and repeated reads during a walk aren't re-decoded. The
199/// cache is bounded by an approximate byte budget (not a fixed object count) so
200/// it neither thrashes on bulk reads of small objects nor blows up on a few
201/// large ones.
202pub(crate) type DecodedObjectCache = Arc<Mutex<LruObjectCache>>;
203
204/// Per-pack caches of objects decoded from a pack, keyed by pack path and then by
205/// the in-pack byte offset of each object's entry. Shared across cloned handles.
206/// This is the delta-base cache: resolving a delta chain by offset reuses already
207/// decoded bases instead of re-inflating the whole chain on every read.
208pub(crate) type PackDeltaCaches = Arc<Mutex<HashMap<PathBuf, Arc<Mutex<LruOffsetCache>>>>>;
209
210/// Per-pack memo of `in-pack offset -> resolved header` for the
211/// `cat-file --batch-check` fast path. Resolving a packed delta's type walks the
212/// chain to its base; without this memo every header read re-walks (and
213/// re-inflates) the whole chain, so reading every object in a deeply-deltified
214/// pack is super-linear (sley#26). The memo includes chain depth so a warm hit
215/// enforces the same cumulative limit as a cold walk. Keyed by pack path so an
216/// offset key is never applied to the wrong pack's bytes; shared across clones.
217/// One pack's offset-keyed header memo (see [`PackHeaderTypeCaches`]).
218pub(crate) type PackHeaderTypeCache = Arc<Mutex<HashMap<u64, sley_pack::PackObjectHeader>>>;
219
220pub(crate) type PackHeaderTypeCaches = Arc<Mutex<HashMap<PathBuf, PackHeaderTypeCache>>>;
221
222/// Default approximate byte budget for the decoded-object LRU. Sized to comfortably
223/// hold the working set of a history walk (commits/trees/blobs and their delta
224/// bases) without growing without bound on large repositories. Overridable via the
225/// `SLEY_OBJECT_CACHE_BYTES` environment variable; there is currently no git-config
226/// hook threaded into the object database, so this constant is the default.
227const DEFAULT_OBJECT_CACHE_BYTES: usize = 96 * 1024 * 1024;
228
229/// Default aggregate byte budget for delta-base caches. The database divides
230/// this across the packs visible when it opens, preventing a many-pack closure
231/// from multiplying the per-process cache by the repository's pack count.
232/// Overridable via `SLEY_DELTA_BASE_CACHE_BYTES`.
233const DEFAULT_DELTA_BASE_CACHE_BYTES: usize = 96 * 1024 * 1024;
234
235/// Approximate heap cost of caching one [`EncodedObject`]: its body plus a fixed
236/// allowance for the key, enum/`Vec` headers, and per-entry map overhead. Used
237/// only to drive eviction, so an estimate is fine.
238pub(crate) fn cached_object_cost(object: &EncodedObject) -> usize {
239    object.body.len().saturating_add(64)
240}
241
242/// Read an approximate byte budget from `var`, falling back to `default` when the
243/// variable is unset or unparseable. A value of `0` disables the cache.
244fn cache_budget_from_env(var: &str, default: usize) -> usize {
245    match env::var(var) {
246        Ok(value) => value.trim().parse::<usize>().unwrap_or(default),
247        Err(_) => default,
248    }
249}
250
251/// Approximate byte budget for the decoded-object LRU (see
252/// [`DEFAULT_OBJECT_CACHE_BYTES`], `SLEY_OBJECT_CACHE_BYTES`).
253///
254/// Resolved once per process: the environment does not change under us, and a new
255/// `FileObjectDatabase` is built often enough (e.g. once per revision resolved)
256/// that re-reading the variable each time showed up as per-object overhead.
257pub(crate) fn object_cache_budget() -> usize {
258    static BUDGET: OnceLock<usize> = OnceLock::new();
259    *BUDGET.get_or_init(|| {
260        cache_budget_from_env("SLEY_OBJECT_CACHE_BYTES", DEFAULT_OBJECT_CACHE_BYTES)
261    })
262}
263
264/// Approximate aggregate byte budget for per-pack delta-base caches (see
265/// [`DEFAULT_DELTA_BASE_CACHE_BYTES`], `SLEY_DELTA_BASE_CACHE_BYTES`). Resolved
266/// once per process for the same reason as [`object_cache_budget`].
267pub(crate) fn delta_base_cache_budget() -> usize {
268    static BUDGET: OnceLock<usize> = OnceLock::new();
269    *BUDGET.get_or_init(|| {
270        cache_budget_from_env(
271            "SLEY_DELTA_BASE_CACHE_BYTES",
272            DEFAULT_DELTA_BASE_CACHE_BYTES,
273        )
274    })
275}
276
277fn per_pack_delta_base_cache_budget(objects_dir: &Path, alternates: &[PathBuf]) -> usize {
278    let pack_count = std::iter::once(objects_dir)
279        .chain(alternates.iter().map(PathBuf::as_path))
280        .map(|object_dir| {
281            fs::read_dir(object_dir.join("pack"))
282                .ok()
283                .into_iter()
284                .flatten()
285                .filter_map(std::result::Result::ok)
286                .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "pack"))
287                .count()
288        })
289        .sum::<usize>()
290        .max(1);
291    delta_base_cache_budget() / pack_count
292}
293
294/// Whether to re-hash every object on read and compare it to the requested id.
295///
296/// Off by default, matching git: reads trust the pack index → offset mapping and
297/// the loose object's on-disk name, and object ids are verified where git verifies
298/// them — when a pack is received (the index build re-hashes every object) and on
299/// demand via [`FileObjectDatabase`]'s `validate`/fsck. Re-hashing on *every* read
300/// dominated bulk-read cost (a scalar pure-Rust SHA-1 over each object's full
301/// body), so it is opt-in via `SLEY_VERIFY_READS` (any value other than unset, ``,
302/// or `0`) for callers that want the paranoid check back. Read once and cached, so
303/// the default path pays only a single relaxed atomic load per read.
304pub(crate) fn verify_reads_enabled() -> bool {
305    static VERIFY: OnceLock<bool> = OnceLock::new();
306    *VERIFY.get_or_init(|| match env::var("SLEY_VERIFY_READS") {
307        Ok(value) => !matches!(value.trim(), "" | "0"),
308        Err(_) => false,
309    })
310}
311
312/// A memory-capped LRU map from a key `K` to a decoded [`EncodedObject`].
313///
314/// Eviction is by approximate byte budget (gix-style), not object count, so the
315/// cache adapts to object size. On access an entry is moved to most-recently-used;
316/// on insert, least-recently-used entries are dropped until the budget holds. A
317/// budget of `0` makes the cache inert. Generic over the key so it backs both the
318/// oid-keyed decoded-object cache and the offset-keyed delta-base cache.
319#[derive(Debug)]
320pub(crate) struct LruCache<K: std::hash::Hash + Eq + Clone> {
321    budget: usize,
322    used: usize,
323    map: HashMap<K, LruEntry<K>>,
324    head: Option<K>,
325    tail: Option<K>,
326}
327
328#[derive(Debug)]
329struct LruEntry<K> {
330    object: Arc<EncodedObject>,
331    prev: Option<K>,
332    next: Option<K>,
333}
334
335impl<K: std::hash::Hash + Eq + Clone> LruCache<K> {
336    pub(crate) fn new(budget: usize) -> Self {
337        Self {
338            budget,
339            used: 0,
340            map: HashMap::new(),
341            head: None,
342            tail: None,
343        }
344    }
345
346    pub(crate) fn get(&mut self, key: &K) -> Option<Arc<EncodedObject>> {
347        let object = Arc::clone(&self.map.get(key)?.object);
348        self.touch(key);
349        Some(object)
350    }
351
352    /// Move `key` to the most-recently-used end in O(1).
353    pub(crate) fn touch(&mut self, key: &K) {
354        if self.tail.as_ref() == Some(key) {
355            return;
356        }
357        if self.map.contains_key(key) {
358            self.detach(key);
359            self.attach_back(key.clone());
360        }
361    }
362
363    /// Drop `key` from both the map and the recency queue, releasing its budget.
364    pub(crate) fn remove(&mut self, key: &K) {
365        if let Some(entry) = self.map.get(key) {
366            self.used = self.used.saturating_sub(cached_object_cost(&entry.object));
367        }
368        self.detach(key);
369        self.map.remove(key);
370    }
371
372    pub(crate) fn detach(&mut self, key: &K) {
373        let Some((prev, next)) = self.map.get_mut(key).map(|entry| {
374            let prev = entry.prev.take();
375            let next = entry.next.take();
376            (prev, next)
377        }) else {
378            return;
379        };
380
381        match &prev {
382            Some(prev_key) => {
383                if let Some(prev_entry) = self.map.get_mut(prev_key) {
384                    prev_entry.next = next.clone();
385                }
386            }
387            None => self.head = next.clone(),
388        }
389        match &next {
390            Some(next_key) => {
391                if let Some(next_entry) = self.map.get_mut(next_key) {
392                    next_entry.prev = prev.clone();
393                }
394            }
395            None => self.tail = prev.clone(),
396        }
397    }
398
399    pub(crate) fn attach_back(&mut self, key: K) {
400        let previous_tail = self.tail.replace(key.clone());
401        match previous_tail {
402            Some(tail_key) => {
403                if let Some(tail_entry) = self.map.get_mut(&tail_key) {
404                    tail_entry.next = Some(key.clone());
405                }
406                if let Some(entry) = self.map.get_mut(&key) {
407                    entry.prev = Some(tail_key);
408                    entry.next = None;
409                }
410            }
411            None => {
412                self.head = Some(key.clone());
413                if let Some(entry) = self.map.get_mut(&key) {
414                    entry.prev = None;
415                    entry.next = None;
416                }
417            }
418        }
419    }
420
421    pub(crate) fn clear(&mut self) {
422        self.map.clear();
423        self.head = None;
424        self.tail = None;
425        self.used = 0;
426    }
427
428    pub(crate) fn put(&mut self, key: K, object: Arc<EncodedObject>) {
429        if self.budget == 0 {
430            return;
431        }
432        let cost = cached_object_cost(&object);
433        // A single object larger than the whole budget is not worth caching; it
434        // would immediately evict everything including itself. Drop any stale
435        // smaller entry stored under the same key so accounting stays exact.
436        if cost > self.budget {
437            self.remove(&key);
438            return;
439        }
440        if let Some(entry) = self.map.get_mut(&key) {
441            let previous = std::mem::replace(&mut entry.object, object);
442            // Replacing an existing entry: adjust accounting and refresh recency.
443            self.used = self
444                .used
445                .saturating_sub(cached_object_cost(&previous))
446                .saturating_add(cost);
447            self.touch(&key);
448        } else {
449            self.used = self.used.saturating_add(cost);
450            self.map.insert(
451                key.clone(),
452                LruEntry {
453                    object,
454                    prev: None,
455                    next: None,
456                },
457            );
458            self.attach_back(key);
459        }
460        while self.used > self.budget {
461            let Some(evicted) = self.head.clone() else {
462                break;
463            };
464            self.remove(&evicted);
465        }
466    }
467}
468
469/// Decoded-object cache keyed by object id (loose + packed reads share it).
470type LruObjectCache = LruCache<ObjectId>;
471/// Delta-base cache keyed by in-pack byte offset, scoped to one pack.
472pub(crate) type LruOffsetCache = LruCache<u64>;
473
474/// Bridges the offset-keyed [`LruOffsetCache`] to [`sley_pack::PackDeltaCache`]
475/// so the pack decoder can reuse decoded delta bases. Holds the shared cache
476/// behind its mutex; a poisoned lock simply behaves as a cache miss/no-op, so a
477/// decode still completes correctly (just without reuse).
478struct PackDeltaCacheAdapter<'a>(&'a Arc<Mutex<LruOffsetCache>>);
479
480impl sley_pack::PackDeltaCache for PackDeltaCacheAdapter<'_> {
481    fn get(&self, offset: u64) -> Option<Arc<EncodedObject>> {
482        self.0.lock().ok()?.get(&offset)
483    }
484
485    fn insert(&self, offset: u64, object: Arc<EncodedObject>) {
486        if let Ok(mut cache) = self.0.lock() {
487            cache.put(offset, object);
488        }
489    }
490}
491
492/// Bridges a per-pack `offset -> resolved header` memo into the header fast path so
493/// the ofs-delta chain walk is performed at most once per chain across a batch
494/// of `read_object_header` calls (sley#26).
495struct PackHeaderTypeCacheAdapter<'a>(&'a PackHeaderTypeCache);
496
497impl sley_pack::HeaderTypeCache for PackHeaderTypeCacheAdapter<'_> {
498    fn get(&self, pack_offset: u64) -> Option<sley_pack::PackObjectHeader> {
499        self.0.lock().ok()?.get(&pack_offset).copied()
500    }
501
502    fn put(&mut self, pack_offset: u64, header: sley_pack::PackObjectHeader) {
503        if let Ok(mut cache) = self.0.lock() {
504            cache.insert(pack_offset, header);
505        }
506    }
507}
508
509/// Active object ids while header-only ref-delta resolution crosses packs.
510///
511/// This is deliberately stack-backed: `read_object_header` is a batch hot path,
512/// so cycle protection must not replace the removed per-object allocations with
513/// a `HashSet` allocation. The extra slot is the undeltified base below a chain
514/// at the configured delta-depth ceiling.
515// SmallVec's default array implementations include 64; that comfortably holds
516// the configured 50-link delta ceiling plus its undeltified base.
517const HEADER_READ_STACK_CAPACITY: usize = 64;
518const HEADER_READ_MAX_ACTIVE: usize = sley_pack::MAX_READ_DELTA_CHAIN_DEPTH + 1;
519
520struct HeaderReadContext {
521    active_oids: SmallVec<[ObjectId; HEADER_READ_STACK_CAPACITY]>,
522    delta_depth: usize,
523}
524
525impl HeaderReadContext {
526    fn new() -> Self {
527        Self {
528            active_oids: SmallVec::new(),
529            delta_depth: 0,
530        }
531    }
532
533    fn enter_pack(&mut self, oid: &ObjectId) -> Result<()> {
534        if self.active_oids.contains(oid) {
535            return Err(GitError::InvalidObject(format!(
536                "pack ref-delta cycle detected at object {oid}"
537            )));
538        }
539        if self.active_oids.len() == HEADER_READ_MAX_ACTIVE {
540            return Err(GitError::InvalidObject(format!(
541                "pack delta chain exceeds maximum depth {}",
542                sley_pack::MAX_READ_DELTA_CHAIN_DEPTH
543            )));
544        }
545        self.active_oids.push(*oid);
546        Ok(())
547    }
548
549    fn leave_pack(&mut self, oid: &ObjectId) {
550        let popped = self.active_oids.pop();
551        debug_assert_eq!(popped, Some(*oid));
552    }
553
554    fn check_ref_base(&self, oid: &ObjectId) -> Result<()> {
555        if self.active_oids.contains(oid) {
556            return Err(GitError::InvalidObject(format!(
557                "pack ref-delta cycle detected at object {oid}"
558            )));
559        }
560        // The active count includes the ref-delta entry currently asking for
561        // its base. A chain exactly at the ceiling may still resolve an
562        // undeltified base; the next ref-delta link may not.
563        if self.active_oids.len() > sley_pack::MAX_READ_DELTA_CHAIN_DEPTH {
564            return Err(GitError::InvalidObject(format!(
565                "pack delta chain exceeds maximum depth {}",
566                sley_pack::MAX_READ_DELTA_CHAIN_DEPTH
567            )));
568        }
569        Ok(())
570    }
571}
572
573/// Parsed pack indexes keyed by `.idx` path, shared across cloned handles. This
574/// remains for MIDX and path-only fallback lookups; normal pack-directory scans
575/// use [`PackRegistrySnapshot`] so the lookup hot path can walk already-parsed
576/// pack records directly.
577pub(crate) type PackIndexCache = Arc<RwLock<HashMap<PathBuf, Arc<PackIndexViewData>>>>;
578
579/// Optional `.rev` reverse indexes keyed by `.idx` path, shared across cloned
580/// handles. A cached `None` means no usable reverse index was found.
581pub(crate) type PackReverseIndexCache =
582    Arc<RwLock<HashMap<PathBuf, Option<Arc<PackReverseIndex>>>>>;
583
584/// Parsed multi-pack-index files keyed by path, shared across cloned handles.
585/// Caches the MIDX parse so object lookups in repositories with a MIDX avoid
586/// reparsing the same fanout/object tables for every read.
587pub(crate) type MultiPackIndexCache = Arc<RwLock<HashMap<PathBuf, Arc<MultiPackIndex>>>>;
588
589/// Explicit replacement-object policy injected by repository setup.
590///
591/// The map is deliberately independent of refs/config/environment: embedders
592/// decide whether replacement refs are enabled and pass the already-resolved
593/// old-to-new object ids into the ODB.
594#[derive(Debug, Clone, Default, PartialEq, Eq)]
595pub struct ObjectReplacements {
596    replacements: HashMap<ObjectId, ObjectId>,
597}
598
599impl ObjectReplacements {
600    pub fn new(replacements: impl IntoIterator<Item = (ObjectId, ObjectId)>) -> Self {
601        Self {
602            replacements: replacements.into_iter().collect(),
603        }
604    }
605
606    pub fn is_empty(&self) -> bool {
607        self.replacements.is_empty()
608    }
609
610    /// Follow a replacement chain, rejecting cycles or chains beyond Git's
611    /// five-link recursion guard.
612    pub fn resolve(&self, oid: &ObjectId) -> Result<ObjectId> {
613        let mut current = *oid;
614        let mut seen = HashSet::new();
615        for _ in 0..5 {
616            if !seen.insert(current) {
617                return Err(GitError::InvalidObject(format!(
618                    "replace depth too high for object {oid}"
619                )));
620            }
621            let Some(next) = self.replacements.get(&current) else {
622                return Ok(current);
623            };
624            // `git replace --graft` may materialize an unchanged commit and
625            // consequently write an identity replace ref. Git treats that as
626            // a terminal no-op rather than replacement recursion.
627            if *next == current {
628                return Ok(current);
629            }
630            current = *next;
631        }
632        if self.replacements.contains_key(&current) {
633            return Err(GitError::InvalidObject(format!(
634                "replace depth too high for object {oid}"
635            )));
636        }
637        Ok(current)
638    }
639}
640
641/// Raw multi-pack-index OID lookup tables keyed by path, shared across cloned
642/// handles. These avoid hashing and materializing every MIDX object when a
643/// command only needs point lookups.
644type MultiPackIndexOidLookupCache = Arc<RwLock<HashMap<PathBuf, Arc<MultiPackIndexOidLookup>>>>;
645
646/// One registered `.idx`/`.pack` pair from a pack directory. The index is parsed
647/// when the registry snapshot is built; pack bytes and per-pack decode/header
648/// caches hang directly off this record so repeated object lookups do not bounce
649/// through path-keyed maps.
650#[derive(Debug, Clone)]
651pub struct FileObjectDatabase {
652    pub(crate) loose: LooseObjectStore,
653    pub(crate) objects_dir: PathBuf,
654    /// `$objects_dir/pack`, stored so the lookup hot path never joins it per object.
655    pub(crate) pack_dir: PathBuf,
656    /// `$objects_dir/pack/multi-pack-index`, stored for the same reason.
657    pub(crate) midx_path: PathBuf,
658    pub(crate) alternates: Vec<PathBuf>,
659    pub(crate) format: ObjectFormat,
660    pub(crate) pack_bytes: PackBytesCache,
661    pub(crate) pack_indexes: PackIndexCache,
662    pub(crate) pack_reverse_indexes: PackReverseIndexCache,
663    pub(crate) multi_pack_indexes: MultiPackIndexCache,
664    pub(crate) multi_pack_oid_lookups: MultiPackIndexOidLookupCache,
665    pub(crate) pack_registry: PackRegistryCache,
666    pub(crate) decoded: DecodedObjectCache,
667    pub(crate) pack_deltas: PackDeltaCaches,
668    pub(crate) delta_base_cache_budget_per_pack: usize,
669    pub(crate) pack_header_types: PackHeaderTypeCaches,
670    pub(crate) promisor_objects: Arc<OnceLock<HashSet<ObjectId>>>,
671    /// Whether the owning repository actually has a promisor remote configured
672    /// (`extensions.partialclone` is set, or some `remote.<name>.promisor` is
673    /// true). Mirrors git's `is_promisor_object`, which only treats objects in
674    /// `.promisor` packs as "promised" when `repo_has_promisor_remote()` holds:
675    /// a stray `.promisor` sidecar in a non-partial repo must NOT excuse missing
676    /// objects from fsck. Defaults to `false`; the fsck driver opts in after
677    /// reading the repo config.
678    pub(crate) promisor_remote_present: bool,
679    /// Graft points (`$GIT_DIR/shallow`), loaded lazily on the first
680    /// [`ObjectReader::is_shallow_graft`] query. `$GIT_DIR` is taken to be
681    /// the parent of `objects_dir`, matching the standard layout.
682    pub(crate) shallow_grafts: Arc<std::sync::OnceLock<HashSet<ObjectId>>>,
683    pub(crate) replacements: Arc<ObjectReplacements>,
684}
685
686/// `$objects_dir/pack` and its `multi-pack-index` path. Cached on
687/// [`FileObjectDatabase`] so `find_pack_containing` / header reads do not
688/// allocate a `PathBuf` per object (sley#26).
689fn pack_layout_paths(objects_dir: &Path) -> (PathBuf, PathBuf) {
690    let pack_dir = objects_dir.join("pack");
691    let midx_path = pack_dir.join("multi-pack-index");
692    (pack_dir, midx_path)
693}
694
695fn read_shallow_grafts(shallow_file: &Path, format: ObjectFormat) -> HashSet<ObjectId> {
696    let Ok(contents) = std::fs::read_to_string(shallow_file) else {
697        return HashSet::new();
698    };
699    contents
700        .lines()
701        .filter_map(|line| ObjectId::from_hex(format, line.trim()).ok())
702        .collect()
703}
704
705impl FileObjectDatabase {
706    /// The object-id format (hash algorithm) this database was opened with.
707    pub fn object_format(&self) -> ObjectFormat {
708        self.format
709    }
710
711    /// The repository object directory this database reads from.
712    pub fn objects_dir(&self) -> &Path {
713        &self.objects_dir
714    }
715
716    pub fn new(objects_dir: impl Into<PathBuf>, format: ObjectFormat) -> Self {
717        let objects_dir = objects_dir.into();
718        let (pack_dir, midx_path) = pack_layout_paths(&objects_dir);
719        let alternates = alternate_object_dirs(&objects_dir);
720        let delta_base_cache_budget_per_pack =
721            per_pack_delta_base_cache_budget(&objects_dir, &alternates);
722        Self {
723            loose: LooseObjectStore::new(objects_dir.clone(), format),
724            pack_dir,
725            midx_path,
726            alternates,
727            objects_dir,
728            format,
729            pack_bytes: Arc::new(RwLock::new(HashMap::new())),
730            pack_indexes: Arc::new(RwLock::new(HashMap::new())),
731            pack_reverse_indexes: Arc::new(RwLock::new(HashMap::new())),
732            multi_pack_indexes: Arc::new(RwLock::new(HashMap::new())),
733            multi_pack_oid_lookups: Arc::new(RwLock::new(HashMap::new())),
734            pack_registry: Arc::new(Mutex::new(None)),
735            decoded: Arc::new(Mutex::new(LruObjectCache::new(object_cache_budget()))),
736            pack_deltas: Arc::new(Mutex::new(HashMap::new())),
737            delta_base_cache_budget_per_pack,
738            pack_header_types: Arc::new(Mutex::new(HashMap::new())),
739            promisor_objects: Arc::new(OnceLock::new()),
740            promisor_remote_present: false,
741            shallow_grafts: Arc::new(std::sync::OnceLock::new()),
742            replacements: Arc::new(ObjectReplacements::default()),
743        }
744    }
745
746    pub(crate) fn without_alternates(
747        objects_dir: impl Into<PathBuf>,
748        format: ObjectFormat,
749    ) -> Self {
750        let objects_dir = objects_dir.into();
751        let (pack_dir, midx_path) = pack_layout_paths(&objects_dir);
752        let delta_base_cache_budget_per_pack = per_pack_delta_base_cache_budget(&objects_dir, &[]);
753        Self {
754            loose: LooseObjectStore::new(objects_dir.clone(), format),
755            pack_dir,
756            midx_path,
757            alternates: Vec::new(),
758            objects_dir,
759            format,
760            pack_bytes: Arc::new(RwLock::new(HashMap::new())),
761            pack_indexes: Arc::new(RwLock::new(HashMap::new())),
762            pack_reverse_indexes: Arc::new(RwLock::new(HashMap::new())),
763            multi_pack_indexes: Arc::new(RwLock::new(HashMap::new())),
764            multi_pack_oid_lookups: Arc::new(RwLock::new(HashMap::new())),
765            pack_registry: Arc::new(Mutex::new(None)),
766            decoded: Arc::new(Mutex::new(LruObjectCache::new(object_cache_budget()))),
767            pack_deltas: Arc::new(Mutex::new(HashMap::new())),
768            delta_base_cache_budget_per_pack,
769            pack_header_types: Arc::new(Mutex::new(HashMap::new())),
770            promisor_objects: Arc::new(OnceLock::new()),
771            promisor_remote_present: false,
772            shallow_grafts: Arc::new(std::sync::OnceLock::new()),
773            replacements: Arc::new(ObjectReplacements::default()),
774        }
775    }
776
777    pub fn from_git_dir(git_dir: impl AsRef<Path>, format: ObjectFormat) -> Self {
778        let git_dir = git_dir.as_ref();
779        let shared_repository = sley_formats::SharedRepositoryPermissions::from_git_dir(git_dir);
780        let mut database = Self::new(repository_objects_dir(git_dir), format);
781        database.loose = database.loose.with_shared_repository(shared_repository);
782        database
783    }
784
785    /// Attach an explicit replacement map. Object enumeration/storage queries
786    /// remain raw; object content and header reads dereference the map.
787    pub fn with_replacements(mut self, replacements: ObjectReplacements) -> Self {
788        self.replacements = Arc::new(replacements);
789        self
790    }
791
792    pub fn replacements(&self) -> &ObjectReplacements {
793        &self.replacements
794    }
795
796    pub fn replacement_oid(&self, oid: &ObjectId) -> Result<ObjectId> {
797        self.replacements.resolve(oid)
798    }
799
800    /// Read object contents without consulting the injected replacement map.
801    /// Used by raw enumeration surfaces such as `cat-file --batch-all-objects`.
802    pub fn read_object_without_replacement(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
803        self.read_object_raw(oid)
804    }
805
806    /// Read an object header without consulting the injected replacement map.
807    pub fn read_object_header_without_replacement(
808        &self,
809        oid: &ObjectId,
810    ) -> Result<Option<(ObjectType, u64)>> {
811        self.read_object_header_raw(oid)
812            .map(|header| header.map(sley_pack::PackObjectHeader::type_and_size))
813    }
814
815    /// Declare whether the owning repository has a promisor remote configured.
816    /// Only when this holds does [`ObjectReader::is_promised_object`] treat
817    /// objects in `.promisor` packs (and their transitive references) as
818    /// promised — matching git's `is_promisor_object`, which is gated on
819    /// `repo_has_promisor_remote()`. Callers that know the repo config (e.g. the
820    /// fsck driver) opt in; readers built without config keep the safe default
821    /// of `false`, so a stray `.promisor` sidecar never silently excuses a
822    /// genuinely missing object.
823    pub fn with_promisor_remote_present(mut self, present: bool) -> Self {
824        self.promisor_remote_present = present;
825        self
826    }
827
828    /// Drop cached pack registries, indexes, and decoded objects so the next read
829    /// sees packs/objects installed after this handle was created (e.g. after
830    /// `fetch` or `install_pack`). Long-lived `Repository` sessions call this
831    /// via the owning repository's `refresh_objects` hook.
832    pub fn refresh_read_cache(&self) {
833        if let Ok(mut cache) = self.pack_registry.lock() {
834            *cache = None;
835        }
836        self.pack_indexes.write().clear();
837        self.multi_pack_indexes.write().clear();
838        self.multi_pack_oid_lookups.write().clear();
839        self.pack_bytes.write().clear();
840        if let Ok(mut cache) = self.pack_deltas.lock() {
841            cache.clear();
842        }
843        if let Ok(mut cache) = self.pack_header_types.lock() {
844            cache.clear();
845        }
846        if let Ok(mut cache) = self.decoded.lock() {
847            cache.clear();
848        }
849        self.loose.invalidate_cache();
850    }
851
852    pub fn loose(&self) -> &LooseObjectStore {
853        &self.loose
854    }
855
856    pub fn presence_checker(&self) -> ObjectPresenceChecker {
857        ObjectPresenceChecker::new(self.clone())
858    }
859
860    pub fn contains(&self, oid: &ObjectId) -> Result<bool> {
861        if self.loose.exists(oid)? {
862            return Ok(true);
863        }
864        if self.find_pack_containing(oid)?.is_some() {
865            return Ok(true);
866        }
867        for alternate in &self.alternates {
868            if Self::without_alternates(alternate, self.format).contains(oid)? {
869                return Ok(true);
870            }
871        }
872        // Reprepare-on-miss: a cached negative loose verdict may predate a
873        // sibling write. Drop it and exact-probe once before reporting absence.
874        self.loose.invalidate_cache();
875        self.loose.exists(oid)
876    }
877
878    pub fn object_ids(&self) -> Result<Vec<ObjectId>> {
879        let mut oids = object_ids_in_objects_dir(&self.objects_dir, self.format)?
880            .into_iter()
881            .collect::<HashSet<_>>();
882        for alternate in &self.alternates {
883            oids.extend(Self::without_alternates(alternate, self.format).object_ids()?);
884        }
885        let mut oids = oids.into_iter().collect::<Vec<_>>();
886        oids.sort_by_key(ObjectId::to_hex);
887        Ok(oids)
888    }
889
890    pub fn object_storage_info(&self, oid: &ObjectId) -> Result<Option<ObjectStorageInfo>> {
891        if let Some(disk_size) = self.loose.disk_size(oid)? {
892            return Ok(Some(ObjectStorageInfo {
893                disk_size,
894                deltabase: ObjectId::null(self.format),
895            }));
896        }
897        if let Some(info) = self.packed_object_storage_info(oid)? {
898            return Ok(Some(info));
899        }
900        for alternate in &self.alternates {
901            if let Some(info) =
902                Self::without_alternates(alternate, self.format).object_storage_info(oid)?
903            {
904                return Ok(Some(info));
905            }
906        }
907        // Reprepare-on-miss: drop any stale negative loose cache and exact-probe
908        // once before reporting absence (see `read_object`).
909        self.loose.invalidate_cache();
910        if let Some(disk_size) = self.loose.disk_size(oid)? {
911            return Ok(Some(ObjectStorageInfo {
912                disk_size,
913                deltabase: ObjectId::null(self.format),
914            }));
915        }
916        Ok(None)
917    }
918
919    pub fn resolve_prefix(&self, prefix: &str) -> Result<ObjectPrefixResolution> {
920        let mut matches = self.object_ids_with_prefix(prefix)?;
921        Ok(match matches.len() {
922            0 => ObjectPrefixResolution::Missing,
923            1 => ObjectPrefixResolution::Unique(matches.remove(0)),
924            _ => ObjectPrefixResolution::Ambiguous(matches),
925        })
926    }
927
928    pub fn object_ids_with_prefix(&self, prefix: &str) -> Result<Vec<ObjectId>> {
929        validate_object_id_prefix(self.format, prefix)?;
930        let prefix_bytes = prefix.as_bytes();
931        let mut matches =
932            object_ids_with_prefix_in_objects_dir(&self.objects_dir, self.format, prefix_bytes)?
933                .into_iter()
934                .collect::<HashSet<_>>();
935        for alternate in &self.alternates {
936            for oid in
937                Self::without_alternates(alternate, self.format).object_ids_with_prefix(prefix)?
938            {
939                matches.insert(oid);
940            }
941        }
942        let mut matches = matches.into_iter().collect::<Vec<_>>();
943        matches.sort_by_key(ObjectId::to_hex);
944        Ok(matches)
945    }
946
947    /// The object type and content size of `oid` without decoding its full body —
948    /// git's `cat-file --batch-check` fast path. Tries the decoded-object cache,
949    /// then packs (entry header; for deltas, only the leading size varints), then
950    /// loose storage, then alternates. Pack-first matches [`ObjectReader::read_object`]
951    /// so a packed-only batch never pays a loose probe, and a corrupt loose copy
952    /// cannot shadow a good pack (sley#26). Returns `Ok(None)` if the object is
953    /// not present.
954    ///
955    /// Unlike [`ObjectReader::read_object`], this never materializes the body, so it
956    /// stays cheap on huge blobs and deep delta chains. It does not populate the
957    /// decoded-object cache (nothing is decoded).
958    pub fn read_object_header(&self, oid: &ObjectId) -> Result<Option<(ObjectType, u64)>> {
959        let read_oid = self.replacements.resolve(oid)?;
960        self.read_object_header_raw(&read_oid)
961            .map(|header| header.map(sley_pack::PackObjectHeader::type_and_size))
962    }
963
964    fn read_object_header_raw(
965        &self,
966        oid: &ObjectId,
967    ) -> Result<Option<sley_pack::PackObjectHeader>> {
968        let mut context = HeaderReadContext::new();
969        self.read_object_header_raw_with_context(oid, &mut context)
970    }
971
972    fn read_object_header_raw_with_context(
973        &self,
974        oid: &ObjectId,
975        context: &mut HeaderReadContext,
976    ) -> Result<Option<sley_pack::PackObjectHeader>> {
977        if implied_empty_tree_object(self.format, oid).is_some() {
978            return Ok(Some(sley_pack::PackObjectHeader::undeltified(
979                ObjectType::Tree,
980                0,
981            )));
982        }
983        // A decoded body does not retain the chain depth that produced it. It is
984        // safe as a top-level header hit, but a ref-delta base must be resolved
985        // through storage so its depth participates in the cumulative limit.
986        if context.delta_depth == 0
987            && let Ok(mut cache) = self.decoded.lock()
988            && let Some(object) = cache.get(oid)
989        {
990            return Ok(Some(sley_pack::PackObjectHeader::undeltified(
991                object.object_type,
992                object.body.len() as u64,
993            )));
994        }
995        // Same source order as `read_object_raw`: pack first. The header path used
996        // to probe loose storage on every call, which defeated the packed fast path
997        // on `cat-file --batch-check` (sley#26) and let a corrupt loose file abort
998        // the read before the pack was consulted.
999        let mut first_error = None;
1000        let mut selected_pack = None;
1001        match self.find_pack_containing(oid) {
1002            Ok(Some(pack_lookup)) => {
1003                match self.read_packed_object_header_at_lookup(oid, &pack_lookup, context) {
1004                    Ok(header) => return Ok(Some(header)),
1005                    Err(err) => first_error = Some(err),
1006                }
1007                selected_pack = Some(pack_lookup);
1008            }
1009            Ok(None) => {}
1010            Err(err) => first_error = Some(err),
1011        }
1012
1013        // Error-only fallback: bypass a corrupt MIDX/registry and try the raw
1014        // `.idx` files, or find a redundant pack when the selected copy is bad.
1015        // The normal successful path never pays this directory scan.
1016        if first_error.is_some() {
1017            match self.read_packed_object_header_from_pack_dir(oid, selected_pack.as_ref(), context)
1018            {
1019                Ok(Some(header)) => return Ok(Some(header)),
1020                Ok(None) => {}
1021                Err(_) => {}
1022            }
1023        }
1024
1025        match self.loose.read_header(oid) {
1026            Ok(Some((object_type, size))) => {
1027                return Ok(Some(sley_pack::PackObjectHeader::undeltified(
1028                    object_type,
1029                    size,
1030                )));
1031            }
1032            Ok(None) => {}
1033            Err(err) if first_error.is_none() => first_error = Some(err),
1034            Err(_) => {}
1035        }
1036        for alternate in &self.alternates {
1037            match Self::without_alternates(alternate, self.format)
1038                .read_object_header_raw_with_context(oid, context)
1039            {
1040                Ok(Some(header)) => return Ok(Some(header)),
1041                Ok(None) => {}
1042                Err(err) if first_error.is_none() => first_error = Some(err),
1043                Err(_) => {}
1044            }
1045        }
1046        // Reprepare-on-miss: discard any stale negative loose cache and retry an
1047        // exact path probe once before reporting absence (see `read_object`).
1048        self.loose.invalidate_cache();
1049        match self.loose.read_header(oid) {
1050            Ok(Some((object_type, size))) => {
1051                return Ok(Some(sley_pack::PackObjectHeader::undeltified(
1052                    object_type,
1053                    size,
1054                )));
1055            }
1056            Ok(None) => {}
1057            Err(err) if first_error.is_none() => first_error = Some(err),
1058            Err(_) => {}
1059        }
1060        if let Some(err) = first_error {
1061            return Err(err);
1062        }
1063        Ok(None)
1064    }
1065
1066    fn read_packed_object_header_at_lookup(
1067        &self,
1068        oid: &ObjectId,
1069        pack_lookup: &PackLookup,
1070        context: &mut HeaderReadContext,
1071    ) -> Result<sley_pack::PackObjectHeader> {
1072        context.enter_pack(oid)?;
1073        let result = (|| {
1074            let bytes = pack_lookup.pack_bytes(self)?;
1075            // Per-pack offset->(type,size) memo so the ofs-delta chain walk that
1076            // resolves a packed object's type runs at most once per chain across the
1077            // batch, instead of re-walking (and re-inflating each link's leading
1078            // varints) on every header read — the sley#26 super-linear
1079            // `cat-file --batch-check`.
1080            let type_cache = pack_lookup.header_type_cache(self);
1081            let initial_delta_depth = context.delta_depth;
1082            let resolve_ref_base = |base: &ObjectId, delta_depth: usize| {
1083                context.check_ref_base(base)?;
1084                let previous_delta_depth = context.delta_depth;
1085                context.delta_depth = delta_depth;
1086                let result = self.read_object_header_raw_with_context(base, context);
1087                context.delta_depth = previous_delta_depth;
1088                result
1089            };
1090            match &type_cache {
1091                Some(cache) => {
1092                    let mut adapter = PackHeaderTypeCacheAdapter(cache);
1093                    sley_pack::read_object_header_at_with_cache(
1094                        &bytes,
1095                        pack_lookup.offset,
1096                        self.format,
1097                        initial_delta_depth,
1098                        resolve_ref_base,
1099                        &mut adapter,
1100                    )
1101                }
1102                None => sley_pack::read_object_header_at(
1103                    &bytes,
1104                    pack_lookup.offset,
1105                    self.format,
1106                    initial_delta_depth,
1107                    resolve_ref_base,
1108                ),
1109            }
1110        })();
1111        context.leave_pack(oid);
1112        result
1113    }
1114
1115    fn read_packed_object_header_from_pack_dir(
1116        &self,
1117        oid: &ObjectId,
1118        exclude: Option<&PackLookup>,
1119        context: &mut HeaderReadContext,
1120    ) -> Result<Option<sley_pack::PackObjectHeader>> {
1121        let Ok(entries) = fs::read_dir(&self.pack_dir) else {
1122            return Ok(None);
1123        };
1124        let excluded_pack = exclude.map(|lookup| lookup.pack_path());
1125        for entry in entries {
1126            let idx_path = entry?.path();
1127            if idx_path.extension().and_then(|ext| ext.to_str()) != Some("idx") {
1128                continue;
1129            }
1130            let pack_path = idx_path.with_extension("pack");
1131            if excluded_pack == Some(pack_path.as_path()) {
1132                continue;
1133            }
1134            let Ok(idx_bytes) = fs::read(&idx_path) else {
1135                continue;
1136            };
1137            let Ok(index) = PackIndex::parse(&idx_bytes, self.format) else {
1138                continue;
1139            };
1140            let Some(entry) = index.find(oid) else {
1141                continue;
1142            };
1143            let candidate = PackLookup::from_path(pack_path, entry.offset);
1144            if let Ok(header) = self.read_packed_object_header_at_lookup(oid, &candidate, context) {
1145                return Ok(Some(header));
1146            }
1147        }
1148        Ok(None)
1149    }
1150
1151    pub(crate) fn read_packed_object(&self, oid: &ObjectId) -> Result<Option<Arc<EncodedObject>>> {
1152        // Memory-capped decoded-object cache first (delta-base reuse for ref-delta
1153        // bases that resolve back through the store + repeated whole-object reads).
1154        if let Ok(mut cache) = self.decoded.lock()
1155            && let Some(object) = cache.get(oid)
1156        {
1157            return Ok(Some(object));
1158        }
1159        let Some(pack_lookup) = self.find_pack_containing(oid)? else {
1160            return Ok(None);
1161        };
1162        self.read_packed_object_at_lookup(oid, &pack_lookup)
1163            .map(Some)
1164    }
1165
1166    pub(crate) fn read_packed_object_at_lookup(
1167        &self,
1168        oid: &ObjectId,
1169        pack_lookup: &PackLookup,
1170    ) -> Result<Arc<EncodedObject>> {
1171        if let Ok(mut cache) = self.decoded.lock()
1172            && let Some(object) = cache.get(oid)
1173        {
1174            return Ok(object);
1175        }
1176        let bytes = pack_lookup.pack_bytes(self)?;
1177        // Per-pack delta-base cache (keyed by in-pack offset). Resolving an
1178        // ofs-delta chain reuses already-decoded bases instead of re-inflating the
1179        // whole chain on every read. Scoped to this pack's path so an offset key is
1180        // never applied to the wrong pack's bytes.
1181        let delta_cache = pack_lookup.delta_cache(self);
1182        let delta_adapter = delta_cache.as_ref().map(PackDeltaCacheAdapter);
1183        // Decode only this object at its offset (plus its delta-base chain). A
1184        // ref-delta base resolves through the full store (loose / other packs) and
1185        // reuses the decoded-object cache. No cache lock is held across the decode,
1186        // so the recursive resolver re-entry (which may re-enter read_object) is
1187        // safe.
1188        let resolve_ref_base = |base: &ObjectId| self.read_object_raw(base).map(Some);
1189        let resolve_ofs_base =
1190            |base_offset| self.read_ofs_delta_base_from_other_sources(pack_lookup, base_offset);
1191        let object = match &delta_adapter {
1192            Some(adapter) => sley_pack::read_object_at_with_cache_and_ofs_base_arc(
1193                &bytes,
1194                pack_lookup.offset,
1195                self.format,
1196                resolve_ref_base,
1197                resolve_ofs_base,
1198                adapter,
1199            )?,
1200            None => sley_pack::read_object_at_with_ofs_base_arc(
1201                &bytes,
1202                pack_lookup.offset,
1203                self.format,
1204                resolve_ref_base,
1205                resolve_ofs_base,
1206            )?,
1207        };
1208        // Trust the index → offset mapping rather than re-hashing every decoded
1209        // object on read (see `verify_reads_enabled`); this re-hash dominated
1210        // bulk-read cost. Opt back in with `SLEY_VERIFY_READS` for a paranoid check.
1211        if verify_reads_enabled() {
1212            let actual = object.object_id(self.format)?;
1213            if actual != *oid {
1214                return Err(GitError::InvalidObject(format!(
1215                    "pack object id mismatch: index says {oid}, decoded {actual}"
1216                )));
1217            }
1218        }
1219        if let Ok(mut cache) = self.decoded.lock() {
1220            cache.put(*oid, Arc::clone(&object));
1221        }
1222        Ok(object)
1223    }
1224
1225    /// The per-pack delta-base cache for `pack_path`, creating it on first use.
1226    /// Returns `None` only if the shared map's lock is poisoned, in which case the
1227    /// caller falls back to an uncached decode (correctness preserved).
1228    pub(crate) fn pack_delta_cache(&self, pack_path: &Path) -> Option<Arc<Mutex<LruOffsetCache>>> {
1229        let mut caches = self.pack_deltas.lock().ok()?;
1230        let cache = caches.entry(pack_path.to_path_buf()).or_insert_with(|| {
1231            Arc::new(Mutex::new(LruOffsetCache::new(
1232                self.delta_base_cache_budget_per_pack,
1233            )))
1234        });
1235        Some(Arc::clone(cache))
1236    }
1237
1238    /// The per-pack header-type memo for `pack_path`, creating it on first use.
1239    /// Returns `None` only if the shared map's lock is poisoned, in which case the
1240    /// caller falls back to an unmemoized header walk (correctness preserved).
1241    pub(crate) fn pack_header_type_cache(&self, pack_path: &Path) -> Option<PackHeaderTypeCache> {
1242        let mut caches = self.pack_header_types.lock().ok()?;
1243        let cache = caches
1244            .entry(pack_path.to_path_buf())
1245            .or_insert_with(|| Arc::new(Mutex::new(HashMap::new())));
1246        Some(Arc::clone(cache))
1247    }
1248
1249    /// Backing bytes of the pack at `pack_path`, loaded at most once per database
1250    /// handle (cached, shared across clones). Memory-mapped under the `mmap` feature,
1251    /// otherwise read into the heap. On a poisoned lock it falls back to loading
1252    /// without caching, preserving correctness.
1253    pub(crate) fn cached_pack_bytes(&self, pack_path: &Path) -> Result<Arc<PackData>> {
1254        if let Some(bytes) = self.pack_bytes.read().get(pack_path) {
1255            return Ok(Arc::clone(bytes));
1256        }
1257        let bytes = Arc::new(load_pack_data(pack_path)?);
1258        self.pack_bytes
1259            .write()
1260            .insert(pack_path.to_path_buf(), Arc::clone(&bytes));
1261        Ok(bytes)
1262    }
1263
1264    /// Parsed index for the `.idx` at `index_path`, parsed at most once per
1265    /// database handle. On a poisoned lock it falls back to parsing without
1266    /// caching, preserving correctness.
1267    pub(crate) fn cached_pack_index(&self, index_path: &Path) -> Result<Arc<PackIndexViewData>> {
1268        if let Some(index) = self.pack_indexes.read().get(index_path) {
1269            return Ok(Arc::clone(index));
1270        }
1271        let index_bytes = load_pack_index_data(index_path)?;
1272        let index = Arc::new(PackIndexViewData::parse_trusted_source_without_checksum(
1273            index_bytes,
1274            self.format,
1275        )?);
1276        self.pack_indexes
1277            .write()
1278            .insert(index_path.to_path_buf(), Arc::clone(&index));
1279        Ok(index)
1280    }
1281
1282    /// Optional reverse index for the `.idx` at `index_path`, loaded at most once
1283    /// per database handle when a matching `.rev` sidecar is present.
1284    pub(crate) fn cached_pack_reverse_index(
1285        &self,
1286        index_path: &Path,
1287        index: &PackIndexViewData,
1288    ) -> Result<Option<Arc<PackReverseIndex>>> {
1289        if let Some(cached) = self.pack_reverse_indexes.read().get(index_path) {
1290            return Ok(cached.as_ref().map(Arc::clone));
1291        }
1292        let rev_path = index_path.with_extension("rev");
1293        let reverse = if rev_path.exists() {
1294            let bytes = fs::read(&rev_path)?;
1295            match PackReverseIndex::parse(&bytes, self.format, index.count) {
1296                Ok(parsed) if parsed.pack_checksum == index.pack_checksum => Some(Arc::new(parsed)),
1297                _ => None,
1298            }
1299        } else {
1300            None
1301        };
1302        self.pack_reverse_indexes
1303            .write()
1304            .insert(index_path.to_path_buf(), reverse.as_ref().map(Arc::clone));
1305        Ok(reverse)
1306    }
1307
1308    pub(crate) fn cached_multi_pack_index_oid_lookup(
1309        &self,
1310        midx_path: &Path,
1311    ) -> Result<Option<Arc<MultiPackIndexOidLookup>>> {
1312        if !midx_path.exists() {
1313            return Ok(None);
1314        }
1315        if let Some(midx) = self.multi_pack_oid_lookups.read().get(midx_path) {
1316            return Ok(Some(Arc::clone(midx)));
1317        }
1318        let bytes = load_multi_pack_index_lookup_data(midx_path)?;
1319        let midx = match MultiPackIndexOidLookup::parse(bytes, self.format) {
1320            Ok(midx) => Arc::new(midx),
1321            Err(GitError::InvalidFormat(message))
1322                if message.starts_with("multi-pack-index hash id ") =>
1323            {
1324                let actual = message
1325                    .strip_prefix("multi-pack-index hash id ")
1326                    .and_then(|rest| rest.split_whitespace().next())
1327                    .unwrap_or("0");
1328                let expected = match self.format {
1329                    ObjectFormat::Sha1 => 1,
1330                    ObjectFormat::Sha256 => 2,
1331                };
1332                eprintln!(
1333                    "error: multi-pack-index hash version {actual} does not match version {expected}"
1334                );
1335                return Ok(None);
1336            }
1337            Err(err) => return Err(err),
1338        };
1339        self.multi_pack_oid_lookups
1340            .write()
1341            .insert(midx_path.to_path_buf(), Arc::clone(&midx));
1342        Ok(Some(midx))
1343    }
1344
1345    pub(crate) fn cached_multi_pack_index(
1346        &self,
1347        midx_path: &Path,
1348    ) -> Result<Option<Arc<MultiPackIndex>>> {
1349        if !midx_path.exists() {
1350            return Ok(None);
1351        }
1352        if let Some(midx) = self.multi_pack_indexes.read().get(midx_path) {
1353            return Ok(Some(Arc::clone(midx)));
1354        }
1355        let bytes = load_multi_pack_index_lookup_data(midx_path)?;
1356        let midx = match MultiPackIndex::parse(bytes.as_bytes(), self.format) {
1357            Ok(midx) => Arc::new(midx),
1358            Err(GitError::InvalidFormat(message))
1359                if message.starts_with("multi-pack-index hash id ") =>
1360            {
1361                let actual = message
1362                    .strip_prefix("multi-pack-index hash id ")
1363                    .and_then(|rest| rest.split_whitespace().next())
1364                    .unwrap_or("0");
1365                let expected = match self.format {
1366                    ObjectFormat::Sha1 => 1,
1367                    ObjectFormat::Sha256 => 2,
1368                };
1369                eprintln!(
1370                    "error: multi-pack-index hash version {actual} does not match version {expected}"
1371                );
1372                return Ok(None);
1373            }
1374            Err(err) => return Err(err),
1375        };
1376        self.multi_pack_indexes
1377            .write()
1378            .insert(midx_path.to_path_buf(), Arc::clone(&midx));
1379        Ok(Some(midx))
1380    }
1381
1382    /// Registry snapshot for this database's pack directory. With `force_rescan`,
1383    /// the directory is re-read; when the fingerprint and pack set match the
1384    /// cached snapshot, the same `Arc` is returned so miss handling can tell that
1385    /// no new packs appeared.
1386    pub(crate) fn cached_pack_registry(
1387        &self,
1388        pack_dir: &Path,
1389        force_rescan: bool,
1390    ) -> Result<Arc<PackRegistrySnapshot>> {
1391        if !force_rescan && let Some(registry) = self.cached_loaded_pack_registry(pack_dir)? {
1392            return Ok(registry);
1393        }
1394        let scanned = Arc::new(scan_pack_registry(
1395            pack_dir,
1396            self.format,
1397            self.delta_base_cache_budget_per_pack,
1398        )?);
1399        if let Ok(mut cache) = self.pack_registry.lock() {
1400            match cache.as_ref() {
1401                Some(existing)
1402                    if existing.fingerprint == scanned.fingerprint
1403                        && same_registered_pack_set(&existing.packs, &scanned.packs) =>
1404                {
1405                    return Ok(Arc::clone(existing));
1406                }
1407                _ => {
1408                    *cache = Some(Arc::clone(&scanned));
1409                }
1410            }
1411        }
1412        Ok(scanned)
1413    }
1414
1415    pub(crate) fn find_in_pack_registry(
1416        &self,
1417        registry: Arc<PackRegistrySnapshot>,
1418        oid: &ObjectId,
1419    ) -> Result<Option<PackLookup>> {
1420        let hinted_pack_index = registry.cached_hint();
1421        if let Some(pack_index) = hinted_pack_index {
1422            let pack = &registry.packs[pack_index];
1423            match pack.index(self.format) {
1424                Ok(index) => {
1425                    if let Some(entry) = index.find(oid) {
1426                        return Ok(Some(PackLookup::from_registered(
1427                            Arc::clone(pack),
1428                            entry.offset,
1429                        )));
1430                    }
1431                }
1432                Err(_) => {
1433                    eprintln!("error: packfile {} index unavailable", pack.pack.display());
1434                }
1435            }
1436        }
1437        for (pack_index, pack) in registry.packs.iter().enumerate() {
1438            if Some(pack_index) == hinted_pack_index {
1439                continue;
1440            }
1441            let index = match pack.index(self.format) {
1442                Ok(index) => index,
1443                Err(_) => {
1444                    eprintln!("error: packfile {} index unavailable", pack.pack.display());
1445                    continue;
1446                }
1447            };
1448            if let Some(entry) = index.find(oid) {
1449                registry.remember_hint(pack_index);
1450                return Ok(Some(PackLookup::from_registered(
1451                    Arc::clone(pack),
1452                    entry.offset,
1453                )));
1454            }
1455        }
1456        Ok(None)
1457    }
1458
1459    /// Read `oid` from any pack *other than* the one named by `exclude`, used as
1460    /// a corruption fallback: a redundant packed copy survives one pack's
1461    /// damage. Scans the on-disk `.idx` files directly (bypassing the registry
1462    /// cache, whose first hit is the excluded pack) and decodes from the first
1463    /// other pack that both indexes the object and parses cleanly.
1464    pub(crate) fn read_packed_object_from_other_packs(
1465        &self,
1466        oid: &ObjectId,
1467        exclude: &PackLookup,
1468    ) -> Result<Option<Arc<EncodedObject>>> {
1469        let pack_dir = self.objects_dir.join("pack");
1470        let Ok(entries) = fs::read_dir(&pack_dir) else {
1471            return Ok(None);
1472        };
1473        let excluded_pack = exclude.pack_path().to_path_buf();
1474        for entry in entries {
1475            let idx_path = entry?.path();
1476            if idx_path.extension().and_then(|ext| ext.to_str()) != Some("idx") {
1477                continue;
1478            }
1479            let pack_path = idx_path.with_extension("pack");
1480            if pack_path == excluded_pack {
1481                continue;
1482            }
1483            let Ok(idx_bytes) = fs::read(&idx_path) else {
1484                continue;
1485            };
1486            let Ok(index) = PackIndex::parse(&idx_bytes, self.format) else {
1487                continue;
1488            };
1489            let Some(entry) = index.find(oid) else {
1490                continue;
1491            };
1492            let candidate = PackLookup::from_path(pack_path, entry.offset);
1493            if let Ok(object) = self.read_packed_object_at_lookup(oid, &candidate) {
1494                return Ok(Some(object));
1495            }
1496        }
1497        Ok(None)
1498    }
1499
1500    pub(crate) fn pack_oid_at_offset(
1501        &self,
1502        pack_lookup: &PackLookup,
1503        offset: u64,
1504    ) -> Result<Option<ObjectId>> {
1505        let index_path = match &pack_lookup.registered {
1506            Some(pack) => pack.idx.clone(),
1507            None => pack_lookup.pack.with_extension("idx"),
1508        };
1509        match pack_lookup.pack_index(self) {
1510            Ok(index) => {
1511                if let Some(reverse) = self.cached_pack_reverse_index(&index_path, &index)? {
1512                    Ok(reverse.oid_at_offset(&index, offset))
1513                } else {
1514                    Ok(index.oid_at_offset_linear(offset))
1515                }
1516            }
1517            Err(_) => self.midx_oid_for_pack_offset(pack_lookup, offset),
1518        }
1519    }
1520
1521    pub(crate) fn read_ofs_delta_base_from_other_sources(
1522        &self,
1523        pack_lookup: &PackLookup,
1524        base_offset: u64,
1525    ) -> Result<Option<Arc<EncodedObject>>> {
1526        let Some(base_oid) = self.pack_oid_at_offset(pack_lookup, base_offset)? else {
1527            return Ok(None);
1528        };
1529        if let Ok(mut cache) = self.decoded.lock()
1530            && let Some(object) = cache.get(&base_oid)
1531        {
1532            return Ok(Some(object));
1533        }
1534        if let Ok(object) = self.loose.read_object(&base_oid) {
1535            return Ok(Some(object));
1536        }
1537        if let Some(object) = self.read_packed_object_from_other_packs(&base_oid, pack_lookup)? {
1538            return Ok(Some(object));
1539        }
1540        for alternate in &self.alternates {
1541            if let Ok(object) =
1542                Self::without_alternates(alternate, self.format).read_object(&base_oid)
1543            {
1544                return Ok(Some(object));
1545            }
1546        }
1547        Ok(None)
1548    }
1549
1550    pub(crate) fn find_pack_containing(&self, oid: &ObjectId) -> Result<Option<PackLookup>> {
1551        if oid.format() != self.format {
1552            return Err(GitError::InvalidObjectId(format!(
1553                "object {oid} uses {}, store uses {}",
1554                oid.format().name(),
1555                self.format.name()
1556            )));
1557        }
1558        let pack_dir = &self.pack_dir;
1559        // Hot path: a previously cached pack registry or multi-pack-index already
1560        // names every pack, and locating `oid` in them is pure in-memory index
1561        // work. Try that first so a warm handle does not parse indexes or hash
1562        // pack paths on every lookup.
1563        if let Some(midx) = self.cached_loaded_multi_pack_index_oid_lookup()
1564            && let Some(pack_paths) = self.midx_oid_lookup_pack_paths(pack_dir, &midx, oid)?
1565        {
1566            return Ok(Some(pack_paths));
1567        }
1568        if let Some(registry) = self.cached_loaded_pack_registry(pack_dir)?
1569            && let Some(pack_paths) = self.find_in_pack_registry(registry, oid)?
1570        {
1571            return Ok(Some(pack_paths));
1572        }
1573
1574        if !pack_dir.exists() {
1575            return Ok(None);
1576        }
1577        if let Some(pack_paths) = self.find_midx_pack_containing(pack_dir, oid)? {
1578            return Ok(Some(pack_paths));
1579        }
1580        // Search the cached registry first. On a complete miss, re-scan the
1581        // directory once (picking up any pack added since the registry was
1582        // cached) and search again, so newly written packs are still found.
1583        let registry = self.cached_pack_registry(pack_dir, false)?;
1584        if let Some(pack_paths) = self.find_in_pack_registry(Arc::clone(&registry), oid)? {
1585            return Ok(Some(pack_paths));
1586        }
1587        let refreshed = self.cached_pack_registry(pack_dir, true)?;
1588        if Arc::ptr_eq(&registry, &refreshed) {
1589            // The re-scan produced the same registry, so nothing new appeared.
1590            return Ok(None);
1591        }
1592        self.find_in_pack_registry(refreshed, oid)
1593    }
1594
1595    pub(crate) fn packed_object_storage_info(
1596        &self,
1597        oid: &ObjectId,
1598    ) -> Result<Option<ObjectStorageInfo>> {
1599        let Some(pack_lookup) = self.find_pack_containing(oid)? else {
1600            return Ok(None);
1601        };
1602        let index = pack_lookup.pack_index(self).ok();
1603        let pack = match pack_lookup.pack_bytes(self) {
1604            Ok(pack) => Some(pack),
1605            Err(_err) if index.is_some() => None,
1606            Err(err) => return Err(err),
1607        };
1608        let trailer_offset = pack
1609            .as_ref()
1610            .map(|pack| {
1611                (pack.len() as u64)
1612                    .checked_sub(self.format.raw_len() as u64)
1613                    .ok_or_else(|| {
1614                        GitError::InvalidFormat("pack file shorter than checksum".into())
1615                    })
1616            })
1617            .transpose()?;
1618        let delta_base = match &pack {
1619            Some(pack) => pack_entry_delta_base(self.format, pack, pack_lookup.offset)?,
1620            None => None,
1621        };
1622        let delta_base_offset = match &delta_base {
1623            Some(PackDeltaBase::Offset(offset)) => Some(*offset),
1624            Some(PackDeltaBase::Ref(_)) | None => None,
1625        };
1626        let offset_info = if let Some(index) = &index {
1627            scan_pack_index_offsets(index, pack_lookup.offset, trailer_offset, delta_base_offset)?
1628        } else if let Some(pack) = &pack {
1629            let end_offset =
1630                scan_pack_offsets_without_index(self.format, pack, pack_lookup.offset)?
1631                    .ok_or_else(|| {
1632                        GitError::InvalidFormat(format!(
1633                            "pack offset {} not found",
1634                            pack_lookup.offset
1635                        ))
1636                    })?;
1637            let delta_base_oid = match delta_base_offset {
1638                Some(offset) => self
1639                    .midx_oid_for_pack_offset(&pack_lookup, offset)?
1640                    .ok_or_else(|| {
1641                        GitError::InvalidFormat(format!("ofs-delta base offset {offset} not found"))
1642                    })?,
1643                None => ObjectId::null(self.format),
1644            };
1645            PackIndexOffsetInfo {
1646                end_offset,
1647                delta_base_oid: delta_base_offset.map(|_| delta_base_oid),
1648            }
1649        } else {
1650            return Err(GitError::InvalidFormat(
1651                "packed object metadata source unavailable".into(),
1652            ));
1653        };
1654        let disk_size = offset_info
1655            .end_offset
1656            .checked_sub(pack_lookup.offset)
1657            .ok_or_else(|| GitError::InvalidFormat("pack index offsets are not sorted".into()))?;
1658        let deltabase = match delta_base {
1659            Some(PackDeltaBase::Offset(_)) => offset_info.delta_base_oid.ok_or_else(|| {
1660                // scan_pack_index_offsets returns Err when delta_base_offset is
1661                // Some but no matching entry is found, so this is unreachable for
1662                // valid packs; propagate as an error rather than panic to keep a
1663                // malformed pack from taking down the process if that invariant
1664                // ever drifts.
1665                GitError::InvalidFormat("ofs-delta base oid missing from pack index".into())
1666            })?,
1667            Some(PackDeltaBase::Ref(oid)) => oid,
1668            None => ObjectId::null(self.format),
1669        };
1670        Ok(Some(ObjectStorageInfo {
1671            disk_size,
1672            deltabase,
1673        }))
1674    }
1675
1676    pub(crate) fn midx_oid_for_pack_offset(
1677        &self,
1678        pack_lookup: &PackLookup,
1679        offset: u64,
1680    ) -> Result<Option<ObjectId>> {
1681        let pack_dir = self.objects_dir.join("pack");
1682        let midx_path = pack_dir.join("multi-pack-index");
1683        let Some(midx) = self.cached_multi_pack_index(&midx_path)? else {
1684            return Ok(None);
1685        };
1686        let Some(pack_name) = pack_lookup
1687            .pack_path()
1688            .file_name()
1689            .and_then(|name| name.to_str())
1690        else {
1691            return Ok(None);
1692        };
1693        let idx_name = pack_name
1694            .strip_suffix(".pack")
1695            .map(|stem| format!("{stem}.idx"))
1696            .unwrap_or_else(|| pack_name.to_string());
1697        let Some(pack_int_id) = midx
1698            .pack_names
1699            .iter()
1700            .position(|candidate| candidate == &idx_name)
1701        else {
1702            return Ok(None);
1703        };
1704        Ok(midx
1705            .objects
1706            .iter()
1707            .find(|entry| entry.pack_int_id == pack_int_id as u32 && entry.offset == offset)
1708            .map(|entry| entry.oid))
1709    }
1710
1711    pub(crate) fn find_midx_pack_containing(
1712        &self,
1713        pack_dir: &Path,
1714        oid: &ObjectId,
1715    ) -> Result<Option<PackLookup>> {
1716        let midx_path = pack_dir.join("multi-pack-index");
1717        if let Some(midx) = self.cached_multi_pack_index_oid_lookup(&midx_path)?
1718            && let Some(pack_lookup) = self.midx_oid_lookup_pack_paths(pack_dir, &midx, oid)?
1719        {
1720            return Ok(Some(pack_lookup));
1721        }
1722        self.find_incremental_midx_pack_containing(pack_dir, oid)
1723    }
1724
1725    pub(crate) fn midx_oid_lookup_pack_paths(
1726        &self,
1727        pack_dir: &Path,
1728        midx: &MultiPackIndexOidLookup,
1729        oid: &ObjectId,
1730    ) -> Result<Option<PackLookup>> {
1731        let Some(entry) = midx.find(oid)? else {
1732            return Ok(None);
1733        };
1734        let Some(pack_name) = midx.pack_name(entry.pack_int_id) else {
1735            return Err(GitError::InvalidFormat(
1736                "multi-pack-index object points past pack table".into(),
1737            ));
1738        };
1739        let pack_file_name = pack_name
1740            .strip_suffix(".idx")
1741            .map(|stem| format!("{stem}.pack"))
1742            .unwrap_or_else(|| pack_name.to_string());
1743        let pack = pack_dir.join(pack_file_name);
1744        Ok(Some(PackLookup::from_path(pack, entry.offset)))
1745    }
1746
1747    pub(crate) fn find_incremental_midx_pack_containing(
1748        &self,
1749        pack_dir: &Path,
1750        oid: &ObjectId,
1751    ) -> Result<Option<PackLookup>> {
1752        let chain = read_incremental_midx_chain(pack_dir)?;
1753        if chain.is_empty() {
1754            return Ok(None);
1755        }
1756        let midx_dir = pack_dir.join("multi-pack-index.d");
1757        for checksum in chain.iter().rev() {
1758            let path = midx_dir.join(format!("multi-pack-index-{checksum}.midx"));
1759            if !path.exists() {
1760                continue;
1761            }
1762            let bytes = load_multi_pack_index_lookup_data(&path)?;
1763            let midx = match MultiPackIndexOidLookup::parse(bytes, self.format) {
1764                Ok(midx) => midx,
1765                Err(_) => continue,
1766            };
1767            if let Some(pack_lookup) = self.midx_oid_lookup_pack_paths(pack_dir, &midx, oid)? {
1768                return Ok(Some(pack_lookup));
1769            }
1770        }
1771        Ok(None)
1772    }
1773
1774    pub(crate) fn cached_loaded_multi_pack_index_oid_lookup(
1775        &self,
1776    ) -> Option<Arc<MultiPackIndexOidLookup>> {
1777        self.multi_pack_oid_lookups
1778            .read()
1779            .get(&self.midx_path)
1780            .map(Arc::clone)
1781    }
1782
1783    /// The pack registry for `pack_dir` *only if already scanned and cached* —
1784    /// never touches the filesystem. Used by the lookup hot path to skip
1785    /// per-object pack-dir metadata checks once a handle is warm. A cold cache
1786    /// returns `None`, so the caller falls back to the scanning path. A complete
1787    /// miss still forces one rescan, preserving the new-pack discovery semantics.
1788    pub(crate) fn cached_loaded_pack_registry(
1789        &self,
1790        _pack_dir: &Path,
1791    ) -> Result<Option<Arc<PackRegistrySnapshot>>> {
1792        let cache = match self.pack_registry.lock() {
1793            Ok(cache) => cache,
1794            Err(_) => return Ok(None),
1795        };
1796        Ok(cache.as_ref().map(Arc::clone))
1797    }
1798}
1799impl ObjectReader for FileObjectDatabase {
1800    fn reusable_pack_candidates(
1801        &self,
1802        object_ids: &HashSet<ObjectId>,
1803    ) -> Result<Vec<ReusablePackCandidate>> {
1804        // Replacement reads return the replacement object's body under the
1805        // original oid. Raw entries from the underlying pack would therefore
1806        // describe different content and are never reusable.
1807        if object_ids.is_empty() || !self.replacements.is_empty() {
1808            return Ok(Vec::new());
1809        }
1810
1811        let mut object_dirs = Vec::with_capacity(self.alternates.len() + 1);
1812        object_dirs.push(self.objects_dir.clone());
1813        object_dirs.extend(self.alternates.iter().cloned());
1814        let mut candidates = Vec::new();
1815        for objects_dir in object_dirs {
1816            let pack_dir = objects_dir.join("pack");
1817            let Ok(entries) = fs::read_dir(pack_dir) else {
1818                continue;
1819            };
1820            let mut index_paths = Vec::new();
1821            for entry in entries.flatten() {
1822                let path = entry.path();
1823                if path.extension().and_then(|ext| ext.to_str()) == Some("idx") {
1824                    index_paths.push(path);
1825                }
1826            }
1827            index_paths.sort();
1828            for index_path in index_paths {
1829                let Ok(index_bytes) = fs::read(&index_path) else {
1830                    continue;
1831                };
1832                let Ok(index) = PackIndex::parse(&index_bytes, self.format) else {
1833                    continue;
1834                };
1835                if !index
1836                    .entries
1837                    .iter()
1838                    .any(|entry| object_ids.contains(&entry.oid))
1839                {
1840                    continue;
1841                }
1842                candidates.push(ReusablePackCandidate {
1843                    pack: Arc::from([]),
1844                    pack_path: Some(index_path.with_extension("pack")),
1845                    entries: index.entries,
1846                    pack_checksum: index.pack_checksum,
1847                });
1848            }
1849        }
1850        Ok(candidates)
1851    }
1852
1853    fn reusable_delta_base(&self, oid: &ObjectId) -> Result<Option<ObjectId>> {
1854        let Some(pack_lookup) = self.find_pack_containing(oid)? else {
1855            return Ok(None);
1856        };
1857        let pack = pack_lookup.pack_bytes(self)?;
1858        match pack_entry_delta_base(self.format, &pack, pack_lookup.offset)? {
1859            Some(PackDeltaBase::Ref(base_oid)) => Ok(Some(base_oid)),
1860            Some(PackDeltaBase::Offset(base_offset)) => {
1861                let base_oid = self
1862                    .pack_oid_at_offset(&pack_lookup, base_offset)?
1863                    .ok_or_else(|| {
1864                        GitError::InvalidFormat(format!(
1865                            "ofs-delta base offset {base_offset} not found"
1866                        ))
1867                    })?;
1868                Ok(Some(base_oid))
1869            }
1870            None => Ok(None),
1871        }
1872    }
1873
1874    fn is_promised_object(&self, oid: &ObjectId) -> bool {
1875        // Gate on a configured promisor remote, exactly like git's
1876        // `is_promisor_object` (which short-circuits when
1877        // `repo_has_promisor_remote()` is false). Without this, a `.promisor`
1878        // sidecar left in an ordinary repository would wrongly excuse missing
1879        // objects from fsck connectivity checks.
1880        self.promisor_remote_present && self.promisor_objects().contains(oid)
1881    }
1882
1883    fn has_shallow_grafts(&self) -> bool {
1884        !self
1885            .shallow_grafts
1886            .get_or_init(|| {
1887                let shallow_file = self
1888                    .objects_dir
1889                    .parent()
1890                    .map(|git_dir| git_dir.join("shallow"));
1891                match shallow_file {
1892                    Some(path) => read_shallow_grafts(&path, self.format),
1893                    None => HashSet::new(),
1894                }
1895            })
1896            .is_empty()
1897    }
1898
1899    fn is_shallow_graft(&self, oid: &ObjectId) -> bool {
1900        self.shallow_grafts
1901            .get_or_init(|| {
1902                let shallow_file = self
1903                    .objects_dir
1904                    .parent()
1905                    .map(|git_dir| git_dir.join("shallow"));
1906                match shallow_file {
1907                    Some(path) => read_shallow_grafts(&path, self.format),
1908                    None => HashSet::new(),
1909                }
1910            })
1911            .contains(oid)
1912    }
1913
1914    fn read_object(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
1915        let read_oid = self.replacements.resolve(oid)?;
1916        self.read_object_raw(&read_oid)
1917    }
1918}
1919
1920impl sley_formats::TreeObjectSource for FileObjectDatabase {
1921    fn read_tree_object_header(&self, oid: &ObjectId) -> Result<Option<(ObjectType, u64)>> {
1922        self.read_object_header(oid)
1923    }
1924
1925    fn read_tree_object(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
1926        ObjectReader::read_object(self, oid)
1927    }
1928}
1929
1930impl FileObjectDatabase {
1931    fn read_object_raw(&self, oid: &ObjectId) -> Result<Arc<EncodedObject>> {
1932        if let Some(object) = implied_empty_tree_object(self.format, oid) {
1933            return Ok(object);
1934        }
1935        // A corrupt loose copy must not shadow a good packed copy: git's
1936        // `oid_object_info_extended` consults every source, so a repacked object
1937        // whose loose file was later corrupted still reads fine from the pack. If
1938        // a packed copy exists, prefer it WITHOUT touching the corrupt loose file
1939        // (which would otherwise emit a spurious `inflate:` diagnostic on each
1940        // probe). Only when no pack copy exists do we read (and, if corrupt,
1941        // surface the error from) the loose file.
1942        if let Some(pack_lookup) = self.find_pack_containing(oid)? {
1943            match self.read_packed_object_at_lookup(oid, &pack_lookup) {
1944                Ok(object) => return Ok(object),
1945                Err(GitError::NotFound(_)) => {}
1946                // A corrupt packed copy must not be fatal when another good copy
1947                // exists: git's `oid_object_info_extended` keeps consulting the
1948                // remaining sources (loose, other packs, alternates) when a pack
1949                // read fails. Fall through to the loose/other-pack probes and
1950                // only surface the packed error if every source comes up empty.
1951                Err(packed_err) => {
1952                    if let Ok(object) = self.loose.read_object(oid) {
1953                        return Ok(object);
1954                    }
1955                    // Try any *other* pack that also holds the object (a
1956                    // redundant copy survives one pack's corruption).
1957                    if let Some(object) =
1958                        self.read_packed_object_from_other_packs(oid, &pack_lookup)?
1959                    {
1960                        return Ok(object);
1961                    }
1962                    for alternate in &self.alternates {
1963                        if let Ok(object) =
1964                            Self::without_alternates(alternate, self.format).read_object_raw(oid)
1965                        {
1966                            return Ok(object);
1967                        }
1968                    }
1969                    return Err(packed_err);
1970                }
1971            }
1972        }
1973        let loose_err = match self.loose.read_object(oid) {
1974            Ok(object) => return Ok(object),
1975            Err(GitError::NotFound(_)) => None,
1976            Err(err) => Some(err),
1977        };
1978        if let Some(object) = self.read_packed_object(oid)? {
1979            return Ok(object);
1980        }
1981        for alternate in &self.alternates {
1982            match Self::without_alternates(alternate, self.format).read_object_raw(oid) {
1983                Ok(object) => return Ok(object),
1984                Err(GitError::NotFound(_)) => {}
1985                Err(err) => return Err(err),
1986            }
1987        }
1988        // Hard miss against every store. If an earlier enumeration built a loose
1989        // cache, an object written loose afterward by a sibling handle could have
1990        // been skipped above. Mirror git's `oid_object_info_extended`
1991        // reprepare-on-miss: drop stale cache state and retry an exact loose path
1992        // probe once before declaring the object missing.
1993        self.loose.invalidate_cache();
1994        match self.loose.read_object(oid) {
1995            Ok(object) => return Ok(object),
1996            Err(GitError::NotFound(_)) => {}
1997            Err(err) => return Err(err),
1998        }
1999        // No good copy in any store. If the local loose copy was corrupt (not
2000        // merely absent), surface that error — it is more specific than a plain
2001        // "not found".
2002        if let Some(err) = loose_err {
2003            return Err(err);
2004        }
2005        Err(GitError::object_not_found_in(
2006            *oid,
2007            MissingObjectContext::Read,
2008        ))
2009    }
2010}
2011impl FileObjectDatabase {
2012    fn promisor_objects(&self) -> &HashSet<ObjectId> {
2013        self.promisor_objects.get_or_init(|| {
2014            // Seed from local and alternate `.promisor` packs. Quarantine
2015            // validation uses the destination object directory as an alternate,
2016            // so historical promisor packs must be visible here — otherwise
2017            // `is_promised_object` would not recognize destination-promised
2018            // missing blobs and connectivity walks would report false broken
2019            // links (git's packed_git list includes alternate promisor packs).
2020            let mut promised =
2021                promisor_pack_object_ids(&self.objects_dir, self.format).unwrap_or_default();
2022            for alternate in &self.alternates {
2023                if let Ok(oids) = promisor_pack_object_ids(alternate, self.format) {
2024                    promised.extend(oids);
2025                }
2026            }
2027            let mut pending = promised.iter().copied().collect::<Vec<_>>();
2028            while let Some(oid) = pending.pop() {
2029                let Ok(object) = self.read_object(&oid) else {
2030                    continue;
2031                };
2032                for link in promisor_object_links(self.format, &object) {
2033                    if promised.insert(link) {
2034                        pending.push(link);
2035                    }
2036                }
2037            }
2038            promised
2039        })
2040    }
2041
2042    fn freshen_existing_object(&self, oid: &ObjectId) -> Result<bool> {
2043        if self.freshen_loose_object(oid)? {
2044            return Ok(true);
2045        }
2046        if self.freshen_packed_object(oid)? {
2047            return Ok(true);
2048        }
2049        for alternate in &self.alternates {
2050            if Self::without_alternates(alternate, self.format).freshen_existing_object(oid)? {
2051                return Ok(true);
2052            }
2053        }
2054        // A previous negative loose-cache probe may predate a sibling write.
2055        self.loose.invalidate_cache();
2056        self.freshen_loose_object(oid)
2057    }
2058
2059    fn freshen_loose_object(&self, oid: &ObjectId) -> Result<bool> {
2060        let path = self.loose.object_path(oid)?;
2061        freshen_file_mtime(&path)
2062    }
2063
2064    fn freshen_packed_object(&self, oid: &ObjectId) -> Result<bool> {
2065        let Some(pack_lookup) = self.find_pack_containing(oid)? else {
2066            return Ok(false);
2067        };
2068        if !pack_lookup.pack_path().with_extension("mtimes").exists() {
2069            return freshen_file_mtime(pack_lookup.pack_path());
2070        }
2071
2072        // Cruft packs carry per-object mtimes in their `.mtimes` sidecar. Git
2073        // deliberately never freshens the pack itself: doing so would make
2074        // every unreachable object in it look recent. If another, non-cruft
2075        // pack also contains the object, freshen that copy; otherwise return a
2076        // miss so the caller writes a new loose copy with its own mtime.
2077        let pack_dir = self.objects_dir.join("pack");
2078        let Ok(entries) = fs::read_dir(pack_dir) else {
2079            return Ok(false);
2080        };
2081        for entry in entries {
2082            let idx_path = entry?.path();
2083            if idx_path.extension().and_then(|ext| ext.to_str()) != Some("idx")
2084                || idx_path.with_extension("mtimes").exists()
2085            {
2086                continue;
2087            }
2088            let pack_path = idx_path.with_extension("pack");
2089            if !pack_path.exists() {
2090                continue;
2091            }
2092            let Ok(index_bytes) = fs::read(&idx_path) else {
2093                continue;
2094            };
2095            let Ok(index) = PackIndex::parse(&index_bytes, self.format) else {
2096                continue;
2097            };
2098            if index.find(oid).is_some() {
2099                return freshen_file_mtime(&pack_path);
2100            }
2101        }
2102        Ok(false)
2103    }
2104}
2105pub(crate) fn freshen_file_mtime(path: &Path) -> Result<bool> {
2106    let file = match fs::OpenOptions::new().read(true).open(path) {
2107        Ok(file) => file,
2108        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2109        Err(err) => return Err(GitError::Io(err.to_string())),
2110    };
2111    file.set_modified(std::time::SystemTime::now())
2112        .map_err(|err| GitError::Io(err.to_string()))?;
2113    Ok(true)
2114}
2115
2116pub(crate) fn promisor_pack_object_ids(
2117    objects_dir: &Path,
2118    format: ObjectFormat,
2119) -> Result<HashSet<ObjectId>> {
2120    let pack_dir = objects_dir.join("pack");
2121    let mut oids = HashSet::new();
2122    if !pack_dir.exists() {
2123        return Ok(oids);
2124    }
2125    for entry in fs::read_dir(pack_dir)? {
2126        let path = entry?.path();
2127        if path.extension().and_then(|ext| ext.to_str()) != Some("idx") {
2128            continue;
2129        }
2130        if !path.with_extension("pack").exists() || !path.with_extension("promisor").exists() {
2131            continue;
2132        }
2133        let index = PackIndex::parse(&fs::read(path)?, format)?;
2134        oids.extend(index.entries.into_iter().map(|entry| entry.oid));
2135    }
2136    Ok(oids)
2137}
2138
2139pub(crate) fn promisor_object_links(format: ObjectFormat, object: &EncodedObject) -> Vec<ObjectId> {
2140    match object.object_type {
2141        ObjectType::Commit => Commit::parse_ref(format, &object.body)
2142            .map(|commit| {
2143                let mut links = Vec::with_capacity(commit.parents.len() + 1);
2144                links.push(commit.tree);
2145                links.extend(commit.parents);
2146                links
2147            })
2148            .unwrap_or_default(),
2149        ObjectType::Tree => TreeEntries::new(format, &object.body)
2150            .filter_map(|entry| entry.ok().map(|entry| entry.oid))
2151            .collect(),
2152        ObjectType::Tag => Tag::parse_ref(format, &object.body)
2153            .map(|tag| vec![tag.object])
2154            .unwrap_or_default(),
2155        ObjectType::Blob => Vec::new(),
2156    }
2157}
2158impl ObjectWriter for FileObjectDatabase {
2159    fn write_object(&self, object: EncodedObject) -> Result<ObjectId> {
2160        // Mirror git's freshen semantics (`write_object_file`:
2161        // `freshen_packed_object || freshen_loose_object`): an object already
2162        // present in a normal pack is not written again, but its backing pack is
2163        // touched so concurrent GC treats it as recent. Cruft packs are excluded
2164        // from freshening, so rewriting a cruft-only object creates a loose copy
2165        // whose per-object mtime can be honored by the next cruft repack.
2166        let oid = object.object_id(self.format)?;
2167        if self.freshen_existing_object(&oid)? {
2168            return Ok(oid);
2169        }
2170        self.loose.write_object(object)
2171    }
2172}