git-ents.gitmain
⌘K
foforge
commit 4d74afa
feat: add git-backend storage traits and file-based backend crates

WS1: extract RefStore/ObjectStore/EffectExecutor as backend-agnostic traits (docs/scale-out.adoc), then implement the local backends on gitoxide and port git-store’s ref access onto RefStore so multi-ref compare-and-swap has one implementation instead of being inlined at every call site.

feat: add git-backend crate defining RefStore, ObjectStore, and EffectExecutor feat: add refstore-files, a RefStore over gitoxide loose refs/packed-refs feat: add odb-files, an ObjectStore with quarantine-based pack staging refactor: route git-store’s ref reads/writes through RefStore Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -1448,6 +1448,15 @@ "thiserror 2.0.18", ] +[[package]] +name = "git-backend" +version = "0.0.0" +dependencies = [ + "gix-hash", + "gix-object", + "thiserror 2.0.18", +] + [[package]] name = "git-comment" version = "0.0.0" @@ -1573,7 +1582,9 @@ dependencies = [ "facet", "facet-git-tree", + "git-backend", "gix", + "refstore-files", "tempfile", "thiserror 2.0.18", ] @@ -3146,6 +3157,19 @@ "autocfg", ] +[[package]] +name = "odb-files" +version = "0.0.0" +dependencies = [ + "git-backend", + "git-store", + "gix", + "gix-hash", + "gix-object", + "gix-pack", + "uuid", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3472,6 +3496,16 @@ "bitflags 2.13.0", ] +[[package]] +name = "refstore-files" +version = "0.0.0" +dependencies = [ + "git-backend", + "git-store", + "gix", + "gix-hash", +] + [[package]] name = "regex" version = "1.12.4"
Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/git-anchor", + "crates/git-backend", "crates/git-comment", "crates/git-effect", "crates/git-ents", @@ -11,6 +12,8 @@ "crates/git-signed-push", "crates/git-store", "crates/git-toolchain", + "crates/odb-files", + "crates/refstore-files", ] [workspace.package] @@ -55,6 +58,7 @@ facet-git-tree = { git = "https://github.com/git-ents/facet-git-tree" } form_urlencoded = "1" git-anchor = { path = "crates/git-anchor" } +git-backend = { path = "crates/git-backend" } git-comment = { path = "crates/git-comment" } git-effect = { path = "crates/git-effect" } git-ents-core = { path = "crates/git-ents-core" } @@ -63,6 +67,8 @@ git-signed-push = { path = "crates/git-signed-push" } git-store = { path = "crates/git-store" } git-toolchain = { path = "crates/git-toolchain" } +odb-files = { path = "crates/odb-files" } +refstore-files = { path = "crates/refstore-files" } gix = "0.84" gix-hash = { version = "0.25", features = ["sha1"] } gix-object = "0.61"
crates/git-store/Cargo.toml @@ -8,7 +8,9 @@ [dependencies] facet = { workspace = true } facet-git-tree = { workspace = true } +git-backend = { workspace = true } gix = { workspace = true } +refstore-files = { workspace = true } tempfile = { workspace = true, optional = true } thiserror = { workspace = true }
crates/git-store/src/lib.rs @@ -23,10 +23,10 @@ use std::path::Path; use facet::Facet; +use git_backend::{Expected, RefEdit, RefName, RefStore as _, TxOutcome}; use gix::ObjectId; use gix::objs::{Commit, FindExt as _, Write as _}; -use gix::refs::Target; -use gix::refs::transaction::PreviousValue; +use refstore_files::FilesRefStore; pub mod component; mod merge; @@ -133,13 +133,14 @@ // @relation(storage.meta-ref, nonfunctional.object-store) /// A repository's typed `refs/meta/*` store. /// -/// Refs are read and updated through the high-level [`gix`] API, while all +/// Refs are read and updated through [`git_backend::RefStore`] (backed by +/// [`refstore_files`], gitoxide's own loose refs and packed-refs), while all /// object IO uses an object database opened on the *common* git directory /// rather than `--git-path objects`: inside a hook git points the latter at a /// receive-pack quarantine holding only the incoming pack, while the documents /// we read and write live in the durable store. pub struct Store { - repo: gix::Repository, + refs: FilesRefStore, odb: gix::odb::Handle, } @@ -147,9 +148,10 @@ // @relation(nonfunctional.object-store) /// Open the typed store for the repository at `repo`. pub fn open(repo: &Path) -> Result<Self, Error> { - let repo = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?; - let odb = gix::odb::at(repo.common_dir().join("objects")).map_err(|_io| Error::Odb)?; - Ok(Self { repo, odb }) + let refs = FilesRefStore::open(repo).map_err(|error| Error::Ref(error.to_string()))?; + let opened = gix::open(repo).map_err(|error| Error::Open(Box::new(error)))?; + let odb = gix::odb::at(opened.common_dir().join("objects")).map_err(|_io| Error::Odb)?; + Ok(Self { refs, odb }) } /// Load the document on `refname`, or `None` when the ref is absent. @@ -337,13 +339,24 @@ /// rather than by rewriting a map document (e.g. `git-toolchain`'s /// `remove`). pub fn delete_ref(&self, refname: &str) -> Result<(), Error> { - let reference = self - .repo - .find_reference(refname) - .map_err(|error| Error::Ref(error.to_string()))?; - reference - .delete() - .map_err(|error| Error::Ref(error.to_string())) + let name = RefName::new(refname); + let current = self + .refs + .get(&name) + .map_err(|error| Error::Ref(error.to_string()))? + .ok_or_else(|| Error::Ref(format!("{refname} does not exist")))?; + let edit = RefEdit { + name, + expected: Expected::MustExistAndMatch(current), + new: None, + }; + match self.refs.transaction(&[edit]) { + Ok(TxOutcome::Applied) => Ok(()), + Ok(TxOutcome::Rejected { .. }) => { + Err(Error::Ref(format!("{refname}: changed concurrently"))) + } + Err(error) => Err(Error::Ref(error.to_string())), + } } /// Load the item `id` under the collection ref namespace `prefix` @@ -528,22 +541,14 @@ /// The full names of the refs under `prefix`, newest committer date first. pub fn list(&self, prefix: &str) -> Result<Vec<String>, Error> { - let platform = self - .repo - .references() - .map_err(|error| Error::Ref(error.to_string()))?; - let iter = platform - .prefixed(prefix) + let iter = self + .refs + .iter_prefix(&RefName::new(prefix)) .map_err(|error| Error::Ref(error.to_string()))?; let mut refs = Vec::new(); - for reference in iter { - let mut reference = reference.map_err(|error| Error::Ref(error.to_string()))?; - let name = reference.name().as_bstr().to_string(); - let oid = reference - .peel_to_id() - .map_err(|error| Error::Ref(error.to_string()))? - .detach(); - refs.push((self.read_commit(&oid)?.seconds, name)); + for item in iter { + let (name, oid) = item.map_err(|error| Error::Ref(error.to_string()))?; + refs.push((self.read_commit(&oid)?.seconds, name.as_str().to_owned())); } refs.sort_by_key(|(seconds, _name)| Reverse(*seconds)); Ok(refs.into_iter().map(|(_seconds, name)| name).collect()) @@ -554,19 +559,9 @@ /// itself (e.g. to parent another commit on it), not just the document /// [`load`](Self::load) reads out of it. pub fn ref_commit(&self, refname: &str) -> Result<Option<ObjectId>, Error> { - match self - .repo - .try_find_reference(refname) - .map_err(|error| Error::Ref(error.to_string()))? - { - Some(mut reference) => { - let id = reference - .peel_to_id() - .map_err(|error| Error::Ref(error.to_string()))?; - Ok(Some(id.detach())) - } - None => Ok(None), - } + self.refs + .get(&RefName::new(refname)) + .map_err(|error| Error::Ref(error.to_string())) } /// Read `oid`'s tree, parents, author, and committer date from the @@ -649,19 +644,18 @@ commit: ObjectId, ) -> Result<(), Error> { let constraint = match expected { - Some(oid) => PreviousValue::MustExistAndMatch(Target::Object(oid)), - None => PreviousValue::MustNotExist, + Some(oid) => Expected::MustExistAndMatch(oid), + None => Expected::MustNotExist, }; - match self - .repo - .reference(refname, commit, constraint, "git-ents: update") - { - Ok(_reference) => Ok(()), - Err(error) => match self.ref_commit(refname) { - Ok(current) if current == expected => Err(Error::Ref(error.to_string())), - Ok(_current) => Err(Error::Conflict), - Err(error) => Err(error), - }, + let edit = RefEdit { + name: RefName::new(refname), + expected: constraint, + new: Some(commit), + }; + match self.refs.transaction(&[edit]) { + Ok(TxOutcome::Applied) => Ok(()), + Ok(TxOutcome::Rejected { .. }) => Err(Error::Conflict), + Err(error) => Err(Error::Ref(error.to_string())), } } }
crates/git-backend/Cargo.toml @@ -1,0 +1,14 @@ +[package] +name = "git-backend" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +gix-hash = { workspace = true } +gix-object = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true
crates/git-backend/src/effect.rs @@ -1,0 +1,58 @@ +//! [`EffectExecutor`]: the seam between the effect engine and wherever an +//! effect actually runs. + +use crate::Result; + +/// The static definition of an effect to spawn: its name and the command +/// run for it (`None` for a composite effect that only aggregates +/// dependencies elsewhere), plus the sandbox image it runs in when it names +/// one. Mirrors the shape `git_effect::Effect` loads from +/// `refs/meta/effects/<name>` — kept as an independent, minimal type here +/// (rather than a dependency on `git-effect`) so this foundational crate +/// stays below the effect engine in the dependency graph, not above it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectDef { + /// The name it is stored under (`refs/meta/effects/<name>`). + pub name: String, + /// The shell command run for the effect, or `None` for a composite + /// effect that only aggregates its dependencies. + pub command: Option<String>, + /// The sandbox image the command runs in; `None` uses the default. + pub image: Option<String>, +} + +/// The materialized, ready-to-run inputs [`EffectExecutor::spawn`] hands to +/// a backend: the tree an effect runs against, each activated toolchain's +/// resolved `PATH` entry (keyed by toolchain name), and its cache +/// directory if it has one. Assembling these is "materialization" +/// (`docs/scale-out.adoc` correctness rule 6): manifest lookup, +/// `ObjectStore` read, hash verification, then handed here — the same one +/// code path regardless of which tier answered the read. +#[derive(Debug, Clone)] +pub struct MaterializedInputs { + /// The tree the effect runs against. + pub tree: gix_hash::ObjectId, + /// Each activated toolchain's resolved `PATH` entry, keyed by name. + pub toolchain_paths: std::collections::BTreeMap<String, String>, + /// The effect's cache directory, if it names one. + pub cache_dir: Option<String>, +} + +/// A handle to a spawned effect. Opaque for now — WS7 (`exec-local`, +/// `exec-sprites`) adds the poll/await surface once a real executor backend +/// exists; this trait only needs to name the seam. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectHandle { + /// A backend-chosen opaque identifier for the spawned effect. + pub id: String, +} + +/// Where a [`MaterializedInputs::tree`] actually runs: a sandboxed +/// subprocess today (`exec-local`), a Fly Machine later (`exec-sprites`). +/// Application code (the effect engine) is written once against this +/// trait; which backend answers `spawn` is a deployment detail. +pub trait EffectExecutor: Send + Sync { + /// Spawn `effect` against `inputs`, returning a handle to the running + /// effect. Does not block for completion. + fn spawn(&self, effect: &EffectDef, inputs: MaterializedInputs) -> Result<EffectHandle>; +}
crates/git-backend/src/lib.rs @@ -1,0 +1,60 @@ +//! Backend-agnostic storage traits for git-ents. +//! +//! [`RefStore`], [`ObjectStore`], and [`EffectExecutor`] are the seams the +//! development plan (`docs/scale-out.adoc`, "Storage traits") draws between +//! application logic and where repository state actually lives. Application +//! code is written once, against these traits; a local backend +//! (`refstore-files`, `odb-files`) and a future cloud backend +//! (`refstore-postgres`, `odb-tigris`) both satisfy the same contract, +//! checked by a conformance suite (WS2) rather than assumed. +//! +//! # Why these three +//! +//! - [`RefStore`] is the unit of correctness: every write to repository +//! state is a ref transaction, and multi-ref compare-and-swap is +//! contractual, not optional. +//! - [`ObjectStore`] is deliberately narrower than a full git object +//! database: there is no `write_loose`, because a remote object tier +//! (Tigris) cannot offer one. Objects arrive as packs, staged in +//! quarantine until the ref transaction that makes them reachable +//! commits. +//! - [`EffectExecutor`] is the seam between the effect engine and wherever +//! an effect actually runs (a local sandbox today, a Fly Sprite later). +//! +//! See `docs/scale-out.adoc` for the full rationale, the correctness rules +//! that bind every backend, and the workstream this crate implements (WS1). + +mod effect; +mod object_store; +mod ref_store; + +pub use effect::{EffectDef, EffectExecutor, EffectHandle, MaterializedInputs}; +pub use object_store::{Object, ObjectStore, PackStream, QuarantineId}; +pub use ref_store::{ + Expected, RefEdit, RefEvent, RefEventStream, RefIter, RefLogEntry, RefLogIter, RefName, + RefStore, TxOutcome, +}; + +/// A failure in a [`RefStore`], [`ObjectStore`], or [`EffectExecutor`] +/// implementation. Shared across all three traits so application code +/// handles storage failures uniformly regardless of which seam raised them. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A [`RefStore`] operation failed for a reason other than a + /// compare-and-swap mismatch, which is reported as + /// [`TxOutcome::Rejected`] rather than an `Err`. + #[error("ref store operation failed: {0}")] + RefStore(String), + /// An [`ObjectStore`] operation failed. + #[error("object store operation failed: {0}")] + ObjectStore(String), + /// An [`EffectExecutor`] operation failed. + #[error("effect executor operation failed: {0}")] + Effect(String), + /// An underlying I/O error. + #[error("i/o error: {0}")] + Io(#[from] std::io::Error), +} + +/// This crate's `Result` alias. +pub type Result<T> = std::result::Result<T, Error>;
crates/git-backend/src/object_store.rs @@ -1,0 +1,96 @@ +//! [`ObjectStore`]: content-addressed object storage, staged then promoted. + +use gix_hash::ObjectId; + +use crate::Result; + +/// A single object read back from an [`ObjectStore`]: its kind and its raw, +/// undeltified content. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Object { + /// The object's kind (blob, tree, commit, or tag). + pub kind: gix_object::Kind, + /// The object's raw content. + pub data: Vec<u8>, +} + +/// An incoming pack of objects, not yet indexed or validated, handed to +/// [`ObjectStore::stage_pack`]. Wraps whatever byte source the caller has — +/// a network connection, a file, an in-memory buffer — behind one type so +/// the trait stays object-safe. +pub struct PackStream(Box<dyn std::io::Read + Send>); + +impl PackStream { + /// Wrap `reader` as a [`PackStream`]. + pub fn new(reader: impl std::io::Read + Send + 'static) -> Self { + Self(Box::new(reader)) + } +} + +impl std::io::Read for PackStream { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> { + self.0.read(buf) + } +} + +/// A handle to a pack staged in quarantine by [`ObjectStore::stage_pack`], +/// passed back to [`ObjectStore::promote`] once the ref transaction that +/// makes its objects reachable has committed. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct QuarantineId(String); + +impl QuarantineId { + /// Build a `QuarantineId` from a backend-chosen opaque token. + pub fn new(id: impl Into<String>) -> Self { + Self(id.into()) + } + + /// The id as a `&str`. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for QuarantineId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Content-addressed object storage. Deliberately narrower than a full git +/// object database: there is no `write_loose`, because the tiered remote +/// backend (Tigris) this trait is also meant to describe cannot offer one — +/// small writes route through a small-object tier as ordinary staged +/// writes instead (see `docs/scale-out.adoc`, "ObjectStore"). +/// +/// # Contract +/// +/// - **Staged objects are invisible to reachability walks and GC.** +/// [`stage_pack`](Self::stage_pack) places objects in quarantine; until +/// [`promote`](Self::promote) is called, [`read`](Self::read) and +/// [`contains`](Self::contains) against the promoted view must not see +/// them, and no reachability walk or collection may visit them either. +/// - **Ref transactions are the only commit point.** An object becomes +/// reachable only once the ref transaction pointing at it (or at +/// something that reaches it) has committed — `promote` makes objects +/// visible, it does not itself make them reachable. +pub trait ObjectStore: Send + Sync { + /// Read the object `id`, erroring if it is not present in the promoted + /// (non-quarantined) store. + fn read(&self, id: ObjectId) -> Result<Object>; + + /// Whether `id` is present in the promoted (non-quarantined) store. + fn contains(&self, id: ObjectId) -> Result<bool>; + + /// Index `pack` into quarantine, invisible to [`read`](Self::read) and + /// [`contains`](Self::contains) until [`promote`](Self::promote) is + /// called on the returned id. + fn stage_pack(&self, pack: PackStream) -> Result<QuarantineId>; + + /// Make the pack staged under `q` visible to [`read`](Self::read) and + /// [`contains`](Self::contains). Callers must not call this before the + /// ref transaction that makes the pack's objects reachable has + /// committed — see the trait's contract above. + fn promote(&self, q: QuarantineId) -> Result<()>; +}
crates/git-backend/src/ref_store.rs @@ -1,0 +1,225 @@ +//! [`RefStore`]: the unit of correctness for repository state. + +use gix_hash::ObjectId; + +use crate::Result; + +/// A full ref name (`refs/heads/main`) or a ref-namespace prefix +/// (`refs/meta/`), used with [`RefStore::iter_prefix`] and +/// [`RefStore::watch`]. Backend-agnostic: it carries no assumption about +/// whether the underlying store is gitoxide loose refs, a Postgres row, or +/// anything else. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RefName(String); + +impl RefName { + /// Build a `RefName` from any owned-or-borrowed string. + pub fn new(name: impl Into<String>) -> Self { + Self(name.into()) + } + + /// The ref name as a `&str`. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl From<&str> for RefName { + fn from(name: &str) -> Self { + Self::new(name) + } +} + +impl From<String> for RefName { + fn from(name: String) -> Self { + Self::new(name) + } +} + +impl AsRef<str> for RefName { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl std::fmt::Display for RefName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// The compare-and-swap precondition a [`RefEdit`] requires of a ref's +/// current value before the edit is allowed to apply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Expected { + /// No requirement: set unconditionally. + Any, + /// The ref must not currently exist. + MustNotExist, + /// The ref must currently exist and equal the given [`ObjectId`]. + MustExistAndMatch(ObjectId), +} + +/// One ref's half of a [`RefStore::transaction`] batch: what `name` is +/// expected to hold, and what it should become. `new: None` deletes the +/// ref. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefEdit { + /// The ref this edit applies to. + pub name: RefName, + /// The compare-and-swap precondition checked against `name`'s current + /// value before the edit applies. + pub expected: Expected, + /// The value to set `name` to, or `None` to delete it. + pub new: Option<ObjectId>, +} + +/// The result of a [`RefStore::transaction`] call that itself completed +/// (returned `Ok`): either every edit applied, or none did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TxOutcome { + /// Every edit in the batch applied atomically. + Applied, + /// The transaction did not apply: `name`'s current value did not match + /// its edit's [`Expected`] precondition. No edit in the batch took + /// effect — compare-and-swap is all-or-nothing, per the trait's + /// contract. + Rejected { + /// The first ref whose precondition failed. + name: RefName, + }, +} + +/// An iterator over `(name, tip)` pairs from a [`RefStore::iter_prefix`] +/// query, wrapping whatever iterator the backend produces so the trait +/// itself stays object-safe. +pub struct RefIter(Box<dyn Iterator<Item = Result<(RefName, ObjectId)>> + Send>); + +impl RefIter { + /// Wrap `iter` as a [`RefIter`]. + pub fn new(iter: impl Iterator<Item = Result<(RefName, ObjectId)>> + Send + 'static) -> Self { + Self(Box::new(iter)) + } +} + +impl Iterator for RefIter { + type Item = Result<(RefName, ObjectId)>; + + fn next(&mut self) -> Option<Self::Item> { + self.0.next() + } +} + +/// One entry in a ref's log: the value it moved from and to, the message +/// recorded with the change, and when it happened. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefLogEntry { + /// The ref's value before this entry, or `None` when the ref was + /// created by it. + pub old: Option<ObjectId>, + /// The ref's value after this entry, or `None` when the ref was + /// deleted by it. + pub new: Option<ObjectId>, + /// The message recorded with the change. + pub message: String, + /// When the change happened, in seconds since the epoch. + pub seconds: u64, +} + +/// An iterator over a ref's [`RefLogEntry`] history, most recent first. +pub struct RefLogIter(Box<dyn Iterator<Item = Result<RefLogEntry>> + Send>); + +impl RefLogIter { + /// Wrap `iter` as a [`RefLogIter`]. + pub fn new(iter: impl Iterator<Item = Result<RefLogEntry>> + Send + 'static) -> Self { + Self(Box::new(iter)) + } +} + +impl Iterator for RefLogIter { + type Item = Result<RefLogEntry>; + + fn next(&mut self) -> Option<Self::Item> { + self.0.next() + } +} + +/// A wakeup hint delivered by a [`RefEventStream`]. Carries no payload: per +/// [`RefStore::watch`]'s contract, a consumer never trusts the event's +/// content, only that *something* changed under the watched prefix, and +/// re-drains its own source of truth (a queue table, a fresh +/// [`RefStore::iter_prefix`]) in response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RefEvent; + +/// A best-effort stream of [`RefEvent`] wakeup hints from +/// [`RefStore::watch`]. Delivery is not guaranteed: a hint can be delayed, +/// coalesced, or dropped entirely (e.g. across a reconnect). Every consumer +/// must therefore drain its own durable state on every wakeup *and* on +/// reconnect, never relying on this stream to have delivered exactly one +/// event per change. +pub struct RefEventStream { + receiver: std::sync::mpsc::Receiver<RefEvent>, +} + +impl RefEventStream { + /// Wrap `receiver` as a [`RefEventStream`]. + #[must_use] + pub fn new(receiver: std::sync::mpsc::Receiver<RefEvent>) -> Self { + Self { receiver } + } + + /// Block until the next wakeup hint, or `None` once the backend's + /// watcher has shut down. + pub fn recv(&self) -> Option<RefEvent> { + self.receiver.recv().ok() + } + + /// Block for up to `timeout` for the next wakeup hint. + pub fn recv_timeout(&self, timeout: std::time::Duration) -> Option<RefEvent> { + self.receiver.recv_timeout(timeout).ok() + } +} + +/// The unit of correctness for repository state: a store of named refs, +/// each pointing at an [`ObjectId`], updated only through atomic +/// transactions. +/// +/// # Contract +/// +/// - **Multi-ref compare-and-swap is contractual, not a capability query.** +/// A backend that cannot apply an arbitrary batch of [`RefEdit`]s +/// atomically — every precondition checked against one consistent view, +/// and either every edit applies or none do — does not satisfy this +/// trait, full stop. +/// - **`watch` is a best-effort wakeup hint, never a source of truth.** The +/// effect queue table (or equivalent durable state) is what carries the +/// at-least-once guarantee; a consumer must drain it on every wakeup and +/// on reconnect, not trust that one hint means exactly one change. +/// - **`log` is the ref's own history**, independent of the store's queue — +/// an audit trail, not a delivery mechanism. +pub trait RefStore: Send + Sync { + /// The object id `name` currently points at, or `None` if `name` does + /// not exist. + fn get(&self, name: &RefName) -> Result<Option<ObjectId>>; + + /// Every ref under `prefix`, with its current tip. + fn iter_prefix(&self, prefix: &RefName) -> Result<RefIter>; + + /// Apply `edits` as one atomic compare-and-swap transaction: every + /// edit's [`Expected`] precondition is checked against the same + /// consistent view of the store, and either every edit applies or none + /// do. See the trait's contract above — this is not optional behavior a + /// backend may approximate. + fn transaction(&self, edits: &[RefEdit]) -> Result<TxOutcome>; + + /// Subscribe to a best-effort wakeup hint whenever a ref under `prefix` + /// changes. See the trait's contract above: delivery is not + /// guaranteed, and no consumer may treat this stream as a source of + /// truth. + fn watch(&self, prefix: &RefName) -> Result<RefEventStream>; + + /// `name`'s history, most recent entry first. + fn log(&self, name: &RefName) -> Result<RefLogIter>; +}
crates/odb-files/Cargo.toml @@ -1,0 +1,20 @@ +[package] +name = "odb-files" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +git-backend = { workspace = true } +gix = { workspace = true } +gix-hash = { workspace = true } +gix-pack = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +git-store = { workspace = true, features = ["test-support"] } +gix-object = { workspace = true } + +[lints] +workspace = true
crates/odb-files/src/lib.rs @@ -1,0 +1,217 @@ +//! [`ObjectStore`] over the gitoxide object database on `objects/` +//! (including alternates) — the local default backend +//! (`docs/scale-out.adoc`, "ObjectStore"). +//! +//! Quarantine mirrors receive-pack's own mechanism: [`OdbFiles::stage_pack`] +//! indexes an incoming pack into a scratch directory under `objects/` that +//! the main object database never scans (it only looks in +//! `objects/pack/`), so staged objects are invisible to `read`/`contains` — +//! and therefore to any reachability walk or GC built on them — until +//! [`OdbFiles::promote`] moves the pack into `objects/pack/` proper. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use git_backend::{Error, Object, ObjectStore, PackStream, QuarantineId, Result}; +use gix::objs::{Exists as _, FindExt as _}; +use gix_hash::ObjectId; + +/// A pack staged by [`OdbFiles::stage_pack`], not yet promoted: the +/// quarantine directory holding it, and the paths +/// `gix_pack::Bundle::write_to_directory` wrote within it. +struct Quarantine { + dir: PathBuf, + data_path: Option<PathBuf>, + index_path: Option<PathBuf>, + keep_path: Option<PathBuf>, +} + +/// [`ObjectStore`] over the gitoxide object database on a repository's +/// `objects/` directory. +/// +/// The [`gix::odb::Handle`] is held behind a [`Mutex`] rather than as a bare +/// field: its decode caches use interior mutability that isn't `Sync`, while +/// [`ObjectStore`] must be — application code holds a backend behind an +/// `Arc` and shares it across threads. +pub struct OdbFiles { + objects_dir: PathBuf, + odb: Mutex<gix::odb::Handle>, + quarantines: Mutex<HashMap<QuarantineId, Quarantine>>, +} + +impl OdbFiles { + /// Open the object store for the repository at `path`. + pub fn open(path: &Path) -> Result<Self> { + let repo = gix::open(path).map_err(|error| Error::ObjectStore(error.to_string()))?; + let objects_dir = repo.common_dir().join("objects"); + let odb = + gix::odb::at(&objects_dir).map_err(|error| Error::ObjectStore(error.to_string()))?; + Ok(Self { + objects_dir, + odb: Mutex::new(odb), + quarantines: Mutex::new(HashMap::new()), + }) + } +} + +impl ObjectStore for OdbFiles { + fn read(&self, id: ObjectId) -> Result<Object> { + let mut buf = Vec::new(); + let data = lock(&self.odb) + .find(&id, &mut buf) + .map_err(|error| Error::ObjectStore(error.to_string()))?; + Ok(Object { + kind: data.kind, + data: data.data.to_vec(), + }) + } + + fn contains(&self, id: ObjectId) -> Result<bool> { + Ok(lock(&self.odb).exists(&id)) + } + + fn stage_pack(&self, pack: PackStream) -> Result<QuarantineId> { + let id = QuarantineId::new(uuid::Uuid::new_v4().to_string()); + let dir = self.objects_dir.join("quarantine").join(id.as_str()); + std::fs::create_dir_all(&dir)?; + + let mut reader = std::io::BufReader::new(pack); + let outcome = gix_pack::Bundle::write_to_directory( + &mut reader, + Some(&dir), + &mut gix::progress::Discard, + &AtomicBool::new(false), + None::<gix::odb::Handle>, + gix_pack::bundle::write::Options { + object_hash: gix_hash::Kind::Sha1, + ..Default::default() + }, + ) + .map_err(|error| Error::ObjectStore(error.to_string()))?; + + lock(&self.quarantines).insert( + id.clone(), + Quarantine { + dir, + data_path: outcome.data_path, + index_path: outcome.index_path, + keep_path: outcome.keep_path, + }, + ); + Ok(id) + } + + fn promote(&self, q: QuarantineId) -> Result<()> { + let quarantine = lock(&self.quarantines) + .remove(&q) + .ok_or_else(|| Error::ObjectStore(format!("unknown quarantine {q}")))?; + let pack_dir = self.objects_dir.join("pack"); + std::fs::create_dir_all(&pack_dir)?; + if let Some(data_path) = &quarantine.data_path { + move_into(data_path, &pack_dir)?; + } + if let Some(index_path) = &quarantine.index_path { + move_into(index_path, &pack_dir)?; + } + if let Some(keep_path) = &quarantine.keep_path { + let _removed = std::fs::remove_file(keep_path); + } + let _removed = std::fs::remove_dir_all(&quarantine.dir); + Ok(()) + } +} + +/// Move the file at `path` into `dest_dir`, keeping its file name. +fn move_into(path: &Path, dest_dir: &Path) -> Result<()> { + let name = path + .file_name() + .ok_or_else(|| Error::ObjectStore(format!("{path:?} has no file name")))?; + std::fs::rename(path, dest_dir.join(name))?; + Ok(()) +} + +/// Lock `mutex`, recovering the guard from a poisoned lock rather than +/// panicking — quarantine bookkeeping is not worth tearing the process +/// down over if an earlier panic poisoned it. +fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::let_underscore_must_use, + reason = "unit test" + )] + + use std::process::{Command, Stdio}; + + use git_backend::{ObjectStore as _, PackStream}; + use git_store::test_support::{commit_all, head, repo}; + + use super::OdbFiles; + + /// A real pack containing `commit` and everything it reaches, built by + /// shelling out to `git rev-list`/`git pack-objects` against `dir` — the + /// same mechanism a real push transmits, so `stage_pack` is exercised + /// against pack bytes gitoxide's indexer actually has to parse. + fn pack_for(dir: &std::path::Path, commit: &str) -> Vec<u8> { + let mut rev_list = Command::new("git") + .arg("-C") + .arg(dir) + .args(["rev-list", "--objects", commit]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let pack_objects = Command::new("git") + .arg("-C") + .arg(dir) + .args(["pack-objects", "--stdout", "-q"]) + .stdin(rev_list.stdout.take().unwrap()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let output = pack_objects.wait_with_output().unwrap(); + assert!(rev_list.wait().unwrap().success()); + assert!(output.status.success()); + output.stdout + } + + #[test] + fn contains_is_false_for_an_object_the_store_never_saw() { + let dir = repo(); + let store = OdbFiles::open(dir.path()).unwrap(); + let missing = gix_hash::ObjectId::null(gix_hash::Kind::Sha1); + assert!(!store.contains(missing).unwrap()); + } + + #[test] + fn staged_objects_are_invisible_until_promoted() { + let source = repo(); + std::fs::write(source.path().join("file"), b"content").unwrap(); + commit_all(source.path(), "first"); + let commit_hex = head(source.path()); + let commit = gix_hash::ObjectId::from_hex(commit_hex.as_bytes()).unwrap(); + let pack_bytes = pack_for(source.path(), &commit_hex); + + // A separate, empty destination repository: the pack's objects + // exist nowhere in it yet. + let dest = repo(); + let store = OdbFiles::open(dest.path()).unwrap(); + assert!(!store.contains(commit).unwrap()); + + let quarantine = store + .stage_pack(PackStream::new(std::io::Cursor::new(pack_bytes))) + .unwrap(); + // Staged, not promoted: still invisible. + assert!(!store.contains(commit).unwrap()); + + store.promote(quarantine).unwrap(); + assert!(store.contains(commit).unwrap()); + let object = store.read(commit).unwrap(); + assert_eq!(object.kind, gix_object::Kind::Commit); + } +}
crates/refstore-files/Cargo.toml @@ -1,0 +1,17 @@ +[package] +name = "refstore-files" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +git-backend = { workspace = true } +gix = { workspace = true } +gix-hash = { workspace = true } + +[dev-dependencies] +git-store = { workspace = true, features = ["test-support"] } + +[lints] +workspace = true
crates/refstore-files/src/lib.rs @@ -1,0 +1,400 @@ +//! [`RefStore`] over gitoxide loose refs and packed-refs — the local +//! default backend (`docs/scale-out.adoc`, "RefStore"). +//! +//! Atomic multi-ref compare-and-swap is gitoxide's own ref transaction +//! (`Repository::edit_references`): every edit's precondition is checked +//! against the store's locked-in-place state, and either the whole batch +//! applies or none of it does. `log` reads the reflog gitoxide already +//! writes; `watch` is a minimal best-effort poller (see [`watch`]), since +//! local loose refs have no push notification channel to hook into. + +mod watch; + +use std::path::Path; +use std::sync::{Mutex, PoisonError}; + +use git_backend::{ + Error, Expected, RefEdit as BackendRefEdit, RefEventStream, RefIter, RefLogEntry, RefLogIter, + RefName, RefStore, Result, TxOutcome, +}; +use gix::refs::FullName; +use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit as GixRefEdit, RefLog}; +use gix_hash::ObjectId; + +/// The message every ref transaction's reflog entry carries. Fixed, like +/// `git_store`'s commit identity, so a write through this backend is +/// self-contained. +const LOG_MESSAGE: &str = "git-backend: transaction"; + +/// [`RefStore`] over a gitoxide repository's loose refs and packed-refs. +/// +/// The [`gix::Repository`] handle is held behind a [`Mutex`] rather than as +/// a bare field: its internal object-access caches use interior mutability +/// that isn't `Sync`, while [`RefStore`] must be — application code holds a +/// backend behind an `Arc` and shares it across threads. +pub struct FilesRefStore { + repo: Mutex<gix::Repository>, +} + +impl FilesRefStore { + /// Open the ref store for the repository at `path`. + pub fn open(path: &Path) -> Result<Self> { + let repo = gix::open(path).map_err(|error| Error::RefStore(error.to_string()))?; + Ok(Self { + repo: Mutex::new(repo), + }) + } + + /// Lock the underlying repository handle, recovering from a poisoned + /// lock rather than panicking. + fn repo(&self) -> std::sync::MutexGuard<'_, gix::Repository> { + self.repo.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl RefStore for FilesRefStore { + fn get(&self, name: &RefName) -> Result<Option<ObjectId>> { + match self + .repo() + .try_find_reference(name.as_str()) + .map_err(|error| Error::RefStore(error.to_string()))? + { + Some(mut reference) => { + let id = reference + .peel_to_id() + .map_err(|error| Error::RefStore(error.to_string()))?; + Ok(Some(id.detach())) + } + None => Ok(None), + } + } + + fn iter_prefix(&self, prefix: &RefName) -> Result<RefIter> { + let repo = self.repo(); + let platform = repo + .references() + .map_err(|error| Error::RefStore(error.to_string()))?; + let iter = platform + .prefixed(prefix.as_str()) + .map_err(|error| Error::RefStore(error.to_string()))?; + let mut out = Vec::new(); + for reference in iter { + let mut reference = reference.map_err(|error| Error::RefStore(error.to_string()))?; + let name = RefName::new(reference.name().as_bstr().to_string()); + let oid = reference + .peel_to_id() + .map_err(|error| Error::RefStore(error.to_string()))? + .detach(); + out.push(Ok((name, oid))); + } + Ok(RefIter::new(out.into_iter())) + } + + fn transaction(&self, edits: &[BackendRefEdit]) -> Result<TxOutcome> { + let mut gix_edits = Vec::with_capacity(edits.len()); + for edit in edits { + gix_edits.push(to_gix_edit(edit)?); + } + match self.repo().edit_references(gix_edits) { + Ok(_applied) => Ok(TxOutcome::Applied), + Err(error) => match rejected_name(&error) { + Some(name) => Ok(TxOutcome::Rejected { name }), + None => Err(Error::RefStore(error.to_string())), + }, + } + } + + fn watch(&self, prefix: &RefName) -> Result<RefEventStream> { + watch::spawn( + self.repo().git_dir().to_path_buf(), + prefix.as_str().to_owned(), + ) + } + + fn log(&self, name: &RefName) -> Result<RefLogIter> { + let repo = self.repo(); + let Some(reference) = repo + .try_find_reference(name.as_str()) + .map_err(|error| Error::RefStore(error.to_string()))? + else { + return Ok(RefLogIter::new(std::iter::empty())); + }; + let mut platform = reference.log_iter(); + let mut entries = Vec::new(); + if let Some(iter) = platform + .all() + .map_err(|error| Error::RefStore(error.to_string()))? + { + for line in iter { + let line = line.map_err(|error| Error::RefStore(error.to_string()))?; + let old = line.previous_oid(); + let new = line.new_oid(); + let seconds = line + .signature + .time() + .map_err(|error| Error::RefStore(error.to_string()))? + .seconds; + entries.push(RefLogEntry { + old: (!old.is_null()).then_some(old), + new: (!new.is_null()).then_some(new), + message: line.message.to_string(), + seconds: u64::try_from(seconds).unwrap_or(0), + }); + } + } + // The forward iterator yields oldest first; the trait promises + // most-recent-first. + entries.reverse(); + Ok(RefLogIter::new(entries.into_iter().map(Ok))) + } +} + +/// Convert one backend-agnostic [`BackendRefEdit`] into gitoxide's own +/// transaction edit type. +fn to_gix_edit(edit: &BackendRefEdit) -> Result<GixRefEdit> { + let name: FullName = + edit.name + .as_str() + .try_into() + .map_err(|error: gix::validate::reference::name::Error| { + Error::RefStore(error.to_string()) + })?; + let change = match edit.new { + Some(oid) => Change::Update { + log: LogChange { + mode: RefLog::AndReference, + force_create_reflog: false, + message: LOG_MESSAGE.into(), + }, + expected: to_previous_value(&edit.expected), + new: gix::refs::Target::Object(oid), + }, + None => Change::Delete { + expected: to_previous_value(&edit.expected), + log: RefLog::AndReference, + }, + }; + Ok(GixRefEdit { + change, + name, + deref: false, + }) +} + +/// Map a backend-agnostic [`Expected`] precondition onto gitoxide's own +/// [`PreviousValue`]. +fn to_previous_value(expected: &Expected) -> PreviousValue { + match expected { + Expected::Any => PreviousValue::Any, + Expected::MustNotExist => PreviousValue::MustNotExist, + Expected::MustExistAndMatch(oid) => { + PreviousValue::MustExistAndMatch(gix::refs::Target::Object(*oid)) + } + } +} + +/// The ref name a rejected transaction's compare-and-swap precondition +/// failed on, or `None` when `error` is not a CAS mismatch (some other +/// failure — a lock timeout, an I/O error — that should propagate as +/// `Err`, not `Ok(TxOutcome::Rejected)`). +fn rejected_name(error: &gix::reference::edit::Error) -> Option<RefName> { + let gix::reference::edit::Error::FileTransactionPrepare(prepare_error) = error else { + return None; + }; + use gix::refs::file::transaction::prepare::Error as PrepareError; + let full_name = match prepare_error { + PrepareError::MustNotExist { full_name, .. } + | PrepareError::MustExist { full_name, .. } + | PrepareError::ReferenceOutOfDate { full_name, .. } + | PrepareError::DeleteReferenceMustExist { full_name, .. } => full_name, + _ => return None, + }; + Some(RefName::new(full_name.to_string())) +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::let_underscore_must_use, + reason = "unit test" + )] + + use git_backend::{Expected, RefEdit, RefName, RefStore as _, TxOutcome}; + use git_store::test_support::{commit_all, head, repo}; + + use super::FilesRefStore; + + /// The branch `HEAD` points at right after `git init`, whatever + /// `init.defaultBranch` resolves to in this environment (`main`, + /// `master`, ...). + fn current_branch_ref(dir: &std::path::Path) -> String { + let output = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(["symbolic-ref", "HEAD"]) + .output() + .unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } + + #[test] + fn get_returns_none_for_an_absent_ref() { + let dir = repo(); + let store = FilesRefStore::open(dir.path()).unwrap(); + assert_eq!(store.get(&RefName::new("refs/heads/nope")).unwrap(), None); + } + + #[test] + fn get_resolves_an_existing_ref() { + let dir = repo(); + std::fs::write(dir.path().join("file"), b"content").unwrap(); + commit_all(dir.path(), "first"); + let store = FilesRefStore::open(dir.path()).unwrap(); + let expected = gix_hash::ObjectId::from_hex(head(dir.path()).as_bytes()).unwrap(); + let branch_ref = current_branch_ref(dir.path()); + assert_eq!( + store.get(&RefName::new(branch_ref)).unwrap(), + Some(expected) + ); + } + + #[test] + fn transaction_creates_a_ref_then_rejects_a_stale_cas() { + let dir = repo(); + std::fs::write(dir.path().join("file"), b"content").unwrap(); + commit_all(dir.path(), "first"); + let store = FilesRefStore::open(dir.path()).unwrap(); + let first_commit = gix_hash::ObjectId::from_hex(head(dir.path()).as_bytes()).unwrap(); + + let create = RefEdit { + name: RefName::new("refs/heads/topic"), + expected: Expected::MustNotExist, + new: Some(first_commit), + }; + assert_eq!(store.transaction(&[create]).unwrap(), TxOutcome::Applied); + + // A second, different commit than the ref's current value. + std::fs::write(dir.path().join("file"), b"more content").unwrap(); + commit_all(dir.path(), "second"); + let second_commit = gix_hash::ObjectId::from_hex(head(dir.path()).as_bytes()).unwrap(); + assert_ne!(first_commit, second_commit); + + // Re-asserting must-not-exist while pointing the ref at a different + // value than what's already there is a CAS mismatch, reported as + // `Rejected`, not an `Err`. + let recreate = RefEdit { + name: RefName::new("refs/heads/topic"), + expected: Expected::MustNotExist, + new: Some(second_commit), + }; + let outcome = store.transaction(&[recreate]).unwrap(); + assert_eq!( + outcome, + TxOutcome::Rejected { + name: RefName::new("refs/heads/topic") + } + ); + } + + #[test] + fn transaction_is_all_or_nothing_across_multiple_edits() { + let dir = repo(); + std::fs::write(dir.path().join("file"), b"content").unwrap(); + commit_all(dir.path(), "first"); + let store = FilesRefStore::open(dir.path()).unwrap(); + let commit = gix_hash::ObjectId::from_hex(head(dir.path()).as_bytes()).unwrap(); + + // The second edit's precondition already fails (the ref doesn't + // exist yet), so neither edit should apply. + let edits = [ + RefEdit { + name: RefName::new("refs/heads/a"), + expected: Expected::MustNotExist, + new: Some(commit), + }, + RefEdit { + name: RefName::new("refs/heads/b"), + expected: Expected::MustExistAndMatch(commit), + new: Some(commit), + }, + ]; + let outcome = store.transaction(&edits).unwrap(); + assert!(matches!(outcome, TxOutcome::Rejected { .. })); + assert_eq!(store.get(&RefName::new("refs/heads/a")).unwrap(), None); + } + + #[test] + fn iter_prefix_lists_matching_refs() { + let dir = repo(); + std::fs::write(dir.path().join("file"), b"content").unwrap(); + commit_all(dir.path(), "first"); + let store = FilesRefStore::open(dir.path()).unwrap(); + let commit = gix_hash::ObjectId::from_hex(head(dir.path()).as_bytes()).unwrap(); + store + .transaction(&[RefEdit { + name: RefName::new("refs/meta/thing"), + expected: Expected::MustNotExist, + new: Some(commit), + }]) + .unwrap(); + + let names: Vec<String> = store + .iter_prefix(&RefName::new("refs/meta/")) + .unwrap() + .map(|item| item.unwrap().0.as_str().to_owned()) + .collect(); + assert_eq!(names, vec!["refs/meta/thing".to_owned()]); + } + + #[test] + fn log_reads_back_the_transaction_message() { + let dir = repo(); + std::fs::write(dir.path().join("file"), b"content").unwrap(); + commit_all(dir.path(), "first"); + let store = FilesRefStore::open(dir.path()).unwrap(); + let commit = gix_hash::ObjectId::from_hex(head(dir.path()).as_bytes()).unwrap(); + let name = RefName::new("refs/heads/logged"); + store + .transaction(&[RefEdit { + name: name.clone(), + expected: Expected::MustNotExist, + new: Some(commit), + }]) + .unwrap(); + + let entries: Vec<_> = store.log(&name).unwrap().map(Result::unwrap).collect(); + assert_eq!(entries.len(), 1); + let entry = entries.first().unwrap(); + assert_eq!(entry.new, Some(commit)); + assert_eq!(entry.old, None); + } + + #[test] + fn watch_wakes_up_on_a_ref_change() { + let dir = repo(); + std::fs::write(dir.path().join("file"), b"content").unwrap(); + commit_all(dir.path(), "first"); + let store = FilesRefStore::open(dir.path()).unwrap(); + let commit = gix_hash::ObjectId::from_hex(head(dir.path()).as_bytes()).unwrap(); + + let watcher = store.watch(&RefName::new("refs/")).unwrap(); + // Give the poller time to take its first fingerprint before the + // write below, so the write is guaranteed to land after it. + std::thread::sleep(std::time::Duration::from_millis(200)); + store + .transaction(&[RefEdit { + name: RefName::new("refs/heads/watched"), + expected: Expected::MustNotExist, + new: Some(commit), + }]) + .unwrap(); + assert!( + watcher + .recv_timeout(std::time::Duration::from_secs(5)) + .is_some(), + "expected a wakeup hint after a ref change" + ); + } +}
crates/refstore-files/src/watch.rs @@ -1,0 +1,92 @@ +//! A minimal best-effort [`RefEventStream`] source: a background thread +//! that polls the ref namespace's on-disk footprint and sends a wakeup +//! hint on change. Local loose refs have no push notification channel to +//! hook into, so polling is the whole mechanism — acceptable per `watch`'s +//! contract, which only promises a hint, never delivery. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use git_backend::{Error, RefEvent, RefEventStream, Result}; + +/// How often the background thread re-checks the watched prefix's on-disk +/// footprint. +const POLL_INTERVAL: Duration = Duration::from_millis(500); + +/// Start polling `git_dir` for changes under `prefix` and return the +/// [`RefEventStream`] that receives a hint on every detected change. The +/// background thread exits on its own once the stream (and its sender) is +/// dropped. +pub fn spawn(git_dir: PathBuf, prefix: String) -> Result<RefEventStream> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("refstore-files-watch".to_owned()) + .spawn(move || poll_loop(&git_dir, &prefix, &tx)) + .map_err(Error::Io)?; + Ok(RefEventStream::new(rx)) +} + +/// Loop until the receiving end of `tx` is dropped, sending a [`RefEvent`] +/// whenever `fingerprint` changes. +fn poll_loop(git_dir: &Path, prefix: &str, tx: &std::sync::mpsc::Sender<RefEvent>) { + let mut last = fingerprint(git_dir, prefix); + loop { + std::thread::sleep(POLL_INTERVAL); + let current = fingerprint(git_dir, prefix); + if current != last { + last = current; + if tx.send(RefEvent).is_err() { + return; + } + } + } +} + +/// A cheap, approximate signature of every ref under `prefix`: the newest +/// modification time and the count of files considered, across both loose +/// refs and `packed-refs`. Good enough for a wakeup hint — an exact match +/// is not the contract, only "something changed since last time". +fn fingerprint(git_dir: &Path, prefix: &str) -> (Option<SystemTime>, u64) { + let mut newest = None; + let mut count: u64 = 0; + { + let mut visit = |path: &Path| { + let Ok(metadata) = std::fs::metadata(path) else { + return; + }; + let Ok(modified) = metadata.modified() else { + return; + }; + count = count.saturating_add(1); + if newest.is_none_or(|previous| modified > previous) { + newest = Some(modified); + } + }; + visit(&git_dir.join("packed-refs")); + walk(&git_dir.join("refs"), prefix, git_dir, &mut visit); + } + (newest, count) +} + +/// Recursively visit every regular file under `dir`, calling `visit` on +/// those whose path relative to `git_dir` starts with `prefix` — the loose +/// ref files a change under `prefix` would touch. +fn walk(dir: &Path, prefix: &str, git_dir: &Path, visit: &mut impl FnMut(&Path)) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() { + walk(&path, prefix, git_dir, visit); + } else if file_type.is_file() { + let relative = path.strip_prefix(git_dir).unwrap_or(&path); + if relative.to_string_lossy().starts_with(prefix) { + visit(&path); + } + } + } +}