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