Skip to main content

grit_lib/odb/
mod.rs

1//! Loose object database: reading and writing zlib-compressed Git objects.
2//!
3//! Git stores objects as files under `<git-dir>/objects/<xx>/<38-hex-chars>`,
4//! where the path is derived from the SHA-1 digest. Each file is a zlib-
5//! compressed byte sequence whose decompressed form is:
6//!
7//! ```text
8//! "<type> <size>\0<data>"
9//! ```
10//!
11//! # Usage
12//!
13//! Files-backed repositories (default):
14//!
15//! ```no_run
16//! use std::path::Path;
17//! use grit_lib::objects::odb::Odb;
18//!
19//! let odb = Odb::new(Path::new(".git/objects"));
20//! ```
21//!
22//! Custom primary store — see [`OdbBuilder`] and [`crate::repo::RepositoryBuilder`].
23
24pub mod builder;
25mod commit;
26mod peel;
27pub mod store;
28mod stream;
29
30#[doc(inline)]
31pub use builder::OdbBuilder;
32
33pub(crate) use store::loose::{
34    decompress_zlib_loose_bytes, for_each_loose_object_id, parse_object_bytes,
35    read_loose_object_info,
36};
37#[doc(inline)]
38pub use store::LooseStore;
39pub use store::{CompositeStore, FilesSource, ObjectStream};
40#[doc(inline)]
41pub use store::{MemoryStore, ObjectStore, WritableObjectStore};
42
43use crate::git_path::CanonicalizeExt;
44use std::collections::{HashMap, HashSet};
45use std::fs;
46use std::hash::{Hash, Hasher};
47use std::ops::ControlFlow;
48use std::path::{Path, PathBuf};
49#[cfg(test)]
50use std::sync::atomic::AtomicUsize;
51use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
52use std::sync::{Arc, Mutex, OnceLock, RwLock};
53
54use crate::config::ConfigSet;
55use crate::diagnostics::NullDiagnostics;
56use crate::error::{Error, Result};
57use crate::hash;
58use crate::midx::validate_midx_referenced_packs_with_diagnostics;
59use crate::objects::{
60    oid_hash_set, HashAlgo, Object, ObjectId, ObjectInfo, ObjectKind, OidHashMap, OidHashSet,
61};
62use crate::pack;
63use crate::pack_store::PackStore;
64use filetime::{set_file_mtime, FileTime};
65use flate2::Compression;
66
67pub(crate) use store::BoundedRead;
68use store::{LocalSizedRead, PackedObjects};
69
70/// How repack/gc retains unreachable objects stored only in packs about to be deleted.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub(crate) enum UnreachablePackRetention {
73    /// Keep every unreachable object (matches `git repack -ad` with `-k` / `gc.pruneExpire=never`).
74    KeepAll,
75    /// Keep unreachable objects whose pack mtime is not strictly older than the cutoff.
76    KeepGraceProtected(std::time::SystemTime),
77    /// Drop unreachable objects when their pack is removed (immediate prune).
78    DropAll,
79}
80
81type MemOdbOverlayStore = Arc<RwLock<Option<Arc<MemoryStore>>>>;
82
83/// Cached alternate [`CompositeStore`] keyed by file/env/submodule alternate state.
84struct AlternateSourcesCache {
85    file_generation: u64,
86    env_fingerprint: u64,
87    submodule_generation: u64,
88    sources: Option<Arc<CompositeStore>>,
89}
90
91struct AlternateSourcesKey {
92    file_generation: u64,
93    env_fingerprint: u64,
94    submodule_generation: u64,
95}
96
97/// Cached `info/alternates` chain for one [`Odb`] (generation bumps on invalidation/reload).
98struct FileAlternatesCache {
99    generation: u64,
100    snapshot: Option<Arc<Vec<PathBuf>>>,
101}
102
103/// Options for [`Odb::write_with_options`].
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105pub struct WriteOptions {
106    /// When set, do not touch mtimes on existing objects (Git `WRITE_OBJECT_SILENT`).
107    pub silent: bool,
108    /// When set, skip the full `Odb::exists` scan (packs/alternates) and only
109    /// test for a loose file in this repository's object directory before writing.
110    ///
111    /// Bulk staging of new worktree blobs uses this so each insert does not walk
112    /// every pack on a miss.
113    pub assume_loose_only_existence: bool,
114    /// When set, skip existence probes and write when the loose path is absent
115    /// (bulk add of all-new blobs).
116    pub trust_new_loose: bool,
117    /// When set, skip `create_dir_all` on the loose prefix directory (call
118    /// [`Odb::ensure_all_loose_prefix_dirs`] first).
119    pub loose_prefix_dirs_ready: bool,
120}
121
122/// [`WriteOptions`] for parallel staging of new worktree blobs (bulk `git add`).
123pub const BULK_NEW_LOOSE_WRITE: WriteOptions = WriteOptions {
124    assume_loose_only_existence: true,
125    trust_new_loose: true,
126    silent: false,
127    loose_prefix_dirs_ready: true,
128};
129
130/// Parallel restage of tracked modifications (objects may already be loose or packed).
131pub const BULK_TRACKED_RESTAGE_LOOSE_WRITE: WriteOptions = WriteOptions {
132    assume_loose_only_existence: true,
133    trust_new_loose: false,
134    silent: false,
135    loose_prefix_dirs_ready: true,
136};
137
138/// True when `oid` is stored as a loose object or in a **non-promisor** local pack.
139///
140/// Non-promisor pack membership is checked before loose storage so packed objects
141/// avoid a per-probe loose `stat` on the hot path. A non-promisor pack hit already
142/// implies the object is materialized locally, including when a loose copy also exists.
143fn exists_materialized_in_objects_dir(objects_dir: &Path, oid: &ObjectId) -> bool {
144    store::local_object_materialized(objects_dir, oid)
145}
146
147/// The oids from `oids` for which `keep` answers `Ok(true)`, stopping at the first error.
148fn filter_oids(
149    oids: impl IntoIterator<Item = ObjectId>,
150    mut keep: impl FnMut(&ObjectId) -> Result<bool>,
151) -> Result<Vec<ObjectId>> {
152    oids.into_iter()
153        .filter_map(|oid| keep(&oid).map(|k| k.then_some(oid)).transpose())
154        .collect()
155}
156
157fn objects_dir_has_pack_index_files(objects_dir: &Path) -> bool {
158    let pack_dir = objects_dir.join("pack");
159    let Ok(entries) = fs::read_dir(pack_dir) else {
160        return false;
161    };
162    entries
163        .filter_map(|e| e.ok())
164        .any(|ent| ent.path().extension().is_some_and(|ext| ext == "idx"))
165}
166
167/// A loose-object database rooted at a given `objects/` directory.
168///
169/// # Examples
170///
171/// ```
172/// use grit_lib::objects::{ObjectKind, odb::Odb};
173/// use std::path::Path;
174///
175/// # fn main() -> grit_lib::error::Result<()> {
176/// let dir = tempfile::tempdir()?;
177/// let objects = dir.path().join("objects");
178/// std::fs::create_dir_all(&objects)?;
179/// let odb = Odb::new(&objects);
180/// let oid = odb.write(ObjectKind::Blob, b"hello")?;
181/// let obj = odb.read(&oid)?;
182/// assert_eq!(obj.data, b"hello");
183/// # Ok(())
184/// # }
185/// ```
186#[derive(Clone)]
187pub struct Odb {
188    objects_dir: PathBuf,
189    /// Work tree root for resolving relative alternate env paths.
190    work_tree: Option<PathBuf>,
191    /// Embedded submodule object stores registered for this read pass (Git `register_all_submodule_sources`).
192    submodule_alternate_dirs: Arc<Mutex<Vec<PathBuf>>>,
193    /// When set, used to read `core.multiPackIndex` (and related) for MIDX-backed object reads.
194    config_git_dir: Option<PathBuf>,
195    /// Cache for `core.multiPackIndex` — populated on first lookup.
196    ///
197    /// Reading this config requires loading the system/global/local config cascade and reparsing
198    /// every file; the value cannot change for a process that has opened a single repository, so
199    /// caching it here avoids re-loading the cascade for every object read.
200    core_multi_pack_index_cache: Arc<OnceLock<bool>>,
201    /// Whether `objects/pack` contains at least one `.idx` file (directory probe, once per Odb).
202    local_pack_indexes_seen: Arc<OnceLock<bool>>,
203    /// `info/alternates` chain resolved lazily; generation bumps on invalidation/reload.
204    file_alternate_dirs_cache: Arc<RwLock<FileAlternatesCache>>,
205    #[cfg(test)]
206    exists_probe: Arc<AtomicUsize>,
207    #[cfg(test)]
208    pub(crate) hot_path_test_metrics: Arc<crate::hot_path_test_metrics::HotPathTestMetrics>,
209    /// Explicit env alternates from [`Self::with_env_alternate_dirs`] (tests / callers); empty uses lazy env read.
210    env_alternate_dirs: Vec<PathBuf>,
211    /// Lazily parsed `GIT_ALTERNATE_OBJECT_DIRECTORIES` (first alternate lookup only).
212    env_alternate_lazy: Arc<OnceLock<Arc<Vec<PathBuf>>>>,
213    /// When `Some`, object writes are redirected into this in-memory overlay instead of being
214    /// persisted to the loose store (Git's tmp-objdir). Reads consult the overlay first. This
215    /// mirrors `git merge-tree --quiet`, which performs a full merge but must leave the object
216    /// database untouched (no new loose objects).
217    mem_overlay: MemOdbOverlayStore,
218    /// Whether [`Self::enable_mem_overlay`] activated the in-memory overlay (avoids locking on reads).
219    mem_overlay_active: Arc<AtomicBool>,
220    /// Lazily built [`FilesSource`] for [`Self::objects_dir`].
221    primary_source: Arc<OnceLock<Arc<FilesSource>>>,
222    /// Alternate object dirs as a [`CompositeStore`] (generation-bumped with file alternates).
223    alternate_sources_cache: Arc<RwLock<AlternateSourcesCache>>,
224    /// Whether MIDX pack validation has run for this [`Odb`] (Git warns once per process).
225    midx_packs_validated: Arc<OnceLock<()>>,
226    /// The repository's object hash algorithm (`extensions.objectformat`),
227    /// detected lazily from the config and cached. Determines the hash used
228    /// when writing objects. Defaults to SHA-1 when no config is available.
229    hash_algo_cache: Arc<OnceLock<HashAlgo>>,
230    /// Zlib level for loose-object writes, resolved from config and cached.
231    loose_zlib_cache: Arc<OnceLock<Compression>>,
232    /// Zlib level for pack-objects output, resolved from config and cached.
233    pack_zlib_cache: Arc<OnceLock<Compression>>,
234    /// `core.bigFileThreshold`, resolved from config and cached.
235    big_file_threshold_cache: Arc<OnceLock<u64>>,
236    /// Shared with [`crate::repo::Repository`] so config is loaded once per open handle.
237    shared_config_state: Option<crate::repo::RepositoryConfigSnapshot>,
238    /// One-time sync of `core.deltaBaseCacheLimit` into the process-wide pack delta-base LRU.
239    delta_base_cache_sync: Arc<OnceLock<()>>,
240    /// Environment paired with [`Self::shared_config_state`] for repository-scoped config cache.
241    shared_environment: Option<Arc<crate::environment::Environment>>,
242    /// Fallback when [`Self::shared_environment`] is unset (repo-local config only).
243    empty_config_env: Arc<OnceLock<crate::environment::Environment>>,
244    /// Pack files whose mtimes were already bumped for object freshening on this [`Odb`]
245    /// (Git's `packed_git->freshened`: at most one `utimensat` per pack per process).
246    freshened_packs: Arc<Mutex<HashSet<PathBuf>>>,
247    /// Repository-scoped pack/MIDX read caches (shared by [`Odb`] clones).
248    pack_store: Arc<PackStore>,
249    /// Cached [`PackStore`] handles for alternate object directories (lazy).
250    alternate_pack_stores: Arc<Mutex<HashMap<PathBuf, Arc<PackStore>>>>,
251    /// Bumps when [`Self::register_submodule_object_directories_from_index`] repopulates submodule sources.
252    submodule_sources_generation: Arc<AtomicU64>,
253    /// When set, replaces the lazy [`FilesSource`] primary ([`OdbBuilder::primary`]).
254    custom_primary: Option<Arc<dyn WritableObjectStore>>,
255    /// Read-only layers from [`OdbBuilder::push_read_source`], after primary, before alternates.
256    extra_read_sources: Vec<Arc<dyn ObjectStore>>,
257    /// When false, alternate object directories are not consulted ([`OdbBuilder::alternates`]).
258    alternates_enabled: bool,
259    /// Avoid re-reading the same loose object repeatedly during one repository session.
260    session_loose_reads: Arc<std::sync::Mutex<SessionLooseReads>>,
261    /// Parsed tree entries reused for the lifetime of this [`Odb`] handle (Git object cache).
262    session_tree_entries: Arc<Mutex<SessionTreeEntries>>,
263}
264
265/// Largest loose object [`Odb::read_unless_larger`] keeps in the session read cache. Its callers
266/// (checkout) promise bounded memory, so a cache of up to a few hundred entries must not be
267/// able to pin hundreds of near-limit blobs.
268const SESSION_BOUNDED_LOOSE_CACHE_MAX: usize = 64 * 1024;
269
270/// Largest blob a general [`Odb::read`] keeps in the session read cache. Tiny blobs that are
271/// read again and again (attributes, submodule and mailmap files) stay cached, while the
272/// blobs a diff or merge reads once each pin at most this much per cache entry.
273const SESSION_READ_BLOB_CACHE_MAX: usize = 4 * 1024;
274
275/// Objects the session read cache holds before it starts over.
276const SESSION_LOOSE_CACHE_MAX_ENTRIES: usize = 256;
277
278/// Payload bytes the session read cache holds before it starts over, so no mix of reads can
279/// make it pin more than this however large the objects are.
280const SESSION_LOOSE_CACHE_MAX_BYTES: usize = 16 * 1024 * 1024;
281
282/// Recently read objects reused within one [`Odb`] session, bounded by entry count and by
283/// total payload bytes. When either bound would be exceeded the cache starts over.
284#[derive(Default)]
285struct SessionLooseReads {
286    objects: OidHashMap<Object>,
287    bytes: usize,
288}
289
290impl SessionLooseReads {
291    fn get(&self, oid: &ObjectId) -> Option<&Object> {
292        self.objects.get(oid)
293    }
294
295    fn insert(&mut self, oid: ObjectId, obj: &Object) {
296        let len = obj.data.len();
297        if len > SESSION_LOOSE_CACHE_MAX_BYTES {
298            return;
299        }
300        if self.objects.len() > SESSION_LOOSE_CACHE_MAX_ENTRIES
301            || self.bytes.saturating_add(len) > SESSION_LOOSE_CACHE_MAX_BYTES
302        {
303            self.objects.clear();
304            self.bytes = 0;
305        }
306        if let Some(old) = self.objects.insert(oid, obj.clone()) {
307            self.bytes = self.bytes.saturating_sub(old.data.len());
308        }
309        self.bytes += len;
310    }
311
312    #[cfg(test)]
313    fn is_empty(&self) -> bool {
314        self.objects.is_empty()
315    }
316}
317
318/// Recently parsed trees for [`Odb::cached_tree_entries`], bounded by total entry count.
319#[derive(Default)]
320struct SessionTreeEntries {
321    trees: OidHashMap<Arc<Vec<crate::objects::TreeEntry>>>,
322    entries: usize,
323}
324
325impl SessionTreeEntries {
326    /// Total tree entries kept before the cache starts over; a bigger tree is never
327    /// kept. A parsed entry costs roughly 80 bytes, so this holds a few MiB: enough
328    /// for the trees a diff of neighbouring commits revisits, without growing with
329    /// history size.
330    const ENTRY_BUDGET: usize = 1 << 16;
331
332    fn insert(&mut self, oid: ObjectId, tree: Arc<Vec<crate::objects::TreeEntry>>) {
333        if tree.len() > Self::ENTRY_BUDGET {
334            return;
335        }
336        if self.entries + tree.len() > Self::ENTRY_BUDGET {
337            self.trees.clear();
338            self.entries = 0;
339        }
340        self.entries += tree.len();
341        self.trees.insert(oid, tree);
342    }
343}
344
345#[cfg(test)]
346mod session_tree_entries_tests {
347    use super::*;
348    use crate::objects::TreeEntry;
349
350    fn tree(entries: usize) -> Arc<Vec<TreeEntry>> {
351        let entry = TreeEntry {
352            mode: 0o100644,
353            name: b"f".to_vec(),
354            oid: ObjectId::zero(),
355        };
356        Arc::new(vec![entry; entries])
357    }
358
359    fn oid(n: u8) -> ObjectId {
360        ObjectId::from_bytes(&[n; 20]).expect("oid")
361    }
362
363    #[test]
364    fn cache_starts_over_when_the_entry_budget_is_exceeded() {
365        let mut cache = SessionTreeEntries::default();
366        let half = SessionTreeEntries::ENTRY_BUDGET / 2;
367        cache.insert(oid(1), tree(half));
368        cache.insert(oid(2), tree(half));
369        assert_eq!(cache.entries, SessionTreeEntries::ENTRY_BUDGET);
370        assert_eq!(cache.trees.len(), 2);
371
372        cache.insert(oid(3), tree(1));
373        assert_eq!(cache.trees.len(), 1);
374        assert!(cache.trees.contains_key(&oid(3)));
375        assert_eq!(cache.entries, 1);
376
377        cache.insert(oid(4), tree(SessionTreeEntries::ENTRY_BUDGET + 1));
378        assert!(
379            !cache.trees.contains_key(&oid(4)),
380            "an oversized tree is not cached"
381        );
382        assert!(cache.trees.contains_key(&oid(3)));
383        assert!(cache.entries <= SessionTreeEntries::ENTRY_BUDGET);
384    }
385}
386
387const _: () = {
388    fn assert_send_sync<T: Send + Sync>() {}
389    let _ = assert_send_sync::<Odb>;
390};
391
392impl std::fmt::Debug for Odb {
393    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
394        f.debug_struct("Odb")
395            .field("objects_dir", &self.objects_dir)
396            .field("work_tree", &self.work_tree)
397            .field("submodule_alternate_dirs", &"<mutex>")
398            .field("config_git_dir", &self.config_git_dir)
399            .finish()
400    }
401}
402
403/// Resolve the repository hash algorithm from `extensions.objectformat` in `git_dir`'s config.
404///
405/// Uses the same config parser as [`Odb::hash_algo`], including case-insensitive section and
406/// key names. Defaults to [`HashAlgo::Sha1`] when the extension is absent or unreadable.
407#[must_use]
408pub fn hash_algo_for_git_dir(env: &crate::environment::Environment, git_dir: &Path) -> HashAlgo {
409    ConfigSet::load(env, Some(git_dir), true)
410        .ok()
411        .and_then(|cfg| cfg.get("extensions.objectformat"))
412        .and_then(|v| HashAlgo::from_name(&v))
413        .unwrap_or(HashAlgo::Sha1)
414}
415
416/// Like [`hash_algo_for_git_dir`] but takes an `objects/` directory path.
417#[must_use]
418pub fn hash_algo_for_objects_dir(
419    env: &crate::environment::Environment,
420    objects_dir: &Path,
421) -> HashAlgo {
422    objects_dir
423        .parent()
424        .map(|d| hash_algo_for_git_dir(env, d))
425        .unwrap_or(HashAlgo::Sha1)
426}
427
428impl Odb {
429    /// Create an [`Odb`] pointing at the given `objects/` directory.
430    ///
431    /// The directory does not need to exist yet; it will be created on the
432    /// first write operation. Does not read `GIT_ALTERNATE_OBJECT_DIRECTORIES`;
433    /// use [`Self::env_alternate_dirs_from_var`] and [`Self::with_env_alternate_dirs`]
434    /// (or open via [`crate::repo::Repository`], which resolves env alternates lazily on first use).
435    #[must_use]
436    pub fn new(objects_dir: &Path) -> Self {
437        Self {
438            objects_dir: objects_dir.to_path_buf(),
439            work_tree: None,
440            submodule_alternate_dirs: Arc::new(Mutex::new(Vec::new())),
441            config_git_dir: None,
442            core_multi_pack_index_cache: Arc::new(OnceLock::new()),
443            local_pack_indexes_seen: Arc::new(OnceLock::new()),
444            file_alternate_dirs_cache: Arc::new(RwLock::new(FileAlternatesCache {
445                generation: 0,
446                snapshot: None,
447            })),
448            env_alternate_dirs: Vec::new(),
449            env_alternate_lazy: Arc::new(OnceLock::new()),
450            mem_overlay: Arc::new(RwLock::new(None)),
451            mem_overlay_active: Arc::new(AtomicBool::new(false)),
452            primary_source: Arc::new(OnceLock::new()),
453            alternate_sources_cache: Arc::new(RwLock::new(AlternateSourcesCache {
454                file_generation: 0,
455                env_fingerprint: 0,
456                submodule_generation: 0,
457                sources: None,
458            })),
459            midx_packs_validated: Arc::new(OnceLock::new()),
460            hash_algo_cache: Arc::new(OnceLock::new()),
461            loose_zlib_cache: Arc::new(OnceLock::new()),
462            pack_zlib_cache: Arc::new(OnceLock::new()),
463            big_file_threshold_cache: Arc::new(OnceLock::new()),
464            shared_config_state: None,
465            delta_base_cache_sync: Arc::new(OnceLock::new()),
466            shared_environment: None,
467            empty_config_env: Arc::new(OnceLock::new()),
468            freshened_packs: Arc::new(Mutex::new(HashSet::new())),
469            pack_store: Arc::new(PackStore::new(objects_dir.to_path_buf())),
470            alternate_pack_stores: Arc::new(Mutex::new(HashMap::new())),
471            submodule_sources_generation: Arc::new(AtomicU64::new(0)),
472            custom_primary: None,
473            extra_read_sources: Vec::new(),
474            alternates_enabled: true,
475            session_loose_reads: Arc::new(Mutex::new(SessionLooseReads::default())),
476            session_tree_entries: Arc::new(Mutex::new(SessionTreeEntries::default())),
477            #[cfg(test)]
478            exists_probe: Arc::new(AtomicUsize::new(0)),
479            #[cfg(test)]
480            hot_path_test_metrics: crate::hot_path_test_metrics::HotPathTestMetrics::new(),
481        }
482    }
483
484    /// Create an [`Odb`] with a work tree for resolving relative alternate paths.
485    ///
486    /// Does not read `GIT_ALTERNATE_OBJECT_DIRECTORIES`; pass env-derived paths with
487    /// [`Self::with_env_alternate_dirs`] (see [`Self::env_alternate_dirs_from_var`]).
488    #[must_use]
489    pub fn with_work_tree(objects_dir: &Path, work_tree: &Path) -> Self {
490        Self {
491            objects_dir: objects_dir.to_path_buf(),
492            work_tree: Some(work_tree.to_path_buf()),
493            submodule_alternate_dirs: Arc::new(Mutex::new(Vec::new())),
494            config_git_dir: None,
495            core_multi_pack_index_cache: Arc::new(OnceLock::new()),
496            local_pack_indexes_seen: Arc::new(OnceLock::new()),
497            file_alternate_dirs_cache: Arc::new(RwLock::new(FileAlternatesCache {
498                generation: 0,
499                snapshot: None,
500            })),
501            env_alternate_dirs: Vec::new(),
502            env_alternate_lazy: Arc::new(OnceLock::new()),
503            mem_overlay: Arc::new(RwLock::new(None)),
504            mem_overlay_active: Arc::new(AtomicBool::new(false)),
505            primary_source: Arc::new(OnceLock::new()),
506            alternate_sources_cache: Arc::new(RwLock::new(AlternateSourcesCache {
507                file_generation: 0,
508                env_fingerprint: 0,
509                submodule_generation: 0,
510                sources: None,
511            })),
512            midx_packs_validated: Arc::new(OnceLock::new()),
513            hash_algo_cache: Arc::new(OnceLock::new()),
514            loose_zlib_cache: Arc::new(OnceLock::new()),
515            pack_zlib_cache: Arc::new(OnceLock::new()),
516            big_file_threshold_cache: Arc::new(OnceLock::new()),
517            shared_config_state: None,
518            delta_base_cache_sync: Arc::new(OnceLock::new()),
519            shared_environment: None,
520            empty_config_env: Arc::new(OnceLock::new()),
521            freshened_packs: Arc::new(Mutex::new(HashSet::new())),
522            pack_store: Arc::new(PackStore::new(objects_dir.to_path_buf())),
523            alternate_pack_stores: Arc::new(Mutex::new(HashMap::new())),
524            submodule_sources_generation: Arc::new(AtomicU64::new(0)),
525            custom_primary: None,
526            extra_read_sources: Vec::new(),
527            alternates_enabled: true,
528            session_loose_reads: Arc::new(Mutex::new(SessionLooseReads::default())),
529            session_tree_entries: Arc::new(Mutex::new(SessionTreeEntries::default())),
530            #[cfg(test)]
531            exists_probe: Arc::new(AtomicUsize::new(0)),
532            #[cfg(test)]
533            hot_path_test_metrics: crate::hot_path_test_metrics::HotPathTestMetrics::new(),
534        }
535    }
536
537    /// Parsed tree entries for `oid`, cached on this [`Odb`] handle.
538    pub(crate) fn cached_tree_entries(
539        &self,
540        oid: &ObjectId,
541    ) -> Result<Arc<Vec<crate::objects::TreeEntry>>> {
542        if let Ok(guard) = self.session_tree_entries.lock() {
543            if let Some(entries) = guard.trees.get(oid) {
544                return Ok(Arc::clone(entries));
545            }
546        }
547        let obj = self.read_impl(oid, false)?;
548        if obj.kind != ObjectKind::Tree {
549            return Err(Error::CorruptObject(format!(
550                "expected tree, got {}",
551                obj.kind.as_str()
552            )));
553        }
554        let parsed = crate::objects::parse_tree_with_oid_len(&obj.data, self.hash_algo().len())?;
555        let arc = Arc::new(parsed);
556        if let Ok(mut guard) = self.session_tree_entries.lock() {
557            guard.insert(*oid, Arc::clone(&arc));
558        }
559        Ok(arc)
560    }
561
562    fn session_cached_loose_read(&self, oid: &ObjectId) -> Option<Object> {
563        self.session_loose_reads.lock().ok()?.get(oid).cloned()
564    }
565
566    /// Keep `obj` in the session read cache. Bounded checkout reads call this directly for
567    /// the small blobs they reuse; general reads go through [`Self::finish_session_loose_read`].
568    fn remember_session_loose_read(&self, oid: &ObjectId, obj: &Object) {
569        if let Ok(mut guard) = self.session_loose_reads.lock() {
570            guard.insert(*oid, obj);
571        }
572    }
573
574    /// Return a freshly read object, caching commits, trees, tags and tiny blobs for reuse
575    /// when `remember` is set.
576    ///
577    /// Larger blobs are not cached here (see [`SESSION_READ_BLOB_CACHE_MAX`]): diffs, merges
578    /// and blame read each blob about once, so a cached copy only costs an extra allocation
579    /// and keeps every blob of an operation resident until the cache starts over. Callers
580    /// that do re-read small blobs opt in through [`Self::read_unless_larger`].
581    fn finish_session_loose_read(
582        &self,
583        oid: &ObjectId,
584        obj: Object,
585        remember: bool,
586    ) -> Result<Object> {
587        if remember
588            && (obj.kind != ObjectKind::Blob || obj.data.len() <= SESSION_READ_BLOB_CACHE_MAX)
589        {
590            self.remember_session_loose_read(oid, &obj);
591        }
592        Ok(obj)
593    }
594
595    /// Hot-path regression counters (crate-internal tests only).
596    #[cfg(test)]
597    #[must_use]
598    pub(crate) fn hot_path_metrics(&self) -> &crate::hot_path_test_metrics::HotPathTestMetrics {
599        &self.hot_path_test_metrics
600    }
601
602    #[cfg(test)]
603    #[must_use]
604    pub(crate) fn hot_path_metrics_arc(
605        &self,
606    ) -> Arc<crate::hot_path_test_metrics::HotPathTestMetrics> {
607        Arc::clone(&self.hot_path_test_metrics)
608    }
609
610    /// Share the repository's lazy config snapshot (see [`crate::repo::Repository::config`]).
611    #[must_use]
612    pub(crate) fn with_shared_config_state(
613        mut self,
614        state: crate::repo::RepositoryConfigSnapshot,
615        env: Arc<crate::environment::Environment>,
616    ) -> Self {
617        self.pack_store.set_repo_caches(Arc::clone(&state));
618        self.shared_config_state = Some(state);
619        self.shared_environment = Some(env);
620        self
621    }
622
623    fn diagnostics_handle(&self) -> crate::diagnostics::DiagnosticsHandle {
624        self.shared_config_state
625            .as_ref()
626            .map(|state| state.diagnostics_handle())
627            .unwrap_or_else(|| Arc::new(NullDiagnostics))
628    }
629
630    fn load_config_cascade(&self) -> Result<ConfigSet> {
631        if let (Some(caches), Some(env)) = (&self.shared_config_state, &self.shared_environment) {
632            let git_dir = self
633                .config_git_dir
634                .as_deref()
635                .or_else(|| self.objects_dir.parent());
636            let cfg = caches.load_config(env.as_ref(), git_dir, true)?;
637            return Ok(cfg.as_ref().clone());
638        }
639        let git_dir = self
640            .config_git_dir
641            .as_deref()
642            .or_else(|| self.objects_dir.parent());
643        if let Some(git_dir) = git_dir {
644            ConfigSet::load(self.config_environment(), Some(git_dir), true)
645        } else {
646            Ok(ConfigSet::new())
647        }
648    }
649
650    fn config_environment(&self) -> &crate::environment::Environment {
651        if let Some(env) = self.shared_environment.as_deref() {
652            return env;
653        }
654        self.empty_config_env
655            .get_or_init(crate::environment::Environment::empty)
656    }
657
658    fn env_alternate_dirs_snapshot(&self) -> Arc<Vec<PathBuf>> {
659        if !self.env_alternate_dirs.is_empty() {
660            return Arc::new(self.env_alternate_dirs.clone());
661        }
662        Arc::clone(self.env_alternate_lazy.get_or_init(|| {
663            Arc::new(Self::env_alternate_dirs_from_var(
664                self.config_environment(),
665                self.work_tree.as_deref(),
666            ))
667        }))
668    }
669
670    /// Parse `GIT_ALTERNATE_OBJECT_DIRECTORIES` once for [`Self::with_env_alternate_dirs`].
671    ///
672    /// Relative entries are resolved against `resolve_base` (typically the work tree root).
673    #[must_use]
674    pub fn env_alternate_dirs_from_var(
675        env: &crate::environment::Environment,
676        resolve_base: Option<&Path>,
677    ) -> Vec<PathBuf> {
678        match env.var("GIT_ALTERNATE_OBJECT_DIRECTORIES") {
679            Some(val) if !val.is_empty() => {
680                let mut dirs = parse_alternate_env(&val);
681                if let Some(base) = resolve_base {
682                    for dir in &mut dirs {
683                        if dir.is_relative() {
684                            *dir = base.join(&dir);
685                        }
686                    }
687                }
688                dirs
689            }
690            _ => Vec::new(),
691        }
692    }
693
694    /// Attach env-derived alternate object directories (see [`Self::env_alternate_dirs_from_var`]).
695    #[must_use]
696    pub fn with_env_alternate_dirs(mut self, dirs: Vec<PathBuf>) -> Self {
697        self.env_alternate_dirs = dirs;
698        self.clear_alternate_sources_cache();
699        self
700    }
701
702    fn clear_alternate_sources_cache(&self) {
703        if let Ok(mut guard) = self.alternate_sources_cache.write() {
704            guard.sources = None;
705        }
706    }
707
708    fn alternate_sources_key(&self) -> AlternateSourcesKey {
709        AlternateSourcesKey {
710            file_generation: self
711                .file_alternate_dirs_cache
712                .read()
713                .map(|g| g.generation)
714                .unwrap_or(0),
715            env_fingerprint: self.env_alternate_fingerprint(),
716            submodule_generation: self.submodule_sources_generation.load(Ordering::Relaxed),
717        }
718    }
719
720    fn env_alternate_fingerprint(&self) -> u64 {
721        let mut hasher = std::collections::hash_map::DefaultHasher::new();
722        for path in self.env_alternate_dirs_snapshot().iter() {
723            path.hash(&mut hasher);
724        }
725        hasher.finish()
726    }
727
728    /// Drop the cached `info/alternates` chain so the next lookup re-reads the file.
729    pub fn invalidate_alternates_cache(&self) {
730        if let Ok(mut guard) = self.file_alternate_dirs_cache.write() {
731            guard.generation = guard.generation.wrapping_add(1);
732            guard.snapshot = None;
733        }
734        self.clear_alternate_sources_cache();
735    }
736
737    /// Reload `info/alternates` from disk into this [`Odb`]'s cache (used after external writes).
738    ///
739    /// # Errors
740    ///
741    /// Propagates failures from reading the alternates chain (I/O or malformed `info/alternates`).
742    pub fn refresh_file_alternates_from_disk(&self) -> Result<()> {
743        let snapshot = Arc::new(pack::read_alternates_recursive(&self.objects_dir)?);
744        if let Ok(mut guard) = self.file_alternate_dirs_cache.write() {
745            guard.generation = guard.generation.wrapping_add(1);
746            guard.snapshot = Some(snapshot);
747        }
748        self.clear_alternate_sources_cache();
749        Ok(())
750    }
751
752    /// Append `alternate_objects_dir` to `objects/info/alternates` (deduped) and refresh the cache.
753    ///
754    /// # Errors
755    ///
756    /// [`Error::Io`] when the alternates file cannot be read or written.
757    pub fn append_file_alternate(&self, alternate_objects_dir: &Path) -> Result<()> {
758        Self::append_alternate_objects_line(&self.objects_dir, alternate_objects_dir)?;
759        self.refresh_file_alternates_from_disk()?;
760        Ok(())
761    }
762
763    /// Append one absolute `objects` directory line to `objects/info/alternates` (deduped).
764    ///
765    /// Does not update any in-memory cache; pair with [`Self::refresh_file_alternates_from_disk`]
766    /// on the relevant [`Odb`] when the same process keeps using it.
767    pub fn append_alternate_objects_line(
768        objects_dir: &Path,
769        alternate_objects_dir: &Path,
770    ) -> Result<()> {
771        let info = objects_dir.join("info");
772        fs::create_dir_all(&info)?;
773        let alt_path = info.join("alternates");
774        let alt_abs = alternate_objects_dir
775            .canonicalize_simplified()
776            .unwrap_or_else(|_| alternate_objects_dir.to_path_buf());
777        let line = format!("{}\n", alt_abs.display());
778        let existing = match fs::read_to_string(&alt_path) {
779            Ok(s) => s,
780            Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
781            Err(e) => return Err(Error::Io(e)),
782        };
783        if existing
784            .lines()
785            .any(|l| l.trim() == alt_abs.to_string_lossy())
786        {
787            return Ok(());
788        }
789        let mut out = existing;
790        out.push_str(&line);
791        fs::write(alt_path, out).map_err(Error::Io)
792    }
793
794    fn file_alternate_dirs_snapshot(&self) -> Arc<Vec<PathBuf>> {
795        loop {
796            let observed_gen = self
797                .file_alternate_dirs_cache
798                .read()
799                .map(|g| g.generation)
800                .unwrap_or(0);
801            if let Ok(guard) = self.file_alternate_dirs_cache.read() {
802                if guard.generation == observed_gen {
803                    if let Some(snapshot) = &guard.snapshot {
804                        return Arc::clone(snapshot);
805                    }
806                }
807            }
808            if let Ok(mut guard) = self.file_alternate_dirs_cache.write() {
809                if guard.generation != observed_gen {
810                    continue;
811                }
812                if let Some(snapshot) = &guard.snapshot {
813                    return Arc::clone(snapshot);
814                }
815                let snapshot = Arc::new(
816                    pack::read_alternates_recursive(&self.objects_dir).unwrap_or_default(),
817                );
818                guard.snapshot = Some(Arc::clone(&snapshot));
819                return snapshot;
820            }
821            break;
822        }
823        Arc::new(Vec::new())
824    }
825
826    #[cfg(test)]
827    pub(crate) fn exists_probe_count(&self) -> usize {
828        self.exists_probe.load(Ordering::Relaxed)
829    }
830
831    #[cfg(test)]
832    pub(crate) fn reset_exists_probe(&self) {
833        self.exists_probe.store(0, Ordering::Relaxed);
834    }
835
836    /// Enable the in-memory write overlay (Git's tmp-objdir): subsequent [`Self::write`]/
837    /// [`Self::write_local`] calls keep objects only in memory, and [`Self::read`] consults that
838    /// overlay before the on-disk store. Used by `merge-tree --quiet` so a full merge can run
839    /// without persisting any new loose objects.
840    pub fn enable_mem_overlay(&self) {
841        self.mem_overlay_active.store(true, Ordering::Release);
842        if let Ok(mut guard) = self.mem_overlay.write() {
843            if guard.is_none() {
844                *guard = Some(Arc::new(MemoryStore::new(self.hash_algo())));
845            }
846        }
847    }
848
849    /// Disable the in-memory write overlay, discarding any objects accumulated in it.
850    pub fn disable_mem_overlay(&self) {
851        self.mem_overlay_active.store(false, Ordering::Release);
852        if let Ok(mut guard) = self.mem_overlay.write() {
853            *guard = None;
854        }
855    }
856
857    /// Whether the in-memory write overlay is currently enabled.
858    #[must_use]
859    pub(crate) fn is_mem_overlay_active(&self) -> bool {
860        self.mem_overlay_active.load(Ordering::Acquire)
861    }
862
863    fn overlay_active(&self) -> bool {
864        self.mem_overlay
865            .read()
866            .ok()
867            .and_then(|g| g.as_ref().map(|_| ()))
868            .is_some()
869    }
870
871    fn overlay_store(&self) -> Option<Arc<MemoryStore>> {
872        if !self.mem_overlay_active.load(Ordering::Acquire) {
873            return None;
874        }
875        self.mem_overlay.read().ok().and_then(|g| g.clone())
876    }
877
878    /// Number of objects stored in the active mem overlay (tests only).
879    #[cfg(test)]
880    pub(crate) fn mem_overlay_len_for_tests(&self) -> Option<usize> {
881        let store = self.overlay_store()?;
882        let mut count = 0usize;
883        store
884            .for_each_object(&mut |_| {
885                count += 1;
886                ControlFlow::Continue(())
887            })
888            .ok()?;
889        Some(count)
890    }
891
892    /// If the in-memory overlay is active, store `(kind, data)` under `oid` there and return
893    /// `true`; otherwise return `false` so the caller falls through to the on-disk path.
894    fn overlay_store_object(&self, oid: ObjectId, kind: ObjectKind, data: &[u8]) -> bool {
895        let Some(store) = self.overlay_store() else {
896            return false;
897        };
898        store.insert_object(oid, kind, Arc::from(data.to_owned()));
899        true
900    }
901
902    /// Read `oid` from the in-memory overlay, if active and present.
903    fn overlay_read(&self, oid: &ObjectId) -> Option<Object> {
904        let store = self.overlay_store()?;
905        store.read(oid).ok().flatten()
906    }
907
908    /// Primary on-disk object source for this repository's `objects/` directory.
909    ///
910    /// # Errors
911    ///
912    /// Propagates failures while opening pack, MIDX, or loose backends.
913    pub fn primary(&self) -> Result<Arc<FilesSource>> {
914        self.require_files_primary("primary")?;
915        if let Some(src) = self.primary_source.get() {
916            return Ok(Arc::clone(src));
917        }
918        let src = self.build_files_source(
919            &self.objects_dir,
920            self.multi_pack_index_reads_enabled(),
921            self.midx_tip_listing_enabled(),
922        )?;
923        match self.primary_source.set(Arc::clone(&src)) {
924            Ok(()) => Ok(src),
925            Err(existing) => Ok(existing),
926        }
927    }
928
929    /// Whether any alternate object directory is configured for this database.
930    #[must_use]
931    fn alternate_object_lookup_needed(&self) -> bool {
932        if !self.file_alternate_dirs_snapshot().is_empty() {
933            return true;
934        }
935        if !self.env_alternate_dirs_snapshot().is_empty() {
936            return true;
937        }
938        self.submodule_alternate_dirs
939            .lock()
940            .ok()
941            .is_some_and(|guard| !guard.is_empty())
942    }
943
944    /// Alternate object directories (`info/alternates`, env, submodules) as one composite store.
945    ///
946    /// # Errors
947    ///
948    /// Propagates failures while opening any alternate files source.
949    pub fn sources(&self) -> Result<Arc<CompositeStore>> {
950        if !self.alternates_enabled {
951            return Ok(Arc::new(CompositeStore::new(Vec::new(), self.hash_algo())));
952        }
953        let key = self.alternate_sources_key();
954        if let Ok(guard) = self.alternate_sources_cache.read() {
955            if guard.file_generation == key.file_generation
956                && guard.env_fingerprint == key.env_fingerprint
957                && guard.submodule_generation == key.submodule_generation
958            {
959                if let Some(sources) = guard.sources.as_ref() {
960                    return Ok(Arc::clone(sources));
961                }
962            }
963        }
964        let use_midx = self.multi_pack_index_reads_enabled();
965        let midx_tip = self.midx_tip_listing_enabled();
966        let mut stores: Vec<Arc<dyn ObjectStore>> = Vec::new();
967        for alt_dir in self.file_alternate_dirs_snapshot().iter() {
968            stores.push(self.build_files_source(alt_dir, use_midx, midx_tip)?);
969        }
970        for alt_dir in self.env_alternate_dirs_snapshot().iter() {
971            stores.push(self.build_files_source(alt_dir, use_midx, midx_tip)?);
972        }
973        if let Ok(guard) = self.submodule_alternate_dirs.lock() {
974            for alt_dir in guard.iter() {
975                stores.push(self.build_files_source(alt_dir, false, false)?);
976            }
977        }
978        let composite = Arc::new(CompositeStore::new(stores, self.hash_algo()));
979        if let Ok(mut guard) = self.alternate_sources_cache.write() {
980            guard.file_generation = key.file_generation;
981            guard.env_fingerprint = key.env_fingerprint;
982            guard.submodule_generation = key.submodule_generation;
983            guard.sources = Some(Arc::clone(&composite));
984        }
985        Ok(composite)
986    }
987
988    fn midx_tip_listing_enabled(&self) -> bool {
989        self.config_git_dir.is_some() && self.core_multi_pack_index_enabled()
990    }
991
992    fn build_files_source(
993        &self,
994        objects_dir: &Path,
995        use_midx: bool,
996        midx_tip_listing_for_exists: bool,
997    ) -> Result<Arc<FilesSource>> {
998        let loose = self.loose_at(objects_dir)?;
999        let pack_store = self.pack_store_for(objects_dir);
1000        let diagnostics = if objects_dir == self.objects_dir.as_path() {
1001            self.diagnostics_handle()
1002        } else {
1003            Arc::new(NullDiagnostics)
1004        };
1005        FilesSource::open(
1006            pack_store,
1007            loose,
1008            use_midx,
1009            midx_tip_listing_for_exists,
1010            diagnostics,
1011        )
1012        .map(Arc::new)
1013    }
1014
1015    /// Register `<submodule-git-dir>/objects` for every stage-0 gitlink in `index` that has a
1016    /// checkout under `work_tree`, so reads can resolve submodule commits stored only in the
1017    /// nested repository (matches Git's `odb_add_submodule_source_by_path` / `register_all_submodule_sources`).
1018    pub fn register_submodule_object_directories_from_index(
1019        &self,
1020        work_tree: &Path,
1021        index: &crate::index::Index,
1022    ) {
1023        use crate::diff::submodule_embedded_git_dir;
1024
1025        let Ok(mut dirs) = self.submodule_alternate_dirs.lock() else {
1026            return;
1027        };
1028        dirs.clear();
1029        for e in &index.entries {
1030            if e.stage() != 0 || e.mode != crate::index::MODE_GITLINK {
1031                continue;
1032            }
1033            let path_str = String::from_utf8_lossy(&e.path);
1034            let abs = work_tree.join(path_str.as_ref());
1035            let Some(sub_git) = submodule_embedded_git_dir(&abs) else {
1036                continue;
1037            };
1038            let objects = sub_git.join("objects");
1039            if !objects.is_dir() {
1040                continue;
1041            }
1042            let canon = objects.canonicalize_simplified().unwrap_or(objects);
1043            if !dirs.iter().any(|p| p == &canon) {
1044                dirs.push(canon);
1045            }
1046        }
1047        self.submodule_sources_generation
1048            .fetch_add(1, Ordering::Relaxed);
1049        self.clear_alternate_sources_cache();
1050    }
1051
1052    /// Attach a git directory so [`Self::read`] can honor `core.multiPackIndex` when resolving packed objects.
1053    #[must_use]
1054    pub fn with_config_git_dir(mut self, git_dir: PathBuf) -> Self {
1055        self.config_git_dir = Some(git_dir);
1056        self
1057    }
1058
1059    /// The repository's object hash algorithm, detected from
1060    /// `extensions.objectformat` and cached for the lifetime of this `Odb`.
1061    ///
1062    /// The git directory is taken from the attached `config_git_dir` when set,
1063    /// otherwise inferred as the parent of the `objects/` directory. Defaults
1064    /// to [`HashAlgo::Sha1`] when no config can be read.
1065    ///
1066    /// With a custom [`OdbBuilder::primary`] this is the store's own algorithm. A
1067    /// successful [`RepositoryBuilder::open`](crate::repo::RepositoryBuilder::open) has
1068    /// already checked that it equals the repository's object format; a standalone
1069    /// [`OdbBuilder::build`] reports the store's algorithm unchecked.
1070    #[must_use]
1071    pub fn hash_algo(&self) -> HashAlgo {
1072        if let Some(primary) = &self.custom_primary {
1073            return primary.hash_algo();
1074        }
1075        *self.hash_algo_cache.get_or_init(|| {
1076            if self.config_git_dir.is_none() && self.objects_dir.parent().is_none() {
1077                return HashAlgo::Sha1;
1078            }
1079            let cfg = self.load_config_cascade().unwrap_or_default();
1080            cfg.get("extensions.objectformat")
1081                .and_then(|v| HashAlgo::from_name(&v))
1082                .unwrap_or(HashAlgo::Sha1)
1083        })
1084    }
1085
1086    fn resolve_loose_zlib_level(&self) -> Result<u32> {
1087        if self.config_git_dir.is_none() && self.objects_dir.parent().is_none() {
1088            return Ok(ConfigSet::LOOSE_OBJECTS_ZLIB_DEFAULT as u32);
1089        }
1090        let cfg = self.load_config_cascade()?;
1091        Ok(cfg.loose_objects_zlib_level()? as u32)
1092    }
1093
1094    /// Zlib compression used for loose-object writes ([`ConfigSet::loose_objects_zlib_level`]),
1095    /// cached for the lifetime of this `Odb`.
1096    ///
1097    /// # Errors
1098    ///
1099    /// Returns errors from loading the config cascade or from invalid compression settings.
1100    pub(crate) fn loose_compression(&self) -> Result<Compression> {
1101        if let Some(cached) = self.loose_zlib_cache.get() {
1102            return Ok(*cached);
1103        }
1104        let level = self.resolve_loose_zlib_level()?;
1105        let compression = Compression::new(level);
1106        let _ = self.loose_zlib_cache.set(compression);
1107        Ok(compression)
1108    }
1109
1110    fn resolve_pack_zlib_level(&self) -> Result<i32> {
1111        if self.config_git_dir.is_none() && self.objects_dir.parent().is_none() {
1112            return Ok(6);
1113        }
1114        let cfg = self.load_config_cascade()?;
1115        cfg.pack_objects_zlib_level()
1116    }
1117
1118    /// Zlib compression for pack-objects ([`ConfigSet::pack_objects_zlib_level`]).
1119    ///
1120    /// # Errors
1121    ///
1122    /// Returns errors from loading the config cascade or from invalid compression settings.
1123    pub(crate) fn pack_compression(&self) -> Result<Compression> {
1124        if let Some(cached) = self.pack_zlib_cache.get() {
1125            return Ok(*cached);
1126        }
1127        let level = self.resolve_pack_zlib_level()?;
1128        let compression = crate::pack_zlib::flate2_compression_for_pack_level(level);
1129        let _ = self.pack_zlib_cache.set(compression);
1130        Ok(compression)
1131    }
1132
1133    /// `core.bigFileThreshold` ([`ConfigSet::big_file_threshold`]), cached for the lifetime of
1134    /// this `Odb`.
1135    ///
1136    /// Objects of at least this size are written by [`Self::write_stream`] into their own
1137    /// pack, and worktree files of at least this size that need no clean conversion are hashed
1138    /// and stored as a stream instead of being read into memory.
1139    ///
1140    /// # Errors
1141    ///
1142    /// Returns errors from loading the config cascade or from an invalid threshold value.
1143    pub fn big_file_threshold(&self) -> Result<u64> {
1144        if let Some(cached) = self.big_file_threshold_cache.get() {
1145            return Ok(*cached);
1146        }
1147        let threshold = if self.config_git_dir.is_none() && self.objects_dir.parent().is_none() {
1148            ConfigSet::BIG_FILE_THRESHOLD_DEFAULT
1149        } else {
1150            self.load_config_cascade()?.big_file_threshold()?
1151        };
1152        let _ = self.big_file_threshold_cache.set(threshold);
1153        Ok(threshold)
1154    }
1155
1156    /// An operation-local view with bounded pack residency and tree delta bases.
1157    /// Custom stores and overlays are preserved; file sources and alternates are rebuilt
1158    /// against private caches so this view cannot evict or reconfigure a caller's caches.
1159    pub(crate) fn with_bounded_pack_cache(&self) -> Self {
1160        let mut odb = self.clone();
1161        odb.pack_store = Arc::new(PackStore::for_bounded_reads(self.objects_dir.clone()));
1162        odb.primary_source = Arc::new(OnceLock::new());
1163        odb.session_loose_reads = Arc::new(Mutex::new(SessionLooseReads::default()));
1164        odb.alternate_pack_stores = Arc::new(Mutex::new(HashMap::new()));
1165        odb.alternate_sources_cache = Arc::new(RwLock::new(AlternateSourcesCache {
1166            file_generation: 0,
1167            env_fingerprint: 0,
1168            submodule_generation: 0,
1169            sources: None,
1170        }));
1171        odb
1172    }
1173
1174    fn sync_delta_base_cache_limit(&self) {
1175        if self.pack_store.bounded_reads() {
1176            return;
1177        }
1178        self.delta_base_cache_sync.get_or_init(|| {
1179            if self.config_git_dir.is_some() {
1180                let cfg = self.load_config_cascade().unwrap_or_default();
1181                self.pack_store.configure_delta_base_from_config(Some(&cfg));
1182            }
1183        });
1184    }
1185
1186    pub(crate) fn pack_store_for(&self, objects_dir: &Path) -> Arc<PackStore> {
1187        if objects_dir == self.objects_dir.as_path() {
1188            return Arc::clone(&self.pack_store);
1189        }
1190        let new_store = || {
1191            Arc::new(if self.pack_store.bounded_reads() {
1192                PackStore::for_bounded_reads(objects_dir.to_path_buf())
1193            } else {
1194                PackStore::new(objects_dir.to_path_buf())
1195            })
1196        };
1197        if let Ok(mut guard) = self.alternate_pack_stores.lock() {
1198            return Arc::clone(
1199                guard
1200                    .entry(objects_dir.to_path_buf())
1201                    .or_insert_with(new_store),
1202            );
1203        }
1204        new_store()
1205    }
1206
1207    /// Pack/MIDX read cache shared by this handle and its [`Clone`]s.
1208    pub fn pack_store(&self) -> Result<&Arc<PackStore>> {
1209        self.require_files_primary("pack_store")?;
1210        Ok(&self.pack_store)
1211    }
1212
1213    /// Path to the largest pack index in the primary `objects/pack/` directory (by entry count).
1214    ///
1215    /// Used when writing a reachability bitmap for an all-into-one repack. Returns `Ok(None)`
1216    /// when no pack index exists.
1217    ///
1218    /// # Errors
1219    ///
1220    /// Returns [`Error::InvalidRef`] when the store is not files-backed.
1221    pub fn largest_pack_index_path(&self) -> Result<Option<PathBuf>> {
1222        self.require_files_primary("largest_pack_index_path")?;
1223        let mut indexes = pack::read_local_pack_indexes_cached(self.objects_dir())?;
1224        indexes.sort_by_key(|idx| std::cmp::Reverse(idx.len()));
1225        Ok(indexes.first().map(|idx| idx.idx_path.clone()))
1226    }
1227
1228    /// Drop the resolved delta bases cached for the primary `objects/` packs, keeping
1229    /// indexes and pack bytes. Frees memory between phases of a long operation whose
1230    /// next reads will not revisit the same bases.
1231    pub(crate) fn clear_delta_base_cache(&self) {
1232        self.pack_store.delta_bases().clear();
1233    }
1234
1235    /// Drop cached pack listings, parsed indexes, pack bytes, delta bases, and MIDX layers.
1236    ///
1237    /// Call after in-process repack, garbage collection, or [`crate::index_pack::install_pack_bytes`]
1238    /// so the next read rescans `objects/pack/`. Cloned [`Odb`] handles share one
1239    /// [`PackStore`] for the primary `objects/` directory; alternates have separate stores
1240    /// that are invalidated here as well.
1241    pub fn invalidate_packs(&self) {
1242        self.pack_store.invalidate_all();
1243        PackStore::invalidate_legacy_for_objects_dir(&self.objects_dir);
1244        if let Ok(guard) = self.alternate_pack_stores.lock() {
1245            for store in guard.values() {
1246                store.invalidate_all();
1247                PackStore::invalidate_legacy_for_objects_dir(store.objects_dir());
1248            }
1249        }
1250        self.invalidate_change_id_header_probe();
1251    }
1252
1253    fn invalidate_change_id_header_probe(&self) {
1254        if let Some(caches) = self.shared_config_state.as_ref() {
1255            crate::change_id::invalidate_stored_change_id_header_probe(caches, self.objects_dir());
1256        }
1257    }
1258
1259    fn note_commit_object_written(&self, kind: ObjectKind) {
1260        if kind == ObjectKind::Commit {
1261            self.invalidate_change_id_header_probe();
1262        }
1263    }
1264
1265    /// Materialize unreachable objects from packfiles superseded by a new repack pack.
1266    ///
1267    /// Git gc stores grace-protected unreachable objects in cruft packs; grit writes them
1268    /// loose (with preserved mtimes) so later loose pruning can apply `gc.pruneExpire`.
1269    ///
1270    /// `reachable` is only walked when a superseded pack holds an object that is in
1271    /// neither the new pack nor a `.keep` pack. `retained_packs` protects ordinary
1272    /// packs that supply available promises from being superseded or loosened.
1273    ///
1274    /// # Errors
1275    ///
1276    /// Propagates pack index read failures, object read/write errors, I/O failures,
1277    /// and errors from the reachability walk.
1278    pub(crate) fn loosen_unreachable_from_superseded_packs(
1279        &self,
1280        pack_dir: &Path,
1281        keep_pack: &Path,
1282        reachable: &crate::gc::LazyReachable<'_>,
1283        retention: UnreachablePackRetention,
1284        retained_packs: &HashSet<PathBuf>,
1285    ) -> Result<usize> {
1286        if matches!(retention, UnreachablePackRetention::DropAll) {
1287            return Ok(0);
1288        }
1289        self.require_files_primary("repack")?;
1290        let keep_idx = keep_pack.with_extension("idx");
1291        let keep_pack_oids = if keep_idx.is_file() {
1292            PackedObjects::object_ids_from_index_path(&keep_idx)?
1293        } else {
1294            HashSet::new()
1295        };
1296        let kept_pack_oids = oids_in_marker_kept_packs(pack_dir, keep_pack)?;
1297        let mut loosened = 0usize;
1298        for (idx_path, pack_mtime) in superseded_pack_indexes(pack_dir, keep_pack, retained_packs)?
1299        {
1300            let oids = PackedObjects::object_ids_from_index_path(&idx_path)?;
1301            for oid in oids {
1302                let keep = match retention {
1303                    UnreachablePackRetention::KeepAll => true,
1304                    UnreachablePackRetention::KeepGraceProtected(cutoff) => pack_mtime >= cutoff,
1305                    UnreachablePackRetention::DropAll => false,
1306                };
1307                if !keep {
1308                    continue;
1309                }
1310                if keep_pack_oids.contains(&oid) || kept_pack_oids.contains(&oid) {
1311                    continue;
1312                }
1313                if reachable.get()?.contains(&oid) {
1314                    continue;
1315                }
1316                let loose_path = oid.loose_path_in(&self.objects_dir);
1317                let pack_ft = system_time_to_file_time(pack_mtime);
1318                match retention {
1319                    UnreachablePackRetention::KeepGraceProtected(cutoff) => {
1320                        if loose_path.is_file() {
1321                            let loose_mtime = fs::metadata(&loose_path)
1322                                .and_then(|m| m.modified())
1323                                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
1324                            if loose_mtime >= cutoff {
1325                                continue;
1326                            }
1327                            set_file_mtime(&loose_path, pack_ft).map_err(Error::Io)?;
1328                            loosened += 1;
1329                            continue;
1330                        }
1331                    }
1332                    UnreachablePackRetention::KeepAll => {
1333                        if loose_path.is_file() {
1334                            continue;
1335                        }
1336                    }
1337                    UnreachablePackRetention::DropAll => {}
1338                }
1339                let obj = self.read(&oid)?;
1340                self.write_loose_materialize(obj.kind, &obj.data)?;
1341                let primary = self.primary()?;
1342                let loose_path = primary.loose_store().object_path(&oid);
1343                set_file_mtime(&loose_path, pack_ft).map_err(Error::Io)?;
1344                loosened += 1;
1345            }
1346        }
1347        Ok(loosened)
1348    }
1349
1350    fn core_multi_pack_index_enabled(&self) -> bool {
1351        // The system/global/local config cascade is expensive to load (the parser walks every
1352        // file from `/etc/gitconfig` through `.git/config`); calling it once per object lookup
1353        // dominated `status` runtime. Cache the result for the lifetime of this `Odb`.
1354        *self.core_multi_pack_index_cache.get_or_init(|| {
1355            if self.config_git_dir.is_none() {
1356                return false;
1357            }
1358            let cfg = self.load_config_cascade().unwrap_or_default();
1359            match cfg.get_bool("core.multiPackIndex") {
1360                Some(Ok(b)) => b,
1361                Some(Err(_)) => true,
1362                None => true,
1363            }
1364        })
1365    }
1366
1367    fn multi_pack_index_reads_enabled(&self) -> bool {
1368        if self.custom_primary.is_some() {
1369            return false;
1370        }
1371        if self.config_git_dir.is_none() {
1372            return false;
1373        }
1374        if !*self
1375            .local_pack_indexes_seen
1376            .get_or_init(|| objects_dir_has_pack_index_files(&self.objects_dir))
1377        {
1378            return false;
1379        }
1380        self.core_multi_pack_index_enabled()
1381    }
1382
1383    fn ensure_midx_prepared(&self) {
1384        if !self.multi_pack_index_reads_enabled() {
1385            return;
1386        }
1387        let _ = self.midx_packs_validated.get_or_init(|| {
1388            let diagnostics = self.diagnostics_handle();
1389            self.with_pack_store_for(&self.objects_dir, || {
1390                validate_midx_referenced_packs_with_diagnostics(
1391                    &self.objects_dir,
1392                    diagnostics.as_ref(),
1393                );
1394            });
1395        });
1396    }
1397
1398    /// Direct pack read when [`PackStore::with_context`] matches the primary `objects/` tree.
1399    ///
1400    /// Skips [`ObjectStore`] dynamic dispatch on batch cat-file workloads; MIDX repos use the
1401    /// full primary path instead.
1402    fn try_read_packed_in_batch_context(&self, oid: &ObjectId) -> Result<Option<Object>> {
1403        if self.custom_primary.is_some()
1404            || !PackStore::read_context_matches_objects_dir(&self.objects_dir)
1405            || self.multi_pack_index_reads_enabled()
1406        {
1407            return Ok(None);
1408        }
1409        self.sync_delta_base_cache_limit();
1410        match pack::try_read_object_from_packs_with_options(
1411            &self.objects_dir,
1412            oid,
1413            pack::PackLookupOptions::ALL_LOCAL_PACKS,
1414        ) {
1415            Ok(obj) => Ok(Some(obj)),
1416            Err(Error::ObjectNotFound(_)) => Ok(None),
1417            Err(err) => Err(err),
1418        }
1419    }
1420
1421    fn try_read_info_packed_in_batch_context(&self, oid: &ObjectId) -> Result<Option<ObjectInfo>> {
1422        if self.custom_primary.is_some()
1423            || !PackStore::read_context_matches_objects_dir(&self.objects_dir)
1424            || self.multi_pack_index_reads_enabled()
1425        {
1426            return Ok(None);
1427        }
1428        match pack::try_read_object_info_from_packs_with_options(
1429            &self.objects_dir,
1430            oid,
1431            pack::PackLookupOptions::ALL_LOCAL_PACKS,
1432        ) {
1433            Ok(info) => Ok(Some(info)),
1434            Err(Error::ObjectNotFound(_)) => Ok(None),
1435            Err(err) => Err(err),
1436        }
1437    }
1438
1439    /// Return the path to the `objects/` directory.
1440    ///
1441    /// This is always the repository layout path (including for custom primaries that do not
1442    /// store objects on disk). Prefer [`Self::files_objects_dir`] when the operation requires a
1443    /// files-backed primary; filesystem maintenance returns
1444    /// [`Error::UnsupportedObjectStore`] otherwise.
1445    #[must_use]
1446    pub fn objects_dir(&self) -> &Path {
1447        &self.objects_dir
1448    }
1449
1450    /// The repository's commit-graph chain, loaded at most once per repository handle.
1451    ///
1452    /// `None` when this database is not attached to a repository's caches (built
1453    /// standalone) or has no readable commit-graph; callers then read commits directly.
1454    pub(crate) fn commit_graph_chain(
1455        &self,
1456    ) -> Option<Arc<crate::commit_graph_file::CommitGraphChain>> {
1457        self.shared_config_state
1458            .as_ref()?
1459            .commit_graph_chain(self.objects_dir())
1460    }
1461
1462    fn try_read_extra_sources(&self, oid: &ObjectId) -> Result<Option<Object>> {
1463        for src in &self.extra_read_sources {
1464            if let Some(obj) = src.read(oid)? {
1465                return Ok(Some(obj));
1466            }
1467        }
1468        Ok(None)
1469    }
1470
1471    fn try_read_info_extra_sources(&self, oid: &ObjectId) -> Result<Option<ObjectInfo>> {
1472        for src in &self.extra_read_sources {
1473            if let Some(info) = src.read_info(oid)? {
1474                return Ok(Some(info));
1475            }
1476        }
1477        Ok(None)
1478    }
1479
1480    fn try_open_stream_extra_sources(
1481        &self,
1482        oid: &ObjectId,
1483    ) -> Result<Option<store::ObjectStream<'_>>> {
1484        for src in &self.extra_read_sources {
1485            if let Some(stream) = src.open_stream(oid)? {
1486                return Ok(Some(stream));
1487            }
1488        }
1489        Ok(None)
1490    }
1491
1492    /// Return the attached git directory, if one was set with
1493    /// [`Self::with_config_git_dir`].
1494    ///
1495    /// Used by config- and ref-aware operations (delta islands, on-disk delta
1496    /// reuse) that need the repository root rather than just the object store.
1497    #[must_use]
1498    pub fn config_git_dir(&self) -> Option<&Path> {
1499        self.config_git_dir.as_deref()
1500    }
1501
1502    /// Return the filesystem path for a given object ID.
1503    #[must_use]
1504    pub fn object_path(&self, oid: &ObjectId) -> PathBuf {
1505        oid.loose_path_in(&self.objects_dir)
1506    }
1507
1508    /// Pack indexes in this database's own `objects/pack` (not alternates), from
1509    /// the shared index cache.
1510    ///
1511    /// # Errors
1512    ///
1513    /// Propagates pack directory listing and index parse failures.
1514    pub(crate) fn local_pack_indexes(&self) -> Result<Vec<Arc<pack::PackIndex>>> {
1515        pack::read_local_pack_indexes_cached(&self.objects_dir)
1516    }
1517
1518    /// Size in bytes of `oid`'s loose file in this database's own `objects/`,
1519    /// or `None` when it is not stored loose here.
1520    pub(crate) fn local_loose_file_size(&self, oid: &ObjectId) -> Option<u64> {
1521        fs::metadata(self.object_path(oid))
1522            .ok()
1523            .filter(fs::Metadata::is_file)
1524            .map(|meta| meta.len())
1525    }
1526
1527    /// Whether the object exists under this database directory only (loose or local packs).
1528    ///
1529    /// Unlike [`Self::exists`], this ignores `info/alternates` and
1530    /// `GIT_ALTERNATE_OBJECT_DIRECTORIES`. Used for partial-clone bookkeeping where
1531    /// objects reachable via alternates are still treated as "missing" until copied locally.
1532    ///
1533    /// Objects stored **only** in promisor packs (sibling `.promisor` marker next to the
1534    /// `.pack`) are treated as absent: Git considers them fetchable on demand, and
1535    /// `rev-list --missing=print` lists them until materialized as loose objects or a
1536    /// non-promisor pack.
1537    ///
1538    /// The empty tree object is treated as present without a loose file (matches Git).
1539    ///
1540    /// A failing custom primary store counts as "absent"; decision paths inside the
1541    /// crate use the fallible form, which propagates the backend error.
1542    #[must_use]
1543    pub fn exists_local(&self, oid: &ObjectId) -> bool {
1544        self.try_exists_local(oid).unwrap_or(false)
1545    }
1546
1547    /// Fallible [`Self::exists_local`]: errors from a custom primary store propagate.
1548    pub(crate) fn try_exists_local(&self, oid: &ObjectId) -> Result<bool> {
1549        if self.is_empty_tree(oid) {
1550            return Ok(true);
1551        }
1552        if let Some(primary) = &self.custom_primary {
1553            return primary.contains(oid);
1554        }
1555        Ok(self.with_pack_store_for(&self.objects_dir, || {
1556            exists_materialized_in_objects_dir(&self.objects_dir, oid)
1557        }))
1558    }
1559
1560    /// Whether `oid` is stored locally in loose storage or any pack (including promisor).
1561    ///
1562    /// Unlike [`Self::exists_local`], objects present only in promisor-marked packs count
1563    /// as stored. Does not trigger lazy fetch or consult alternates.
1564    ///
1565    /// A failing custom primary store counts as "absent"; decision paths inside the
1566    /// crate use the fallible form, which propagates the backend error.
1567    #[must_use]
1568    pub fn exists_stored(&self, oid: &ObjectId) -> bool {
1569        self.try_exists_stored(oid).unwrap_or(false)
1570    }
1571
1572    /// Fallible [`Self::exists_stored`]: errors from a custom primary store propagate.
1573    pub(crate) fn try_exists_stored(&self, oid: &ObjectId) -> Result<bool> {
1574        if oid.is_canonical_empty_tree() || self.overlay_read(oid).is_some() {
1575            return Ok(true);
1576        }
1577        if let Some(primary) = &self.custom_primary {
1578            return primary.contains(oid);
1579        }
1580        Ok(self.with_pack_store_for(&self.objects_dir, || {
1581            store::local_object_stored(&self.objects_dir, oid)
1582        }))
1583    }
1584
1585    /// Whether `oid` is stored on disk in this repository's object store (loose or
1586    /// non-promisor pack), without treating the canonical empty tree as virtually
1587    /// present.
1588    ///
1589    /// Use this when deciding whether a peer already holds an object for incremental
1590    /// local transfer; [`Self::exists`] and [`Self::exists_local`] treat the empty
1591    /// tree as present even when no object file exists.
1592    ///
1593    /// A failing custom primary store counts as "absent".
1594    #[must_use]
1595    pub fn exists_materialized_local(&self, oid: &ObjectId) -> bool {
1596        if let Some(primary) = &self.custom_primary {
1597            return primary.contains(oid).unwrap_or(false);
1598        }
1599        self.with_pack_store_for(&self.objects_dir, || {
1600            exists_materialized_in_objects_dir(&self.objects_dir, oid)
1601        })
1602    }
1603
1604    /// Bring this repository's cached pack listing up to date with `objects/pack/`.
1605    ///
1606    /// Call once before a batch of [`Self::exists_materialized_local_without_rescan`]
1607    /// probes so packs written before the batch are visible.
1608    pub(crate) fn reprepare_local_packs(&self) {
1609        if self.custom_primary.is_some() {
1610            return;
1611        }
1612        self.with_pack_store_for(&self.objects_dir, || {
1613            let _ = crate::pack::reprepare_pack_directory_on_miss(&self.objects_dir);
1614        });
1615    }
1616
1617    /// Like [`Self::exists_materialized_local`], but a miss does not rescan
1618    /// `objects/pack/`.
1619    ///
1620    /// For walks that probe many objects that are expected to be absent (local
1621    /// clone/fetch/push boundary checks): each miss would otherwise list the pack
1622    /// directory again. Packs that appear after [`Self::reprepare_local_packs`] are
1623    /// not seen, so a miss can be stale; callers must treat it as "send anyway".
1624    pub(crate) fn exists_materialized_local_without_rescan(&self, oid: &ObjectId) -> bool {
1625        if self.custom_primary.is_some() {
1626            return self.exists_materialized_local(oid);
1627        }
1628        self.with_pack_store_for(&self.objects_dir, || {
1629            store::local_object_materialized_without_rescan(&self.objects_dir, oid)
1630        })
1631    }
1632
1633    /// Whether `oid` is in this database: the primary store, any extra read source
1634    /// ([`OdbBuilder::push_read_source`]) or an alternate object directory.
1635    ///
1636    /// The canonical empty tree is always present. Sources are probed in the same order
1637    /// [`Self::read`] uses, and a source that fails stops the probe just as it stops a
1638    /// read, so `contains` never reports an object that `read` cannot return.
1639    ///
1640    /// # Errors
1641    ///
1642    /// The first source failure (for example an [`Error::Io`] from a network- or
1643    /// database-backed store), instead of reporting the object as present or missing.
1644    pub fn contains(&self, oid: &ObjectId) -> Result<bool> {
1645        self.probe_sources(oid, Err)
1646    }
1647
1648    /// Probe every source for `oid` in lookup order; `on_err` decides whether a failing
1649    /// source aborts the probe (`Err`) or is skipped (`Ok`).
1650    fn probe_sources(
1651        &self,
1652        oid: &ObjectId,
1653        mut on_err: impl FnMut(Error) -> Result<()>,
1654    ) -> Result<bool> {
1655        #[cfg(test)]
1656        self.exists_probe.fetch_add(1, Ordering::Relaxed);
1657        if self.is_empty_tree(oid) || self.overlay_read(oid).is_some() {
1658            return Ok(true);
1659        }
1660        let mut found = |probe: Result<bool>| match probe {
1661            Ok(present) => Ok(present),
1662            Err(err) => on_err(err).map(|()| false),
1663        };
1664        if found(self.primary_writable().and_then(|p| p.contains(oid)))? {
1665            return Ok(true);
1666        }
1667        for src in &self.extra_read_sources {
1668            if found(src.contains(oid))? {
1669                return Ok(true);
1670            }
1671        }
1672        if !self.alternate_object_lookup_needed() {
1673            return Ok(false);
1674        }
1675        found(self.sources().and_then(|s| s.contains(oid)))
1676    }
1677
1678    /// [`Self::contains`] for every id in `oids`, one answer per id in input order.
1679    ///
1680    /// Each source is asked once about the ids no earlier source holds, through
1681    /// [`ObjectStore::contains_many`], so a database- or network-backed primary answers
1682    /// the whole batch in one round trip instead of one per id.
1683    ///
1684    /// # Errors
1685    ///
1686    /// The first source failure, as for [`Self::contains`].
1687    pub(crate) fn contains_many(&self, oids: &[ObjectId]) -> Result<Vec<bool>> {
1688        #[cfg(test)]
1689        self.exists_probe.fetch_add(oids.len(), Ordering::Relaxed);
1690        let mut present: Vec<bool> = oids
1691            .iter()
1692            .map(|oid| self.is_empty_tree(oid) || self.overlay_read(oid).is_some())
1693            .collect();
1694        let mut pending: Vec<usize> = (0..oids.len()).filter(|&i| !present[i]).collect();
1695        store::resolve_pending(oids, &mut present, &mut pending, |batch| {
1696            self.primary_writable()?.contains_many(batch)
1697        })?;
1698        for src in &self.extra_read_sources {
1699            store::resolve_pending(oids, &mut present, &mut pending, |batch| {
1700                src.contains_many(batch)
1701            })?;
1702        }
1703        if !pending.is_empty() && self.alternate_object_lookup_needed() {
1704            store::resolve_pending(oids, &mut present, &mut pending, |batch| {
1705                self.sources()?.contains_many(batch)
1706            })?;
1707        }
1708        Ok(present)
1709    }
1710
1711    /// The oids from `oids` that [`Self::contains`] reports present, in input order.
1712    ///
1713    /// # Errors
1714    ///
1715    /// Propagates the first [`Self::contains`] failure.
1716    pub(crate) fn present_oids(
1717        &self,
1718        oids: impl IntoIterator<Item = ObjectId>,
1719    ) -> Result<Vec<ObjectId>> {
1720        self.partition_oids(oids, true)
1721    }
1722
1723    /// The oids from `oids` that [`Self::contains`] reports missing, in input order.
1724    ///
1725    /// # Errors
1726    ///
1727    /// Propagates the first [`Self::contains`] failure.
1728    pub(crate) fn missing_oids(
1729        &self,
1730        oids: impl IntoIterator<Item = ObjectId>,
1731    ) -> Result<Vec<ObjectId>> {
1732        self.partition_oids(oids, false)
1733    }
1734
1735    /// The oids from `oids` whose [`Self::contains_many`] answer equals `want_present`.
1736    fn partition_oids(
1737        &self,
1738        oids: impl IntoIterator<Item = ObjectId>,
1739        want_present: bool,
1740    ) -> Result<Vec<ObjectId>> {
1741        let oids: Vec<ObjectId> = oids.into_iter().collect();
1742        let present = self.contains_many(&oids)?;
1743        Ok(oids
1744            .into_iter()
1745            .zip(present)
1746            .filter_map(|(oid, has)| (has == want_present).then_some(oid))
1747            .collect())
1748    }
1749
1750    /// The oids from `oids` that are not stored locally per [`Self::exists_stored`]
1751    /// (alternates are not consulted), in input order.
1752    ///
1753    /// # Errors
1754    ///
1755    /// Propagates the first failure of a custom primary store.
1756    pub(crate) fn missing_stored_oids(
1757        &self,
1758        oids: impl IntoIterator<Item = ObjectId>,
1759    ) -> Result<Vec<ObjectId>> {
1760        filter_oids(oids, |oid| self.try_exists_stored(oid).map(|has| !has))
1761    }
1762
1763    /// Lenient [`Self::contains`]: a source that fails is skipped, so a later source can
1764    /// still answer `true` and an object no healthy source holds counts as absent.
1765    ///
1766    /// Use only for advisory probes. Where "missing" changes what happens next (what to
1767    /// fetch or send, whether a ref is broken), call [`Self::contains`] so a backend
1768    /// outage surfaces as an error rather than as a missing object.
1769    #[must_use]
1770    pub fn exists(&self, oid: &ObjectId) -> bool {
1771        self.probe_sources(oid, |_| Ok(())).unwrap_or(false)
1772    }
1773
1774    /// Run `f` with this [`Odb`]'s [`PackStore`] for `objects_dir` (primary or alternate).
1775    fn with_pack_store_for<R>(&self, objects_dir: &Path, f: impl FnOnce() -> R) -> R {
1776        let store = self.pack_store_for(objects_dir);
1777        PackStore::with_context(store, f)
1778    }
1779
1780    /// Run `f` with pack read caches for this database's primary `objects/` directory.
1781    ///
1782    /// Use for batch workloads (for example `cat-file --batch`) so each object read does not
1783    /// reinstall thread-local pack context.
1784    pub fn with_pack_read_context<R>(&self, f: impl FnOnce() -> R) -> R {
1785        self.with_pack_store_for(&self.objects_dir, f)
1786    }
1787
1788    /// Install the pack read context of [`Self::with_pack_read_context`] on the current
1789    /// thread until the returned guard drops (for long-lived worker threads).
1790    pub(crate) fn pack_read_context_guard(&self) -> crate::pack_store::PackReadContextGuard {
1791        crate::pack_store::PackReadContextGuard::install(self.pack_store_for(&self.objects_dir))
1792    }
1793
1794    /// Open a reader over the single local pack for a long object walk.
1795    ///
1796    /// Returns `None` unless [`Self::with_pack_read_context`] is active for this database,
1797    /// there is no custom primary store, and exactly one local pack exists (the conditions
1798    /// under which the files store's [`Self::read`] path opens the same reader per call). A
1799    /// reader miss is not authoritative: callers must fall back to [`Self::read`] for
1800    /// objects it does not find.
1801    pub(crate) fn open_session_pack_reader(&self) -> Result<Option<pack::SingleLocalPackReader>> {
1802        if self.custom_primary.is_some()
1803            || !PackStore::read_context_matches_objects_dir(&self.objects_dir)
1804        {
1805            return Ok(None);
1806        }
1807        self.sync_delta_base_cache_limit();
1808        pack::SingleLocalPackReader::open_allow_loose(&self.objects_dir)
1809    }
1810
1811    /// Touch the loose object file or pack file containing `oid`, matching Git's
1812    /// `odb_freshen_object` (updates mtime so age-based prune keeps recently re-referenced objects).
1813    ///
1814    /// Returns `true` if an on-disk object was found and touched.
1815    #[must_use]
1816    pub fn freshen_object(&self, oid: &ObjectId) -> bool {
1817        if self.is_empty_tree(oid) {
1818            return false;
1819        }
1820
1821        let loose = self.object_path(oid);
1822        if loose.is_file() {
1823            return self.touch_object_mtime(&loose).is_some();
1824        }
1825
1826        if self.freshen_object_in_objects_dir(&self.objects_dir, oid) {
1827            return true;
1828        }
1829
1830        let file_alts = self.file_alternate_dirs_snapshot();
1831        for alt_dir in file_alts.iter() {
1832            if self.freshen_object_in_objects_dir(alt_dir, oid) {
1833                return true;
1834            }
1835        }
1836
1837        for alt_dir in self.env_alternate_dirs_snapshot().iter() {
1838            if self.freshen_object_in_objects_dir(alt_dir, oid) {
1839                return true;
1840            }
1841        }
1842
1843        if let Ok(guard) = self.submodule_alternate_dirs.lock() {
1844            for alt_dir in guard.iter() {
1845                if self.freshen_object_in_objects_dir(alt_dir, oid) {
1846                    return true;
1847                }
1848            }
1849        }
1850
1851        false
1852    }
1853
1854    fn freshen_object_in_objects_dir(&self, objects_dir: &Path, oid: &ObjectId) -> bool {
1855        let loose = objects_dir
1856            .join(oid.loose_prefix())
1857            .join(oid.loose_suffix());
1858        if loose.is_file() {
1859            return self.touch_object_mtime(&loose).is_some();
1860        }
1861        self.with_pack_store_for(objects_dir, || {
1862            let Ok(indexes) = pack::read_local_pack_indexes_cached(objects_dir) else {
1863                return false;
1864            };
1865            for idx in &indexes {
1866                if idx.contains(oid).unwrap_or(false) {
1867                    return self.freshen_pack_once(&idx.pack_path, idx.is_cruft);
1868                }
1869            }
1870            false
1871        })
1872    }
1873
1874    /// Touch `pack_path`'s mtime at most once for this [`Odb`], refreshing the pack cache signature.
1875    ///
1876    /// The path is recorded in [`Self::freshened_packs`] only after a successful touch, matching
1877    /// Git's `packed_git->freshened` assignment after `utime` succeeds.
1878    fn freshen_pack_once(&self, pack_path: &Path, is_cruft: bool) -> bool {
1879        let Ok(mut guard) = self.freshened_packs.lock() else {
1880            return false;
1881        };
1882        if guard.contains(pack_path) {
1883            return true;
1884        }
1885        if is_cruft {
1886            return false;
1887        }
1888        let Some(touched_at) = self.touch_object_mtime(pack_path) else {
1889            return false;
1890        };
1891        pack::refresh_pack_bytes_signature(pack_path, touched_at);
1892        guard.insert(pack_path.to_path_buf());
1893        true
1894    }
1895
1896    /// When `oid` is already reachable, freshen it and return its id. Returns `None` when the
1897    /// object lives only in a pack and freshening failed so the caller can materialize a loose copy.
1898    fn try_freshen_existing(&self, oid: &ObjectId) -> Option<ObjectId> {
1899        if let Ok(primary) = self.primary() {
1900            if let Ok(Some(existing)) =
1901                primary.try_finish_duplicate_write(oid, WriteOptions::default())
1902            {
1903                return Some(existing);
1904            }
1905        }
1906        if !self.exists(oid) {
1907            return None;
1908        }
1909        if self.freshen_object(oid) {
1910            return Some(*oid);
1911        }
1912        None
1913    }
1914
1915    fn try_freshen_existing_local(&self, oid: &ObjectId) -> Option<ObjectId> {
1916        let primary = self.primary().ok()?;
1917        primary
1918            .try_finish_duplicate_write(
1919                oid,
1920                WriteOptions {
1921                    assume_loose_only_existence: true,
1922                    ..WriteOptions::default()
1923                },
1924            )
1925            .ok()
1926            .flatten()
1927    }
1928
1929    /// Whether an object is already stored for [`Self::write_with_options`] deduplication.
1930    ///
1931    /// Like [`Self::exists`], but the well-known empty tree counts as present only when
1932    /// materialized on disk (see [`Self::write_local`]).
1933    fn object_exists_for_write_skip(&self, oid: &ObjectId) -> bool {
1934        if self.is_empty_tree(oid) {
1935            self.exists_materialized_local(oid)
1936        } else {
1937            self.exists(oid)
1938        }
1939    }
1940
1941    /// Read a loose object file at `path`, verifying the uncompressed payload hashes to `expected_oid`.
1942    ///
1943    /// Git stores loose objects under paths derived from the OID; if the file contents hash to a
1944    /// different id (for example after a mistaken `mv`), this returns [`Error::LooseHashMismatch`].
1945    ///
1946    /// # Errors
1947    ///
1948    /// - [`Error::Zlib`] — decompression failed.
1949    /// - [`Error::CorruptObject`] — header is malformed.
1950    /// - [`Error::LooseHashMismatch`] — payload OID does not match `expected_oid`.
1951    pub fn read_loose_verify_oid(path: &Path, expected_oid: &ObjectId) -> Result<Object> {
1952        LooseStore::read_verify_oid(path, expected_oid)
1953    }
1954
1955    fn local_loose(&self) -> Result<LooseStore> {
1956        self.require_files_primary("loose_write")?;
1957        Ok(LooseStore::new(
1958            self.objects_dir.clone(),
1959            self.hash_algo(),
1960            self.loose_compression()?,
1961        ))
1962    }
1963
1964    fn loose_at(&self, objects_dir: &Path) -> Result<LooseStore> {
1965        Ok(LooseStore::new(
1966            objects_dir.to_path_buf(),
1967            self.hash_algo(),
1968            self.loose_compression()?,
1969        ))
1970    }
1971
1972    /// Read and decompress an object from the loose store.
1973    ///
1974    /// # Errors
1975    ///
1976    /// - [`Error::ObjectNotFound`] — no file at the expected path.
1977    /// - [`Error::Zlib`] — decompression failed.
1978    /// - [`Error::CorruptObject`] — header is malformed.
1979    pub fn read(&self, oid: &ObjectId) -> Result<Object> {
1980        self.read_impl(oid, true)
1981    }
1982
1983    /// `remember` keeps a copy in the session loose-read cache; callers that cache a
1984    /// parsed form themselves skip it.
1985    fn read_impl(&self, oid: &ObjectId, remember: bool) -> Result<Object> {
1986        if self.is_empty_tree(oid) {
1987            return Ok(Object {
1988                kind: ObjectKind::Tree,
1989                data: Vec::new(),
1990            });
1991        }
1992
1993        if let Some(obj) = self.session_cached_loose_read(oid) {
1994            return Ok(obj);
1995        }
1996
1997        if let Some(obj) = self.overlay_read(oid) {
1998            return Ok(obj);
1999        }
2000        if let Some(obj) = self.try_read_packed_in_batch_context(oid)? {
2001            return Ok(obj);
2002        }
2003
2004        let use_midx = self.multi_pack_index_reads_enabled();
2005        if use_midx {
2006            self.ensure_midx_prepared();
2007        }
2008
2009        self.sync_delta_base_cache_limit();
2010
2011        let primary = self.primary_writable()?;
2012        let files_primary = self.primary().ok();
2013        let mut unreadable_local_loose = None;
2014        let read_primary = || primary.read(oid);
2015        match if PackStore::read_context_matches_objects_dir(&self.objects_dir) {
2016            read_primary()
2017        } else {
2018            self.with_pack_store_for(&self.objects_dir, read_primary)
2019        } {
2020            Ok(Some(obj)) => return self.finish_session_loose_read(oid, obj, remember),
2021            Ok(None) => {}
2022            Err(err)
2023                if files_primary
2024                    .as_ref()
2025                    .is_some_and(|p| p.local_loose_exists_but_unreadable(oid, &err)) =>
2026            {
2027                unreadable_local_loose = Some(err);
2028            }
2029            Err(err) => return Err(err),
2030        }
2031
2032        if let Some(obj) = self.try_read_extra_sources(oid)? {
2033            return self.finish_session_loose_read(oid, obj, remember);
2034        }
2035
2036        if self.alternates_enabled {
2037            match self.sources()?.read(oid) {
2038                Ok(Some(obj)) => return self.finish_session_loose_read(oid, obj, remember),
2039                Ok(None) => {}
2040                Err(err) => return Err(err),
2041            }
2042        }
2043
2044        if let Some(err) = unreadable_local_loose {
2045            return Err(err);
2046        }
2047
2048        self.read_after_promisor_miss(oid)
2049    }
2050
2051    #[cold]
2052    #[inline(never)]
2053    fn read_after_promisor_miss(&self, oid: &ObjectId) -> Result<Object> {
2054        let mut lazy_retries = 0u32;
2055        loop {
2056            let Some(res) = self.resolve_promisor_miss(oid, lazy_retries)? else {
2057                return Err(Error::ObjectNotFound(oid.to_hex()));
2058            };
2059            match res {
2060                crate::promisor_fetch::PromisorMissResolution::RetryAfterFetch => {
2061                    lazy_retries += 1;
2062                    if let Some(obj) = self.try_read_once(oid)? {
2063                        return Ok(obj);
2064                    }
2065                }
2066                crate::promisor_fetch::PromisorMissResolution::NotPromisorRepo => {
2067                    return Err(Error::ObjectNotFound(oid.to_hex()));
2068                }
2069                crate::promisor_fetch::PromisorMissResolution::PromisedMissing(err) => {
2070                    return Err(err);
2071                }
2072            }
2073        }
2074    }
2075
2076    /// Read available storage without consulting the promisor fetcher.
2077    /// Missing objects return `None`; corrupt storage still returns an error.
2078    pub(crate) fn try_read_once(&self, oid: &ObjectId) -> Result<Option<Object>> {
2079        if oid.is_well_known_empty_tree() {
2080            return Ok(Some(Object {
2081                kind: ObjectKind::Tree,
2082                data: Vec::new(),
2083            }));
2084        }
2085
2086        if let Some(obj) = self.session_cached_loose_read(oid) {
2087            return Ok(Some(obj));
2088        }
2089
2090        if let Some(obj) = self.overlay_read(oid) {
2091            return Ok(Some(obj));
2092        }
2093        if let Some(obj) = self.try_read_packed_in_batch_context(oid)? {
2094            return Ok(Some(self.finish_session_loose_read(oid, obj, true)?));
2095        }
2096
2097        let use_midx = self.multi_pack_index_reads_enabled();
2098        if use_midx {
2099            self.ensure_midx_prepared();
2100        }
2101
2102        self.sync_delta_base_cache_limit();
2103
2104        let primary = self.primary_writable()?;
2105        let files_primary = self.primary().ok();
2106        let mut unreadable_local_loose = None;
2107        let read_primary = || primary.read(oid);
2108        match if PackStore::read_context_matches_objects_dir(&self.objects_dir) {
2109            read_primary()
2110        } else {
2111            self.with_pack_store_for(&self.objects_dir, read_primary)
2112        } {
2113            Ok(Some(obj)) => return Ok(Some(self.finish_session_loose_read(oid, obj, true)?)),
2114            Ok(None) => {}
2115            Err(err)
2116                if files_primary
2117                    .as_ref()
2118                    .is_some_and(|p| p.local_loose_exists_but_unreadable(oid, &err)) =>
2119            {
2120                unreadable_local_loose = Some(err);
2121            }
2122            Err(err) => return Err(err),
2123        }
2124
2125        if let Some(obj) = self.try_read_extra_sources(oid)? {
2126            return Ok(Some(self.finish_session_loose_read(oid, obj, true)?));
2127        }
2128
2129        if self.alternates_enabled {
2130            match self.sources()?.read(oid) {
2131                Ok(Some(obj)) => return Ok(Some(self.finish_session_loose_read(oid, obj, true)?)),
2132                Ok(None) => {}
2133                Err(err) => return Err(err),
2134            }
2135        }
2136
2137        if let Some(err) = unreadable_local_loose {
2138            return Err(err);
2139        }
2140
2141        Ok(None)
2142    }
2143
2144    fn resolve_promisor_miss(
2145        &self,
2146        oid: &ObjectId,
2147        lazy_retries: u32,
2148    ) -> Result<Option<crate::promisor_fetch::PromisorMissResolution>> {
2149        let (Some(caches), Some(env)) = (&self.shared_config_state, &self.shared_environment)
2150        else {
2151            return Ok(None);
2152        };
2153        Ok(Some(crate::promisor_fetch::resolve_promisor_read_miss(
2154            caches,
2155            self,
2156            oid,
2157            env.as_ref(),
2158            lazy_retries,
2159        )?))
2160    }
2161
2162    /// Read and decompress an object into `buf`, reusing its capacity across calls.
2163    ///
2164    /// Resolution order matches [`Self::read`]. Returns a borrowed view of the payload
2165    /// stored in `buf`.
2166    ///
2167    /// # Errors
2168    ///
2169    /// Same as [`Self::read`].
2170    pub fn read_into(&self, oid: &ObjectId, buf: &mut Vec<u8>) -> Result<ObjectKind> {
2171        if self.is_empty_tree(oid) {
2172            buf.clear();
2173            return Ok(ObjectKind::Tree);
2174        }
2175
2176        if let Some(obj) = self.session_cached_loose_read(oid) {
2177            buf.clear();
2178            buf.extend_from_slice(&obj.data);
2179            return Ok(obj.kind);
2180        }
2181
2182        if let Some(obj) = self.overlay_read(oid) {
2183            buf.clear();
2184            buf.extend_from_slice(&obj.data);
2185            return Ok(obj.kind);
2186        }
2187
2188        if let Some(kind) = self.try_read_packed_in_batch_context_into(oid, false, buf)? {
2189            return Ok(kind);
2190        }
2191
2192        let use_midx = self.multi_pack_index_reads_enabled();
2193        if use_midx {
2194            self.ensure_midx_prepared();
2195        }
2196
2197        self.sync_delta_base_cache_limit();
2198
2199        let primary = self.primary_writable()?;
2200        let files_primary = self.primary().ok();
2201        let mut unreadable_local_loose = None;
2202        let mut read_primary = || primary.read_into(oid, buf);
2203        match if PackStore::read_context_matches_objects_dir(&self.objects_dir) {
2204            read_primary()
2205        } else {
2206            self.with_pack_store_for(&self.objects_dir, read_primary)
2207        } {
2208            Ok(Some(kind)) => return Ok(kind),
2209            Ok(None) => {}
2210            Err(err)
2211                if files_primary
2212                    .as_ref()
2213                    .is_some_and(|p| p.local_loose_exists_but_unreadable(oid, &err)) =>
2214            {
2215                unreadable_local_loose = Some(err);
2216            }
2217            Err(err) => return Err(err),
2218        }
2219
2220        if let Some(kind) = self.try_read_into_extra_sources(oid, buf)? {
2221            return Ok(kind);
2222        }
2223
2224        if self.alternates_enabled {
2225            match self.sources()?.read_into(oid, buf) {
2226                Ok(Some(kind)) => return Ok(kind),
2227                Ok(None) => {}
2228                Err(err) => return Err(err),
2229            }
2230        }
2231
2232        if let Some(err) = unreadable_local_loose {
2233            return Err(err);
2234        }
2235
2236        self.read_into_after_promisor_miss(oid, buf)
2237    }
2238
2239    /// Like [`Self::read_into`], but keep delta bases resolved from local packs in the
2240    /// delta-base cache, as [`Self::read`] does.
2241    ///
2242    /// For walks that read many objects sharing delta chains (successive root trees) and do
2243    /// not want the per-session object copy [`Self::read`] makes. Objects outside the local
2244    /// object directory (overlay, alternates, promisor fetches) and any local read failure go
2245    /// through [`Self::read`], so errors and fallbacks match it.
2246    ///
2247    /// # Errors
2248    ///
2249    /// Same as [`Self::read`].
2250    pub(crate) fn read_into_caching_delta_bases(
2251        &self,
2252        oid: &ObjectId,
2253        buf: &mut Vec<u8>,
2254    ) -> Result<ObjectKind> {
2255        if let Some(kind) = self.try_read_packed_in_batch_context_into(oid, true, buf)? {
2256            return Ok(kind);
2257        }
2258        if self.custom_primary.is_none() && !self.is_empty_tree(oid) {
2259            if let Ok(files) = self.primary() {
2260                self.ensure_midx_prepared();
2261                self.sync_delta_base_cache_limit();
2262                let mut read = || files.read_into_with_delta_policy(oid, true, buf);
2263                let local = if PackStore::read_context_matches_objects_dir(&self.objects_dir) {
2264                    read()
2265                } else {
2266                    self.with_pack_store_for(&self.objects_dir, read)
2267                };
2268                if let Ok(Some(kind)) = local {
2269                    return Ok(kind);
2270                }
2271            }
2272        }
2273        let object = self.read(oid)?;
2274        buf.clear();
2275        buf.extend_from_slice(&object.data);
2276        Ok(object.kind)
2277    }
2278
2279    #[cold]
2280    #[inline(never)]
2281    fn read_into_after_promisor_miss(
2282        &self,
2283        oid: &ObjectId,
2284        buf: &mut Vec<u8>,
2285    ) -> Result<ObjectKind> {
2286        let mut lazy_retries = 0u32;
2287        loop {
2288            let Some(res) = self.resolve_promisor_miss(oid, lazy_retries)? else {
2289                return Err(Error::ObjectNotFound(oid.to_hex()));
2290            };
2291            match res {
2292                crate::promisor_fetch::PromisorMissResolution::RetryAfterFetch => {
2293                    lazy_retries += 1;
2294                    if let Some(kind) = self.try_read_into_once(oid, buf)? {
2295                        return Ok(kind);
2296                    }
2297                }
2298                crate::promisor_fetch::PromisorMissResolution::NotPromisorRepo => {
2299                    return Err(Error::ObjectNotFound(oid.to_hex()));
2300                }
2301                crate::promisor_fetch::PromisorMissResolution::PromisedMissing(err) => {
2302                    return Err(err);
2303                }
2304            }
2305        }
2306    }
2307
2308    fn try_read_into_once(&self, oid: &ObjectId, buf: &mut Vec<u8>) -> Result<Option<ObjectKind>> {
2309        if oid.is_well_known_empty_tree() {
2310            buf.clear();
2311            return Ok(Some(ObjectKind::Tree));
2312        }
2313
2314        if let Some(obj) = self.session_cached_loose_read(oid) {
2315            buf.clear();
2316            buf.extend_from_slice(&obj.data);
2317            return Ok(Some(obj.kind));
2318        }
2319
2320        if let Some(obj) = self.overlay_read(oid) {
2321            buf.clear();
2322            buf.extend_from_slice(&obj.data);
2323            return Ok(Some(obj.kind));
2324        }
2325
2326        if let Some(kind) = self.try_read_packed_in_batch_context_into(oid, false, buf)? {
2327            return Ok(Some(kind));
2328        }
2329
2330        let use_midx = self.multi_pack_index_reads_enabled();
2331        if use_midx {
2332            self.ensure_midx_prepared();
2333        }
2334
2335        self.sync_delta_base_cache_limit();
2336
2337        let primary = self.primary_writable()?;
2338        let files_primary = self.primary().ok();
2339        let mut unreadable_local_loose = None;
2340        let mut read_primary = || primary.read_into(oid, buf);
2341        match if PackStore::read_context_matches_objects_dir(&self.objects_dir) {
2342            read_primary()
2343        } else {
2344            self.with_pack_store_for(&self.objects_dir, read_primary)
2345        } {
2346            Ok(Some(kind)) => return Ok(Some(kind)),
2347            Ok(None) => {}
2348            Err(err)
2349                if files_primary
2350                    .as_ref()
2351                    .is_some_and(|p| p.local_loose_exists_but_unreadable(oid, &err)) =>
2352            {
2353                unreadable_local_loose = Some(err);
2354            }
2355            Err(err) => return Err(err),
2356        }
2357
2358        if let Some(kind) = self.try_read_into_extra_sources(oid, buf)? {
2359            return Ok(Some(kind));
2360        }
2361
2362        if self.alternates_enabled {
2363            match self.sources()?.read_into(oid, buf) {
2364                Ok(Some(kind)) => return Ok(Some(kind)),
2365                Ok(None) => {}
2366                Err(err) => return Err(err),
2367            }
2368        }
2369
2370        if let Some(err) = unreadable_local_loose {
2371            return Err(err);
2372        }
2373
2374        Ok(None)
2375    }
2376
2377    fn try_read_into_extra_sources(
2378        &self,
2379        oid: &ObjectId,
2380        buf: &mut Vec<u8>,
2381    ) -> Result<Option<ObjectKind>> {
2382        for src in &self.extra_read_sources {
2383            if let Some(kind) = src.read_into(oid, buf)? {
2384                return Ok(Some(kind));
2385            }
2386        }
2387        Ok(None)
2388    }
2389
2390    fn try_read_packed_in_batch_context_into(
2391        &self,
2392        oid: &ObjectId,
2393        populate_delta_lru: bool,
2394        buf: &mut Vec<u8>,
2395    ) -> Result<Option<ObjectKind>> {
2396        if self.custom_primary.is_some()
2397            || !PackStore::read_context_matches_objects_dir(&self.objects_dir)
2398            || self.multi_pack_index_reads_enabled()
2399        {
2400            return Ok(None);
2401        }
2402        self.sync_delta_base_cache_limit();
2403        match pack::try_read_object_from_packs_with_options_into(
2404            &self.objects_dir,
2405            oid,
2406            pack::PackLookupOptions::ALL_LOCAL_PACKS,
2407            populate_delta_lru,
2408            buf,
2409        ) {
2410            Ok(kind) => Ok(Some(kind)),
2411            Err(Error::ObjectNotFound(_)) => Ok(None),
2412            Err(err) => Err(err),
2413        }
2414    }
2415
2416    /// Return the kind and uncompressed size of `oid` without loading the full object body.
2417    ///
2418    /// Resolution order matches [`Self::read`]: in-memory overlay, multi-pack-index, local packs
2419    /// (MRU order, skipping MIDX-covered indexes when MIDX is enabled), loose objects, a pack
2420    /// directory re-prepare and pack retry, then alternates.
2421    ///
2422    /// # Errors
2423    ///
2424    /// - [`Error::ObjectNotFound`] — no object with this id in any consulted store.
2425    /// - [`Error::Zlib`] — decompression failed while reading a loose or pack header.
2426    /// - [`Error::CorruptObject`] — malformed object or pack headers.
2427    pub fn read_info(&self, oid: &ObjectId) -> Result<ObjectInfo> {
2428        if self.is_empty_tree(oid) {
2429            return Ok(ObjectInfo {
2430                kind: ObjectKind::Tree,
2431                size: 0,
2432            });
2433        }
2434
2435        if let Some(obj) = self.overlay_read(oid) {
2436            return Ok(ObjectInfo {
2437                kind: obj.kind,
2438                size: u64::try_from(obj.data.len())
2439                    .map_err(|_| Error::CorruptObject("object size overflow".to_owned()))?,
2440            });
2441        }
2442
2443        if let Some(info) = self.try_read_info_packed_in_batch_context(oid)? {
2444            return Ok(info);
2445        }
2446
2447        let use_midx = self.multi_pack_index_reads_enabled();
2448        if use_midx {
2449            self.ensure_midx_prepared();
2450        }
2451
2452        let primary = self.primary_writable()?;
2453        let files_primary = self.primary().ok();
2454        let mut unreadable_local_loose = None;
2455        match primary.read_info(oid) {
2456            Ok(Some(info)) => return Ok(info),
2457            Ok(None) => {}
2458            Err(err)
2459                if files_primary
2460                    .as_ref()
2461                    .is_some_and(|p| p.local_loose_exists_but_unreadable(oid, &err)) =>
2462            {
2463                unreadable_local_loose = Some(err);
2464            }
2465            Err(err) => return Err(err),
2466        }
2467
2468        if let Some(info) = self.try_read_info_extra_sources(oid)? {
2469            return Ok(info);
2470        }
2471
2472        if self.alternates_enabled {
2473            match self.sources()?.read_info(oid) {
2474                Ok(Some(info)) => return Ok(info),
2475                Ok(None) => {}
2476                Err(err) => return Err(err),
2477            }
2478        }
2479
2480        if let Some(err) = unreadable_local_loose {
2481            return Err(err);
2482        }
2483
2484        self.read_info_after_promisor_miss(oid)
2485    }
2486
2487    /// Object metadata for local-only probes, serving, and maintenance.
2488    ///
2489    /// Like [`Self::read_info`], but does not resolve promisor misses by fetching
2490    /// from a remote; missing local objects return [`Error::ObjectNotFound`].
2491    pub(crate) fn read_info_without_fetch(&self, oid: &ObjectId) -> Result<ObjectInfo> {
2492        self.try_read_info_once(oid)?
2493            .ok_or_else(|| Error::ObjectNotFound(oid.to_hex()))
2494    }
2495
2496    #[cold]
2497    #[inline(never)]
2498    fn read_info_after_promisor_miss(&self, oid: &ObjectId) -> Result<ObjectInfo> {
2499        let mut lazy_retries = 0u32;
2500        loop {
2501            let Some(res) = self.resolve_promisor_miss(oid, lazy_retries)? else {
2502                return Err(Error::ObjectNotFound(oid.to_hex()));
2503            };
2504            match res {
2505                crate::promisor_fetch::PromisorMissResolution::PromisedMissing(err) => {
2506                    return Err(err);
2507                }
2508                crate::promisor_fetch::PromisorMissResolution::NotPromisorRepo => {
2509                    return Err(Error::ObjectNotFound(oid.to_hex()));
2510                }
2511                crate::promisor_fetch::PromisorMissResolution::RetryAfterFetch => {
2512                    lazy_retries += 1;
2513                    if let Some(info) = self.try_read_info_once(oid)? {
2514                        return Ok(info);
2515                    }
2516                }
2517            }
2518        }
2519    }
2520
2521    fn try_read_info_once(&self, oid: &ObjectId) -> Result<Option<ObjectInfo>> {
2522        if oid.is_well_known_empty_tree() {
2523            return Ok(Some(ObjectInfo {
2524                kind: ObjectKind::Tree,
2525                size: 0,
2526            }));
2527        }
2528
2529        if let Some(obj) = self.overlay_read(oid) {
2530            return Ok(Some(ObjectInfo {
2531                kind: obj.kind,
2532                size: u64::try_from(obj.data.len())
2533                    .map_err(|_| Error::CorruptObject("object size overflow".to_owned()))?,
2534            }));
2535        }
2536
2537        if let Some(info) = self.try_read_info_packed_in_batch_context(oid)? {
2538            return Ok(Some(info));
2539        }
2540
2541        let use_midx = self.multi_pack_index_reads_enabled();
2542        if use_midx {
2543            self.ensure_midx_prepared();
2544        }
2545
2546        let primary = self.primary_writable()?;
2547        let files_primary = self.primary().ok();
2548        let mut unreadable_local_loose = None;
2549        match primary.read_info(oid) {
2550            Ok(Some(info)) => return Ok(Some(info)),
2551            Ok(None) => {}
2552            Err(err)
2553                if files_primary
2554                    .as_ref()
2555                    .is_some_and(|p| p.local_loose_exists_but_unreadable(oid, &err)) =>
2556            {
2557                unreadable_local_loose = Some(err);
2558            }
2559            Err(err) => return Err(err),
2560        }
2561
2562        if let Some(info) = self.try_read_info_extra_sources(oid)? {
2563            return Ok(Some(info));
2564        }
2565
2566        if self.alternates_enabled {
2567            match self.sources()?.read_info(oid) {
2568                Ok(Some(info)) => return Ok(Some(info)),
2569                Ok(None) => {}
2570                Err(err) => return Err(err),
2571            }
2572        }
2573
2574        if let Some(err) = unreadable_local_loose {
2575            return Err(err);
2576        }
2577
2578        Ok(None)
2579    }
2580
2581    /// Read `oid` unless its payload is larger than `limit` bytes, in which case
2582    /// [`BoundedRead::Larger`] is returned (for example so the caller can stream it with
2583    /// [`Self::with_stream`] instead).
2584    ///
2585    /// Costs about one [`Self::read`]: a loose object is opened once and inflation stops at
2586    /// its header when it is too large, and an object stored whole in a pack is sized from its
2587    /// entry header before it is read. A pack delta is always read whole, since reconstructing
2588    /// it needs the full object in memory anyway.
2589    ///
2590    /// `cache_small` reuses and retains small loose objects for bulk checkout; callers
2591    /// doing isolated writes can bypass that bookkeeping.
2592    ///
2593    /// # Errors
2594    ///
2595    /// Same as [`Self::read`], including [`Error::ObjectNotFound`].
2596    pub(crate) fn read_unless_larger(
2597        &self,
2598        oid: &ObjectId,
2599        limit: u64,
2600        cache_small: bool,
2601    ) -> Result<BoundedRead> {
2602        if cache_small {
2603            if let Some(read) = self.session_loose_reads.lock().ok().and_then(|cache| {
2604                cache.get(oid).map(|obj| {
2605                    if obj.data.len() as u64 > limit {
2606                        BoundedRead::Larger
2607                    } else {
2608                        BoundedRead::Object(obj.clone())
2609                    }
2610                })
2611            }) {
2612                return Ok(read);
2613            }
2614        }
2615        if self.custom_primary.is_none() && !self.multi_pack_index_reads_enabled() {
2616            if let Some(read) =
2617                pack::pack_cache::try_read_object_via_single_pack_reader_unless_whole_larger(
2618                    &self.objects_dir,
2619                    oid,
2620                    limit,
2621                )?
2622            {
2623                return Ok(read);
2624            }
2625        }
2626        if let Ok(files) = self.primary() {
2627            if self.multi_pack_index_reads_enabled() {
2628                self.ensure_midx_prepared();
2629            }
2630            match files.read_unless_larger(oid, limit)? {
2631                LocalSizedRead::Packed(Some(size)) if size > limit => {
2632                    return Ok(BoundedRead::Larger);
2633                }
2634                LocalSizedRead::Packed(_) => return self.read(oid).map(BoundedRead::Object),
2635                LocalSizedRead::Loose(BoundedRead::Object(obj)) => {
2636                    if cache_small && obj.data.len() <= SESSION_BOUNDED_LOOSE_CACHE_MAX {
2637                        self.remember_session_loose_read(oid, &obj);
2638                    }
2639                    return Ok(BoundedRead::Object(obj));
2640                }
2641                LocalSizedRead::Loose(read) => return Ok(read),
2642                LocalSizedRead::Missing => {}
2643            }
2644        }
2645        let info = self.read_info(oid)?;
2646        if info.size > limit {
2647            return Ok(BoundedRead::Larger);
2648        }
2649        self.read(oid).map(BoundedRead::Object)
2650    }
2651
2652    /// Open a stream over `oid`'s uncompressed payload (overlay, primary, then alternates).
2653    ///
2654    /// The returned stream owns its payload: it is read whole into memory before returning.
2655    /// Use [`Self::with_stream`] to read a large object in bounded memory.
2656    ///
2657    /// # Errors
2658    ///
2659    /// Propagates backend failures other than a missing object (`Ok(None)`).
2660    pub fn open_stream(&self, oid: &ObjectId) -> Result<Option<ObjectStream<'static>>> {
2661        self.with_stream(oid, materialize_stream)
2662    }
2663
2664    /// Run `f` on a stream over `oid`'s uncompressed payload (overlay, primary, then alternates).
2665    ///
2666    /// Unlike [`Self::open_stream`] the payload is not buffered: loose objects and objects
2667    /// stored whole in a pack are inflated as `f` reads, so memory stays bounded however large
2668    /// the object is. Objects stored as pack deltas are reconstructed in memory first.
2669    ///
2670    /// # Parameters
2671    ///
2672    /// - `oid` — the object to stream.
2673    /// - `f` — consumes the stream; its result is returned as `Ok(Some(_))`.
2674    ///
2675    /// # Errors
2676    ///
2677    /// Propagates backend failures other than a missing object (`Ok(None)`, and `f` is not
2678    /// called), and any error returned by `f`. A corrupt or truncated object surfaces as an
2679    /// [`std::io::Error`] from the stream's reader.
2680    pub fn with_stream<R>(
2681        &self,
2682        oid: &ObjectId,
2683        f: impl FnOnce(ObjectStream<'_>) -> Result<R>,
2684    ) -> Result<Option<R>> {
2685        use std::io::Cursor;
2686        if let Some(obj) = self.overlay_read(oid) {
2687            let size = u64::try_from(obj.data.len()).map_err(|_| {
2688                Error::CorruptObject(format!("object size overflow for {}", oid.to_hex()))
2689            })?;
2690            return f(ObjectStream {
2691                kind: obj.kind,
2692                size,
2693                reader: Box::new(Cursor::new(obj.data)),
2694            })
2695            .map(Some);
2696        }
2697        if self.multi_pack_index_reads_enabled() {
2698            self.ensure_midx_prepared();
2699        }
2700        self.sync_delta_base_cache_limit();
2701        let primary = self.primary_writable()?;
2702        let files_primary = self.primary().ok();
2703        let mut unreadable_local_loose = None;
2704        match primary.open_stream(oid) {
2705            Ok(Some(stream)) => return f(stream).map(Some),
2706            Ok(None) => {}
2707            Err(err)
2708                if files_primary
2709                    .as_ref()
2710                    .is_some_and(|p| p.local_loose_exists_but_unreadable(oid, &err)) =>
2711            {
2712                unreadable_local_loose = Some(err);
2713            }
2714            Err(err) => return Err(err),
2715        }
2716        if let Some(stream) = self.try_open_stream_extra_sources(oid)? {
2717            return f(stream).map(Some);
2718        }
2719        if self.alternates_enabled {
2720            let sources = self.sources()?;
2721            let stream = sources.open_stream(oid)?;
2722            if let Some(stream) = stream {
2723                return f(stream).map(Some);
2724            }
2725        }
2726        if let Some(err) = unreadable_local_loose {
2727            return Err(err);
2728        }
2729        Ok(None)
2730    }
2731
2732    /// Invoke `f` for every object id reachable from this database until `f` returns [`ControlFlow::Break`].
2733    ///
2734    /// # Errors
2735    ///
2736    /// Propagates enumeration failures from any consulted backend.
2737    pub fn for_each_object(&self, f: &mut dyn FnMut(&ObjectId) -> ControlFlow<()>) -> Result<()> {
2738        let mut seen = oid_hash_set();
2739        let mut visit = |store: &dyn ObjectStore| -> Result<bool> {
2740            store::for_each_propagate_break(store, &mut |oid| {
2741                if !seen.insert(*oid) {
2742                    return ControlFlow::Continue(());
2743                }
2744                f(oid)
2745            })
2746        };
2747        if let Some(overlay) = self.overlay_store() {
2748            if visit(overlay.as_ref())? {
2749                return Ok(());
2750            }
2751        }
2752        if visit(self.primary_writable()?.as_ref())? {
2753            return Ok(());
2754        }
2755        for src in &self.extra_read_sources {
2756            if visit(src.as_ref())? {
2757                return Ok(());
2758            }
2759        }
2760        if self.alternates_enabled {
2761            let _ = visit(self.sources()?.as_ref())?;
2762        }
2763        Ok(())
2764    }
2765
2766    /// Fill `out` with ids whose hex starts with `prefix` (deduplicated across overlay, primary, alternates).
2767    ///
2768    /// # Errors
2769    ///
2770    /// Returns [`Error::InvalidObjectId`] when `prefix` is not valid hex.
2771    pub fn lookup_prefix(&self, prefix: &str, limit: usize, out: &mut Vec<ObjectId>) -> Result<()> {
2772        let mut seen: OidHashSet = out.iter().copied().collect();
2773        if let Some(overlay) = self.overlay_store() {
2774            append_lookup_prefix_layer(overlay.as_ref(), prefix, limit, out, &mut seen)?;
2775            if limit != 0 && out.len() >= limit {
2776                return Ok(());
2777            }
2778        }
2779        append_lookup_prefix_layer(
2780            self.primary_writable()?.as_ref(),
2781            prefix,
2782            limit,
2783            out,
2784            &mut seen,
2785        )?;
2786        if limit != 0 && out.len() >= limit {
2787            return Ok(());
2788        }
2789        for src in &self.extra_read_sources {
2790            append_lookup_prefix_layer(src.as_ref(), prefix, limit, out, &mut seen)?;
2791            if limit != 0 && out.len() >= limit {
2792                return Ok(());
2793            }
2794        }
2795        if self.alternates_enabled {
2796            append_lookup_prefix_layer(self.sources()?.as_ref(), prefix, limit, out, &mut seen)?;
2797        }
2798        Ok(())
2799    }
2800
2801    /// Enumerate loose objects in this database's primary `objects/` directory as `(oid, path)`.
2802    ///
2803    /// Pack files and alternate object directories are not scanned.
2804    ///
2805    /// # Errors
2806    ///
2807    /// Propagates failures while opening the primary files source or walking loose storage.
2808    pub fn enumerate_local_loose_objects(&self) -> Result<Vec<(ObjectId, PathBuf)>> {
2809        let primary = self.primary()?;
2810        let loose = primary.loose_store();
2811        let mut out = Vec::new();
2812        loose.for_each_object(&mut |oid| {
2813            out.push((*oid, loose.object_path(oid)));
2814            ControlFlow::Continue(())
2815        })?;
2816        Ok(out)
2817    }
2818
2819    /// Hash raw content of a given kind using this repository's hash algorithm.
2820    ///
2821    /// This does **not** write anything to disk.
2822    ///
2823    /// # Errors
2824    ///
2825    /// Returns [`Error::HashCollision`] when sha1dc detects a chosen-prefix collision.
2826    pub fn hash(&self, kind: ObjectKind, data: &[u8]) -> Result<ObjectId> {
2827        hash_object_data_with(self.hash_algo(), kind, data)
2828    }
2829
2830    /// Hash `size` payload bytes of `kind` read from `reader`, without storing anything.
2831    ///
2832    /// Memory use does not depend on `size`: the payload is read in fixed-size chunks. Exactly
2833    /// `size` bytes are consumed; anything after them is left unread.
2834    ///
2835    /// # Errors
2836    ///
2837    /// - [`Error::ObjectStreamTruncated`] — `reader` ended before `size` bytes.
2838    /// - [`Error::Io`] — reading failed.
2839    /// - [`Error::HashCollision`] — sha1dc detected a chosen-prefix collision.
2840    pub fn hash_stream(
2841        &self,
2842        kind: ObjectKind,
2843        size: u64,
2844        reader: &mut dyn std::io::Read,
2845    ) -> Result<ObjectId> {
2846        #[cfg(test)]
2847        self.hot_path_metrics().record_stream_hash();
2848        stream::hash_stream(self.hash_algo(), kind, size, reader)
2849    }
2850
2851    /// Store `size` payload bytes of `kind` read from `reader` and return the object id.
2852    ///
2853    /// Objects smaller than [`Self::big_file_threshold`] are read into memory and written like
2854    /// [`Self::write_with_options`]. Bigger ones are hashed and deflated in fixed-size chunks
2855    /// straight into a new single-object pack (at the [`ConfigSet::pack_objects_zlib_level`]
2856    /// level), as Git does for big files, so memory use does not depend on `size`. If the
2857    /// object already exists the new pack is discarded and the existing copy is freshened
2858    /// unless `options.silent` is set.
2859    ///
2860    /// A custom primary receives the stream through [`WritableObjectStore::write_stream`];
2861    /// while the in-memory overlay is active the object is buffered into the overlay.
2862    ///
2863    /// Exactly `size` bytes are consumed from `reader`.
2864    ///
2865    /// # Errors
2866    ///
2867    /// - [`Error::ObjectStreamTruncated`] — `reader` ended before `size` bytes.
2868    /// - [`Error::Io`] / [`Error::Zlib`] — reading, compressing, or writing failed.
2869    /// - [`Error::HashCollision`] — sha1dc detected a chosen-prefix collision.
2870    /// - Config errors from resolving the threshold or compression level.
2871    pub fn write_stream(
2872        &self,
2873        kind: ObjectKind,
2874        size: u64,
2875        reader: &mut dyn std::io::Read,
2876        options: WriteOptions,
2877    ) -> Result<ObjectId> {
2878        #[cfg(test)]
2879        self.hot_path_metrics().record_stream_write();
2880        if let Some(primary) = &self.custom_primary {
2881            if !self.overlay_active() {
2882                let oid = primary.write_stream(kind, size, reader, options)?;
2883                self.note_commit_object_written(kind);
2884                return Ok(oid);
2885            }
2886        }
2887        if self.overlay_active() || size < self.big_file_threshold()? {
2888            let len = usize::try_from(size).map_err(|_| {
2889                Error::CorruptObject(format!("object size {size} exceeds address space"))
2890            })?;
2891            let mut data = Vec::with_capacity(len);
2892            let read =
2893                std::io::Read::read_to_end(&mut std::io::Read::take(reader, size), &mut data)?;
2894            if read != len {
2895                return Err(Error::ObjectStreamTruncated {
2896                    expected: size,
2897                    actual: read as u64,
2898                });
2899            }
2900            return self.write_with_options(kind, &data, options);
2901        }
2902        let staged = stream::StagedPack::write(
2903            &self.objects_dir.join("pack"),
2904            self.hash_algo(),
2905            kind,
2906            size,
2907            reader,
2908            self.pack_compression()?,
2909        )?;
2910        let oid = staged.oid();
2911        if self.object_exists_for_write_skip(&oid) {
2912            if !options.silent {
2913                let _ = self.freshen_object(&oid);
2914            }
2915            return Ok(oid);
2916        }
2917        staged.install()?;
2918        self.invalidate_packs();
2919        self.note_commit_object_written(kind);
2920        Ok(oid)
2921    }
2922
2923    /// Object id of the empty tree for this store's hash algorithm.
2924    ///
2925    /// Hashes the canonical empty tree payload only; nothing is written to disk.
2926    ///
2927    /// # Errors
2928    ///
2929    /// Returns [`Error::HashCollision`] when sha1dc detects a chosen-prefix collision.
2930    pub fn empty_tree_oid(&self) -> Result<ObjectId> {
2931        self.hash(ObjectKind::Tree, b"")
2932    }
2933
2934    /// Whether `oid` is this repository's empty tree (virtually present when not on disk).
2935    #[must_use]
2936    pub fn is_empty_tree(&self, oid: &ObjectId) -> bool {
2937        empty_tree_oid_matches(self.hash_algo(), oid)
2938    }
2939
2940    /// Write an object to the loose store and return its [`ObjectId`].
2941    ///
2942    /// If the object already exists it is not overwritten (Git behaviour).
2943    ///
2944    /// The payload is stored as given, without fsck checks; callers that build tree,
2945    /// commit or tag bytes themselves should go through
2946    /// [`Objects::write`](crate::objects::Objects::write) instead.
2947    ///
2948    /// # Errors
2949    ///
2950    /// - [`Error::Io`] — could not create the directory or write the file.
2951    /// - [`Error::Zlib`] — compression failed.
2952    pub fn write(&self, kind: ObjectKind, data: &[u8]) -> Result<ObjectId> {
2953        self.write_with_options(kind, data, WriteOptions::default())
2954    }
2955
2956    /// Create all 256 loose-object prefix directories (`objects/xx/`).
2957    ///
2958    /// Bulk `git add`-style staging calls this once before parallel loose writes. A no-op for
2959    /// custom primaries, which receive those writes instead of `objects/`.
2960    ///
2961    /// # Errors
2962    ///
2963    /// Returns [`Error::Io`] if a directory cannot be created.
2964    pub fn ensure_all_loose_prefix_dirs(&self) -> Result<()> {
2965        if self.custom_primary.is_some() {
2966            return Ok(());
2967        }
2968        self.local_loose()?.ensure_all_loose_prefix_dirs()
2969    }
2970
2971    /// Test-only: filesystem path for a loose object (see `grit-lib/tests/odb_abstraction_guard.rs`).
2972    #[cfg(test)]
2973    #[must_use]
2974    pub fn test_loose_object_path(&self, oid: &ObjectId) -> PathBuf {
2975        self.object_path(oid)
2976    }
2977
2978    /// Test-only: delete a loose object file if present.
2979    #[cfg(test)]
2980    pub fn test_remove_loose_object(&self, oid: &ObjectId) {
2981        let _ = fs::remove_file(self.object_path(oid));
2982    }
2983
2984    /// Count loose object files under this repository's `objects/` directory.
2985    ///
2986    /// # Errors
2987    ///
2988    /// Propagates directory enumeration failures from the loose store walker.
2989    pub fn count_loose_objects(&self) -> Result<usize> {
2990        let mut count = 0usize;
2991        for_each_loose_object_id(&self.objects_dir, self.hash_algo(), &mut |_| {
2992            count += 1;
2993            ControlFlow::Continue(())
2994        })?;
2995        Ok(count)
2996    }
2997
2998    /// Approximate object count for Git-style automatic `core.abbrev` sizing.
2999    ///
3000    /// Sums object counts from cached pack indexes (no per-object scan) plus loose files under
3001    /// this database's `objects/` directory. Matches Git's `ODB_COUNT_OBJECTS_APPROXIMATE` well
3002    /// enough for abbreviation heuristics on typical repositories.
3003    ///
3004    /// # Errors
3005    ///
3006    /// Propagates pack index listing and loose enumeration failures.
3007    pub fn approximate_object_count(&self) -> Result<usize> {
3008        let pack_objects: Result<usize> = self.with_pack_read_context(|| {
3009            let indexes = pack::read_local_pack_indexes_cached(self.objects_dir())?;
3010            Ok(indexes.iter().map(|idx| idx.len()).sum())
3011        });
3012        let mut count = pack_objects?;
3013        count += self.count_loose_objects()?;
3014        Ok(count)
3015    }
3016
3017    /// Zlib-compress canonical loose store bytes using the repository's loose level.
3018    ///
3019    /// # Errors
3020    ///
3021    /// Returns [`Error::Zlib`] when compression fails.
3022    pub fn zlib_compress_loose_store(&self, store_bytes: &[u8]) -> Result<Vec<u8>> {
3023        self.local_loose()?.zlib_compress_store_bytes(store_bytes)
3024    }
3025
3026    /// Write a loose object from precomputed id and zlib-compressed store bytes.
3027    ///
3028    /// The caller must supply bytes that the internal zlib loose reader would expand to
3029    /// valid canonical store form (`"<kind> <len>\\0<payload>"`). Hashing and
3030    /// compression are skipped; use after parallel batch preparation.
3031    ///
3032    /// With a custom primary the bytes are inflated and stored through
3033    /// [`WritableObjectStore::write`] instead, so staging works on any backend.
3034    ///
3035    /// # Errors
3036    ///
3037    /// Same as [`Self::write`]; [`Error::Zlib`] or [`Error::CorruptObject`] when a custom
3038    /// primary receives bytes that do not inflate to a valid object.
3039    pub fn write_loose_zlib_prehashed(
3040        &self,
3041        oid: &ObjectId,
3042        zlib_store: &[u8],
3043        options: WriteOptions,
3044    ) -> Result<ObjectId> {
3045        if self.custom_primary.is_some() {
3046            let raw = decompress_zlib_loose_bytes(zlib_store)?;
3047            let obj = parse_object_bytes(&raw)?;
3048            return self.write_with_options(obj.kind, &obj.data, options);
3049        }
3050        let primary = self.primary()?;
3051        if let Some(existing) = primary.try_finish_duplicate_write(oid, options)? {
3052            return Ok(existing);
3053        }
3054
3055        let already_exists = !(options.trust_new_loose || options.assume_loose_only_existence)
3056            && self.object_exists_for_write_skip(oid);
3057
3058        if self.overlay_active() && !already_exists {
3059            if let Ok(raw) = decompress_zlib_loose_bytes(zlib_store) {
3060                if let Ok(obj) = parse_object_bytes(&raw) {
3061                    if self.overlay_store_object(*oid, obj.kind, &obj.data) {
3062                        return Ok(*oid);
3063                    }
3064                }
3065            }
3066        }
3067
3068        if already_exists {
3069            if !options.silent {
3070                let _ = self.freshen_object(oid);
3071            }
3072            return Ok(*oid);
3073        }
3074
3075        primary.write_zlib_prehashed(oid, zlib_store, options)
3076    }
3077
3078    /// Write blob `data` whose id `oid` the caller already computed.
3079    ///
3080    /// On a files primary the content is deflated straight into the loose object file, so
3081    /// no compressed copy is held in memory; a custom primary gets
3082    /// [`Self::write_with_options`].
3083    pub(crate) fn write_blob_prehashed(
3084        &self,
3085        oid: &ObjectId,
3086        data: &[u8],
3087        options: WriteOptions,
3088    ) -> Result<ObjectId> {
3089        if self.custom_primary.is_some() {
3090            return self.write_with_options(ObjectKind::Blob, data, options);
3091        }
3092        let primary = self.primary()?;
3093        if let Some(existing) = primary.try_finish_duplicate_write(oid, options)? {
3094            return Ok(existing);
3095        }
3096        let already_exists = !(options.trust_new_loose || options.assume_loose_only_existence)
3097            && self.object_exists_for_write_skip(oid);
3098        if self.overlay_active()
3099            && !already_exists
3100            && self.overlay_store_object(*oid, ObjectKind::Blob, data)
3101        {
3102            return Ok(*oid);
3103        }
3104        if already_exists {
3105            if !options.silent {
3106                let _ = self.freshen_object(oid);
3107            }
3108            return Ok(*oid);
3109        }
3110        primary
3111            .loose_store()
3112            .write_prehashed(oid, ObjectKind::Blob, data, options)
3113    }
3114    /// Like [`Self::write`], with control over freshen behaviour on existing objects.
3115    pub fn write_with_options(
3116        &self,
3117        kind: ObjectKind,
3118        data: &[u8],
3119        options: WriteOptions,
3120    ) -> Result<ObjectId> {
3121        let oid = hash_object_data_with(self.hash_algo(), kind, data)?;
3122
3123        if let Some(primary) = &self.custom_primary {
3124            let already_exists = if options.trust_new_loose {
3125                false
3126            } else if options.assume_loose_only_existence {
3127                primary.contains(&oid)?
3128            } else {
3129                self.object_exists_for_write_skip(&oid)
3130            };
3131            if self.overlay_active()
3132                && !already_exists
3133                && self.overlay_store_object(oid, kind, data)
3134            {
3135                return Ok(oid);
3136            }
3137            if already_exists {
3138                return Ok(oid);
3139            }
3140            let oid = WritableObjectStore::write(primary.as_ref(), kind, data, options)?;
3141            self.note_commit_object_written(kind);
3142            return Ok(oid);
3143        }
3144
3145        let primary = self.primary()?;
3146        if let Some(existing) = primary.try_finish_duplicate_write(&oid, options)? {
3147            return Ok(existing);
3148        }
3149
3150        let already_exists = !(options.trust_new_loose || options.assume_loose_only_existence)
3151            && self.object_exists_for_write_skip(&oid);
3152
3153        // When the in-memory overlay is active, keep the object in memory only (unless it is
3154        // already present on disk, in which case nothing new needs to be written anyway).
3155        if self.overlay_active() && !already_exists && self.overlay_store_object(oid, kind, data) {
3156            return Ok(oid);
3157        }
3158
3159        if already_exists {
3160            if !options.silent {
3161                let _ = self.freshen_object(&oid);
3162            }
3163            return Ok(oid);
3164        }
3165
3166        let oid = WritableObjectStore::write(primary.as_ref(), kind, data, options)?;
3167        self.note_commit_object_written(kind);
3168        Ok(oid)
3169    }
3170
3171    /// Write an object as a loose file in this object directory only.
3172    ///
3173    /// Unlike [`Self::write`], this ignores `info/alternates` and
3174    /// `GIT_ALTERNATE_OBJECT_DIRECTORIES`: if the object exists only in an
3175    /// alternate store, it is still written here. That matches how Git's
3176    /// `unpack-objects` materializes every packed object into the receiving
3177    /// repository even when the same OID is already reachable via alternates
3178    /// (see `t5519-push-alternates`).
3179    ///
3180    /// The well-known empty tree is still written when no loose file exists yet,
3181    /// even though [`Self::exists_local`] treats it as virtually present.
3182    ///
3183    /// # Errors
3184    ///
3185    /// Same as [`Self::write`].
3186    pub fn write_local(&self, kind: ObjectKind, data: &[u8]) -> Result<ObjectId> {
3187        if self.custom_primary.is_some() {
3188            return Err(Error::UnsupportedObjectStore {
3189                operation: "write_local",
3190            });
3191        }
3192        let oid = hash_object_data_with(self.hash_algo(), kind, data)?;
3193
3194        if let Some(existing) = self.try_freshen_existing_local(&oid) {
3195            return Ok(existing);
3196        }
3197
3198        let oid = self
3199            .local_loose()?
3200            .write(kind, data, WriteOptions::default())?;
3201        self.note_commit_object_written(kind);
3202        Ok(oid)
3203    }
3204
3205    /// Like [`Self::write_local`], but stores payload bytes supplied chunk-by-chunk (pack unpack).
3206    pub(crate) fn write_local_stream_with(
3207        &self,
3208        kind: ObjectKind,
3209        size: u64,
3210        supply: impl FnMut(&mut dyn FnMut(&[u8]) -> Result<()>) -> Result<()>,
3211    ) -> Result<ObjectId> {
3212        if self.custom_primary.is_some() {
3213            return Err(Error::UnsupportedObjectStore {
3214                operation: "write_local_stream",
3215            });
3216        }
3217        let loose = self.local_loose()?;
3218        let oid = loose.write_payload_stream_with(kind, size, WriteOptions::default(), supply)?;
3219        self.note_commit_object_written(kind);
3220        Ok(oid)
3221    }
3222
3223    /// Write a loose object file when it is missing, even if [`Self::exists`] is true because
3224    /// the object lives only in a pack.
3225    ///
3226    /// Used when materializing a partial-clone layout: objects must be duplicated as loose files
3227    /// before local packs are removed. Unlike [`Self::write_local`], objects present only in a
3228    /// promisor pack are still written because [`Self::exists_local`] treats those as absent.
3229    pub fn write_loose_materialize(&self, kind: ObjectKind, data: &[u8]) -> Result<ObjectId> {
3230        if self.custom_primary.is_some() {
3231            return Err(Error::UnsupportedObjectStore {
3232                operation: "write_loose_materialize",
3233            });
3234        }
3235        let oid = hash_object_data_with(self.hash_algo(), kind, data)?;
3236        if let Ok(primary) = self.primary() {
3237            if let Ok(Some(existing)) = primary
3238                .loose_store()
3239                .try_finish_if_loose_present(&oid, WriteOptions::default())
3240            {
3241                return Ok(existing);
3242            }
3243        }
3244
3245        self.local_loose()?
3246            .write(kind, data, WriteOptions::default())
3247    }
3248
3249    /// Write an already-serialized object (header + data) to the loose store.
3250    ///
3251    /// Useful when the caller has the full store bytes (e.g. from stdin with
3252    /// `--literally`).
3253    ///
3254    /// # Errors
3255    ///
3256    /// - [`Error::CorruptObject`] — the provided bytes don't form a valid header.
3257    /// - [`Error::Io`] / [`Error::Zlib`] — storage errors.
3258    pub fn write_raw(&self, store_bytes: &[u8]) -> Result<ObjectId> {
3259        let obj = parse_object_bytes(store_bytes)?;
3260        let oid = hash_object_data_with(self.hash_algo(), obj.kind, &obj.data)?;
3261
3262        if let Some(primary) = &self.custom_primary {
3263            if self.exists(&oid) {
3264                return Ok(oid);
3265            }
3266            if self.overlay_active() && self.overlay_store_object(oid, obj.kind, &obj.data) {
3267                return Ok(oid);
3268            }
3269            return WritableObjectStore::write(
3270                primary.as_ref(),
3271                obj.kind,
3272                &obj.data,
3273                WriteOptions::default(),
3274            );
3275        }
3276
3277        if let Some(existing) = self.try_freshen_existing(&oid) {
3278            return Ok(existing);
3279        }
3280
3281        self.local_loose()?
3282            .write_store_prehashed(&oid, store_bytes, WriteOptions::default())
3283    }
3284
3285    /// Like [`Self::write_raw`] but only consults this object directory, not alternates.
3286    ///
3287    /// See [`Self::write_local`].
3288    ///
3289    /// # Errors
3290    ///
3291    /// Same as [`Self::write_raw`].
3292    pub fn write_raw_local(&self, store_bytes: &[u8]) -> Result<ObjectId> {
3293        if self.custom_primary.is_some() {
3294            return Err(Error::UnsupportedObjectStore {
3295                operation: "write_raw_local",
3296            });
3297        }
3298        let obj = parse_object_bytes(store_bytes)?;
3299        let oid = hash_object_data_with(self.hash_algo(), obj.kind, &obj.data)?;
3300        if let Some(existing) = self.try_freshen_existing_local(&oid) {
3301            return Ok(existing);
3302        }
3303
3304        self.local_loose()?
3305            .write_store_prehashed(&oid, store_bytes, WriteOptions::default())
3306    }
3307
3308    /// Update `path`'s mtime to "now" (Git `utime(path, NULL)`), returning whether it succeeded.
3309    fn touch_object_mtime(&self, path: &Path) -> Option<std::time::SystemTime> {
3310        #[cfg(test)]
3311        self.hot_path_test_metrics.record_freshen();
3312        // `utime(path, NULL)` sets both atime and mtime to the current time.
3313        let now = filetime::FileTime::now();
3314        filetime::set_file_times(path, now, now).ok()?;
3315        Some(
3316            std::time::UNIX_EPOCH
3317                + std::time::Duration::new(now.unix_seconds() as u64, now.nanoseconds()),
3318        )
3319    }
3320}
3321
3322/// Hash the canonical store bytes of an object (`"<kind> <len>\0<data>"`) with
3323/// the given hash algorithm.
3324fn hash_object_data_with(algo: HashAlgo, kind: ObjectKind, data: &[u8]) -> Result<ObjectId> {
3325    hash::hash_object(algo, kind, data)
3326}
3327
3328/// Whether `oid` is Git's empty tree for `algo` (including the legacy SHA-1 typo OID).
3329#[must_use]
3330pub(crate) fn empty_tree_oid_matches(algo: HashAlgo, oid: &ObjectId) -> bool {
3331    oid.is_empty_tree_for(algo)
3332}
3333
3334/// Zlib-compress blob `data` in loose-object form (`"blob <len>\\0<payload>"`) without first
3335/// copying it behind the header.
3336pub(crate) fn zlib_compress_blob_loose(
3337    data: &[u8],
3338    compression: flate2::Compression,
3339) -> Result<Vec<u8>> {
3340    use std::io::Write as _;
3341    let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), compression);
3342    let zlib_err = |e: std::io::Error| Error::Zlib(e.to_string());
3343    write!(encoder, "{} {}\0", ObjectKind::Blob, data.len()).map_err(zlib_err)?;
3344    encoder.write_all(data).map_err(zlib_err)?;
3345    encoder.finish().map_err(zlib_err)
3346}
3347
3348fn append_lookup_prefix_layer(
3349    store: &dyn ObjectStore,
3350    prefix: &str,
3351    limit: usize,
3352    out: &mut Vec<ObjectId>,
3353    seen: &mut OidHashSet,
3354) -> Result<()> {
3355    let mut scratch = Vec::new();
3356    store.lookup_prefix(prefix, 0, &mut scratch)?;
3357    for oid in scratch {
3358        if seen.insert(oid) {
3359            out.push(oid);
3360            if limit != 0 && out.len() >= limit {
3361                return Ok(());
3362            }
3363        }
3364    }
3365    Ok(())
3366}
3367
3368fn materialize_stream(stream: ObjectStream<'_>) -> Result<ObjectStream<'static>> {
3369    use std::io::{Cursor, Read};
3370    let ObjectStream {
3371        kind,
3372        size,
3373        mut reader,
3374    } = stream;
3375    let mut payload = Vec::new();
3376    reader.read_to_end(&mut payload).map_err(Error::Io)?;
3377    Ok(ObjectStream {
3378        kind,
3379        size,
3380        reader: Box::new(Cursor::new(payload)),
3381    })
3382}
3383
3384/// Parse a colon-separated alternates string, handling double-quoted entries
3385/// with octal escape sequences.
3386fn parse_alternate_env(val: &str) -> Vec<PathBuf> {
3387    let mut result = Vec::new();
3388    let mut chars = val.chars().peekable();
3389    while chars.peek().is_some() {
3390        if chars.peek() == Some(&':') {
3391            chars.next();
3392            continue;
3393        }
3394        if chars.peek() == Some(&'"') {
3395            // Try quoted parsing; if EOF is hit without closing quote,
3396            // fall back to treating the whole segment as a raw path.
3397            chars.next(); // consume the opening '"'
3398            let saved: Vec<char> = chars.clone().collect();
3399            let mut path = String::new();
3400            let mut properly_closed = false;
3401            loop {
3402                match chars.next() {
3403                    None => break,
3404                    Some('"') => {
3405                        properly_closed = true;
3406                        break;
3407                    }
3408                    Some('\\') => match chars.peek() {
3409                        Some(c) if c.is_ascii_digit() => {
3410                            let mut oct = String::new();
3411                            for _ in 0..3 {
3412                                if let Some(&c) = chars.peek() {
3413                                    if c.is_ascii_digit() {
3414                                        oct.push(c);
3415                                        chars.next();
3416                                    } else {
3417                                        break;
3418                                    }
3419                                } else {
3420                                    break;
3421                                }
3422                            }
3423                            if let Ok(byte) = u8::from_str_radix(&oct, 8) {
3424                                path.push(byte as char);
3425                            }
3426                        }
3427                        Some(_) => {
3428                            if let Some(c) = chars.next() {
3429                                match c {
3430                                    'n' => path.push('\n'),
3431                                    't' => path.push('\t'),
3432                                    'r' => path.push('\r'),
3433                                    _ => path.push(c),
3434                                }
3435                            }
3436                        }
3437                        None => {}
3438                    },
3439                    Some(c) => path.push(c),
3440                }
3441            }
3442            if !properly_closed {
3443                // Broken quoting: fall back to treating raw value (with leading ")
3444                // as a literal path.
3445                let raw: String = std::iter::once('"').chain(saved).collect();
3446                // Extract up to ':' or end
3447                let raw_path = raw.split(':').next().unwrap_or(&raw);
3448                if !raw_path.is_empty() {
3449                    result.push(PathBuf::from(raw_path));
3450                }
3451                // Advance past the ':' in the original chars (we consumed the saved copy)
3452                // Since chars is now at EOF, we need to handle remaining items.
3453                // Actually, we consumed chars fully. Let's reconstruct from raw.
3454                let remainder = &raw[raw_path.len()..];
3455                if let Some(rest) = remainder.strip_prefix(':') {
3456                    // Parse remaining entries
3457                    result.extend(parse_alternate_env(rest));
3458                }
3459                return result;
3460            } else if !path.is_empty() {
3461                result.push(PathBuf::from(path));
3462            }
3463        } else {
3464            let mut path = String::new();
3465            while let Some(&c) = chars.peek() {
3466                if c == ':' {
3467                    break;
3468                }
3469                path.push(c);
3470                chars.next();
3471            }
3472            if !path.is_empty() {
3473                result.push(PathBuf::from(path));
3474            }
3475        }
3476    }
3477    result
3478}
3479
3480fn system_time_to_file_time(t: std::time::SystemTime) -> FileTime {
3481    let dur = t
3482        .duration_since(std::time::SystemTime::UNIX_EPOCH)
3483        .unwrap_or_default();
3484    FileTime::from_unix_time(
3485        i64::try_from(dur.as_secs()).unwrap_or(i64::MAX),
3486        dur.subsec_nanos(),
3487    )
3488}
3489
3490fn superseded_pack_indexes(
3491    pack_dir: &Path,
3492    keep_pack: &Path,
3493    retained_packs: &HashSet<PathBuf>,
3494) -> Result<Vec<(PathBuf, std::time::SystemTime)>> {
3495    let keep_stem = keep_pack
3496        .file_stem()
3497        .and_then(|s| s.to_str())
3498        .ok_or_else(|| {
3499            Error::Io(std::io::Error::new(
3500                std::io::ErrorKind::InvalidInput,
3501                "bad pack path",
3502            ))
3503        })?;
3504    if !pack_dir.is_dir() {
3505        return Ok(Vec::new());
3506    }
3507    let mut out = Vec::new();
3508    for entry in fs::read_dir(pack_dir).map_err(Error::Io)? {
3509        let entry = entry.map_err(Error::Io)?;
3510        let name = entry.file_name();
3511        let name_str = name.to_string_lossy();
3512        if name_str == keep_stem || name_str.starts_with(&format!("{keep_stem}.")) {
3513            continue;
3514        }
3515        if !name_str.ends_with(".idx") {
3516            continue;
3517        }
3518        let idx_path = entry.path();
3519        let pack_path = idx_path.with_extension("pack");
3520        if !pack_path.is_file() {
3521            continue;
3522        }
3523        if superseded_pack_has_keep_marker(pack_dir, &pack_path)
3524            || pack_path.with_extension("promisor").is_file()
3525            || retained_packs.contains(&pack_path)
3526        {
3527            continue;
3528        }
3529        let pack_mtime = fs::metadata(&pack_path)
3530            .and_then(|m| m.modified())
3531            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
3532        out.push((idx_path, pack_mtime));
3533    }
3534    Ok(out)
3535}
3536
3537fn superseded_pack_has_keep_marker(pack_dir: &Path, pack_path: &Path) -> bool {
3538    let Some(stem) = pack_path.file_stem().and_then(|s| s.to_str()) else {
3539        return false;
3540    };
3541    pack_dir.join(format!("{stem}.keep")).is_file()
3542}
3543
3544fn oids_in_marker_kept_packs(pack_dir: &Path, keep_pack: &Path) -> Result<OidHashSet> {
3545    let keep_stem = keep_pack.file_stem().and_then(|s| s.to_str()).unwrap_or("");
3546    let mut oids = oid_hash_set();
3547    if !pack_dir.is_dir() {
3548        return Ok(oids);
3549    }
3550    for entry in fs::read_dir(pack_dir).map_err(Error::Io)? {
3551        let entry = entry.map_err(Error::Io)?;
3552        let pack_path = entry.path();
3553        if pack_path.extension().is_none_or(|e| e != "pack") {
3554            continue;
3555        }
3556        let Some(stem) = pack_path.file_stem().and_then(|s| s.to_str()) else {
3557            continue;
3558        };
3559        if stem == keep_stem {
3560            continue;
3561        }
3562        if !superseded_pack_has_keep_marker(pack_dir, &pack_path) {
3563            continue;
3564        }
3565        let idx_path = pack_path.with_extension("idx");
3566        if idx_path.is_file() {
3567            oids.extend(PackedObjects::object_ids_from_index_path(&idx_path)?);
3568        }
3569    }
3570    Ok(oids)
3571}
3572
3573#[cfg(test)]
3574mod tests {
3575    #![allow(clippy::expect_used, clippy::unwrap_used)]
3576
3577    use super::*;
3578    use tempfile::TempDir;
3579
3580    #[test]
3581    fn round_trip_blob() {
3582        let dir = TempDir::new().unwrap();
3583        let odb = Odb::new(dir.path());
3584        let data = b"hello world";
3585        let oid = odb.write(ObjectKind::Blob, data).unwrap();
3586        let obj = odb.read(&oid).unwrap();
3587        assert_eq!(obj.kind, ObjectKind::Blob);
3588        assert_eq!(obj.data, data);
3589    }
3590
3591    #[test]
3592    fn known_blob_hash() {
3593        // Verified: echo -n "hello" | git hash-object --stdin
3594        //        => b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0
3595        let oid = HashAlgo::Sha1
3596            .hash_object(ObjectKind::Blob, b"hello")
3597            .expect("hash");
3598        assert_eq!(oid.to_hex(), "b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0");
3599    }
3600
3601    /// Pack `oids` into one new pack and drop their loose copies.
3602    fn pack_objects(objects: &Path, oids: &[ObjectId]) {
3603        let odb = Odb::new(objects);
3604        let pack = crate::pack_objects::build_pack_from_send_list(
3605            &odb,
3606            oids,
3607            &HashSet::new(),
3608            &crate::pack_objects::PackBuildOptions::default(),
3609            &HashMap::new(),
3610        )
3611        .unwrap();
3612        for oid in oids {
3613            fs::remove_file(oid.loose_path_in(objects)).unwrap();
3614        }
3615        crate::index_pack::install_pack_bytes(
3616            pack,
3617            &odb,
3618            &crate::index_pack::IngestPackOptions::default(),
3619        )
3620        .unwrap();
3621    }
3622
3623    #[test]
3624    fn general_reads_cache_trees_and_tiny_blobs_but_not_larger_blobs() {
3625        let dir = TempDir::new().unwrap();
3626        let odb = Odb::new(dir.path());
3627        let blob = odb
3628            .write(
3629                ObjectKind::Blob,
3630                &vec![b'x'; SESSION_READ_BLOB_CACHE_MAX + 1],
3631            )
3632            .unwrap();
3633        let tiny = odb.write(ObjectKind::Blob, b"tiny\n").unwrap();
3634        let tree = odb
3635            .write(
3636                ObjectKind::Tree,
3637                &crate::objects::serialize_tree(&[crate::objects::TreeEntry {
3638                    mode: 0o100644,
3639                    name: b"f".to_vec(),
3640                    oid: blob,
3641                }]),
3642            )
3643            .unwrap();
3644        assert_eq!(
3645            odb.read(&blob).unwrap().data.len(),
3646            SESSION_READ_BLOB_CACHE_MAX + 1
3647        );
3648        assert!(odb.session_loose_reads.lock().unwrap().is_empty());
3649        let tree_bytes = odb.read(&tree).unwrap().data;
3650        assert_eq!(odb.read(&tiny).unwrap().data, b"tiny\n");
3651        for oid in [blob, tree, tiny] {
3652            let hex = oid.to_hex();
3653            fs::remove_file(dir.path().join(&hex[..2]).join(&hex[2..])).unwrap();
3654        }
3655        assert_eq!(odb.read(&tree).unwrap().data, tree_bytes, "tree reused");
3656        assert_eq!(odb.read(&tiny).unwrap().data, b"tiny\n", "tiny blob reused");
3657        assert!(odb.read(&blob).is_err(), "blob was not retained");
3658    }
3659
3660    #[test]
3661    fn session_read_cache_is_bounded_by_bytes_and_entries() {
3662        let mut cache = SessionLooseReads::default();
3663        let chunk = SESSION_LOOSE_CACHE_MAX_BYTES / 4;
3664        let object = |len: usize| Object {
3665            kind: ObjectKind::Tree,
3666            data: vec![0; len],
3667        };
3668        let oid = |n: u32| {
3669            let mut raw = [0u8; 20];
3670            raw[..4].copy_from_slice(&n.to_be_bytes());
3671            ObjectId::from_bytes(&raw).unwrap()
3672        };
3673        for n in 0..4 {
3674            cache.insert(oid(n), &object(chunk));
3675        }
3676        assert_eq!(cache.bytes, SESSION_LOOSE_CACHE_MAX_BYTES);
3677        cache.insert(oid(4), &object(1));
3678        assert_eq!(cache.bytes, 1, "exceeding the byte budget starts over");
3679        assert!(cache.get(&oid(0)).is_none());
3680        cache.insert(oid(4), &object(10));
3681        assert_eq!(cache.bytes, 10, "replacing an entry drops its old size");
3682        cache.insert(oid(5), &object(SESSION_LOOSE_CACHE_MAX_BYTES + 1));
3683        assert!(
3684            cache.get(&oid(5)).is_none(),
3685            "oversized objects are never kept"
3686        );
3687        assert!(cache.get(&oid(4)).is_some());
3688        for n in 10..(10 + SESSION_LOOSE_CACHE_MAX_ENTRIES as u32 + 2) {
3689            cache.insert(oid(n), &object(1));
3690        }
3691        assert!(cache.objects.len() <= SESSION_LOOSE_CACHE_MAX_ENTRIES + 1);
3692        assert!(cache.bytes <= SESSION_LOOSE_CACHE_MAX_BYTES);
3693    }
3694
3695    fn bounded_read_fixture() -> (TempDir, PathBuf, ObjectId, ObjectId) {
3696        let dir = TempDir::new().unwrap();
3697        let objects = dir.path().to_path_buf();
3698        let odb = Odb::new(&objects);
3699        let small = odb.write(ObjectKind::Blob, b"small blob\n").unwrap();
3700        let big: Vec<u8> = (0..300_000u32).flat_map(|i| i.to_le_bytes()).collect();
3701        let big = odb.write(ObjectKind::Blob, &big).unwrap();
3702        (dir, objects, small, big)
3703    }
3704
3705    fn assert_bounded_reads(odb: &Odb, small: &ObjectId, big: &ObjectId) {
3706        match odb.read_unless_larger(small, 1000, false).unwrap() {
3707            BoundedRead::Object(obj) => assert_eq!(obj.data, b"small blob\n"),
3708            BoundedRead::Larger => panic!("small blob is within the limit"),
3709        }
3710        assert!(matches!(
3711            odb.read_unless_larger(big, 1000, false).unwrap(),
3712            BoundedRead::Larger
3713        ));
3714        match odb.read_unless_larger(big, 1_200_000, false).unwrap() {
3715            BoundedRead::Object(obj) => assert_eq!(obj.data.len(), 1_200_000),
3716            BoundedRead::Larger => panic!("limit equal to the size reads the object"),
3717        }
3718        let missing = ObjectId::from_hex("1234567890123456789012345678901234567890").unwrap();
3719        assert!(matches!(
3720            odb.read_unless_larger(&missing, 1000, false),
3721            Err(Error::ObjectNotFound(_))
3722        ));
3723    }
3724
3725    #[test]
3726    fn read_unless_larger_sizes_loose_objects_from_their_header() {
3727        let (_dir, objects, small, big) = bounded_read_fixture();
3728        assert_bounded_reads(&Odb::new(&objects), &small, &big);
3729    }
3730
3731    #[test]
3732    fn read_unless_larger_sizes_packed_objects_in_and_out_of_read_context() {
3733        let (_dir, objects, small, big) = bounded_read_fixture();
3734        pack_objects(&objects, &[small, big]);
3735        let odb = Odb::new(&objects);
3736        assert_bounded_reads(&odb, &small, &big);
3737        odb.with_pack_read_context(|| assert_bounded_reads(&odb, &small, &big));
3738    }
3739
3740    #[test]
3741    fn parsed_tree_reads_keep_no_raw_copy_in_the_session_cache() {
3742        let (_dir, objects, small, _big) = bounded_read_fixture();
3743        let odb = Odb::new(&objects);
3744        let entries = [crate::objects::TreeEntry {
3745            mode: 0o100644,
3746            name: b"small".to_vec(),
3747            oid: small,
3748        }];
3749        let tree = odb
3750            .write(ObjectKind::Tree, &crate::objects::serialize_tree(&entries))
3751            .unwrap();
3752        for packed in [false, true] {
3753            if packed {
3754                pack_objects(&objects, &[tree]);
3755            }
3756            let odb = Odb::new(&objects);
3757            assert_eq!(*odb.cached_tree_entries(&tree).unwrap(), entries);
3758            assert!(odb.session_cached_loose_read(&tree).is_none());
3759            assert!(Arc::ptr_eq(
3760                &odb.cached_tree_entries(&tree).unwrap(),
3761                &odb.cached_tree_entries(&tree).unwrap()
3762            ));
3763            assert_eq!(odb.read(&tree).unwrap().kind, ObjectKind::Tree);
3764            assert!(
3765                odb.session_cached_loose_read(&tree).is_some(),
3766                "plain reads still use the session cache (packed: {packed})"
3767            );
3768        }
3769    }
3770
3771    #[test]
3772    fn read_unless_larger_reuses_small_loose_reads_but_never_caches_big_ones() {
3773        let (_dir, objects, small, big) = bounded_read_fixture();
3774        let odb = Odb::new(&objects);
3775        assert_bounded_reads(&odb, &small, &big);
3776        assert!(odb.session_loose_reads.lock().unwrap().is_empty());
3777        assert!(matches!(
3778            odb.read_unless_larger(&small, 1000, true).unwrap(),
3779            BoundedRead::Object(_)
3780        ));
3781        assert!(matches!(
3782            odb.read_unless_larger(&big, 1_200_000, true).unwrap(),
3783            BoundedRead::Object(_)
3784        ));
3785        for oid in [small, big] {
3786            let hex = oid.to_hex();
3787            fs::remove_file(objects.join(&hex[..2]).join(&hex[2..])).unwrap();
3788        }
3789        match odb.read_unless_larger(&small, 1000, true).unwrap() {
3790            BoundedRead::Object(obj) => assert_eq!(obj.data, b"small blob\n"),
3791            BoundedRead::Larger => panic!("small blob is within the limit"),
3792        }
3793        assert!(matches!(
3794            odb.read_unless_larger(&small, 4, true),
3795            Ok(BoundedRead::Larger)
3796        ));
3797        assert!(matches!(
3798            odb.read_unless_larger(&big, 1_200_000, true),
3799            Err(Error::ObjectNotFound(_))
3800        ));
3801    }
3802
3803    #[test]
3804    fn read_unless_larger_finds_objects_across_several_packs() {
3805        let (_dir, objects, small, big) = bounded_read_fixture();
3806        pack_objects(&objects, &[small]);
3807        pack_objects(&objects, &[big]);
3808        let odb = Odb::new(&objects);
3809        odb.with_pack_read_context(|| assert_bounded_reads(&odb, &small, &big));
3810    }
3811
3812    #[test]
3813    fn parse_object_header_prefix_unknown_kind_is_typed_error() {
3814        let err = crate::odb::store::loose::parse_object_header_prefix(b"not-a-git-kind 0\0")
3815            .unwrap_err();
3816        assert!(matches!(err, Error::UnknownObjectType(_)));
3817    }
3818
3819    fn git_dir_with_config(config: &str) -> (TempDir, PathBuf, PathBuf) {
3820        let dir = TempDir::new().unwrap();
3821        let git_dir = dir.path().join(".git");
3822        let objects = git_dir.join("objects");
3823        fs::create_dir_all(&objects).unwrap();
3824        fs::write(git_dir.join("config"), config).unwrap();
3825        (dir, git_dir, objects)
3826    }
3827
3828    /// Zlib FLEVEL flag (upper two bits of the second header byte).
3829    fn zlib_flevel(compressed: &[u8]) -> u8 {
3830        assert!(compressed.len() >= 2);
3831        (compressed[1] >> 6) & 3
3832    }
3833
3834    fn tmp_loose_object_paths(objects: &Path) -> Vec<PathBuf> {
3835        let mut tmp = Vec::new();
3836        let Ok(shards) = fs::read_dir(objects) else {
3837            return tmp;
3838        };
3839        for shard in shards.flatten() {
3840            if !shard.file_type().is_ok_and(|t| t.is_dir()) {
3841                continue;
3842            }
3843            let Ok(files) = fs::read_dir(shard.path()) else {
3844                continue;
3845            };
3846            for file in files.flatten() {
3847                let name = file.file_name();
3848                if name.to_string_lossy().starts_with("tmp_") {
3849                    tmp.push(file.path());
3850                }
3851            }
3852        }
3853        tmp
3854    }
3855
3856    #[test]
3857    fn loose_write_honours_level() {
3858        let payload: Vec<u8> = (0..4096).map(|i| (i % 251) as u8).collect();
3859
3860        let (_dir, git_dir, objects) = git_dir_with_config("");
3861        let odb = Odb::new(&objects).with_config_git_dir(git_dir.clone());
3862        let oid_default = odb.write(ObjectKind::Blob, &payload).unwrap();
3863        let bytes_default = fs::read(odb.object_path(&oid_default)).unwrap();
3864        assert!(zlib_flevel(&bytes_default) <= 1);
3865        assert_eq!(odb.read(&oid_default).unwrap().data, payload);
3866
3867        for level in -1i32..=9 {
3868            let config = format!("[core]\n\tloosecompression = {level}\n");
3869            let (_dir, git_dir, objects) = git_dir_with_config(&config);
3870            let odb = Odb::new(&objects).with_config_git_dir(git_dir);
3871            let byte = u8::try_from(level.rem_euclid(256)).unwrap_or(0);
3872            let bytes: Vec<u8> = vec![byte; 512];
3873            let oid = odb.write(ObjectKind::Blob, &bytes).unwrap();
3874            let on_disk = fs::read(odb.object_path(&oid)).unwrap();
3875            assert!(on_disk.len() >= 2, "level {level}");
3876            assert_eq!(odb.read(&oid).unwrap().data, bytes.as_slice());
3877        }
3878
3879        let config = "[core]\n\tcompression = 9\n";
3880        let (_dir, git_dir, objects) = git_dir_with_config(config);
3881        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
3882        let oid = odb.write(ObjectKind::Blob, &payload).unwrap();
3883        assert_eq!(zlib_flevel(&fs::read(odb.object_path(&oid)).unwrap()), 3);
3884
3885        let config = "[core]\n\tcompression = 0\n\tloosecompression = 1\n";
3886        let (_dir, git_dir, objects) = git_dir_with_config(config);
3887        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
3888        let oid = odb.write(ObjectKind::Blob, &payload).unwrap();
3889        assert_eq!(zlib_flevel(&fs::read(odb.object_path(&oid)).unwrap()), 0);
3890
3891        let config = "[core]\n\tloosecompression = 99\n";
3892        let (_dir, git_dir, objects) = git_dir_with_config(config);
3893        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
3894        assert!(odb.write(ObjectKind::Blob, b"x").is_err());
3895        assert!(tmp_loose_object_paths(&objects).is_empty());
3896    }
3897
3898    fn git_run(dir: &Path, args: &[&str]) -> std::process::Output {
3899        std::process::Command::new("git") // hygiene: git fixture comparison in cfg(test) module
3900            .current_dir(dir)
3901            .args(args)
3902            .env("GIT_CONFIG_GLOBAL", "/dev/null")
3903            .env("GIT_CONFIG_SYSTEM", "/dev/null")
3904            .env("GIT_AUTHOR_NAME", "T")
3905            .env("GIT_AUTHOR_EMAIL", "t@example.com")
3906            .env("GIT_COMMITTER_NAME", "T")
3907            .env("GIT_COMMITTER_EMAIL", "t@example.com")
3908            .output()
3909            .unwrap()
3910    }
3911
3912    fn git_in(dir: &Path, args: &[&str]) {
3913        let out = git_run(dir, args);
3914        assert!(
3915            out.status.success(),
3916            "git {:?}: {}",
3917            args,
3918            String::from_utf8_lossy(&out.stderr)
3919        );
3920    }
3921
3922    fn git_rev_parse_oid(dir: &Path, spec: &str) -> ObjectId {
3923        let out = git_run(dir, &["rev-parse", spec]);
3924        assert!(
3925            out.status.success(),
3926            "git rev-parse {spec}: {}",
3927            String::from_utf8_lossy(&out.stderr)
3928        );
3929        ObjectId::from_hex(std::str::from_utf8(&out.stdout).unwrap().trim()).unwrap()
3930    }
3931
3932    #[test]
3933    fn write_loose_zlib_prehashed_matches_write() {
3934        let dir = TempDir::new().unwrap();
3935        let objects = dir.path().join("objects");
3936        let odb = Odb::new(&objects);
3937        let data = b"parallel prehash path";
3938        let oid_normal = odb.write(ObjectKind::Blob, data).unwrap();
3939
3940        let dir2 = TempDir::new().unwrap();
3941        let objects2 = dir2.path().join("objects");
3942        let odb2 = Odb::new(&objects2);
3943        let oid = hash::hash_object(odb2.hash_algo(), ObjectKind::Blob, data).expect("hash");
3944        assert_eq!(oid, oid_normal);
3945        let zlib =
3946            super::zlib_compress_blob_loose(data, odb2.loose_compression().unwrap()).unwrap();
3947        let oid2 = odb2
3948            .write_loose_zlib_prehashed(
3949                &oid,
3950                &zlib,
3951                WriteOptions {
3952                    assume_loose_only_existence: true,
3953                    ..WriteOptions::default()
3954                },
3955            )
3956            .unwrap();
3957        assert_eq!(oid2, oid_normal);
3958        assert_eq!(
3959            fs::read(odb2.object_path(&oid2)).unwrap(),
3960            fs::read(odb.object_path(&oid_normal)).unwrap()
3961        );
3962    }
3963
3964    #[test]
3965    fn write_blob_prehashed_matches_write_and_skips_existing_objects() {
3966        let dir = TempDir::new().unwrap();
3967        let odb = Odb::new(&dir.path().join("objects"));
3968        let data = vec![7u8; 100_000];
3969        let oid_normal = odb.write(ObjectKind::Blob, &data).unwrap();
3970
3971        let dir2 = TempDir::new().unwrap();
3972        let odb2 = Odb::new(&dir2.path().join("objects"));
3973        let oid = hash::hash_object(odb2.hash_algo(), ObjectKind::Blob, &data).unwrap();
3974        let options = WriteOptions {
3975            assume_loose_only_existence: true,
3976            ..WriteOptions::default()
3977        };
3978        assert_eq!(
3979            odb2.write_blob_prehashed(&oid, &data, options).unwrap(),
3980            oid
3981        );
3982        assert_eq!(oid, oid_normal);
3983        assert!(odb2.object_path(&oid).is_file());
3984        let obj = odb2.read(&oid).unwrap();
3985        assert_eq!(
3986            (obj.kind, obj.data.as_slice()),
3987            (ObjectKind::Blob, data.as_slice())
3988        );
3989
3990        // An existing object is kept as is, even when handed different bytes for its id.
3991        assert_eq!(
3992            odb2.write_blob_prehashed(&oid, b"other", options).unwrap(),
3993            oid
3994        );
3995        assert_eq!(odb2.read(&oid).unwrap().data, data);
3996    }
3997
3998    #[test]
3999    fn write_loose_zlib_prehashed_routes_to_custom_primary() {
4000        use crate::objects::HashAlgo;
4001        use std::sync::Arc;
4002
4003        let dir = TempDir::new().unwrap();
4004        let objects = dir.path().join("objects");
4005        fs::create_dir_all(&objects).unwrap();
4006        let store = Arc::new(MemoryStore::new(HashAlgo::Sha1));
4007        let odb = OdbBuilder::files(&objects)
4008            .primary(store.clone())
4009            .alternates(false)
4010            .build();
4011        let data = b"staged through a custom primary";
4012        let oid = hash::hash_object(HashAlgo::Sha1, ObjectKind::Blob, data).unwrap();
4013        let zlib = super::zlib_compress_blob_loose(data, Compression::fast()).unwrap();
4014
4015        odb.ensure_all_loose_prefix_dirs().unwrap();
4016        let written = odb
4017            .write_loose_zlib_prehashed(&oid, &zlib, WriteOptions::default())
4018            .unwrap();
4019        assert_eq!(written, oid);
4020        assert!(store.contains(&oid).unwrap());
4021        assert_eq!(odb.read(&oid).unwrap().data, data);
4022        assert_eq!(
4023            fs::read_dir(&objects).unwrap().count(),
4024            0,
4025            "no loose shard directories or files beside a custom primary"
4026        );
4027
4028        assert!(odb
4029            .write_loose_zlib_prehashed(&oid, b"not zlib", WriteOptions::default())
4030            .is_err());
4031    }
4032
4033    /// Re-writing an object that already exists as a loose file must not replace it; Git
4034    /// only touches mtime via [`Self::freshen_object`].
4035    #[test]
4036    fn write_existing_loose_does_not_rewrite() {
4037        let dir = TempDir::new().unwrap();
4038        let objects = dir.path().join("objects");
4039        fs::create_dir_all(&objects).unwrap();
4040        let odb = Odb::new(&objects);
4041        let payload = b"already loose";
4042        let oid = odb.write(ObjectKind::Blob, payload).unwrap();
4043        let path = odb.object_path(&oid);
4044        let before_bytes = fs::read(&path).unwrap();
4045        let meta_before = fs::metadata(&path).unwrap();
4046        let before_mtime = filetime::FileTime::from_last_modification_time(&meta_before);
4047        #[cfg(unix)]
4048        let ino_before = {
4049            use std::os::unix::fs::MetadataExt;
4050            meta_before.ino()
4051        };
4052        std::thread::sleep(std::time::Duration::from_millis(20));
4053        let oid2 = odb.write(ObjectKind::Blob, payload).unwrap();
4054        assert_eq!(oid, oid2);
4055        assert_eq!(fs::read(&path).unwrap(), before_bytes);
4056        let meta_after = fs::metadata(&path).unwrap();
4057        let after_mtime = filetime::FileTime::from_last_modification_time(&meta_after);
4058        assert!(
4059            after_mtime >= before_mtime,
4060            "expected freshen to bump or preserve mtime"
4061        );
4062        #[cfg(unix)]
4063        {
4064            use std::os::unix::fs::MetadataExt;
4065            assert_eq!(
4066                meta_after.ino(),
4067                ino_before,
4068                "rewrite must not replace the loose object file"
4069            );
4070        }
4071    }
4072
4073    /// Objects present only in a pack must not be duplicated as loose files on [`Self::write`].
4074    #[cfg_attr(
4075        not(feature = "full-tests"),
4076        ignore = "starts system git or is heavy; run with --features full-tests"
4077    )]
4078    #[test]
4079    fn write_skips_loose_when_object_in_pack_only() {
4080        let dir = TempDir::new().unwrap();
4081        git_in(dir.path(), &["init", "-q"]);
4082        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
4083        git_in(dir.path(), &["config", "user.name", "T"]);
4084        fs::write(dir.path().join("f"), b"pack-only payload").unwrap();
4085        git_in(dir.path(), &["add", "f"]);
4086        git_in(dir.path(), &["commit", "-m", "c"]);
4087        git_in(dir.path(), &["repack", "-ad"]);
4088        let objects = dir.path().join(".git").join("objects");
4089        let odb = Odb::new(&objects);
4090        let oid = odb.write(ObjectKind::Blob, b"pack-only payload").unwrap();
4091        assert!(!odb.object_path(&oid).exists());
4092        assert!(odb.exists(&oid));
4093    }
4094
4095    /// Same as pack-only: alternates satisfy [`Self::exists`] without a local loose copy.
4096    #[cfg_attr(
4097        not(feature = "full-tests"),
4098        ignore = "starts system git or is heavy; run with --features full-tests"
4099    )]
4100    #[test]
4101    fn write_freshens_loose_object_in_alternate() {
4102        use filetime::FileTime;
4103        use std::thread;
4104        use std::time::Duration;
4105
4106        let alt = TempDir::new().unwrap();
4107        git_in(alt.path(), &["init", "-q", "--bare"]);
4108        git_in(alt.path(), &["config", "user.email", "t@example.com"]);
4109        let alt_objects = alt.path().join("objects");
4110        let alt_odb = Odb::new(&alt_objects);
4111        let payload = b"alternate-loose";
4112        let oid = alt_odb.write(ObjectKind::Blob, payload).unwrap();
4113        let alt_loose = alt_odb.object_path(&oid);
4114
4115        let primary = TempDir::new().unwrap();
4116        let primary_objects = primary.path().join("objects");
4117        fs::create_dir_all(primary_objects.join("info")).unwrap();
4118        fs::write(
4119            primary_objects.join("info").join("alternates"),
4120            format!("{}\n", alt_objects.display()),
4121        )
4122        .unwrap();
4123        let odb = Odb::new(&primary_objects);
4124        thread::sleep(Duration::from_millis(50));
4125        let before = FileTime::from_last_modification_time(&fs::metadata(&alt_loose).unwrap());
4126        filetime::set_file_mtime(&alt_loose, before).unwrap();
4127
4128        let oid2 = odb.write(ObjectKind::Blob, payload).unwrap();
4129        assert_eq!(oid, oid2);
4130        assert!(!odb.object_path(&oid).exists());
4131        let after = FileTime::from_last_modification_time(&fs::metadata(&alt_loose).unwrap());
4132        assert!(after > before, "write must freshen alternate loose object");
4133    }
4134
4135    #[cfg_attr(
4136        not(feature = "full-tests"),
4137        ignore = "starts system git or is heavy; run with --features full-tests"
4138    )]
4139    #[test]
4140    fn write_freshens_packed_object_in_alternate() {
4141        use filetime::FileTime;
4142        use std::thread;
4143        use std::time::Duration;
4144
4145        let alt = TempDir::new().unwrap();
4146        git_in(alt.path(), &["init", "-q"]);
4147        git_in(alt.path(), &["config", "user.email", "t@example.com"]);
4148        git_in(alt.path(), &["config", "user.name", "T"]);
4149        let payload = b"alternate-pack";
4150        fs::write(alt.path().join("f"), payload).unwrap();
4151        git_in(alt.path(), &["add", "f"]);
4152        git_in(alt.path(), &["commit", "-m", "c"]);
4153        git_in(alt.path(), &["repack", "-ad"]);
4154        let alt_objects = alt.path().join(".git").join("objects");
4155        let alt_odb = Odb::new(&alt_objects);
4156        let oid = alt_odb.write(ObjectKind::Blob, payload).unwrap();
4157        let pack_path = fs::read_dir(alt_objects.join("pack"))
4158            .unwrap()
4159            .flatten()
4160            .find(|e| e.path().extension().is_some_and(|x| x == "pack"))
4161            .unwrap()
4162            .path();
4163        thread::sleep(Duration::from_millis(50));
4164        let before = FileTime::from_last_modification_time(&fs::metadata(&pack_path).unwrap());
4165
4166        let primary = TempDir::new().unwrap();
4167        let primary_objects = primary.path().join("objects");
4168        fs::create_dir_all(primary_objects.join("info")).unwrap();
4169        fs::write(
4170            primary_objects.join("info").join("alternates"),
4171            format!("{}\n", alt_objects.display()),
4172        )
4173        .unwrap();
4174        pack::clear_pack_cache();
4175        let odb = Odb::new(&primary_objects);
4176        assert!(odb.exists(&oid));
4177        let oid2 = odb.write(ObjectKind::Blob, payload).unwrap();
4178        assert_eq!(oid, oid2);
4179        assert!(!odb.object_path(&oid).exists());
4180        let after = FileTime::from_last_modification_time(&fs::metadata(&pack_path).unwrap());
4181        assert!(after >= before, "write must freshen alternate pack");
4182    }
4183
4184    #[cfg_attr(
4185        not(feature = "full-tests"),
4186        ignore = "starts system git or is heavy; run with --features full-tests"
4187    )]
4188    #[test]
4189    fn write_skips_loose_when_object_only_in_alternate() {
4190        let alt = TempDir::new().unwrap();
4191        git_in(alt.path(), &["init", "-q", "--bare"]);
4192        git_in(alt.path(), &["config", "user.email", "t@example.com"]);
4193        let alt_objects = alt.path().join("objects");
4194        let alt_odb = Odb::new(&alt_objects);
4195        let payload = b"alternate-only";
4196        let oid = alt_odb.write(ObjectKind::Blob, payload).unwrap();
4197
4198        let primary = TempDir::new().unwrap();
4199        let primary_objects = primary.path().join("objects");
4200        fs::create_dir_all(primary.path().join("objects").join("info")).unwrap();
4201        fs::write(
4202            primary
4203                .path()
4204                .join("objects")
4205                .join("info")
4206                .join("alternates"),
4207            format!("{}\n", alt_objects.display()),
4208        )
4209        .unwrap();
4210        let odb = Odb::new(&primary_objects);
4211        assert!(odb.exists(&oid));
4212        let oid2 = odb.write(ObjectKind::Blob, payload).unwrap();
4213        assert_eq!(oid, oid2);
4214        assert!(!odb.object_path(&oid).exists());
4215    }
4216
4217    #[cfg_attr(
4218        not(feature = "full-tests"),
4219        ignore = "starts system git or is heavy; run with --features full-tests"
4220    )]
4221    #[test]
4222    fn write_with_mem_overlay_stays_in_memory() {
4223        let dir = TempDir::new().unwrap();
4224        let objects = dir.path().join("objects");
4225        fs::create_dir_all(&objects).unwrap();
4226        let odb = Odb::new(&objects);
4227        odb.enable_mem_overlay();
4228        let payload = b"overlay blob";
4229        let oid = odb.write(ObjectKind::Blob, payload).unwrap();
4230        assert!(!odb.object_path(&oid).exists());
4231        assert_eq!(odb.read(&oid).unwrap().data, payload);
4232        odb.disable_mem_overlay();
4233        assert!(odb.read(&oid).is_err());
4234    }
4235
4236    #[test]
4237    fn read_alternates_relative_path_and_symlinked_objects_dir() {
4238        use crate::pack::read_alternates_recursive;
4239
4240        let layout = TempDir::new().unwrap();
4241        let alt_objects = layout.path().join("alt-store").join("objects");
4242        fs::create_dir_all(&alt_objects).unwrap();
4243        let primary_objects = layout.path().join("repo").join("objects");
4244        fs::create_dir_all(primary_objects.join("info")).unwrap();
4245        fs::write(
4246            primary_objects.join("info").join("alternates"),
4247            "../../alt-store/objects\n",
4248        )
4249        .unwrap();
4250        let resolved = read_alternates_recursive(&primary_objects).unwrap();
4251        let alt_canonical = alt_objects
4252            .canonicalize_simplified()
4253            .unwrap_or(alt_objects.clone());
4254        assert!(
4255            resolved.iter().any(|p| {
4256                p.canonicalize_simplified().unwrap_or_else(|_| p.clone()) == alt_canonical
4257            }),
4258            "relative alternate entry should resolve: {resolved:?}"
4259        );
4260
4261        #[cfg(unix)]
4262        {
4263            let link_parent = TempDir::new().unwrap();
4264            std::os::unix::fs::symlink(&primary_objects, link_parent.path().join("objects-link"))
4265                .unwrap();
4266            let via_link =
4267                read_alternates_recursive(&link_parent.path().join("objects-link")).unwrap();
4268            assert!(
4269                !via_link.is_empty(),
4270                "alternates through symlinked objects dir: {via_link:?}"
4271            );
4272        }
4273    }
4274    #[test]
4275    fn empty_tree_exists_without_hex() {
4276        let odb = Odb::new(std::path::Path::new("/nonexistent/objects"));
4277        let canon = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap();
4278        let legacy = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf899d69f7c6948d4").unwrap();
4279        assert!(odb.exists(&canon));
4280        assert!(odb.exists(&legacy));
4281        assert!(odb.exists_local(&canon));
4282        assert!(!odb.freshen_object(&canon));
4283    }
4284
4285    #[test]
4286    fn sha256_empty_tree_virtual_read_without_loose_file() {
4287        use crate::objects::HashAlgo;
4288        use std::sync::Arc;
4289
4290        let dir = TempDir::new().unwrap();
4291        let objects = dir.path().join("objects");
4292        fs::create_dir_all(&objects).unwrap();
4293        let store = Arc::new(MemoryStore::new(HashAlgo::Sha256));
4294        let odb = OdbBuilder::files(&objects)
4295            .primary(store)
4296            .alternates(false)
4297            .build();
4298        let empty = odb.empty_tree_oid().expect("hash empty tree");
4299        assert!(!odb.exists_materialized_local(&empty));
4300        assert!(odb.exists_local(&empty));
4301        assert!(odb.is_empty_tree(&empty));
4302        let obj = odb.read(&empty).expect("virtual read empty tree");
4303        assert_eq!(obj.kind, ObjectKind::Tree);
4304        assert!(obj.data.is_empty());
4305    }
4306
4307    #[test]
4308    fn empty_tree_oid_and_object_id_empty_tree_match_sha1() {
4309        let odb = Odb::new(std::path::Path::new("/nonexistent/objects"));
4310        let from_odb = odb.empty_tree_oid().expect("empty tree oid");
4311        assert_eq!(from_odb, ObjectId::empty_tree());
4312        assert!(odb.is_empty_tree(&ObjectId::empty_tree()));
4313    }
4314
4315    #[test]
4316    fn empty_tree_virtual_presence_skips_materialized_local() {
4317        let dir = TempDir::new().unwrap();
4318        let objects = dir.path().join("objects");
4319        fs::create_dir_all(&objects).unwrap();
4320        let odb = Odb::new(&objects);
4321        let canon = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap();
4322        assert!(odb.exists_local(&canon));
4323        assert!(!odb.exists_materialized_local(&canon));
4324    }
4325
4326    #[test]
4327    fn write_materializes_well_known_empty_tree() {
4328        let dir = TempDir::new().unwrap();
4329        let objects = dir.path().join("objects");
4330        fs::create_dir_all(&objects).unwrap();
4331        let odb = Odb::new(&objects);
4332        let canon = ObjectId::from_hex("4b825dc642cb6eb9a060e54bf8d69288fbee4904").unwrap();
4333        assert!(!odb.exists_materialized_local(&canon));
4334        let oid = odb
4335            .write(crate::objects::ObjectKind::Tree, &[])
4336            .expect("write empty tree");
4337        assert_eq!(oid, canon);
4338        assert!(odb.exists_materialized_local(&canon));
4339    }
4340
4341    #[test]
4342    fn append_alternate_objects_line_errors_on_unreadable_existing_file() {
4343        let dir = TempDir::new().unwrap();
4344        let objects = dir.path().join("objects");
4345        fs::create_dir_all(objects.join("info")).unwrap();
4346        let alt_path = objects.join("info/alternates");
4347        fs::write(&alt_path, b"not valid utf-8 \xff\n").unwrap();
4348        let err = Odb::append_alternate_objects_line(&objects, Path::new("/tmp/other/objects"))
4349            .unwrap_err();
4350        assert!(matches!(err, Error::Io(_)));
4351        assert_eq!(fs::read(&alt_path).unwrap(), b"not valid utf-8 \xff\n");
4352    }
4353
4354    #[test]
4355    fn append_file_alternate_refreshes_cached_snapshot() {
4356        let primary = TempDir::new().unwrap();
4357        let alternate = TempDir::new().unwrap();
4358        let alt_objects = alternate.path().join("objects");
4359        std::fs::create_dir_all(&alt_objects).unwrap();
4360        let alt_odb = Odb::new(&alt_objects);
4361        let oid = alt_odb.write(ObjectKind::Blob, b"via-alternate").unwrap();
4362
4363        let primary_objects = primary.path().join("objects");
4364        std::fs::create_dir_all(primary_objects.join("info")).unwrap();
4365        let primary_odb = Odb::new(&primary_objects);
4366        assert!(!primary_odb.exists(&oid));
4367        let _ = primary_odb.file_alternate_dirs_snapshot();
4368        primary_odb.append_file_alternate(&alt_objects).unwrap();
4369        assert!(primary_odb.exists(&oid));
4370    }
4371
4372    #[test]
4373    fn exists_via_env_alternate_dirs_at_construction() {
4374        let primary = TempDir::new().unwrap();
4375        let alternate = TempDir::new().unwrap();
4376        let alt_objects = alternate.path().join("objects");
4377        std::fs::create_dir_all(&alt_objects).unwrap();
4378        let alt_odb = Odb::new(&alt_objects);
4379        let oid = alt_odb.write(ObjectKind::Blob, b"env-alt").unwrap();
4380
4381        let primary_objects = primary.path().join("objects");
4382        std::fs::create_dir_all(&primary_objects).unwrap();
4383        let primary_odb = Odb::new(&primary_objects).with_env_alternate_dirs(vec![alt_objects]);
4384        assert!(primary_odb.exists(&oid));
4385    }
4386
4387    fn flaky_odb(objects: &Path) -> (Odb, Arc<store::flaky::FlakyStore>) {
4388        let flaky = store::flaky::FlakyStore::new();
4389        let odb = OdbBuilder::files(objects)
4390            .primary(flaky.clone())
4391            .alternates(false)
4392            .build();
4393        (odb, flaky)
4394    }
4395
4396    #[test]
4397    fn contains_reports_primary_failure_instead_of_missing() {
4398        let dir = TempDir::new().unwrap();
4399        let (odb, flaky) = flaky_odb(dir.path());
4400        let oid = odb.write(ObjectKind::Blob, b"stored").unwrap();
4401        assert!(odb.contains(&oid).unwrap());
4402
4403        flaky.fail_all(true);
4404        assert!(store::flaky::is_injected(&odb.read(&oid).unwrap_err()));
4405        assert!(store::flaky::is_injected(&odb.contains(&oid).unwrap_err()));
4406        assert!(
4407            !odb.exists(&oid),
4408            "exists() keeps treating failures as absent"
4409        );
4410        assert!(odb.contains(&odb.empty_tree_oid().unwrap()).unwrap());
4411
4412        let try_local = odb.try_exists_local(&oid).unwrap_err();
4413        assert!(store::flaky::is_injected(&try_local));
4414        assert!(!odb.exists_local(&oid));
4415        let try_stored = odb.try_exists_stored(&oid).unwrap_err();
4416        assert!(store::flaky::is_injected(&try_stored));
4417        assert!(!odb.exists_stored(&oid));
4418    }
4419
4420    #[test]
4421    fn contains_reports_extra_read_source_failure() {
4422        let dir = TempDir::new().unwrap();
4423        let flaky = store::flaky::FlakyStore::new();
4424        let odb = OdbBuilder::files(dir.path())
4425            .push_read_source(flaky.clone())
4426            .alternates(false)
4427            .build();
4428        let local = odb.write(ObjectKind::Blob, b"in primary").unwrap();
4429        let absent = HashAlgo::Sha1
4430            .hash_object(ObjectKind::Blob, b"nowhere")
4431            .unwrap();
4432        assert!(!odb.contains(&absent).unwrap());
4433
4434        flaky.fail_contains(true);
4435        assert!(
4436            odb.contains(&local).unwrap(),
4437            "primary hit wins over a failing source"
4438        );
4439        assert!(store::flaky::is_injected(
4440            &odb.contains(&absent).unwrap_err()
4441        ));
4442        assert!(!odb.exists(&absent));
4443    }
4444
4445    #[test]
4446    fn contains_reports_primary_failure_even_when_later_source_has_object() {
4447        let dir = TempDir::new().unwrap();
4448        let extra = Arc::new(store::MemoryStore::new(HashAlgo::Sha1));
4449        let flaky = store::flaky::FlakyStore::new();
4450        let odb = OdbBuilder::files(dir.path())
4451            .primary(flaky.clone())
4452            .push_read_source(extra.clone())
4453            .alternates(false)
4454            .build();
4455        let oid = extra
4456            .write(ObjectKind::Blob, b"extra", WriteOptions::default())
4457            .unwrap();
4458        assert!(odb.contains(&oid).unwrap());
4459
4460        flaky.fail_all(true);
4461        assert!(store::flaky::is_injected(&odb.read(&oid).unwrap_err()));
4462        assert!(
4463            store::flaky::is_injected(&odb.contains(&oid).unwrap_err()),
4464            "contains must agree with read"
4465        );
4466        assert!(odb.exists(&oid), "exists() skips the failing primary");
4467    }
4468
4469    #[test]
4470    fn contains_reports_alternate_failure() {
4471        let primary = TempDir::new().unwrap();
4472        let alternate = TempDir::new().unwrap();
4473        let alt_objects = alternate.path().join("objects");
4474        std::fs::create_dir_all(&alt_objects).unwrap();
4475        let primary_objects = primary.path().join("objects");
4476        std::fs::create_dir_all(&primary_objects).unwrap();
4477        let odb = Odb::new(&primary_objects).with_env_alternate_dirs(vec![alt_objects]);
4478        let local = odb.write(ObjectKind::Blob, b"local").unwrap();
4479        let absent = HashAlgo::Sha1
4480            .hash_object(ObjectKind::Blob, b"nowhere")
4481            .unwrap();
4482
4483        // Files alternates skip unreadable packs, so stand a failing store in for the
4484        // composite the alternate directories resolve to.
4485        let flaky = store::flaky::FlakyStore::new();
4486        odb.sources().unwrap();
4487        odb.alternate_sources_cache.write().unwrap().sources = Some(Arc::new(CompositeStore::new(
4488            vec![flaky.clone()],
4489            HashAlgo::Sha1,
4490        )));
4491        assert!(!odb.contains(&absent).unwrap());
4492
4493        flaky.fail_contains(true);
4494        assert!(odb.contains(&local).unwrap());
4495        assert!(store::flaky::is_injected(
4496            &odb.contains(&absent).unwrap_err()
4497        ));
4498        assert!(!odb.exists(&absent));
4499    }
4500
4501    #[test]
4502    fn contains_on_files_backend_covers_present_absent_empty_tree_and_alternate() {
4503        let primary = TempDir::new().unwrap();
4504        let alternate = TempDir::new().unwrap();
4505        let alt_objects = alternate.path().join("objects");
4506        std::fs::create_dir_all(&alt_objects).unwrap();
4507        let alt_oid = Odb::new(&alt_objects)
4508            .write(ObjectKind::Blob, b"alternate")
4509            .unwrap();
4510        let primary_objects = primary.path().join("objects");
4511        std::fs::create_dir_all(&primary_objects).unwrap();
4512        let odb = Odb::new(&primary_objects).with_env_alternate_dirs(vec![alt_objects]);
4513        let present = odb.write(ObjectKind::Blob, b"present").unwrap();
4514        let absent = HashAlgo::Sha1
4515            .hash_object(ObjectKind::Blob, b"absent")
4516            .unwrap();
4517
4518        assert!(odb.contains(&present).unwrap());
4519        assert!(odb.contains(&odb.empty_tree_oid().unwrap()).unwrap());
4520        assert!(odb.contains(&alt_oid).unwrap());
4521        assert!(!odb.contains(&absent).unwrap());
4522        let empty_tree = odb.empty_tree_oid().unwrap();
4523        assert_eq!(
4524            odb.contains_many(&[present, absent, alt_oid, empty_tree])
4525                .unwrap(),
4526            vec![true, false, true, true]
4527        );
4528        assert!(odb.contains_many(&[]).unwrap().is_empty());
4529        assert_eq!(
4530            odb.present_oids([present, absent, alt_oid]).unwrap(),
4531            vec![present, alt_oid]
4532        );
4533        assert_eq!(
4534            odb.missing_oids([present, absent, alt_oid]).unwrap(),
4535            vec![absent]
4536        );
4537        assert_eq!(
4538            odb.missing_stored_oids([present, absent, alt_oid]).unwrap(),
4539            vec![absent, alt_oid],
4540            "stored ignores alternates"
4541        );
4542    }
4543
4544    #[test]
4545    fn oid_filters_propagate_backend_failure() {
4546        let dir = TempDir::new().unwrap();
4547        let (odb, flaky) = flaky_odb(dir.path());
4548        let oid = odb.write(ObjectKind::Blob, b"x").unwrap();
4549        assert_eq!(odb.contains_many(&[oid]).unwrap(), vec![true]);
4550        flaky.fail_contains(true);
4551        assert!(store::flaky::is_injected(
4552            &odb.contains_many(&[oid]).unwrap_err()
4553        ));
4554        assert!(store::flaky::is_injected(
4555            &odb.present_oids([oid]).unwrap_err()
4556        ));
4557        assert!(store::flaky::is_injected(
4558            &odb.missing_oids([oid]).unwrap_err()
4559        ));
4560        assert!(store::flaky::is_injected(
4561            &odb.missing_stored_oids([oid]).unwrap_err()
4562        ));
4563    }
4564
4565    #[test]
4566    fn write_existing_loose_uses_path_stat_not_exists() {
4567        let dir = TempDir::new().unwrap();
4568        let odb = Odb::new(dir.path());
4569        let oid = odb.write(ObjectKind::Blob, b"once").unwrap();
4570        let path = odb.object_path(&oid);
4571        assert!(path.is_file());
4572        odb.reset_exists_probe();
4573        let oid2 = odb.write(ObjectKind::Blob, b"once").unwrap();
4574        assert_eq!(oid, oid2);
4575        assert_eq!(
4576            odb.exists_probe_count(),
4577            0,
4578            "loose fast path must not call exists()"
4579        );
4580    }
4581
4582    #[cfg_attr(
4583        not(feature = "full-tests"),
4584        ignore = "starts system git or is heavy; run with --features full-tests"
4585    )]
4586    #[test]
4587    fn exists_and_write_after_invalidate_packs_use_odb_store() {
4588        let dir = TempDir::new().unwrap();
4589        git_in(dir.path(), &["init", "-q"]);
4590        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
4591        git_in(dir.path(), &["config", "user.name", "T"]);
4592        let payload = b"invalidate-pack-exists";
4593        fs::write(dir.path().join("f"), payload).unwrap();
4594        git_in(dir.path(), &["add", "f"]);
4595        git_in(dir.path(), &["commit", "-m", "c"]);
4596        git_in(dir.path(), &["repack", "-ad"]);
4597        let objects = dir.path().join(".git").join("objects");
4598        let odb = Odb::new(&objects);
4599        let oid = odb.hash(ObjectKind::Blob, payload).expect("hash");
4600        assert!(odb.exists(&oid), "blob must live in pack after repack");
4601        let pack_dir = objects.join("pack");
4602        for entry in fs::read_dir(&pack_dir).unwrap().flatten() {
4603            let path = entry.path();
4604            if path.extension().is_some_and(|e| e == "pack" || e == "idx") {
4605                fs::remove_file(path).unwrap();
4606            }
4607        }
4608        odb.invalidate_packs();
4609        assert!(
4610            !odb.exists(&oid),
4611            "exists must not read stale legacy pack listing after invalidate_packs"
4612        );
4613        let oid2 = odb.write(ObjectKind::Blob, payload).unwrap();
4614        assert_eq!(oid, oid2);
4615        assert!(
4616            odb.object_path(&oid).is_file(),
4617            "write must materialize loose object when pack copy is gone"
4618        );
4619        odb.read(&oid).expect("read after write");
4620    }
4621
4622    #[cfg_attr(
4623        not(feature = "full-tests"),
4624        ignore = "starts system git or is heavy; run with --features full-tests"
4625    )]
4626    #[test]
4627    fn write_uses_single_exists_probe_when_not_loose() {
4628        let dir = TempDir::new().unwrap();
4629        git_in(dir.path(), &["init", "-q"]);
4630        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
4631        git_in(dir.path(), &["config", "user.name", "T"]);
4632        fs::write(dir.path().join("f"), b"probe-once").unwrap();
4633        git_in(dir.path(), &["add", "f"]);
4634        git_in(dir.path(), &["commit", "-m", "c"]);
4635        git_in(dir.path(), &["repack", "-ad"]);
4636        let objects = dir.path().join(".git").join("objects");
4637        let odb = Odb::new(&objects);
4638        let oid = odb.write(ObjectKind::Blob, b"probe-once").unwrap();
4639        assert!(!odb.object_path(&oid).exists());
4640        odb.reset_exists_probe();
4641        let oid2 = odb.write(ObjectKind::Blob, b"probe-once").unwrap();
4642        assert_eq!(oid, oid2);
4643        assert_eq!(
4644            odb.exists_probe_count(),
4645            1,
4646            "pack/alternate write path must call exists() exactly once"
4647        );
4648    }
4649    #[test]
4650    fn write_existing_object_freshens() {
4651        use filetime::FileTime;
4652        use std::fs;
4653        use std::thread;
4654        use std::time::Duration;
4655
4656        let dir = TempDir::new().unwrap();
4657        let odb = Odb::new(dir.path());
4658        let oid = odb.write(ObjectKind::Blob, b"hello").unwrap();
4659        let path = odb.object_path(&oid);
4660        thread::sleep(Duration::from_millis(50));
4661        let before = FileTime::from_last_modification_time(&fs::metadata(&path).unwrap());
4662        filetime::set_file_mtime(&path, before).unwrap();
4663
4664        let _ = odb.write(ObjectKind::Blob, b"hello").unwrap();
4665        let after = FileTime::from_last_modification_time(&fs::metadata(&path).unwrap());
4666        assert!(
4667            after > before,
4668            "rewriting an existing loose object must freshen its mtime"
4669        );
4670    }
4671
4672    #[test]
4673    fn freshen_pack_once_per_odb() {
4674        use filetime::FileTime;
4675        use flate2::write::ZlibEncoder;
4676        use flate2::Compression;
4677        use std::fs;
4678        use std::io::Write;
4679        use std::thread;
4680        use std::time::Duration;
4681
4682        fn append_pack_object(buf: &mut Vec<u8>, type_code: u8, data: &[u8]) {
4683            let mut size = data.len();
4684            let first = ((type_code & 0x7) << 4) | (size & 0x0f) as u8;
4685            size >>= 4;
4686            if size > 0 {
4687                buf.push(first | 0x80);
4688                while size > 0 {
4689                    let b = (size & 0x7f) as u8;
4690                    size >>= 7;
4691                    buf.push(if size > 0 { b | 0x80 } else { b });
4692                }
4693            } else {
4694                buf.push(first);
4695            }
4696            let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
4697            enc.write_all(data).unwrap();
4698            buf.extend_from_slice(&enc.finish().unwrap());
4699        }
4700
4701        fn write_v2_idx(
4702            idx_path: &Path,
4703            pack_path: &Path,
4704            entries: &[(ObjectId, u64)],
4705        ) -> Result<()> {
4706            let mut sorted = entries.to_vec();
4707            sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
4708            let n = sorted.len();
4709            let mut fanout = [0u32; 256];
4710            for byte in 0u32..256 {
4711                let count = sorted
4712                    .iter()
4713                    .filter(|(oid, _)| u32::from(oid.as_bytes()[0]) <= byte)
4714                    .count();
4715                fanout[byte as usize] = u32::try_from(count).unwrap_or(u32::MAX);
4716            }
4717            let mut buf = Vec::new();
4718            buf.extend_from_slice(b"\xfftOc");
4719            buf.extend_from_slice(&2u32.to_be_bytes());
4720            for f in fanout {
4721                buf.extend_from_slice(&f.to_be_bytes());
4722            }
4723            for (oid, _) in &sorted {
4724                buf.extend_from_slice(oid.as_bytes());
4725            }
4726            for _ in 0..n {
4727                buf.extend_from_slice(&0u32.to_be_bytes());
4728            }
4729            for (_, off) in &sorted {
4730                buf.extend_from_slice(&u32::try_from(*off).unwrap_or(0x8000_0000).to_be_bytes());
4731            }
4732            let pack_bytes = fs::read(pack_path)?;
4733            buf.extend_from_slice(&pack_bytes[pack_bytes.len() - 20..]);
4734            buf.extend_from_slice(&HashAlgo::Sha1.checksum_digest(&buf));
4735            fs::write(idx_path, buf)?;
4736            Ok(())
4737        }
4738
4739        let dir = TempDir::new().unwrap();
4740        let objects_dir = dir.path();
4741        let odb = Odb::new(objects_dir);
4742
4743        let oid_a = odb.write(ObjectKind::Blob, b"alpha").unwrap();
4744        let oid_b = odb.write(ObjectKind::Blob, b"beta").unwrap();
4745        fs::remove_file(odb.object_path(&oid_a)).unwrap();
4746        fs::remove_file(odb.object_path(&oid_b)).unwrap();
4747
4748        let mut pack = Vec::new();
4749        pack.extend_from_slice(b"PACK");
4750        pack.extend_from_slice(&2u32.to_be_bytes());
4751        pack.extend_from_slice(&2u32.to_be_bytes());
4752        let off_a = pack.len() as u64;
4753        append_pack_object(&mut pack, 3, b"alpha");
4754        let off_b = pack.len() as u64;
4755        append_pack_object(&mut pack, 3, b"beta");
4756        pack.extend_from_slice(&HashAlgo::Sha1.checksum_digest(&pack));
4757
4758        let pack_dir = objects_dir.join("pack");
4759        fs::create_dir_all(&pack_dir).unwrap();
4760        let pack_path = pack_dir.join("test-177.pack");
4761        let idx_path = pack_dir.join("test-177.idx");
4762        fs::write(&pack_path, &pack).unwrap();
4763        write_v2_idx(&idx_path, &pack_path, &[(oid_a, off_a), (oid_b, off_b)]).unwrap();
4764        odb.invalidate_packs();
4765
4766        thread::sleep(Duration::from_millis(50));
4767        let before = FileTime::from_last_modification_time(&fs::metadata(&pack_path).unwrap());
4768
4769        assert!(odb.freshen_object(&oid_a));
4770        let after_first = FileTime::from_last_modification_time(&fs::metadata(&pack_path).unwrap());
4771        assert!(after_first > before);
4772
4773        assert!(odb.freshen_object(&oid_b));
4774        let after_second =
4775            FileTime::from_last_modification_time(&fs::metadata(&pack_path).unwrap());
4776        assert_eq!(
4777            after_second, after_first,
4778            "second freshen of another object in the same pack must not touch the pack again"
4779        );
4780    }
4781
4782    #[test]
4783    fn write_existing_packed_object_freshens_pack() {
4784        use filetime::FileTime;
4785        use flate2::write::ZlibEncoder;
4786        use flate2::Compression;
4787        use std::fs;
4788        use std::io::Write;
4789        use std::thread;
4790        use std::time::Duration;
4791
4792        let dir = TempDir::new().unwrap();
4793        let objects_dir = dir.path();
4794        let odb = Odb::new(objects_dir);
4795        let oid = odb.write(ObjectKind::Blob, b"packed-only").unwrap();
4796        fs::remove_file(odb.object_path(&oid)).unwrap();
4797
4798        let mut pack = Vec::new();
4799        pack.extend_from_slice(b"PACK");
4800        pack.extend_from_slice(&2u32.to_be_bytes());
4801        pack.extend_from_slice(&1u32.to_be_bytes());
4802        let off = pack.len() as u64;
4803        let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
4804        enc.write_all(b"packed-only").unwrap();
4805        let compressed = enc.finish().unwrap();
4806        pack.push(0x30 | 0x03); // blob len 11
4807        pack.extend_from_slice(&compressed);
4808        pack.extend_from_slice(&HashAlgo::Sha1.checksum_digest(&pack));
4809
4810        let pack_dir = objects_dir.join("pack");
4811        fs::create_dir_all(&pack_dir).unwrap();
4812        let pack_path = pack_dir.join("single-177.pack");
4813        fs::write(&pack_path, &pack).unwrap();
4814        // Minimal idx: reuse logic from freshen_pack_once_per_odb via inline write
4815        let idx_path = pack_dir.join("single-177.idx");
4816        let mut sorted = [(oid, off)];
4817        sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
4818        let mut fanout = [0u32; 256];
4819        for byte in 0u32..256 {
4820            fanout[byte as usize] = if u32::from(oid.as_bytes()[0]) <= byte {
4821                1
4822            } else {
4823                0
4824            };
4825        }
4826        let mut buf = Vec::new();
4827        buf.extend_from_slice(b"\xfftOc");
4828        buf.extend_from_slice(&2u32.to_be_bytes());
4829        for f in fanout {
4830            buf.extend_from_slice(&f.to_be_bytes());
4831        }
4832        buf.extend_from_slice(oid.as_bytes());
4833        buf.extend_from_slice(&0u32.to_be_bytes());
4834        buf.extend_from_slice(&(off as u32).to_be_bytes());
4835        buf.extend_from_slice(&pack[pack.len() - 20..]);
4836        buf.extend_from_slice(&HashAlgo::Sha1.checksum_digest(&buf));
4837        fs::write(&idx_path, buf).unwrap();
4838        odb.invalidate_packs();
4839
4840        thread::sleep(Duration::from_millis(50));
4841        let before = FileTime::from_last_modification_time(&fs::metadata(&pack_path).unwrap());
4842
4843        odb.write(ObjectKind::Blob, b"packed-only").unwrap();
4844
4845        let after = FileTime::from_last_modification_time(&fs::metadata(&pack_path).unwrap());
4846        assert!(
4847            after > before,
4848            "write of an existing packed object must freshen the pack mtime"
4849        );
4850    }
4851
4852    /// When a local pack is indexed but its `.pack` path cannot be utime'd, [`Odb::write`]
4853    /// materializes a loose copy (Git `write_object_file` fallback). Replacing the pack file
4854    /// with a directory simulates a failed freshen without requiring a root-owned pack.
4855    #[cfg_attr(
4856        not(feature = "full-tests"),
4857        ignore = "starts system git or is heavy; run with --features full-tests"
4858    )]
4859    #[test]
4860    fn write_materializes_loose_when_pack_freshen_fails() {
4861        let dir = TempDir::new().unwrap();
4862        git_in(dir.path(), &["init", "-q"]);
4863        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
4864        git_in(dir.path(), &["config", "user.name", "T"]);
4865        let payload = b"packed-fallback";
4866        fs::write(dir.path().join("f"), payload).unwrap();
4867        git_in(dir.path(), &["add", "f"]);
4868        git_in(dir.path(), &["commit", "-m", "c"]);
4869        git_in(dir.path(), &["repack", "-ad"]);
4870        let objects = dir.path().join(".git").join("objects");
4871        let odb = Odb::new(&objects);
4872        let oid = odb.write(ObjectKind::Blob, payload).unwrap();
4873        assert!(!odb.object_path(&oid).exists());
4874        assert!(odb.exists(&oid));
4875
4876        let pack_path = fs::read_dir(objects.join("pack"))
4877            .unwrap()
4878            .flatten()
4879            .find(|e| e.path().extension().is_some_and(|x| x == "pack"))
4880            .unwrap()
4881            .path();
4882        fs::remove_file(&pack_path).unwrap();
4883        fs::create_dir(&pack_path).unwrap();
4884        odb.invalidate_packs();
4885        assert!(!odb.freshen_object(&oid));
4886
4887        odb.write(ObjectKind::Blob, payload).unwrap();
4888        assert!(odb.object_path(&oid).exists());
4889    }
4890    #[test]
4891    fn write_with_options_silent_skips_freshen_on_existing_loose_object() {
4892        let dir = TempDir::new().unwrap();
4893        let odb = Odb::new(dir.path());
4894        let oid = odb.write(ObjectKind::Blob, b"stable").unwrap();
4895        let path = odb.object_path(&oid);
4896        let before = std::fs::metadata(&path).unwrap().modified().unwrap();
4897        std::thread::sleep(std::time::Duration::from_millis(20));
4898        let again = odb
4899            .write_with_options(
4900                ObjectKind::Blob,
4901                b"stable",
4902                WriteOptions {
4903                    silent: true,
4904                    ..WriteOptions::default()
4905                },
4906            )
4907            .unwrap();
4908        assert_eq!(oid, again);
4909        let after = std::fs::metadata(&path).unwrap().modified().unwrap();
4910        assert_eq!(before, after);
4911
4912        let touched = odb
4913            .write_with_options(
4914                ObjectKind::Blob,
4915                b"stable",
4916                WriteOptions {
4917                    silent: false,
4918                    ..WriteOptions::default()
4919                },
4920            )
4921            .unwrap();
4922        assert_eq!(oid, touched);
4923        let freshened = std::fs::metadata(&path).unwrap().modified().unwrap();
4924        assert!(freshened >= before);
4925    }
4926
4927    #[cfg_attr(
4928        not(feature = "full-tests"),
4929        ignore = "starts system git or is heavy; run with --features full-tests"
4930    )]
4931    #[test]
4932    fn promisor_marker_checked_once_per_prepare() {
4933        use crate::pack::{
4934            clear_pack_cache, pack_cache_test_guard, test_pack_marker_stat_count,
4935            test_reset_pack_marker_stat_count,
4936        };
4937
4938        let _guard = pack_cache_test_guard();
4939        let dir = TempDir::new().unwrap();
4940        git_in(dir.path(), &["init", "-q", "-b", "main"]);
4941        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
4942        git_in(dir.path(), &["config", "user.name", "T"]);
4943        for i in 0..64 {
4944            fs::write(dir.path().join(format!("f{i}.txt")), format!("body {i}")).unwrap();
4945            git_in(dir.path(), &["add", "."]);
4946            git_in(dir.path(), &["commit", "-m", &format!("c{i}")]);
4947        }
4948        git_in(dir.path(), &["repack", "-a", "-d"]);
4949        let objects = dir.path().join(".git/objects");
4950        let oid = git_rev_parse_oid(dir.path(), "HEAD");
4951
4952        clear_pack_cache();
4953        test_reset_pack_marker_stat_count();
4954        let pack_count = fs::read_dir(objects.join("pack"))
4955            .unwrap()
4956            .filter_map(|e| e.ok())
4957            .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))
4958            .count();
4959        let expected_prepare_stats = u64::try_from(pack_count * 2).unwrap();
4960        let odb = Odb::new(&objects);
4961        let before_prepare = test_pack_marker_stat_count();
4962        assert!(odb.exists_local(&oid));
4963        assert_eq!(
4964            test_pack_marker_stat_count().saturating_sub(before_prepare),
4965            expected_prepare_stats,
4966            "prepare should stat promisor and mtimes once per pack"
4967        );
4968
4969        test_reset_pack_marker_stat_count();
4970        for _ in 0..10_000 {
4971            let before_probe = test_pack_marker_stat_count();
4972            assert!(odb.exists_local(&oid));
4973            assert_eq!(
4974                test_pack_marker_stat_count(),
4975                before_probe,
4976                "cached sidecar flags must not stat markers on each exists_local probe"
4977            );
4978        }
4979    }
4980
4981    #[cfg_attr(
4982        not(feature = "full-tests"),
4983        ignore = "starts system git or is heavy; run with --features full-tests"
4984    )]
4985    #[test]
4986    fn promisor_marker_added_after_prepare_is_seen_after_reprepare() {
4987        use crate::pack::{
4988            clear_pack_cache, pack_cache_test_guard, reprepare_pack_directory_on_miss,
4989        };
4990
4991        let _guard = pack_cache_test_guard();
4992        let dir = TempDir::new().unwrap();
4993        git_in(dir.path(), &["init", "-q", "-b", "main"]);
4994        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
4995        git_in(dir.path(), &["config", "user.name", "T"]);
4996        fs::write(dir.path().join("one.txt"), b"x").unwrap();
4997        git_in(dir.path(), &["add", "one.txt"]);
4998        git_in(dir.path(), &["commit", "-m", "one"]);
4999        git_in(dir.path(), &["repack", "-a", "-d"]);
5000        let objects = dir.path().join(".git/objects");
5001        let oid = git_rev_parse_oid(dir.path(), "HEAD");
5002
5003        clear_pack_cache();
5004        let odb = Odb::new(&objects);
5005        assert!(odb.exists_local(&oid));
5006
5007        let pack_path = fs::read_dir(objects.join("pack"))
5008            .unwrap()
5009            .filter_map(|e| e.ok())
5010            .map(|e| e.path())
5011            .find(|p| p.extension().is_some_and(|x| x == "pack"))
5012            .unwrap();
5013        fs::write(pack_path.with_extension("promisor"), b"").unwrap();
5014        filetime::set_file_mtime(objects.join("pack"), filetime::FileTime::now()).unwrap();
5015
5016        assert!(
5017            odb.with_pack_store_for(&objects, || {
5018                reprepare_pack_directory_on_miss(&objects).unwrap()
5019            }),
5020            "pack directory change must require reprepare"
5021        );
5022        assert!(
5023            !odb.exists_local(&oid),
5024            "promisor marker must hide pack objects after reprepare refreshed sidecar flags"
5025        );
5026    }
5027
5028    #[cfg_attr(
5029        not(feature = "full-tests"),
5030        ignore = "starts system git or is heavy; run with --features full-tests"
5031    )]
5032    #[test]
5033    fn git_promisor_marker_pack_not_materialized_for_exists_local() {
5034        use crate::pack::clear_pack_cache;
5035
5036        let dir = TempDir::new().unwrap();
5037        git_in(dir.path(), &["init", "-q", "-b", "main"]);
5038        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
5039        git_in(dir.path(), &["config", "user.name", "T"]);
5040        fs::write(dir.path().join("blob.txt"), b"git promisor compat").unwrap();
5041        git_in(dir.path(), &["add", "blob.txt"]);
5042        git_in(dir.path(), &["commit", "-m", "seed"]);
5043        git_in(dir.path(), &["repack", "-a", "-d"]);
5044        let objects = dir.path().join(".git/objects");
5045        let oid = git_rev_parse_oid(dir.path(), "HEAD");
5046        let pack_path = fs::read_dir(objects.join("pack"))
5047            .unwrap()
5048            .filter_map(|e| e.ok())
5049            .map(|e| e.path())
5050            .find(|p| p.extension().is_some_and(|x| x == "pack"))
5051            .unwrap();
5052        fs::write(pack_path.with_extension("promisor"), b"").unwrap();
5053
5054        clear_pack_cache();
5055        let odb = Odb::new(&objects);
5056        assert!(
5057            !odb.exists_local(&oid),
5058            "git-written pack with .promisor must not count as local materialization"
5059        );
5060
5061        let cat = git_run(dir.path(), &["cat-file", "-e", &oid.to_hex()]);
5062        assert!(
5063            cat.status.success(),
5064            "git still resolves the object in a promisor-marked pack"
5065        );
5066    }
5067
5068    #[cfg_attr(
5069        not(feature = "full-tests"),
5070        ignore = "starts system git or is heavy; run with --features full-tests"
5071    )]
5072    #[test]
5073    fn packed_object_read_avoids_loose_path_open() {
5074        use crate::hot_path_test_metrics::HotPathMetricsScope;
5075        use crate::pack::clear_pack_cache;
5076
5077        let dir = TempDir::new().unwrap();
5078        git_in(dir.path(), &["init", "-q", "-b", "main"]);
5079        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
5080        git_in(dir.path(), &["config", "user.name", "T"]);
5081        fs::write(dir.path().join("packed.txt"), b"packed-only payload").unwrap();
5082        git_in(dir.path(), &["add", "packed.txt"]);
5083        git_in(dir.path(), &["commit", "-m", "pack"]);
5084        git_in(dir.path(), &["repack", "-a", "-d"]);
5085        let objects = dir.path().join(".git/objects");
5086        let oid = git_rev_parse_oid(dir.path(), "HEAD");
5087
5088        clear_pack_cache();
5089        let odb = Odb::new(&objects);
5090        odb.hot_path_metrics().set_loose_open_counting(true);
5091        odb.hot_path_metrics().reset_loose_path_open_attempts();
5092        let _scope = HotPathMetricsScope::install(odb.hot_path_metrics_arc());
5093
5094        let obj = odb.read(&oid).expect("packed read");
5095        assert_eq!(obj.kind, ObjectKind::Commit);
5096        assert_eq!(
5097            odb.hot_path_metrics().loose_path_open_attempts(),
5098            0,
5099            "packed objects must not attempt a loose-path open"
5100        );
5101    }
5102
5103    #[test]
5104    fn loose_only_read_records_loose_path_open() {
5105        use crate::hot_path_test_metrics::HotPathMetricsScope;
5106
5107        let dir = TempDir::new().unwrap();
5108        let objects = dir.path().join("objects");
5109        fs::create_dir_all(&objects).unwrap();
5110        let odb = Odb::new(&objects);
5111        let oid = odb
5112            .write(ObjectKind::Blob, b"loose-only")
5113            .expect("write loose");
5114        odb.hot_path_metrics().set_loose_open_counting(true);
5115        odb.hot_path_metrics().reset_loose_path_open_attempts();
5116        let _scope = HotPathMetricsScope::install(odb.hot_path_metrics_arc());
5117
5118        odb.read(&oid).expect("loose read");
5119        assert_eq!(
5120            odb.hot_path_metrics().loose_path_open_attempts(),
5121            1,
5122            "loose fallback must record a path open attempt"
5123        );
5124    }
5125
5126    #[cfg_attr(
5127        not(feature = "full-tests"),
5128        ignore = "starts system git or is heavy; run with --features full-tests"
5129    )]
5130    #[test]
5131    fn loose_and_packed_copies_return_identical_bytes() {
5132        use crate::pack::clear_pack_cache;
5133
5134        let dir = TempDir::new().unwrap();
5135        git_in(dir.path(), &["init", "-q", "-b", "main"]);
5136        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
5137        git_in(dir.path(), &["config", "user.name", "T"]);
5138        fs::write(dir.path().join("dup.txt"), b"duplicate loose and packed\n").unwrap();
5139        git_in(dir.path(), &["add", "dup.txt"]);
5140        git_in(dir.path(), &["commit", "-m", "seed"]);
5141        let objects = dir.path().join(".git/objects");
5142        let tree_oid = git_rev_parse_oid(dir.path(), "HEAD^{tree}");
5143        git_in(dir.path(), &["repack", "-a"]);
5144        clear_pack_cache();
5145        let odb = Odb::new(&objects);
5146        let loose_path = tree_oid.loose_path_in(&objects);
5147        assert!(
5148            loose_path.is_file(),
5149            "loose copy must remain after repack -a"
5150        );
5151        let loose = Odb::read_loose_verify_oid(&loose_path, &tree_oid).expect("loose tree");
5152        let packed = odb.read(&tree_oid).expect("packed tree");
5153        assert_eq!(packed.data, loose.data);
5154        assert_eq!(packed.kind, loose.kind);
5155    }
5156
5157    #[cfg_attr(
5158        not(feature = "full-tests"),
5159        ignore = "starts system git or is heavy; run with --features full-tests"
5160    )]
5161    #[test]
5162    fn freshly_written_loose_object_visible_before_repack() {
5163        let dir = TempDir::new().unwrap();
5164        git_in(dir.path(), &["init", "-q", "-b", "main"]);
5165        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
5166        git_in(dir.path(), &["config", "user.name", "T"]);
5167        fs::write(dir.path().join("seed.txt"), b"seed").unwrap();
5168        git_in(dir.path(), &["add", "seed.txt"]);
5169        git_in(dir.path(), &["commit", "-m", "seed"]);
5170        git_in(dir.path(), &["repack", "-a", "-d"]);
5171        let objects = dir.path().join(".git/objects");
5172        let odb = Odb::new(&objects);
5173        let fresh = b"brand-new loose blob\n";
5174        let oid = odb.write(ObjectKind::Blob, fresh).expect("loose write");
5175        let obj = odb.read(&oid).expect("immediate loose read");
5176        assert_eq!(obj.data.as_slice(), fresh);
5177    }
5178
5179    #[cfg_attr(
5180        not(feature = "full-tests"),
5181        ignore = "starts system git or is heavy; run with --features full-tests"
5182    )]
5183    #[test]
5184    fn pack_added_after_first_read_found_via_odb_reprepare() {
5185        use crate::pack::{clear_pack_cache, pack_cache_test_guard};
5186
5187        let _guard = pack_cache_test_guard();
5188        clear_pack_cache();
5189        let dir = TempDir::new().unwrap();
5190        git_in(dir.path(), &["init", "-q", "-b", "main"]);
5191        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
5192        git_in(dir.path(), &["config", "user.name", "T"]);
5193        fs::write(dir.path().join("a.txt"), b"pack-a").unwrap();
5194        git_in(dir.path(), &["add", "a.txt"]);
5195        git_in(dir.path(), &["commit", "-m", "a"]);
5196        git_in(dir.path(), &["repack", "-a", "-d"]);
5197        let objects = dir.path().join(".git/objects");
5198        let pack_dir = objects.join("pack");
5199        let git_dir = dir.path().join(".git");
5200
5201        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
5202        let warm_oid = git_rev_parse_oid(dir.path(), "HEAD");
5203        odb.read(&warm_oid).expect("warm read");
5204
5205        fs::write(dir.path().join("b.txt"), b"pack-b").unwrap();
5206        git_in(dir.path(), &["add", "b.txt"]);
5207        git_in(dir.path(), &["commit", "-m", "b"]);
5208        let new_oid = git_rev_parse_oid(dir.path(), "HEAD");
5209
5210        std::thread::sleep(std::time::Duration::from_secs(1));
5211        filetime::set_file_mtime(&pack_dir, filetime::FileTime::now()).unwrap();
5212
5213        let obj = odb.read(&new_oid).expect("new pack via reprepare");
5214        assert_eq!(obj.kind, ObjectKind::Commit);
5215    }
5216
5217    #[cfg_attr(
5218        not(feature = "full-tests"),
5219        ignore = "starts system git or is heavy; run with --features full-tests"
5220    )]
5221    #[test]
5222    fn late_midx_after_warm_read_resolves_new_commit() {
5223        use crate::pack::{clear_pack_cache, pack_cache_test_guard};
5224
5225        let _guard = pack_cache_test_guard();
5226        clear_pack_cache();
5227        let dir = TempDir::new().unwrap();
5228        git_in(dir.path(), &["init", "-q", "-b", "main"]);
5229        git_in(dir.path(), &["config", "user.email", "t@example.com"]);
5230        git_in(dir.path(), &["config", "user.name", "T"]);
5231        fs::write(dir.path().join("a.txt"), b"pack-a").unwrap();
5232        git_in(dir.path(), &["add", "a.txt"]);
5233        git_in(dir.path(), &["commit", "-m", "a"]);
5234        git_in(dir.path(), &["repack", "-a", "-d"]);
5235        let objects = dir.path().join(".git/objects");
5236        let git_dir = dir.path().join(".git");
5237        assert!(
5238            crate::midx::cached_tip_midx_path(&objects.join("pack")).is_none(),
5239            "fixture starts without a MIDX"
5240        );
5241
5242        let odb = Odb::new(&objects).with_config_git_dir(git_dir);
5243        let warm_oid = git_rev_parse_oid(dir.path(), "HEAD");
5244        odb.read(&warm_oid).expect("warm read before MIDX exists");
5245
5246        fs::write(dir.path().join("b.txt"), b"pack-b").unwrap();
5247        git_in(dir.path(), &["add", "b.txt"]);
5248        git_in(dir.path(), &["commit", "-m", "b"]);
5249        git_in(dir.path(), &["repack", "-a", "-d"]);
5250        git_in(dir.path(), &["multi-pack-index", "write"]);
5251        let new_oid = git_rev_parse_oid(dir.path(), "HEAD");
5252
5253        let cat = git_run(dir.path(), &["cat-file", "-e", &new_oid.to_hex()]);
5254        assert!(
5255            cat.status.success(),
5256            "system git must resolve the new commit"
5257        );
5258
5259        let obj = odb
5260            .read(&new_oid)
5261            .expect("MIDX-covered commit after late MIDX write");
5262        assert_eq!(obj.kind, ObjectKind::Commit);
5263        assert!(
5264            crate::midx::cached_tip_midx_path(&objects.join("pack")).is_some(),
5265            "successful read after late MIDX must refresh the tip cache"
5266        );
5267    }
5268}