feat: add git-backend storage traits and file-based backend crates
commit
4d74afafeat: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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);
+ }
+ }
+ }
+}