Skip to main content

grit_lib/
pack.rs

1//! Pack and pack-index helpers for object counting and verification.
2//!
3//! This module implements a focused subset of pack functionality required by
4//! `count-objects` and `verify-pack`.
5
6pub use crate::pack_index::{
7    compute_fanout_from_entries, compute_fanout_from_oid_slices, pack_index_entry_matches_sha1_oid,
8    parse_pack_index_bytes, PackIndex, PackIndexEntry, PackIndexEntryRef,
9};
10
11use crate::error::{Error, Result};
12use crate::git_path::CanonicalizeExt;
13use crate::hash::{hash_object, verify_trailer};
14use crate::objects::{HashAlgo, Object, ObjectId, ObjectInfo, ObjectKind};
15use crate::odb::BoundedRead;
16pub use crate::pack_map::PackData;
17use crate::pack_map::ResidentBudget;
18use crate::unpack_objects::apply_delta_into;
19use crate::zlib_inflate::ZlibInflateScratch;
20use std::cell::RefCell;
21use std::collections::{BTreeMap, HashMap, HashSet};
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26use std::time::SystemTime;
27
28/// Basic information about local packs.
29#[derive(Debug, Clone, Default)]
30pub struct LocalPackInfo {
31    /// Number of valid local packs.
32    pub pack_count: usize,
33    /// Total objects across all valid local packs.
34    pub object_count: usize,
35    /// Combined on-disk bytes of `.pack` + `.idx`.
36    pub size_bytes: u64,
37    /// Set of all object IDs present in local packs.
38    pub object_ids: HashSet<ObjectId>,
39}
40
41/// Read all valid `.idx` files in `objects/pack`.
42///
43/// # Errors
44///
45/// Returns [`Error::Io`] for directory-level failures. Individual invalid pack
46/// pairs are skipped.
47pub fn read_local_pack_indexes(objects_dir: &Path) -> Result<Vec<PackIndex>> {
48    let pack_dir = objects_dir.join("pack");
49    let rd = match fs::read_dir(&pack_dir) {
50        Ok(rd) => rd,
51        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
52        Err(err) => return Err(Error::Io(err)),
53    };
54
55    let mut out = Vec::new();
56    for entry in rd {
57        let entry = entry.map_err(Error::Io)?;
58        let path = entry.path();
59        if path.extension().and_then(|s| s.to_str()) != Some("idx") {
60            continue;
61        }
62        if let Ok(idx) = read_pack_index(&path) {
63            // Ignore orphan `.idx` files (no `.pack`). They must not make `fsck` think objects
64            // exist (`t7700-repack`); repack also skips them so a stray index does not block work.
65            if !idx.pack_path.is_file() {
66                continue;
67            }
68            out.push(idx);
69        }
70    }
71    Ok(out)
72}
73
74/// Process-wide cache of parsed pack indexes and pack file bytes.
75///
76/// Hot lookups serve cached directory listings, parsed `.idx` files, and `.pack` bytes
77/// without `stat` per access (Git prepares the pack list once and only re-scans on a
78/// lookup miss when the pack directory's mtime changed). Pack bytes are re-read only on
79/// cache miss or when parsing a pack object fails and the on-disk signature changed.
80///
81/// SHA-1 verification of the index trailer is **not** performed on cached reads: Git only
82/// verifies pack indexes during `fsck`/`verify-pack`, not on every object lookup. Use
83/// [`read_pack_index`] when verification is required.
84pub(crate) mod pack_cache {
85    use super::{read_pack_index_no_verify, Error, ObjectKind, PackIndex, Result};
86    use crate::pack_map::{fingerprint_from_file, PackData, PackFingerprint};
87    use std::collections::{HashMap, HashSet, VecDeque};
88    use std::fs;
89    use std::hash::{Hash, Hasher};
90    use std::io;
91    use std::path::{Path, PathBuf};
92    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
93    use std::sync::{Arc, Mutex, Weak};
94    use std::time::SystemTime;
95
96    const DELTA_LRU_SHARD_COUNT: usize = 32;
97    // A bounded operation must release descriptors and compressed windows together.
98    // Active readers can keep their own Arc alive across a catalog eviction.
99    const BOUNDED_PACK_READERS: usize = 32;
100
101    type ThreadLocalDirListing = Option<(PathBuf, SystemTime, Vec<Arc<PackIndex>>)>;
102
103    enum CachedPackBytes {
104        Retained(Arc<PackData>),
105        Positioned(Weak<PackData>),
106    }
107
108    impl CachedPackBytes {
109        fn get(&self) -> Option<Arc<PackData>> {
110            match self {
111                Self::Retained(bytes) => Some(Arc::clone(bytes)),
112                Self::Positioned(bytes) => bytes.upgrade(),
113            }
114        }
115    }
116
117    // hygiene: per-thread pack read hot cache (indexes, bytes, dir listing); cleared on invalidate
118    thread_local! {
119        static PACK_DIR_INDEXES: std::cell::RefCell<ThreadLocalDirListing> =
120            const { std::cell::RefCell::new(None) };
121        static PACK_HIT_INDEX: std::cell::RefCell<Option<(PathBuf, Arc<PackIndex>)>> =
122            const { std::cell::RefCell::new(None) };
123        static PACK_BYTES: std::cell::RefCell<HashMap<PathBuf, CachedPackBytes>> =
124            std::cell::RefCell::new(HashMap::new());
125        static PACK_INDEXES: std::cell::RefCell<HashMap<PathBuf, Arc<PackIndex>>> =
126            std::cell::RefCell::new(HashMap::new());
127        /// Looked up for every object read, so keyed by the path's bytes with a fast
128        /// hasher rather than by [`Path`] components; a miss falls back to the catalog.
129        static PACK_IDS: std::cell::RefCell<hashbrown::HashMap<std::ffi::OsString, u32>> =
130            std::cell::RefCell::new(hashbrown::HashMap::new());
131        static PACK_COUNT_VALIDATED: std::cell::RefCell<HashSet<PathBuf>> =
132            std::cell::RefCell::new(HashSet::new());
133        static PACK_DIR_LISTING_TRUSTED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
134        /// [`PackStore::cache_epoch`] last seen on this thread (cross-thread invalidation).
135        static THREAD_WARM_EPOCH: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
136        /// [`PackStore`] address last warmed on this thread (distinct stores can share epoch 0).
137        static THREAD_WARM_STORE: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
138        static PACK_MONOTONIC_CURSOR: std::cell::RefCell<Option<(PathBuf, usize)>> =
139            const { std::cell::RefCell::new(None) };
140        static SINGLE_READER_MEMO: std::cell::RefCell<Option<SingleReaderMemo>> =
141            const { std::cell::RefCell::new(None) };
142    }
143
144    /// The pack a [`super::SingleLocalPackReader`] last opened on this thread.
145    ///
146    /// Lets repeated opens (one per object read) skip re-deriving the same pack through the
147    /// path-keyed caches and the shared catalog.
148    struct SingleReaderMemo {
149        objects_dir: PathBuf,
150        pack_dir: PathBuf,
151        idx: Arc<PackIndex>,
152        pack_bytes: Arc<PackData>,
153        pack_id: u32,
154    }
155
156    fn forget_single_reader_memo() {
157        SINGLE_READER_MEMO.with(|cell| *cell.borrow_mut() = None);
158    }
159
160    /// Record the single local pack just opened for `objects_dir`.
161    pub(crate) fn remember_single_reader(
162        objects_dir: &Path,
163        idx: &Arc<PackIndex>,
164        pack_bytes: &Arc<PackData>,
165        pack_id: u32,
166    ) {
167        SINGLE_READER_MEMO.with(|cell| {
168            *cell.borrow_mut() = Some(SingleReaderMemo {
169                objects_dir: objects_dir.to_path_buf(),
170                pack_dir: objects_dir.join("pack"),
171                idx: Arc::clone(idx),
172                pack_bytes: Arc::clone(pack_bytes),
173                pack_id,
174            });
175        });
176    }
177
178    /// The single local pack previously opened for `objects_dir`, if this thread's trusted
179    /// pack listing still consists of exactly that pack.
180    ///
181    /// Returns `None` whenever the listing is untrusted (so the caller rechecks the pack
182    /// directory) or has changed since the pack was remembered.
183    pub(crate) fn single_reader_for(
184        objects_dir: &Path,
185    ) -> Option<(Arc<PackIndex>, Arc<PackData>, u32)> {
186        if !PACK_DIR_LISTING_TRUSTED.get() {
187            return None;
188        }
189        SINGLE_READER_MEMO.with(|memo| {
190            let memo = memo.borrow();
191            let memo = memo.as_ref()?;
192            if memo.objects_dir.as_os_str() != objects_dir.as_os_str() {
193                return None;
194            }
195            let listing_unchanged = PACK_DIR_INDEXES.with(|cell| {
196                cell.borrow().as_ref().is_some_and(|(dir, _, indexes)| {
197                    dir.as_os_str() == memo.pack_dir.as_os_str()
198                        && indexes.len() == 1
199                        && Arc::ptr_eq(&indexes[0], &memo.idx)
200                })
201            });
202            if !listing_unchanged {
203                return None;
204            }
205            let hit_current = PACK_HIT_INDEX.with(|cell| {
206                cell.borrow().as_ref().is_some_and(|(dir, idx)| {
207                    dir.as_os_str() == objects_dir.as_os_str() && Arc::ptr_eq(idx, &memo.idx)
208                })
209            });
210            if !hit_current {
211                return None;
212            }
213            Some((
214                Arc::clone(&memo.idx),
215                Arc::clone(&memo.pack_bytes),
216                memo.pack_id,
217            ))
218        })
219    }
220
221    fn store_thread_local_key(store: &crate::pack_store::PackStore) -> usize {
222        store as *const crate::pack_store::PackStore as usize
223    }
224
225    pub(crate) fn clear_thread_local_dir_indexes() {
226        PACK_DIR_INDEXES.with(|cell| *cell.borrow_mut() = None);
227        PACK_HIT_INDEX.with(|cell| *cell.borrow_mut() = None);
228        PACK_BYTES.with(|cell| cell.borrow_mut().clear());
229        PACK_INDEXES.with(|cell| cell.borrow_mut().clear());
230        PACK_IDS.with(|cell| cell.borrow_mut().clear());
231        PACK_COUNT_VALIDATED.with(|cell| cell.borrow_mut().clear());
232        PACK_DIR_LISTING_TRUSTED.set(false);
233        THREAD_WARM_EPOCH.set(0);
234        THREAD_WARM_STORE.set(0);
235        super::clear_pack_read_info_cache();
236        PACK_MONOTONIC_CURSOR.with(|cell| *cell.borrow_mut() = None);
237        forget_single_reader_memo();
238    }
239
240    pub(crate) fn try_read_object_via_single_pack_reader(
241        objects_dir: &Path,
242        oid: &crate::objects::ObjectId,
243    ) -> Result<Option<crate::objects::Object>> {
244        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
245            return Ok(None);
246        }
247        let Some(mut reader) = super::SingleLocalPackReader::open_allow_loose(objects_dir)? else {
248            return Ok(None);
249        };
250        match reader.read_object(oid) {
251            Ok(obj) => Ok(Some(obj)),
252            Err(Error::ObjectNotFound(_)) => Ok(None),
253            Err(err) => Err(err),
254        }
255    }
256
257    /// Like [`try_read_object_via_single_pack_reader`], skipping objects the pack stores whole
258    /// with a payload larger than `limit` (see
259    /// [`super::SingleLocalPackReader::read_object_unless_whole_larger`]).
260    pub(crate) fn try_read_object_via_single_pack_reader_unless_whole_larger(
261        objects_dir: &Path,
262        oid: &crate::objects::ObjectId,
263        limit: u64,
264    ) -> Result<Option<super::BoundedRead>> {
265        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
266            return Ok(None);
267        }
268        let Some(mut reader) = super::SingleLocalPackReader::open_allow_loose(objects_dir)? else {
269            return Ok(None);
270        };
271        match reader.read_object_unless_whole_larger(oid, limit) {
272            Ok(read) => Ok(Some(read)),
273            Err(Error::ObjectNotFound(_)) => Ok(None),
274            Err(err) => Err(err),
275        }
276    }
277
278    pub(crate) fn try_read_object_via_single_pack_reader_into(
279        objects_dir: &Path,
280        oid: &crate::objects::ObjectId,
281        populate_delta_lru: bool,
282        buf: &mut Vec<u8>,
283    ) -> Result<Option<crate::objects::ObjectKind>> {
284        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
285            return Ok(None);
286        }
287        let Some(mut reader) = super::SingleLocalPackReader::open_allow_loose(objects_dir)? else {
288            return Ok(None);
289        };
290        match reader.read_object_into_with_delta_policy(oid, populate_delta_lru, buf) {
291            Ok(kind) => Ok(Some(kind)),
292            Err(Error::ObjectNotFound(_)) => Ok(None),
293            Err(err) => Err(err),
294        }
295    }
296
297    /// Drop thread-local pack caches after invalidation on this or another thread.
298    pub(crate) fn clear_thread_local_after_invalidation(new_epoch: u64) {
299        clear_thread_local_dir_indexes();
300        THREAD_WARM_EPOCH.set(new_epoch);
301    }
302
303    pub(crate) fn sync_thread_local_with_store(store: &crate::pack_store::PackStore) {
304        if !crate::pack_store::PackStore::has_context() {
305            return;
306        }
307        let epoch = store.cache_epoch();
308        let store_key = store_thread_local_key(store);
309        if THREAD_WARM_EPOCH.get() == epoch && THREAD_WARM_STORE.get() == store_key {
310            return;
311        }
312        clear_thread_local_dir_indexes();
313        THREAD_WARM_EPOCH.set(epoch);
314        THREAD_WARM_STORE.set(store_key);
315    }
316
317    fn drop_thread_local_dir_listing(pack_dir: &Path) {
318        PACK_DIR_INDEXES.with(|cell| {
319            if cell
320                .borrow()
321                .as_ref()
322                .is_some_and(|(dir, _, _)| dir == pack_dir)
323            {
324                *cell.borrow_mut() = None;
325                PACK_DIR_LISTING_TRUSTED.set(false);
326            }
327        });
328    }
329
330    fn drop_thread_local_pack_bytes(pack_path: &Path) {
331        PACK_BYTES.with(|cell| {
332            cell.borrow_mut().remove(pack_path);
333        });
334        forget_single_reader_memo();
335    }
336
337    /// Drop thread-local indexes, hints, and directory listing tied to a replaced pack path.
338    fn invalidate_thread_local_for_pack_path_change(pack_path: &Path) {
339        let idx_path = pack_path.with_extension("idx");
340        drop_thread_local_pack_bytes(pack_path);
341        PACK_INDEXES.with(|cell| {
342            cell.borrow_mut().remove(&idx_path);
343        });
344        PACK_IDS.with(|cell| {
345            cell.borrow_mut().remove(pack_path.as_os_str());
346        });
347        PACK_COUNT_VALIDATED.with(|cell| {
348            cell.borrow_mut().remove(pack_path);
349        });
350        PACK_HIT_INDEX.with(|cell| {
351            let drop_hint = cell
352                .borrow()
353                .as_ref()
354                .is_some_and(|(_, idx)| idx.pack_path == pack_path || idx.idx_path == idx_path);
355            if drop_hint {
356                *cell.borrow_mut() = None;
357            }
358        });
359        if let Some(pack_dir) = pack_path.parent() {
360            drop_thread_local_dir_listing(pack_dir);
361        }
362        super::clear_pack_read_info_cache();
363    }
364
365    /// Seed thread-local pack caches from `store` (one catalog read lock; hot reads avoid the catalog).
366    pub(crate) fn warm_thread_local_from_store(store: &crate::pack_store::PackStore) {
367        if !crate::pack_store::PackStore::has_context() {
368            return;
369        }
370        let epoch = store.cache_epoch();
371        let store_key = store_thread_local_key(store);
372        if THREAD_WARM_EPOCH.get() == epoch
373            && THREAD_WARM_STORE.get() == store_key
374            && PACK_DIR_LISTING_TRUSTED.get()
375        {
376            return;
377        }
378        PACK_BYTES.with(|cell| cell.borrow_mut().clear());
379        PACK_INDEXES.with(|cell| cell.borrow_mut().clear());
380        PACK_IDS.with(|cell| cell.borrow_mut().clear());
381        PACK_COUNT_VALIDATED.with(|cell| cell.borrow_mut().clear());
382        PACK_HIT_INDEX.with(|cell| *cell.borrow_mut() = None);
383        forget_single_reader_memo();
384        super::clear_pack_read_info_cache();
385        let objects_dir = store.objects_dir();
386        let pack_dir = objects_dir.join("pack");
387        store.with_catalog_read(|g| {
388            if let Some(cached) = g.by_dir.get(&pack_dir) {
389                PACK_DIR_INDEXES.with(|cell| {
390                    *cell.borrow_mut() =
391                        Some((pack_dir.clone(), cached.dir_mtime, cached.indexes.clone()));
392                });
393            }
394            for (path, entry) in &g.by_pack {
395                remember_pack_bytes(path, &entry.bytes);
396            }
397            for (path, entry) in &g.by_idx {
398                remember_index(path, &entry.idx);
399            }
400            PACK_IDS.with(|cell| {
401                cell.borrow_mut().extend(
402                    g.pack_ids
403                        .iter()
404                        .map(|(k, v)| (k.as_os_str().to_owned(), *v)),
405                );
406            });
407            PACK_COUNT_VALIDATED.with(|cell| {
408                cell.borrow_mut()
409                    .extend(g.validated_pack_counts.iter().cloned());
410            });
411            PACK_DIR_LISTING_TRUSTED.set(true);
412            THREAD_WARM_EPOCH.set(store.cache_epoch());
413            THREAD_WARM_STORE.set(store_thread_local_key(store));
414        });
415    }
416
417    fn cached_pack_bytes(pack_path: &Path) -> Option<Arc<PackData>> {
418        if !crate::pack_store::PackStore::has_context() {
419            return None;
420        }
421        PACK_BYTES.with(|cell| cell.borrow().get(pack_path).and_then(CachedPackBytes::get))
422    }
423
424    fn remember_pack_bytes(pack_path: &Path, bytes: &Arc<PackData>) {
425        // The operation-local catalog owns positioned readers. A second strong cache
426        // would keep their descriptors alive after eviction from that catalog.
427        if !crate::pack_store::PackStore::has_context() {
428            return;
429        }
430        let cached = if bytes.uses_positioned_reads() {
431            CachedPackBytes::Positioned(Arc::downgrade(bytes))
432        } else {
433            CachedPackBytes::Retained(Arc::clone(bytes))
434        };
435        PACK_BYTES.with(|cell| {
436            cell.borrow_mut().insert(pack_path.to_path_buf(), cached);
437        });
438        forget_single_reader_memo();
439    }
440
441    pub(crate) fn remember_pack_hit(objects_dir: &Path, idx: &Arc<PackIndex>) {
442        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
443            return;
444        }
445        promote_pack_index(objects_dir, &idx.idx_path);
446        PACK_HIT_INDEX.with(|cell| {
447            *cell.borrow_mut() = Some((objects_dir.to_path_buf(), Arc::clone(idx)));
448        });
449    }
450
451    pub(crate) fn find_offset_in_pack_read_context(
452        objects_dir: &Path,
453        idx: &PackIndex,
454        oid: &crate::objects::ObjectId,
455    ) -> Result<Option<u64>> {
456        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
457            return idx.find_offset(oid);
458        }
459        PACK_MONOTONIC_CURSOR.with(|cell| {
460            let mut slot = cell.borrow_mut();
461            if slot.as_ref().is_none_or(|(dir, _)| dir != objects_dir) {
462                *slot = Some((objects_dir.to_path_buf(), 0));
463            }
464            let Some((_, cursor)) = slot.as_mut() else {
465                return idx.find_offset(oid);
466            };
467            idx.find_offset_monotonic(oid, cursor)
468        })
469    }
470
471    pub(crate) fn try_read_from_hinted_pack(
472        objects_dir: &Path,
473        oid: &crate::objects::ObjectId,
474    ) -> Result<Option<crate::objects::Object>> {
475        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
476            return Ok(None);
477        }
478        let idx = PACK_HIT_INDEX.with(|cell| {
479            cell.borrow()
480                .as_ref()
481                .and_then(|(dir, idx)| (dir == objects_dir).then(|| Arc::clone(idx)))
482        });
483        let Some(idx) = idx else {
484            return Ok(None);
485        };
486        let Some(offset) = find_offset_in_pack_read_context(objects_dir, &idx, oid)? else {
487            return Ok(None);
488        };
489        match super::read_object_from_pack_at_offset(&idx, offset) {
490            Ok(obj) => Ok(Some(obj)),
491            Err(Error::ObjectNotFound(_)) => Ok(None),
492            Err(err) => Err(err),
493        }
494    }
495
496    pub(crate) fn try_read_from_hinted_pack_into(
497        objects_dir: &Path,
498        oid: &crate::objects::ObjectId,
499        populate_delta_lru: bool,
500        buf: &mut Vec<u8>,
501    ) -> Result<Option<crate::objects::ObjectKind>> {
502        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
503            return Ok(None);
504        }
505        let idx = PACK_HIT_INDEX.with(|cell| {
506            cell.borrow()
507                .as_ref()
508                .and_then(|(dir, idx)| (dir == objects_dir).then(|| Arc::clone(idx)))
509        });
510        let Some(idx) = idx else {
511            return Ok(None);
512        };
513        let Some(offset) = find_offset_in_pack_read_context(objects_dir, &idx, oid)? else {
514            return Ok(None);
515        };
516        match super::read_object_at_depth_into(&idx, offset, 0, populate_delta_lru, buf) {
517            Ok(kind) => Ok(Some(kind)),
518            Err(Error::ObjectNotFound(_)) => Ok(None),
519            Err(err) => Err(err),
520        }
521    }
522
523    pub(crate) fn try_read_info_from_hinted_pack(
524        objects_dir: &Path,
525        oid: &crate::objects::ObjectId,
526    ) -> Result<Option<crate::objects::ObjectInfo>> {
527        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
528            return Ok(None);
529        }
530        let idx = PACK_HIT_INDEX.with(|cell| {
531            cell.borrow()
532                .as_ref()
533                .and_then(|(dir, idx)| (dir == objects_dir).then(|| Arc::clone(idx)))
534        });
535        let Some(idx) = idx else {
536            return Ok(None);
537        };
538        let Some(offset) = idx.find_offset(oid)? else {
539            return Ok(None);
540        };
541        match super::read_object_info_from_pack_at_offset(&idx, offset) {
542            Ok(info) => Ok(Some(info)),
543            Err(Error::ObjectNotFound(_)) => Ok(None),
544            Err(err) => Err(err),
545        }
546    }
547    fn cached_dir_indexes(objects_dir: &Path, pack_dir: &Path) -> Option<Vec<Arc<PackIndex>>> {
548        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
549            return None;
550        }
551        let trust_listing = PACK_DIR_LISTING_TRUSTED.get();
552        let dir_mt = if trust_listing {
553            None
554        } else {
555            Some(dir_mtime(pack_dir))
556        };
557        PACK_DIR_INDEXES.with(|cell| {
558            cell.borrow()
559                .as_ref()
560                .and_then(|(dir, cached_mt, indexes)| {
561                    if dir != pack_dir {
562                        return None;
563                    }
564                    if trust_listing || dir_mt == Some(*cached_mt) {
565                        Some(indexes.clone())
566                    } else {
567                        None
568                    }
569                })
570        })
571    }
572
573    fn remember_dir_indexes(objects_dir: &Path, pack_dir: &Path, indexes: &[Arc<PackIndex>]) {
574        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
575            return;
576        }
577        let dir_mt = dir_mtime(pack_dir);
578        PACK_DIR_INDEXES.with(|cell| {
579            *cell.borrow_mut() = Some((pack_dir.to_path_buf(), dir_mt, indexes.to_vec()));
580        });
581    }
582
583    struct CachedDir {
584        dir_mtime: SystemTime,
585        indexes: Vec<Arc<PackIndex>>,
586        midx_mtime: SystemTime,
587        midx_pack_names: HashSet<String>,
588    }
589
590    struct CachedIdx {
591        idx: Arc<PackIndex>,
592    }
593
594    struct CachedPack {
595        mtime: SystemTime,
596        size: u64,
597        bytes: Arc<PackData>,
598    }
599
600    /// Default retained delta-base bytes (`core.deltaBaseCacheLimit`).
601    pub(super) const DELTA_BASE_CACHE_DEFAULT: usize = 96 * 1024 * 1024;
602
603    #[derive(Clone, Copy, PartialEq, Eq, Hash)]
604    pub(crate) struct DeltaCacheKey {
605        pack_id: u32,
606        offset: u64,
607    }
608
609    /// Byte-bounded LRU of resolved **intermediate** delta bases (never the object being returned).
610    struct DeltaBaseLru {
611        byte_limit: usize,
612        bytes_used: usize,
613        entries: HashMap<DeltaCacheKey, (ObjectKind, Arc<[u8]>)>,
614        head: Option<DeltaCacheKey>,
615        tail: Option<DeltaCacheKey>,
616        links: HashMap<DeltaCacheKey, (Option<DeltaCacheKey>, Option<DeltaCacheKey>)>,
617    }
618
619    impl Default for DeltaBaseLru {
620        fn default() -> Self {
621            Self {
622                byte_limit: DELTA_BASE_CACHE_DEFAULT,
623                bytes_used: 0,
624                entries: HashMap::new(),
625                head: None,
626                tail: None,
627                links: HashMap::new(),
628            }
629        }
630    }
631
632    impl DeltaBaseLru {
633        fn set_byte_limit(&mut self, limit: usize) {
634            if limit == 0 {
635                self.clear();
636                self.byte_limit = 0;
637                return;
638            }
639            self.byte_limit = limit;
640            while self.bytes_used > self.byte_limit {
641                let Some(key) = self.head else {
642                    break;
643                };
644                self.evict_key(key);
645            }
646        }
647
648        fn unlink(&mut self, key: DeltaCacheKey) {
649            let Some((prev, next)) = self.links.remove(&key) else {
650                return;
651            };
652            match prev {
653                Some(p) => {
654                    if let Some(link) = self.links.get_mut(&p) {
655                        link.1 = next;
656                    }
657                }
658                None => self.head = next,
659            }
660            match next {
661                Some(n) => {
662                    if let Some(link) = self.links.get_mut(&n) {
663                        link.0 = prev;
664                    }
665                }
666                None => self.tail = prev,
667            }
668        }
669
670        fn append_tail(&mut self, key: DeltaCacheKey) {
671            let prev = self.tail;
672            self.links.insert(key, (prev, None));
673            if let Some(p) = prev {
674                if let Some(link) = self.links.get_mut(&p) {
675                    link.1 = Some(key);
676                }
677            } else {
678                self.head = Some(key);
679            }
680            self.tail = Some(key);
681        }
682
683        fn touch(&mut self, key: DeltaCacheKey) {
684            self.unlink(key);
685            self.append_tail(key);
686        }
687
688        fn evict_key(&mut self, key: DeltaCacheKey) {
689            self.unlink(key);
690            if let Some((_, data)) = self.entries.remove(&key) {
691                self.bytes_used = self.bytes_used.saturating_sub(data.len());
692            }
693        }
694
695        fn get(&mut self, key: DeltaCacheKey) -> Option<(ObjectKind, Arc<[u8]>)> {
696            let (kind, data) = self.entries.get(&key).map(|(k, d)| (*k, Arc::clone(d)))?;
697            self.touch(key);
698            Some((kind, data))
699        }
700
701        fn put(&mut self, key: DeltaCacheKey, kind: ObjectKind, data: Arc<[u8]>) {
702            if self.byte_limit == 0 {
703                return;
704            }
705            let sz = data.len();
706            if sz > self.byte_limit {
707                return;
708            }
709            use std::collections::hash_map::Entry;
710            match self.entries.entry(key) {
711                Entry::Occupied(mut slot) => {
712                    let old_len = slot.get().1.len();
713                    slot.insert((kind, Arc::clone(&data)));
714                    self.bytes_used = self.bytes_used.saturating_sub(old_len).saturating_add(sz);
715                    self.touch(key);
716                }
717                Entry::Vacant(slot) => {
718                    slot.insert((kind, Arc::clone(&data)));
719                    self.bytes_used = self.bytes_used.saturating_add(sz);
720                    self.append_tail(key);
721                }
722            }
723            while self.bytes_used > self.byte_limit {
724                let Some(evict) = self.head else {
725                    break;
726                };
727                self.evict_key(evict);
728            }
729        }
730
731        fn drop_pack(&mut self, pack_id: u32) {
732            let keys: Vec<DeltaCacheKey> = self
733                .entries
734                .keys()
735                .copied()
736                .filter(|k| k.pack_id == pack_id)
737                .collect();
738            for key in keys {
739                self.evict_key(key);
740            }
741        }
742
743        fn clear(&mut self) {
744            self.entries.clear();
745            self.links.clear();
746            self.head = None;
747            self.tail = None;
748            self.bytes_used = 0;
749        }
750    }
751
752    fn shard_index(key: DeltaCacheKey) -> usize {
753        let mut hasher = std::collections::hash_map::DefaultHasher::new();
754        key.hash(&mut hasher);
755        (hasher.finish() as usize) % DELTA_LRU_SHARD_COUNT
756    }
757
758    /// Delta-base LRU sharded by `(pack_id, offset)` so concurrent reads rarely contend.
759    pub(crate) struct ShardedDeltaBaseLru {
760        cache_enabled: AtomicBool,
761        lru_populated: AtomicBool,
762        total_byte_limit: AtomicUsize,
763        shards: Box<[Mutex<DeltaBaseLru>; DELTA_LRU_SHARD_COUNT]>,
764    }
765
766    impl ShardedDeltaBaseLru {
767        pub(crate) fn new() -> Self {
768            let per_shard = DELTA_BASE_CACHE_DEFAULT / DELTA_LRU_SHARD_COUNT;
769            Self {
770                cache_enabled: AtomicBool::new(true),
771                lru_populated: AtomicBool::new(false),
772                total_byte_limit: AtomicUsize::new(DELTA_BASE_CACHE_DEFAULT),
773                shards: Box::new(std::array::from_fn(|_| {
774                    Mutex::new(DeltaBaseLru {
775                        byte_limit: per_shard,
776                        ..DeltaBaseLru::default()
777                    })
778                })),
779            }
780        }
781
782        pub(crate) fn set_byte_limit(&self, limit: usize) {
783            self.total_byte_limit.store(limit, Ordering::Release);
784            if limit == 0 {
785                self.cache_enabled.store(false, Ordering::Release);
786                self.clear();
787                return;
788            }
789            self.cache_enabled.store(true, Ordering::Release);
790            let base = limit / DELTA_LRU_SHARD_COUNT;
791            let rem = limit % DELTA_LRU_SHARD_COUNT;
792            let mut any = false;
793            for (i, shard) in self.shards.iter().enumerate() {
794                let cap = base + usize::from(i < rem);
795                let mut g = shard.lock().unwrap_or_else(|e| e.into_inner());
796                g.set_byte_limit(cap);
797                any |= !g.entries.is_empty();
798            }
799            self.lru_populated.store(any, Ordering::Release);
800        }
801
802        pub(crate) fn get(&self, key: DeltaCacheKey) -> Option<(ObjectKind, Arc<[u8]>)> {
803            if !self.cache_enabled.load(Ordering::Acquire)
804                || !self.lru_populated.load(Ordering::Acquire)
805            {
806                return None;
807            }
808            let shard = &self.shards[shard_index(key)];
809            let mut g = shard.lock().unwrap_or_else(|e| e.into_inner());
810            g.get(key)
811        }
812
813        pub(crate) fn put_many(&self, kind: ObjectKind, items: &[(u32, u64, Arc<[u8]>)]) {
814            if items.is_empty() || !self.cache_enabled.load(Ordering::Acquire) {
815                return;
816            }
817            for &(pack_id, offset, ref data) in items {
818                let key = DeltaCacheKey { pack_id, offset };
819                let shard = &self.shards[shard_index(key)];
820                let mut g = shard.lock().unwrap_or_else(|e| e.into_inner());
821                if g.byte_limit == 0 {
822                    continue;
823                }
824                g.put(key, kind, Arc::clone(data));
825            }
826            self.lru_populated.store(true, Ordering::Release);
827        }
828
829        pub(crate) fn clear(&self) {
830            for shard in self.shards.iter() {
831                shard.lock().unwrap_or_else(|e| e.into_inner()).clear();
832            }
833            self.lru_populated.store(false, Ordering::Release);
834        }
835
836        pub(crate) fn drop_pack(&self, pack_id: u32) {
837            let mut any = false;
838            for shard in self.shards.iter() {
839                let mut g = shard.lock().unwrap_or_else(|e| e.into_inner());
840                g.drop_pack(pack_id);
841                any |= !g.entries.is_empty();
842            }
843            self.lru_populated.store(any, Ordering::Release);
844        }
845
846        pub(crate) fn caching_enabled(&self) -> bool {
847            self.cache_enabled.load(Ordering::Acquire)
848        }
849
850        #[cfg(test)]
851        pub(crate) fn bytes_used(&self) -> usize {
852            self.shards
853                .iter()
854                .map(|s| s.lock().unwrap_or_else(|e| e.into_inner()).bytes_used)
855                .sum()
856        }
857
858        #[cfg(test)]
859        pub(crate) fn contains(&self, pack_id: u32, offset: u64) -> bool {
860            let key = DeltaCacheKey { pack_id, offset };
861            self.shards[shard_index(key)]
862                .lock()
863                .unwrap_or_else(|e| e.into_inner())
864                .entries
865                .contains_key(&key)
866        }
867    }
868
869    #[derive(Default)]
870    pub(crate) struct CatalogState {
871        by_dir: HashMap<PathBuf, CachedDir>,
872        by_idx: HashMap<PathBuf, CachedIdx>,
873        by_pack: HashMap<PathBuf, CachedPack>,
874        bounded_pack_order: VecDeque<PathBuf>,
875        /// Small id assigned when a pack file is first registered in this process.
876        pack_ids: HashMap<PathBuf, u32>,
877        next_pack_id: u32,
878        /// Packs whose header object count was matched to the index (hot read path).
879        validated_pack_counts: HashSet<PathBuf>,
880        /// Per `objects/pack` directory rescan count (parallel-safe; tests only).
881        #[cfg(test)]
882        test_dir_rescan_counts: HashMap<PathBuf, u64>,
883    }
884
885    impl CatalogState {
886        fn retain_bounded_pack(&mut self, pack_path: &Path) {
887            if let Some(pos) = self.bounded_pack_order.iter().position(|p| p == pack_path) {
888                self.bounded_pack_order.remove(pos);
889            }
890            while self.bounded_pack_order.len() >= BOUNDED_PACK_READERS {
891                if let Some(path) = self.bounded_pack_order.pop_front() {
892                    self.by_pack.remove(&path);
893                }
894            }
895            self.bounded_pack_order.push_back(pack_path.to_path_buf());
896        }
897    }
898
899    pub(crate) fn with_current<R>(f: impl FnOnce(&mut CatalogState) -> R) -> R {
900        crate::pack_store::PackStore::with_current_or_implicit(f)
901    }
902
903    pub(crate) fn clear_catalog_on(m: &std::sync::RwLock<CatalogState>) {
904        let mut g = m.write().unwrap_or_else(|e| e.into_inner());
905        clear_inner(&mut g);
906    }
907
908    pub(crate) fn configure_delta_base_on(
909        delta: &ShardedDeltaBaseLru,
910        cfg: Option<&crate::config::ConfigSet>,
911    ) {
912        let bytes = cfg
913            .and_then(|config| config.get("core.deltaBaseCacheLimit"))
914            .as_ref()
915            .and_then(|value| crate::config::parse_i64(value).ok())
916            .and_then(|value| usize::try_from(value).ok())
917            .unwrap_or(DELTA_BASE_CACHE_DEFAULT);
918        delta.set_byte_limit(bytes);
919    }
920
921    #[cfg(test)]
922    thread_local! {
923        static TEST_MARKER_STAT_COUNT: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
924    }
925
926    fn marker_sidecar_is_file(path: &Path) -> bool {
927        #[cfg(test)]
928        TEST_MARKER_STAT_COUNT.with(|count| count.set(count.get().saturating_add(1)));
929        path.is_file()
930    }
931
932    /// Whether sibling `.promisor` / `.mtimes` markers exist next to `pack_path`.
933    pub fn sidecar_flags(pack_path: &Path) -> (bool, bool) {
934        (
935            marker_sidecar_is_file(&pack_path.with_extension("promisor")),
936            marker_sidecar_is_file(&pack_path.with_extension("mtimes")),
937        )
938    }
939
940    fn refresh_pack_sidecar_flags(idx: Arc<PackIndex>) -> Arc<PackIndex> {
941        let (is_promisor, is_cruft) = sidecar_flags(&idx.pack_path);
942        if idx.is_promisor == is_promisor && idx.is_cruft == is_cruft {
943            return idx;
944        }
945        let updated = Arc::new(idx.with_sidecar_flags(is_promisor, is_cruft));
946        with_current(|g| {
947            g.by_idx.insert(
948                idx.idx_path.clone(),
949                CachedIdx {
950                    idx: Arc::clone(&updated),
951                },
952            );
953        });
954        updated
955    }
956
957    fn dir_mtime(path: &Path) -> SystemTime {
958        fs::metadata(path)
959            .and_then(|m| m.modified())
960            .unwrap_or(SystemTime::UNIX_EPOCH)
961    }
962
963    fn file_signature(path: &Path) -> Option<(SystemTime, u64)> {
964        #[cfg(test)]
965        crate::hot_path_test_metrics::record_pack_signature_stat_for_active_scope();
966        let m = fs::metadata(path).ok()?;
967        let mtime = m.modified().unwrap_or(SystemTime::UNIX_EPOCH);
968        Some((mtime, m.len()))
969    }
970
971    pub fn pack_object_count_validated(pack_path: &Path) -> bool {
972        if crate::pack_store::PackStore::has_context()
973            && PACK_COUNT_VALIDATED.with(|cell| cell.borrow().contains(pack_path))
974        {
975            return true;
976        }
977        crate::pack_store::PackStore::active_for_reads()
978            .with_catalog_read(|g| g.validated_pack_counts.contains(pack_path))
979    }
980
981    pub fn mark_pack_object_count_validated(pack_path: &Path) {
982        if crate::pack_store::PackStore::has_context() {
983            PACK_COUNT_VALIDATED.with(|cell| {
984                cell.borrow_mut().insert(pack_path.to_path_buf());
985            });
986        }
987        let store = crate::pack_store::PackStore::active_for_reads();
988        if store.with_catalog_read(|g| g.validated_pack_counts.contains(pack_path)) {
989            return;
990        }
991        let _ = store.try_with_catalog(|g| {
992            g.validated_pack_counts.insert(pack_path.to_path_buf());
993        });
994    }
995
996    fn cached_index(idx_path: &Path) -> Option<Arc<PackIndex>> {
997        if !crate::pack_store::PackStore::has_context() {
998            return None;
999        }
1000        PACK_INDEXES.with(|cell| cell.borrow().get(idx_path).map(Arc::clone))
1001    }
1002
1003    fn remember_index(idx_path: &Path, idx: &Arc<PackIndex>) {
1004        if !crate::pack_store::PackStore::has_context() {
1005            return;
1006        }
1007        PACK_INDEXES.with(|cell| {
1008            cell.borrow_mut()
1009                .insert(idx_path.to_path_buf(), Arc::clone(idx));
1010        });
1011    }
1012
1013    fn index_cache_hit_stale(idx: &PackIndex) -> bool {
1014        !idx.pack_path.is_file()
1015    }
1016
1017    /// Get a parsed pack index from cache, parsing from disk only on cache miss.
1018    pub fn get_index(idx_path: &Path) -> Result<Arc<PackIndex>> {
1019        if let Some(hit) = cached_index(idx_path) {
1020            if index_cache_hit_stale(&hit) {
1021                PACK_INDEXES.with(|cell| {
1022                    cell.borrow_mut().remove(idx_path);
1023                });
1024            } else {
1025                return Ok(hit);
1026            }
1027        }
1028        let store = crate::pack_store::PackStore::active_for_reads();
1029        if let Some(hit) =
1030            store.with_catalog_read(|g| g.by_idx.get(idx_path).map(|c| Arc::clone(&c.idx)))
1031        {
1032            if index_cache_hit_stale(&hit) {
1033                store.with_catalog(|g| {
1034                    g.by_idx.remove(idx_path);
1035                });
1036            } else {
1037                remember_index(idx_path, &hit);
1038                return Ok(hit);
1039            }
1040        }
1041        crate::trace::trace_span!("grit.pack.index.open");
1042        let parsed = Arc::new(read_pack_index_no_verify(idx_path)?);
1043        store.with_catalog(|g| {
1044            g.by_idx.insert(
1045                idx_path.to_path_buf(),
1046                CachedIdx {
1047                    idx: Arc::clone(&parsed),
1048                },
1049            );
1050        });
1051        remember_index(idx_path, &parsed);
1052        Ok(parsed)
1053    }
1054
1055    fn rescan_dir_indexes(objects_dir: &Path) -> Result<Vec<Arc<PackIndex>>> {
1056        let pack_dir = objects_dir.join("pack");
1057        drop_thread_local_dir_listing(&pack_dir);
1058        let dir_mt = dir_mtime(&pack_dir);
1059
1060        let rd = match fs::read_dir(&pack_dir) {
1061            Ok(rd) => rd,
1062            Err(err) if err.kind() == io::ErrorKind::NotFound => {
1063                with_current(|g| {
1064                    g.by_dir.insert(
1065                        pack_dir.clone(),
1066                        CachedDir {
1067                            dir_mtime: dir_mt,
1068                            indexes: Vec::new(),
1069                            midx_mtime: SystemTime::UNIX_EPOCH,
1070                            midx_pack_names: HashSet::new(),
1071                        },
1072                    );
1073                });
1074                return Ok(Vec::new());
1075            }
1076            Err(err) => return Err(Error::Io(err)),
1077        };
1078
1079        let mut out = Vec::new();
1080        for entry in rd {
1081            let entry = entry.map_err(Error::Io)?;
1082            let path = entry.path();
1083            if path.extension().and_then(|s| s.to_str()) != Some("idx") {
1084                continue;
1085            }
1086            let Ok(idx) = get_index(&path) else {
1087                continue;
1088            };
1089            if !idx.pack_path.is_file() {
1090                continue;
1091            }
1092            out.push(refresh_pack_sidecar_flags(idx));
1093        }
1094
1095        let midx_pack_names = load_midx_pack_names(&pack_dir);
1096        with_current(|g| {
1097            #[cfg(test)]
1098            {
1099                *g.test_dir_rescan_counts
1100                    .entry(pack_dir.clone())
1101                    .or_insert(0) += 1;
1102            }
1103            g.by_dir.insert(
1104                pack_dir,
1105                CachedDir {
1106                    dir_mtime: dir_mt,
1107                    indexes: out.clone(),
1108                    midx_mtime: midx_pack_names.0,
1109                    midx_pack_names: midx_pack_names.1,
1110                },
1111            );
1112        });
1113        Ok(out)
1114    }
1115
1116    fn load_midx_pack_names(pack_dir: &Path) -> (SystemTime, HashSet<String>) {
1117        let Some(midx_path) = crate::midx::resolve_tip_midx_path(pack_dir) else {
1118            return (SystemTime::UNIX_EPOCH, HashSet::new());
1119        };
1120        let metadata = fs::metadata(&midx_path).ok();
1121        let mtime = metadata
1122            .as_ref()
1123            .and_then(|m| m.modified().ok())
1124            .unwrap_or(SystemTime::UNIX_EPOCH);
1125        let names = metadata
1126            .as_ref()
1127            .and_then(|metadata| {
1128                crate::midx::read_midx_pack_idx_names_from_path(&midx_path, metadata).ok()
1129            })
1130            .unwrap_or_default()
1131            .into_iter()
1132            .collect();
1133        (mtime, names)
1134    }
1135
1136    fn refresh_midx_pack_names_locked(pack_dir: &Path, cached: &mut CachedDir) {
1137        let (mtime, names) = load_midx_pack_names(pack_dir);
1138        if cached.midx_mtime != mtime {
1139            cached.midx_mtime = mtime;
1140            cached.midx_pack_names = names;
1141        }
1142    }
1143
1144    /// Move `idx_path` to the front of the MRU pack search order for `objects_dir`.
1145    pub fn promote_pack_index(objects_dir: &Path, idx_path: &Path) {
1146        let pack_dir = objects_dir.join("pack");
1147        let store = crate::pack_store::PackStore::active_for_reads();
1148        let _ = store.try_with_catalog(|g| {
1149            let Some(cached) = g.by_dir.get_mut(&pack_dir) else {
1150                return;
1151            };
1152            let Some(pos) = cached
1153                .indexes
1154                .iter()
1155                .position(|idx| idx.idx_path == idx_path)
1156            else {
1157                return;
1158            };
1159            if pos == 0 {
1160                return;
1161            }
1162            let hit = cached.indexes.remove(pos);
1163            cached.indexes.insert(0, hit);
1164        });
1165    }
1166
1167    /// Pack index basenames listed in the active multi-pack-index, if any.
1168    pub fn midx_covered_pack_names(objects_dir: &Path) -> HashSet<String> {
1169        let pack_dir = objects_dir.join("pack");
1170        let cached = with_current(|g| {
1171            let cached = g.by_dir.get_mut(&pack_dir)?;
1172            refresh_midx_pack_names_locked(&pack_dir, cached);
1173            Some(cached.midx_pack_names.clone())
1174        });
1175        if let Some(names) = cached {
1176            return names;
1177        }
1178        load_midx_pack_names(&pack_dir).1
1179    }
1180
1181    /// Get all `.idx` files for `objects_dir`, using the cached directory listing when present.
1182    pub fn get_dir_indexes(objects_dir: &Path) -> Result<Vec<Arc<PackIndex>>> {
1183        let pack_dir = objects_dir.join("pack");
1184        let store = crate::pack_store::PackStore::active_for_reads();
1185        if crate::pack_store::PackStore::has_context() {
1186            sync_thread_local_with_store(&store);
1187        }
1188        if let Some(hit) = cached_dir_indexes(objects_dir, &pack_dir) {
1189            return Ok(hit);
1190        }
1191        let dir_mt = dir_mtime(&pack_dir);
1192        if let Some(hit) = store.with_catalog_read(|g| {
1193            g.by_dir
1194                .get(&pack_dir)
1195                .and_then(|c| (c.dir_mtime == dir_mt).then(|| c.indexes.clone()))
1196        }) {
1197            remember_dir_indexes(objects_dir, &pack_dir, &hit);
1198            return Ok(hit);
1199        }
1200        let fresh = rescan_dir_indexes(objects_dir)?;
1201        remember_dir_indexes(objects_dir, &pack_dir, &fresh);
1202        Ok(fresh)
1203    }
1204
1205    fn idx_paths_on_disk(pack_dir: &Path) -> Result<HashSet<PathBuf>> {
1206        let mut out = HashSet::new();
1207        let rd = match fs::read_dir(pack_dir) {
1208            Ok(rd) => rd,
1209            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(out),
1210            Err(err) => return Err(Error::Io(err)),
1211        };
1212        for entry in rd {
1213            let entry = entry.map_err(Error::Io)?;
1214            let path = entry.path();
1215            if path.extension().and_then(|s| s.to_str()) != Some("idx") {
1216                continue;
1217            }
1218            if path.with_extension("pack").is_file() {
1219                out.insert(path);
1220            }
1221        }
1222        Ok(out)
1223    }
1224
1225    fn cached_dir_listing_stale(
1226        pack_dir: &Path,
1227        dir_mt: SystemTime,
1228        cached: &CachedDir,
1229    ) -> Result<bool> {
1230        if cached.dir_mtime != dir_mt {
1231            return Ok(true);
1232        }
1233        // Pack directory mtimes often have one-second resolution; external `git gc` / repack can
1234        // replace `.idx` files without bumping the directory mtime (same wall-clock second).
1235        let on_disk = idx_paths_on_disk(pack_dir)?;
1236        let cached_paths: HashSet<PathBuf> = cached
1237            .indexes
1238            .iter()
1239            .map(|idx| idx.idx_path.clone())
1240            .collect();
1241        Ok(on_disk != cached_paths)
1242    }
1243
1244    /// Re-scan `objects_dir`/`pack/` when its mtime changed since the cached listing (Git
1245    /// `reprepare_packed_git`), or when the set of pack index files no longer matches the
1246    /// cached listing. Returns true if the listing was refreshed.
1247    pub fn reprepare_dir_on_miss(objects_dir: &Path) -> Result<bool> {
1248        let pack_dir = objects_dir.join("pack");
1249        let dir_mt = dir_mtime(&pack_dir);
1250        let needs_rescan =
1251            crate::pack_store::PackStore::active_for_reads().with_catalog_read(|g| {
1252                match g.by_dir.get(&pack_dir) {
1253                    None => true,
1254                    Some(c) => cached_dir_listing_stale(&pack_dir, dir_mt, c).unwrap_or(true),
1255                }
1256            });
1257        if !needs_rescan {
1258            return Ok(false);
1259        }
1260        rescan_dir_indexes(objects_dir)?;
1261        Ok(true)
1262    }
1263
1264    fn load_pack_bytes_from_disk(
1265        pack_path: &Path,
1266        mtime: SystemTime,
1267        size: u64,
1268    ) -> Result<Arc<PackData>> {
1269        invalidate_thread_local_for_pack_path_change(pack_path);
1270        let store = crate::pack_store::PackStore::active_for_reads();
1271        let bytes = if store.bounded_reads() {
1272            PackData::open_random_access(pack_path)?
1273        } else {
1274            PackData::open_for_sparse_reads(pack_path, &store.sparse_read_files)?
1275        };
1276        store.with_catalog(|g| {
1277            drop_delta_entries_locked(g, pack_path, store.delta_bases());
1278            g.by_idx.remove(&pack_path.with_extension("idx"));
1279            g.validated_pack_counts.remove(pack_path);
1280            if store.bounded_reads() {
1281                g.retain_bounded_pack(pack_path);
1282            }
1283            g.by_pack.insert(
1284                pack_path.to_path_buf(),
1285                CachedPack {
1286                    mtime,
1287                    size,
1288                    bytes: Arc::clone(&bytes),
1289                },
1290            );
1291        });
1292        remember_pack_bytes(pack_path, &bytes);
1293        Ok(bytes)
1294    }
1295
1296    /// Get the raw bytes of a pack file from cache, reading from disk only on cache miss.
1297    ///
1298    /// Cache hits skip `stat` for all pack basenames; in-process repack/gc clears the cache
1299    /// when packs change.
1300    pub fn get_pack_bytes(pack_path: &Path) -> Result<Arc<PackData>> {
1301        if let Some(hit) = cached_pack_bytes(pack_path) {
1302            return Ok(hit);
1303        }
1304        let store = crate::pack_store::PackStore::active_for_reads();
1305        let hit = if store.bounded_reads() {
1306            store.with_catalog(|g| {
1307                let bytes = Arc::clone(&g.by_pack.get(pack_path)?.bytes);
1308                g.retain_bounded_pack(pack_path);
1309                Some(bytes)
1310            })
1311        } else {
1312            store.with_catalog_read(|g| g.by_pack.get(pack_path).map(|c| Arc::clone(&c.bytes)))
1313        };
1314        if let Some(hit) = hit {
1315            remember_pack_bytes(pack_path, &hit);
1316            return Ok(hit);
1317        }
1318        let (mtime, size) = file_signature(pack_path).ok_or_else(|| {
1319            Error::Io(io::Error::new(
1320                io::ErrorKind::NotFound,
1321                format!("pack not found: {}", pack_path.display()),
1322            ))
1323        })?;
1324        load_pack_bytes_from_disk(pack_path, mtime, size)
1325    }
1326
1327    fn revalidate_stale_pack_bytes_in_context(pack_path: &Path) -> Result<bool> {
1328        let Some((mtime, size)) = file_signature(pack_path) else {
1329            let store = crate::pack_store::PackStore::active_for_reads();
1330            let removed = store.with_catalog(|g| {
1331                let removed = g.by_pack.remove(pack_path).is_some();
1332                if removed {
1333                    drop_delta_entries_locked(g, pack_path, store.delta_bases());
1334                }
1335                removed
1336            });
1337            invalidate_thread_local_for_pack_path_change(pack_path);
1338            super::drop_pack_read_info_cache_for(pack_path);
1339            return Ok(removed);
1340        };
1341        let store = crate::pack_store::PackStore::active_for_reads();
1342        let stale = store.with_catalog_read(|g| match g.by_pack.get(pack_path) {
1343            Some(c) => c.mtime != mtime || c.size != size,
1344            None => true,
1345        });
1346        if !stale {
1347            return Ok(false);
1348        }
1349        load_pack_bytes_from_disk(pack_path, mtime, size)?;
1350        super::drop_pack_read_info_cache_for(pack_path);
1351        Ok(true)
1352    }
1353
1354    /// Re-read `pack_path` when its on-disk signature differs from the cached entry.
1355    ///
1356    /// Returns true when bytes were reloaded or the cache entry was dropped.
1357    pub fn revalidate_stale_pack_bytes(pack_path: &Path) -> Result<bool> {
1358        if crate::pack_store::PackStore::has_context() {
1359            return revalidate_stale_pack_bytes_in_context(pack_path);
1360        }
1361        let store = crate::pack_store::PackStore::legacy_for_pack_path(pack_path);
1362        crate::pack_store::PackStore::with_context(store, || {
1363            revalidate_stale_pack_bytes_in_context(pack_path)
1364        })
1365    }
1366
1367    /// After a pack parse/decompress failure, reload from disk when cached bytes differ.
1368    ///
1369    /// Signature-only checks miss in-memory stale entries (tests) and same-second replacements
1370    /// with unchanged size; comparing the pack header and trailer fingerprint catches those
1371    /// without reading the whole file.
1372    ///
1373    /// Returns true when the cache was refreshed with disk contents.
1374    pub fn reload_pack_bytes_after_parse_failure(pack_path: &Path) -> Result<bool> {
1375        if revalidate_stale_pack_bytes(pack_path)? {
1376            return Ok(true);
1377        }
1378        let Some((mtime, size)) = file_signature(pack_path) else {
1379            return Ok(false);
1380        };
1381        let hash_bytes = hash_bytes_for_pack_file(pack_path)?;
1382        let disk_fp = fingerprint_from_file(pack_path, hash_bytes)?;
1383        let needs_reload = with_current(|g| {
1384            g.by_pack.get(pack_path).is_some_and(|c| {
1385                pack_fingerprint_mismatch(&c.bytes, hash_bytes, &disk_fp).unwrap_or(true)
1386            })
1387        });
1388        if !needs_reload {
1389            return Ok(false);
1390        }
1391        load_pack_bytes_from_disk(pack_path, mtime, size)?;
1392        super::drop_pack_read_info_cache_for(pack_path);
1393        Ok(true)
1394    }
1395
1396    fn hash_bytes_for_pack_file(pack_path: &Path) -> Result<usize> {
1397        let idx_path = pack_path.with_extension("idx");
1398        if let Some(hash_bytes) =
1399            with_current(|g| g.by_idx.get(&idx_path).map(|c| c.idx.hash_bytes()))
1400        {
1401            return Ok(hash_bytes);
1402        }
1403        Ok(read_pack_index_no_verify(&idx_path)?.hash_bytes())
1404    }
1405
1406    fn pack_fingerprint_mismatch(
1407        cached: &PackData,
1408        hash_bytes: usize,
1409        disk: &PackFingerprint,
1410    ) -> Result<bool> {
1411        Ok(cached.fingerprint(hash_bytes)? != *disk)
1412    }
1413
1414    #[cfg(test)]
1415    pub fn test_reset_dir_rescan_count(pack_dir: &Path) {
1416        let objects_dir = pack_dir.parent().expect("pack dir under objects/");
1417        crate::pack_store::PackStore::legacy_for_objects_dir(objects_dir).with_pack(|g| {
1418            g.by_dir.remove(pack_dir);
1419            g.test_dir_rescan_counts.insert(pack_dir.to_path_buf(), 0);
1420        });
1421    }
1422
1423    #[cfg(test)]
1424    pub fn test_reset_marker_stat_count() {
1425        TEST_MARKER_STAT_COUNT.with(|count| count.set(0));
1426    }
1427
1428    #[cfg(test)]
1429    #[must_use]
1430    pub fn test_marker_stat_count() -> u64 {
1431        TEST_MARKER_STAT_COUNT.with(std::cell::Cell::get)
1432    }
1433
1434    #[cfg(test)]
1435    #[must_use]
1436    pub fn test_dir_rescan_count(pack_dir: &Path) -> u64 {
1437        let objects_dir = pack_dir.parent().expect("pack dir under objects/");
1438        test_dir_rescan_count_on_store(
1439            &crate::pack_store::PackStore::legacy_for_objects_dir(objects_dir),
1440            pack_dir,
1441        )
1442    }
1443
1444    #[cfg(test)]
1445    #[must_use]
1446    pub fn test_dir_rescan_count_on_store(
1447        store: &std::sync::Arc<crate::pack_store::PackStore>,
1448        pack_dir: &Path,
1449    ) -> u64 {
1450        store.with_pack(|g| g.test_dir_rescan_counts.get(pack_dir).copied().unwrap_or(0))
1451    }
1452
1453    #[cfg(test)]
1454    pub fn test_reset_dir_rescan_count_on_store(
1455        store: &std::sync::Arc<crate::pack_store::PackStore>,
1456        pack_dir: &Path,
1457    ) {
1458        store.with_pack(|g| {
1459            g.by_dir.remove(pack_dir);
1460            g.test_dir_rescan_counts.insert(pack_dir.to_path_buf(), 0);
1461        });
1462    }
1463
1464    /// Replace cached pack bytes (tests simulating a stale in-memory mapping).
1465    #[cfg(test)]
1466    pub fn test_inject_stale_pack_bytes(pack_path: &Path, stale: Arc<PackData>) {
1467        crate::pack_store::PackStore::legacy_for_pack_path(pack_path).with_pack(|g| {
1468            let stamp = g
1469                .by_pack
1470                .get(pack_path)
1471                .map(|c| (c.mtime, c.size))
1472                .unwrap_or((SystemTime::UNIX_EPOCH, stale.len() as u64));
1473            g.by_pack.insert(
1474                pack_path.to_path_buf(),
1475                CachedPack {
1476                    mtime: stamp.0,
1477                    size: stamp.1,
1478                    bytes: stale,
1479                },
1480            );
1481        });
1482    }
1483
1484    fn clear_inner(g: &mut CatalogState) {
1485        g.by_dir.clear();
1486        g.by_idx.clear();
1487        g.by_pack.clear();
1488        g.bounded_pack_order.clear();
1489        g.pack_ids.clear();
1490    }
1491
1492    /// Delta-base byte cap for the active read store (default [`DELTA_BASE_CACHE_DEFAULT`]).
1493    pub fn set_delta_base_cache_byte_limit(limit: usize) {
1494        crate::pack_store::PackStore::active_for_reads()
1495            .delta_bases()
1496            .set_byte_limit(limit);
1497    }
1498
1499    #[cfg(test)]
1500    pub fn test_delta_base_cache_bytes_used(pack_path: &Path) -> usize {
1501        crate::pack_store::PackStore::legacy_for_pack_path(pack_path)
1502            .delta_bases()
1503            .bytes_used()
1504    }
1505
1506    #[cfg(test)]
1507    pub fn test_set_delta_base_cache_byte_limit(pack_path: &Path, limit: usize) {
1508        crate::pack_store::PackStore::legacy_for_pack_path(pack_path)
1509            .delta_bases()
1510            .set_byte_limit(limit);
1511    }
1512
1513    #[cfg(test)]
1514    pub fn test_delta_base_cached(pack_path: &Path, pack_id: u32, offset: u64) -> bool {
1515        crate::pack_store::PackStore::legacy_for_pack_path(pack_path)
1516            .delta_bases()
1517            .contains(pack_id, offset)
1518    }
1519
1520    fn pack_id_for_in_context(pack_path: &Path) -> u32 {
1521        if let Some(id) = PACK_IDS.with(|cell| cell.borrow().get(pack_path.as_os_str()).copied()) {
1522            return id;
1523        }
1524        let store = crate::pack_store::PackStore::active_for_reads();
1525        if let Some(id) = store.with_catalog_read(|g| g.pack_ids.get(pack_path).copied()) {
1526            PACK_IDS.with(|cell| {
1527                cell.borrow_mut()
1528                    .insert(pack_path.as_os_str().to_owned(), id);
1529            });
1530            return id;
1531        }
1532        store.with_catalog(|g| {
1533            if let Some(&id) = g.pack_ids.get(pack_path) {
1534                return id;
1535            }
1536            let id = g.next_pack_id;
1537            g.next_pack_id = g.next_pack_id.saturating_add(1);
1538            g.pack_ids.insert(pack_path.to_path_buf(), id);
1539            PACK_IDS.with(|cell| {
1540                cell.borrow_mut()
1541                    .insert(pack_path.as_os_str().to_owned(), id);
1542            });
1543            id
1544        })
1545    }
1546
1547    /// Stable small id for `pack_path`, assigned on first use.
1548    pub fn pack_id_for(pack_path: &Path) -> u32 {
1549        if crate::pack_store::PackStore::has_context() {
1550            return pack_id_for_in_context(pack_path);
1551        }
1552        let store = crate::pack_store::PackStore::legacy_for_pack_path(pack_path);
1553        crate::pack_store::PackStore::with_context(store, || pack_id_for_in_context(pack_path))
1554    }
1555
1556    /// Drop every cached delta base inflated from `pack_path`.
1557    fn drop_delta_entries_locked(
1558        g: &mut CatalogState,
1559        pack_path: &Path,
1560        delta: &ShardedDeltaBaseLru,
1561    ) {
1562        if let Some(&pack_id) = g.pack_ids.get(pack_path) {
1563            delta.drop_pack(pack_id);
1564        }
1565    }
1566
1567    /// Cached resolved object at `(pack_id, offset)`, if still resident.
1568    pub fn get_delta_base(pack_id: u32, offset: u64) -> Option<(ObjectKind, Arc<[u8]>)> {
1569        crate::pack_store::PackStore::active_for_reads()
1570            .delta_bases()
1571            .get(DeltaCacheKey { pack_id, offset })
1572    }
1573
1574    #[must_use]
1575    pub fn delta_base_caching_enabled() -> bool {
1576        crate::pack_store::PackStore::active_for_reads()
1577            .delta_bases()
1578            .caching_enabled()
1579    }
1580
1581    /// Insert many intermediate bases (hot path for delta-chain resolution).
1582    pub fn put_delta_bases(kind: ObjectKind, items: &[(u32, u64, Arc<[u8]>)]) {
1583        if items.is_empty() {
1584            return;
1585        }
1586        crate::pack_store::PackStore::active_for_reads()
1587            .delta_bases()
1588            .put_many(kind, items);
1589    }
1590
1591    /// Drop only cached delta bases (pack indexes and bytes stay cached).
1592    pub fn clear_delta_bases() {
1593        crate::pack_store::PackStore::active_for_reads()
1594            .delta_bases()
1595            .clear();
1596    }
1597
1598    /// Re-stamp the cached signature for `pack_path` after the caller deliberately touched the
1599    /// file's mtime (object freshening). Pack contents are immutable for a given pack name, so
1600    /// a self-inflicted mtime bump must not evict the cached bytes — without this, every
1601    /// `odb.write` of an already-packed object forced a full re-read of the pack on the next
1602    /// lookup. We bump the cached mtime to now without `stat` (the caller just utime'd the file).
1603    /// External modifications still invalidate via [`revalidate_stale_pack_bytes`].
1604    pub fn refresh_pack_signature(pack_path: &Path, touched_at: SystemTime) {
1605        with_current(|g| {
1606            if let Some(c) = g.by_pack.get_mut(pack_path) {
1607                c.mtime = touched_at;
1608            }
1609        });
1610    }
1611
1612    #[cfg(test)]
1613    #[test]
1614    fn bounded_pack_cache_releases_inactive_readers() {
1615        let dir = tempfile::tempdir().expect("dir");
1616        let store = Arc::new(crate::pack_store::PackStore::for_bounded_reads(
1617            dir.path().to_path_buf(),
1618        ));
1619        crate::pack_store::PackStore::with_context(Arc::clone(&store), || {
1620            let mut previous = Vec::new();
1621            let hot_path = dir.path().join("0.pack");
1622            let mut hot = None;
1623            for i in 0..70 {
1624                let path = dir.path().join(format!("{i}.pack"));
1625                let mut bytes = vec![i as u8; 32768];
1626                bytes[..12].copy_from_slice(b"PACK\x00\x00\x00\x02\x00\x00\x00\x00");
1627                fs::write(&path, bytes).expect("write");
1628                let pack = get_pack_bytes(&path).expect("pack");
1629                assert!(pack.uses_positioned_reads());
1630                previous.push(Arc::downgrade(&pack));
1631                hot.get_or_insert_with(|| Arc::clone(&pack));
1632                assert!(Arc::ptr_eq(
1633                    &get_pack_bytes(&hot_path).expect("hot pack"),
1634                    hot.as_ref().expect("hot")
1635                ));
1636            }
1637            warm_thread_local_from_store(&store);
1638            assert!(store.with_catalog_read(|g| g.by_pack.len()) <= 32);
1639            assert!(previous[1..32].iter().all(|pack| pack.upgrade().is_none()));
1640            assert!(previous[69].upgrade().is_some());
1641            let reloaded = get_pack_bytes(&dir.path().join("1.pack")).expect("reopen");
1642            assert_eq!(
1643                reloaded
1644                    .read_range(100, 200, &mut Vec::new())
1645                    .expect("read"),
1646                &[1; 100]
1647            );
1648        });
1649    }
1650}
1651
1652pub use pack_cache::revalidate_stale_pack_bytes;
1653
1654/// Read all pack indexes under `<objects_dir>/pack/` from the process-wide cache.
1655///
1656/// Cached reads skip the `.idx` SHA-1 trailer verification that [`read_pack_index`]
1657/// performs; corruption checks happen during `fsck`/`verify-pack`, not on every object
1658/// lookup (matches Git). The directory listing is prepared once per process and
1659/// re-scanned only on a lookup miss when the pack directory's mtime changed (Git
1660/// `reprepare_packed_git`), or when [`clear_pack_cache`] drops the cache after repack/gc.
1661///
1662/// # Errors
1663///
1664/// Returns [`Error::Io`] when the directory cannot be enumerated.
1665pub fn read_local_pack_indexes_cached(objects_dir: &Path) -> Result<Vec<Arc<PackIndex>>> {
1666    if crate::pack_store::PackStore::has_context() {
1667        return pack_cache::get_dir_indexes(objects_dir);
1668    }
1669    let store = crate::pack_store::PackStore::legacy_for_objects_dir(objects_dir);
1670    crate::pack_store::PackStore::with_context(store, || pack_cache::get_dir_indexes(objects_dir))
1671}
1672
1673/// Re-scan the pack directory when its mtime changed since the cached listing.
1674///
1675/// Called when an object was not found in loose storage or any known pack; returns
1676/// `true` when the listing was refreshed so the caller can retry once.
1677///
1678/// # Errors
1679///
1680/// Returns [`Error::Io`] when the pack directory cannot be read.
1681pub fn reprepare_pack_directory_on_miss(objects_dir: &Path) -> Result<bool> {
1682    if crate::pack_store::PackStore::has_context() {
1683        return reprepare_pack_directory_on_miss_in_context(objects_dir);
1684    }
1685    let store = crate::pack_store::PackStore::legacy_for_objects_dir(objects_dir);
1686    crate::pack_store::PackStore::with_context(store, || {
1687        reprepare_pack_directory_on_miss_in_context(objects_dir)
1688    })
1689}
1690
1691fn reprepare_pack_directory_on_miss_in_context(objects_dir: &Path) -> Result<bool> {
1692    let refreshed = pack_cache::reprepare_dir_on_miss(objects_dir)?;
1693    if refreshed {
1694        crate::midx::evict_midx_read_cache_for_pack_dir(&objects_dir.join("pack"));
1695    }
1696    Ok(refreshed)
1697}
1698
1699/// Read a single pack index from the process-wide cache (parses from disk on miss).
1700/// Skips trailer verification.
1701///
1702/// # Errors
1703///
1704/// Returns [`Error::Io`] when the file is missing or [`Error::CorruptObject`] for
1705/// malformed indexes.
1706pub fn read_pack_index_cached(idx_path: &Path) -> Result<Arc<PackIndex>> {
1707    if crate::pack_store::PackStore::has_context() {
1708        return pack_cache::get_index(idx_path);
1709    }
1710    let pack_path = idx_path.with_extension("pack");
1711    let store = crate::pack_store::PackStore::legacy_for_pack_path(&pack_path);
1712    crate::pack_store::PackStore::with_context(store, || pack_cache::get_index(idx_path))
1713}
1714
1715/// Read pack file bytes from the process-wide cache.
1716///
1717/// # Errors
1718///
1719/// Returns [`Error::Io`] when the pack cannot be read.
1720pub fn read_pack_bytes_cached(pack_path: &Path) -> Result<Arc<PackData>> {
1721    if crate::pack_store::PackStore::has_context() {
1722        return pack_cache::get_pack_bytes(pack_path);
1723    }
1724    let store = crate::pack_store::PackStore::legacy_for_pack_path(pack_path);
1725    crate::pack_store::PackStore::with_context(store, || pack_cache::get_pack_bytes(pack_path))
1726}
1727
1728/// Drop pack/MIDX caches on every registered [`PackStore`](crate::pack_store::PackStore).
1729///
1730/// Prefer [`Odb::invalidate_packs`](crate::odb::Odb::invalidate_packs) when an [`Odb`](crate::odb::Odb)
1731/// handle is available. Tests and benchmarks use this to reset all stores in the process.
1732pub fn clear_pack_cache() {
1733    #[cfg(test)]
1734    let _guard = pack_cache_test_guard();
1735    clear_pack_read_info_cache();
1736    pack_cache::clear_thread_local_dir_indexes();
1737    crate::pack_store::PackStore::invalidate_all_legacy();
1738}
1739
1740/// Test helper: reset pack marker stat counter (integration tests only).
1741#[cfg(test)]
1742pub fn test_reset_pack_marker_stat_count() {
1743    pack_cache::test_reset_marker_stat_count();
1744}
1745
1746/// Test helper: read pack marker stat counter (integration tests only).
1747#[cfg(test)]
1748#[must_use]
1749pub fn test_pack_marker_stat_count() -> u64 {
1750    pack_cache::test_marker_stat_count()
1751}
1752
1753/// Drop only the process-wide delta-base LRU (indexes and pack bytes stay cached).
1754pub fn clear_pack_delta_base_cache() {
1755    pack_cache::clear_delta_bases();
1756}
1757
1758/// Apply `core.deltaBaseCacheLimit` from repository config to the process-wide delta-base LRU.
1759pub fn configure_delta_base_cache_from_config(cfg: Option<&crate::config::ConfigSet>) {
1760    let bytes = cfg
1761        .and_then(|c| c.get("core.deltaBaseCacheLimit"))
1762        .as_ref()
1763        .and_then(|v| crate::config::parse_i64(v).ok())
1764        .and_then(|n| usize::try_from(n).ok())
1765        .unwrap_or(pack_cache::DELTA_BASE_CACHE_DEFAULT);
1766    pack_cache::set_delta_base_cache_byte_limit(bytes);
1767}
1768
1769/// Re-stamp the cached pack-bytes signature after deliberately touching `pack_path`'s mtime
1770/// (object freshening). See the internal `pack_cache::refresh_pack_signature` helper.
1771pub fn refresh_pack_bytes_signature(pack_path: &Path, touched_at: SystemTime) {
1772    pack_cache::refresh_pack_signature(pack_path, touched_at);
1773}
1774
1775/// Collect aggregate local pack metrics.
1776///
1777/// # Errors
1778///
1779/// Returns [`Error::Io`] when reading pack metadata fails.
1780pub fn collect_local_pack_info(objects_dir: &Path) -> Result<LocalPackInfo> {
1781    let indexes = read_local_pack_indexes(objects_dir)?;
1782    let mut info = LocalPackInfo::default();
1783    for idx in indexes {
1784        let pack_meta = fs::metadata(&idx.pack_path).map_err(Error::Io)?;
1785        let idx_meta = fs::metadata(&idx.idx_path).map_err(Error::Io)?;
1786        info.pack_count += 1;
1787        info.object_count += idx.len();
1788        info.size_bytes += pack_meta.len() + idx_meta.len();
1789        let hash_bytes = idx.hash_bytes();
1790        for entry in idx.iter() {
1791            if entry.oid().len() == hash_bytes {
1792                if let Ok(oid) = ObjectId::from_bytes(entry.oid()) {
1793                    info.object_ids.insert(oid);
1794                }
1795            }
1796        }
1797    }
1798    Ok(info)
1799}
1800
1801/// Write a version-2 `.idx` for `entries` describing objects in `pack_path`.
1802///
1803/// `entries` lists `(oid, byte_offset)` pairs; they are sorted by OID before
1804/// writing. The pack file must already exist on disk with a valid trailing
1805/// checksum at the repository hash width `hash_bytes` (`20` or `32`).
1806///
1807/// # Errors
1808///
1809/// Returns [`Error::CorruptObject`] when the pack is too small or an entry
1810/// offset cannot be walked, or [`Error::Io`] on filesystem failure.
1811/// Write a v2 pack index file.
1812///
1813/// `sort_threads` controls OID-table sorting: `None` or `Some(1)` uses a single thread;
1814/// `Some(n)` with `n > 1` may sort chunks on up to `n` workers (for large entry counts).
1815pub fn write_v2_pack_index_with_trailer(
1816    idx_path: &Path,
1817    entries: &[(ObjectId, u64, u32)],
1818    pack_trailer: &[u8],
1819    hash_bytes: usize,
1820    sort_threads: Option<usize>,
1821) -> Result<()> {
1822    write_v2_pack_index_sorting(
1823        idx_path,
1824        &mut entries.to_vec(),
1825        pack_trailer,
1826        hash_bytes,
1827        sort_threads,
1828    )
1829}
1830
1831/// `write_v2_pack_index` function.
1832pub fn write_v2_pack_index(
1833    idx_path: &Path,
1834    pack_path: &Path,
1835    entries: &[(ObjectId, u64, u32)],
1836    hash_bytes: usize,
1837) -> Result<()> {
1838    let pack_bytes = fs::read(pack_path).map_err(Error::Io)?;
1839    if pack_bytes.len() < hash_bytes {
1840        return Err(Error::CorruptObject(
1841            "pack too small for idx trailer".into(),
1842        ));
1843    }
1844    let trailer = &pack_bytes[pack_bytes.len() - hash_bytes..];
1845    write_v2_pack_index_with_trailer(idx_path, entries, trailer, hash_bytes, None)
1846}
1847
1848const PARALLEL_INDEX_SORT_MIN: usize = 8_192;
1849
1850/// Sort `entries` by object id in place and return the v2 fanout table.
1851///
1852/// Entries are first partitioned in place by their leading byte (which also yields the
1853/// fanout), then contiguous runs of buckets are sorted on up to `sort_threads` workers.
1854/// No copy of the entries is made.
1855fn sort_index_entries(
1856    entries: &mut [(ObjectId, u64, u32)],
1857    sort_threads: Option<usize>,
1858) -> [u32; 256] {
1859    let lead = |entry: &(ObjectId, u64, u32)| usize::from(entry.0.as_bytes()[0]);
1860    let mut counts = [0usize; 256];
1861    for entry in entries.iter() {
1862        counts[lead(entry)] += 1;
1863    }
1864    let mut ends = [0usize; 256];
1865    let mut total = 0usize;
1866    for (end, count) in ends.iter_mut().zip(counts) {
1867        total += count;
1868        *end = total;
1869    }
1870    let mut next = [0usize; 256];
1871    next[1..].copy_from_slice(&ends[..255]);
1872    for bucket in 0..256 {
1873        while next[bucket] < ends[bucket] {
1874            let at = next[bucket];
1875            let home = lead(&entries[at]);
1876            if home == bucket {
1877                next[bucket] += 1;
1878            } else {
1879                entries.swap(at, next[home]);
1880                next[home] += 1;
1881            }
1882        }
1883    }
1884
1885    let by_oid =
1886        |a: &(ObjectId, u64, u32), b: &(ObjectId, u64, u32)| a.0.as_bytes().cmp(b.0.as_bytes());
1887    let threads = sort_threads.unwrap_or(1).max(1);
1888    if threads <= 1 || entries.len() < PARALLEL_INDEX_SORT_MIN {
1889        let mut start = 0;
1890        for end in ends {
1891            entries[start..end].sort_unstable_by(by_oid);
1892            start = end;
1893        }
1894    } else {
1895        let share = entries.len().div_ceil(threads);
1896        std::thread::scope(|scope| {
1897            let mut rest = &mut *entries;
1898            let mut taken = 0usize;
1899            for end in ends {
1900                if end - taken >= share || end == total {
1901                    let (run, tail) = std::mem::take(&mut rest).split_at_mut(end - taken);
1902                    rest = tail;
1903                    taken = end;
1904                    if run.len() > 1 {
1905                        scope.spawn(move || run.sort_unstable_by(by_oid));
1906                    }
1907                }
1908            }
1909        });
1910    }
1911    ends.map(|end| u32::try_from(end).unwrap_or(u32::MAX))
1912}
1913
1914/// Sort `entries` in place and write them as a version-2 pack index at `idx_path`.
1915///
1916/// Streams the file through its checksum instead of building it in memory, so the only
1917/// large allocation is the caller's `entries`.
1918///
1919/// # Errors
1920///
1921/// Returns [`Error::CorruptObject`] when an object id or the trailer does not have
1922/// `hash_bytes` bytes, or the large-offset table overflows, and [`Error::Io`] on write
1923/// failure.
1924pub(crate) fn write_v2_pack_index_sorting(
1925    idx_path: &Path,
1926    entries: &mut [(ObjectId, u64, u32)],
1927    pack_trailer: &[u8],
1928    hash_bytes: usize,
1929    sort_threads: Option<usize>,
1930) -> Result<()> {
1931    use std::io::Write;
1932
1933    let Some(algo) = HashAlgo::from_len(hash_bytes) else {
1934        return Err(Error::CorruptObject(format!(
1935            "unsupported index hash width {hash_bytes}"
1936        )));
1937    };
1938    if pack_trailer.len() != hash_bytes {
1939        return Err(Error::CorruptObject(format!(
1940            "pack trailer length {} does not match hash width {hash_bytes}",
1941            pack_trailer.len()
1942        )));
1943    }
1944    if let Some((oid, _, _)) = entries
1945        .iter()
1946        .find(|(oid, _, _)| oid.as_bytes().len() != hash_bytes)
1947    {
1948        return Err(Error::CorruptObject(format!(
1949            "OID width {} does not match index hash width {hash_bytes}",
1950            oid.as_bytes().len()
1951        )));
1952    }
1953    let fanout = sort_index_entries(entries, sort_threads);
1954
1955    let file = fs::File::create(idx_path).map_err(Error::Io)?;
1956    let mut out = std::io::BufWriter::with_capacity(256 * 1024, file);
1957    let mut hasher = algo.checksum_hasher();
1958    let mut put = |bytes: &[u8]| -> Result<()> {
1959        hasher.update(bytes);
1960        out.write_all(bytes).map_err(Error::Io)
1961    };
1962    put(b"\xfftOc")?;
1963    put(&2u32.to_be_bytes())?;
1964    for f in fanout {
1965        put(&f.to_be_bytes())?;
1966    }
1967    for (oid, _, _) in entries.iter() {
1968        put(oid.as_bytes())?;
1969    }
1970    for (_, _, crc) in entries.iter() {
1971        put(&crc.to_be_bytes())?;
1972    }
1973    let mut large = 0u32;
1974    for (_, off, _) in entries.iter() {
1975        match u32::try_from(*off) {
1976            Ok(small) if small < 0x8000_0000 => put(&small.to_be_bytes())?,
1977            _ => {
1978                if large >= 0x8000_0000 {
1979                    return Err(Error::CorruptObject(
1980                        "pack index large-offset table overflow".to_owned(),
1981                    ));
1982                }
1983                put(&(0x8000_0000u32 | large).to_be_bytes())?;
1984                large += 1;
1985            }
1986        }
1987    }
1988    for (_, off, _) in entries.iter() {
1989        if *off >= 0x8000_0000 {
1990            put(&off.to_be_bytes())?;
1991        }
1992    }
1993    put(pack_trailer)?;
1994    let digest = hasher.finalize();
1995    out.write_all(&digest).map_err(Error::Io)?;
1996    out.flush().map_err(Error::Io)?;
1997    Ok(())
1998}
1999
2000#[must_use]
2001/// `oid_bytes_to_hex` function.
2002pub fn oid_bytes_to_hex(oid: &[u8]) -> String {
2003    hex::encode(oid)
2004}
2005
2006/// Hash canonical loose object bytes (`kind SP size NUL data`) with the repo hash width.
2007pub fn hash_object_bytes(kind: ObjectKind, data: &[u8], hash_bytes: usize) -> Result<Vec<u8>> {
2008    let Some(algo) = HashAlgo::from_len(hash_bytes) else {
2009        return Err(Error::CorruptObject(format!(
2010            "unsupported object hash width: {hash_bytes}"
2011        )));
2012    };
2013    Ok(crate::hash::hash_object(algo, kind, data)?
2014        .as_bytes()
2015        .to_vec())
2016}
2017
2018/// Parse a pack index file (version 1 legacy or version 2), verifying the SHA-1
2019/// trailer checksum.
2020///
2021/// Used by `fsck`/`verify-pack` and similar code that wants on-disk validation. Hot
2022/// object-lookup paths should call [`read_pack_index_cached`] (which skips trailer
2023/// verification, matching Git's normal read path).
2024///
2025/// # Errors
2026///
2027/// Returns [`Error::CorruptObject`] when format checks fail and
2028/// [`Error::PackIndexChecksumMismatch`] when the `.idx` trailer is wrong.
2029pub fn read_pack_index(idx_path: &Path) -> Result<PackIndex> {
2030    crate::pack_index::open_pack_index_file(idx_path, true)
2031}
2032
2033/// Parse a pack index file without verifying the SHA-1 trailer checksum.
2034///
2035/// Git reads the `.idx` offset table without re-checking its trailer in the MIDX
2036/// write path (`midx-write.c`/`packfile.c` `open_pack_index`), so a deliberately
2037/// corrupted-but-structurally-valid idx (t5319 64-bit offset tests) still loads.
2038pub fn read_pack_index_no_verify(idx_path: &Path) -> Result<PackIndex> {
2039    crate::pack_index::open_pack_index_file(idx_path, false)
2040}
2041
2042/// A pack object type as encoded in the packed stream header.
2043#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2044pub enum PackedType {
2045    /// Commit object.
2046    Commit,
2047    /// Tree object.
2048    Tree,
2049    /// Blob object.
2050    Blob,
2051    /// Tag object.
2052    Tag,
2053    /// Offset delta.
2054    OfsDelta,
2055    /// Reference delta.
2056    RefDelta,
2057}
2058
2059impl PackedType {
2060    /// Printable name used by `verify-pack -v` output.
2061    #[must_use]
2062    pub fn as_str(self) -> &'static str {
2063        match self {
2064            Self::Commit => "commit",
2065            Self::Tree => "tree",
2066            Self::Blob => "blob",
2067            Self::Tag => "tag",
2068            Self::OfsDelta => "ofs-delta",
2069            Self::RefDelta => "ref-delta",
2070        }
2071    }
2072}
2073
2074/// A decoded object header record used by `verify-pack`.
2075#[derive(Debug, Clone)]
2076pub struct VerifyObjectRecord {
2077    /// Object ID from the index (20 or 32 raw bytes).
2078    pub oid: Vec<u8>,
2079    /// Type from the pack stream header.
2080    pub packed_type: PackedType,
2081    /// Uncompressed object size from the pack header.
2082    pub size: u64,
2083    /// Total bytes in pack occupied by this object slot.
2084    pub size_in_pack: u64,
2085    /// Offset in pack file.
2086    pub offset: u64,
2087    /// Delta chain depth, if deltified.
2088    pub depth: Option<u64>,
2089    /// Base object for ref-delta objects.
2090    pub base_oid: Option<Vec<u8>>,
2091    /// In-pack base offset for ofs-delta objects.
2092    pub base_pack_offset: Option<u64>,
2093}
2094
2095/// How a delta object in a pack references its base, used to compute chain depth order-independently.
2096enum DeltaBaseLink {
2097    /// `REF_DELTA`: base identified by raw object id (20 or 32 bytes).
2098    Oid(Vec<u8>),
2099    /// `OFS_DELTA`: base identified by its absolute offset in the pack.
2100    Offset(u64),
2101}
2102
2103/// Resolve the delta-chain depth of record `i`, memoizing the result into `records[i].depth`.
2104///
2105/// Full (non-delta) objects have depth 0. A delta's depth is one greater than its base's depth.
2106/// Following base links by offset/oid makes this independent of the order objects appear in the
2107/// pack — a ref-delta's base may be stored *after* the delta itself. A base that is not present in
2108/// this pack (thin pack) or a cycle is treated as depth 0 for the missing/looping link.
2109///
2110/// # Errors
2111///
2112/// Returns [`Error::CorruptObject`] when a ref-delta record is missing its base oid.
2113fn resolve_delta_depth(
2114    i: usize,
2115    base_links: &[Option<DeltaBaseLink>],
2116    by_oid: &HashMap<Vec<u8>, usize>,
2117    by_offset_idx: &HashMap<u64, usize>,
2118    records: &mut [VerifyObjectRecord],
2119) -> Result<u64> {
2120    if let Some(d) = records[i].depth {
2121        return Ok(d);
2122    }
2123    let Some(link) = &base_links[i] else {
2124        return Ok(0);
2125    };
2126    let base_idx = match link {
2127        DeltaBaseLink::Oid(oid) => by_oid.get(oid).copied(),
2128        DeltaBaseLink::Offset(off) => by_offset_idx.get(off).copied(),
2129    };
2130    // Mark this record visited before recursing so a malformed cyclic chain cannot recurse forever.
2131    records[i].depth = Some(1);
2132    let depth = match base_idx {
2133        Some(b) if b != i => {
2134            resolve_delta_depth(b, base_links, by_oid, by_offset_idx, records)?.saturating_add(1)
2135        }
2136        // Base absent from this pack (thin) or self-referential: count this delta as depth 1.
2137        _ => 1,
2138    };
2139    records[i].depth = Some(depth);
2140    Ok(depth)
2141}
2142
2143/// Default in-pack delta chain depth when `pack.depth` is unset (matches Git).
2144pub const DEFAULT_PACK_DEPTH: usize = 50;
2145
2146/// Longest delta-chain depth among verify-pack records (number of edges from base to tip).
2147#[must_use]
2148pub fn max_verify_pack_delta_depth(records: &[VerifyObjectRecord]) -> u64 {
2149    records.iter().filter_map(|r| r.depth).max().unwrap_or(0)
2150}
2151
2152/// Break only delta chains longer than `max_depth` edges in a target→base map.
2153///
2154/// Each entry maps a delta object to its chosen in-pack base. Chains already within
2155/// `max_depth` keep their original bases unchanged. Longer chains are broken on Git's
2156/// `(depth + 1)` modulo rule by removing only the delta edges at cut points; links
2157/// inside each resulting segment stay as originally chosen (matching `break_delta_chains`).
2158pub fn apply_delta_depth_limit(map: &mut HashMap<ObjectId, ObjectId>, max_depth: usize) {
2159    let keys: Vec<ObjectId> = map.keys().copied().collect();
2160    let value_set: HashSet<ObjectId> = map.values().copied().collect();
2161    let mut tips: Vec<ObjectId> = keys
2162        .into_iter()
2163        .filter(|k| !value_set.contains(k))
2164        .collect();
2165    tips.sort();
2166
2167    let modulus = max_depth.saturating_add(1);
2168    let mut snip: HashSet<ObjectId> = HashSet::new();
2169
2170    for tip in tips {
2171        let mut chain: Vec<ObjectId> = Vec::new();
2172        let mut cur = tip;
2173        let mut seen = HashSet::new();
2174        while seen.insert(cur) {
2175            chain.push(cur);
2176            let Some(&b) = map.get(&cur) else {
2177                break;
2178            };
2179            cur = b;
2180        }
2181
2182        let n = chain.len();
2183        if n < 2 || n - 1 <= max_depth {
2184            continue;
2185        }
2186
2187        let mut total_depth = (n - 1) as u32;
2188        for &oid in &chain {
2189            let assigned = (total_depth as usize) % modulus;
2190            total_depth = total_depth.saturating_sub(1);
2191            if assigned == 0 {
2192                snip.insert(oid);
2193            }
2194        }
2195    }
2196
2197    for oid in snip {
2198        map.remove(&oid);
2199    }
2200}
2201
2202/// Pack/index pair validated for fsck without inflating every object.
2203///
2204/// Structure, checksums, and per-entry headers are checked; object hashes are
2205/// verified separately when objects are read for semantic fsck.
2206#[derive(Debug)]
2207pub(crate) struct VerifiedPackForFsck {
2208    pub idx: PackIndex,
2209    /// Memory-mapped pack file (owned only for tiny packs or when mapping fails).
2210    pub pack_bytes: Arc<PackData>,
2211    pub records: Vec<VerifyObjectRecord>,
2212}
2213
2214/// Verify pack trailer, index checksum, counts, and object header layout.
2215///
2216/// Does not inflate objects or verify per-object hashes (see [`verify_pack_and_collect`]).
2217///
2218/// # Errors
2219///
2220/// Returns [`Error::CorruptObject`] when the index or pack are malformed.
2221pub(crate) fn verify_pack_for_fsck(idx_path: &Path) -> Result<VerifiedPackForFsck> {
2222    let (idx, pack_bytes, records) = verify_pack_structure_and_records(idx_path, true)?;
2223    Ok(VerifiedPackForFsck {
2224        idx,
2225        pack_bytes,
2226        records,
2227    })
2228}
2229
2230/// Pack-level trailer/index checksum problem detected while preparing object fsck.
2231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2232pub(crate) enum PackFsckTrailerIssue {
2233    /// Pack file trailing hash does not match payload.
2234    PackChecksum,
2235    /// Embedded pack hash in the `.idx` does not match the pack file.
2236    IdxMismatch,
2237}
2238
2239/// Load pack bytes and header records for per-object fsck even when the pack trailer is wrong.
2240///
2241/// Returns a trailer/index checksum issue separately so callers can still inflate objects.
2242pub(crate) fn prepare_pack_for_object_fsck(
2243    idx_path: &Path,
2244) -> Result<(VerifiedPackForFsck, Option<PackFsckTrailerIssue>)> {
2245    let (idx, pack_bytes, records) = verify_pack_structure_and_records(idx_path, false)?;
2246    let trailer = pack_trailer_fsck_issue(idx_path, &idx, &pack_bytes);
2247    Ok((
2248        VerifiedPackForFsck {
2249            idx,
2250            pack_bytes,
2251            records,
2252        },
2253        trailer,
2254    ))
2255}
2256
2257fn pack_trailer_fsck_issue(
2258    idx_path: &Path,
2259    idx: &PackIndex,
2260    pack_bytes: &[u8],
2261) -> Option<PackFsckTrailerIssue> {
2262    let hb = idx.hash_bytes();
2263    let algo = HashAlgo::from_len(hb)?;
2264    if verify_trailer(algo, pack_bytes).is_err() {
2265        return Some(PackFsckTrailerIssue::PackChecksum);
2266    }
2267    let idx_file_bytes = fs::read(idx_path).ok()?;
2268    if idx_file_bytes.len() >= 2 * hb {
2269        let pack_end = pack_bytes.len().checked_sub(hb)?;
2270        let n = idx_file_bytes.len();
2271        let embedded = &idx_file_bytes[n - 2 * hb..n - hb];
2272        if embedded != &pack_bytes[pack_end..] {
2273            return Some(PackFsckTrailerIssue::IdxMismatch);
2274        }
2275    }
2276    None
2277}
2278
2279/// Verify one pack/index pair and optionally return object records.
2280///
2281/// # Errors
2282///
2283/// Returns [`Error::CorruptObject`] when the index or pack are malformed.
2284pub fn verify_pack_and_collect(idx_path: &Path) -> Result<Vec<VerifyObjectRecord>> {
2285    let VerifiedPackForFsck {
2286        idx,
2287        pack_bytes,
2288        records,
2289    } = verify_pack_for_fsck(idx_path)?;
2290    let hb = idx.hash_bytes();
2291    for entry in idx.iter() {
2292        let obj = read_object_from_pack_bytes(&pack_bytes, &idx, entry.oid())?;
2293        let computed = hash_object_bytes(obj.kind, &obj.data, hb)?;
2294        if computed.as_slice() != entry.oid() {
2295            return Err(Error::CorruptObject(format!(
2296                "pack object hash mismatch at offset {} (index says {})",
2297                entry.offset()?,
2298                oid_bytes_to_hex(entry.oid())
2299            )));
2300        }
2301    }
2302    Ok(records)
2303}
2304
2305fn verify_pack_structure_and_records(
2306    idx_path: &Path,
2307    verify_pack_trailer: bool,
2308) -> Result<(PackIndex, Arc<PackData>, Vec<VerifyObjectRecord>)> {
2309    let idx = read_pack_index(idx_path)?;
2310    let idx_file_bytes = fs::read(idx_path).map_err(Error::Io)?;
2311    let pack_bytes = PackData::open_with_min_len(&idx.pack_path, 0)?;
2312    let hb = idx.hash_bytes();
2313    if pack_bytes.len() < 12 + hb {
2314        return Err(Error::CorruptObject(format!(
2315            "pack file {} is too small",
2316            idx.pack_path.display()
2317        )));
2318    }
2319    let pack_end = pack_bytes.len() - hb;
2320    let Some(algo) = HashAlgo::from_len(hb) else {
2321        return Err(Error::CorruptObject(format!(
2322            "unsupported OID width {hb} for pack {}",
2323            idx.pack_path.display()
2324        )));
2325    };
2326    if verify_pack_trailer {
2327        verify_trailer(algo, &pack_bytes).map_err(|e| {
2328            Error::CorruptObject(format!(
2329                "pack trailing checksum mismatch for {}: {e}",
2330                idx.pack_path.display()
2331            ))
2332        })?;
2333        // The `.idx` ends with the pack checksum followed by its own checksum, both
2334        // at the repository hash width `hb` (20 for SHA-1, 32 for SHA-256).
2335        if idx_file_bytes.len() >= 2 * hb {
2336            let n = idx_file_bytes.len();
2337            let embedded = &idx_file_bytes[n - 2 * hb..n - hb];
2338            if embedded != &pack_bytes[pack_end..] {
2339                return Err(Error::CorruptObject(format!(
2340                    "pack checksum in index does not match {}",
2341                    idx.pack_path.display()
2342                )));
2343            }
2344        }
2345    }
2346    if &pack_bytes[0..4] != b"PACK" {
2347        return Err(Error::CorruptObject(format!(
2348            "pack file {} has invalid signature",
2349            idx.pack_path.display()
2350        )));
2351    }
2352    let version = u32::from_be_bytes(pack_bytes[4..8].try_into().unwrap_or([0, 0, 0, 0]));
2353    if version != 2 && version != 3 {
2354        return Err(Error::CorruptObject(format!(
2355            "unsupported pack version {} in {}",
2356            version,
2357            idx.pack_path.display()
2358        )));
2359    }
2360    let count = u32::from_be_bytes(pack_bytes[8..12].try_into().unwrap_or([0, 0, 0, 0])) as usize;
2361    if count != idx.len() {
2362        return Err(Error::CorruptObject(format!(
2363            "pack/index object count mismatch for {}",
2364            idx.pack_path.display()
2365        )));
2366    }
2367
2368    let mut by_offset: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
2369    for entry in idx.iter() {
2370        by_offset.insert(entry.offset()?, entry.oid().to_vec());
2371    }
2372    let offsets: Vec<u64> = by_offset.keys().copied().collect();
2373    if offsets.is_empty() {
2374        return Ok((idx, pack_bytes, Vec::new()));
2375    }
2376
2377    let mut by_oid: HashMap<Vec<u8>, usize> = HashMap::new();
2378    let mut by_offset_idx: HashMap<u64, usize> = HashMap::new();
2379    let mut records: Vec<VerifyObjectRecord> = Vec::with_capacity(offsets.len());
2380    // Per-record base pointer for delta objects, captured while scanning headers and resolved into
2381    // chain depths afterwards. Delta bases can appear *after* the delta in pack order (ref-deltas in
2382    // particular), so depth must be computed by following these pointers, not in scan order.
2383    let mut base_links: Vec<Option<DeltaBaseLink>> = Vec::with_capacity(offsets.len());
2384    for (i, offset) in offsets.iter().copied().enumerate() {
2385        let oid = by_offset.get(&offset).cloned().ok_or_else(|| {
2386            Error::CorruptObject(format!("missing object id for offset {}", offset))
2387        })?;
2388        let next_off = offsets
2389            .get(i + 1)
2390            .copied()
2391            .unwrap_or((pack_bytes.len() - hb) as u64);
2392        if next_off <= offset || next_off > (pack_bytes.len() - hb) as u64 {
2393            return Err(Error::CorruptObject(format!(
2394                "invalid object boundaries at offset {} in {}",
2395                offset,
2396                idx.pack_path.display()
2397            )));
2398        }
2399        let mut p = offset as usize;
2400        let (packed_type, size) = parse_pack_object_header(&pack_bytes, &mut p)?;
2401        let mut base_oid: Option<Vec<u8>> = None;
2402        let mut base_link: Option<DeltaBaseLink> = None;
2403
2404        match packed_type {
2405            PackedType::RefDelta => {
2406                if p + hb > pack_bytes.len() {
2407                    return Err(Error::CorruptObject(format!(
2408                        "truncated ref-delta base at offset {}",
2409                        offset
2410                    )));
2411                }
2412                let raw = pack_bytes[p..p + hb].to_vec();
2413                base_oid = Some(raw.clone());
2414                base_link = Some(DeltaBaseLink::Oid(raw));
2415            }
2416            PackedType::OfsDelta => {
2417                let base_offset = parse_ofs_delta_base(&pack_bytes, &mut p, offset)?;
2418                base_link = Some(DeltaBaseLink::Offset(base_offset));
2419            }
2420            PackedType::Commit | PackedType::Tree | PackedType::Blob | PackedType::Tag => {}
2421        }
2422
2423        let size_in_pack = next_off - offset;
2424        let base_pack_offset = match &base_link {
2425            Some(DeltaBaseLink::Offset(off)) => Some(*off),
2426            _ => None,
2427        };
2428        records.push(VerifyObjectRecord {
2429            oid: oid.clone(),
2430            packed_type,
2431            size,
2432            size_in_pack,
2433            offset,
2434            depth: None,
2435            base_oid,
2436            base_pack_offset,
2437        });
2438        base_links.push(base_link);
2439        by_oid.insert(oid, i);
2440        by_offset_idx.insert(offset, i);
2441    }
2442
2443    // Resolve delta chain depths by following base links to their record index, regardless of the
2444    // order objects appear in the pack. A delta's depth is one more than its base's depth; full
2445    // objects have depth 0 (represented as `None` in the record). Memoize to keep this O(n).
2446    for i in 0..records.len() {
2447        if base_links[i].is_some() {
2448            let _ = resolve_delta_depth(i, &base_links, &by_oid, &by_offset_idx, &mut records)?;
2449        }
2450    }
2451
2452    Ok((idx, pack_bytes, records))
2453}
2454
2455/// Read alternates recursively, deduplicated in discovery order.
2456///
2457/// # Errors
2458///
2459/// Returns [`Error::Io`] when alternate files cannot be read.
2460pub fn read_alternates_recursive(objects_dir: &Path) -> Result<Vec<PathBuf>> {
2461    let mut visited = HashSet::new();
2462    let mut out = Vec::new();
2463    read_alternates_inner(objects_dir, &mut visited, &mut out, 0)?;
2464    Ok(out)
2465}
2466
2467/// Maximum alternate chain depth (git uses 5).
2468const MAX_ALTERNATE_DEPTH: usize = 5;
2469
2470/// Parse one non-empty line from `objects/info/alternates`.
2471///
2472/// Blank lines and `#` comments are ignored. Paths may be absolute, relative to the
2473/// current `objects/` directory, or C-style quoted (Git `read_info/alternates`).
2474fn parse_alternates_file_line(raw: &str) -> Option<PathBuf> {
2475    let line = raw.trim();
2476    if line.is_empty() || line.starts_with('#') {
2477        return None;
2478    }
2479    if line.starts_with('"') {
2480        let path = unquote_alternates_c_style(line)?;
2481        if path.is_empty() {
2482            return None;
2483        }
2484        return Some(PathBuf::from(path));
2485    }
2486    Some(PathBuf::from(line))
2487}
2488
2489/// Unquote a double-quoted alternates path (Git `unquote_c_style` subset).
2490fn unquote_alternates_c_style(line: &str) -> Option<String> {
2491    let bytes = line.as_bytes();
2492    if bytes.first() != Some(&b'"') {
2493        return None;
2494    }
2495    let mut out = Vec::new();
2496    let mut i = 1usize;
2497    while i < bytes.len() {
2498        if bytes[i] == b'"' {
2499            return String::from_utf8(out).ok();
2500        }
2501        if bytes[i] == b'\\' {
2502            i += 1;
2503            if i >= bytes.len() {
2504                return None;
2505            }
2506            match bytes[i] {
2507                b'\\' => out.push(b'\\'),
2508                b'"' => out.push(b'"'),
2509                b'a' => out.push(7),
2510                b'b' => out.push(8),
2511                b'f' => out.push(12),
2512                b'n' => out.push(b'\n'),
2513                b'r' => out.push(b'\r'),
2514                b't' => out.push(b'\t'),
2515                b'v' => out.push(11),
2516                c if c.is_ascii_digit() => {
2517                    if i + 2 >= bytes.len() {
2518                        return None;
2519                    }
2520                    let oct = std::str::from_utf8(&bytes[i..i + 3]).ok()?;
2521                    out.push(u8::from_str_radix(oct, 8).ok()?);
2522                    i += 2;
2523                }
2524                other => out.push(other),
2525            }
2526            i += 1;
2527            continue;
2528        }
2529        out.push(bytes[i]);
2530        i += 1;
2531    }
2532    None
2533}
2534
2535fn read_alternates_inner(
2536    objects_dir: &Path,
2537    visited: &mut HashSet<PathBuf>,
2538    out: &mut Vec<PathBuf>,
2539    depth: usize,
2540) -> Result<()> {
2541    if depth > MAX_ALTERNATE_DEPTH {
2542        return Ok(());
2543    }
2544    // Read the alternates file before canonicalizing: the canonical form is only needed to
2545    // resolve relative entries and deduplicate, and canonicalize walks every path component
2546    // (a readlink per ancestor) — pure waste in the overwhelmingly common no-alternates case.
2547    let alt_file = objects_dir.join("info").join("alternates");
2548    let text = match fs::read_to_string(&alt_file) {
2549        Ok(text) => text,
2550        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
2551        Err(err) => return Err(Error::Io(err)),
2552    };
2553    let canonical = canonical_or_self(objects_dir);
2554
2555    for raw in text.lines() {
2556        let Some(path) = parse_alternates_file_line(raw) else {
2557            continue;
2558        };
2559        let candidate = if path.is_absolute() {
2560            path
2561        } else {
2562            canonical.join(path)
2563        };
2564        let candidate = canonical_or_self(&candidate);
2565        if visited.insert(candidate.clone()) {
2566            out.push(candidate.clone());
2567            read_alternates_inner(&candidate, visited, out, depth + 1)?;
2568        }
2569    }
2570    Ok(())
2571}
2572
2573fn canonical_or_self(path: &Path) -> PathBuf {
2574    path.canonicalize_simplified()
2575        .unwrap_or_else(|_| path.to_path_buf())
2576}
2577
2578/// Convert a [`PackedType`] to an [`ObjectKind`] for non-delta types.
2579fn packed_type_to_kind(pt: PackedType) -> Result<ObjectKind> {
2580    match pt {
2581        PackedType::Commit => Ok(ObjectKind::Commit),
2582        PackedType::Tree => Ok(ObjectKind::Tree),
2583        PackedType::Blob => Ok(ObjectKind::Blob),
2584        PackedType::Tag => Ok(ObjectKind::Tag),
2585        PackedType::OfsDelta | PackedType::RefDelta => Err(Error::CorruptObject(
2586            "cannot convert delta type to object kind directly".to_owned(),
2587        )),
2588    }
2589}
2590
2591// hygiene: per-thread pack zlib scratch and read-info cache (ROADMAP item 7 — pack read hot path)
2592thread_local! {
2593    static DELTA_SIZE_ZLIB: std::cell::RefCell<crate::zlib_inflate::ZlibInflateScratch> =
2594        std::cell::RefCell::new(crate::zlib_inflate::ZlibInflateScratch::default());
2595}
2596// hygiene: scoped batch read_info cache; cleared on pack invalidation and context exit
2597thread_local! {
2598    static PACK_READ_INFO_CACHE: std::cell::RefCell<HashMap<PackReadInfoCacheKey, ObjectInfo>> =
2599        std::cell::RefCell::new(HashMap::new());
2600}
2601
2602const PACK_READ_INFO_CACHE_CAP: usize = 600_000;
2603
2604#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2605struct PackReadInfoCacheKey {
2606    pack_id: u32,
2607    offset: u64,
2608}
2609
2610fn pack_read_info_cache_key(pack_path: &Path, offset: u64) -> Option<PackReadInfoCacheKey> {
2611    if !crate::pack_store::PackStore::has_context() {
2612        return None;
2613    }
2614    Some(PackReadInfoCacheKey {
2615        pack_id: pack_cache::pack_id_for(pack_path),
2616        offset,
2617    })
2618}
2619
2620/// Drop cached batch `read_info` entries (pack context end, invalidation, repack).
2621pub(crate) fn clear_pack_read_info_cache() {
2622    PACK_READ_INFO_CACHE.with(|c| c.borrow_mut().clear());
2623}
2624
2625fn drop_pack_read_info_cache_for(pack_path: &Path) {
2626    let pack_id = pack_cache::pack_id_for(pack_path);
2627    PACK_READ_INFO_CACHE.with(|c| {
2628        c.borrow_mut().retain(|k, _| k.pack_id != pack_id);
2629    });
2630}
2631
2632fn cached_pack_read_info(pack_path: &Path, offset: u64) -> Option<ObjectInfo> {
2633    let key = pack_read_info_cache_key(pack_path, offset)?;
2634    PACK_READ_INFO_CACHE.with(|c| c.borrow().get(&key).cloned())
2635}
2636
2637fn remember_pack_read_info(pack_path: &Path, offset: u64, info: ObjectInfo) {
2638    let Some(key) = pack_read_info_cache_key(pack_path, offset) else {
2639        return;
2640    };
2641    PACK_READ_INFO_CACHE.with(|c| {
2642        let mut g = c.borrow_mut();
2643        if g.len() >= PACK_READ_INFO_CACHE_CAP {
2644            g.clear();
2645        }
2646        g.insert(key, info);
2647    });
2648}
2649/// Read the delta result-size varint from a pack zlib stream without inflating the full delta.
2650///
2651/// Advances `*pos` past the compressed bytes consumed from `bytes`.
2652pub(crate) fn read_delta_result_size_from_pack_zlib(bytes: &[u8], pos: &mut usize) -> Result<u64> {
2653    DELTA_SIZE_ZLIB.with(|slot| slot.borrow_mut().read_pack_delta_result_size(bytes, pos))
2654}
2655
2656fn decompress_pack_data_into(
2657    bytes: &[u8],
2658    pos: &mut usize,
2659    expected_size: u64,
2660    scratch: &mut ZlibInflateScratch,
2661    out: &mut Vec<u8>,
2662) -> Result<()> {
2663    scratch.decompress_fixed_into(bytes, pos, expected_size, out)
2664}
2665
2666/// Read and fully resolve one object from a pack file given its offset.
2667///
2668/// Handles OFS_DELTA and REF_DELTA by recursively reading the base object.
2669/// The `idx` is used for REF_DELTA resolution (to find a base by OID).
2670/// Deepest delta chain a read will follow before treating the pack as corrupt.
2671///
2672/// Safety valve against cyclic ref-delta chains and unbounded recursion, not a
2673/// format limit — Git reads chains of any depth (`pack.depth` caps only writers).
2674const MAX_DELTA_CHAIN_READ_DEPTH: usize = 4096;
2675
2676/// One hop in an in-flight delta chain (collected tip→base, applied base→tip).
2677#[derive(Clone)]
2678struct PendingDeltaFrame {
2679    pack_id: u32,
2680    offset: u64,
2681    delta: Vec<u8>,
2682}
2683
2684/// Tracks delta-chain depth and the current `(pack_id, offset)` path for cycle detection.
2685///
2686/// Also holds reusable scratch buffers for pack reads on this thread (cleared per resolution).
2687#[derive(Default)]
2688struct DeltaChainState {
2689    depth: usize,
2690    visited: HashSet<(u32, u64)>,
2691    pending: Vec<PendingDeltaFrame>,
2692    inflate: ZlibInflateScratch,
2693    whole_object: Vec<u8>,
2694    delta_decompress: Vec<u8>,
2695    chain_a: Vec<u8>,
2696    chain_b: Vec<u8>,
2697}
2698
2699// hygiene: pack read delta-chain state (ROADMAP item 7 — per-thread pack cache)
2700thread_local! {
2701    static PACK_READ_DELTA_STATE: std::cell::RefCell<DeltaChainState> =
2702        std::cell::RefCell::new(DeltaChainState::default());
2703}
2704
2705fn with_pack_read_delta_state<R>(
2706    start_depth: usize,
2707    f: impl FnOnce(&mut DeltaChainState) -> R,
2708) -> R {
2709    PACK_READ_DELTA_STATE.with(|slot| {
2710        let mut state = slot.borrow_mut();
2711        state.depth = start_depth;
2712        state.visited.clear();
2713        f(&mut state)
2714    })
2715}
2716
2717impl DeltaChainState {
2718    fn visit(&mut self, pack_id: u32, offset: u64) -> Result<()> {
2719        if self.depth > MAX_DELTA_CHAIN_READ_DEPTH {
2720            return Err(Error::DeltaChainTooDeep {
2721                limit: MAX_DELTA_CHAIN_READ_DEPTH,
2722            });
2723        }
2724        if !self.visited.insert((pack_id, offset)) {
2725            return Err(Error::DeltaChainTooDeep {
2726                limit: MAX_DELTA_CHAIN_READ_DEPTH,
2727            });
2728        }
2729        Ok(())
2730    }
2731
2732    fn next_hop(&mut self) -> Result<()> {
2733        self.depth += 1;
2734        if self.depth > MAX_DELTA_CHAIN_READ_DEPTH {
2735            return Err(Error::DeltaChainTooDeep {
2736                limit: MAX_DELTA_CHAIN_READ_DEPTH,
2737            });
2738        }
2739        Ok(())
2740    }
2741}
2742
2743/// Copy `payload` into `out`, reusing `out`'s capacity when sufficient.
2744fn copy_payload_preserve_capacity(out: &mut Vec<u8>, payload: &[u8]) {
2745    out.clear();
2746    out.extend_from_slice(payload);
2747}
2748
2749/// Like [`apply_delta_chain_forward`], writing the final payload into `out` without replacing
2750/// `out`'s allocation when the result fits in the existing capacity.
2751#[allow(clippy::too_many_arguments)]
2752fn apply_delta_chain_forward_into(
2753    out: &mut Vec<u8>,
2754    kind: ObjectKind,
2755    base_data: &[u8],
2756    whole_base: Option<(u32, u64)>,
2757    pending: &[PendingDeltaFrame],
2758    result_pack_id: u32,
2759    result_offset: u64,
2760    populate_delta_lru: bool,
2761    state: &mut DeltaChainState,
2762) -> Result<()> {
2763    if pending.is_empty() {
2764        copy_payload_preserve_capacity(out, base_data);
2765        return Ok(());
2766    }
2767
2768    let caching = populate_delta_lru && pack_cache::delta_base_caching_enabled();
2769    let mut cache_inserts: Vec<(u32, u64, Arc<[u8]>)> =
2770        Vec::with_capacity(if caching { pending.len() + 1 } else { 0 });
2771    if caching {
2772        if let Some((pid, off)) = whole_base {
2773            cache_inserts.push((pid, off, Arc::from(base_data.to_vec())));
2774        }
2775    }
2776
2777    let chain_a = &mut state.chain_a;
2778    let chain_b = &mut state.chain_b;
2779    let mut rev: Vec<&PendingDeltaFrame> = pending.iter().rev().collect();
2780    let last = rev
2781        .pop()
2782        .ok_or_else(|| Error::CorruptObject("empty delta chain".to_owned()))?;
2783    if rev.is_empty() {
2784        apply_delta_into(out, base_data, &last.delta)?;
2785        if caching && (last.pack_id != result_pack_id || last.offset != result_offset) {
2786            cache_inserts.push((last.pack_id, last.offset, Arc::from(out.as_slice())));
2787        }
2788        if caching {
2789            pack_cache::put_delta_bases(kind, &cache_inserts);
2790        }
2791        return Ok(());
2792    }
2793
2794    let Some(first) = rev.first().copied() else {
2795        return Err(Error::CorruptObject(
2796            "delta chain missing base-adjacent frame".to_owned(),
2797        ));
2798    };
2799    apply_delta_into(chain_a, base_data, &first.delta)?;
2800    if caching && (first.pack_id != result_pack_id || first.offset != result_offset) {
2801        cache_inserts.push((first.pack_id, first.offset, Arc::from(chain_a.as_slice())));
2802    }
2803    let mut src_is_a = true;
2804    for frame in rev.iter().skip(1) {
2805        if src_is_a {
2806            apply_delta_into(chain_b, chain_a.as_slice(), &frame.delta)?;
2807            if caching && (frame.pack_id != result_pack_id || frame.offset != result_offset) {
2808                cache_inserts.push((frame.pack_id, frame.offset, Arc::from(chain_b.as_slice())));
2809            }
2810        } else {
2811            apply_delta_into(chain_a, chain_b.as_slice(), &frame.delta)?;
2812            if caching && (frame.pack_id != result_pack_id || frame.offset != result_offset) {
2813                cache_inserts.push((frame.pack_id, frame.offset, Arc::from(chain_a.as_slice())));
2814            }
2815        }
2816        src_is_a = !src_is_a;
2817    }
2818    let penultimate_base = if src_is_a {
2819        chain_a.as_slice()
2820    } else {
2821        chain_b.as_slice()
2822    };
2823    apply_delta_into(out, penultimate_base, &last.delta)?;
2824    if caching && (last.pack_id != result_pack_id || last.offset != result_offset) {
2825        cache_inserts.push((last.pack_id, last.offset, Arc::from(out.as_slice())));
2826    }
2827    if caching {
2828        pack_cache::put_delta_bases(kind, &cache_inserts);
2829    }
2830    Ok(())
2831}
2832
2833#[allow(clippy::too_many_arguments)]
2834fn finish_pack_read_with_pending(
2835    out: &mut Vec<u8>,
2836    kind: ObjectKind,
2837    base: &[u8],
2838    whole_base: Option<(u32, u64)>,
2839    result_pack_id: u32,
2840    result_offset: u64,
2841    populate_delta_lru: bool,
2842    state: &mut DeltaChainState,
2843) -> Result<ObjectKind> {
2844    let pending = std::mem::take(&mut state.pending);
2845    apply_delta_chain_forward_into(
2846        out,
2847        kind,
2848        base,
2849        whole_base,
2850        &pending,
2851        result_pack_id,
2852        result_offset,
2853        populate_delta_lru,
2854        state,
2855    )?;
2856    Ok(kind)
2857}
2858
2859/// Starting index for a pack read: borrow the caller's [`PackIndex`], or hold a cached
2860/// [`Arc`] after hopping to another pack (no deep clone of `entries`).
2861enum PackIndexHandle<'a> {
2862    Borrowed(&'a PackIndex),
2863    Shared(Arc<PackIndex>),
2864}
2865
2866impl<'a> Clone for PackIndexHandle<'a> {
2867    fn clone(&self) -> Self {
2868        match self {
2869            Self::Borrowed(idx) => Self::Borrowed(idx),
2870            Self::Shared(arc) => Self::Shared(Arc::clone(arc)),
2871        }
2872    }
2873}
2874
2875impl PackIndexHandle<'_> {
2876    fn get(&self) -> &PackIndex {
2877        match self {
2878            Self::Borrowed(idx) => idx,
2879            Self::Shared(idx) => idx.as_ref(),
2880        }
2881    }
2882}
2883
2884fn find_other_pack_index(
2885    objects_dir: &Path,
2886    current: &PackIndex,
2887    oid: &ObjectId,
2888) -> Result<Option<Arc<PackIndex>>> {
2889    for idx in read_local_pack_indexes_cached(objects_dir)? {
2890        if idx.idx_path == current.idx_path {
2891            continue;
2892        }
2893        if idx.contains(oid)? {
2894            return Ok(Some(Arc::clone(&idx)));
2895        }
2896    }
2897    Ok(None)
2898}
2899
2900/// When an indexed in-pack base is unreadable, try a verified loose copy or the same OID in
2901/// another pack (cold rescue path preserved from the recursive reader).
2902fn rescue_in_pack_base(
2903    idx: &PackIndex,
2904    base_offset: u64,
2905    objects_dir: Option<&Path>,
2906    state: &mut DeltaChainState,
2907) -> Result<Option<(ObjectKind, Vec<u8>)>> {
2908    let Some(pos) = idx.find_position_by_offset_sorted(base_offset)? else {
2909        return Ok(None);
2910    };
2911    let entry_oid = idx.oid_at(pos);
2912    if entry_oid.len() != idx.hash_bytes() {
2913        return Ok(None);
2914    }
2915    let base_oid = ObjectId::from_bytes(entry_oid)?;
2916    let Some(dir) = objects_dir else {
2917        return Ok(None);
2918    };
2919    let loose = dir
2920        .join(base_oid.loose_prefix())
2921        .join(base_oid.loose_suffix());
2922    if loose.is_file() {
2923        if let Ok(obj) = crate::odb::Odb::read_loose_verify_oid(&loose, &base_oid) {
2924            return Ok(Some((obj.kind, obj.data)));
2925        }
2926    }
2927    if let Some(other_idx) = find_other_pack_index(dir, idx, &base_oid)? {
2928        let Some(off) = other_idx.find_offset(&base_oid)? else {
2929            return Ok(None);
2930        };
2931        // The nested resolution starts its own chain and clears `state.pending`; keep the
2932        // caller's collected deltas aside so they are applied on top of the rescued base.
2933        let caller_pending = std::mem::take(&mut state.pending);
2934        let rescued =
2935            resolve_pack_object_at(PackIndexHandle::Shared(other_idx), off, Some(dir), state);
2936        state.pending = caller_pending;
2937        if let Ok((kind, data)) = rescued {
2938            return Ok(Some((kind, data)));
2939        }
2940    }
2941    Ok(None)
2942}
2943
2944/// True when a pack parse failure might be stale cached bytes rather than on-disk corruption.
2945fn pack_bytes_parse_may_be_stale(err: &Error) -> bool {
2946    matches!(err, Error::CorruptObject(_) | Error::Zlib(_))
2947}
2948
2949/// Resolve one pack object, walking delta chains iteratively across packs with shared
2950/// [`DeltaChainState`] so cycles and depth limits surface as [`Error::DeltaChainTooDeep`].
2951///
2952/// On corruption/zlib errors, revalidates the starting pack's on-disk signature once and
2953/// retries the full resolution before surfacing the error.
2954fn resolve_pack_object_at(
2955    start_idx: PackIndexHandle<'_>,
2956    start_offset: u64,
2957    objects_dir: Option<&Path>,
2958    state: &mut DeltaChainState,
2959) -> Result<(ObjectKind, Vec<u8>)> {
2960    resolve_pack_object_at_with_bytes(start_idx, start_offset, objects_dir, state, None)
2961}
2962
2963fn resolve_pack_object_at_into(
2964    start_idx: PackIndexHandle<'_>,
2965    start_offset: u64,
2966    objects_dir: Option<&Path>,
2967    state: &mut DeltaChainState,
2968    populate_delta_lru: bool,
2969    out: &mut Vec<u8>,
2970) -> Result<ObjectKind> {
2971    resolve_pack_object_at_with_bytes_into(
2972        start_idx,
2973        start_offset,
2974        objects_dir,
2975        state,
2976        None,
2977        populate_delta_lru,
2978        out,
2979    )
2980}
2981
2982fn resolve_pack_object_at_with_bytes(
2983    start_idx: PackIndexHandle<'_>,
2984    start_offset: u64,
2985    objects_dir: Option<&Path>,
2986    state: &mut DeltaChainState,
2987    pack_bytes_override: Option<&[u8]>,
2988) -> Result<(ObjectKind, Vec<u8>)> {
2989    let mut out = Vec::new();
2990    let kind = resolve_pack_object_at_with_bytes_into(
2991        start_idx,
2992        start_offset,
2993        objects_dir,
2994        state,
2995        pack_bytes_override,
2996        true,
2997        &mut out,
2998    )?;
2999    Ok((kind, out))
3000}
3001
3002fn resolve_pack_object_at_with_bytes_into(
3003    start_idx: PackIndexHandle<'_>,
3004    start_offset: u64,
3005    objects_dir: Option<&Path>,
3006    state: &mut DeltaChainState,
3007    pack_bytes_override: Option<&[u8]>,
3008    populate_delta_lru: bool,
3009    out: &mut Vec<u8>,
3010) -> Result<ObjectKind> {
3011    let pack_path = start_idx.get().pack_path.clone();
3012    let start_pack_id = pack_cache::pack_id_for(&pack_path);
3013    for attempt in 0..2 {
3014        match resolve_pack_object_at_body(
3015            start_idx.clone(),
3016            start_offset,
3017            start_pack_id,
3018            objects_dir,
3019            state,
3020            pack_bytes_override,
3021            populate_delta_lru,
3022            out,
3023        ) {
3024            Ok(kind) => return Ok(kind),
3025            Err(e) if attempt == 0 && pack_bytes_parse_may_be_stale(&e) => {
3026                if pack_cache::reload_pack_bytes_after_parse_failure(&pack_path)? {
3027                    state.visited.retain(|(id, _)| *id != start_pack_id);
3028                    continue;
3029                }
3030                return Err(e);
3031            }
3032            Err(e) => return Err(e),
3033        }
3034    }
3035    unreachable!("at most two resolution attempts")
3036}
3037
3038#[allow(clippy::too_many_arguments)]
3039fn resolve_pack_object_at_body(
3040    mut cur_idx: PackIndexHandle<'_>,
3041    start_offset: u64,
3042    result_pack_id: u32,
3043    objects_dir: Option<&Path>,
3044    state: &mut DeltaChainState,
3045    pack_bytes_override: Option<&[u8]>,
3046    populate_delta_lru: bool,
3047    out: &mut Vec<u8>,
3048) -> Result<ObjectKind> {
3049    let result_offset = start_offset;
3050    let mut cur_offset = start_offset;
3051    state.pending.clear();
3052
3053    let mut cur_pack_path = cur_idx.get().pack_path.clone();
3054    let mut cur_pack_id = pack_cache::pack_id_for(&cur_pack_path);
3055    let mut validated_pack_count = false;
3056
3057    enum PackBytesSource<'a> {
3058        Borrowed(&'a [u8]),
3059        Cached(Arc<PackData>),
3060    }
3061
3062    let mut pack_bytes = match pack_bytes_override {
3063        Some(b) => PackBytesSource::Borrowed(b),
3064        None => PackBytesSource::Cached(read_pack_bytes_cached(&cur_pack_path)?),
3065    };
3066
3067    let mut compressed = Vec::new();
3068    loop {
3069        let idx_ref = cur_idx.get();
3070        if idx_ref.pack_path != cur_pack_path {
3071            cur_pack_path = idx_ref.pack_path.clone();
3072            pack_bytes = PackBytesSource::Cached(read_pack_bytes_cached(&cur_pack_path)?);
3073            cur_pack_id = pack_cache::pack_id_for(&cur_pack_path);
3074            validated_pack_count = false;
3075        }
3076        let pack_id = cur_pack_id;
3077        state.visit(pack_id, cur_offset)?;
3078
3079        if !validated_pack_count {
3080            let header: &[u8] = match &pack_bytes {
3081                PackBytesSource::Borrowed(b) => b,
3082                PackBytesSource::Cached(c) => c,
3083            };
3084            validate_pack_index_object_count(header, idx_ref)?;
3085            validated_pack_count = true;
3086        }
3087        let (bytes, mut pos) = match &pack_bytes {
3088            PackBytesSource::Borrowed(b) => (*b, cur_offset as usize),
3089            PackBytesSource::Cached(c) => {
3090                pack_entry_bytes(idx_ref, cur_offset, c, &mut compressed)?
3091            }
3092        };
3093        let object_start = cur_offset;
3094        let (packed_type, size) = match parse_pack_object_header(bytes, &mut pos) {
3095            Ok(v) => v,
3096            Err(err) => {
3097                if let Some((kind, data)) =
3098                    rescue_in_pack_base(idx_ref, cur_offset, objects_dir, state)?
3099                {
3100                    return finish_pack_read_with_pending(
3101                        out,
3102                        kind,
3103                        &data,
3104                        None,
3105                        result_pack_id,
3106                        result_offset,
3107                        populate_delta_lru,
3108                        state,
3109                    );
3110                }
3111                return Err(err);
3112            }
3113        };
3114
3115        match packed_type {
3116            PackedType::Commit | PackedType::Tree | PackedType::Blob | PackedType::Tag => {
3117                let kind = packed_type_to_kind(packed_type)?;
3118                if state.pending.is_empty() {
3119                    if let Err(err) =
3120                        decompress_pack_data_into(bytes, &mut pos, size, &mut state.inflate, out)
3121                    {
3122                        if let Some((rescue_kind, rescued)) =
3123                            rescue_in_pack_base(idx_ref, cur_offset, objects_dir, state)?
3124                        {
3125                            return finish_pack_read_with_pending(
3126                                out,
3127                                rescue_kind,
3128                                &rescued,
3129                                None,
3130                                result_pack_id,
3131                                result_offset,
3132                                populate_delta_lru,
3133                                state,
3134                            );
3135                        }
3136                        return Err(err);
3137                    }
3138                    return Ok(kind);
3139                }
3140                state.whole_object.clear();
3141                if let Err(err) = decompress_pack_data_into(
3142                    bytes,
3143                    &mut pos,
3144                    size,
3145                    &mut state.inflate,
3146                    &mut state.whole_object,
3147                ) {
3148                    if let Some((rescue_kind, rescued)) =
3149                        rescue_in_pack_base(idx_ref, cur_offset, objects_dir, state)?
3150                    {
3151                        return finish_pack_read_with_pending(
3152                            out,
3153                            rescue_kind,
3154                            &rescued,
3155                            None,
3156                            result_pack_id,
3157                            result_offset,
3158                            populate_delta_lru,
3159                            state,
3160                        );
3161                    }
3162                    return Err(err);
3163                }
3164                let whole = std::mem::take(&mut state.whole_object);
3165                return finish_pack_read_with_pending(
3166                    out,
3167                    kind,
3168                    &whole,
3169                    Some((pack_id, cur_offset)),
3170                    result_pack_id,
3171                    result_offset,
3172                    populate_delta_lru,
3173                    state,
3174                );
3175            }
3176            PackedType::OfsDelta => {
3177                state.next_hop()?;
3178                let base_offset = parse_ofs_delta_base(bytes, &mut pos, object_start)?;
3179                state.delta_decompress.clear();
3180                decompress_pack_data_into(
3181                    bytes,
3182                    &mut pos,
3183                    size,
3184                    &mut state.inflate,
3185                    &mut state.delta_decompress,
3186                )?;
3187                state.pending.push(PendingDeltaFrame {
3188                    pack_id,
3189                    offset: object_start,
3190                    delta: std::mem::take(&mut state.delta_decompress),
3191                });
3192                if let Some((base_kind, base_data)) =
3193                    pack_cache::get_delta_base(pack_id, base_offset)
3194                {
3195                    return finish_pack_read_with_pending(
3196                        out,
3197                        base_kind,
3198                        base_data.as_ref(),
3199                        None,
3200                        result_pack_id,
3201                        result_offset,
3202                        populate_delta_lru,
3203                        state,
3204                    );
3205                }
3206                cur_offset = base_offset;
3207            }
3208            PackedType::RefDelta => {
3209                state.next_hop()?;
3210                let hb = idx_ref.hash_bytes();
3211                if pos + hb > bytes.len() {
3212                    return Err(Error::CorruptObject(
3213                        "truncated ref-delta base OID".to_owned(),
3214                    ));
3215                }
3216                let base_raw = bytes[pos..pos + hb].to_vec();
3217                pos += hb;
3218                state.delta_decompress.clear();
3219                decompress_pack_data_into(
3220                    bytes,
3221                    &mut pos,
3222                    size,
3223                    &mut state.inflate,
3224                    &mut state.delta_decompress,
3225                )?;
3226                state.pending.push(PendingDeltaFrame {
3227                    pack_id,
3228                    offset: object_start,
3229                    delta: std::mem::take(&mut state.delta_decompress),
3230                });
3231
3232                if let Ok(base_oid) = ObjectId::from_bytes(base_raw.as_slice()) {
3233                    if let Some(base_offset) = idx_ref.find_offset(&base_oid)? {
3234                        if let Some((base_kind, base_data)) =
3235                            pack_cache::get_delta_base(pack_id, base_offset)
3236                        {
3237                            return finish_pack_read_with_pending(
3238                                out,
3239                                base_kind,
3240                                base_data.as_ref(),
3241                                None,
3242                                result_pack_id,
3243                                result_offset,
3244                                populate_delta_lru,
3245                                state,
3246                            );
3247                        }
3248                        cur_offset = base_offset;
3249                        continue;
3250                    }
3251                }
3252
3253                if hb == 20 {
3254                    if let (Some(dir), Ok(base_oid)) =
3255                        (objects_dir, ObjectId::from_bytes(base_raw.as_slice()))
3256                    {
3257                        let loose = dir
3258                            .join(base_oid.loose_prefix())
3259                            .join(base_oid.loose_suffix());
3260                        if loose.is_file() {
3261                            if let Ok(obj) =
3262                                crate::odb::Odb::read_loose_verify_oid(&loose, &base_oid)
3263                            {
3264                                return finish_pack_read_with_pending(
3265                                    out,
3266                                    obj.kind,
3267                                    &obj.data,
3268                                    None,
3269                                    result_pack_id,
3270                                    result_offset,
3271                                    populate_delta_lru,
3272                                    state,
3273                                );
3274                            }
3275                        }
3276                        if let Some(other_idx) = find_other_pack_index(dir, idx_ref, &base_oid)? {
3277                            let Some(off) = other_idx.find_offset(&base_oid)? else {
3278                                return Err(Error::ObjectNotFound(base_oid.to_hex()));
3279                            };
3280                            cur_idx = PackIndexHandle::Shared(other_idx);
3281                            cur_offset = off;
3282                            continue;
3283                        }
3284                    }
3285                }
3286                return Err(Error::CorruptObject(format!(
3287                    "ref-delta base {} not found in pack",
3288                    oid_bytes_to_hex(&base_raw)
3289                )));
3290            }
3291        }
3292    }
3293}
3294
3295/// Resolve kind and uncompressed size for one pack object without materializing its body.
3296fn resolve_pack_object_info_at(
3297    start_idx: PackIndexHandle<'_>,
3298    start_offset: u64,
3299    objects_dir: Option<&Path>,
3300    state: &mut DeltaChainState,
3301) -> Result<ObjectInfo> {
3302    resolve_pack_object_info_at_with_bytes(start_idx, start_offset, objects_dir, state, None)
3303}
3304
3305fn resolve_pack_object_info_at_body(
3306    cur_idx: PackIndexHandle<'_>,
3307    start_offset: u64,
3308    objects_dir: Option<&Path>,
3309    state: &mut DeltaChainState,
3310    pack_bytes_override: Option<&[u8]>,
3311) -> Result<ObjectInfo> {
3312    let cached_pack;
3313    let cur_bytes: &[u8] = if let Some(b) = pack_bytes_override {
3314        b
3315    } else {
3316        cached_pack = read_pack_bytes_cached(&cur_idx.get().pack_path)?;
3317        cached_pack.as_ref()
3318    };
3319    let mut cur_offset = start_offset;
3320    let mut result_size: Option<u64> = None;
3321
3322    loop {
3323        let idx_ref = cur_idx.get();
3324        let pack_id = pack_cache::pack_id_for(&idx_ref.pack_path);
3325        state.visit(pack_id, cur_offset)?;
3326
3327        if let Some(base) = cached_pack_read_info(&idx_ref.pack_path, cur_offset) {
3328            if let Some(size) = result_size {
3329                return Ok(ObjectInfo {
3330                    kind: base.kind,
3331                    size,
3332                });
3333            }
3334            return Ok(base);
3335        }
3336        let object_start = cur_offset;
3337        let mut pos = cur_offset as usize;
3338        let (packed_type, header_size) = parse_pack_object_header(cur_bytes, &mut pos)?;
3339
3340        match packed_type {
3341            PackedType::Commit | PackedType::Tree | PackedType::Blob | PackedType::Tag => {
3342                let kind = packed_type_to_kind(packed_type)?;
3343                remember_pack_read_info(
3344                    &idx_ref.pack_path,
3345                    cur_offset,
3346                    ObjectInfo {
3347                        kind,
3348                        size: header_size,
3349                    },
3350                );
3351                let size = result_size.unwrap_or(header_size);
3352                return Ok(ObjectInfo { kind, size });
3353            }
3354            PackedType::OfsDelta => {
3355                state.next_hop()?;
3356                let base_offset = parse_ofs_delta_base(cur_bytes, &mut pos, object_start)?;
3357                if result_size.is_none() {
3358                    result_size = Some(read_delta_result_size_from_pack_zlib(cur_bytes, &mut pos)?);
3359                }
3360                cur_offset = base_offset;
3361            }
3362            PackedType::RefDelta => {
3363                state.next_hop()?;
3364                let hb = idx_ref.hash_bytes();
3365                if pos + hb > cur_bytes.len() {
3366                    return Err(Error::CorruptObject(
3367                        "truncated ref-delta base OID".to_owned(),
3368                    ));
3369                }
3370                let base_raw = &cur_bytes[pos..pos + hb];
3371                pos += hb;
3372                if result_size.is_none() {
3373                    result_size = Some(read_delta_result_size_from_pack_zlib(cur_bytes, &mut pos)?);
3374                }
3375
3376                let base_oid = ObjectId::from_bytes(base_raw)?;
3377                if let Some(base_offset) = idx_ref.find_offset(&base_oid)? {
3378                    cur_offset = base_offset;
3379                    continue;
3380                }
3381
3382                if let Some(dir) = objects_dir {
3383                    let loose = dir
3384                        .join(base_oid.loose_prefix())
3385                        .join(base_oid.loose_suffix());
3386                    if loose.is_file() {
3387                        let info = crate::odb::read_loose_object_info(&loose)?;
3388                        let size = result_size.unwrap_or(info.size);
3389                        return Ok(ObjectInfo {
3390                            kind: info.kind,
3391                            size,
3392                        });
3393                    }
3394                    if let Some(other_idx) = find_other_pack_index(dir, idx_ref, &base_oid)? {
3395                        let Some(off) = other_idx.find_offset(&base_oid)? else {
3396                            return Err(Error::ObjectNotFound(base_oid.to_hex()));
3397                        };
3398                        let base_info = resolve_pack_object_info_at(
3399                            PackIndexHandle::Shared(other_idx),
3400                            off,
3401                            objects_dir,
3402                            state,
3403                        )?;
3404                        let size = result_size.unwrap_or(base_info.size);
3405                        return Ok(ObjectInfo {
3406                            kind: base_info.kind,
3407                            size,
3408                        });
3409                    }
3410                }
3411                return Err(Error::CorruptObject(format!(
3412                    "ref-delta base {} not found in pack",
3413                    oid_bytes_to_hex(base_raw)
3414                )));
3415            }
3416        }
3417    }
3418}
3419
3420fn read_pack_object_at(
3421    pack_bytes: &[u8],
3422    start_offset: u64,
3423    idx: &PackIndex,
3424    objects_dir: Option<&Path>,
3425    start_depth: usize,
3426) -> Result<(ObjectKind, Vec<u8>)> {
3427    let mut state = DeltaChainState {
3428        depth: start_depth,
3429        visited: HashSet::new(),
3430        ..Default::default()
3431    };
3432    resolve_pack_object_at_with_bytes(
3433        PackIndexHandle::Borrowed(idx),
3434        start_offset,
3435        objects_dir,
3436        &mut state,
3437        Some(pack_bytes),
3438    )
3439}
3440
3441/// Read an object from a pack file by its OID.
3442///
3443/// Searches the given pack index for the OID, then reads and decompresses
3444/// the object from the corresponding pack file, resolving delta chains.
3445///
3446/// # Errors
3447///
3448/// Returns [`Error::ObjectNotFound`] if the OID is not in this pack.
3449pub fn read_object_from_pack(idx: &PackIndex, oid: &ObjectId) -> Result<Object> {
3450    if crate::pack_store::PackStore::has_context() {
3451        return read_object_from_pack_at_depth(idx, oid, 0);
3452    }
3453    let store = crate::pack_store::PackStore::legacy_for_pack_path(&idx.pack_path);
3454    crate::pack_store::PackStore::with_context(store, || {
3455        read_object_from_pack_at_depth(idx, oid, 0)
3456    })
3457}
3458
3459/// Like [`read_object_from_pack`], inflating into `buf` and reusing its capacity.
3460///
3461/// # Errors
3462///
3463/// Same as [`read_object_from_pack`].
3464pub fn read_object_from_pack_into(
3465    idx: &PackIndex,
3466    oid: &ObjectId,
3467    buf: &mut Vec<u8>,
3468) -> Result<ObjectKind> {
3469    let Some(offset) = idx.find_offset(oid)? else {
3470        return Err(Error::ObjectNotFound(oid.to_hex()));
3471    };
3472    if crate::pack_store::PackStore::has_context() {
3473        return read_object_at_depth_into(idx, offset, 0, false, buf);
3474    }
3475    let store = crate::pack_store::PackStore::legacy_for_pack_path(&idx.pack_path);
3476    crate::pack_store::PackStore::with_context(store, || {
3477        read_object_at_depth_into(idx, offset, 0, false, buf)
3478    })
3479}
3480
3481/// Read [`ObjectInfo`] for `oid` stored in `idx`'s pack without inflating the full object body.
3482///
3483/// # Errors
3484///
3485/// Same as [`read_object_from_pack`], except no hash verification is performed on the payload.
3486pub fn read_object_info_from_pack(idx: &PackIndex, oid: &ObjectId) -> Result<ObjectInfo> {
3487    let Some(offset) = idx.find_offset(oid)? else {
3488        return Err(Error::ObjectNotFound(oid.to_hex()));
3489    };
3490    read_object_info_from_pack_at_offset(idx, offset)
3491}
3492
3493/// Read [`ObjectInfo`] at a known pack offset (avoids a second index lookup in batch workloads).
3494///
3495/// # Errors
3496///
3497/// Same as [`read_object_info_from_pack`].
3498pub fn read_object_info_from_pack_at_offset(idx: &PackIndex, offset: u64) -> Result<ObjectInfo> {
3499    let pack_path = idx.pack_path.clone();
3500    for attempt in 0..2 {
3501        let pack_bytes = read_pack_bytes_cached(&pack_path)?;
3502        if !pack_cache::pack_object_count_validated(&pack_path) {
3503            match validate_pack_index_object_count(&pack_bytes, idx) {
3504                Ok(()) => pack_cache::mark_pack_object_count_validated(&pack_path),
3505                Err(e) if attempt == 0 && pack_bytes_parse_may_be_stale(&e) => {
3506                    if pack_cache::reload_pack_bytes_after_parse_failure(&pack_path)? {
3507                        continue;
3508                    }
3509                    return Err(e);
3510                }
3511                Err(e) => return Err(e),
3512            }
3513        }
3514        // Object-info probes often visit many unrelated historical commits while peeling
3515        // loose tags. A few header bytes must not retain a fault-around window per tip.
3516        let mut header = [0u8; 16];
3517        if let Some(len) = pack_bytes.read_sparse(offset, &mut header) {
3518            if let Some(info) = read_whole_pack_entry_info_fast(idx, 0, &header[..len]) {
3519                if info.kind == ObjectKind::Tag {
3520                    remember_pack_read_info(&pack_path, offset, info);
3521                }
3522                return Ok(info);
3523            }
3524        }
3525        match read_object_info_from_pack_at_offset_with_bytes(idx, offset, pack_bytes.as_ref()) {
3526            Ok(info) => return Ok(info),
3527            Err(e) if attempt == 0 && pack_bytes_parse_may_be_stale(&e) => {
3528                if pack_cache::reload_pack_bytes_after_parse_failure(&pack_path)? {
3529                    continue;
3530                }
3531                return Err(e);
3532            }
3533            Err(e) => return Err(e),
3534        }
3535    }
3536    unreachable!("at most two read attempts")
3537}
3538
3539fn read_object_info_from_pack_at_offset_with_bytes(
3540    idx: &PackIndex,
3541    offset: u64,
3542    pack_bytes: &[u8],
3543) -> Result<ObjectInfo> {
3544    if let Some(info) = read_whole_pack_entry_info_fast(idx, offset, pack_bytes) {
3545        return Ok(info);
3546    }
3547    let objects_dir = idx.pack_path.parent().and_then(Path::parent);
3548    with_pack_read_delta_state(0, |state| {
3549        resolve_pack_object_info_at_with_bytes(
3550            PackIndexHandle::Borrowed(idx),
3551            offset,
3552            objects_dir,
3553            state,
3554            Some(pack_bytes),
3555        )
3556    })
3557}
3558
3559fn resolve_pack_object_info_at_with_bytes(
3560    start_idx: PackIndexHandle<'_>,
3561    start_offset: u64,
3562    objects_dir: Option<&Path>,
3563    state: &mut DeltaChainState,
3564    pack_bytes_override: Option<&[u8]>,
3565) -> Result<ObjectInfo> {
3566    let idx_ref = start_idx.get();
3567    let pack_path = idx_ref.pack_path.clone();
3568    let start_pack_id = pack_cache::pack_id_for(&pack_path);
3569    if let Some(info) = cached_pack_read_info(&pack_path, start_offset) {
3570        return Ok(info);
3571    }
3572    for attempt in 0..2 {
3573        match resolve_pack_object_info_at_body(
3574            start_idx.clone(),
3575            start_offset,
3576            objects_dir,
3577            state,
3578            pack_bytes_override,
3579        ) {
3580            Ok(v) => {
3581                remember_pack_read_info(&pack_path, start_offset, v);
3582                return Ok(v);
3583            }
3584            Err(e) if attempt == 0 && pack_bytes_parse_may_be_stale(&e) => {
3585                if pack_cache::reload_pack_bytes_after_parse_failure(&pack_path)? {
3586                    state.visited.retain(|(id, _)| *id != start_pack_id);
3587                    continue;
3588                }
3589                return Err(e);
3590            }
3591            Err(e) => return Err(e),
3592        }
3593    }
3594    unreachable!("at most two resolution attempts")
3595}
3596
3597/// Header-only info for undeltified pack entries (batch-check hot path).
3598#[inline]
3599fn read_whole_pack_entry_info_fast(
3600    _idx: &PackIndex,
3601    offset: u64,
3602    pack_bytes: &[u8],
3603) -> Option<ObjectInfo> {
3604    let mut pos = offset as usize;
3605    if pos >= pack_bytes.len() {
3606        return None;
3607    }
3608    let (packed_type, header_size) = parse_pack_object_header(pack_bytes, &mut pos).ok()?;
3609    match packed_type {
3610        PackedType::Commit | PackedType::Tree | PackedType::Blob | PackedType::Tag => {
3611            let kind = packed_type_to_kind(packed_type).ok()?;
3612            Some(ObjectInfo {
3613                kind,
3614                size: header_size,
3615            })
3616        }
3617        PackedType::OfsDelta | PackedType::RefDelta => None,
3618    }
3619}
3620
3621// hygiene: per-thread zlib scratch for whole pack entry reads (ROADMAP item 7 — pack read hot path)
3622thread_local! {
3623    static WHOLE_OBJECT_ZLIB: RefCell<ZlibInflateScratch> =
3624        RefCell::new(ZlibInflateScratch::default());
3625}
3626
3627/// Max uncompressed bytes to inflate when only commit headers (e.g. author) are needed.
3628pub(crate) const COMMIT_HEADER_READ_PREFIX: usize = 512;
3629
3630/// Select one entry for random reads; ordinary readers keep their zero-copy fast path.
3631fn pack_entry_bytes<'a>(
3632    idx: &PackIndex,
3633    offset: u64,
3634    pack: &'a PackData,
3635    buf: &'a mut Vec<u8>,
3636) -> Result<(&'a [u8], usize)> {
3637    let start = usize::try_from(offset)
3638        .map_err(|_| Error::CorruptObject("pack offset out of range".to_owned()))?;
3639    if !pack.uses_positioned_reads() {
3640        return Ok((pack, start));
3641    }
3642    let pack_end = pack
3643        .len()
3644        .checked_sub(idx.hash_bytes())
3645        .ok_or_else(|| Error::CorruptObject("truncated pack trailer".to_owned()))?;
3646    let end = idx.next_pack_offset_after(offset, pack_end as u64)? as usize;
3647    Ok((pack.read_range(start, end, buf)?, 0))
3648}
3649
3650fn read_whole_pack_entry_object_fast(
3651    idx: &PackIndex,
3652    offset: u64,
3653    pack_bytes: &PackData,
3654) -> Option<Object> {
3655    let mut data = Vec::new();
3656    read_whole_pack_entry_object_into(idx, offset, pack_bytes, &mut data)
3657        .map(|kind| Object::new(kind, data))
3658}
3659
3660fn read_whole_pack_entry_object_into(
3661    idx: &PackIndex,
3662    offset: u64,
3663    pack_bytes: &PackData,
3664    out: &mut Vec<u8>,
3665) -> Option<ObjectKind> {
3666    let pack_id = pack_cache::pack_id_for(&idx.pack_path);
3667    if let Some((kind, data)) = pack_cache::get_delta_base(pack_id, offset) {
3668        out.clear();
3669        out.extend_from_slice(data.as_ref());
3670        return Some(kind);
3671    }
3672    // Single-pack readers and general reads share this path: a preceding tag-info
3673    // probe lets either read a small whole tag without faulting mmap windows.
3674    if cached_pack_read_info(&idx.pack_path, offset)
3675        .is_some_and(|info| info.kind == ObjectKind::Tag)
3676    {
3677        let mut bytes = [0u8; 4096];
3678        if let Some(len) = pack_bytes.read_sparse(offset, &mut bytes) {
3679            if let Some(kind) = inflate_whole_pack_entry_into(&bytes[..len], 0, out) {
3680                return Some(kind);
3681            }
3682        }
3683    }
3684    let mut compressed = Vec::new();
3685    let (pack_bytes, pos) = pack_entry_bytes(idx, offset, pack_bytes, &mut compressed).ok()?;
3686    inflate_whole_pack_entry_into(pack_bytes, pos as u64, out)
3687}
3688
3689fn inflate_whole_pack_entry_into(
3690    pack_bytes: &[u8],
3691    offset: u64,
3692    out: &mut Vec<u8>,
3693) -> Option<ObjectKind> {
3694    let mut pos = offset as usize;
3695    if pos >= pack_bytes.len() {
3696        return None;
3697    }
3698    let (packed_type, size) = parse_pack_object_header(pack_bytes, &mut pos).ok()?;
3699    match packed_type {
3700        PackedType::Commit | PackedType::Tree | PackedType::Blob | PackedType::Tag => {
3701            let kind = packed_type_to_kind(packed_type).ok()?;
3702            WHOLE_OBJECT_ZLIB.with(|slot| {
3703                let mut inflate = slot.borrow_mut();
3704                decompress_pack_data_into(pack_bytes, &mut pos, size, &mut inflate, out)
3705                    .ok()
3706                    .map(|()| kind)
3707            })
3708        }
3709        PackedType::OfsDelta | PackedType::RefDelta => None,
3710    }
3711}
3712
3713fn read_object_from_packs_in_context_into(
3714    objects_dir: &Path,
3715    oid: &ObjectId,
3716    buf: &mut Vec<u8>,
3717) -> Result<ObjectKind> {
3718    match try_read_object_from_packs_with_options_into(
3719        objects_dir,
3720        oid,
3721        PackLookupOptions::ALL_LOCAL_PACKS,
3722        false,
3723        buf,
3724    ) {
3725        Ok(kind) => Ok(kind),
3726        Err(err @ Error::ObjectNotFound(_)) => {
3727            if reprepare_pack_directory_on_miss(objects_dir)? {
3728                try_read_object_from_packs_with_options_into(
3729                    objects_dir,
3730                    oid,
3731                    PackLookupOptions::ALL_LOCAL_PACKS,
3732                    false,
3733                    buf,
3734                )
3735            } else {
3736                Err(err)
3737            }
3738        }
3739        Err(err) => Err(err),
3740    }
3741}
3742
3743fn read_object_from_packs_in_context(objects_dir: &Path, oid: &ObjectId) -> Result<Object> {
3744    match try_read_object_from_packs_with_options(
3745        objects_dir,
3746        oid,
3747        PackLookupOptions::ALL_LOCAL_PACKS,
3748    ) {
3749        Ok(obj) => Ok(obj),
3750        Err(err @ Error::ObjectNotFound(_)) => {
3751            if reprepare_pack_directory_on_miss_in_context(objects_dir)? {
3752                try_read_object_from_packs_with_options(
3753                    objects_dir,
3754                    oid,
3755                    PackLookupOptions::ALL_LOCAL_PACKS,
3756                )
3757            } else {
3758                Err(err)
3759            }
3760        }
3761        Err(err) => Err(err),
3762    }
3763}
3764
3765/// Read and decompress the object stored at `offset` in `idx`'s pack file.
3766///
3767/// The pack index is used for pack path, object count validation, and resolving ref-delta
3768/// bases; the starting location comes from the caller (e.g. a multi-pack-index entry).
3769pub(crate) fn read_object_at(idx: &PackIndex, offset: u64) -> Result<Object> {
3770    read_object_at_depth(idx, offset, 0)
3771}
3772
3773/// Read a packed object when the pack index entry is already known (batch cat-file workloads).
3774///
3775/// Avoids a second index lookup when iterating objects in offset order.
3776pub fn read_object_from_pack_at_offset(idx: &PackIndex, offset: u64) -> Result<Object> {
3777    read_object_at(idx, offset)
3778}
3779
3780/// [`read_object_from_pack`] with an explicit starting delta-chain depth, used when the read
3781/// itself resolves a delta base from another pack (the chain budget must carry across packs).
3782fn read_object_from_pack_at_depth(idx: &PackIndex, oid: &ObjectId, depth: usize) -> Result<Object> {
3783    let Some(offset) = idx.find_offset(oid)? else {
3784        return Err(Error::ObjectNotFound(oid.to_hex()));
3785    };
3786    read_object_at_depth(idx, offset, depth)
3787}
3788
3789fn read_object_at_depth(idx: &PackIndex, offset: u64, depth: usize) -> Result<Object> {
3790    let mut data = Vec::new();
3791    let kind = read_object_at_depth_into(idx, offset, depth, true, &mut data)?;
3792    Ok(Object::new(kind, data))
3793}
3794
3795fn read_object_at_depth_into(
3796    idx: &PackIndex,
3797    offset: u64,
3798    depth: usize,
3799    populate_delta_lru: bool,
3800    out: &mut Vec<u8>,
3801) -> Result<ObjectKind> {
3802    if depth == 0 {
3803        let pack_id = pack_cache::pack_id_for(&idx.pack_path);
3804        if let Some((kind, data)) = pack_cache::get_delta_base(pack_id, offset) {
3805            out.clear();
3806            out.extend_from_slice(data.as_ref());
3807            return Ok(kind);
3808        }
3809    }
3810    let pack_path = &idx.pack_path;
3811    let objects_dir = idx.pack_path.parent().and_then(Path::parent);
3812    for attempt in 0..2 {
3813        let pack_bytes = read_pack_bytes_cached(pack_path)?;
3814        if !pack_cache::pack_object_count_validated(pack_path) {
3815            match validate_pack_index_object_count(&pack_bytes, idx) {
3816                Ok(()) => pack_cache::mark_pack_object_count_validated(pack_path),
3817                Err(e) if attempt == 0 && pack_bytes_parse_may_be_stale(&e) => {
3818                    if pack_cache::reload_pack_bytes_after_parse_failure(pack_path)? {
3819                        continue;
3820                    }
3821                    return Err(e);
3822                }
3823                Err(e) => return Err(e),
3824            }
3825        }
3826        if depth == 0 && !pack_bytes.uses_positioned_reads() {
3827            if let Some(kind) = read_whole_pack_entry_object_into(idx, offset, &pack_bytes, out) {
3828                return Ok(kind);
3829            }
3830        }
3831        let resolved = with_pack_read_delta_state(depth, |state| {
3832            resolve_pack_object_at_into(
3833                PackIndexHandle::Borrowed(idx),
3834                offset,
3835                objects_dir,
3836                state,
3837                populate_delta_lru,
3838                out,
3839            )
3840        });
3841        match resolved {
3842            Ok(kind) => return Ok(kind),
3843            Err(e) if attempt == 0 && pack_bytes_parse_may_be_stale(&e) => {
3844                if pack_cache::reload_pack_bytes_after_parse_failure(pack_path)? {
3845                    continue;
3846                }
3847                return Err(e);
3848            }
3849            Err(e) => return Err(e),
3850        }
3851    }
3852    unreachable!("at most two read attempts")
3853}
3854
3855/// Resolve an object from already-loaded pack bytes (used by `verify-pack`).
3856pub fn read_object_from_pack_bytes(
3857    pack_bytes: &[u8],
3858    idx: &PackIndex,
3859    oid: &[u8],
3860) -> Result<Object> {
3861    validate_pack_index_object_count(pack_bytes, idx)?;
3862    let id = ObjectId::from_bytes(oid).map_err(|_| Error::ObjectNotFound(oid_bytes_to_hex(oid)))?;
3863    let entry_offset = idx
3864        .find_offset(&id)?
3865        .ok_or_else(|| Error::ObjectNotFound(oid_bytes_to_hex(oid)))?;
3866    let store = crate::pack_store::PackStore::legacy_for_pack_path(&idx.pack_path);
3867    crate::pack_store::PackStore::with_context(store, || {
3868        let (kind, data) = read_pack_object_at(pack_bytes, entry_offset, idx, None, 0)?;
3869        verify_packed_object_hash(kind, &data, oid)?;
3870        Ok(Object::new(kind, data))
3871    })
3872}
3873
3874fn validate_pack_index_object_count(pack_bytes: &[u8], idx: &PackIndex) -> Result<()> {
3875    if pack_bytes.len() < 12 || &pack_bytes[0..4] != b"PACK" {
3876        return Err(Error::CorruptObject("bad pack header".to_owned()));
3877    }
3878    let count =
3879        u32::from_be_bytes([pack_bytes[8], pack_bytes[9], pack_bytes[10], pack_bytes[11]]) as usize;
3880    if count != idx.len() {
3881        return Err(Error::CorruptObject(format!(
3882            "pack object count mismatch: pack has {count}, index has {}",
3883            idx.len()
3884        )));
3885    }
3886    Ok(())
3887}
3888
3889fn verify_packed_object_hash(kind: ObjectKind, data: &[u8], expected_oid: &[u8]) -> Result<()> {
3890    let algo = HashAlgo::from_len(expected_oid.len()).ok_or_else(|| {
3891        Error::CorruptObject(format!(
3892            "unsupported packed object id width: {}",
3893            expected_oid.len()
3894        ))
3895    })?;
3896    let actual = hash_object(algo, kind, data)?;
3897    if actual.as_bytes() != expected_oid {
3898        return Err(Error::CorruptObject(format!(
3899            "packed object {} hashes to {}",
3900            oid_bytes_to_hex(expected_oid),
3901            actual.to_hex()
3902        )));
3903    }
3904    Ok(())
3905}
3906
3907/// Options controlling which local packs participate in a lookup pass.
3908#[derive(Clone, Copy, Debug, Default)]
3909pub struct PackLookupOptions {
3910    /// When true, skip pack indexes named in the active multi-pack-index (MIDX lookup runs separately).
3911    pub skip_midx_covered_packs: bool,
3912    /// When true, search only pack indexes named in the active MIDX (rescue pass after a MIDX miss).
3913    pub only_midx_covered_packs: bool,
3914}
3915
3916impl PackLookupOptions {
3917    /// Search every local pack (including MIDX-covered indexes).
3918    pub const ALL_LOCAL_PACKS: Self = Self {
3919        skip_midx_covered_packs: false,
3920        only_midx_covered_packs: false,
3921    };
3922
3923    /// Search only MIDX-listed pack indexes (Git redundant-pack rescue when MIDX lookup missed).
3924    pub const MIDX_COVERED_PACKS: Self = Self {
3925        skip_midx_covered_packs: false,
3926        only_midx_covered_packs: true,
3927    };
3928
3929    /// Whether the single-pack and last-hit-pack shortcuts may run before the full scan.
3930    ///
3931    /// Those shortcuts probe a pack without consulting the MIDX filter, so a filtered pass
3932    /// would re-probe packs the other pass owns (every loose-object read pays both passes).
3933    fn allows_pack_shortcuts(self) -> bool {
3934        !self.skip_midx_covered_packs && !self.only_midx_covered_packs
3935    }
3936}
3937
3938/// Pick one pack index for monotonic commit reads (single pack, or commit pack + blob promisor).
3939fn select_single_local_pack_index(
3940    indexes: &[Arc<PackIndex>],
3941    commit_hint: Option<&crate::objects::ObjectId>,
3942) -> Result<Option<Arc<PackIndex>>> {
3943    if indexes.is_empty() {
3944        return Ok(None);
3945    }
3946    if indexes.len() == 1 {
3947        return Ok(Some(Arc::clone(&indexes[0])));
3948    }
3949    if let Some(oid) = commit_hint {
3950        let mut found: Option<Arc<PackIndex>> = None;
3951        for idx in indexes {
3952            if idx.find_offset(oid)?.is_some() {
3953                if found.is_some() {
3954                    return Ok(None);
3955                }
3956                found = Some(Arc::clone(idx));
3957            }
3958        }
3959        if found.is_some() {
3960            return Ok(found);
3961        }
3962    }
3963    Ok(None)
3964}
3965
3966/// Sorted `cat-file --batch` reader for repos with exactly one local pack (hot-path benchmarks).
3967///
3968/// Keeps a pack index cursor so probes in non-decreasing OID order avoid repeated binary search.
3969pub struct SingleLocalPackReader {
3970    idx: Arc<PackIndex>,
3971    pack_bytes: Arc<PackData>,
3972    pack_id: u32,
3973    cursor: usize,
3974    info_cache: HashMap<u64, ObjectInfo>,
3975    /// Created on first header/size probe; full object reads never touch it, so opening
3976    /// a reader per [`crate::odb::Odb::read`] must not pay for an inflater.
3977    delta_scratch: Option<ZlibInflateScratch>,
3978    commit_header_buf: Vec<u8>,
3979}
3980
3981fn objects_dir_has_loose_objects(objects_dir: &Path) -> bool {
3982    let Ok(entries) = std::fs::read_dir(objects_dir) else {
3983        return false;
3984    };
3985    for entry in entries.flatten() {
3986        let name = entry.file_name();
3987        let name = name.to_string_lossy();
3988        if name.len() != 2 || !name.bytes().all(|b| b.is_ascii_hexdigit()) {
3989            continue;
3990        }
3991        let Ok(hex_entries) = std::fs::read_dir(entry.path()) else {
3992            continue;
3993        };
3994        if hex_entries.flatten().any(|e| e.path().is_file()) {
3995            return true;
3996        }
3997    }
3998    false
3999}
4000
4001impl SingleLocalPackReader {
4002    /// Open when [`crate::pack_store::PackStore`] read context matches and one local pack index exists.
4003    ///
4004    /// # Errors
4005    ///
4006    /// Propagates pack directory read failures from index loading.
4007    pub fn open(objects_dir: &Path) -> Result<Option<Self>> {
4008        Self::open_with_options(objects_dir, false, None)
4009    }
4010
4011    /// Open when one local pack exists, optionally ignoring loose objects (commit parent walks).
4012    pub fn open_allow_loose(objects_dir: &Path) -> Result<Option<Self>> {
4013        Self::open_with_options(objects_dir, true, None)
4014    }
4015
4016    /// Like [`Self::open_allow_loose`], but when several packs exist (e.g. promisor blob
4017    /// packs from partial clone), select the pack that contains `commit_hint`.
4018    pub(crate) fn open_allow_loose_for_commits(
4019        objects_dir: &Path,
4020        commit_hint: &crate::objects::ObjectId,
4021    ) -> Result<Option<Self>> {
4022        Self::open_with_options(objects_dir, true, Some(commit_hint))
4023    }
4024
4025    /// Open a reader bound to a specific on-disk pack index (bitmap / pack-order walks).
4026    pub(crate) fn open_for_pack_index(
4027        objects_dir: &Path,
4028        idx: Arc<PackIndex>,
4029    ) -> Result<Option<Self>> {
4030        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
4031            return Ok(None);
4032        }
4033        pack_cache::sync_thread_local_with_store(&crate::pack_store::PackStore::active_for_reads());
4034        let pack_bytes = read_pack_bytes_cached(&idx.pack_path)?;
4035        validate_pack_index_object_count(pack_bytes.as_ref(), idx.as_ref())?;
4036        pack_cache::mark_pack_object_count_validated(&idx.pack_path);
4037        pack_cache::remember_pack_hit(objects_dir, &idx);
4038        let pack_id = pack_cache::pack_id_for(&idx.pack_path);
4039        Ok(Some(Self {
4040            idx,
4041            pack_bytes,
4042            pack_id,
4043            cursor: 0,
4044            info_cache: HashMap::new(),
4045            delta_scratch: None,
4046            commit_header_buf: Vec::new(),
4047        }))
4048    }
4049
4050    /// Object kind of every entry starting at `offsets` (ascending), from entry headers alone.
4051    ///
4052    /// A delta takes the kind of its base, so nothing is inflated. The result is `None` for a
4053    /// delta whose base is not an earlier entry of `offsets` (a REF_DELTA base stored later in
4054    /// the pack or outside it), and for deltas on such entries; callers resolve those another way.
4055    ///
4056    /// # Errors
4057    ///
4058    /// [`Error::CorruptObject`] when an offset lies outside the pack or an entry header is
4059    /// malformed.
4060    pub(crate) fn object_kinds_at_sorted_offsets(
4061        &self,
4062        offsets: &[u64],
4063    ) -> Result<Vec<Option<ObjectKind>>> {
4064        let pack_bytes = self.pack_bytes.as_ref();
4065        let hash_bytes = self.idx.hash_bytes();
4066        let mut kinds: Vec<Option<ObjectKind>> = Vec::with_capacity(offsets.len());
4067        for (rank, &offset) in offsets.iter().enumerate() {
4068            let mut pos = usize::try_from(offset)
4069                .map_err(|_| Error::CorruptObject("pack offset out of range".to_owned()))?;
4070            let (packed_type, _) = parse_pack_object_header(pack_bytes, &mut pos)?;
4071            let base_offset = match packed_type {
4072                PackedType::OfsDelta => Some(parse_ofs_delta_base(pack_bytes, &mut pos, offset)?),
4073                PackedType::RefDelta => {
4074                    let raw = pack_bytes.get(pos..pos + hash_bytes).ok_or_else(|| {
4075                        Error::CorruptObject("truncated ref-delta base OID".to_owned())
4076                    })?;
4077                    self.idx.find_offset(&ObjectId::from_bytes(raw)?)?
4078                }
4079                _ => {
4080                    kinds.push(Some(packed_type_to_kind(packed_type)?));
4081                    continue;
4082                }
4083            };
4084            let base_kind = base_offset
4085                .and_then(|base| offsets.binary_search(&base).ok())
4086                .filter(|&base_rank| base_rank < rank)
4087                .and_then(|base_rank| kinds[base_rank]);
4088            kinds.push(base_kind);
4089        }
4090        Ok(kinds)
4091    }
4092
4093    /// Whether `oid` is indexed in this reader's pack.
4094    pub(crate) fn contains_oid(&self, oid: &crate::objects::ObjectId) -> Result<bool> {
4095        Ok(self.idx.find_offset(oid)?.is_some())
4096    }
4097
4098    fn open_with_options(
4099        objects_dir: &Path,
4100        allow_loose: bool,
4101        commit_hint: Option<&crate::objects::ObjectId>,
4102    ) -> Result<Option<Self>> {
4103        if !crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
4104            return Ok(None);
4105        }
4106        pack_cache::sync_thread_local_with_store(&crate::pack_store::PackStore::active_for_reads());
4107        let memo_eligible = allow_loose && commit_hint.is_none();
4108        if memo_eligible {
4109            if let Some((idx, pack_bytes, pack_id)) = pack_cache::single_reader_for(objects_dir) {
4110                return Ok(Some(Self {
4111                    idx,
4112                    pack_bytes,
4113                    pack_id,
4114                    cursor: 0,
4115                    info_cache: HashMap::new(),
4116                    delta_scratch: None,
4117                    commit_header_buf: Vec::new(),
4118                }));
4119            }
4120        }
4121        let indexes = pack_indexes_for_lookup(objects_dir, PackLookupOptions::ALL_LOCAL_PACKS)?;
4122        let idx = match select_single_local_pack_index(&indexes, commit_hint)? {
4123            Some(idx) => idx,
4124            None => return Ok(None),
4125        };
4126        if !allow_loose && objects_dir_has_loose_objects(objects_dir) {
4127            return Ok(None);
4128        }
4129        let pack_bytes = read_pack_bytes_cached(&idx.pack_path)?;
4130        validate_pack_index_object_count(pack_bytes.as_ref(), idx.as_ref())?;
4131        pack_cache::mark_pack_object_count_validated(&idx.pack_path);
4132        pack_cache::remember_pack_hit(objects_dir, &idx);
4133        let pack_id = pack_cache::pack_id_for(&idx.pack_path);
4134        if memo_eligible && indexes.len() == 1 {
4135            pack_cache::remember_single_reader(objects_dir, &idx, &pack_bytes, pack_id);
4136        }
4137        Ok(Some(Self {
4138            idx,
4139            pack_bytes,
4140            pack_id,
4141            cursor: 0,
4142            info_cache: HashMap::new(),
4143            delta_scratch: None,
4144            commit_header_buf: Vec::new(),
4145        }))
4146    }
4147
4148    /// Inflate the start of commit `oid` into an internal buffer (undeltified entries only).
4149    ///
4150    /// Returns `None` when the object is missing, not a commit, or stored as a delta.
4151    ///
4152    /// # Errors
4153    ///
4154    /// Propagates pack index and zlib errors.
4155    pub fn read_commit_author_prefix(&mut self, oid: &ObjectId) -> Result<Option<&[u8]>> {
4156        self.read_commit_author_prefix_at(oid, true)
4157    }
4158
4159    /// Like [`Self::read_commit_author_prefix`], but pack index probes may be out of OID order.
4160    pub(crate) fn read_commit_author_prefix_unordered(
4161        &mut self,
4162        oid: &ObjectId,
4163    ) -> Result<Option<&[u8]>> {
4164        self.read_commit_author_prefix_at(oid, false)
4165    }
4166
4167    /// Inflate the start of the commit at pack index row `row` (pack-order / bitmap walks).
4168    pub(crate) fn read_commit_author_prefix_at_pack_row(
4169        &mut self,
4170        row: usize,
4171    ) -> Result<Option<&[u8]>> {
4172        self.cursor = row;
4173        let offset = self.idx.offset_at(row)?;
4174        self.read_commit_author_prefix_at_pack_offset(offset)
4175    }
4176
4177    fn read_commit_author_prefix_at(
4178        &mut self,
4179        oid: &ObjectId,
4180        monotonic: bool,
4181    ) -> Result<Option<&[u8]>> {
4182        let offset = if monotonic {
4183            self.idx.find_offset_monotonic(oid, &mut self.cursor)?
4184        } else {
4185            self.idx.find_offset(oid)?
4186        };
4187        let Some(offset) = offset else {
4188            return Ok(None);
4189        };
4190        self.read_commit_author_prefix_at_pack_offset(offset)
4191    }
4192
4193    fn read_commit_author_prefix_at_pack_offset(&mut self, offset: u64) -> Result<Option<&[u8]>> {
4194        let mut compressed = Vec::new();
4195        let (pack_bytes, mut pos) =
4196            pack_entry_bytes(&self.idx, offset, &self.pack_bytes, &mut compressed)?;
4197        if pos >= pack_bytes.len() {
4198            return Ok(None);
4199        }
4200        let (packed_type, _) = match parse_pack_object_header(pack_bytes, &mut pos) {
4201            Ok(v) => v,
4202            Err(_) => return Ok(None),
4203        };
4204        if !matches!(packed_type, PackedType::Commit) {
4205            return Ok(None);
4206        }
4207        self.commit_header_buf.clear();
4208        self.delta_scratch
4209            .get_or_insert_with(ZlibInflateScratch::default)
4210            .decompress_prefix_into(
4211                &pack_bytes[pos..],
4212                COMMIT_HEADER_READ_PREFIX,
4213                &mut self.commit_header_buf,
4214            )?;
4215        if self.commit_header_buf.is_empty() {
4216            return Ok(None);
4217        }
4218        Ok(Some(&self.commit_header_buf))
4219    }
4220
4221    /// Drop any cached commit header prefix (required before delta / ODB fallbacks).
4222    pub(crate) fn clear_commit_header_prefix(&mut self) {
4223        self.commit_header_buf.clear();
4224    }
4225
4226    fn read_info_at_pack_offset(&mut self, start_offset: u64) -> Result<ObjectInfo> {
4227        if let Some(info) = self.info_cache.get(&start_offset) {
4228            return Ok(*info);
4229        }
4230        let pack_path = self.idx.pack_path.clone();
4231        let info = with_pack_read_delta_state(0, |state| {
4232            self.resolve_info_at_pack_offset_body(start_offset, state)
4233        })?;
4234        self.info_cache.insert(start_offset, info);
4235        remember_pack_read_info(&pack_path, start_offset, info);
4236        Ok(info)
4237    }
4238
4239    fn resolve_info_at_pack_offset_body(
4240        &mut self,
4241        start_offset: u64,
4242        state: &mut DeltaChainState,
4243    ) -> Result<ObjectInfo> {
4244        let pack_bytes = self.pack_bytes.as_ref();
4245        let mut cur_offset = start_offset;
4246        let mut result_size: Option<u64> = None;
4247        loop {
4248            state.visit(self.pack_id, cur_offset)?;
4249            if let Some(base) = self.info_cache.get(&cur_offset) {
4250                if let Some(size) = result_size {
4251                    return Ok(ObjectInfo {
4252                        kind: base.kind,
4253                        size,
4254                    });
4255                }
4256                return Ok(*base);
4257            }
4258
4259            let object_start = cur_offset;
4260            let mut pos = cur_offset as usize;
4261            let (packed_type, header_size) = parse_pack_object_header(pack_bytes, &mut pos)?;
4262            match packed_type {
4263                PackedType::Commit | PackedType::Tree | PackedType::Blob | PackedType::Tag => {
4264                    let kind = packed_type_to_kind(packed_type)?;
4265                    let undeltified = ObjectInfo {
4266                        kind,
4267                        size: header_size,
4268                    };
4269                    self.info_cache.insert(cur_offset, undeltified);
4270                    let size = result_size.unwrap_or(header_size);
4271                    return Ok(ObjectInfo { kind, size });
4272                }
4273                PackedType::OfsDelta => {
4274                    state.next_hop()?;
4275                    let base_offset = parse_ofs_delta_base(pack_bytes, &mut pos, object_start)?;
4276                    if result_size.is_none() {
4277                        result_size = Some(
4278                            self.delta_scratch
4279                                .get_or_insert_with(ZlibInflateScratch::default)
4280                                .read_pack_delta_result_size(pack_bytes, &mut pos)?,
4281                        );
4282                    }
4283                    cur_offset = base_offset;
4284                }
4285                PackedType::RefDelta => {
4286                    state.next_hop()?;
4287                    let hb = self.idx.hash_bytes();
4288                    if pos + hb > pack_bytes.len() {
4289                        return Err(Error::CorruptObject(
4290                            "truncated ref-delta base OID".to_owned(),
4291                        ));
4292                    }
4293                    let base_raw = &pack_bytes[pos..pos + hb];
4294                    pos += hb;
4295                    if result_size.is_none() {
4296                        result_size = Some(
4297                            self.delta_scratch
4298                                .get_or_insert_with(ZlibInflateScratch::default)
4299                                .read_pack_delta_result_size(pack_bytes, &mut pos)?,
4300                        );
4301                    }
4302                    let base_oid = ObjectId::from_bytes(base_raw)?;
4303                    let Some(base_offset) = self.idx.find_offset(&base_oid)? else {
4304                        return Err(Error::ObjectNotFound(base_oid.to_hex()));
4305                    };
4306                    cur_offset = base_offset;
4307                }
4308            }
4309        }
4310    }
4311
4312    /// Kind and size for `oid` without loading the object body.
4313    ///
4314    /// # Errors
4315    ///
4316    /// Same as [`read_object_info_from_pack_at_offset`].
4317    pub fn read_info(&mut self, oid: &ObjectId) -> Result<ObjectInfo> {
4318        let Some(offset) = self.idx.find_offset_monotonic(oid, &mut self.cursor)? else {
4319            return Err(Error::ObjectNotFound(oid.to_hex()));
4320        };
4321        if let Some(info) =
4322            read_whole_pack_entry_info_fast(&self.idx, offset, self.pack_bytes.as_ref())
4323        {
4324            self.info_cache.insert(offset, info);
4325            return Ok(info);
4326        }
4327        self.read_info_at_pack_offset(offset)
4328    }
4329
4330    /// Decompress the object for `oid`.
4331    ///
4332    /// # Errors
4333    ///
4334    /// Same as [`read_object_from_pack_at_offset`].
4335    pub fn read_object(&mut self, oid: &ObjectId) -> Result<Object> {
4336        let Some(offset) = self.idx.find_offset_monotonic(oid, &mut self.cursor)? else {
4337            return Err(Error::ObjectNotFound(oid.to_hex()));
4338        };
4339        if !self.pack_bytes.uses_positioned_reads() {
4340            if let Some(obj) =
4341                read_whole_pack_entry_object_fast(&self.idx, offset, &self.pack_bytes)
4342            {
4343                return Ok(obj);
4344            }
4345        }
4346        read_object_from_pack_at_offset(&self.idx, offset)
4347    }
4348
4349    /// Like [`Self::read_object`], but skip an object the pack stores whole with a payload
4350    /// larger than `limit`, returning [`BoundedRead::Larger`] without inflating it.
4351    ///
4352    /// Deltas are always read: reconstructing one needs the whole object in memory anyway.
4353    ///
4354    /// # Errors
4355    ///
4356    /// Same as [`Self::read_object`].
4357    pub(crate) fn read_object_unless_whole_larger(
4358        &mut self,
4359        oid: &ObjectId,
4360        limit: u64,
4361    ) -> Result<BoundedRead> {
4362        let Some(offset) = self.idx.find_offset_monotonic(oid, &mut self.cursor)? else {
4363            return Err(Error::ObjectNotFound(oid.to_hex()));
4364        };
4365        let mut pos = usize::try_from(offset)
4366            .map_err(|_| Error::CorruptObject(format!("pack offset {offset} overflows")))?;
4367        if let Ok((packed_type, size)) = parse_pack_object_header(&self.pack_bytes, &mut pos) {
4368            let whole = !matches!(packed_type, PackedType::OfsDelta | PackedType::RefDelta);
4369            if whole && size > limit {
4370                return Ok(BoundedRead::Larger);
4371            }
4372        }
4373        if let Some(obj) =
4374            read_whole_pack_entry_object_fast(&self.idx, offset, self.pack_bytes.as_ref())
4375        {
4376            return Ok(BoundedRead::Object(obj));
4377        }
4378        read_object_from_pack_at_offset(&self.idx, offset).map(BoundedRead::Object)
4379    }
4380
4381    /// Like [`Self::read_object`], inflating into `out` and preserving its capacity when possible.
4382    ///
4383    /// Delta bases resolved along the way are not added to the delta-base cache.
4384    pub fn read_object_into(&mut self, oid: &ObjectId, out: &mut Vec<u8>) -> Result<ObjectKind> {
4385        self.read_object_into_with_delta_policy(oid, false, out)
4386    }
4387
4388    /// [`Self::read_object_into`], keeping resolved delta bases in the delta-base cache when
4389    /// `populate_delta_lru` is set.
4390    pub(crate) fn read_object_into_with_delta_policy(
4391        &mut self,
4392        oid: &ObjectId,
4393        populate_delta_lru: bool,
4394        out: &mut Vec<u8>,
4395    ) -> Result<ObjectKind> {
4396        let Some(offset) = self.idx.find_offset_monotonic(oid, &mut self.cursor)? else {
4397            return Err(Error::ObjectNotFound(oid.to_hex()));
4398        };
4399        if !self.pack_bytes.uses_positioned_reads() {
4400            if let Some(kind) =
4401                read_whole_pack_entry_object_into(&self.idx, offset, &self.pack_bytes, out)
4402            {
4403                return Ok(kind);
4404            }
4405        }
4406        read_object_at_depth_into(&self.idx, offset, 0, populate_delta_lru, out)
4407    }
4408
4409    /// Pack file offset for `oid` in this reader's index, if present.
4410    ///
4411    /// # Errors
4412    ///
4413    /// Propagates pack index lookup errors.
4414    pub fn pack_offset(&self, oid: &ObjectId) -> Result<Option<u64>> {
4415        self.idx.find_offset(oid)
4416    }
4417}
4418fn pack_indexes_for_lookup(
4419    objects_dir: &Path,
4420    opts: PackLookupOptions,
4421) -> Result<Vec<Arc<PackIndex>>> {
4422    let indexes = read_local_pack_indexes_cached(objects_dir)?;
4423    if !opts.skip_midx_covered_packs && !opts.only_midx_covered_packs {
4424        return Ok(indexes);
4425    }
4426    let covered = pack_cache::midx_covered_pack_names(objects_dir);
4427    if covered.is_empty() {
4428        return if opts.only_midx_covered_packs {
4429            Ok(Vec::new())
4430        } else {
4431            Ok(indexes)
4432        };
4433    }
4434    Ok(indexes
4435        .into_iter()
4436        .filter(|idx| {
4437            idx.idx_path
4438                .file_name()
4439                .and_then(|n| n.to_str())
4440                .map(|name| {
4441                    if opts.only_midx_covered_packs {
4442                        covered.contains(name)
4443                    } else {
4444                        !covered.contains(name)
4445                    }
4446                })
4447                .unwrap_or(!opts.only_midx_covered_packs)
4448        })
4449        .collect())
4450}
4451
4452/// Search all pack indexes in `objects_dir` for the given OID and read it.
4453///
4454/// When more than one pack contains `oid` (a redundant copy), a read failure in
4455/// one pack — e.g. a corrupted delta base or zlib stream — is not fatal: Git
4456/// retries the remaining sources before giving up, so an intact redundant pack
4457/// still satisfies the read (t5303 pack-corruption-resilience). Only when every
4458/// pack that names `oid` fails to produce it do we surface the last error.
4459///
4460/// # Errors
4461///
4462/// Returns [`Error::ObjectNotFound`] if no pack contains the OID.
4463pub fn read_object_from_packs(objects_dir: &Path, oid: &ObjectId) -> Result<Object> {
4464    if crate::pack_store::PackStore::has_context() {
4465        return read_object_from_packs_in_context(objects_dir, oid);
4466    }
4467    let store = crate::pack_store::PackStore::legacy_for_objects_dir(objects_dir);
4468    crate::pack_store::PackStore::with_context(store, || {
4469        read_object_from_packs_in_context(objects_dir, oid)
4470    })
4471}
4472
4473/// Like [`read_object_from_packs`], inflating into `buf` and reusing its capacity.
4474///
4475/// # Errors
4476///
4477/// Same as [`read_object_from_packs`].
4478pub fn read_object_from_packs_into(
4479    objects_dir: &Path,
4480    oid: &ObjectId,
4481    buf: &mut Vec<u8>,
4482) -> Result<ObjectKind> {
4483    if crate::pack_store::PackStore::has_context() {
4484        return read_object_from_packs_in_context_into(objects_dir, oid, buf);
4485    }
4486    let store = crate::pack_store::PackStore::legacy_for_objects_dir(objects_dir);
4487    crate::pack_store::PackStore::with_context(store, || {
4488        read_object_from_packs_in_context_into(objects_dir, oid, buf)
4489    })
4490}
4491
4492/// Search local packs for `oid` and return its kind and uncompressed size.
4493///
4494/// # Errors
4495///
4496/// Returns [`Error::ObjectNotFound`] when no pack lists `oid`.
4497pub fn read_object_info_from_packs(objects_dir: &Path, oid: &ObjectId) -> Result<ObjectInfo> {
4498    match try_read_object_info_from_packs_with_options(
4499        objects_dir,
4500        oid,
4501        PackLookupOptions::ALL_LOCAL_PACKS,
4502    ) {
4503        Ok(info) => Ok(info),
4504        Err(err @ Error::ObjectNotFound(_)) => {
4505            if reprepare_pack_directory_on_miss(objects_dir)? {
4506                try_read_object_info_from_packs_with_options(
4507                    objects_dir,
4508                    oid,
4509                    PackLookupOptions::ALL_LOCAL_PACKS,
4510                )
4511            } else {
4512                Err(err)
4513            }
4514        }
4515        Err(err) => Err(err),
4516    }
4517}
4518
4519/// Search local packs for `oid` without re-preparing the pack directory listing.
4520pub(crate) fn try_read_object_info_from_packs_with_options(
4521    objects_dir: &Path,
4522    oid: &ObjectId,
4523    opts: PackLookupOptions,
4524) -> Result<ObjectInfo> {
4525    if crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
4526        pack_cache::sync_thread_local_with_store(&crate::pack_store::PackStore::active_for_reads());
4527    }
4528    if opts.allows_pack_shortcuts() {
4529        if let Some(info) = pack_cache::try_read_info_from_hinted_pack(objects_dir, oid)? {
4530            return Ok(info);
4531        }
4532    }
4533    let indexes = pack_indexes_for_lookup(objects_dir, opts)?;
4534    let mut last_err: Option<Error> = None;
4535    for idx in &indexes {
4536        let Some(offset) = idx.find_offset(oid)? else {
4537            continue;
4538        };
4539        match read_object_info_from_pack_at_offset(idx, offset) {
4540            Ok(info) => {
4541                pack_cache::remember_pack_hit(objects_dir, idx);
4542                return Ok(info);
4543            }
4544            Err(Error::ObjectNotFound(_)) => {}
4545            Err(err) => last_err = Some(err),
4546        }
4547    }
4548    Err(last_err.unwrap_or_else(|| Error::ObjectNotFound(oid.to_hex())))
4549}
4550
4551/// Search local packs for `oid` without re-preparing the pack directory listing.
4552pub(crate) fn try_read_object_from_packs_with_options(
4553    objects_dir: &Path,
4554    oid: &ObjectId,
4555    opts: PackLookupOptions,
4556) -> Result<Object> {
4557    if crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
4558        pack_cache::sync_thread_local_with_store(&crate::pack_store::PackStore::active_for_reads());
4559    }
4560    if opts.allows_pack_shortcuts() {
4561        if let Some(obj) = pack_cache::try_read_object_via_single_pack_reader(objects_dir, oid)? {
4562            return Ok(obj);
4563        }
4564        if let Some(obj) = pack_cache::try_read_from_hinted_pack(objects_dir, oid)? {
4565            return Ok(obj);
4566        }
4567    }
4568    let indexes = pack_indexes_for_lookup(objects_dir, opts)?;
4569    let mut last_err: Option<Error> = None;
4570    for idx in &indexes {
4571        let Some(offset) = pack_cache::find_offset_in_pack_read_context(objects_dir, idx, oid)?
4572        else {
4573            continue;
4574        };
4575        match read_object_from_pack_at_offset(idx, offset) {
4576            Ok(obj) => {
4577                pack_cache::remember_pack_hit(objects_dir, idx);
4578                return Ok(obj);
4579            }
4580            // The object is missing from this particular pack despite the index
4581            // claim — keep looking in the others.
4582            Err(Error::ObjectNotFound(_)) => {}
4583            // The pack copy is unreadable (corrupt delta/zlib/header). A redundant
4584            // pack may still hold an intact copy, so remember the error and retry.
4585            Err(err) => last_err = Some(err),
4586        }
4587    }
4588    Err(last_err.unwrap_or_else(|| Error::ObjectNotFound(oid.to_hex())))
4589}
4590
4591/// Like [`try_read_object_from_packs_with_options`], writing the payload into `buf`.
4592///
4593/// `populate_delta_lru` controls whether delta bases resolved on the way are kept in the
4594/// shared delta-base cache (as [`try_read_object_from_packs_with_options`] always does).
4595pub(crate) fn try_read_object_from_packs_with_options_into(
4596    objects_dir: &Path,
4597    oid: &ObjectId,
4598    opts: PackLookupOptions,
4599    populate_delta_lru: bool,
4600    buf: &mut Vec<u8>,
4601) -> Result<ObjectKind> {
4602    if crate::pack_store::PackStore::read_context_matches_objects_dir(objects_dir) {
4603        pack_cache::sync_thread_local_with_store(&crate::pack_store::PackStore::active_for_reads());
4604    }
4605    if opts.allows_pack_shortcuts() {
4606        if let Some(kind) = pack_cache::try_read_object_via_single_pack_reader_into(
4607            objects_dir,
4608            oid,
4609            populate_delta_lru,
4610            buf,
4611        )? {
4612            return Ok(kind);
4613        }
4614        if let Some(kind) =
4615            pack_cache::try_read_from_hinted_pack_into(objects_dir, oid, populate_delta_lru, buf)?
4616        {
4617            return Ok(kind);
4618        }
4619    }
4620    let indexes = pack_indexes_for_lookup(objects_dir, opts)?;
4621    let mut last_err: Option<Error> = None;
4622    for idx in &indexes {
4623        let Some(offset) = pack_cache::find_offset_in_pack_read_context(objects_dir, idx, oid)?
4624        else {
4625            continue;
4626        };
4627        match read_object_at_depth_into(idx, offset, 0, populate_delta_lru, buf) {
4628            Ok(kind) => {
4629                pack_cache::remember_pack_hit(objects_dir, idx);
4630                return Ok(kind);
4631            }
4632            Err(Error::ObjectNotFound(_)) => {}
4633            Err(err) => last_err = Some(err),
4634        }
4635    }
4636    Err(last_err.unwrap_or_else(|| Error::ObjectNotFound(oid.to_hex())))
4637}
4638
4639/// Whether `idx_path` uses the legacy v1 pack-index format (no `PACK` signature).
4640#[must_use]
4641pub fn pack_index_is_v1(idx_path: &Path) -> bool {
4642    fs::read(idx_path)
4643        .ok()
4644        .is_some_and(|bytes| bytes.len() >= 4 && bytes[0..4] != [0xff, b't', b'O', b'c'])
4645}
4646
4647/// Resolve `oid` from local packs via the process-wide pack cache (cached `.idx`
4648/// parses, cached pack bytes, fanout binary search, delta-base cache).
4649///
4650/// Returns `Ok(None)` when no local pack index names the object. When a pack
4651/// copy fails to decode from a v1 (legacy) index, returns an empty blob placeholder
4652/// (historical pack-objects behavior). Any other decode failure invokes
4653/// `alternate_read` once before trying the next pack.
4654///
4655/// # Errors
4656///
4657/// Returns [`Error::Io`] when pack indexes cannot be enumerated.
4658pub fn try_read_object_from_local_packs_cached(
4659    objects_dir: &Path,
4660    oid: &ObjectId,
4661    mut alternate_read: impl FnMut() -> Result<Object>,
4662) -> Result<Option<Object>> {
4663    let indexes = read_local_pack_indexes_cached(objects_dir)?;
4664    for idx in &indexes {
4665        if idx.find_offset(oid)?.is_none() {
4666            continue;
4667        }
4668        match read_object_from_pack(idx, oid) {
4669            Ok(obj) => return Ok(Some(obj)),
4670            Err(_) if pack_index_is_v1(&idx.idx_path) => {
4671                return Ok(Some(Object::new(ObjectKind::Blob, Vec::new())));
4672            }
4673            Err(_) => {
4674                if let Ok(obj) = alternate_read() {
4675                    return Ok(Some(obj));
4676                }
4677            }
4678        }
4679    }
4680    Ok(None)
4681}
4682
4683/// When `oid` is stored as a delta in a pack, return its delta base object id.
4684/// Returns [`None`] for loose objects and for non-delta packed objects.
4685/// Parse a packed delta entry and inflate its instruction stream when the base OID is in `in_pack`.
4686fn parse_in_pack_delta_reuse_candidate(
4687    idx: &PackIndex,
4688    pack_bytes: &[u8],
4689    entry_offset: u64,
4690    in_pack: &HashSet<ObjectId>,
4691) -> Result<Option<(ObjectId, Vec<u8>)>> {
4692    let hb = idx.hash_bytes();
4693    let mut p = entry_offset as usize;
4694    let (packed_type, size) = parse_pack_object_header(pack_bytes, &mut p)?;
4695    let base = match packed_type {
4696        PackedType::RefDelta => {
4697            if p + hb > pack_bytes.len() {
4698                return Err(Error::CorruptObject(
4699                    "truncated ref-delta base oid while scanning for reuse".to_owned(),
4700                ));
4701            }
4702            let bo = ObjectId::from_bytes(&pack_bytes[p..p + hb])?;
4703            p += hb;
4704            bo
4705        }
4706        PackedType::OfsDelta => {
4707            let base_off = parse_ofs_delta_base(pack_bytes, &mut p, entry_offset)?;
4708            let Some(pos) = idx.find_position_by_offset_sorted(base_off)? else {
4709                return Ok(None);
4710            };
4711            let base_oid_bytes = idx.oid_at(pos);
4712            if base_oid_bytes.len() != hb {
4713                return Ok(None);
4714            }
4715            ObjectId::from_bytes(base_oid_bytes)?
4716        }
4717        _ => return Ok(None),
4718    };
4719    if !in_pack.contains(&base) {
4720        return Ok(None);
4721    }
4722    let zlib_start = p;
4723    let Some(end_pos) = pack_entry_raw_end(idx, pack_bytes, entry_offset)? else {
4724        return Ok(None);
4725    };
4726    if zlib_start >= end_pos {
4727        return Ok(None);
4728    }
4729    let compressed = &pack_bytes[zlib_start..end_pos];
4730    let mut zpos = 0usize;
4731    let mut scratch = ZlibInflateScratch::default();
4732    let delta = match scratch.decompress_fixed(compressed, &mut zpos, size) {
4733        Ok(d) => d,
4734        Err(_) => return Ok(None),
4735    };
4736    Ok(Some((base, delta)))
4737}
4738
4739/// Like [`parse_in_pack_delta_reuse_candidate`], but returns the zlib payload and header size
4740/// without inflating (for verbatim reuse in a new pack).
4741fn parse_in_pack_delta_reuse_zlib(
4742    idx: &PackIndex,
4743    pack_bytes: &[u8],
4744    entry_offset: u64,
4745    reusable_base: &dyn Fn(&ObjectId) -> bool,
4746) -> Result<Option<(ObjectId, u64, usize, usize)>> {
4747    let start = entry_offset as usize;
4748    let hb = idx.hash_bytes();
4749    let mut p = start;
4750    let (packed_type, size) = parse_pack_object_header(pack_bytes, &mut p)?;
4751    let base = match packed_type {
4752        PackedType::RefDelta => {
4753            if p + hb > pack_bytes.len() {
4754                return Err(Error::CorruptObject(
4755                    "truncated ref-delta base oid while scanning for reuse".to_owned(),
4756                ));
4757            }
4758            let bo = ObjectId::from_bytes(&pack_bytes[p..p + hb])?;
4759            p += hb;
4760            bo
4761        }
4762        PackedType::OfsDelta => {
4763            let base_off = parse_ofs_delta_base(pack_bytes, &mut p, entry_offset)?;
4764            let Some(pos) = idx.find_position_by_offset_sorted(base_off)? else {
4765                return Ok(None);
4766            };
4767            let base_oid_bytes = idx.oid_at(pos);
4768            if base_oid_bytes.len() != hb {
4769                return Ok(None);
4770            }
4771            ObjectId::from_bytes(base_oid_bytes)?
4772        }
4773        _ => return Ok(None),
4774    };
4775    if !reusable_base(&base) {
4776        return Ok(None);
4777    }
4778    let zlib_start = p;
4779    let Some(end_pos) = pack_entry_raw_end(idx, pack_bytes, entry_offset)? else {
4780        return Ok(None);
4781    };
4782    if zlib_start >= end_pos {
4783        return Ok(None);
4784    }
4785    let entry_bytes = &pack_bytes[start..end_pos];
4786    if let Some(crc) = idx.crc32_for_pack_offset(entry_offset)? {
4787        if crc32fast::hash(entry_bytes) != crc {
4788            return Ok(None);
4789        }
4790    }
4791    Ok(Some((base, size, zlib_start, end_pos)))
4792}
4793
4794fn reuse_delta_matches_pack_resolution(
4795    idx: &PackIndex,
4796    target: &ObjectId,
4797    base: ObjectId,
4798    delta: &[u8],
4799) -> bool {
4800    let Ok(base_obj) = read_object_from_pack(idx, &base) else {
4801        return false;
4802    };
4803    let Ok(target_obj) = read_object_from_pack(idx, target) else {
4804        return false;
4805    };
4806    crate::unpack_objects::apply_delta(&base_obj.data, delta)
4807        .ok()
4808        .is_some_and(|applied| applied == target_obj.data)
4809}
4810
4811/// The `(id, pack offset)` of every object of `wanted` that `idx` lists.
4812///
4813/// A set much smaller than the index is looked up id by id, so packing a few objects
4814/// from a large pack does not visit every index entry; larger sets scan the index once.
4815fn wanted_index_entries<'a>(
4816    idx: &PackIndex,
4817    wanted: impl ExactSizeIterator<Item = &'a ObjectId>,
4818    contains: impl Fn(&ObjectId) -> bool,
4819) -> Result<Vec<(ObjectId, u64)>> {
4820    // A lookup costs about log2(entries) comparisons; a scan one set probe per entry.
4821    let lookup_cost = wanted
4822        .len()
4823        .saturating_mul(usize::BITS.saturating_sub(idx.len().leading_zeros()) as usize);
4824    if lookup_cost < idx.len() {
4825        let mut out = Vec::new();
4826        for oid in wanted {
4827            if let Some(offset) = idx.find_offset(oid)? {
4828                out.push((*oid, offset));
4829            }
4830        }
4831        return Ok(out);
4832    }
4833    let mut out = Vec::new();
4834    for entry in idx.iter() {
4835        let Ok(oid) = ObjectId::from_bytes(entry.oid()) else {
4836            continue;
4837        };
4838        if contains(&oid) {
4839            out.push((oid, entry.offset()?));
4840        }
4841    }
4842    Ok(out)
4843}
4844
4845/// Where an object to be packed currently lives among the local packs.
4846#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4847pub(crate) struct PackedSlot {
4848    /// Position of the pack in oldest-first order (only compared for equality).
4849    pub pack: u32,
4850    /// The object is stored whole (not as `OFS_DELTA` / `REF_DELTA`) in that pack.
4851    pub whole: bool,
4852}
4853
4854/// Locate each object of `wanted` in the local packs, oldest pack first, as
4855/// [`collect_packed_delta_reuse_for_send`] does when choosing which copy to reuse.
4856///
4857/// Objects that are not packed are absent from the result.
4858///
4859/// # Errors
4860///
4861/// Returns [`Error::CorruptObject`] when a pack entry header is malformed, or
4862/// [`Error::Io`] when a pack cannot be read.
4863pub(crate) fn locate_packed_objects(
4864    objects_dir: &Path,
4865    wanted: &crate::objects::OidHashSet,
4866) -> Result<crate::objects::OidHashMap<PackedSlot>> {
4867    let mut indexes = read_local_pack_indexes_cached(objects_dir)?;
4868    sort_pack_indexes_oldest_first(&mut indexes);
4869    let mut slots = crate::objects::oid_hash_map_with_capacity(wanted.len());
4870    for (pack, idx) in (0u32..).zip(&indexes) {
4871        let bytes = read_pack_bytes_cached(&idx.pack_path)?;
4872        for (oid, offset) in wanted_index_entries(idx, wanted.iter(), |oid| wanted.contains(oid))? {
4873            if slots.contains_key(&oid) {
4874                continue;
4875            }
4876            let mut pos = usize::try_from(offset)
4877                .map_err(|_| Error::CorruptObject("pack offset exceeds memory".to_owned()))?;
4878            let (packed_type, _) = parse_pack_object_header(&bytes, &mut pos)?;
4879            let whole = !matches!(packed_type, PackedType::OfsDelta | PackedType::RefDelta);
4880            slots.insert(oid, PackedSlot { pack, whole });
4881        }
4882    }
4883    Ok(slots)
4884}
4885
4886/// An on-disk delta that can be copied as it is: base, uncompressed delta size, the pack
4887/// it is stored in, and the zlib stream's byte range in that pack.
4888pub(crate) type OnDiskReuseZlibEntry = (ObjectId, u64, Arc<PackData>, usize, usize);
4889
4890/// Scan local packs once and collect on-disk delta reuse candidates for objects in `send`.
4891///
4892/// An object of `send` that some local pack (oldest first) stores as `REF_DELTA` or
4893/// `OFS_DELTA` is kept when `reusable_base` accepts both it and its base; the result maps it
4894/// to its base id, uncompressed delta size, the pack bytes and the zlib stream's start and end
4895/// offsets in them, so the delta can be copied from the source pack verbatim. Every base down
4896/// a stored chain is itself such a target or was accepted when its target was parsed. Entries
4897/// are CRC-checked when the index provides CRC32.
4898///
4899/// # Errors
4900///
4901/// Returns [`Error::CorruptObject`] when a pack stream is malformed.
4902pub(crate) fn collect_packed_delta_reuse_for_send(
4903    objects_dir: &Path,
4904    send: &HashSet<ObjectId>,
4905    reusable_base: &dyn Fn(&ObjectId) -> bool,
4906) -> Result<HashMap<ObjectId, OnDiskReuseZlibEntry>> {
4907    let mut indexes = read_local_pack_indexes_cached(objects_dir)?;
4908    sort_pack_indexes_oldest_first(&mut indexes);
4909
4910    let mut found: HashMap<ObjectId, OnDiskReuseZlibEntry> = HashMap::new();
4911    for idx in indexes.iter().filter(|idx| idx.hash_bytes() == 20) {
4912        let pack_bytes = read_pack_bytes_cached(&idx.pack_path)?;
4913        for (oid, offset) in wanted_index_entries(idx, send.iter(), |oid| send.contains(oid))? {
4914            if found.contains_key(&oid) || !reusable_base(&oid) {
4915                continue;
4916            }
4917            let Some((base, size, zlib_start, zlib_end)) =
4918                parse_in_pack_delta_reuse_zlib(idx, &pack_bytes, offset, reusable_base)?
4919            else {
4920                continue;
4921            };
4922            found.insert(
4923                oid,
4924                (base, size, Arc::clone(&pack_bytes), zlib_start, zlib_end),
4925            );
4926        }
4927    }
4928    Ok(found)
4929}
4930
4931/// If `oid` is stored as `REF_DELTA` or `OFS_DELTA` in a local pack and its base OID is in
4932/// `packed_set`, return the base OID and the **uncompressed** delta payload (Git binary delta).
4933///
4934/// Callers re-zlib when writing a new pack so we do not depend on copying raw deflate streams.
4935///
4936/// # Errors
4937///
4938/// Returns [`Error::CorruptObject`] when the pack stream is malformed.
4939pub fn packed_ref_delta_reuse_slice(
4940    objects_dir: &Path,
4941    oid: &ObjectId,
4942    packed_set: &HashSet<ObjectId>,
4943) -> Result<Option<(ObjectId, Vec<u8>)>> {
4944    let mut indexes = read_local_pack_indexes_cached(objects_dir)?;
4945    sort_pack_indexes_oldest_first(&mut indexes);
4946    for idx in indexes {
4947        if idx.hash_bytes() != 20 {
4948            continue;
4949        }
4950        let Some(entry_offset) = idx.find_offset(oid)? else {
4951            continue;
4952        };
4953        let pack_bytes = read_pack_bytes_cached(&idx.pack_path)?;
4954        let Some((base, delta)) =
4955            parse_in_pack_delta_reuse_candidate(&idx, &pack_bytes, entry_offset, packed_set)?
4956        else {
4957            continue;
4958        };
4959        if reuse_delta_matches_pack_resolution(&idx, oid, base, &delta) {
4960            return Ok(Some((base, delta)));
4961        }
4962    }
4963    Ok(None)
4964}
4965
4966/// Prefer older packs when the same OID exists as a full object in a fresh repack and as a delta
4967/// in an earlier thin pack (t5316).
4968pub(crate) fn sort_pack_indexes_oldest_first(indexes: &mut [Arc<PackIndex>]) {
4969    indexes.sort_by(|a, b| {
4970        let ta = fs::metadata(&a.pack_path)
4971            .and_then(|m| m.modified())
4972            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
4973        let tb = fs::metadata(&b.pack_path)
4974            .and_then(|m| m.modified())
4975            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
4976        ta.cmp(&tb).then_with(|| a.pack_path.cmp(&b.pack_path))
4977    });
4978}
4979
4980fn sort_pack_indexes_newest_first(indexes: &mut [Arc<PackIndex>]) {
4981    indexes.sort_by(|a, b| {
4982        let ta = fs::metadata(&a.pack_path)
4983            .and_then(|m| m.modified())
4984            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
4985        let tb = fs::metadata(&b.pack_path)
4986            .and_then(|m| m.modified())
4987            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
4988        tb.cmp(&ta).then_with(|| b.pack_path.cmp(&a.pack_path))
4989    });
4990}
4991
4992/// `packed_delta_base_oid` function.
4993pub fn packed_delta_base_oid(objects_dir: &Path, oid: &ObjectId) -> Result<Option<ObjectId>> {
4994    let mut indexes = read_local_pack_indexes_cached(objects_dir)?;
4995    sort_pack_indexes_newest_first(&mut indexes);
4996    for idx in &indexes {
4997        if idx.hash_bytes() != 20 {
4998            continue;
4999        }
5000        let Some(entry_offset) = idx.find_offset(oid)? else {
5001            continue;
5002        };
5003        let pack_bytes = read_pack_bytes_cached(&idx.pack_path)?;
5004        let mut p = entry_offset as usize;
5005        let (packed_type, _) = parse_pack_object_header(&pack_bytes, &mut p)?;
5006        match packed_type {
5007            PackedType::RefDelta => {
5008                let hb = idx.hash_bytes();
5009                if p + hb > pack_bytes.len() {
5010                    return Err(Error::CorruptObject("truncated ref-delta base".to_owned()));
5011                }
5012                return Ok(Some(ObjectId::from_bytes(&pack_bytes[p..p + hb])?));
5013            }
5014            PackedType::OfsDelta => {
5015                let base_off = parse_ofs_delta_base(&pack_bytes, &mut p, entry_offset)?;
5016                return Ok(idx
5017                    .find_position_by_offset_sorted(base_off)?
5018                    .and_then(|pos| ObjectId::from_bytes(idx.oid_at(pos)).ok()));
5019            }
5020            _ => continue,
5021        }
5022    }
5023    Ok(None)
5024}
5025
5026/// End offset (exclusive) of the raw packed bytes for the object at `entry_offset`.
5027///
5028/// Uses the next larger pack offset from the index, or the pack trailer when this is the last object.
5029pub(crate) fn pack_entry_raw_end(
5030    idx: &PackIndex,
5031    pack_bytes: &[u8],
5032    entry_offset: u64,
5033) -> Result<Option<usize>> {
5034    let hb = idx.hash_bytes();
5035    let Some(pack_end) = pack_bytes.len().checked_sub(hb) else {
5036        return Ok(None);
5037    };
5038    let start = entry_offset as usize;
5039    if start >= pack_end {
5040        return Ok(None);
5041    }
5042    Ok(Some(
5043        idx.next_pack_offset_after(entry_offset, pack_end as u64)? as usize,
5044    ))
5045}
5046
5047/// A non-delta object's zlib stream inside a mapped local pack.
5048pub(crate) struct PackedWholeObject {
5049    /// The pack holding the object.
5050    pub(crate) pack: Arc<PackData>,
5051    /// Offset of the object's zlib stream, just past its entry header.
5052    pub(crate) zlib_offset: usize,
5053    /// End of the pack's object data (start of the trailing checksum).
5054    pub(crate) data_end: usize,
5055    /// Object type from the entry header (never a delta type).
5056    pub(crate) kind: ObjectKind,
5057    /// Uncompressed payload size from the entry header.
5058    pub(crate) size: u64,
5059}
5060
5061/// How the local packs store an object, from [`locate_packed_whole_object`].
5062pub(crate) enum PackedEntry {
5063    /// Stored whole in some pack.
5064    Whole(PackedWholeObject),
5065    /// Present in a pack, but not usable whole: stored as a delta, with an unparsable entry
5066    /// header, or failing the pack index CRC when that is checked.
5067    NotWhole,
5068    /// In no local pack.
5069    Absent,
5070}
5071
5072/// Locate `oid` stored whole (not as a delta) in a local pack, without inflating it.
5073///
5074/// Costs one index lookup and entry-header parse per pack holding the object. Packs that
5075/// hold it only as a delta, or whose entry header does not parse, are skipped so another
5076/// pack can serve it whole. With `verify_crc`, an entry whose bytes do not match the pack
5077/// index CRC (v2 indexes) is skipped too, so a corrupt copy is never streamed and a
5078/// redundant intact copy, or the buffered read path, serves the object instead.
5079///
5080/// # Errors
5081///
5082/// Returns [`Error::Io`] when pack indexes or pack files cannot be read.
5083pub(crate) fn locate_packed_whole_object(
5084    objects_dir: &Path,
5085    oid: &ObjectId,
5086    verify_crc: bool,
5087) -> Result<PackedEntry> {
5088    let indexes = read_local_pack_indexes_cached(objects_dir)?;
5089    let mut found = PackedEntry::Absent;
5090    for idx in &indexes {
5091        let Some(entry_offset) = idx.find_offset(oid)? else {
5092            continue;
5093        };
5094        found = PackedEntry::NotWhole;
5095        let pack = read_pack_bytes_cached(&idx.pack_path)?;
5096        let Some(data_end) = pack.len().checked_sub(idx.hash_bytes()) else {
5097            continue;
5098        };
5099        let Ok(mut pos) = usize::try_from(entry_offset) else {
5100            continue;
5101        };
5102        if pos >= data_end {
5103            continue;
5104        }
5105        let Ok((packed_type, size)) = parse_pack_object_header(&pack[..data_end], &mut pos) else {
5106            continue;
5107        };
5108        let Ok(kind) = packed_type_to_kind(packed_type) else {
5109            continue;
5110        };
5111        if verify_crc {
5112            if !packed_entry_matches_index_crc(idx, &pack, entry_offset)? {
5113                continue;
5114            }
5115            // The CRC pass read the whole compressed entry; leave none of it resident so a
5116            // caller streaming the object starts from a clean resident set.
5117            pack.release_resident();
5118        }
5119        return Ok(PackedEntry::Whole(PackedWholeObject {
5120            pack,
5121            zlib_offset: pos,
5122            data_end,
5123            kind,
5124            size,
5125        }));
5126    }
5127    Ok(found)
5128}
5129
5130/// Whether the raw pack entry at `entry_offset` matches the CRC32 its pack index records.
5131///
5132/// Version 1 indexes record no CRC; their entries are accepted (the zlib checksum still
5133/// guards the payload).
5134///
5135/// A large entry is hashed with a bounded window of the pack mapping resident (see
5136/// [`VERIFY_RESIDENT_PACK_BYTES`]), so checking a big blob before streaming it does not pull
5137/// its whole compressed size into the resident set.
5138fn packed_entry_matches_index_crc(
5139    idx: &PackIndex,
5140    pack: &PackData,
5141    entry_offset: u64,
5142) -> Result<bool> {
5143    let Some(recorded) = idx.crc32_for_pack_offset(entry_offset)? else {
5144        return Ok(true);
5145    };
5146    let Some(end) = pack_entry_raw_end(idx, pack, entry_offset)? else {
5147        return Ok(false);
5148    };
5149    let start = usize::try_from(entry_offset)
5150        .map_err(|_| Error::CorruptObject(format!("pack offset {entry_offset} overflows")))?;
5151    if start >= end {
5152        return Ok(false);
5153    }
5154    let budget = ResidentBudget::new(pack, VERIFY_RESIDENT_PACK_BYTES);
5155    Ok(budget.crc32(&pack[start..end]) == recorded)
5156}
5157
5158/// Mapped pack bytes [`packed_entry_matches_index_crc`] lets stay resident while it hashes
5159/// one entry before dropping the mapping's pages.
5160const VERIFY_RESIDENT_PACK_BYTES: usize = 4 * 1024 * 1024;
5161
5162/// When `oid` is stored as a full (non-delta) object in a local pack, return its verbatim packed
5163/// bytes: the varint type+size header followed by the zlib stream.
5164///
5165/// The header of a non-delta pack entry is position-independent, so the returned slice can be
5166/// copied into a new pack unchanged — skipping both inflate and deflate. This is Git
5167/// pack-objects' object reuse for objects that stay full in the output pack.
5168///
5169/// # Parameters
5170/// - `objects_dir` — the repository's `objects/` directory.
5171/// - `oid` — the object to look up.
5172///
5173/// Returns `None` when the object is loose only, stored as a delta, or the repository uses a
5174/// hash width other than SHA-1.
5175///
5176/// # Errors
5177///
5178/// Returns [`Error::Io`] when pack files cannot be read; a malformed candidate entry is skipped
5179/// rather than reported so another pack (or the recompression path) can serve the object.
5180pub fn packed_full_object_slice(objects_dir: &Path, oid: &ObjectId) -> Result<Option<Vec<u8>>> {
5181    Ok(packed_full_object_range(objects_dir, oid)?
5182        .map(|(bytes, start, end)| bytes[start..end].to_vec()))
5183}
5184
5185/// [`packed_full_object_slice`] without the copy: the pack's bytes and the entry's range in
5186/// them, so a pack writer can copy straight from the mapping.
5187pub(crate) fn packed_full_object_range(
5188    objects_dir: &Path,
5189    oid: &ObjectId,
5190) -> Result<Option<(Arc<PackData>, usize, usize)>> {
5191    let mut indexes = read_local_pack_indexes_cached(objects_dir)?;
5192    sort_pack_indexes_newest_first(&mut indexes);
5193    for idx in &indexes {
5194        let Some(entry_offset) = idx.find_offset(oid)? else {
5195            continue;
5196        };
5197        let pack_bytes = read_pack_bytes_cached(&idx.pack_path)?;
5198        let start = entry_offset as usize;
5199        let mut p = start;
5200        let Ok((packed_type, _size)) = parse_pack_object_header(&pack_bytes, &mut p) else {
5201            continue;
5202        };
5203        if matches!(packed_type, PackedType::OfsDelta | PackedType::RefDelta) {
5204            // The same OID may be a full object in another pack; keep scanning.
5205            continue;
5206        }
5207        let Some(end) = pack_entry_raw_end(idx, &pack_bytes, entry_offset)? else {
5208            continue;
5209        };
5210        if end <= start {
5211            continue;
5212        }
5213        let slice = &pack_bytes[start..end];
5214        // Git `check_pack_crc`: verbatim reuse copies bytes unparsed, so guard with the pack
5215        // index's CRC32. A corrupt copy is skipped, letting a redundant pack or the normal
5216        // (validating) read path serve the object instead (t5303).
5217        let recorded_crc = idx.crc32_for_pack_offset(entry_offset)?;
5218        match recorded_crc {
5219            Some(crc) if crc32fast::hash(slice) != crc => continue,
5220            // v1 indexes carry no CRC; verify by inflating and re-hashing the content.
5221            None if read_object_from_pack(idx, oid)
5222                .map(|obj| {
5223                    HashAlgo::from_len(idx.hash_bytes())
5224                        .map(|algo| {
5225                            hash_object(algo, obj.kind, &obj.data)
5226                                .map(|h| h != *oid)
5227                                .unwrap_or(true)
5228                        })
5229                        .unwrap_or(true)
5230                })
5231                .unwrap_or(true) =>
5232            {
5233                continue;
5234            }
5235            _ => {}
5236        }
5237        return Ok(Some((pack_bytes, start, end)));
5238    }
5239    Ok(None)
5240}
5241
5242pub(crate) fn parse_pack_object_header(bytes: &[u8], pos: &mut usize) -> Result<(PackedType, u64)> {
5243    let first = *bytes.get(*pos).ok_or_else(|| {
5244        Error::CorruptObject("unexpected end of pack header while decoding object".to_owned())
5245    })?;
5246    *pos += 1;
5247
5248    let type_code = (first >> 4) & 0x7;
5249    let mut size = (first & 0x0f) as u64;
5250    let mut shift = 4u32;
5251    let mut c = first;
5252    while (c & 0x80) != 0 {
5253        c = *bytes.get(*pos).ok_or_else(|| {
5254            Error::CorruptObject("unexpected end of variable size header".to_owned())
5255        })?;
5256        *pos += 1;
5257        size |= ((c & 0x7f) as u64) << shift;
5258        shift += 7;
5259    }
5260
5261    let packed_type = match type_code {
5262        1 => PackedType::Commit,
5263        2 => PackedType::Tree,
5264        3 => PackedType::Blob,
5265        4 => PackedType::Tag,
5266        6 => PackedType::OfsDelta,
5267        7 => PackedType::RefDelta,
5268        _ => {
5269            return Err(Error::CorruptObject(format!(
5270                "unsupported packed object type {}",
5271                type_code
5272            )))
5273        }
5274    };
5275    Ok((packed_type, size))
5276}
5277
5278/// Dependency of a packed delta object at `object_offset` within `pack_bytes`.
5279#[derive(Debug, Clone, Copy)]
5280pub enum PackedDeltaDependency {
5281    /// OFS_DELTA: base object offset within the same pack.
5282    OfsBase {
5283        /// Pack offset of the base object.
5284        base_offset: u64,
5285    },
5286    /// REF_DELTA: base object id (may live in another pack).
5287    RefBase {
5288        /// OID of the delta base.
5289        base_oid: ObjectId,
5290    },
5291}
5292
5293/// If the object at `object_offset` is a delta, return how it refers to its base.
5294///
5295/// `hash_bytes` is the ref-delta base OID width in this pack (`20` for SHA-1, `32` for SHA-256).
5296pub fn read_packed_delta_dependency(
5297    pack_bytes: &[u8],
5298    object_offset: u64,
5299    hash_bytes: usize,
5300) -> Result<Option<PackedDeltaDependency>> {
5301    let mut pos = object_offset as usize;
5302    let (ty, _) = parse_pack_object_header(pack_bytes, &mut pos)?;
5303    match ty {
5304        PackedType::OfsDelta => {
5305            let base = parse_ofs_delta_base(pack_bytes, &mut pos, object_offset)?;
5306            Ok(Some(PackedDeltaDependency::OfsBase { base_offset: base }))
5307        }
5308        PackedType::RefDelta => {
5309            if pos + hash_bytes > pack_bytes.len() {
5310                return Err(Error::CorruptObject("truncated ref-delta base oid".into()));
5311            }
5312            let base_oid = ObjectId::from_bytes(&pack_bytes[pos..pos + hash_bytes])?;
5313            Ok(Some(PackedDeltaDependency::RefBase { base_oid }))
5314        }
5315        _ => Ok(None),
5316    }
5317}
5318
5319pub(crate) fn parse_ofs_delta_base(bytes: &[u8], pos: &mut usize, this_offset: u64) -> Result<u64> {
5320    let mut c = *bytes
5321        .get(*pos)
5322        .ok_or_else(|| Error::CorruptObject("truncated ofs-delta header".to_owned()))?;
5323    *pos += 1;
5324    let mut value = (c & 0x7f) as u64;
5325    while (c & 0x80) != 0 {
5326        c = *bytes
5327            .get(*pos)
5328            .ok_or_else(|| Error::CorruptObject("truncated ofs-delta header".to_owned()))?;
5329        *pos += 1;
5330        value = ((value + 1) << 7) | (c & 0x7f) as u64;
5331    }
5332    this_offset
5333        .checked_sub(value)
5334        .ok_or_else(|| Error::CorruptObject("invalid ofs-delta base offset".to_owned()))
5335}
5336
5337/// Advance `pos` past one packed object (including zlib payload).
5338///
5339/// `object_start_offset` is the byte offset of this object within the pack file
5340/// (used for `OFS_DELTA` base resolution).
5341/// Raw bytes of one packed object (header + zlib payload) starting at `object_start_offset`.
5342///
5343/// `hash_bytes` is the ref-delta base OID width in this pack (`20` for SHA-1, `32` for SHA-256).
5344pub fn slice_one_pack_object(
5345    bytes: &[u8],
5346    object_start_offset: u64,
5347    hash_bytes: usize,
5348) -> Result<&[u8]> {
5349    let start = object_start_offset as usize;
5350    let mut pos = start;
5351    skip_one_pack_object(bytes, &mut pos, object_start_offset, hash_bytes)?;
5352    Ok(&bytes[start..pos])
5353}
5354
5355/// `skip_one_pack_object` function.
5356pub fn skip_one_pack_object(
5357    bytes: &[u8],
5358    pos: &mut usize,
5359    object_start_offset: u64,
5360    hash_bytes: usize,
5361) -> Result<()> {
5362    let (packed_type, size) = parse_pack_object_header(bytes, pos)?;
5363    let mut scratch = ZlibInflateScratch::default();
5364    match packed_type {
5365        PackedType::Commit | PackedType::Tree | PackedType::Blob | PackedType::Tag => {
5366            scratch.skip_zlib_stream(bytes, pos, size)?;
5367        }
5368        PackedType::RefDelta => {
5369            if *pos + hash_bytes > bytes.len() {
5370                return Err(Error::CorruptObject("truncated ref-delta base oid".into()));
5371            }
5372            *pos += hash_bytes;
5373            scratch.skip_zlib_stream(bytes, pos, size)?;
5374        }
5375        PackedType::OfsDelta => {
5376            let _base_off = parse_ofs_delta_base(bytes, pos, object_start_offset)?;
5377            scratch.skip_zlib_stream(bytes, pos, size)?;
5378        }
5379    }
5380    Ok(())
5381}
5382
5383/// Read all object IDs from a `.idx` file.
5384pub fn read_idx_object_ids(idx_path: &Path) -> Result<Vec<ObjectId>> {
5385    let index = read_pack_index(idx_path)?;
5386    let mut out = Vec::new();
5387    let hash_bytes = index.hash_bytes();
5388    for e in index.iter() {
5389        if e.oid().len() == hash_bytes {
5390            out.push(ObjectId::from_bytes(e.oid())?);
5391        }
5392    }
5393    Ok(out)
5394}
5395
5396mod pack_cache_test_sync {
5397    use std::cell::Cell;
5398    use std::sync::Mutex;
5399    use std::sync::MutexGuard;
5400
5401    /// Process-global pack cache test lock state (guards real synchronization state).
5402    struct PackCacheTestCoordinator {
5403        top_level_guards: u32,
5404    }
5405
5406    // hygiene: pack cache test synchronization (cfg(test) only)
5407    static COORD: Mutex<PackCacheTestCoordinator> = Mutex::new(PackCacheTestCoordinator {
5408        top_level_guards: 0,
5409    });
5410
5411    // hygiene: pack cache test depth guard (cfg(test) only)
5412    thread_local! {
5413        static DEPTH: Cell<u32> = const { Cell::new(0) };
5414    }
5415
5416    /// Serializes tests that share the process-global pack cache (including `clear_pack_cache`).
5417    pub struct PackCacheTestGuard {
5418        _inner: Option<MutexGuard<'static, PackCacheTestCoordinator>>,
5419    }
5420
5421    pub fn acquire() -> PackCacheTestGuard {
5422        let depth = DEPTH.with(|d| {
5423            let n = d.get();
5424            d.set(n + 1);
5425            n
5426        });
5427        PackCacheTestGuard {
5428            _inner: if depth == 0 {
5429                let mut g = COORD.lock().unwrap_or_else(|e| e.into_inner());
5430                assert_eq!(
5431                    g.top_level_guards, 0,
5432                    "pack cache test coordinator already held"
5433                );
5434                g.top_level_guards = 1;
5435                Some(g)
5436            } else {
5437                None
5438            },
5439        }
5440    }
5441
5442    impl Drop for PackCacheTestGuard {
5443        fn drop(&mut self) {
5444            DEPTH.with(|d| {
5445                let n = d.get();
5446                assert!(n > 0, "pack cache test guard depth underflow");
5447                d.set(n - 1);
5448                if n == 1 {
5449                    if let Some(mut g) = self._inner.take() {
5450                        assert_eq!(g.top_level_guards, 1);
5451                        g.top_level_guards = 0;
5452                    }
5453                }
5454            });
5455        }
5456    }
5457}
5458
5459#[cfg(test)]
5460pub(crate) fn pack_cache_test_guard() -> pack_cache_test_sync::PackCacheTestGuard {
5461    pack_cache_test_sync::acquire()
5462}
5463
5464/// Serializes tests that mutate the process-global pack cache (integration tests).
5465pub fn test_pack_cache_guard() -> pack_cache_test_sync::PackCacheTestGuard {
5466    pack_cache_test_sync::acquire()
5467}
5468
5469#[cfg(test)]
5470mod tests {
5471    use super::*;
5472    use crate::delta_encode::encode_lcp_delta;
5473    use crate::odb::Odb;
5474    use crate::pack_map::PackData;
5475    use crate::transfer::{build_pack, PackBuildOptions};
5476    use flate2::write::ZlibEncoder;
5477    use flate2::Compression;
5478    use std::io::Write;
5479    use std::process::Command;
5480
5481    fn git_try(dir: &std::path::Path, args: &[&str]) -> bool {
5482        Command::new("git") // hygiene: git fixture comparison in cfg(test) module // hygiene: git fixture comparison in cfg(test) module
5483            .current_dir(dir)
5484            .args(args)
5485            .env("GIT_CONFIG_GLOBAL", "/dev/null")
5486            .env("GIT_CONFIG_SYSTEM", "/dev/null")
5487            .output()
5488            .map(|o| o.status.success())
5489            .unwrap_or(false)
5490    }
5491
5492    fn git(dir: &std::path::Path, args: &[&str]) {
5493        let out = Command::new("git") // hygiene: git fixture comparison in cfg(test) module
5494            .current_dir(dir)
5495            .args(args)
5496            .env("GIT_AUTHOR_NAME", "T")
5497            .env("GIT_AUTHOR_EMAIL", "t@example.com")
5498            .env("GIT_COMMITTER_NAME", "T")
5499            .env("GIT_COMMITTER_EMAIL", "t@example.com")
5500            .env("GIT_AUTHOR_DATE", "2005-04-07T22:13:13 +0200")
5501            .env("GIT_COMMITTER_DATE", "2005-04-07T22:13:13 +0200")
5502            .env("GIT_CONFIG_GLOBAL", "/dev/null")
5503            .env("GIT_CONFIG_SYSTEM", "/dev/null")
5504            .output()
5505            .expect("git");
5506        assert!(
5507            out.status.success(),
5508            "git {:?}: {}",
5509            args,
5510            String::from_utf8_lossy(&out.stderr)
5511        );
5512    }
5513
5514    fn append_pack_object_header(buf: &mut Vec<u8>, type_code: u8, payload_len: usize) {
5515        let mut size = payload_len;
5516        let first = ((type_code & 0x7) << 4) | (size & 0x0f) as u8;
5517        size >>= 4;
5518        if size > 0 {
5519            buf.push(first | 0x80);
5520            while size > 0 {
5521                let b = (size & 0x7f) as u8;
5522                size >>= 7;
5523                buf.push(if size > 0 { b | 0x80 } else { b });
5524            }
5525        } else {
5526            buf.push(first);
5527        }
5528    }
5529
5530    fn zlib_pack(payload: &[u8]) -> Vec<u8> {
5531        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
5532        enc.write_all(payload).unwrap();
5533        enc.finish().unwrap()
5534    }
5535
5536    fn append_sha1_pack_trailer(buf: &mut Vec<u8>) {
5537        buf.extend_from_slice(HashAlgo::Sha1.checksum_digest(&*buf).as_slice());
5538    }
5539
5540    fn append_ref_delta(buf: &mut Vec<u8>, base_oid: &ObjectId, delta: &[u8]) {
5541        let compressed = zlib_pack(delta);
5542        append_pack_object_header(buf, 7, delta.len());
5543        buf.extend_from_slice(base_oid.as_bytes());
5544        buf.extend_from_slice(&compressed);
5545    }
5546
5547    fn append_ofs_delta(buf: &mut Vec<u8>, base_offset: u64, delta: &[u8]) -> u64 {
5548        let object_start = buf.len() as u64;
5549        let compressed = zlib_pack(delta);
5550        append_pack_object_header(buf, 6, delta.len());
5551        let mut ofs = object_start - base_offset;
5552        while ofs >= 0x80 {
5553            buf.push((ofs as u8 & 0x7f) | 0x80);
5554            ofs >>= 7;
5555        }
5556        buf.push(ofs as u8);
5557        buf.extend_from_slice(&compressed);
5558        object_start
5559    }
5560
5561    fn append_whole_blob(buf: &mut Vec<u8>, data: &[u8]) -> u64 {
5562        let off = buf.len() as u64;
5563        let compressed = zlib_pack(data);
5564        append_pack_object_header(buf, 3, data.len());
5565        buf.extend_from_slice(&compressed);
5566        off
5567    }
5568
5569    fn append_corrupt_whole_blob(buf: &mut Vec<u8>) -> u64 {
5570        let off = buf.len() as u64;
5571        append_pack_object_header(buf, 3, 8);
5572        buf.extend_from_slice(&[0xff, 0xfe, 0xfd]);
5573        off
5574    }
5575
5576    fn write_v2_idx_for_test(
5577        idx_path: &Path,
5578        pack_path: &Path,
5579        entries: &[(ObjectId, u64)],
5580    ) -> Result<()> {
5581        let pack_bytes = std::fs::read(pack_path)?;
5582        let mut with_crc = Vec::with_capacity(entries.len());
5583        for (oid, off) in entries {
5584            let start = *off as usize;
5585            let mut end = start;
5586            skip_one_pack_object(&pack_bytes, &mut end, *off, 20)
5587                .map_err(|e| Error::CorruptObject(format!("idx crc walk: {e}")))?;
5588            with_crc.push((*oid, *off, crc32fast::hash(&pack_bytes[start..end])));
5589        }
5590        write_v2_pack_index(idx_path, pack_path, &with_crc, 20)
5591    }
5592
5593    fn single_blob_pack(data: &[u8]) -> (Vec<u8>, ObjectId, u64) {
5594        let odb = Odb::new(tempfile::tempdir().expect("tempdir").path());
5595        let oid = odb.hash(ObjectKind::Blob, data).expect("hash");
5596        let mut pack = Vec::new();
5597        pack.extend_from_slice(b"PACK");
5598        pack.extend_from_slice(&2u32.to_be_bytes());
5599        pack.extend_from_slice(&1u32.to_be_bytes());
5600        let off = append_whole_blob(&mut pack, data);
5601        append_sha1_pack_trailer(&mut pack);
5602        (pack, oid, off)
5603    }
5604
5605    #[test]
5606    fn single_local_pack_reader_batch_check_caches_deltified_entries() {
5607        let _guard = pack_cache_test_guard();
5608        clear_pack_cache();
5609        let Some((tmp, idx, tip, odb)) = build_ofs_delta_chain_pack(2) else {
5610            return;
5611        };
5612        let objects = tmp.path().join("objects");
5613        let store = crate::pack_store::PackStore::legacy_for_objects_dir(&objects);
5614        crate::pack_store::PackStore::with_context(store, || {
5615            let mut reader = SingleLocalPackReader::open(&objects)
5616                .expect("open")
5617                .expect("single pack");
5618            let info1 = reader.read_info(&tip).expect("first read");
5619            let info2 = reader.read_info(&tip).expect("cached read");
5620            assert_eq!(info1, info2);
5621            let full = read_object_from_pack(&idx, &tip).expect("full read");
5622            assert_eq!(info1.kind, full.kind);
5623            assert_eq!(info1.size, full.data.len() as u64);
5624        });
5625        let _ = odb;
5626    }
5627
5628    #[test]
5629    fn pack_read_info_cache_is_scoped_per_repository_and_pack_bytes() {
5630        let _guard = pack_cache_test_guard();
5631        clear_pack_cache();
5632        const ENTRY_OFFSET: u64 = 200;
5633        let dir_a = tempfile::tempdir().expect("tempdir a");
5634        let dir_b = tempfile::tempdir().expect("tempdir b");
5635        let objects_a = dir_a.path().join("objects");
5636        let objects_b = dir_b.path().join("objects");
5637        fs::create_dir_all(objects_a.join("pack")).expect("pack dir a");
5638        fs::create_dir_all(objects_b.join("pack")).expect("pack dir b");
5639        let pack_a = objects_a.join("pack/p-a.pack");
5640        let pack_b = objects_b.join("pack/p-b.pack");
5641        let (oid_a, _) =
5642            write_sparse_single_blob_pack(dir_a.path(), &pack_a, b"five!", ENTRY_OFFSET)
5643                .expect("pack a");
5644        let (oid_b, _) =
5645            write_sparse_single_blob_pack(dir_b.path(), &pack_b, b"1234567890", ENTRY_OFFSET)
5646                .expect("pack b");
5647        let idx_a = pack_a.with_extension("idx");
5648        let idx_b = pack_b.with_extension("idx");
5649        write_v2_idx_for_test(&idx_a, &pack_a, &[(oid_a, ENTRY_OFFSET)]).expect("idx a");
5650        write_v2_idx_for_test(&idx_b, &pack_b, &[(oid_b, ENTRY_OFFSET)]).expect("idx b");
5651        let idx_a = read_pack_index(&idx_a).expect("parse idx a");
5652        let idx_b = read_pack_index(&idx_b).expect("parse idx b");
5653
5654        let read_size = |objects: &Path, idx: &PackIndex| -> u64 {
5655            let store = crate::pack_store::PackStore::legacy_for_objects_dir(objects);
5656            crate::pack_store::PackStore::with_context(store, || {
5657                super::read_object_info_from_pack_at_offset(idx, ENTRY_OFFSET)
5658                    .expect("read_info")
5659                    .size
5660            })
5661        };
5662
5663        let size_a = read_size(&objects_a, &idx_a);
5664        let size_b = read_size(&objects_b, &idx_b);
5665        assert_eq!(size_a, 5);
5666        assert_eq!(size_b, 10);
5667        // A → B → A again: cache must not return B's size for A's pack entry.
5668        assert_eq!(read_size(&objects_a, &idx_a), 5);
5669        assert_eq!(read_size(&objects_b, &idx_b), 10);
5670        assert_eq!(read_size(&objects_a, &idx_a), 5);
5671    }
5672    /// Write a valid pack with one blob placed at `object_offset` so the on-disk file
5673    /// length exceeds the mmap owned-buffer threshold without storing huge compressed payloads.
5674    fn write_sparse_single_blob_pack(
5675        repo_root: &Path,
5676        pack_path: &Path,
5677        data: &[u8],
5678        object_offset: u64,
5679    ) -> Result<(ObjectId, u64)> {
5680        use std::fs::OpenOptions;
5681        use std::io::{Seek, SeekFrom, Write};
5682
5683        let odb = Odb::new(repo_root);
5684        let oid = odb.hash(ObjectKind::Blob, data).expect("hash");
5685        let mut file = OpenOptions::new()
5686            .create(true)
5687            .write(true)
5688            .read(true)
5689            .truncate(true)
5690            .open(pack_path)
5691            .map_err(Error::Io)?;
5692        file.write_all(b"PACK").map_err(Error::Io)?;
5693        file.write_all(&2u32.to_be_bytes()).map_err(Error::Io)?;
5694        file.write_all(&1u32.to_be_bytes()).map_err(Error::Io)?;
5695        file.seek(SeekFrom::Start(object_offset))
5696            .map_err(Error::Io)?;
5697        let mut tail = Vec::new();
5698        append_whole_blob(&mut tail, data);
5699        file.write_all(&tail).map_err(Error::Io)?;
5700        let trailer = sha1_trailer_for_open_file(&mut file)?;
5701        file.write_all(trailer.as_bytes()).map_err(Error::Io)?;
5702        file.flush().map_err(Error::Io)?;
5703        Ok((oid, object_offset))
5704    }
5705
5706    #[test]
5707    fn skip_one_pack_object_rejects_zero_size_with_zlib_payload() {
5708        let mut bytes = Vec::new();
5709        bytes.push(0x30);
5710        bytes.extend_from_slice(&zlib_pack(b"x"));
5711        let mut pos = 0usize;
5712        let err = skip_one_pack_object(&bytes, &mut pos, 0, 20).unwrap_err();
5713        assert!(matches!(err, Error::CorruptObject(_)), "got {err:?}");
5714    }
5715
5716    fn install_synthetic_pack(
5717        objects_dir: &Path,
5718        stem: &str,
5719        pack: &[u8],
5720        entries: &[(ObjectId, u64)],
5721    ) -> PackIndex {
5722        install_synthetic_pack_inner(objects_dir, stem, pack, entries, true)
5723    }
5724
5725    fn install_synthetic_pack_no_clear(
5726        objects_dir: &Path,
5727        stem: &str,
5728        pack: &[u8],
5729        entries: &[(ObjectId, u64)],
5730    ) -> PackIndex {
5731        install_synthetic_pack_inner(objects_dir, stem, pack, entries, false)
5732    }
5733
5734    fn install_synthetic_pack_inner(
5735        objects_dir: &Path,
5736        stem: &str,
5737        pack: &[u8],
5738        entries: &[(ObjectId, u64)],
5739        clear_cache: bool,
5740    ) -> PackIndex {
5741        let pack_dir = objects_dir.join("pack");
5742        std::fs::create_dir_all(&pack_dir).expect("pack dir");
5743        let pack_path = pack_dir.join(format!("{stem}.pack"));
5744        let idx_path = pack_dir.join(format!("{stem}.idx"));
5745        std::fs::write(&pack_path, pack).expect("write pack");
5746        write_v2_idx_for_test(&idx_path, &pack_path, entries).expect("write idx");
5747        if clear_cache {
5748            clear_pack_cache();
5749        }
5750        read_pack_index(&idx_path).expect("read idx")
5751    }
5752
5753    fn pack_index_for_objects(
5754        pack_path: std::path::PathBuf,
5755        objects: &[(ObjectId, u64)],
5756    ) -> PackIndex {
5757        let idx_path = pack_path.with_extension("idx");
5758        let pack_bytes = fs::read(&pack_path).expect("read pack for idx trailer");
5759        let trailer = &pack_bytes[pack_bytes.len().saturating_sub(20)..];
5760        let rows: Vec<(ObjectId, u64, u32)> = objects
5761            .iter()
5762            .map(|(oid, offset)| (*oid, *offset, 0))
5763            .collect();
5764        write_v2_pack_index_with_trailer(&idx_path, &rows, trailer, 20, None).expect("write idx");
5765        read_pack_index(&idx_path).expect("read idx")
5766    }
5767
5768    fn index_pack_in_scratch(pack: &[u8]) -> (tempfile::TempDir, PackIndex) {
5769        let dir = tempfile::tempdir().expect("tempdir");
5770        let pack_path = dir.path().join("chain.pack");
5771        std::fs::write(&pack_path, pack).expect("write pack");
5772        let out = Command::new("git") // hygiene: git fixture comparison in cfg(test) module
5773            .current_dir(dir.path())
5774            .args(["index-pack", "chain.pack"])
5775            .env("GIT_CONFIG_GLOBAL", "/dev/null")
5776            .env("GIT_CONFIG_SYSTEM", "/dev/null")
5777            .output()
5778            .expect("index-pack");
5779        assert!(
5780            out.status.success(),
5781            "git index-pack failed: {}",
5782            String::from_utf8_lossy(&out.stderr)
5783        );
5784        let idx_path = dir.path().join("chain.idx");
5785        let idx = read_pack_index(&idx_path).expect("read idx");
5786        (dir, idx)
5787    }
5788
5789    /// Repo with many prefix-preserving edits to one blob so delta chains can exceed 50.
5790    fn build_deep_delta_repo(commit_count: usize) -> Option<(tempfile::TempDir, ObjectId, Odb)> {
5791        let tmp = tempfile::tempdir().ok()?;
5792        let dir = tmp.path();
5793        if !git_try(dir, &["init", "-q", "-b", "main", "."]) {
5794            return None;
5795        }
5796        let mut body = String::new();
5797        for i in 0..200 {
5798            body.push_str(&format!("seed line {i:04}\n"));
5799        }
5800        for rev in 0..commit_count {
5801            body.push_str(&format!("edit-{rev}\n"));
5802            std::fs::write(dir.join("blob.txt"), body.as_bytes()).ok()?;
5803            git(dir, &["add", "blob.txt"]);
5804            git(dir, &["commit", "-q", "-m", &format!("c{rev}")]);
5805        }
5806        let tip_hex = Command::new("git") // hygiene: git fixture comparison in cfg(test) module
5807            .current_dir(dir)
5808            .args(["rev-parse", "HEAD"])
5809            .env("GIT_CONFIG_GLOBAL", "/dev/null")
5810            .env("GIT_CONFIG_SYSTEM", "/dev/null")
5811            .output()
5812            .ok()?;
5813        if !tip_hex.status.success() {
5814            return None;
5815        }
5816        let tip = ObjectId::from_hex(std::str::from_utf8(&tip_hex.stdout).ok()?.trim()).ok()?;
5817        let git_dir = dir.join(".git");
5818        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir);
5819        Some((tmp, tip, odb))
5820    }
5821
5822    #[test]
5823    fn verify_packed_object_hash_sha256_detects_corruption() {
5824        use crate::hash::hash_object;
5825        use std::fs;
5826
5827        let data = b"sha256 packed blob for hash verify\n";
5828        let oid = hash_object(HashAlgo::Sha256, ObjectKind::Blob, data).expect("hash");
5829        let wrong_oid =
5830            hash_object(HashAlgo::Sha256, ObjectKind::Blob, b"different payload\n").expect("hash");
5831        let mut pack = Vec::new();
5832        pack.extend_from_slice(b"PACK");
5833        pack.extend_from_slice(&2u32.to_be_bytes());
5834        pack.extend_from_slice(&1u32.to_be_bytes());
5835        let off = append_whole_blob(&mut pack, data);
5836        pack.extend_from_slice(HashAlgo::Sha256.checksum_digest(&pack).as_slice());
5837
5838        let dir = tempfile::tempdir().expect("tempdir");
5839        let pack_path = dir.path().join("sha256-corrupt.pack");
5840        fs::write(&pack_path, &pack).expect("write pack");
5841
5842        let idx_path = pack_path.with_extension("idx");
5843        let trailer = &pack[pack.len() - 32..];
5844        write_v2_pack_index_with_trailer(&idx_path, &[(wrong_oid, off, 0)], trailer, 32, None)
5845            .expect("write idx");
5846        let idx = read_pack_index(&idx_path).expect("read idx");
5847
5848        let err = read_object_from_pack_bytes(&pack, &idx, wrong_oid.as_bytes()).unwrap_err();
5849        match err {
5850            Error::CorruptObject(msg) => {
5851                assert!(
5852                    msg.contains("hashes to"),
5853                    "expected hash mismatch message, got: {msg}"
5854                );
5855                assert!(
5856                    msg.contains(&oid.to_hex()),
5857                    "expected recomputed oid in message, got: {msg}"
5858                );
5859            }
5860            other => panic!("expected CorruptObject, got {other:?}"),
5861        }
5862    }
5863
5864    #[cfg_attr(
5865        not(feature = "full-tests"),
5866        ignore = "starts system git or is heavy; run with --features full-tests"
5867    )]
5868    #[test]
5869    fn reads_delta_chain_deeper_than_50() {
5870        let _guard = pack_cache_test_guard();
5871        let Some((_repo, tip, odb)) = build_deep_delta_repo(120) else {
5872            let _ = "SKIP: git unavailable for deep delta fixture";
5873            return;
5874        };
5875        let pack = build_pack(
5876            &odb,
5877            &[tip],
5878            &[],
5879            &PackBuildOptions {
5880                delta: true,
5881                max_depth: 120,
5882                use_ofs_delta: true,
5883                ..PackBuildOptions::default()
5884            },
5885        )
5886        .expect("build deep delta pack");
5887
5888        let (_scratch, idx) = index_pack_in_scratch(&pack);
5889        assert!(
5890            idx.len() > 1,
5891            "expected a multi-object pack, got {}",
5892            idx.len()
5893        );
5894
5895        for entry in idx.iter() {
5896            let oid = ObjectId::from_bytes(entry.oid()).expect("oid in idx");
5897            let from_pack = read_object_from_pack(&idx, &oid).expect("read packed object");
5898            let expected = odb.read(&oid).expect("loose/alt copy for oid");
5899            assert_eq!(from_pack.kind, expected.kind);
5900            assert_eq!(from_pack.data, expected.data);
5901            assert_eq!(
5902                odb.hash(from_pack.kind, &from_pack.data).expect("hash"),
5903                oid
5904            );
5905        }
5906    }
5907
5908    #[test]
5909    fn cyclic_ref_delta_chain_errors() {
5910        let _guard = pack_cache_test_guard();
5911        let odb = Odb::new(tempfile::tempdir().expect("tempdir").path());
5912        let content_a = b"aaa".as_slice();
5913        let content_b = b"bbb".as_slice();
5914        let oid_a = odb.hash(ObjectKind::Blob, content_a).expect("hash");
5915        let oid_b = odb.hash(ObjectKind::Blob, content_b).expect("hash");
5916        let delta_a = encode_lcp_delta(content_b, content_a).expect("delta a");
5917        let delta_b = encode_lcp_delta(content_a, content_b).expect("delta b");
5918
5919        let mut pack = Vec::new();
5920        pack.extend_from_slice(b"PACK");
5921        pack.extend_from_slice(&2u32.to_be_bytes());
5922        pack.extend_from_slice(&2u32.to_be_bytes());
5923        let off_a = pack.len() as u64;
5924        append_ref_delta(&mut pack, &oid_b, &delta_a);
5925        let off_b = pack.len() as u64;
5926        append_ref_delta(&mut pack, &oid_a, &delta_b);
5927        append_sha1_pack_trailer(&mut pack);
5928
5929        let dir = tempfile::tempdir().expect("tempdir");
5930        let pack_path = dir.path().join("cycle.pack");
5931        std::fs::write(&pack_path, &pack).expect("write cyclic pack");
5932        let idx = pack_index_for_objects(pack_path, &[(oid_a, off_a), (oid_b, off_b)]);
5933        let pack_bytes = std::fs::read(&idx.pack_path).expect("read pack");
5934        for err in [
5935            read_object_from_pack(&idx, &oid_a).expect_err("read_object_from_pack"),
5936            read_object_from_pack_bytes(&pack_bytes, &idx, oid_a.as_bytes())
5937                .expect_err("read_object_from_pack_bytes"),
5938        ] {
5939            assert!(
5940                matches!(err, Error::DeltaChainTooDeep { limit: 4096 }),
5941                "expected typed delta chain limit, got {err:?}"
5942            );
5943        }
5944    }
5945
5946    #[test]
5947    fn resolve_cross_pack_delta_without_index_clone() {
5948        let _guard = pack_cache_test_guard();
5949        clear_pack_cache();
5950        let odb = Odb::new(tempfile::tempdir().expect("tempdir").path());
5951        let base = b"cross-pack-base-payload".as_slice();
5952        let tip = b"cross-pack-base-payload!".as_slice();
5953        let oid_base = odb.hash(ObjectKind::Blob, base).expect("hash");
5954        let oid_tip = odb.hash(ObjectKind::Blob, tip).expect("hash");
5955        let delta = encode_lcp_delta(base, tip).expect("delta");
5956
5957        let mut pack_base = Vec::new();
5958        pack_base.extend_from_slice(b"PACK");
5959        pack_base.extend_from_slice(&2u32.to_be_bytes());
5960        pack_base.extend_from_slice(&1u32.to_be_bytes());
5961        let off_base = append_whole_blob(&mut pack_base, base);
5962        append_sha1_pack_trailer(&mut pack_base);
5963
5964        let mut pack_delta = Vec::new();
5965        pack_delta.extend_from_slice(b"PACK");
5966        pack_delta.extend_from_slice(&2u32.to_be_bytes());
5967        pack_delta.extend_from_slice(&1u32.to_be_bytes());
5968        let off_tip = pack_delta.len() as u64;
5969        append_ref_delta(&mut pack_delta, &oid_base, &delta);
5970        append_sha1_pack_trailer(&mut pack_delta);
5971
5972        let dir = tempfile::tempdir().expect("tempdir");
5973        let objects = dir.path().join("objects");
5974        let _idx_base =
5975            install_synthetic_pack(&objects, "cross-base", &pack_base, &[(oid_base, off_base)]);
5976        let idx_delta =
5977            install_synthetic_pack(&objects, "cross-delta", &pack_delta, &[(oid_tip, off_tip)]);
5978
5979        let from_delta_pack = read_object_from_pack(&idx_delta, &oid_tip).expect("delta pack read");
5980        assert_eq!(from_delta_pack.kind, ObjectKind::Blob);
5981        assert_eq!(from_delta_pack.data, tip);
5982        assert_eq!(
5983            odb.hash(from_delta_pack.kind, &from_delta_pack.data)
5984                .expect("hash"),
5985            oid_tip
5986        );
5987
5988        let from_packs = read_object_from_packs(&objects, &oid_tip).expect("packs dir read");
5989        assert_eq!(from_packs.kind, ObjectKind::Blob);
5990        assert_eq!(from_packs.data, tip);
5991        assert_eq!(
5992            odb.hash(from_packs.kind, &from_packs.data).expect("hash"),
5993            oid_tip
5994        );
5995    }
5996
5997    #[cfg_attr(
5998        not(feature = "full-tests"),
5999        ignore = "starts system git or is heavy; run with --features full-tests"
6000    )]
6001    #[test]
6002    fn read_object_from_pack_bytes_uses_in_memory_index_without_on_disk_idx() {
6003        let odb = Odb::new(tempfile::tempdir().expect("tempdir").path());
6004        let content = b"in-memory-index-blob";
6005        let oid = odb.hash(ObjectKind::Blob, content).expect("hash");
6006
6007        let mut pack = Vec::new();
6008        pack.extend_from_slice(b"PACK");
6009        pack.extend_from_slice(&2u32.to_be_bytes());
6010        pack.extend_from_slice(&1u32.to_be_bytes());
6011        let off = append_whole_blob(&mut pack, content);
6012        append_sha1_pack_trailer(&mut pack);
6013
6014        let dir = tempfile::tempdir().expect("tempdir");
6015        let pack_path = dir.path().join("mem.pack");
6016        std::fs::write(&pack_path, &pack).expect("write pack");
6017        let idx = pack_index_for_objects(pack_path, &[(oid, off)]);
6018        std::fs::remove_file(&idx.idx_path).expect("drop on-disk idx");
6019        assert!(
6020            !idx.idx_path.exists(),
6021            "fixture must not rely on an on-disk .idx"
6022        );
6023
6024        let got = read_object_from_pack_bytes(&pack, &idx, oid.as_bytes()).expect("read bytes");
6025        assert_eq!(got.kind, ObjectKind::Blob);
6026        assert_eq!(got.data, content);
6027    }
6028
6029    fn build_ofs_delta_chain_pack(
6030        depth: usize,
6031    ) -> Option<(tempfile::TempDir, PackIndex, ObjectId, Odb)> {
6032        if depth == 0 {
6033            return None;
6034        }
6035        let tmp = tempfile::tempdir().ok()?;
6036        let odb = Odb::new(&tmp.path().join("objects"));
6037        let mut content = b"ofs-delta-chain-base\n".to_vec();
6038        let mut pack = Vec::new();
6039        pack.extend_from_slice(b"PACK");
6040        pack.extend_from_slice(&2u32.to_be_bytes());
6041        pack.extend_from_slice(&(depth as u32 + 1).to_be_bytes());
6042        let mut base_off = append_whole_blob(&mut pack, &content);
6043        let mut entries = vec![(
6044            odb.hash(ObjectKind::Blob, &content).expect("hash"),
6045            base_off,
6046        )];
6047        for _ in 0..depth {
6048            let mut next = content.clone();
6049            next.push(b'!');
6050            let delta = encode_lcp_delta(&content, &next).ok()?;
6051            let off = append_ofs_delta(&mut pack, base_off, &delta);
6052            let oid = odb.hash(ObjectKind::Blob, &next).expect("hash");
6053            entries.push((oid, off));
6054            content = next;
6055            base_off = off;
6056        }
6057        append_sha1_pack_trailer(&mut pack);
6058        let tip = entries.last()?.0;
6059        let dir = tmp.path();
6060        std::fs::create_dir_all(dir.join("objects/pack")).ok()?;
6061        let pack_path = dir.join("objects/pack/chain.pack");
6062        std::fs::write(&pack_path, &pack).ok()?;
6063        write_v2_idx_for_test(&pack_path.with_extension("idx"), &pack_path, &entries).ok()?;
6064        clear_pack_cache();
6065        let idx = read_pack_index(&pack_path.with_extension("idx")).ok()?;
6066        Some((tmp, idx, tip, odb))
6067    }
6068
6069    #[test]
6070    fn bounded_pack_cache_resolves_deltas_without_changing_shared_cache() {
6071        let _guard = pack_cache_test_guard();
6072        let (_tmp, idx, tip, odb) = build_ofs_delta_chain_pack(800).expect("chain");
6073        let normal = odb.read(&tip).expect("normal");
6074        let shared_bytes = odb.pack_store().expect("store").delta_bases().bytes_used();
6075        let bounded = odb.with_bounded_pack_cache();
6076        assert!(!Arc::ptr_eq(
6077            odb.pack_store().expect("store"),
6078            bounded.pack_store().expect("store")
6079        ));
6080        bounded.with_pack_read_context(|| {
6081            let got = bounded.read(&tip).expect("bounded");
6082            assert_eq!(got.data, normal.data);
6083            let pack = read_pack_bytes_cached(&idx.pack_path).expect("pack");
6084            assert!(pack.uses_positioned_reads());
6085            let cached = bounded
6086                .pack_store()
6087                .expect("store")
6088                .delta_bases()
6089                .bytes_used();
6090            assert!(cached > 0 && cached <= crate::pack_store::BOUNDED_DELTA_BASE_BYTES);
6091        });
6092        assert_eq!(
6093            odb.pack_store().expect("store").delta_bases().bytes_used(),
6094            shared_bytes
6095        );
6096    }
6097
6098    #[test]
6099    fn ofs_delta_chain_depth_4096_resolves() {
6100        let _guard = pack_cache_test_guard();
6101        clear_pack_cache();
6102        let Some((_tmp, idx, tip, odb)) = build_ofs_delta_chain_pack(4096) else {
6103            return;
6104        };
6105        let got = read_object_from_pack(&idx, &tip).expect("4096-deep chain");
6106        let expect = odb.hash(ObjectKind::Blob, &got.data).expect("hash");
6107        assert_eq!(expect, tip);
6108    }
6109
6110    #[test]
6111    fn ofs_delta_chain_depth_4097_errors() {
6112        let _guard = pack_cache_test_guard();
6113        clear_pack_cache();
6114        let Some((_tmp, idx, tip, _odb)) = build_ofs_delta_chain_pack(4097) else {
6115            return;
6116        };
6117        let err = read_object_from_pack(&idx, &tip).expect_err("4097-deep chain");
6118        assert!(
6119            matches!(err, Error::DeltaChainTooDeep { limit: 4096 }),
6120            "got {err:?}"
6121        );
6122    }
6123
6124    #[test]
6125    fn delta_base_cache_limit_zero_disables_caching() {
6126        let _guard = pack_cache_test_guard();
6127        clear_pack_cache();
6128        let Some((_tmp, idx, tip, _odb)) = build_ofs_delta_chain_pack(12) else {
6129            return;
6130        };
6131        pack_cache::test_set_delta_base_cache_byte_limit(&idx.pack_path, 0);
6132        let _ = read_object_from_pack(&idx, &tip).expect("read chain");
6133        assert_eq!(
6134            pack_cache::test_delta_base_cache_bytes_used(&idx.pack_path),
6135            0,
6136            "limit 0 must not retain delta bases"
6137        );
6138        pack_cache::test_set_delta_base_cache_byte_limit(
6139            &idx.pack_path,
6140            pack_cache::DELTA_BASE_CACHE_DEFAULT,
6141        );
6142    }
6143
6144    #[test]
6145    fn delta_base_cache_eviction_respects_byte_limit() {
6146        let _guard = pack_cache_test_guard();
6147        clear_pack_cache();
6148        let Some((_tmp, idx, tip, _odb)) = build_ofs_delta_chain_pack(32) else {
6149            return;
6150        };
6151        let expected = read_object_from_pack(&idx, &tip)
6152            .expect("baseline read")
6153            .data;
6154        pack_cache::test_set_delta_base_cache_byte_limit(&idx.pack_path, 4096);
6155        let _ = read_object_from_pack(&idx, &tip).expect("prime cache");
6156        assert!(
6157            pack_cache::test_delta_base_cache_bytes_used(&idx.pack_path) <= 4096,
6158            "cache bytes {} exceeded cap",
6159            pack_cache::test_delta_base_cache_bytes_used(&idx.pack_path)
6160        );
6161        pack_cache::test_set_delta_base_cache_byte_limit(&idx.pack_path, 512);
6162        for round in 0..8 {
6163            let got = read_object_from_pack(&idx, &tip).expect("read after eviction");
6164            assert_eq!(got.data, expected, "round {round} must stay byte-identical");
6165        }
6166        pack_cache::test_set_delta_base_cache_byte_limit(
6167            &idx.pack_path,
6168            pack_cache::DELTA_BASE_CACHE_DEFAULT,
6169        );
6170    }
6171
6172    #[test]
6173    fn odb_read_into_caching_delta_bases_fills_cache_read_into_does_not() {
6174        let _guard = pack_cache_test_guard();
6175        clear_pack_cache();
6176        let Some((_tmp, idx, tip, odb)) = build_ofs_delta_chain_pack(12) else {
6177            return;
6178        };
6179        let expected = read_object_from_pack(&idx, &tip)
6180            .expect("baseline read")
6181            .data;
6182        clear_pack_cache();
6183        let store = crate::pack_store::PackStore::legacy_for_pack_path(&idx.pack_path);
6184        crate::pack_store::PackStore::with_context(store, || {
6185            let mut buf = Vec::new();
6186            assert_eq!(
6187                odb.read_into(&tip, &mut buf).expect("read_into"),
6188                ObjectKind::Blob
6189            );
6190            assert_eq!(buf, expected);
6191            assert_eq!(
6192                pack_cache::test_delta_base_cache_bytes_used(&idx.pack_path),
6193                0,
6194                "read_into must not retain delta bases"
6195            );
6196
6197            let mut cached = Vec::new();
6198            let kind = odb
6199                .read_into_caching_delta_bases(&tip, &mut cached)
6200                .expect("caching read");
6201            assert_eq!(kind, ObjectKind::Blob);
6202            assert_eq!(cached, expected);
6203            assert!(
6204                pack_cache::test_delta_base_cache_bytes_used(&idx.pack_path) > 0,
6205                "caching read must retain the chain's delta bases"
6206            );
6207
6208            let again = odb
6209                .read_into_caching_delta_bases(&tip, &mut cached)
6210                .expect("second caching read");
6211            assert_eq!(again, ObjectKind::Blob);
6212            assert_eq!(cached, expected);
6213        });
6214    }
6215
6216    #[test]
6217    fn repository_odb_read_into_caching_delta_bases_fills_cache_through_files_primary() {
6218        use crate::repo::{InitOptions, Repository};
6219        let _guard = pack_cache_test_guard();
6220        clear_pack_cache();
6221        let Some((tmp, idx, tip, _odb)) = build_ofs_delta_chain_pack(12) else {
6222            return;
6223        };
6224        let expected = read_object_from_pack(&idx, &tip)
6225            .expect("baseline read")
6226            .data;
6227        Repository::init(
6228            tmp.path(),
6229            &InitOptions {
6230                bare: true,
6231                ..InitOptions::default()
6232            },
6233        )
6234        .expect("init over packed objects");
6235        let repo = Repository::open(tmp.path(), &crate::environment::RepositoryOptions::empty())
6236            .expect("open");
6237        let odb = repo.odb();
6238        let store = Arc::clone(odb.pack_store().expect("pack store"));
6239        store.invalidate_packs();
6240
6241        let mut buf = Vec::new();
6242        assert_eq!(
6243            odb.read_into(&tip, &mut buf).expect("read_into"),
6244            ObjectKind::Blob
6245        );
6246        assert_eq!(buf, expected);
6247        assert_eq!(store.delta_bases().bytes_used(), 0);
6248
6249        let mut cached = Vec::new();
6250        assert_eq!(
6251            odb.read_into_caching_delta_bases(&tip, &mut cached)
6252                .expect("caching read"),
6253            ObjectKind::Blob
6254        );
6255        assert_eq!(cached, expected);
6256        assert!(store.delta_bases().bytes_used() > 0);
6257    }
6258
6259    #[test]
6260    fn odb_read_into_caching_delta_bases_falls_back_outside_packs() {
6261        let tmp = tempfile::tempdir().expect("tempdir");
6262        let odb = Odb::new(&tmp.path().join("objects"));
6263        let oid = odb
6264            .write(ObjectKind::Blob, b"loose only\n")
6265            .expect("write loose");
6266        let mut buf = vec![0u8; 4];
6267        let kind = odb
6268            .read_into_caching_delta_bases(&oid, &mut buf)
6269            .expect("loose read");
6270        assert_eq!(kind, ObjectKind::Blob);
6271        assert_eq!(buf, b"loose only\n");
6272    }
6273
6274    #[test]
6275    fn cached_delta_reads_support_overlay_alternates_and_custom_stores() {
6276        use crate::odb::store::MemoryStore;
6277        use crate::odb::OdbBuilder;
6278
6279        let tmp = tempfile::tempdir().expect("tempdir");
6280        let local_objects = tmp.path().join("local/objects");
6281        let alternate_objects = tmp.path().join("alternate/objects");
6282        let alternate = Odb::new(&alternate_objects);
6283        let alternate_oid = alternate
6284            .write(ObjectKind::Blob, b"alternate\n")
6285            .expect("alternate object");
6286        fs::create_dir_all(local_objects.join("info")).expect("info");
6287        fs::write(
6288            local_objects.join("info/alternates"),
6289            format!("{}\n", alternate_objects.display()),
6290        )
6291        .expect("alternates");
6292
6293        let odb = Odb::new(&local_objects);
6294        let mut buf = Vec::with_capacity(1024);
6295        assert_eq!(
6296            odb.read_into_caching_delta_bases(&alternate_oid, &mut buf)
6297                .expect("alternate read"),
6298            ObjectKind::Blob
6299        );
6300        assert_eq!(buf, b"alternate\n");
6301
6302        odb.enable_mem_overlay();
6303        let overlay_oid = odb
6304            .write(ObjectKind::Blob, b"overlay\n")
6305            .expect("overlay object");
6306        odb.read_into_caching_delta_bases(&overlay_oid, &mut buf)
6307            .expect("overlay read");
6308        assert_eq!(buf, b"overlay\n");
6309
6310        let custom = OdbBuilder::files(tmp.path().join("custom/objects"))
6311            .primary(Arc::new(MemoryStore::new(HashAlgo::Sha1)))
6312            .build();
6313        let custom_oid = custom
6314            .write(ObjectKind::Blob, b"custom\n")
6315            .expect("custom object");
6316        custom
6317            .read_into_caching_delta_bases(&custom_oid, &mut buf)
6318            .expect("custom read");
6319        assert_eq!(buf, b"custom\n");
6320
6321        let missing = HashAlgo::Sha1
6322            .hash_object(ObjectKind::Blob, b"missing")
6323            .expect("missing id");
6324        assert!(matches!(
6325            custom.read_into_caching_delta_bases(&missing, &mut buf),
6326            Err(Error::ObjectNotFound(_))
6327        ));
6328    }
6329
6330    #[test]
6331    fn delta_base_cache_invalidated_when_pack_bytes_reloaded() {
6332        use filetime::{set_file_mtime, FileTime};
6333        let _guard = pack_cache_test_guard();
6334        clear_pack_cache();
6335        let Some((_tmp, idx, tip, _odb)) = build_ofs_delta_chain_pack(8) else {
6336            return;
6337        };
6338        let pack_path = idx.pack_path.clone();
6339        let pack_id = pack_cache::pack_id_for(&pack_path);
6340        let mid_off = idx
6341            .iter()
6342            .nth(4)
6343            .map(|e| e.offset())
6344            .expect("mid hop")
6345            .expect("mid off");
6346        let _ = read_object_from_pack(&idx, &tip).expect("warm cache");
6347        assert!(
6348            pack_cache::test_delta_base_cached(&pack_path, pack_id, mid_off),
6349            "expected intermediate cached at offset {mid_off}"
6350        );
6351        set_file_mtime(&pack_path, FileTime::now()).expect("touch pack mtime");
6352        assert!(
6353            pack_cache::revalidate_stale_pack_bytes(&pack_path).expect("revalidate"),
6354            "expected pack bytes reload"
6355        );
6356        assert!(
6357            !pack_cache::test_delta_base_cached(&pack_path, pack_id, mid_off),
6358            "cache must drop when pack bytes reload"
6359        );
6360    }
6361
6362    #[cfg_attr(
6363        not(feature = "full-tests"),
6364        ignore = "starts system git or is heavy; run with --features full-tests"
6365    )]
6366    #[test]
6367    fn git_repack_depth_250_matches_cat_file_batch() {
6368        let _guard = pack_cache_test_guard();
6369        clear_pack_cache();
6370        let tmp = tempfile::tempdir().expect("tempdir");
6371        let dir = tmp.path();
6372        if Command::new("git") // hygiene: git fixture comparison in cfg(test) module
6373            .current_dir(dir)
6374            .args(["init", "-q"])
6375            .env("GIT_CONFIG_GLOBAL", "/dev/null")
6376            .env("GIT_CONFIG_SYSTEM", "/dev/null")
6377            .status()
6378            .is_err()
6379        {
6380            let _ = "SKIP: git unavailable";
6381            return;
6382        }
6383        for i in 0..64 {
6384            std::fs::write(dir.join(format!("f{i}.txt")), format!("payload {i}\n")).unwrap();
6385            git(dir, &["add", &format!("f{i}.txt")]);
6386            git(dir, &["commit", "-qm", &format!("c{i}")]);
6387        }
6388        git(dir, &["repack", "-adf", "--depth=250", "--window=250"]);
6389        let objects = dir.join(".git/objects");
6390        let indexes = read_local_pack_indexes_cached(&objects).expect("pack indexes");
6391        let idx = indexes
6392            .into_iter()
6393            .max_by_key(|i| i.len())
6394            .expect("repack pack");
6395        for entry in idx.iter() {
6396            let oid = ObjectId::from_bytes(entry.oid()).expect("oid");
6397            let grit_obj = read_object_from_pack(&idx, &oid).expect("grit read");
6398            let git_type = Command::new("git") // hygiene: git fixture comparison in cfg(test) module
6399                .current_dir(dir)
6400                .args(["cat-file", "-t", &oid.to_hex()])
6401                .env("GIT_CONFIG_GLOBAL", "/dev/null")
6402                .env("GIT_CONFIG_SYSTEM", "/dev/null")
6403                .output()
6404                .expect("git cat-file -t");
6405            assert!(git_type.status.success());
6406            let git_kind = ObjectKind::from_bytes(
6407                std::str::from_utf8(git_type.stdout.trim_ascii_end())
6408                    .expect("utf8 type")
6409                    .as_bytes(),
6410            )
6411            .expect("git type");
6412            assert_eq!(git_kind, grit_obj.kind);
6413            let type_arg = match grit_obj.kind {
6414                ObjectKind::Blob => "blob",
6415                ObjectKind::Tree => "tree",
6416                ObjectKind::Commit => "commit",
6417                ObjectKind::Tag => "tag",
6418            };
6419            let git_payload = Command::new("git") // hygiene: git fixture comparison in cfg(test) module
6420                .current_dir(dir)
6421                .args(["cat-file", type_arg, &oid.to_hex()])
6422                .env("GIT_CONFIG_GLOBAL", "/dev/null")
6423                .env("GIT_CONFIG_SYSTEM", "/dev/null")
6424                .output()
6425                .expect("git cat-file raw");
6426            assert!(git_payload.status.success());
6427            assert_eq!(git_payload.stdout, grit_obj.data);
6428        }
6429    }
6430
6431    #[test]
6432    fn cyclic_ref_delta_chain_across_two_packs_errors() {
6433        let _guard = pack_cache_test_guard();
6434        clear_pack_cache();
6435        let odb = Odb::new(tempfile::tempdir().expect("tempdir").path());
6436        let content_a = b"aaa".as_slice();
6437        let content_b = b"bbb".as_slice();
6438        let oid_a = odb.hash(ObjectKind::Blob, content_a).expect("hash");
6439        let oid_b = odb.hash(ObjectKind::Blob, content_b).expect("hash");
6440        let delta_a = encode_lcp_delta(content_b, content_a).expect("delta a");
6441        let delta_b = encode_lcp_delta(content_a, content_b).expect("delta b");
6442
6443        let mut pack_a = Vec::new();
6444        pack_a.extend_from_slice(b"PACK");
6445        pack_a.extend_from_slice(&2u32.to_be_bytes());
6446        pack_a.extend_from_slice(&1u32.to_be_bytes());
6447        let off_a = pack_a.len() as u64;
6448        append_ref_delta(&mut pack_a, &oid_b, &delta_a);
6449        append_sha1_pack_trailer(&mut pack_a);
6450
6451        let mut pack_b = Vec::new();
6452        pack_b.extend_from_slice(b"PACK");
6453        pack_b.extend_from_slice(&2u32.to_be_bytes());
6454        pack_b.extend_from_slice(&1u32.to_be_bytes());
6455        let off_b = pack_b.len() as u64;
6456        append_ref_delta(&mut pack_b, &oid_a, &delta_b);
6457        append_sha1_pack_trailer(&mut pack_b);
6458
6459        let dir = tempfile::tempdir().expect("tempdir");
6460        let objects = dir.path().join("objects");
6461        let idx_b = install_synthetic_pack(&objects, "cycle-b", &pack_b, &[(oid_b, off_b)]);
6462        let idx_a = install_synthetic_pack(&objects, "cycle-a", &pack_a, &[(oid_a, off_a)]);
6463        assert_eq!(idx_a.len(), 1);
6464        assert_eq!(idx_b.len(), 1);
6465        assert!(idx_b.contains(&oid_b).expect("contains"));
6466        let _ = idx_b;
6467
6468        let err = read_object_from_pack(&idx_a, &oid_a).expect_err("cross-pack cycle");
6469        assert!(
6470            matches!(err, Error::DeltaChainTooDeep { limit: 4096 }),
6471            "expected typed delta chain limit, got {err:?}"
6472        );
6473    }
6474
6475    #[test]
6476    fn cross_pack_ref_delta_resolves_through_odb_read() {
6477        let _guard = pack_cache_test_guard();
6478        clear_pack_cache();
6479        let tmp = tempfile::tempdir().expect("tempdir");
6480        let objects = tmp.path().join("objects");
6481        let odb = Odb::new(&objects);
6482        let mut random = 17u64;
6483        let base: Vec<u8> = (0..20000)
6484            .map(|_| {
6485                random ^= random << 13;
6486                random ^= random >> 7;
6487                random ^= random << 17;
6488                random as u8
6489            })
6490            .collect();
6491        let mut target = base.clone();
6492        target.extend_from_slice(b"target suffix");
6493        let oid_base = odb.hash(ObjectKind::Blob, &base).expect("hash");
6494        let delta = encode_lcp_delta(&base, &target).expect("delta");
6495        let oid_target = odb.hash(ObjectKind::Blob, &target).expect("hash");
6496
6497        let mut pack_base = Vec::new();
6498        pack_base.extend_from_slice(b"PACK");
6499        pack_base.extend_from_slice(&2u32.to_be_bytes());
6500        pack_base.extend_from_slice(&1u32.to_be_bytes());
6501        let off_base = append_whole_blob(&mut pack_base, &base);
6502        append_sha1_pack_trailer(&mut pack_base);
6503
6504        let mut pack_tip = Vec::new();
6505        pack_tip.extend_from_slice(b"PACK");
6506        pack_tip.extend_from_slice(&2u32.to_be_bytes());
6507        pack_tip.extend_from_slice(&1u32.to_be_bytes());
6508        let off_tip = pack_tip.len() as u64;
6509        append_ref_delta(&mut pack_tip, &oid_base, &delta);
6510        append_sha1_pack_trailer(&mut pack_tip);
6511
6512        install_synthetic_pack(&objects, "base", &pack_base, &[(oid_base, off_base)]);
6513        install_synthetic_pack(&objects, "tip", &pack_tip, &[(oid_target, off_tip)]);
6514
6515        for reader in [&odb, &odb.with_bounded_pack_cache()] {
6516            let got = reader.read(&oid_target).expect("cross-pack ref-delta");
6517            assert_eq!(got.kind, ObjectKind::Blob);
6518            assert_eq!(got.data, target);
6519            assert_eq!(reader.hash(got.kind, &got.data).expect("hash"), oid_target);
6520        }
6521    }
6522
6523    #[test]
6524    fn corrupt_in_pack_base_rescued_from_loose() {
6525        let _guard = pack_cache_test_guard();
6526        let tmp = tempfile::tempdir().expect("tempdir");
6527        let objects = tmp.path().join("objects");
6528        std::fs::create_dir_all(objects.join("pack")).expect("dirs");
6529        let odb = Odb::new(&objects);
6530        let base = b"good-base-bytes";
6531        let tip = b"good-base-bytes!";
6532        let oid_base = odb.write_local(ObjectKind::Blob, base).expect("loose base");
6533        let delta = encode_lcp_delta(base, tip).expect("delta");
6534        let oid_tip = odb.hash(ObjectKind::Blob, tip).expect("hash");
6535
6536        let mut pack = Vec::new();
6537        pack.extend_from_slice(b"PACK");
6538        pack.extend_from_slice(&2u32.to_be_bytes());
6539        pack.extend_from_slice(&2u32.to_be_bytes());
6540        let off_base = append_corrupt_whole_blob(&mut pack);
6541        let off_tip = pack.len() as u64;
6542        append_ref_delta(&mut pack, &oid_base, &delta);
6543        append_sha1_pack_trailer(&mut pack);
6544
6545        let pack_path = objects.join("pack/rescue.pack");
6546        std::fs::write(&pack_path, &pack).expect("write pack");
6547        let idx = pack_index_for_objects(pack_path, &[(oid_base, off_base), (oid_tip, off_tip)]);
6548
6549        let got = read_object_from_pack(&idx, &oid_tip).expect("rescue read");
6550        assert_eq!(got.kind, ObjectKind::Blob);
6551        assert_eq!(got.data, tip);
6552    }
6553
6554    #[test]
6555    fn pack_added_after_first_lookup_found_on_miss() {
6556        let _guard = pack_cache_test_guard();
6557        clear_pack_cache();
6558        let tmp = tempfile::tempdir().expect("tempdir");
6559        let objects = tmp.path().join("objects");
6560        let pack_dir = objects.join("pack");
6561        let (pack_a, oid_a, off_a) = single_blob_pack(b"pack-a");
6562        install_synthetic_pack(&objects, "first", &pack_a, &[(oid_a, off_a)]);
6563        pack_cache::test_reset_dir_rescan_count(&pack_dir);
6564
6565        read_object_from_packs(&objects, &oid_a).expect("warm cache");
6566        assert_eq!(
6567            pack_cache::test_dir_rescan_count(&pack_dir),
6568            1,
6569            "initial lookup should scan the pack directory once"
6570        );
6571
6572        // Ensure the pack directory mtime advances past the cached stamp (second resolution).
6573        std::thread::sleep(std::time::Duration::from_secs(1));
6574
6575        let (pack_b, oid_b, off_b) = single_blob_pack(b"pack-b");
6576        install_synthetic_pack_no_clear(&objects, "second", &pack_b, &[(oid_b, off_b)]);
6577        assert_eq!(
6578            pack_cache::test_dir_rescan_count(&pack_dir),
6579            1,
6580            "adding a pack must not rescan until a miss"
6581        );
6582
6583        read_object_from_packs(&objects, &oid_b).expect("new pack via miss path");
6584        assert_eq!(
6585            pack_cache::test_dir_rescan_count(&pack_dir),
6586            2,
6587            "miss path should re-scan when pack dir mtime changed"
6588        );
6589    }
6590
6591    #[test]
6592    fn known_object_lookup_does_not_rescan() {
6593        let _guard = pack_cache_test_guard();
6594        clear_pack_cache();
6595        let tmp = tempfile::tempdir().expect("tempdir");
6596        let objects = tmp.path().join("objects");
6597        let pack_dir = objects.join("pack");
6598        pack_cache::test_reset_dir_rescan_count(&pack_dir);
6599        let (pack, oid, off) = single_blob_pack(b"cached-blob");
6600        install_synthetic_pack(&objects, "only", &pack, &[(oid, off)]);
6601
6602        read_object_from_packs(&objects, &oid).expect("prime cache");
6603        let after_first = pack_cache::test_dir_rescan_count(&pack_dir);
6604        for _ in 0..64 {
6605            read_object_from_packs(&objects, &oid).expect("repeat read");
6606        }
6607        assert_eq!(
6608            pack_cache::test_dir_rescan_count(&pack_dir),
6609            after_first,
6610            "repeated hits must not re-scan the pack directory"
6611        );
6612    }
6613
6614    #[test]
6615    fn two_repositories_concurrent_reads() {
6616        use crate::odb::Odb;
6617        use std::sync::Arc;
6618
6619        let _guard = pack_cache_test_guard();
6620        clear_pack_cache();
6621        let tmp_a = tempfile::tempdir().expect("tempdir a");
6622        let tmp_b = tempfile::tempdir().expect("tempdir b");
6623        let objects_a = tmp_a.path().join("objects");
6624        let objects_b = tmp_b.path().join("objects");
6625        let pack_a_dir = objects_a.join("pack");
6626        let pack_b_dir = objects_b.join("pack");
6627        let (pack_a, oid_a, off_a) = single_blob_pack(b"parallel-a");
6628        let (pack_b, oid_b, off_b) = single_blob_pack(b"parallel-b");
6629        install_synthetic_pack(&objects_a, "a", &pack_a, &[(oid_a, off_a)]);
6630        install_synthetic_pack(&objects_b, "b", &pack_b, &[(oid_b, off_b)]);
6631        let odb_a = Odb::new(&objects_a);
6632        let odb_b = Odb::new(&objects_b);
6633        assert!(
6634            !Arc::ptr_eq(odb_a.pack_store().unwrap(), odb_b.pack_store().unwrap()),
6635            "independent Odb opens must not share pack stores"
6636        );
6637        pack_cache::test_reset_dir_rescan_count_on_store(odb_a.pack_store().unwrap(), &pack_a_dir);
6638        pack_cache::test_reset_dir_rescan_count_on_store(odb_b.pack_store().unwrap(), &pack_b_dir);
6639
6640        let barrier = Arc::new(std::sync::Barrier::new(2));
6641        let b0 = Arc::clone(&barrier);
6642        let b1 = Arc::clone(&barrier);
6643        let odb_a_t = odb_a.clone();
6644        let odb_b_t = odb_b.clone();
6645        let id_a = oid_a;
6646        let id_b = oid_b;
6647
6648        let h1 = std::thread::spawn(move || {
6649            b0.wait();
6650            odb_a_t.read(&id_a).expect("read a");
6651        });
6652        let h2 = std::thread::spawn(move || {
6653            b1.wait();
6654            odb_b_t.read(&id_b).expect("read b");
6655        });
6656        h1.join().expect("thread a");
6657        h2.join().expect("thread b");
6658
6659        assert_eq!(
6660            pack_cache::test_dir_rescan_count_on_store(odb_a.pack_store().unwrap(), &pack_a_dir),
6661            1
6662        );
6663        assert_eq!(
6664            pack_cache::test_dir_rescan_count_on_store(odb_b.pack_store().unwrap(), &pack_b_dir),
6665            1
6666        );
6667    }
6668
6669    #[test]
6670    fn stale_cached_pack_bytes_revalidated_before_corruption_error() {
6671        let _guard = pack_cache_test_guard();
6672        clear_pack_cache();
6673        let tmp = tempfile::tempdir().expect("tempdir");
6674        let objects = tmp.path().join("objects");
6675        let (pack, oid, off) = single_blob_pack(b"revalidate-me");
6676        let idx = install_synthetic_pack(&objects, "good", &pack, &[(oid, off)]);
6677        read_object_from_pack(&idx, &oid).expect("prime pack bytes cache");
6678
6679        let good_on_disk = std::fs::read(&idx.pack_path).expect("read pack");
6680        pack_cache::test_inject_stale_pack_bytes(
6681            &idx.pack_path,
6682            PackData::from_owned(b"BAD".to_vec()),
6683        );
6684        std::fs::write(&idx.pack_path, &good_on_disk).expect("refresh on-disk pack");
6685        let got = read_object_from_pack(&idx, &oid).expect("revalidate from disk");
6686        assert_eq!(got.data, b"revalidate-me");
6687    }
6688
6689    #[test]
6690    fn replaced_pack_same_path_reread_after_invalidation() {
6691        let _guard = pack_cache_test_guard();
6692        clear_pack_cache();
6693        let tmp = tempfile::tempdir().expect("tempdir");
6694        let objects = tmp.path().join("objects");
6695        std::fs::create_dir_all(objects.join("pack")).expect("pack dir");
6696        let pack_path = objects.join("pack/repack-me.pack");
6697        let idx_path = objects.join("pack/repack-me.idx");
6698        const OBJECT_OFFSET: u64 = 9000;
6699        let (oid_a, off_a) =
6700            write_sparse_single_blob_pack(tmp.path(), &pack_path, b"before-repack", OBJECT_OFFSET)
6701                .expect("sparse pack a");
6702        assert!(
6703            std::fs::metadata(&pack_path).expect("stat").len() > 8192,
6704            "fixture pack must exceed mmap owned-buffer threshold"
6705        );
6706        write_v2_idx_for_test(&idx_path, &pack_path, &[(oid_a, off_a)]).expect("idx a");
6707        let idx = read_pack_index(&idx_path).expect("parse idx a");
6708        read_object_from_pack(&idx, &oid_a).expect("prime cache");
6709
6710        let (oid_b, off_b) = write_sparse_single_blob_pack(
6711            tmp.path(),
6712            &pack_path,
6713            b"after-repack-content",
6714            OBJECT_OFFSET,
6715        )
6716        .expect("sparse pack b");
6717        write_v2_idx_for_test(&idx_path, &pack_path, &[(oid_b, off_b)]).expect("idx b");
6718
6719        assert!(
6720            pack_cache::revalidate_stale_pack_bytes(&pack_path).expect("revalidate"),
6721            "replacement must invalidate cached bytes"
6722        );
6723        let idx = read_pack_index(&idx_path).expect("reload idx after pack swap");
6724        let got = read_object_from_pack(&idx, &oid_b).expect("read from replaced pack");
6725        assert_eq!(got.data, b"after-repack-content");
6726    }
6727
6728    #[test]
6729    fn read_object_at_large_pack_offset_via_mmap() {
6730        use std::fs::OpenOptions;
6731        use std::io::{Seek, SeekFrom, Write};
6732
6733        let _guard = pack_cache_test_guard();
6734        clear_pack_cache();
6735        let tmp = tempfile::tempdir().expect("tempdir");
6736        let objects = tmp.path().join("objects");
6737        std::fs::create_dir_all(objects.join("pack")).expect("pack dir");
6738
6739        let data = b"large-offset-blob";
6740        let odb = Odb::new(tmp.path());
6741        let oid = odb.hash(ObjectKind::Blob, data).expect("hash");
6742
6743        let pack_path = objects.join("pack/large-off.pack");
6744        let mut file = OpenOptions::new()
6745            .create(true)
6746            .write(true)
6747            .read(true)
6748            .open(&pack_path)
6749            .expect("create sparse pack");
6750        file.write_all(b"PACK").expect("sig");
6751        file.write_all(&2u32.to_be_bytes()).expect("ver");
6752        file.write_all(&1u32.to_be_bytes()).expect("count");
6753        let object_offset: u64 = 1 << 32;
6754        file.seek(SeekFrom::Start(object_offset))
6755            .expect("seek past 4GiB");
6756        let mut tail = Vec::new();
6757        append_whole_blob(&mut tail, data);
6758        file.write_all(&tail).expect("object at huge offset");
6759        let trailer = sha1_trailer_for_open_file(&mut file).expect("trailer hash");
6760        file.write_all(trailer.as_bytes()).expect("append trailer");
6761        file.flush().expect("flush");
6762        let file_len = file.metadata().expect("meta").len();
6763        drop(file);
6764        assert!(file_len > object_offset);
6765
6766        let idx_path = pack_path.with_extension("idx");
6767        write_v2_pack_index_with_trailer(
6768            &idx_path,
6769            &[(oid, object_offset, 0)],
6770            trailer.as_bytes(),
6771            20,
6772            None,
6773        )
6774        .expect("idx");
6775
6776        let idx = read_pack_index(&idx_path).expect("parse idx");
6777        assert_eq!(idx.offset_at(0).expect("off"), object_offset);
6778        let got = read_object_from_pack(&idx, &oid).expect("read at large offset");
6779        assert_eq!(got.data, data);
6780    }
6781
6782    fn synthetic_index_entries(n: u32) -> Vec<(ObjectId, u64, u32)> {
6783        (0..n)
6784            .map(|i| {
6785                let digest = HashAlgo::Sha1.checksum_digest(&i.to_be_bytes());
6786                let oid = ObjectId::from_bytes(&digest).expect("oid");
6787                let offset = if i % 7 == 0 {
6788                    (1u64 << 31) + u64::from(i)
6789                } else {
6790                    12 + u64::from(i) * 40
6791                };
6792                (oid, offset, i.wrapping_mul(2_654_435_761))
6793            })
6794            .collect()
6795    }
6796
6797    #[test]
6798    fn index_entries_sort_in_place_on_any_thread_count() {
6799        for n in [0_u32, 1, 300, 20_000] {
6800            let mut reference = synthetic_index_entries(n);
6801            reference.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
6802            let mut fanout_ref = [0u32; 256];
6803            for (oid, _, _) in &reference {
6804                for slot in &mut fanout_ref[usize::from(oid.as_bytes()[0])..] {
6805                    *slot += 1;
6806                }
6807            }
6808            for threads in [None, Some(1), Some(2), Some(3), Some(8)] {
6809                let mut entries = synthetic_index_entries(n);
6810                let fanout = sort_index_entries(&mut entries, threads);
6811                assert_eq!(entries, reference, "n={n} threads={threads:?}");
6812                assert_eq!(fanout, fanout_ref, "n={n} threads={threads:?}");
6813            }
6814        }
6815    }
6816
6817    #[test]
6818    fn index_written_in_place_reads_back_every_row_with_large_offsets() {
6819        let tmp = tempfile::tempdir().expect("tempdir");
6820        let idx_path = tmp.path().join("p.idx");
6821        let rows = synthetic_index_entries(20_000);
6822        let trailer = [0x5a_u8; 20];
6823        let mut entries = rows.clone();
6824        write_v2_pack_index_sorting(&idx_path, &mut entries, &trailer, 20, Some(4))
6825            .expect("write idx");
6826        let idx = read_pack_index(&idx_path).expect("read idx");
6827        assert_eq!(idx.len(), rows.len());
6828        let mut want = rows;
6829        want.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
6830        for (pos, (oid, offset, crc)) in want.iter().enumerate() {
6831            assert_eq!(idx.oid_at(pos), oid.as_bytes(), "row {pos}");
6832            assert_eq!(idx.offset_at(pos).expect("offset"), *offset, "row {pos}");
6833            assert_eq!(idx.crc32_at(pos).expect("crc"), *crc, "row {pos}");
6834        }
6835
6836        let mut bad = vec![(ObjectId::from_bytes(&[1; 32]).expect("oid"), 12, 0)];
6837        assert!(matches!(
6838            write_v2_pack_index_sorting(&idx_path, &mut bad, &trailer, 20, None),
6839            Err(Error::CorruptObject(_))
6840        ));
6841        assert!(matches!(
6842            write_v2_pack_index_sorting(&idx_path, &mut entries, &trailer[..19], 20, None),
6843            Err(Error::CorruptObject(_))
6844        ));
6845    }
6846
6847    /// Hash pack bytes already written to `file` (from offset 0 through current EOF) for a SHA-1 trailer.
6848    fn sha1_trailer_for_open_file(file: &mut std::fs::File) -> Result<ObjectId> {
6849        use std::io::{Read, Seek, SeekFrom};
6850        let len = file.metadata().map_err(Error::Io)?.len();
6851        file.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
6852        let mut hasher = HashAlgo::Sha1.checksum_hasher();
6853        let mut buf = [0u8; 64 * 1024];
6854        let mut left = len;
6855        while left > 0 {
6856            let chunk = left.min(buf.len() as u64) as usize;
6857            let n = file.read(&mut buf[..chunk]).map_err(Error::Io)?;
6858            if n == 0 {
6859                break;
6860            }
6861            hasher.update(&buf[..n]);
6862            left -= n as u64;
6863        }
6864        let digest = hasher.finalize();
6865        ObjectId::from_bytes(&digest).map_err(|e| Error::InvalidObjectId(format!("{e}")))
6866    }
6867
6868    #[test]
6869    fn cleared_cache_sees_removed_packs_disappear() {
6870        let _guard = pack_cache_test_guard();
6871        clear_pack_cache();
6872        let tmp = tempfile::tempdir().expect("tempdir");
6873        let objects = tmp.path().join("objects");
6874        let (pack, oid, off) = single_blob_pack(b"ephemeral");
6875        install_synthetic_pack(&objects, "gone", &pack, &[(oid, off)]);
6876        read_object_from_packs(&objects, &oid).expect("warm cache");
6877
6878        let pack_path = objects.join("pack/gone.pack");
6879        let idx_path = objects.join("pack/gone.idx");
6880        std::fs::remove_file(&pack_path).expect("remove pack");
6881        std::fs::remove_file(&idx_path).expect("remove idx");
6882        clear_pack_cache();
6883
6884        let indexes = read_local_pack_indexes_cached(&objects).expect("rescan");
6885        assert!(
6886            indexes.is_empty(),
6887            "after clear, listing should not retain removed packs"
6888        );
6889    }
6890
6891    #[test]
6892    fn repack_while_sibling_odb_holds_store_reprepares_on_miss() {
6893        use crate::odb::Odb;
6894
6895        let _guard = pack_cache_test_guard();
6896        clear_pack_cache();
6897        let tmp = tempfile::tempdir().expect("tempdir");
6898        let objects = tmp.path().join("objects");
6899        let (pack_old, oid_old, off_old) = single_blob_pack(b"before");
6900        install_synthetic_pack(&objects, "old", &pack_old, &[(oid_old, off_old)]);
6901        let odb_a = Odb::new(&objects);
6902        let odb_b = Odb::new(&objects);
6903        assert!(
6904            !std::sync::Arc::ptr_eq(odb_a.pack_store().unwrap(), odb_b.pack_store().unwrap()),
6905            "sibling Odbs must have distinct pack stores"
6906        );
6907        odb_b.read(&oid_old).expect("warm b store");
6908
6909        std::fs::remove_file(objects.join("pack/old.pack")).expect("rm pack");
6910        std::fs::remove_file(objects.join("pack/old.idx")).expect("rm idx");
6911        let (pack_new, oid_new, off_new) = single_blob_pack(b"after");
6912        install_synthetic_pack(&objects, "new", &pack_new, &[(oid_new, off_new)]);
6913        filetime::set_file_mtime(objects.join("pack"), filetime::FileTime::now())
6914            .expect("bump pack dir mtime for reprepare");
6915        odb_a.invalidate_packs();
6916
6917        // B's store is unchanged by A's invalidation; a miss on the new oid rescans pack/.
6918        let got = odb_b.read(&oid_new).expect("b reprepares after miss");
6919        assert_eq!(got.data.as_slice(), b"after");
6920    }
6921}
6922#[cfg(test)]
6923mod cached_lookup_tests {
6924    use super::*;
6925    use std::fs;
6926    use std::process::Command;
6927    use tempfile::TempDir;
6928
6929    fn git(dir: &Path, args: &[&str]) {
6930        let out = Command::new("git") // hygiene: git fixture comparison in cfg(test) module
6931            .current_dir(dir)
6932            .args(args)
6933            .env("GIT_CONFIG_GLOBAL", "/dev/null")
6934            .env("GIT_CONFIG_SYSTEM", "/dev/null")
6935            .env("GIT_AUTHOR_NAME", "t")
6936            .env("GIT_AUTHOR_EMAIL", "t@example.com")
6937            .env("GIT_COMMITTER_NAME", "t")
6938            .env("GIT_COMMITTER_EMAIL", "t@example.com")
6939            .output()
6940            .expect("spawn git");
6941        assert!(
6942            out.status.success(),
6943            "git {:?}: {}",
6944            args,
6945            String::from_utf8_lossy(&out.stderr)
6946        );
6947    }
6948
6949    fn repack_all(dir: &Path) {
6950        git(dir, &["repack", "-a", "-d"]);
6951    }
6952
6953    fn init_repo_with_pack(dir: &Path) -> ObjectId {
6954        git(dir, &["init", "-q"]);
6955        std::fs::write(dir.join("blob.txt"), b"pack cached lookup fixture\n").unwrap();
6956        git(dir, &["add", "blob.txt"]);
6957        git(dir, &["commit", "-qm", "c"]);
6958        repack_all(dir);
6959        let hex = Command::new("git") // hygiene: git fixture comparison in cfg(test) module
6960            .current_dir(dir)
6961            .args(["rev-parse", "HEAD^{tree}"])
6962            .env("GIT_CONFIG_GLOBAL", "/dev/null")
6963            .env("GIT_CONFIG_SYSTEM", "/dev/null")
6964            .output()
6965            .unwrap();
6966        let tree_hex = String::from_utf8(hex.stdout).unwrap();
6967        ObjectId::from_hex(tree_hex.trim()).unwrap()
6968    }
6969
6970    fn pack_dir(objects: &Path) -> PathBuf {
6971        objects.join("pack")
6972    }
6973
6974    fn v2_idx_path(objects: &Path) -> PathBuf {
6975        pack_dir(objects)
6976            .read_dir()
6977            .unwrap()
6978            .filter_map(|e| e.ok())
6979            .map(|e| e.path())
6980            .find(|p| p.extension().is_some_and(|x| x == "idx"))
6981            .expect("pack idx")
6982    }
6983
6984    /// Replace the repository's v2 `.idx` with an equivalent v1-format index.
6985    fn convert_pack_idx_to_v1(objects: &Path) -> PathBuf {
6986        let idx_path = v2_idx_path(objects);
6987        let parsed = read_pack_index(&idx_path).expect("v2 idx");
6988        let mut body = Vec::new();
6989        for slot in parsed.fanout {
6990            body.extend_from_slice(&slot.to_be_bytes());
6991        }
6992        for entry in parsed.iter() {
6993            body.extend_from_slice(&(entry.offset().expect("off") as u32).to_be_bytes());
6994            body.extend_from_slice(entry.oid());
6995        }
6996        body.extend_from_slice(HashAlgo::Sha1.checksum_digest(&body).as_slice());
6997        rewrite_test_file(&idx_path, &body);
6998        clear_pack_cache();
6999        idx_path
7000    }
7001
7002    fn rewrite_test_file(path: &Path, body: &[u8]) {
7003        let _ = fs::remove_file(path);
7004        fs::write(path, body).unwrap();
7005    }
7006
7007    fn synthetic_fanout_index(entries: &[(Vec<u8>, u64)]) -> PackIndex {
7008        let mut sorted = entries.to_vec();
7009        sorted.sort_by(|a, b| a.0.cmp(&b.0));
7010        let slices: Vec<&[u8]> = sorted.iter().map(|(o, _)| o.as_slice()).collect();
7011        let fanout = compute_fanout_from_oid_slices(&slices);
7012        let mut body = Vec::new();
7013        for f in fanout {
7014            body.extend_from_slice(&f.to_be_bytes());
7015        }
7016        for (oid, off) in sorted {
7017            body.extend_from_slice(&(off as u32).to_be_bytes());
7018            body.extend_from_slice(&oid);
7019        }
7020        body.extend_from_slice(HashAlgo::Sha1.checksum_digest(&body).as_slice());
7021        parse_pack_index_bytes(Path::new("synthetic.idx"), body, false).expect("v1 idx")
7022    }
7023
7024    #[test]
7025    fn find_offset_fanout_bucket_first_last_and_miss() {
7026        let first = vec![0x05u8; 20];
7027        let mut mid = vec![0x05u8; 20];
7028        mid[19] = 1;
7029        let mut last = vec![0x05u8; 20];
7030        last[19] = 2;
7031        let miss = vec![0x06u8; 20];
7032        let idx =
7033            synthetic_fanout_index(&[(first.clone(), 12), (mid.clone(), 24), (last.clone(), 36)]);
7034
7035        let oid_first = ObjectId::from_bytes(&first).unwrap();
7036        let oid_mid = ObjectId::from_bytes(&mid).unwrap();
7037        let oid_last = ObjectId::from_bytes(&last).unwrap();
7038        let oid_miss = ObjectId::from_bytes(&miss).unwrap();
7039
7040        assert_eq!(idx.find_offset(&oid_first).expect("f"), Some(12));
7041        assert_eq!(idx.find_offset(&oid_mid).expect("m"), Some(24));
7042        assert_eq!(idx.find_offset(&oid_last).expect("l"), Some(36));
7043        assert_eq!(idx.find_offset(&oid_miss).expect("miss"), None);
7044    }
7045
7046    #[cfg_attr(
7047        not(feature = "full-tests"),
7048        ignore = "starts system git or is heavy; run with --features full-tests"
7049    )]
7050    #[test]
7051    fn cached_lookup_hit_v2_idx() {
7052        let _guard = pack_cache_test_guard();
7053        clear_pack_cache();
7054        let dir = TempDir::new().unwrap();
7055        let tree = init_repo_with_pack(dir.path());
7056        let objects = dir.path().join(".git").join("objects");
7057        let got = read_object_from_packs(&objects, &tree).expect("read tree from v2 pack");
7058        assert_eq!(got.kind, ObjectKind::Tree);
7059        // Second read must hit the in-memory cache (same result, no reparsing).
7060        let again = read_object_from_packs(&objects, &tree).expect("cached read");
7061        assert_eq!(again.data, got.data);
7062    }
7063
7064    #[cfg_attr(
7065        not(feature = "full-tests"),
7066        ignore = "starts system git or is heavy; run with --features full-tests"
7067    )]
7068    #[test]
7069    fn cached_lookup_hit_v1_idx() {
7070        let _guard = pack_cache_test_guard();
7071        clear_pack_cache();
7072        let dir = TempDir::new().unwrap();
7073        let tree = init_repo_with_pack(dir.path());
7074        let objects = dir.path().join(".git").join("objects");
7075        let v1 = convert_pack_idx_to_v1(&objects);
7076        assert!(pack_index_is_v1(&v1));
7077        let idx = read_pack_index_cached(&v1).expect("parse v1 idx");
7078        let off = idx
7079            .find_offset(&tree)
7080            .expect("fanout lookup in v1 idx")
7081            .expect("off");
7082        assert!(off > 0);
7083        let obj = read_object_from_pack(&idx, &tree).expect("read via v1 index");
7084        assert_eq!(obj.kind, ObjectKind::Tree);
7085    }
7086
7087    #[cfg_attr(
7088        not(feature = "full-tests"),
7089        ignore = "starts system git or is heavy; run with --features full-tests"
7090    )]
7091    #[test]
7092    fn cached_lookup_miss() {
7093        let _guard = pack_cache_test_guard();
7094        clear_pack_cache();
7095        let dir = TempDir::new().unwrap();
7096        init_repo_with_pack(dir.path());
7097        let objects = dir.path().join(".git").join("objects");
7098        let miss = ObjectId::from_hex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef").unwrap();
7099        assert!(read_object_from_packs(&objects, &miss).is_err());
7100        assert!(
7101            try_read_object_from_local_packs_cached(&objects, &miss, || {
7102                Err(Error::ObjectNotFound(miss.to_hex()))
7103            })
7104            .unwrap()
7105            .is_none()
7106        );
7107    }
7108
7109    #[cfg_attr(
7110        not(feature = "full-tests"),
7111        ignore = "starts system git or is heavy; run with --features full-tests"
7112    )]
7113    #[test]
7114    fn cached_lookup_loose_only() {
7115        let _guard = pack_cache_test_guard();
7116        clear_pack_cache();
7117        let dir = TempDir::new().unwrap();
7118        git(dir.path(), &["init", "-q"]);
7119        let out = Command::new("git") // hygiene: git fixture comparison in cfg(test) module
7120            .current_dir(dir.path())
7121            .args(["hash-object", "-w", "--stdin"])
7122            .env("GIT_CONFIG_GLOBAL", "/dev/null")
7123            .env("GIT_CONFIG_SYSTEM", "/dev/null")
7124            .stdin(std::process::Stdio::piped())
7125            .stdout(std::process::Stdio::piped())
7126            .spawn()
7127            .and_then(|mut c| {
7128                use std::io::Write;
7129                c.stdin
7130                    .as_mut()
7131                    .unwrap()
7132                    .write_all(b"loose-only\n")
7133                    .unwrap();
7134                c.wait_with_output()
7135            })
7136            .unwrap();
7137        assert!(out.status.success());
7138        let oid = ObjectId::from_hex(String::from_utf8(out.stdout).unwrap().trim()).unwrap();
7139        let objects = dir.path().join(".git").join("objects");
7140        assert!(try_read_object_from_local_packs_cached(&objects, &oid, || {
7141            Err(Error::ObjectNotFound(oid.to_hex()))
7142        })
7143        .unwrap()
7144        .is_none());
7145    }
7146
7147    #[cfg_attr(
7148        not(feature = "full-tests"),
7149        ignore = "starts system git or is heavy; run with --features full-tests"
7150    )]
7151    #[test]
7152    fn cached_lookup_multiple_packs() {
7153        let _guard = pack_cache_test_guard();
7154        clear_pack_cache();
7155        let dir = TempDir::new().unwrap();
7156        git(dir.path(), &["init", "-q"]);
7157        std::fs::write(dir.path().join("a.txt"), b"a").unwrap();
7158        git(dir.path(), &["add", "a.txt"]);
7159        git(dir.path(), &["commit", "-qm", "a"]);
7160        repack_all(dir.path());
7161        let oid_a = ObjectId::from_hex(
7162            String::from_utf8(
7163                Command::new("git") // hygiene: git fixture comparison in cfg(test) module // hygiene: git fixture comparison in cfg(test) module
7164                    .current_dir(dir.path())
7165                    .args(["rev-parse", "HEAD:a.txt"])
7166                    .env("GIT_CONFIG_GLOBAL", "/dev/null")
7167                    .env("GIT_CONFIG_SYSTEM", "/dev/null")
7168                    .output()
7169                    .unwrap()
7170                    .stdout,
7171            )
7172            .unwrap()
7173            .trim(),
7174        )
7175        .unwrap();
7176        std::fs::write(dir.path().join("b.txt"), b"b").unwrap();
7177        git(dir.path(), &["add", "b.txt"]);
7178        git(dir.path(), &["commit", "-qm", "b"]);
7179        git(dir.path(), &["repack", "-d"]);
7180        let oid_b = ObjectId::from_hex(
7181            String::from_utf8(
7182                Command::new("git") // hygiene: git fixture comparison in cfg(test) module // hygiene: git fixture comparison in cfg(test) module
7183                    .current_dir(dir.path())
7184                    .args(["rev-parse", "HEAD:b.txt"])
7185                    .env("GIT_CONFIG_GLOBAL", "/dev/null")
7186                    .env("GIT_CONFIG_SYSTEM", "/dev/null")
7187                    .output()
7188                    .unwrap()
7189                    .stdout,
7190            )
7191            .unwrap()
7192            .trim(),
7193        )
7194        .unwrap();
7195        let objects = dir.path().join(".git").join("objects");
7196        let pack_count = pack_dir(&objects)
7197            .read_dir()
7198            .unwrap()
7199            .filter_map(|e| e.ok())
7200            .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
7201            .count();
7202        assert!(
7203            pack_count >= 2,
7204            "expected multiple pack files, got {pack_count}"
7205        );
7206        let obj_a = read_object_from_packs(&objects, &oid_a).expect("pack a");
7207        let obj_b = read_object_from_packs(&objects, &oid_b).expect("pack b");
7208        assert_eq!(obj_a.kind, ObjectKind::Blob);
7209        assert_eq!(obj_b.kind, ObjectKind::Blob);
7210    }
7211
7212    #[cfg_attr(
7213        not(feature = "full-tests"),
7214        ignore = "starts system git or is heavy; run with --features full-tests"
7215    )]
7216    #[test]
7217    fn packed_delta_base_oid_uses_cached_index() {
7218        let _guard = pack_cache_test_guard();
7219        clear_pack_cache();
7220        let dir = TempDir::new().unwrap();
7221        init_repo_with_pack(dir.path());
7222        let objects = dir.path().join(".git").join("objects");
7223        let miss = ObjectId::from_hex("0101010101010101010101010101010101010101").unwrap();
7224        assert!(packed_delta_base_oid(&objects, &miss).unwrap().is_none());
7225    }
7226
7227    #[test]
7228    fn content_addressed_pack_bytes_skip_stat_revalidation() {
7229        let _guard = pack_cache_test_guard();
7230        clear_pack_cache();
7231        let dir = tempfile::tempdir().expect("tempdir");
7232        let pack_path = dir
7233            .path()
7234            .join("pack-1111111111111111111111111111111111111111.pack");
7235        let v1 = b"PACK\x00\x00\x00\x02\x00\x00\x00\x00";
7236        std::fs::write(&pack_path, v1).expect("write v1");
7237        let cached = read_pack_bytes_cached(&pack_path).expect("prime cache");
7238        assert_eq!(&cached[..], v1);
7239
7240        let v2 = b"PACK-replaced-by-repack";
7241        std::fs::write(&pack_path, v2).expect("overwrite on disk");
7242        let again = read_pack_bytes_cached(&pack_path).expect("cached without stat");
7243        assert_eq!(
7244            &again[..],
7245            v1,
7246            "content-addressed name pins cached bytes in-process"
7247        );
7248    }
7249
7250    #[test]
7251    fn temporary_pack_bytes_served_from_cache_until_cleared() {
7252        let _guard = pack_cache_test_guard();
7253        clear_pack_cache();
7254        let dir = tempfile::tempdir().expect("tempdir");
7255        for pack_path in [
7256            dir.path().join("tmp_pack_abc123.pack"),
7257            dir.path().join("pack-custom.pack"),
7258        ] {
7259            clear_pack_cache();
7260            let v1 = b"PACK-temp-v1";
7261            std::fs::write(&pack_path, v1).expect("write v1");
7262            let _ = read_pack_bytes_cached(&pack_path).expect("prime cache");
7263
7264            let v2 = b"PACK-temp-v2-longer-body";
7265            std::fs::write(&pack_path, v2).expect("overwrite pack");
7266            let stale = read_pack_bytes_cached(&pack_path).expect("cache hit");
7267            assert_eq!(
7268                &stale[..],
7269                v1,
7270                "{} serves cached bytes without stat on hit",
7271                pack_path.display()
7272            );
7273            clear_pack_cache();
7274            let fresh = read_pack_bytes_cached(&pack_path).expect("after clear");
7275            assert_eq!(
7276                &fresh[..],
7277                v2,
7278                "{} reloads from disk after clear",
7279                pack_path.display()
7280            );
7281        }
7282    }
7283    #[cfg_attr(
7284        not(feature = "full-tests"),
7285        ignore = "starts system git or is heavy; run with --features full-tests"
7286    )]
7287    #[test]
7288    fn system_git_repack_requires_clear_pack_cache_for_fresh_listing() {
7289        let _guard = pack_cache_test_guard();
7290        clear_pack_cache();
7291        let dir = TempDir::new().unwrap();
7292        init_repo_with_pack(dir.path());
7293        let objects = dir.path().join(".git").join("objects");
7294        read_local_pack_indexes_cached(&objects).expect("warm listing");
7295        for entry in pack_dir(&objects).read_dir().unwrap().flatten() {
7296            let path = entry.path();
7297            if path.extension().is_some_and(|e| e == "pack" || e == "idx") {
7298                fs::remove_file(path).unwrap();
7299            }
7300        }
7301        clear_pack_cache();
7302        let indexes =
7303            read_local_pack_indexes_cached(&objects).expect("list after repack-like delete");
7304        assert!(
7305            indexes.is_empty(),
7306            "callers must clear_pack_cache after removing pack files"
7307        );
7308    }
7309
7310    fn test_oid(n: u8) -> ObjectId {
7311        ObjectId::from_hex(&format!("{n:040x}")).unwrap()
7312    }
7313
7314    fn max_delta_map_chain_edges(map: &HashMap<ObjectId, ObjectId>) -> usize {
7315        let value_set: HashSet<ObjectId> = map.values().copied().collect();
7316        let tips: Vec<ObjectId> = map
7317            .keys()
7318            .copied()
7319            .filter(|k| !value_set.contains(k))
7320            .collect();
7321        let mut max = 0usize;
7322        for tip in tips {
7323            let mut edges = 0usize;
7324            let mut cur = tip;
7325            let mut seen = HashSet::new();
7326            while seen.insert(cur) {
7327                let Some(&b) = map.get(&cur) else {
7328                    break;
7329                };
7330                edges += 1;
7331                cur = b;
7332            }
7333            max = max.max(edges);
7334        }
7335        max
7336    }
7337
7338    #[test]
7339    fn apply_delta_depth_limit_hits_exact_cap_on_seventy_edge_chain() {
7340        let n = 70usize;
7341        let oids: Vec<ObjectId> = (0..n as u8).map(test_oid).collect();
7342        let mut map: HashMap<ObjectId, ObjectId> = HashMap::new();
7343        for i in 1..n {
7344            map.insert(oids[i], oids[i - 1]);
7345        }
7346        let original = map.clone();
7347        apply_delta_depth_limit(&mut map, 50);
7348        assert_eq!(max_delta_map_chain_edges(&map), 50);
7349        let preserved = map
7350            .iter()
7351            .filter(|(t, b)| original.get(t) == Some(b))
7352            .count();
7353        assert!(
7354            preserved > 40,
7355            "non-cut edges should keep original bases (preserved {preserved})"
7356        );
7357    }
7358
7359    #[test]
7360    fn apply_delta_depth_limit_leaves_short_chains_untouched() {
7361        let a = test_oid(1);
7362        let b = test_oid(2);
7363        let c = test_oid(3);
7364        let mut map = HashMap::from([(b, a), (c, b)]);
7365        apply_delta_depth_limit(&mut map, 50);
7366        assert_eq!(map.get(&b), Some(&a));
7367        assert_eq!(map.get(&c), Some(&b));
7368        assert_eq!(max_delta_map_chain_edges(&map), 2);
7369    }
7370
7371    #[test]
7372    fn apply_delta_depth_limit_caps_long_chain_and_preserves_blobs() {
7373        let n = 120usize;
7374        let oids: Vec<ObjectId> = (0..n as u8).map(test_oid).collect();
7375        let mut blobs: HashMap<ObjectId, Vec<u8>> = HashMap::new();
7376        blobs.insert(oids[0], b"base".to_vec());
7377        for i in 1..n {
7378            let mut next = blobs[&oids[i - 1]].clone();
7379            next.push(i as u8);
7380            blobs.insert(oids[i], next);
7381        }
7382        let mut map: HashMap<ObjectId, ObjectId> = HashMap::new();
7383        for i in 1..n {
7384            map.insert(oids[i], oids[i - 1]);
7385        }
7386        let original = map.clone();
7387        apply_delta_depth_limit(&mut map, 50);
7388        assert_eq!(
7389            max_delta_map_chain_edges(&map),
7390            50,
7391            "long chain must cap at exactly max_depth edges"
7392        );
7393        let preserved = map
7394            .iter()
7395            .filter(|(target, base)| original.get(target) == Some(base))
7396            .count();
7397        assert!(
7398            preserved > 60,
7399            "segment links should stay on original bases (preserved {preserved} of {})",
7400            map.len()
7401        );
7402
7403        use crate::delta_encode::encode_prefix_extension_delta;
7404        use crate::unpack_objects::apply_delta;
7405
7406        for (&target, &base) in &map {
7407            let delta = encode_prefix_extension_delta(&blobs[&base], &blobs[&target]).unwrap();
7408            let got = apply_delta(&blobs[&base], &delta).unwrap();
7409            assert_eq!(got, blobs[&target]);
7410        }
7411    }
7412
7413    #[cfg_attr(
7414        not(feature = "full-tests"),
7415        ignore = "starts system git or is heavy; run with --features full-tests"
7416    )]
7417    #[test]
7418    fn v2_idx_crc_matches_crc32fast_over_entry_bytes() {
7419        let _guard = pack_cache_test_guard();
7420        clear_pack_cache();
7421        let dir = TempDir::new().unwrap();
7422        let tree = init_repo_with_pack(dir.path());
7423        let objects = dir.path().join(".git").join("objects");
7424        let idx_path = v2_idx_path(&objects);
7425        let idx = read_pack_index(&idx_path).expect("read idx");
7426        let pack_bytes = std::fs::read(&idx.pack_path).expect("read pack");
7427        for entry in idx.iter() {
7428            let crc = entry.crc32().expect("v2 idx entry crc");
7429            let off = entry.offset().expect("entry off");
7430            let start = off as usize;
7431            let end = super::pack_entry_raw_end(&idx, &pack_bytes, off)
7432                .expect("entry end")
7433                .expect("end");
7434            assert_eq!(crc32fast::hash(&pack_bytes[start..end]), crc);
7435        }
7436        let _ = tree;
7437    }
7438
7439    #[cfg_attr(
7440        not(feature = "full-tests"),
7441        ignore = "starts system git or is heavy; run with --features full-tests"
7442    )]
7443    #[test]
7444    fn packed_full_object_slice_returns_on_disk_bytes() {
7445        let _guard = pack_cache_test_guard();
7446        clear_pack_cache();
7447        let dir = TempDir::new().unwrap();
7448        let tree = init_repo_with_pack(dir.path());
7449        let objects = dir.path().join(".git").join("objects");
7450        let idx = read_pack_index(&v2_idx_path(&objects)).expect("idx");
7451        let pack_bytes = std::fs::read(&idx.pack_path).expect("pack");
7452        let off = idx.find_offset(&tree).expect("tree in pack").expect("off");
7453        let start = off as usize;
7454        let end = super::pack_entry_raw_end(&idx, &pack_bytes, off)
7455            .expect("entry end")
7456            .expect("end");
7457        let expected = &pack_bytes[start..end];
7458        let slice = packed_full_object_slice(&objects, &tree)
7459            .expect("lookup")
7460            .expect("full tree slice");
7461        assert_eq!(slice, expected);
7462    }
7463
7464    fn touch_mtime(path: &Path, when: filetime::FileTime) {
7465        filetime::set_file_mtime(path, when).expect("set mtime");
7466    }
7467
7468    #[cfg_attr(
7469        not(feature = "full-tests"),
7470        ignore = "starts system git or is heavy; run with --features full-tests"
7471    )]
7472    #[test]
7473    fn packed_full_object_slice_crc_mismatch_falls_back_to_redundant_pack() {
7474        let _guard = pack_cache_test_guard();
7475        clear_pack_cache();
7476        let dir = TempDir::new().unwrap();
7477        let tree = init_repo_with_pack(dir.path());
7478        let objects = dir.path().join(".git").join("objects");
7479        let pack_dir = pack_dir(&objects);
7480        let primary_idx = read_pack_index(&v2_idx_path(&objects)).expect("idx");
7481        let primary_pack = primary_idx.pack_path.clone();
7482        let primary_idx_path = primary_idx.idx_path.clone();
7483
7484        let redundant_pack = pack_dir.join("pack-redundant.pack");
7485        let redundant_idx = pack_dir.join("pack-redundant.idx");
7486        fs::copy(&primary_pack, &redundant_pack).expect("copy pack");
7487        fs::copy(&primary_idx_path, &redundant_idx).expect("copy idx");
7488
7489        let older = filetime::FileTime::from_unix_time(1_700_000_000, 0);
7490        let newer = filetime::FileTime::from_unix_time(1_700_000_100, 0);
7491        touch_mtime(&primary_pack, older);
7492        touch_mtime(&primary_idx_path, older);
7493        touch_mtime(&redundant_pack, newer);
7494        touch_mtime(&redundant_idx, newer);
7495        clear_pack_cache();
7496
7497        let off = primary_idx
7498            .find_offset(&tree)
7499            .expect("tree offset")
7500            .expect("off");
7501        let start = off as usize;
7502        let end = super::pack_entry_raw_end(&primary_idx, &fs::read(&primary_pack).unwrap(), off)
7503            .expect("entry end")
7504            .expect("end");
7505        let flip = start + (end - start) / 2;
7506        let mut corrupt = fs::read(&redundant_pack).expect("read redundant");
7507        corrupt[flip] ^= 0x01;
7508        rewrite_test_file(&redundant_pack, &corrupt);
7509        clear_pack_cache();
7510
7511        let recorded_crc = read_pack_index(&redundant_idx)
7512            .expect("redundant idx")
7513            .crc32_for_pack_offset(off)
7514            .expect("crc")
7515            .expect("crc value");
7516        let corrupt_slice = &corrupt[start..end];
7517        assert_ne!(
7518            crc32fast::hash(corrupt_slice),
7519            recorded_crc,
7520            "test must flip bytes without updating idx CRC"
7521        );
7522        assert!(
7523            packed_full_object_slice(&objects, &tree)
7524                .expect("lookup")
7525                .is_some(),
7526            "valid redundant pack should serve the slice after CRC rejects corrupt copy"
7527        );
7528
7529        let obj = read_object_from_packs(&objects, &tree).expect("read tree");
7530        assert_eq!(obj.kind, ObjectKind::Tree);
7531        let slice = packed_full_object_slice(&objects, &tree)
7532            .expect("lookup")
7533            .expect("slice");
7534        let good_pack = fs::read(&primary_pack).expect("primary pack");
7535        assert_eq!(&slice, &good_pack[start..end]);
7536    }
7537
7538    #[test]
7539    fn wanted_index_entries_lookups_match_a_full_scan() {
7540        let tmp = TempDir::new().unwrap();
7541        let bare = grit_test_support::git_packed_history(tmp.path(), 30);
7542        let objects = bare.join("objects");
7543        let indexes = read_local_pack_indexes_cached(&objects).unwrap();
7544        assert_eq!(indexes.len(), 1);
7545        let idx = &indexes[0];
7546        let all: Vec<(ObjectId, u64)> = idx
7547            .iter()
7548            .map(|e| (ObjectId::from_bytes(e.oid()).unwrap(), e.offset().unwrap()))
7549            .collect();
7550        assert!(all.len() > 64);
7551        let absent = ObjectId::from_hex("0000000000000000000000000000000000000001").unwrap();
7552        let sorted = |mut v: Vec<(ObjectId, u64)>| {
7553            v.sort();
7554            v
7555        };
7556
7557        let few: HashSet<ObjectId> = [all[3].0, all[all.len() - 1].0, absent].into();
7558        let looked_up = wanted_index_entries(idx, few.iter(), |oid| few.contains(oid)).unwrap();
7559        assert_eq!(sorted(looked_up), sorted(vec![all[3], all[all.len() - 1]]));
7560
7561        let many: HashSet<ObjectId> = all.iter().map(|(oid, _)| *oid).chain([absent]).collect();
7562        let scanned = wanted_index_entries(idx, many.iter(), |oid| many.contains(oid)).unwrap();
7563        assert_eq!(sorted(scanned), sorted(all));
7564
7565        let slots = locate_packed_objects(&objects, &few.iter().copied().collect()).unwrap();
7566        assert_eq!(slots.len(), 2);
7567        assert!(!slots.contains_key(&absent));
7568    }
7569}