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