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