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