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::collect_reachable_commit_oids;
26use crate::config::ConfigSet;
27use crate::error::{Error, Result};
28use crate::fsck::reachability_roots;
29use crate::gc::prune_loose_unreachable;
30use crate::objects::HashAlgo;
31use crate::odb::store::{ObjectStore, WritableObjectStore};
32use crate::repo::Repository;
33
34use super::Odb;
35
36/// Configure and construct an [`Odb`] with a pluggable primary backend.
37#[derive(Debug, Clone)]
38pub struct OdbBuilder {
39 objects_dir: PathBuf,
40 custom_primary: Option<Arc<dyn WritableObjectStore>>,
41 read_sources: Vec<Arc<dyn ObjectStore>>,
42 alternates: bool,
43 commit_graph: Option<Arc<CommitGraphChain>>,
44}
45
46impl OdbBuilder {
47 /// Default files-backed layout rooted at `objects_dir` (the repository's `objects/` tree).
48 ///
49 /// When passed to [`RepositoryBuilder::odb_builder`](crate::repo::RepositoryBuilder::odb_builder),
50 /// any spelling of the repository's `objects/` directory is accepted: a path through
51 /// symlinks, or a relative path (resolved against the environment's current directory).
52 /// With a custom [`Self::primary`] the directory need not exist, but it must still name
53 /// the repository's `objects/` directory.
54 #[must_use]
55 pub fn files(objects_dir: impl AsRef<Path>) -> Self {
56 Self {
57 objects_dir: objects_dir.as_ref().to_path_buf(),
58 custom_primary: None,
59 read_sources: Vec::new(),
60 alternates: true,
61 commit_graph: None,
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 /// [`Remote::fetch`](crate::remote::Remote::fetch), [`Remote::push`](crate::remote::Remote::push),
76 /// pull, bundles, and [`clone_into`](crate::clone::clone_into) read and write objects through
77 /// the custom primary. Partial-clone filters (`operation: "partial fetch"`) and filesystem
78 /// maintenance still refuse when they need on-disk pack layout:
79 ///
80 /// - maintenance: [`Odb::gc`], [`Odb::write_commit_graph`], repack, MIDX, and pack helpers
81 ///
82 /// History walks never read commit-graph files from `objects/` for a custom primary: such
83 /// a file describes the files layout, not this store. To give walks a commit-graph, build
84 /// one with [`crate::maintenance::build_commit_graph`], persist its bytes in the store, and
85 /// attach it with [`Self::commit_graph`] when opening the repository.
86 ///
87 /// The store's [`ObjectStore::hash_algo`] must match the repository's object format:
88 /// [`RepositoryBuilder::open`](crate::repo::RepositoryBuilder::open) fails with
89 /// [`Error::ObjectFormatMismatch`] otherwise. [`Self::build`] does not check, since it
90 /// has no repository config to compare against.
91 #[must_use]
92 pub fn primary(mut self, store: Arc<dyn WritableObjectStore>) -> Self {
93 self.custom_primary = Some(store);
94 self
95 }
96
97 /// Append a read-only store consulted after the primary and before alternates.
98 ///
99 /// Like [`Self::primary`], its hash algorithm must match the repository's object format
100 /// or [`RepositoryBuilder::open`](crate::repo::RepositoryBuilder::open) fails with
101 /// [`Error::ObjectFormatMismatch`].
102 #[must_use]
103 pub fn push_read_source(mut self, store: Arc<dyn ObjectStore>) -> Self {
104 self.read_sources.push(store);
105 self
106 }
107
108 /// Attach a commit-graph for history walks to consult, for any primary store.
109 ///
110 /// Rev-list, merge-base, ancestry checks, rev-parse navigation, blame and path-limited log
111 /// read parents, root trees, committer dates and generation numbers from `chain`, and test
112 /// changed-path Bloom filters when it has them, instead of reading commit objects. Commits
113 /// missing from the graph (written after it was built) are read from the store as usual,
114 /// so a graph that covers only older history stays correct.
115 ///
116 /// An attached graph replaces any graph file under `objects/info/`. It is trusted, not
117 /// checked against the store: every commit it lists must be in this database with exactly
118 /// the parents and tree it records, which holds for a graph
119 /// [`crate::maintenance::build_commit_graph`] built from this repository. Build it with
120 /// [`CommitGraphChain::from_bytes`].
121 ///
122 /// The graph's [`CommitGraphChain::hash_algo`] must match the repository's object format,
123 /// or [`RepositoryBuilder::open`](crate::repo::RepositoryBuilder::open) fails with
124 /// [`Error::ObjectFormatMismatch`].
125 #[must_use]
126 pub fn commit_graph(mut self, chain: Arc<CommitGraphChain>) -> Self {
127 self.commit_graph = Some(chain);
128 self
129 }
130
131 /// When `false`, skip `info/alternates`, environment alternates, and submodule object dirs.
132 #[must_use]
133 pub fn alternates(mut self, enabled: bool) -> Self {
134 self.alternates = enabled;
135 self
136 }
137
138 /// `objects/` directory this builder targets.
139 #[must_use]
140 pub fn objects_dir(&self) -> &Path {
141 &self.objects_dir
142 }
143
144 pub(crate) fn set_objects_dir(&mut self, objects_dir: PathBuf) {
145 self.objects_dir = objects_dir;
146 }
147
148 /// Whether [`Self::primary`] replaced the files-backed primary store.
149 pub(crate) fn has_custom_primary(&self) -> bool {
150 self.custom_primary.is_some()
151 }
152
153 /// Hash algorithms of the injected stores: the custom primary, then each read source,
154 /// then the attached commit-graph.
155 pub(crate) fn injected_hash_algos(&self) -> impl Iterator<Item = HashAlgo> + '_ {
156 self.custom_primary
157 .iter()
158 .map(|store| store.hash_algo())
159 .chain(self.read_sources.iter().map(|store| store.hash_algo()))
160 .chain(self.commit_graph.iter().map(|chain| chain.hash_algo()))
161 }
162
163 /// Construct an [`Odb`] handle (attach [`Odb::with_config_git_dir`] when opening a repository).
164 #[must_use]
165 pub fn build(self) -> Odb {
166 Odb::from_builder(self)
167 }
168}
169
170impl Odb {
171 /// Build from [`OdbBuilder`] (used by [`RepositoryBuilder`](crate::repo::RepositoryBuilder)).
172 #[must_use]
173 pub(crate) fn from_builder(builder: OdbBuilder) -> Self {
174 let mut odb = Self::new(&builder.objects_dir);
175 odb.custom_primary = builder.custom_primary;
176 odb.extra_read_sources = builder.read_sources;
177 odb.alternates_enabled = builder.alternates;
178 odb.attached_commit_graph = builder.commit_graph;
179 odb
180 }
181
182 /// Whether the primary backend is the default on-disk [`super::store::FilesSource`].
183 #[must_use]
184 pub fn uses_files_primary(&self) -> bool {
185 self.custom_primary.is_none()
186 }
187
188 /// `objects/` path when the primary is files-backed; `None` for custom primaries.
189 #[must_use]
190 pub fn files_objects_dir(&self) -> Option<&Path> {
191 if self.uses_files_primary() {
192 Some(&self.objects_dir)
193 } else {
194 None
195 }
196 }
197
198 /// Filesystem-only helper: error when the primary is not files-backed.
199 pub(crate) fn require_files_primary(&self, operation: &'static str) -> Result<()> {
200 if self.custom_primary.is_some() {
201 return Err(Error::UnsupportedObjectStore { operation });
202 }
203 Ok(())
204 }
205
206 /// Primary writable backend (files source or custom store).
207 ///
208 /// # Errors
209 ///
210 /// Propagates failures while opening the lazy files source.
211 pub fn primary_writable(&self) -> Result<Arc<dyn WritableObjectStore>> {
212 if let Some(primary) = &self.custom_primary {
213 return Ok(Arc::clone(primary));
214 }
215 Ok(self.primary()? as Arc<dyn WritableObjectStore>)
216 }
217
218 /// In-process garbage collection for the files-backed object directory.
219 ///
220 /// Custom primaries return [`Error::UnsupportedObjectStore`].
221 ///
222 /// # Errors
223 ///
224 /// Propagates object-store or I/O failures during maintenance.
225 pub fn gc(&self) -> Result<()> {
226 self.require_files_primary("gc")?;
227 let Some(git_dir) = self.config_git_dir() else {
228 return Err(Error::UnsupportedObjectStore { operation: "gc" });
229 };
230 let opts = crate::environment::RepositoryOptions::empty();
231 let repo = if git_dir.file_name().is_some_and(|n| n == ".git") {
232 let wt = git_dir
233 .parent()
234 .ok_or(Error::UnsupportedObjectStore { operation: "gc" })?;
235 Repository::open(git_dir, &opts.with_work_tree(wt))?
236 } else {
237 Repository::open(git_dir, &opts)?
238 };
239 let roots = reachability_roots(&repo)?;
240 prune_loose_unreachable(self, &roots, None)?;
241 Ok(())
242 }
243
244 /// Write `objects/info/commit-graph` from reachable commits (files primary only).
245 ///
246 /// A custom primary has no `objects/info/` to install a graph in: build it in memory with
247 /// [`crate::maintenance::build_commit_graph`] and attach it with
248 /// [`OdbBuilder::commit_graph`] instead.
249 ///
250 /// Writes nothing and deletes any existing graph in a repository that rewrites parent
251 /// lists (shallow, `info/grafts`, or replace refs unless `core.useReplaceRefs=false`), as
252 /// [`crate::maintenance::write_commit_graph`] does.
253 ///
254 /// # Errors
255 ///
256 /// Returns [`Error::UnsupportedObjectStore`] for custom primaries, or errors from graph
257 /// generation or I/O.
258 pub fn write_commit_graph(&self) -> Result<()> {
259 self.require_files_primary("commit-graph write")?;
260 let Some(git_dir) = self.config_git_dir() else {
261 return Err(Error::UnsupportedObjectStore {
262 operation: "commit-graph write",
263 });
264 };
265 let Some(objects_dir) = self.files_objects_dir() else {
266 return Err(Error::UnsupportedObjectStore {
267 operation: "commit-graph write",
268 });
269 };
270 let config = ConfigSet::load(
271 &crate::environment::Environment::empty(),
272 Some(git_dir),
273 true,
274 )
275 .unwrap_or_default();
276 let skip = crate::maintenance::CommitGraphSkipReason::detect(git_dir, || {
277 let enabled = config
278 .get_bool("core.useReplaceRefs")
279 .and_then(|r| r.ok())
280 .unwrap_or(true);
281 Ok(enabled && !crate::refs::list_refs(git_dir, "refs/replace/")?.is_empty())
282 })?;
283 if skip.is_some() {
284 crate::commit_graph_write::remove_commit_graph_files(objects_dir)?;
285 return Ok(());
286 }
287 let commits = collect_reachable_commit_oids(git_dir, self)?;
288 let existing = CommitGraphChain::try_load(objects_dir).ok().flatten();
289 let built = crate::maintenance::build_commit_graph_for_commits(
290 self,
291 commits,
292 &config,
293 true,
294 existing.as_ref(),
295 )?;
296 crate::commit_graph_write::atomic_replace_commit_graph(objects_dir, &built.bytes)?;
297 let _ = crate::commit_graph_write::remove_commit_graph_chain_dir(objects_dir);
298 Ok(())
299 }
300
301 /// Attach a resolved work tree after [`OdbBuilder::build`].
302 #[must_use]
303 pub fn with_resolved_work_tree(mut self, work_tree: Option<PathBuf>) -> Self {
304 self.work_tree = work_tree;
305 self
306 }
307}