Phase 1 per docs/development-plan.adoc. Implements arch.refstore-read-cas-split
(RefStoreRead vs RefStore, so the gate can depend on reads only) and
arch.loose-cas-discipline (LooseRefStore writes through gitoxide’s own
in-process ref transaction, never a git update-ref subprocess) from
overview.sdoc.
The CAS conformance suite (tests/conformance.rs) that stands in for the
Phase 1 → 2 gate exposed a real gap in the pinned gitoxide version: the
file-transaction precondition check reads a ref’s current value before
acquiring that ref’s own lock file, so two independent gix::Repository
handles racing the same ref (two processes, concretely) can both observe
the same stale precondition and both "win" a create or update. Closing
that is exactly what "its own compare-and-swap discipline" asks for, so
LooseRefStore::transaction now holds a store-level lock (a .lock file
distinct from any ref’s own, acquired via gix-lock) around the whole
read-check-write sequence before ever calling into gitoxide, rather than
trusting gitoxide’s internal ordering alone. Costs cross-ref concurrency
(one lock serializes all refs in a repository) in exchange for actual
correctness; a per-ref-set lock is a straightforward follow-up if the
cost matters in practice.
facet-git-tree needed no phase-1 work: it already lives as an external
crate (git dependency on github.com/git-ents/facet-git-tree), the
#[facet(transparent)] fix already landed upstream (a910057), and the
workspace Cargo.toml already has no local [patch] section to remove —
verified rather than redone.
config/nextest.toml’s docker test-group filter named four pre-redo
crates (refstore-postgres, effect-dispatcher, git-ents-server,
git-effect) that don’t exist yet in this workspace; a filter naming an
absent package is a hard nextest config error, so it’s trimmed back to
just the group definition until those crates land in their own phases.
.config/nextest.toml
@@ -5,15 +5,12 @@
# backend) race and flake when several test binaries hit the Docker daemon
# at once at default parallelism. Serialize exactly those tests through
# one test group; everything else keeps full parallelism.
+#
+# The `docker` group and its filter are reinstated as the crates that need
+# it (refstore-postgres, effect-dispatcher, git-ents-server, git-effect)
+# land in their own phases; none exist yet in the post-redo workspace, and
+# a filter naming an absent package is a hard nextest config error, not a
+# no-op.
[test-groups]
docker = { max-threads = 1 }
-
-[[profile.default.overrides]]
-filter = '''
-package(refstore-postgres)
-| package(effect-dispatcher)
-| (package(git-ents-server) & binary(hydrate))
-| (package(git-effect) & test(docker_backend_runs_a_trivial_effect))
-'''
-test-group = "docker"
crates/gix-ref-store/src/edit.rs
@@ -1,0 +1,95 @@
+//! The vocabulary of a [`crate::RefStore::transaction`] call: what a
+//! [`RefEdit`] expects a ref to hold, what a batch of them can do
+//! atomically, and how the store reports which one failed.
+
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+
+/// 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 object id.
+ MustExistAndMatch(ObjectId),
+}
+
+/// One ref's half of a [`crate::RefStore::transaction`] batch: what `name`
+/// is expected to hold, and what it should become. `new: None` deletes the
+/// ref.
+///
+/// # Examples
+///
+/// ```
+/// use gix_hash::ObjectId;
+/// use gix_ref_store::{Expected, RefEdit};
+///
+/// let oid = ObjectId::null(gix_hash::Kind::Sha1);
+/// let edit = RefEdit {
+/// name: "refs/meta/config".try_into().expect("valid refname"),
+/// expected: Expected::MustNotExist,
+/// new: Some(oid),
+/// };
+/// assert_eq!(edit.new, Some(oid));
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RefEdit {
+ /// The ref this edit applies to.
+ pub name: FullName,
+ /// 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 [`crate::RefStore::transaction`] call that itself
+/// completed (returned `Ok`): either every edit applied, or none did.
+///
+/// A `Rejected` outcome is not an [`crate::Error`] — a stale
+/// compare-and-swap precondition is an expected, checkable result, not a
+/// backend fault.
+#[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: FullName,
+ },
+}
+
+/// An iterator over `(name, tip)` pairs from a
+/// [`crate::RefStoreRead::iter_prefix`] query, wrapping whatever iterator
+/// the backend produces so the trait itself stays object-safe.
+pub struct RefIter(Box<dyn Iterator<Item = crate::Result<(FullName, ObjectId)>> + Send>);
+
+impl RefIter {
+ /// Wrap `iter` as a [`RefIter`].
+ pub fn new(
+ iter: impl Iterator<Item = crate::Result<(FullName, ObjectId)>> + Send + 'static,
+ ) -> Self {
+ Self(Box::new(iter))
+ }
+}
+
+impl Iterator for RefIter {
+ type Item = crate::Result<(FullName, ObjectId)>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ self.0.next()
+ }
+}
+
+impl std::fmt::Debug for RefIter {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("RefIter(..)")
+ }
+}
crates/gix-ref-store/src/error.rs
@@ -1,0 +1,58 @@
+//! The error type every `gix-ref-store` operation returns.
+
+use std::path::PathBuf;
+
+/// Everything that can go wrong reading or writing through a [`crate::RefStore`].
+///
+/// Every variant is a backend I/O or protocol failure; a *rejected*
+/// compare-and-swap is not an error at all, since a stale precondition is
+/// an expected outcome, not a fault. See [`crate::TxOutcome::Rejected`].
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// Opening the on-disk repository the store reads and writes through
+ /// failed. The caller should check that `path` names a git repository
+ /// (or its `.git` directory) and that the process has permission to
+ /// read it.
+ #[error("failed to open the repository at {path}: {source}")]
+ Open {
+ /// The path that was passed to [`crate::LooseRefStore::open`].
+ path: PathBuf,
+ /// The underlying gitoxide error.
+ #[source]
+ source: Box<gix::open::Error>,
+ },
+
+ /// A refname string failed gitoxide's own validation (for example, it
+ /// contained a `..` component or a disallowed character). The caller
+ /// should reject the name before offering it to a [`crate::RefStore`].
+ #[error("invalid reference name: {0}")]
+ InvalidName(#[from] gix::validate::reference::name::Error),
+
+ /// A read (lookup, peel, or iteration) against the backend failed for
+ /// a reason other than the ref simply not existing. This wraps
+ /// whatever gitoxide's own read path reported; the caller should treat
+ /// it as an I/O-class failure, not a CAS rejection.
+ #[error("ref-store read failed: {0}")]
+ Read(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
+
+ /// A [`crate::RefStore::transaction`] call failed outright — a lock
+ /// could not be acquired, the on-disk state could not be parsed, or
+ /// similar — as distinct from a clean CAS rejection, which is
+ /// reported as `Ok(TxOutcome::Rejected { .. })` rather than this
+ /// variant.
+ #[error("ref transaction failed: {0}")]
+ Transaction(#[from] gix::reference::edit::Error),
+
+ /// The store's own serialization lock (see `loose` module docs for why
+ /// it exists) could not be acquired within its timeout — most likely
+ /// another `transaction()` call is legitimately in flight and slow, or
+ /// a prior process crashed while holding it and left the lock file
+ /// behind. The caller should retry, and an operator investigating a
+ /// permanently-stuck store should look for a stale lock file in the
+ /// repository's git directory.
+ #[error("could not acquire the ref-store transaction lock: {0}")]
+ StoreLock(#[source] gix_lock::acquire::Error),
+}
+
+/// The `Result` alias every `gix-ref-store` operation returns.
+pub type Result<T> = std::result::Result<T, Error>;
crates/gix-ref-store/src/lib.rs
@@ -1,0 +1,83 @@
+//! The pluggable ref store: reads plus atomic multi-ref compare-and-swap,
+//! and a loose-ref implementation over gitoxide.
+//!
+//! This crate is the one place `git-ents` defines a trait gitoxide itself
+//! is silent about (`arch.no-object-store-trait` names the ref store as
+//! one of the seams that qualifies). It owns two things: the `RefStore`
+//! trait, split into a read half ([`RefStoreRead`]) and a write half
+//! ([`RefStore`]) per `arch.refstore-read-cas-split`, and
+//! [`LooseRefStore`], the local default backend, which writes through
+//! gitoxide's own in-process ref transaction rather than shelling out to
+//! `git update-ref` (`arch.loose-cas-discipline`).
+//!
+//! The split exists for the gate (`gate.adoc`): verification is a pure
+//! function over ref-store reads and must be statically incapable of
+//! performing a write, so it is written against `RefStoreRead` alone.
+//!
+//! `LooseRefStore` delegates the mechanics of a write (the loose-file
+//! format, reflog, packed-refs interaction) to gitoxide, but layers its
+//! own serialization lock around every `transaction()` call — see the
+//! `loose` module's doc comment for why: the pinned gitoxide version's
+//! file-transaction precondition check reads a ref's value *before*
+//! acquiring that ref's own lock, which is safe only when every writer
+//! already funnels through one in-process handle. Two independent
+//! `gix::Repository` handles racing the same ref (two `git-ents`
+//! processes, most concretely) can otherwise both observe the same stale
+//! precondition and both "win" a `MustNotExist`/`MustExistAndMatch` check.
+//! `arch.loose-cas-discipline` asks for this store's *own* CAS discipline
+//! for exactly this reason; `LooseRefStore` earns that literally rather
+//! than trusting gitoxide's internal ordering to be enough on its own.
+//!
+//! # Spec coverage
+//!
+//! This crate implements, from `docs/spec/overview.sdoc`:
+//!
+//! - `arch.refstore-read-cas-split` — the `RefStoreRead`/`RefStore` split.
+//! - `arch.loose-cas-discipline` — [`LooseRefStore`]'s use of gitoxide's
+//! own transaction machinery instead of a `git update-ref` subprocess.
+//! - `arch.no-object-store-trait` — this crate defines exactly one new
+//! trait (the ref store), and touches object access only through
+//! gitoxide's own types.
+//!
+//! # Examples
+//!
+//! ```
+//! use gix_hash::ObjectId;
+//! use gix_ref_store::{Expected, LooseRefStore, RefEdit, RefStore, RefStoreRead, TxOutcome};
+//!
+//! # fn main() -> gix_ref_store::Result<()> {
+//! let dir = tempfile::tempdir().expect("tempdir");
+//! gix::init(dir.path()).expect("init");
+//! let store = LooseRefStore::open(dir.path())?;
+//!
+//! let name: gix::refs::FullName = "refs/meta/config".try_into().expect("valid refname");
+//! let oid = ObjectId::null(gix_hash::Kind::Sha1);
+//!
+//! // The read half alone is enough to observe the ref not existing yet —
+//! // exactly what the gate is handed.
+//! let read: &dyn RefStoreRead = &store;
+//! assert_eq!(read.get(name.as_ref())?, None);
+//!
+//! // Only the write half can change it, and only via CAS.
+//! let outcome = store.transaction(&[RefEdit {
+//! name: name.clone(),
+//! expected: Expected::MustNotExist,
+//! new: Some(oid),
+//! }])?;
+//! assert_eq!(outcome, TxOutcome::Applied);
+//! assert_eq!(store.get(name.as_ref())?, Some(oid));
+//! # Ok(())
+//! # }
+//! ```
+
+mod edit;
+mod error;
+mod loose;
+mod read;
+mod store;
+
+pub use edit::{Expected, RefEdit, RefIter, TxOutcome};
+pub use error::{Error, Result};
+pub use loose::LooseRefStore;
+pub use read::RefStoreRead;
+pub use store::RefStore;
crates/gix-ref-store/src/loose.rs
@@ -1,0 +1,416 @@
+//! [`LooseRefStore`]: `RefStore` over gitoxide loose refs and packed-refs —
+//! the local default backend (`roots.local`).
+//!
+//! Atomic multi-ref compare-and-swap is layered on gitoxide's own
+//! in-process ref transaction (`Repository::edit_references_as`), which
+//! does the actual loose-file write, reflog append, and packed-refs
+//! interaction. Nothing in this module shells out to `git`.
+//!
+//! gitoxide's file-transaction precondition check reads a ref's current
+//! value *before* acquiring that ref's lock file, then locks and writes
+//! without re-verifying — safe for callers who already serialize through
+//! one in-process handle, but not for two independent `gix::Repository`
+//! handles (two processes, or two handles opened separately in one
+//! process) racing the same ref: both can read the same stale
+//! precondition before either has locked anything, and both then "win".
+//! `arch.loose-cas-discipline` requires this store to write through *its
+//! own* compare-and-swap discipline, so [`LooseRefStore::transaction`]
+//! closes that window itself with [`STORE_LOCK_NAME`]: a lock file,
+//! separate from any ref's own `.lock`, that every `transaction()` call —
+//! from any handle, any process, sharing the same on-disk repository —
+//! must hold for the full read-check-write sequence before gitoxide's own
+//! per-ref locking ever begins.
+
+use std::path::{Path, PathBuf};
+use std::sync::{Mutex, PoisonError};
+use std::time::Duration;
+
+use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit as GixRefEdit, RefLog};
+use gix::refs::{FullName, FullNameRef, Target};
+use gix_hash::ObjectId;
+
+use crate::{Error, Expected, RefEdit, RefIter, RefStore, RefStoreRead, Result, TxOutcome};
+
+/// The lock file name, held for the duration of every
+/// [`LooseRefStore::transaction`] call, that closes the precondition
+/// TOCTOU window described in this module's doc comment. Deliberately
+/// distinct from any ref's own name so it can never collide with a
+/// `refs/**` path gitoxide locks internally.
+const STORE_LOCK_NAME: &str = "gix-ref-store.lock";
+
+/// How long [`LooseRefStore::transaction`] waits to acquire
+/// [`STORE_LOCK_NAME`] before giving up. Generous relative to how long a
+/// transaction actually holds it (a handful of small file writes), so a
+/// legitimate queue of waiters drains rather than spuriously failing.
+const STORE_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// The identity every transaction's reflog entry is written under.
+///
+/// A `RefStore` write is a plumbing-level operation, not an authored
+/// change — `gate.adoc`'s tip invariant is what carries authorship for
+/// meta-ref content, via the commit's own signature. The reflog identity
+/// here exists only so gitoxide has somewhere to write a committer line;
+/// it is deliberately fixed and independent of the local `git config`, so
+/// a `LooseRefStore` never depends on `user.name`/`user.email` being set.
+const REFLOG_NAME: &str = "gix-ref-store";
+const REFLOG_EMAIL: &str = "ref-store@git-ents.invalid";
+const REFLOG_MESSAGE: &str = "gix-ref-store: transaction";
+
+/// [`RefStore`] over a gitoxide repository's loose refs and packed-refs.
+///
+/// # Examples
+///
+/// ```
+/// use gix_ref_store::LooseRefStore;
+///
+/// # fn open(dir: &std::path::Path) -> gix_ref_store::Result<()> {
+/// let store = LooseRefStore::open(dir)?;
+/// # let _ = store;
+/// # Ok(())
+/// # }
+/// ```
+pub struct LooseRefStore {
+ repo: Mutex<gix::Repository>,
+ /// The repository's git directory, captured at open time so
+ /// [`Self::store_lock_path`] can be computed without locking
+ /// [`Self::repo`] — the store-level lock must be acquired *before* any
+ /// gitoxide call touches `repo`, not while already holding it.
+ git_dir: PathBuf,
+}
+
+impl LooseRefStore {
+ /// Open the ref store for the repository at `path`.
+ ///
+ /// `path` may be a work tree or the `.git` directory itself; gitoxide
+ /// resolves either the same way `git` does.
+ // @relation(arch.loose-cas-discipline, scope=function)
+ pub fn open(path: impl AsRef<Path>) -> Result<Self> {
+ let path = path.as_ref();
+ let repo = gix::open(path).map_err(|source| Error::Open {
+ path: path.to_path_buf(),
+ source: Box::new(source),
+ })?;
+ let git_dir = repo.git_dir().to_path_buf();
+ Ok(Self {
+ repo: Mutex::new(repo),
+ git_dir,
+ })
+ }
+
+ /// Lock the underlying repository handle, recovering from a poisoned
+ /// lock rather than panicking: a panic in one caller while holding the
+ /// lock must not permanently wedge every other caller sharing this
+ /// store.
+ fn repo(&self) -> std::sync::MutexGuard<'_, gix::Repository> {
+ self.repo.lock().unwrap_or_else(PoisonError::into_inner)
+ }
+
+ /// The fixed reflog identity every transaction is written under. See
+ /// [`REFLOG_NAME`] for why this is not the ambient `git config`
+ /// identity.
+ fn committer(&self) -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: REFLOG_NAME.into(),
+ email: REFLOG_EMAIL.into(),
+ time: gix::date::Time::now_local_or_utc(),
+ }
+ }
+
+ /// The path of this store's own serialization lock — see this
+ /// module's doc comment for why `transaction` needs one beyond
+ /// whatever gitoxide locks internally.
+ fn store_lock_path(&self) -> PathBuf {
+ self.git_dir.join(STORE_LOCK_NAME)
+ }
+}
+
+impl RefStoreRead for LooseRefStore {
+ fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>> {
+ let repo = self.repo();
+ let Some(mut reference) = repo
+ .try_find_reference(name.as_bstr())
+ .map_err(|error| Error::Read(Box::new(error)))?
+ else {
+ return Ok(None);
+ };
+ let id = reference
+ .follow_to_object()
+ .map_err(|error| Error::Read(Box::new(error)))?;
+ Ok(Some(id.detach()))
+ }
+
+ fn iter_prefix(&self, prefix: &str) -> Result<RefIter> {
+ let repo = self.repo();
+ let platform = repo
+ .references()
+ .map_err(|error| Error::Read(Box::new(error)))?;
+ let iter = platform
+ .prefixed(prefix)
+ .map_err(|error| Error::Read(Box::new(error)))?;
+
+ let mut out = Vec::new();
+ for reference in iter {
+ let mut reference = reference.map_err(Error::Read)?;
+ let name = reference.name().to_owned();
+ let oid = reference
+ .follow_to_object()
+ .map_err(|error| Error::Read(Box::new(error)))?
+ .detach();
+ out.push(Ok((name, oid)));
+ }
+ Ok(RefIter::new(out.into_iter()))
+ }
+}
+
+impl RefStore for LooseRefStore {
+ // @relation(arch.loose-cas-discipline, scope=function)
+ fn transaction(&self, edits: &[RefEdit]) -> Result<TxOutcome> {
+ // Close the precondition-read-before-lock race described in this
+ // module's doc comment: no other `transaction()` call, on this
+ // handle or any other handle sharing this on-disk repository, may
+ // be inside its own read-check-write sequence while we are.
+ let _store_lock = gix_lock::Marker::acquire_to_hold_resource(
+ self.store_lock_path(),
+ gix_lock::acquire::Fail::AfterDurationWithBackoff(STORE_LOCK_TIMEOUT),
+ Some(self.git_dir.clone()),
+ )
+ .map_err(Error::StoreLock)?;
+
+ let gix_edits: Vec<GixRefEdit> = edits.iter().map(to_gix_edit).collect();
+ let committer = self.committer();
+ let mut buf = gix::date::parse::TimeBuf::default();
+ match self
+ .repo()
+ .edit_references_as(gix_edits, Some(committer.to_ref(&mut buf)))
+ {
+ Ok(_applied) => Ok(TxOutcome::Applied),
+ Err(error) => match rejected_name(&error) {
+ Some(name) => Ok(TxOutcome::Rejected { name }),
+ None => Err(Error::Transaction(error)),
+ },
+ }
+ }
+}
+
+/// Convert one backend-agnostic [`RefEdit`] into gitoxide's own
+/// transaction edit type.
+fn to_gix_edit(edit: &RefEdit) -> GixRefEdit {
+ let change = match edit.new {
+ Some(oid) => Change::Update {
+ log: LogChange {
+ mode: RefLog::AndReference,
+ // gitoxide only auto-creates a missing reflog for
+ // refs/heads/, refs/remotes/, refs/notes/, and HEAD unless
+ // told otherwise; this project's refs mostly live under
+ // refs/meta/*, which needs a log regardless of namespace.
+ force_create_reflog: true,
+ message: REFLOG_MESSAGE.into(),
+ },
+ expected: to_previous_value(&edit.expected),
+ new: Target::Object(oid),
+ },
+ None => Change::Delete {
+ expected: to_previous_value(&edit.expected),
+ log: RefLog::AndReference,
+ },
+ };
+ GixRefEdit {
+ change,
+ name: edit.name.clone(),
+ 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(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<FullName> {
+ 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,
+ };
+ full_name_from_bytes(full_name.clone())
+}
+
+/// Reconstruct a validated [`FullName`] from the raw bytes a
+/// `prepare::Error` variant carries.
+///
+/// These bytes always originated from a [`FullName`] we constructed
+/// ourselves in [`to_gix_edit`] and handed to gitoxide, so re-validating
+/// them can only fail if gitoxide's own transaction machinery corrupted a
+/// name it was given — a backend bug, not a caller error. `None` is
+/// returned rather than panicking so a hypothetical future gitoxide
+/// version that reports a differently-shaped name degrades to "not
+/// recognized as a CAS rejection" instead of crashing the caller.
+fn full_name_from_bytes(bytes: gix::bstr::BString) -> Option<FullName> {
+ FullName::try_from(bytes).ok()
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::unwrap_used, reason = "unit test")]
+
+ use gix_hash::ObjectId;
+
+ use super::LooseRefStore;
+ use crate::{Expected, RefEdit, RefStore, RefStoreRead, TxOutcome};
+
+ fn init_repo() -> tempfile::TempDir {
+ let dir = tempfile::tempdir().unwrap();
+ gix::init(dir.path()).unwrap();
+ dir
+ }
+
+ fn name(s: &str) -> gix::refs::FullName {
+ s.try_into().unwrap()
+ }
+
+ fn fixture_oid(byte: u8) -> ObjectId {
+ ObjectId::from_bytes_or_panic(&[byte; 20])
+ }
+
+ #[test]
+ fn get_returns_none_for_an_absent_ref() {
+ let dir = init_repo();
+ let store = LooseRefStore::open(dir.path()).unwrap();
+ assert_eq!(store.get(name("refs/heads/nope").as_ref()).unwrap(), None);
+ }
+
+ #[test]
+ fn transaction_creates_a_ref_then_rejects_a_stale_cas() {
+ let dir = init_repo();
+ let store = LooseRefStore::open(dir.path()).unwrap();
+ let first = fixture_oid(1);
+ let second = fixture_oid(2);
+
+ let create = RefEdit {
+ name: name("refs/heads/topic"),
+ expected: Expected::MustNotExist,
+ new: Some(first),
+ };
+ assert_eq!(store.transaction(&[create]).unwrap(), TxOutcome::Applied);
+ assert_eq!(
+ store.get(name("refs/heads/topic").as_ref()).unwrap(),
+ Some(first)
+ );
+
+ // Re-asserting must-not-exist while the ref already exists is a
+ // CAS mismatch, reported as `Rejected`, not an `Err`.
+ let recreate = RefEdit {
+ name: name("refs/heads/topic"),
+ expected: Expected::MustNotExist,
+ new: Some(second),
+ };
+ let outcome = store.transaction(&[recreate]).unwrap();
+ assert_eq!(
+ outcome,
+ TxOutcome::Rejected {
+ name: name("refs/heads/topic")
+ }
+ );
+ // The rejected edit must not have applied.
+ assert_eq!(
+ store.get(name("refs/heads/topic").as_ref()).unwrap(),
+ Some(first)
+ );
+ }
+
+ #[test]
+ fn transaction_is_all_or_nothing_across_multiple_edits() {
+ let dir = init_repo();
+ let store = LooseRefStore::open(dir.path()).unwrap();
+ let oid = fixture_oid(3);
+
+ // The second edit's precondition already fails (the ref doesn't
+ // exist yet), so neither edit should apply.
+ let edits = [
+ RefEdit {
+ name: name("refs/heads/a"),
+ expected: Expected::MustNotExist,
+ new: Some(oid),
+ },
+ RefEdit {
+ name: name("refs/heads/b"),
+ expected: Expected::MustExistAndMatch(oid),
+ new: Some(oid),
+ },
+ ];
+ let outcome = store.transaction(&edits).unwrap();
+ assert!(matches!(outcome, TxOutcome::Rejected { .. }));
+ assert_eq!(store.get(name("refs/heads/a").as_ref()).unwrap(), None);
+ }
+
+ #[test]
+ fn iter_prefix_lists_matching_refs() {
+ let dir = init_repo();
+ let store = LooseRefStore::open(dir.path()).unwrap();
+ let oid = fixture_oid(4);
+ store
+ .transaction(&[RefEdit {
+ name: name("refs/meta/thing"),
+ expected: Expected::MustNotExist,
+ new: Some(oid),
+ }])
+ .unwrap();
+ store
+ .transaction(&[RefEdit {
+ name: name("refs/heads/unrelated"),
+ expected: Expected::MustNotExist,
+ new: Some(oid),
+ }])
+ .unwrap();
+
+ let names: Vec<String> = store
+ .iter_prefix("refs/meta/")
+ .unwrap()
+ .map(|item| item.unwrap().0.as_bstr().to_string())
+ .collect();
+ assert_eq!(names, vec!["refs/meta/thing".to_owned()]);
+ }
+
+ #[test]
+ fn delete_removes_a_ref() {
+ let dir = init_repo();
+ let store = LooseRefStore::open(dir.path()).unwrap();
+ let oid = fixture_oid(5);
+ store
+ .transaction(&[RefEdit {
+ name: name("refs/meta/gone"),
+ expected: Expected::MustNotExist,
+ new: Some(oid),
+ }])
+ .unwrap();
+ assert_eq!(
+ store.get(name("refs/meta/gone").as_ref()).unwrap(),
+ Some(oid)
+ );
+
+ let outcome = store
+ .transaction(&[RefEdit {
+ name: name("refs/meta/gone"),
+ expected: Expected::MustExistAndMatch(oid),
+ new: None,
+ }])
+ .unwrap();
+ assert_eq!(outcome, TxOutcome::Applied);
+ assert_eq!(store.get(name("refs/meta/gone").as_ref()).unwrap(), None);
+ }
+}
crates/gix-ref-store/src/read.rs
@@ -1,0 +1,67 @@
+//! The read half of the `RefStore` seam.
+//!
+//! `arch.refstore-read-cas-split` requires that a consumer able to check
+//! ref state never automatically gains the ability to change it. The gate
+//! (`gate.adoc`) is the reason this split exists: it is a pure function
+//! over ref-store reads and must be statically incapable of writing.
+
+use gix::refs::FullNameRef;
+use gix_hash::ObjectId;
+
+use crate::{RefIter, Result};
+
+/// The read half of a `RefStore`: everything needed to evaluate the gate
+/// (`gate.adoc`) or render a UI, with no path to mutation.
+///
+/// A type that also supports writes implements [`crate::RefStore`], which
+/// extends this trait with [`crate::RefStore::transaction`]. Code that only
+/// ever needs to read — the gate above all — should be written against
+/// `RefStoreRead` (or `&dyn RefStoreRead`) so it is impossible, not just
+/// disciplined, for it to write.
+///
+/// # Examples
+///
+/// ```
+/// use gix_ref_store::{LooseRefStore, RefStoreRead};
+///
+/// # fn open(dir: &std::path::Path) -> gix_ref_store::Result<()> {
+/// let store = LooseRefStore::open(dir)?;
+/// let read: &dyn RefStoreRead = &store;
+/// let name: gix::refs::FullName = "refs/heads/does-not-exist".try_into().expect("valid refname");
+/// assert_eq!(read.get(name.as_ref())?, None);
+/// # Ok(())
+/// # }
+/// ```
+// @relation(arch.refstore-read-cas-split, scope=file)
+pub trait RefStoreRead: Send + Sync {
+ /// The object id `name` currently points at, or `None` if `name` does
+ /// not exist.
+ fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>>;
+
+ /// Every ref under `prefix` (for example `refs/meta/`), with its
+ /// current tip.
+ fn iter_prefix(&self, prefix: &str) -> Result<RefIter>;
+}
+
+/// Blanket impl so a `RefStoreRead` behind any indirection remains usable
+/// as `RefStoreRead` itself — `&T`, `Box<T>`, and `std::sync::Arc<T>` all
+/// forward transparently.
+impl<T: RefStoreRead + ?Sized> RefStoreRead for &T {
+ fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>> {
+ (**self).get(name)
+ }
+
+ fn iter_prefix(&self, prefix: &str) -> Result<RefIter> {
+ (**self).iter_prefix(prefix)
+ }
+}
+
+impl<T: RefStoreRead + ?Sized> RefStoreRead for std::sync::Arc<T> {
+ fn get(&self, name: &FullNameRef) -> Result<Option<ObjectId>> {
+ (**self).get(name)
+ }
+
+ fn iter_prefix(&self, prefix: &str) -> Result<RefIter> {
+ (**self).iter_prefix(prefix)
+ }
+}
crates/gix-ref-store/src/store.rs
@@ -1,0 +1,50 @@
+//! The write (CAS) half of the `RefStore` seam.
+
+use crate::{RefEdit, RefStoreRead, Result, TxOutcome};
+
+/// The unit of correctness for repository state: a store of named refs,
+/// each pointing at an object id, updated only through atomic
+/// compare-and-swap transactions.
+///
+/// `RefStore` extends [`RefStoreRead`] rather than duplicating its
+/// methods, so any code already written against the read half keeps
+/// working unchanged when handed a full store. `arch.refstore-read-cas-split`
+/// is about restricting what the *gate* is handed, not about the store
+/// implementation's own shape: one type legitimately implements both
+/// halves, as [`crate::LooseRefStore`] does.
+///
+/// # 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.
+///
+/// # Examples
+///
+/// ```
+/// use gix_hash::ObjectId;
+/// use gix_ref_store::{Expected, LooseRefStore, RefEdit, RefStore, RefStoreRead, TxOutcome};
+///
+/// # fn run(dir: &std::path::Path, oid: ObjectId) -> gix_ref_store::Result<()> {
+/// let store = LooseRefStore::open(dir)?;
+/// let name: gix::refs::FullName = "refs/meta/config".try_into().expect("valid refname");
+/// let outcome = store.transaction(&[RefEdit {
+/// name: name.clone(),
+/// expected: Expected::MustNotExist,
+/// new: Some(oid),
+/// }])?;
+/// assert_eq!(outcome, TxOutcome::Applied);
+/// assert_eq!(store.get(name.as_ref())?, Some(oid));
+/// # Ok(())
+/// # }
+/// ```
+// @relation(arch.refstore-read-cas-split, scope=file)
+pub trait RefStore: RefStoreRead {
+ /// Apply `edits` as one atomic compare-and-swap transaction: every
+ /// edit's [`crate::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>;
+}
crates/gix-ref-store/tests/conformance.rs
@@ -1,0 +1,295 @@
+//! CAS conformance suite for [`gix_ref_store::LooseRefStore`].
+//!
+//! This is the Phase 1 -> 2 gate from `docs/development-plan.adoc`:
+//! "`gix-ref-store` passes a CAS conformance suite (concurrent writers,
+//! crash injection)." Both properties below exercise gitoxide's own
+//! on-disk lock file, not an in-process mutex standing in for it: each
+//! "writer" opens its own [`LooseRefStore`] (its own `gix::Repository`
+//! handle) against the same on-disk path, the way independent OS
+//! processes would.
+//!
+//! Strategy: rstest table-driven for the fixed-shape crash-injection
+//! scenario (a handful of named cases, not an unbounded input space);
+//! a hand-rolled multi-thread race for concurrent writers, since the
+//! property under test — exactly one of N racing CAS transactions wins,
+//! observed from independent store handles — is about thread
+//! interleaving, which proptest's shrinking model has nothing to offer
+//! for. `@relation(..., role=Verifies)` is on each test.
+
+#![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ reason = "assertion helpers for a conformance suite, not application code"
+)]
+
+use std::sync::Arc;
+use std::sync::atomic::{AtomicUsize, Ordering};
+
+use gix_hash::ObjectId;
+use gix_ref_store::{Expected, LooseRefStore, RefEdit, RefStore, RefStoreRead, TxOutcome};
+
+/// A fresh bare repository. Bare so `dir.path()` *is* the git directory —
+/// no `.git` subdirectory indirection to get wrong when a test computes a
+/// ref's on-disk path directly, as the crash-injection cases below do.
+fn init_repo() -> tempfile::TempDir {
+ let dir = tempfile::tempdir().expect("tempdir");
+ gix::init_bare(dir.path()).expect("gix init_bare");
+ dir
+}
+
+fn refname(s: &str) -> gix::refs::FullName {
+ s.try_into().expect("valid refname")
+}
+
+fn oid(byte: u8) -> ObjectId {
+ ObjectId::from_bytes_or_panic(&[byte; 20])
+}
+
+/// N independent store handles race a `MustNotExist` CAS create on the
+/// *same* ref, each proposing a different oid. Exactly one must win; every
+/// other transaction must observe the ref as already existing and report
+/// `Rejected`, never silently overwrite the winner, and never both "win".
+// @relation(arch.refstore-read-cas-split, arch.loose-cas-discipline, scope=function, role=Verifies)
+#[test]
+fn concurrent_writers_exactly_one_cas_wins() {
+ let dir = init_repo();
+ let name = refname("refs/meta/race");
+ let writers = 8u8;
+
+ let applied = Arc::new(AtomicUsize::new(0));
+ let handles: Vec<_> = (0..writers)
+ .map(|i| {
+ let path = dir.path().to_path_buf();
+ let name = name.clone();
+ let applied = Arc::clone(&applied);
+ std::thread::spawn(move || {
+ // Each thread opens its own store handle against the same
+ // on-disk repository, standing in for independent
+ // processes contending the same loose ref file.
+ let store = LooseRefStore::open(&path).expect("open");
+ let outcome = store
+ .transaction(&[RefEdit {
+ name: name.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(oid(i)),
+ }])
+ .expect("transaction must not error under contention, only reject");
+ if outcome == TxOutcome::Applied {
+ applied.fetch_add(1, Ordering::SeqCst);
+ }
+ outcome
+ })
+ })
+ .collect();
+
+ let outcomes: Vec<TxOutcome> = handles
+ .into_iter()
+ .map(|h| h.join().expect("thread"))
+ .collect();
+
+ let applied_count = outcomes
+ .iter()
+ .filter(|o| **o == TxOutcome::Applied)
+ .count();
+ assert_eq!(
+ applied_count, 1,
+ "exactly one of {writers} racing CAS creates must apply; got {applied_count}: {outcomes:?}"
+ );
+ let rejected_count = outcomes
+ .iter()
+ .filter(|o| matches!(o, TxOutcome::Rejected { .. }))
+ .count();
+ assert_eq!(
+ rejected_count,
+ (writers - 1) as usize,
+ "every non-winning transaction must be a clean Rejected, not an error or a second Applied"
+ );
+
+ // The ref must hold exactly one of the proposed values, not a torn
+ // write and not a value nobody proposed.
+ let store = LooseRefStore::open(dir.path()).expect("open");
+ let landed = store.get(name.as_ref()).expect("get").expect("ref exists");
+ assert!(
+ (0..writers).map(oid).any(|candidate| candidate == landed),
+ "the ref must hold exactly one racing writer's proposed oid"
+ );
+}
+
+/// Concurrent writers targeting *different* refs must not falsely
+/// serialize into contention with one another: independent refs are
+/// independent compare-and-swap units.
+// @relation(arch.refstore-read-cas-split, scope=function, role=Verifies)
+#[test]
+fn concurrent_writers_on_distinct_refs_all_apply() {
+ let dir = init_repo();
+ let writers = 8u8;
+
+ let handles: Vec<_> = (0..writers)
+ .map(|i| {
+ let path = dir.path().to_path_buf();
+ std::thread::spawn(move || {
+ let store = LooseRefStore::open(&path).expect("open");
+ store
+ .transaction(&[RefEdit {
+ name: refname(&format!("refs/meta/independent-{i}")),
+ expected: Expected::MustNotExist,
+ new: Some(oid(i)),
+ }])
+ .expect("transaction")
+ })
+ })
+ .collect();
+
+ for (i, handle) in handles.into_iter().enumerate() {
+ let outcome = handle.join().expect("thread");
+ assert_eq!(
+ outcome,
+ TxOutcome::Applied,
+ "writer {i} on its own ref must not be blocked by unrelated concurrent writers"
+ );
+ }
+
+ let store = LooseRefStore::open(dir.path()).expect("open");
+ for i in 0..writers {
+ assert_eq!(
+ store
+ .get(refname(&format!("refs/meta/independent-{i}")).as_ref())
+ .expect("get"),
+ Some(oid(i))
+ );
+ }
+}
+
+/// Simulates the on-disk artifact a writer crashing mid-transaction
+/// leaves behind: a `.lock` file next to the ref, created but never
+/// cleaned up because the process died holding it. A `LooseRefStore` must
+/// neither corrupt the ref's last known-good value nor silently apply a
+/// transaction while that lock stands; it must fail the contending
+/// transaction cleanly, and a fresh transaction must succeed once the
+/// stale lock is cleared, as a real recovery path (fsck / restart) would
+/// clear it.
+// @relation(arch.loose-cas-discipline, scope=function, role=Verifies)
+#[rstest::rstest]
+#[case::branch_ref("refs/heads/crash-test")]
+#[case::meta_ref("refs/meta/crash-test")]
+fn crash_injection_stale_lock_fails_safe_and_recovers(#[case] ref_name: &str) {
+ let dir = init_repo();
+ let name = refname(ref_name);
+ let good = oid(0xAA);
+ let attempted = oid(0xBB);
+
+ let store = LooseRefStore::open(dir.path()).expect("open");
+ let outcome = store
+ .transaction(&[RefEdit {
+ name: name.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(good),
+ }])
+ .expect("baseline transaction");
+ assert_eq!(outcome, TxOutcome::Applied);
+
+ // Inject the artifact a crash mid-write leaves: an orphaned lock file
+ // next to the loose ref, never cleaned up because nothing removed it.
+ let lock_path = dir.path().join(format!("{ref_name}.lock"));
+ std::fs::create_dir_all(lock_path.parent().expect("lock has a parent")).expect("mkdir -p");
+ std::fs::write(&lock_path, b"orphaned by a simulated crash\n").expect("write stale lock");
+
+ // A contending transaction must fail safely — not hang forever, not
+ // silently overwrite the ref — while the stale lock stands.
+ let result = store.transaction(&[RefEdit {
+ name: name.clone(),
+ expected: Expected::MustExistAndMatch(good),
+ new: Some(attempted),
+ }]);
+ assert!(
+ result.is_err(),
+ "a transaction contending a stale lock must fail, not silently succeed or hang: {result:?}"
+ );
+
+ // The ref must be exactly as it was — no torn or partial write from
+ // the failed attempt.
+ assert_eq!(
+ store
+ .get(name.as_ref())
+ .expect("get after failed transaction"),
+ Some(good),
+ "a failed transaction under a stale lock must not have changed the ref's value"
+ );
+
+ // Recovery: once the stale lock is cleared (as a restart or an fsck
+ // pass would clear it), a fresh transaction must succeed normally.
+ std::fs::remove_file(&lock_path).expect("clear the stale lock");
+ let recovered = store
+ .transaction(&[RefEdit {
+ name: name.clone(),
+ expected: Expected::MustExistAndMatch(good),
+ new: Some(attempted),
+ }])
+ .expect("transaction after lock clears");
+ assert_eq!(recovered, TxOutcome::Applied);
+ assert_eq!(
+ store.get(name.as_ref()).expect("get after recovery"),
+ Some(attempted)
+ );
+}
+
+/// The same exactly-one-wins property as
+/// [`concurrent_writers_exactly_one_cas_wins`], but racing a
+/// `MustExistAndMatch` update against an already-existing ref rather than
+/// a `MustNotExist` create — the pattern `gate.fast-forward` and
+/// `gate.atomic-cas` actually describe: a meta-ref advances from a known
+/// old tip, not from nothing.
+// @relation(gate.atomic-cas, arch.loose-cas-discipline, scope=function, role=Verifies)
+#[test]
+fn concurrent_writers_exactly_one_cas_update_wins() {
+ let dir = init_repo();
+ let name = refname("refs/meta/update-race");
+ let store = LooseRefStore::open(dir.path()).expect("open");
+ let base = oid(0x10);
+ store
+ .transaction(&[RefEdit {
+ name: name.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(base),
+ }])
+ .expect("baseline");
+
+ let writers = 8u8;
+ let handles: Vec<_> = (0..writers)
+ .map(|i| {
+ let path = dir.path().to_path_buf();
+ let name = name.clone();
+ std::thread::spawn(move || {
+ let store = LooseRefStore::open(&path).expect("open");
+ store
+ .transaction(&[RefEdit {
+ name: name.clone(),
+ expected: Expected::MustExistAndMatch(base),
+ new: Some(oid(0x20 + i)),
+ }])
+ .expect("transaction must not error under contention, only reject")
+ })
+ })
+ .collect();
+ let outcomes: Vec<TxOutcome> = handles
+ .into_iter()
+ .map(|h| h.join().expect("thread"))
+ .collect();
+
+ let applied_count = outcomes
+ .iter()
+ .filter(|o| **o == TxOutcome::Applied)
+ .count();
+ assert_eq!(
+ applied_count, 1,
+ "exactly one of {writers} racing CAS updates from the same known-good tip must apply; got {applied_count}: {outcomes:?}"
+ );
+
+ let landed = store.get(name.as_ref()).expect("get").expect("ref exists");
+ assert!(
+ (0..writers)
+ .map(|i| oid(0x20 + i))
+ .any(|candidate| candidate == landed),
+ "the ref must hold exactly one racing writer's proposed oid, not the stale base value"
+ );
+}