git-ents.gitmain
⌘K
foforge
root.rs441 lines · 19.1 KB · rusthistorycomment on this file
1//! Composition roots (`roots.composition`): the only place `git-ents`
2//! wires the four seams — `RefStore`, the object store, `EventSink`, and
3//! `Executor` — together.
4//!
5//! Two roots live in this module, per the development plan's phase-6 row:
6//!
7//! - [`LocalRoot`] (`roots.local`): the plain CLI, wired against whatever
8//! repository the current directory is in — loose-ref `RefStore`, the
9//! local odb, a null `EventSink`, the advisory gate, and a fixed
10//! `DockerExecutor` (there is no `--executor` flag anywhere in
11//! [`crate::cli`] to choose otherwise; local execution stays pull-only,
12//! via `git effect run`, per `effect.local-run`).
13//! - [`HostedRoot`] (`roots.single-node-hosted`, the single-node hosted
14//! root the development plan's `git-ents` row describes: "loose refs and
15//! a real odb on a Fly volume, served behind git's own `receive-pack`...
16//! with an in-memory `EventSink` and a boot-time reconciliation scan, and
17//! the Sprite executor"): the same loose-ref/odb primitives as
18//! [`LocalRoot`], but the mandatory gate, an in-memory `EventSink`, and a
19//! fixed `SpriteExecutor` — wired by the `git-ents hook` plumbing
20//! subcommands ([`crate::hook`]) that git's own `receive-pack` invokes.
21//!
22//! Neither root is `roots.hosted` (`git-ents-server`, phase 8): that root
23//! replaces the `RefStore` and object store with Postgres and Tigris and
24//! is out of scope until scale forces it (`roots.honesty-test`). This
25//! module's `HostedRoot` keeps git's own on-disk repository and
26//! `receive-pack` as the transport, exactly as the development plan's
27//! preamble describes for this phase.
28//!
29//! # Config isolation (`roots.config-isolation`)
30//!
31//! Every trait implementation is selected here, in these two structs, and
32//! nowhere else: no command module reads an environment variable or git
33//! config value to decide *which* `RefStore` or `Executor` to use — they
34//! are only ever handed one already-constructed by a root.
35//!
36//! # Boundary rules this module upholds
37//!
38//! [`LocalRoot`] and [`HostedRoot`] are the first composition roots this
39//! codebase has (every crate before phase 6 was a library, handed trait
40//! objects rather than constructing them): `arch.store-composition-root`
41//! ("a concrete store implementation... MUST be wired only inside a
42//! composition root") and `arch.no-hosted-branch` ("a library crate MUST
43//! NOT contain a branch on deployment mode") are both properties this
44//! file demonstrates rather than merely states — `LocalRoot` and
45//! `HostedRoot` are two distinct types, never one type with an
46//! `if hosted` branch, and every command module ([`crate::commands`])
47//! takes an already-constructed root, never constructing a store itself.
48// @relation(roots.composition, roots.local, roots.single-node-hosted, roots.config-isolation, arch.store-composition-root, arch.no-hosted-branch, scope=file)
49
50use std::path::{Path, PathBuf};
51
52use ents_effect::Executor;
53use ents_receive::{Mode, NullEventSink};
54use gix_ref_store::LooseRefStore;
55
56use crate::error::{Error, Result};
57
58/// A real, on-disk object store: the repository's own odb, opened for
59/// genuine reads *and* writes (`arch.no-object-store-trait`: accessed only
60/// through gitoxide's own `Find`/`Write` traits, never a private one).
61///
62/// `gix::Repository::objects` proxies writes into an in-memory overlay by
63/// default (so in-process object creation can be staged before a
64/// transaction commits); a composition root that wants every write to land
65/// on disk immediately calls
66/// [`gix_odb::memory::Proxy::with_write_passthrough`] to strip that
67/// overlay off, which is exactly what [`open_objects`] does. This is the
68/// "which object directory... is the composition root's responsibility to
69/// wire" `ents_receive::receive` itself defers to its caller.
70pub type Objects = gix::OdbHandle;
71
72/// Open `path`'s repository and return a real, write-through object store
73/// over it (see [`Objects`]'s own doc for why `with_write_passthrough` is
74/// required here).
75///
76/// # Errors
77///
78/// [`Error::Repo`] if `path` is not a git repository `gix` can open.
79pub fn open_objects(path: &Path) -> Result<Objects> {
80 let repo = gix::open(path)?;
81 Ok(repo.objects.with_write_passthrough())
82}
83
84/// The local composition root (`roots.local`): a loose-ref `RefStore`, the
85/// local odb, a null `EventSink`, the advisory gate. Wired once per CLI
86/// invocation against whichever repository the current directory
87/// discovers.
88///
89/// # Examples
90///
91/// ```
92/// # let dir = tempfile::tempdir().expect("tempdir");
93/// # gix::init(dir.path()).expect("init");
94/// use git_ents::root::LocalRoot;
95///
96/// let root = LocalRoot::open(dir.path()).expect("opens a real repository");
97/// assert_eq!(root.mode(), ents_receive::Mode::Advisory);
98/// ```
99pub struct LocalRoot {
100 /// The repository path this root was opened against.
101 pub path: PathBuf,
102 /// The loose-ref `RefStore` (`arch.loose-cas-discipline`).
103 pub refs: LooseRefStore,
104 /// The real, on-disk object store.
105 pub objects: Objects,
106 /// The null `EventSink` (`roots.local`): local effect execution is
107 /// pull-only, so nothing is ever enqueued here (`effect.local-run`).
108 pub events: NullEventSink,
109 /// The fixed `Executor` this root wires (`roots.local`): a
110 /// `DockerExecutor`. Boxed because `LocalRoot` and `HostedRoot` fix
111 /// different concrete backends and every command module is written
112 /// against the trait, never a specific one.
113 pub executor: Box<dyn Executor>,
114}
115
116impl LocalRoot {
117 /// Open the local composition root against the repository at `path`.
118 ///
119 /// # Errors
120 ///
121 /// [`Error::Repo`] or [`Error::Refs`] if `path` is not a git
122 /// repository, or its refs cannot be opened.
123 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
124 let path = path.as_ref().to_owned();
125 let refs = LooseRefStore::open(&path)?;
126 let objects = open_objects(&path)?;
127 Ok(Self {
128 path,
129 refs,
130 objects,
131 events: NullEventSink,
132 executor: Box::new(ents_effect::DockerExecutor),
133 })
134 }
135
136 /// Discover the repository from `start` upward (mirroring `git`'s own
137 /// discovery), then open the local root against it.
138 ///
139 /// # Errors
140 ///
141 /// [`Error::NotARepo`] if no git repository is found at or above
142 /// `start`.
143 pub fn discover(start: impl AsRef<Path>) -> Result<Self> {
144 let start = start.as_ref();
145 let discovered = gix::discover(start).map_err(|_source| Error::NotARepo {
146 path: start.to_owned(),
147 })?;
148 let path = discovered.workdir().unwrap_or_else(|| discovered.path());
149 Self::open(path)
150 }
151
152 /// The gate policy this root runs under: always advisory
153 /// (`gate.advisory-local`) — a local write is annotated, never
154 /// blocked.
155 #[must_use]
156 pub fn mode(&self) -> Mode {
157 Mode::Advisory
158 }
159}
160
161/// The single-node hosted composition root: the same loose-ref/odb
162/// primitives [`LocalRoot`] uses, but the mandatory gate
163/// (`gate.mandatory-hosted`) — a push landing on the actual canonical
164/// remote has teeth (`docs/design.adoc`: "the hosted server is not where
165/// policy lives — it is the one place where the verdict has teeth") — and
166/// an in-memory `EventSink` reconciled at boot
167/// (`receive.reconstructible`).
168///
169/// This is wired by [`crate::hook`]'s plumbing subcommands, which git's own
170/// `receive-pack` invokes as `pre-receive`/`post-receive` hooks; see that
171/// module's doc for why the ref *write* itself is left to git's native
172/// `receive-pack` rather than `ents_receive::receive`'s own
173/// `RefStore::transaction` in this deployment shape.
174pub struct HostedRoot {
175 /// The repository path this root was opened against.
176 pub path: PathBuf,
177 /// The loose-ref `RefStore`.
178 pub refs: LooseRefStore,
179 /// The real, on-disk object store, transparently extended to also read
180 /// through a `pre-receive` quarantine directory when the environment
181 /// names one (`GIT_OBJECT_DIRECTORY`) — see [`QuarantineObjects`]'s own
182 /// doc for why this is an in-process read chain rather than a written
183 /// `info/alternates` file.
184 pub objects: QuarantineObjects,
185 /// The in-memory `EventSink`, reconciled at boot
186 /// (`receive.reconstructible`).
187 pub events: ents_receive::MemoryEventSink,
188 /// The fixed `Executor` this root wires (`roots.single-node-hosted`): a
189 /// `SpriteExecutor` targeting [`HOSTED_WORKER_NAME`].
190 pub executor: Box<dyn Executor>,
191}
192
193/// The Sprite name (and commit author name) the single-node hosted root's
194/// worker uses — shared between [`HostedRoot`]'s `SpriteExecutor` and
195/// [`crate::hook::post_receive`]'s result-commit author, so the two stay
196/// the same identity by construction rather than by two literals staying
197/// in sync by hand.
198pub const HOSTED_WORKER_NAME: &str = "git-ents-hosted-worker";
199
200impl HostedRoot {
201 /// Open the hosted composition root against the repository at `path`,
202 /// honoring a pre-receive quarantine object directory if the
203 /// environment names one (`GIT_OBJECT_DIRECTORY`), and immediately run
204 /// the boot-time reconciliation scan (`receive.reconstructible`) to
205 /// populate the in-memory `EventSink` from repository state alone.
206 ///
207 /// # Errors
208 ///
209 /// [`Error::Repo`] or [`Error::Refs`] if `path` is not a git
210 /// repository; [`Error::Receive`] if the reconciliation scan itself
211 /// fails to read repository state.
212 pub fn open(path: impl AsRef<Path>) -> Result<Self> {
213 let path = path.as_ref().to_owned();
214 let refs = LooseRefStore::open(&path)?;
215 let objects = QuarantineObjects::open(&path)?;
216 let events = ents_receive::MemoryEventSink::default();
217 ents_receive::reconcile(&refs, &objects, &events)?;
218 Ok(Self {
219 path,
220 refs,
221 objects,
222 events,
223 executor: Box::new(ents_effect::SpriteExecutor::new(HOSTED_WORKER_NAME)),
224 })
225 }
226
227 /// The gate policy this root runs under: always mandatory
228 /// (`gate.mandatory-hosted`) — the canonical hosted remote's writes
229 /// are actually enforced, not merely annotated.
230 #[must_use]
231 pub fn mode(&self) -> Mode {
232 Mode::Mandatory
233 }
234}
235
236/// The real, on-disk object store, transparently extended to also read
237/// through a `pre-receive` quarantine directory when the environment names
238/// one (`GIT_OBJECT_DIRECTORY`).
239///
240/// # Why an in-process read chain, not a written `info/alternates` file
241///
242/// git's own `pre-receive` quarantine does *not* write a physical
243/// `info/alternates` file into the quarantine directory it hands hook
244/// processes — it communicates the real odb's location purely via the
245/// `GIT_ALTERNATE_OBJECT_DIRECTORIES` environment variable (confirmed
246/// empirically: a quarantine directory git creates has no
247/// `info/alternates` at all). `gix_odb::at`, in contrast, only ever
248/// follows a physical alternates *file* — it does not consult this
249/// environment variable itself. This is exactly the "which object
250/// directory... is the composition root's responsibility to wire" gap
251/// `ents_receive::receive`'s own doc names for the quarantine case.
252///
253/// The first attempt at closing this gap wrote the environment's paths
254/// into the quarantine's own `info/alternates` file, reasoning that the
255/// quarantine directory is discarded once the push resolves. That
256/// reasoning was wrong: git's quarantine finalization *moves* the
257/// quarantine directory's entire contents — including any file a hook
258/// process wrote into it — onto the real object directory once the push
259/// is accepted, permanently persisting a written alternates file at
260/// `objects/info/alternates` whose own content names `objects` itself, a
261/// self-cycle that fails every future open of the repository (observed
262/// directly: a second push into the same repository failed with
263/// `gix_odb`'s own "Alternates form a cycle" error, `objects` pointing at
264/// itself). Nothing here writes to the object directory at all now,
265/// closing that hazard structurally rather than by adding another
266/// disk-state special case.
267///
268/// # Examples
269///
270/// ```
271/// # let dir = tempfile::tempdir().expect("tempdir");
272/// # gix::init_bare(dir.path()).expect("init"); // HostedRoot always runs against a bare repo.
273/// use git_ents::root::QuarantineObjects;
274///
275/// // No `GIT_OBJECT_DIRECTORY` set: reads go straight to the real odb.
276/// let objects = QuarantineObjects::open(dir.path()).expect("opens");
277/// let missing = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
278/// assert!(
279/// gix_object::Find::try_find(&objects, &missing, &mut Vec::new())
280/// .expect("a missing lookup is Ok(None), not an error")
281/// .is_none()
282/// );
283/// ```
284pub struct QuarantineObjects {
285 /// The quarantine directory's own odb, when `GIT_OBJECT_DIRECTORY`
286 /// names one distinct from the repository's real `objects/` —
287 /// consulted first, so a push's own not-yet-committed objects are
288 /// visible before the fallback.
289 quarantine: Option<gix_odb::Handle>,
290 /// The repository's real, on-disk odb — reads fall back to this, and
291 /// every write always lands here (see [`gix_object::Write`]'s impl):
292 /// `pre-receive` never writes objects itself
293 /// (`ents_gate::verify` is read-only), so this is only exercised by
294 /// `post-receive`'s write-back path, which never runs under a
295 /// quarantine at all.
296 real: gix_odb::Handle,
297}
298
299impl QuarantineObjects {
300 /// Open the object store for the repository at `path`, chaining a
301 /// `pre-receive` quarantine directory in front of the real odb when
302 /// the environment names one distinct from `path`'s own `objects/`
303 /// (canonicalized, since a quarantine and the real directory can
304 /// otherwise compare unequal only by an unresolved symlink or a `/./`
305 /// path component).
306 ///
307 /// # Errors
308 ///
309 /// [`Error::Io`] if either directory cannot be opened as an object
310 /// store.
311 pub fn open(path: &Path) -> Result<Self> {
312 let real_dir = path.join("objects");
313 let real = gix_odb::at(&real_dir).map_err(|source| Error::Io {
314 path: real_dir.clone(),
315 source,
316 })?;
317 let quarantine = match std::env::var_os("GIT_OBJECT_DIRECTORY") {
318 Some(dir) => {
319 let dir = PathBuf::from(dir);
320 let is_real_quarantine = match (dir.canonicalize(), real_dir.canonicalize()) {
321 (Ok(q), Ok(r)) => q != r,
322 _ => dir != real_dir,
323 };
324 if is_real_quarantine {
325 Some(gix_odb::at(&dir).map_err(|source| Error::Io { path: dir, source })?)
326 } else {
327 None
328 }
329 }
330 None => None,
331 };
332 Ok(Self { quarantine, real })
333 }
334}
335
336// @relation(arch.no-object-store-trait, scope=function)
337impl gix_object::Find for QuarantineObjects {
338 fn try_find<'a>(
339 &self,
340 id: &gix_hash::oid,
341 buffer: &'a mut Vec<u8>,
342 ) -> std::result::Result<Option<gix_object::Data<'a>>, gix_object::find::Error> {
343 // The quarantine attempt reads into its own, function-local
344 // buffer rather than the caller's `buffer` (whose lifetime `'a`
345 // is named, not elided, so the borrow checker cannot shrink a
346 // second, conditional reborrow of it to a sub-region even though
347 // only one branch ever executes at runtime) — copying the found
348 // bytes into `buffer` afterward keeps this the same one-call-site
349 // shape `gix_odb::memory::Proxy::try_find` uses for its own
350 // primary-then-fallback lookup.
351 if let Some(quarantine) = &self.quarantine {
352 let mut local = Vec::new();
353 if let Some(found) = quarantine.try_find(id, &mut local)? {
354 let kind = found.kind;
355 buffer.clear();
356 buffer.extend_from_slice(found.data);
357 return Ok(Some(gix_object::Data {
358 kind,
359 object_hash: id.kind(),
360 data: buffer.as_slice(),
361 }));
362 }
363 }
364 self.real.try_find(id, buffer)
365 }
366}
367
368impl gix_object::Write for QuarantineObjects {
369 fn write_stream(
370 &self,
371 kind: gix_object::Kind,
372 size: u64,
373 from: &mut dyn std::io::Read,
374 ) -> std::result::Result<gix_hash::ObjectId, gix_object::write::Error> {
375 self.real.write_stream(kind, size, from)
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 #![allow(clippy::expect_used, reason = "unit test")]
382
383 use rstest::rstest;
384
385 use super::*;
386
387 /// `arch.no-hosted-branch`: the two roots are distinct types with a
388 /// fixed, compile-time-chosen gate policy each — never one type
389 /// branching on a runtime "am I hosted?" check. Table-driven because
390 /// the spec enumerates exactly these two cases, one per root.
391 #[rstest]
392 #[case::local_is_advisory(true, Mode::Advisory)]
393 #[case::hosted_is_mandatory(false, Mode::Mandatory)]
394 // @relation(arch.no-hosted-branch, roots.config-isolation, scope=function, role=Verifies)
395 fn each_root_has_one_fixed_mode(#[case] local: bool, #[case] expected: Mode) {
396 let dir = tempfile::tempdir().expect("tempdir");
397 let mode = if local {
398 gix::init(dir.path()).expect("init");
399 LocalRoot::open(dir.path()).expect("opens").mode()
400 } else {
401 gix::init_bare(dir.path()).expect("init bare");
402 HostedRoot::open(dir.path()).expect("opens").mode()
403 };
404 assert_eq!(mode, expected);
405 }
406
407 /// `arch.store-composition-root`: opening either root is the *only*
408 /// place a `LooseRefStore`/odb pair gets constructed — every command
409 /// module ([`crate::commands`]) only ever receives an already-built
410 /// root, never builds one of its own store handles.
411 #[rstest]
412 // @relation(arch.store-composition-root, scope=function, role=Verifies)
413 fn opening_a_root_is_the_only_construction_path() {
414 let dir = tempfile::tempdir().expect("tempdir");
415 gix::init(dir.path()).expect("init");
416 let root = LocalRoot::open(dir.path()).expect("opens");
417 // The root itself is the seam every command module is handed;
418 // there is no second, parallel way to obtain a `RefStore`/odb
419 // pair for this repository within this crate.
420 assert_eq!(root.path, dir.path());
421 }
422
423 /// `arch.no-object-store-trait`: `QuarantineObjects` reads and writes
424 /// exclusively through gitoxide's own `Find`/`Write` traits — no
425 /// private object-store trait exists in this crate for it to
426 /// implement instead.
427 #[rstest]
428 // @relation(arch.no-object-store-trait, scope=function, role=Verifies)
429 fn quarantine_objects_round_trips_through_gitoxides_own_traits() {
430 let dir = tempfile::tempdir().expect("tempdir");
431 gix::init_bare(dir.path()).expect("init bare");
432 let objects = QuarantineObjects::open(dir.path()).expect("opens");
433
434 let oid = gix_object::Write::write(&objects, &gix_object::Tree::empty()).expect("writes");
435 let mut buf = Vec::new();
436 let found = gix_object::Find::try_find(&objects, &oid, &mut buf)
437 .expect("reads")
438 .expect("just written");
439 assert_eq!(found.kind, gix_object::Kind::Tree);
440 }
441}