crates/cli/git-ents/src/root.rs
root.rshistorycomment 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 | |
| 50 | use std::path::{Path, PathBuf}; |
| 51 | |
| 52 | use ents_effect::Executor; |
| 53 | use ents_receive::{Mode, NullEventSink}; |
| 54 | use gix_ref_store::LooseRefStore; |
| 55 | |
| 56 | use 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. |
| 70 | pub 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. |
| 79 | pub 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 | /// ``` |
| 99 | pub 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 | |
| 116 | impl 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. |
| 174 | pub 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. |
| 198 | pub const HOSTED_WORKER_NAME: &str = "git-ents-hosted-worker"; |
| 199 | |
| 200 | impl 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 | /// ``` |
| 284 | pub 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 | |
| 299 | impl 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) |
| 337 | impl 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 | |
| 368 | impl 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)] |
| 380 | mod 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 | } |