grit_lib/odb/builder.rs
1//! Build an [`Odb`] with a custom primary store and optional read overlays.
2//!
3//! # Example
4//!
5//! ```
6//! use std::sync::Arc;
7//!
8//! use grit_lib::objects::{HashAlgo, ObjectKind};
9//! use grit_lib::objects::odb::store::{MemoryStore, WritableObjectStore};
10//! use grit_lib::objects::odb::{Odb, OdbBuilder, WriteOptions};
11//!
12//! let store = Arc::new(MemoryStore::new(HashAlgo::Sha1));
13//! let odb = OdbBuilder::files("/tmp/repo/.git/objects")
14//! .primary(store.clone())
15//! .alternates(false)
16//! .build();
17//! let oid = WritableObjectStore::write(odb.primary_writable().unwrap().as_ref(), ObjectKind::Blob, b"hi", WriteOptions::default()).unwrap();
18//! assert_eq!(oid.to_hex().len(), 40);
19//! ```
20
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24use crate::commit_graph_file::CommitGraphChain;
25use crate::commit_graph_write::{
26 build_commit_graph_bytes, collect_reachable_commit_oids, load_commit_graph_commit_info,
27};
28use crate::config::ConfigSet;
29use crate::error::{Error, Result};
30use crate::fsck::reachability_roots;
31use crate::gc::prune_loose_unreachable;
32use crate::objects::{oid_hash_map, HashAlgo, ObjectId};
33use crate::odb::store::{ObjectStore, WritableObjectStore};
34use crate::repo::Repository;
35
36use super::Odb;
37
38/// Configure and construct an [`Odb`] with a pluggable primary backend.
39#[derive(Debug, Clone)]
40pub struct OdbBuilder {
41 objects_dir: PathBuf,
42 custom_primary: Option<Arc<dyn WritableObjectStore>>,
43 read_sources: Vec<Arc<dyn ObjectStore>>,
44 alternates: bool,
45}
46
47impl OdbBuilder {
48 /// Default files-backed layout rooted at `objects_dir` (the repository's `objects/` tree).
49 ///
50 /// When passed to [`RepositoryBuilder::odb_builder`](crate::repo::RepositoryBuilder::odb_builder),
51 /// any spelling of the repository's `objects/` directory is accepted: a path through
52 /// symlinks, or a relative path (resolved against the environment's current directory).
53 /// With a custom [`Self::primary`] the directory need not exist, but it must still name
54 /// the repository's `objects/` directory.
55 #[must_use]
56 pub fn files(objects_dir: impl AsRef<Path>) -> Self {
57 Self {
58 objects_dir: objects_dir.as_ref().to_path_buf(),
59 custom_primary: None,
60 read_sources: Vec::new(),
61 alternates: true,
62 }
63 }
64
65 /// Replace the default [`super::store::FilesSource`] primary with a custom store.
66 ///
67 /// Writes and primary reads go through this backend, including commits, staging
68 /// ([`crate::porcelain::add::stage`]), merges, stash, notes, and bundle creation.
69 /// [`Repository::fsck`](crate::repo::Repository::fsck) validates the objects this
70 /// store lists. Received packs are written into it object by object:
71 /// [`ingest_received_pack`](crate::index_pack::ingest_received_pack) and
72 /// [`Bundle::unbundle`](crate::bundle::Bundle::unbundle) resolve every delta and call
73 /// [`WritableObjectStore::write`] instead of installing a pack under `objects/pack/`.
74 ///
75 /// Operations that still need the on-disk `objects/` directory refuse up front with
76 /// [`Error::UnsupportedObjectStore`] (its `operation` names the refused call) and leave
77 /// refs and `objects/` untouched:
78 ///
79 /// - `"fetch"`: [`Remote::fetch`](crate::remote::Remote::fetch) and everything built on it
80 /// ([`RepositoryRemotes::fetch`](crate::repo_handles::RepositoryRemotes::fetch),
81 /// `fetch_all`, pull, promisor lazy fetch), for every transport including bundles
82 /// - `"push"`: [`Remote::push`](crate::remote::Remote::push) and
83 /// [`RepositoryRemotes::push`](crate::repo_handles::RepositoryRemotes::push)
84 /// - maintenance: [`Odb::gc`], [`Odb::write_commit_graph`], repack, MIDX, and pack helpers
85 ///
86 /// The store's [`ObjectStore::hash_algo`] must match the repository's object format:
87 /// [`RepositoryBuilder::open`](crate::repo::RepositoryBuilder::open) fails with
88 /// [`Error::ObjectFormatMismatch`] otherwise. [`Self::build`] does not check, since it
89 /// has no repository config to compare against.
90 #[must_use]
91 pub fn primary(mut self, store: Arc<dyn WritableObjectStore>) -> Self {
92 self.custom_primary = Some(store);
93 self
94 }
95
96 /// Append a read-only store consulted after the primary and before alternates.
97 ///
98 /// Like [`Self::primary`], its hash algorithm must match the repository's object format
99 /// or [`RepositoryBuilder::open`](crate::repo::RepositoryBuilder::open) fails with
100 /// [`Error::ObjectFormatMismatch`].
101 #[must_use]
102 pub fn push_read_source(mut self, store: Arc<dyn ObjectStore>) -> Self {
103 self.read_sources.push(store);
104 self
105 }
106
107 /// When `false`, skip `info/alternates`, environment alternates, and submodule object dirs.
108 #[must_use]
109 pub fn alternates(mut self, enabled: bool) -> Self {
110 self.alternates = enabled;
111 self
112 }
113
114 /// `objects/` directory this builder targets.
115 #[must_use]
116 pub fn objects_dir(&self) -> &Path {
117 &self.objects_dir
118 }
119
120 pub(crate) fn set_objects_dir(&mut self, objects_dir: PathBuf) {
121 self.objects_dir = objects_dir;
122 }
123
124 /// Whether [`Self::primary`] replaced the files-backed primary store.
125 pub(crate) fn has_custom_primary(&self) -> bool {
126 self.custom_primary.is_some()
127 }
128
129 /// Hash algorithms of the injected stores: the custom primary, then each read source.
130 pub(crate) fn injected_hash_algos(&self) -> impl Iterator<Item = HashAlgo> + '_ {
131 self.custom_primary
132 .iter()
133 .map(|store| store.hash_algo())
134 .chain(self.read_sources.iter().map(|store| store.hash_algo()))
135 }
136
137 /// Construct an [`Odb`] handle (attach [`Odb::with_config_git_dir`] when opening a repository).
138 #[must_use]
139 pub fn build(self) -> Odb {
140 Odb::from_builder(self)
141 }
142}
143
144impl Odb {
145 /// Build from [`OdbBuilder`] (used by [`RepositoryBuilder`](crate::repo::RepositoryBuilder)).
146 #[must_use]
147 pub(crate) fn from_builder(builder: OdbBuilder) -> Self {
148 let mut odb = Self::new(&builder.objects_dir);
149 odb.custom_primary = builder.custom_primary;
150 odb.extra_read_sources = builder.read_sources;
151 odb.alternates_enabled = builder.alternates;
152 odb
153 }
154
155 /// Whether the primary backend is the default on-disk [`super::store::FilesSource`].
156 #[must_use]
157 pub fn uses_files_primary(&self) -> bool {
158 self.custom_primary.is_none()
159 }
160
161 /// `objects/` path when the primary is files-backed; `None` for custom primaries.
162 #[must_use]
163 pub fn files_objects_dir(&self) -> Option<&Path> {
164 if self.uses_files_primary() {
165 Some(&self.objects_dir)
166 } else {
167 None
168 }
169 }
170
171 /// Filesystem-only helper: error when the primary is not files-backed.
172 pub(crate) fn require_files_primary(&self, operation: &'static str) -> Result<()> {
173 if self.custom_primary.is_some() {
174 return Err(Error::UnsupportedObjectStore { operation });
175 }
176 Ok(())
177 }
178
179 /// Primary writable backend (files source or custom store).
180 ///
181 /// # Errors
182 ///
183 /// Propagates failures while opening the lazy files source.
184 pub fn primary_writable(&self) -> Result<Arc<dyn WritableObjectStore>> {
185 if let Some(primary) = &self.custom_primary {
186 return Ok(Arc::clone(primary));
187 }
188 Ok(self.primary()? as Arc<dyn WritableObjectStore>)
189 }
190
191 /// In-process garbage collection for the files-backed object directory.
192 ///
193 /// Custom primaries return [`Error::UnsupportedObjectStore`].
194 ///
195 /// # Errors
196 ///
197 /// Propagates object-store or I/O failures during maintenance.
198 pub fn gc(&self) -> Result<()> {
199 self.require_files_primary("gc")?;
200 let Some(git_dir) = self.config_git_dir() else {
201 return Err(Error::UnsupportedObjectStore { operation: "gc" });
202 };
203 let opts = crate::environment::RepositoryOptions::empty();
204 let repo = if git_dir.file_name().is_some_and(|n| n == ".git") {
205 let wt = git_dir
206 .parent()
207 .ok_or(Error::UnsupportedObjectStore { operation: "gc" })?;
208 Repository::open(git_dir, &opts.with_work_tree(wt))?
209 } else {
210 Repository::open(git_dir, &opts)?
211 };
212 let roots = reachability_roots(&repo)?;
213 prune_loose_unreachable(self, &roots, None)?;
214 Ok(())
215 }
216
217 /// Write `objects/info/commit-graph` from reachable commits (files primary only).
218 ///
219 /// Writes nothing and deletes any existing graph in a repository that rewrites parent
220 /// lists (shallow, `info/grafts`, or replace refs unless `core.useReplaceRefs=false`), as
221 /// [`crate::maintenance::write_commit_graph`] does.
222 ///
223 /// # Errors
224 ///
225 /// Returns [`Error::UnsupportedObjectStore`] for custom primaries, or errors from graph
226 /// generation or I/O.
227 pub fn write_commit_graph(&self) -> Result<()> {
228 self.require_files_primary("commit-graph write")?;
229 let Some(git_dir) = self.config_git_dir() else {
230 return Err(Error::UnsupportedObjectStore {
231 operation: "commit-graph write",
232 });
233 };
234 let Some(objects_dir) = self.files_objects_dir() else {
235 return Err(Error::UnsupportedObjectStore {
236 operation: "commit-graph write",
237 });
238 };
239 let config = ConfigSet::load(
240 &crate::environment::Environment::empty(),
241 Some(git_dir),
242 true,
243 )
244 .unwrap_or_default();
245 let skip = crate::maintenance::CommitGraphSkipReason::detect(git_dir, || {
246 let enabled = config
247 .get_bool("core.useReplaceRefs")
248 .and_then(|r| r.ok())
249 .unwrap_or(true);
250 Ok(enabled && !crate::refs::list_refs(git_dir, "refs/replace/")?.is_empty())
251 })?;
252 if skip.is_some() {
253 crate::commit_graph_write::remove_commit_graph_files(objects_dir)?;
254 return Ok(());
255 }
256 let bloom = crate::maintenance::bloom_settings_for_commit_graph_write(&config, true);
257 let write_generation = crate::maintenance::commit_graph_writes_generation_data(&config);
258 let commits = collect_reachable_commit_oids(git_dir, self)?;
259 let mut sorted: Vec<ObjectId> = commits.into_iter().collect();
260 sorted.sort();
261 let mut infos = oid_hash_map();
262 for oid in &sorted {
263 infos.insert(*oid, load_commit_graph_commit_info(self, *oid)?);
264 }
265 let chain = CommitGraphChain::try_load(objects_dir).ok().flatten();
266 let mut existing_filters = oid_hash_map();
267 let mut upgraded_filters = oid_hash_map();
268 if let Some(ref chain) = chain {
269 for oid in &sorted {
270 if let Some(bytes) = chain.existing_filter_bytes(oid, &bloom) {
271 existing_filters.insert(*oid, bytes);
272 } else if let Some(bytes) = chain.upgradable_filter_bytes(oid, &bloom) {
273 upgraded_filters.insert(*oid, bytes);
274 }
275 }
276 }
277 use std::collections::HashMap;
278 let infos_map: HashMap<_, _> = infos.iter().map(|(k, v)| (*k, v.clone())).collect();
279 let existing_map: HashMap<_, _> = existing_filters
280 .iter()
281 .map(|(k, v)| (*k, v.clone()))
282 .collect();
283 let upgraded_map: HashMap<_, _> = upgraded_filters
284 .iter()
285 .map(|(k, v)| (*k, v.clone()))
286 .collect();
287 let (bytes, _) = build_commit_graph_bytes(
288 &sorted,
289 &infos_map,
290 self,
291 true,
292 &bloom,
293 None,
294 &[],
295 None,
296 &existing_map,
297 &upgraded_map,
298 write_generation,
299 )?;
300 crate::commit_graph_write::atomic_replace_commit_graph(objects_dir, &bytes)?;
301 let _ = crate::commit_graph_write::remove_commit_graph_chain_dir(objects_dir);
302 Ok(())
303 }
304
305 /// Attach a resolved work tree after [`OdbBuilder::build`].
306 #[must_use]
307 pub fn with_resolved_work_tree(mut self, work_tree: Option<PathBuf>) -> Self {
308 self.work_tree = work_tree;
309 self
310 }
311}