feat: add backend-conformance suite for RefStore and ObjectStore (WS2)
commit 894498c
feat: add backend-conformance suite for RefStore and ObjectStore (WS2)
Property functions run generically over any RefStore/ObjectStore
implementation; refstore-files and odb-files each add a one-file test
target instantiating the suite (WithScratchRepo wires the backend to
a throwaway repo, FixtureOids supplies real oids gitoxide-backed
stores can resolve, Collector/NoopCollector is the seam for backends
without GC wired up yet).
feat: cover multi-ref CAS concurrency, all-or-nothing, prefix-iteration
consistency, quarantine invisibility, causal collection safety,
watch-loss tolerance, and reflog properties
fix: force reflog creation for refs outside refs/heads|remotes|notes in
refstore-files, since refs/meta/* never got one under gitoxide’s default
Assisted-by: Claude:claude-sonnet-5
crates/refstore-files/src/lib.rs
@@ -163,7 +163,12 @@
Some(oid) => Change::Update {
log: LogChange {
mode: RefLog::AndReference,
- force_create_reflog: false,
+ // 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 `log` to work per the trait's
+ // contract regardless of namespace.
+ force_create_reflog: true,
message: LOG_MESSAGE.into(),
},
expected: to_previous_value(&edit.expected),
crates/backend-conformance/src/collector.rs
@@ -1,0 +1,39 @@
+//! [`Collector`]: the seam [`crate::causal_collection_safety`] tests
+//! against. Local file backends (`refstore-files`, `odb-files`) have no GC
+//! wired up yet (`docs/scale-out.adoc`, WS1), so [`NoopCollector`] lets
+//! today's suite exercise what a collection pass must never do — touch a
+//! staged/quarantined object — without asserting behavior no collector
+//! implements yet. A backend with real GC plugs its own `Collector`
+//! (reporting a real [`Collector::staging_grace`] window, if it has one)
+//! into the same property function instead.
+
+use std::time::Duration;
+
+/// A hook onto a backend's collection (GC) pass, for
+/// [`crate::causal_collection_safety`] to drive.
+pub trait Collector {
+ /// Run one collection pass now.
+ fn collect(&self);
+
+ /// The backend's staging grace window, if it bounds staging sessions
+ /// with a time-based deadline (correctness rule 1 in
+ /// `docs/scale-out.adoc`) rather than promotion alone. `None` for
+ /// backends with no time-bounded staging, which is what the local file
+ /// backends have today.
+ fn staging_grace(&self) -> Option<Duration> {
+ None
+ }
+}
+
+/// A [`Collector`] that never collects anything and has no grace window —
+/// today's stand-in for backends (`refstore-files`/`odb-files`) that have
+/// no GC wired up yet. Running the suite against it still exercises the
+/// real quarantine/promote path; it just never exercises the "a collection
+/// pass actually reaped something" arm, which has no implementation to
+/// test yet.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct NoopCollector;
+
+impl Collector for NoopCollector {
+ fn collect(&self) {}
+}
crates/backend-conformance/src/fixture_oids.rs
@@ -1,0 +1,22 @@
+//! [`FixtureOids`]: how `RefStore` property functions obtain object ids to
+//! write into `RefEdit`s.
+//!
+//! A gitoxide-backed `RefStore` resolves a ref by reading the object it
+//! targets (peeling through tags), so it needs oids of objects that
+//! actually exist in *its own* backing repository — an oid from an
+//! unrelated throwaway repo will not resolve. A backend that never touches
+//! object storage (e.g. a Postgres-backed one, per `docs/scale-out.adoc`)
+//! has no such requirement and can hand back any distinct synthetic value.
+//! Each backend's conformance instantiation says which it is by
+//! implementing this trait: [`crate::WithScratchRepo`] does it by
+//! committing directly into the scratch repository it holds.
+
+use gix_hash::ObjectId;
+
+/// Supplies distinct object ids a `RefStore` property function can use as
+/// `RefEdit` targets against this instance.
+pub trait FixtureOids {
+ /// `n` distinct object ids safe to write into a `RefEdit` against this
+ /// instance.
+ fn fixture_oids(&self, n: usize) -> Vec<ObjectId>;
+}
crates/backend-conformance/src/lib.rs
@@ -1,0 +1,44 @@
+//! The backend conformance suite — the property tests that *are* the
+//! governing invariant (`docs/scale-out.adoc`, "Governing invariant"). One
+//! semantics, enforced by conformance: every [`git_backend::RefStore`] and
+//! [`git_backend::ObjectStore`] backend must pass the same properties,
+//! defined once here rather than per backend.
+//!
+//! # Plugging in a new backend
+//!
+//! Add `backend-conformance` as a dev-dependency and a small test file that
+//! calls [`ref_store_properties`] and/or [`object_store_properties`] with a
+//! closure that builds a fresh instance of the backend under test:
+//!
+//! ```ignore
+//! #[test]
+//! fn conforms_to_ref_store_properties() {
+//! backend_conformance::ref_store_properties(|| WithScratchRepo::new(FilesRefStore::open));
+//! }
+//! ```
+//!
+//! Every property is generic over the trait, not any one backend, so a type
+//! that satisfies `RefStore`/`ObjectStore` gets the whole suite for free.
+//! [`WithScratchRepo`] is a convenience for file-backed local backends that
+//! need a throwaway git repository to open against; a cloud backend's own
+//! instantiation builds its backend however it needs to and does not have
+//! to use it.
+
+mod collector;
+mod fixture_oids;
+mod object_store;
+mod ref_store;
+mod scratch_repo;
+mod support;
+
+pub use collector::{Collector, NoopCollector};
+pub use fixture_oids::FixtureOids;
+pub use object_store::{
+ causal_collection_safety, object_store_properties, quarantine_invisibility,
+};
+pub use ref_store::{
+ multi_ref_all_or_nothing, multi_ref_cas_concurrent_conflict, prefix_iteration_consistency,
+ ref_store_properties, reflog_records_transactions, watch_loss_tolerance,
+};
+pub use scratch_repo::WithScratchRepo;
+pub use support::{commit_oids_into, distinct_oids};
crates/backend-conformance/src/object_store.rs
@@ -1,0 +1,96 @@
+//! Property functions for [`git_backend::ObjectStore`] implementations —
+//! the same suite run against every backend (`docs/scale-out.adoc`,
+//! "Storage traits" / WS2).
+
+#![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ reason = "assertion helpers for a conformance suite, not application code"
+)]
+
+use git_backend::{ObjectStore, PackStream};
+
+use crate::collector::Collector;
+use crate::support::oid_and_pack;
+
+/// Run every [`ObjectStore`] property against a fresh backend built by
+/// `mk`, using `collector` to drive the causal-collection-safety property.
+/// Each property gets its own fresh backend instance (a fresh call to
+/// `mk`) so one property's writes never leak into another's assertions.
+pub fn object_store_properties<S, C>(mk: impl Fn() -> S, collector: &C)
+where
+ S: ObjectStore,
+ C: Collector,
+{
+ quarantine_invisibility(&mk());
+ causal_collection_safety(&mk(), collector);
+}
+
+/// Staged objects are invisible to `read`/`contains` until promoted; once
+/// promoted, they're visible and correct (`docs/scale-out.adoc`,
+/// "ObjectStore").
+pub fn quarantine_invisibility<S: ObjectStore>(store: &S) {
+ let fixture = oid_and_pack();
+
+ assert!(
+ !store
+ .contains(fixture.oid)
+ .expect("contains before staging"),
+ "a fresh store must not already contain the fixture object"
+ );
+
+ let quarantine = store
+ .stage_pack(PackStream::new(std::io::Cursor::new(fixture.pack.clone())))
+ .expect("stage_pack");
+ assert!(
+ !store.contains(fixture.oid).expect("contains while staged"),
+ "a staged, unpromoted object must be invisible to contains"
+ );
+ assert!(
+ store.read(fixture.oid).is_err(),
+ "a staged, unpromoted object must be invisible to read"
+ );
+
+ store.promote(quarantine).expect("promote");
+ assert!(
+ store.contains(fixture.oid).expect("contains after promote"),
+ "a promoted object must be visible to contains"
+ );
+ let object = store.read(fixture.oid).expect("read after promote");
+ assert_eq!(object.kind, gix_object::Kind::Commit);
+}
+
+/// Objects staged for an in-flight transaction are never collected
+/// (`docs/scale-out.adoc`, correctness rule 1). `collector` stands in for
+/// whatever collection mechanism a backend has; today's local backends
+/// have none wired up ([`crate::NoopCollector`]), which still exercises
+/// the quarantine path a real collector must also respect, without
+/// asserting a collection arm no backend implements yet. A backend with a
+/// time-bounded staging grace window (`Collector::staging_grace`) is
+/// responsible for asserting its own boundary — that a session which can't
+/// finish inside the window aborts rather than becoming collectible
+/// mid-flight — in its own instantiation, since only it knows how to hold
+/// a staging session open past its deadline.
+pub fn causal_collection_safety<S: ObjectStore, C: Collector>(store: &S, collector: &C) {
+ let fixture = oid_and_pack();
+ let quarantine = store
+ .stage_pack(PackStream::new(std::io::Cursor::new(fixture.pack.clone())))
+ .expect("stage_pack");
+
+ // A collection pass runs while the object is still only staged — the
+ // in-flight transaction hasn't committed (promoted) yet.
+ collector.collect();
+
+ // Regardless of what the collector did, the staged object must still
+ // be promotable and, once promoted, readable and correct: a collector
+ // that reaped a staged object would make one of these fail.
+ store
+ .promote(quarantine)
+ .expect("promote after a collection pass");
+ assert!(
+ store.contains(fixture.oid).expect("contains after promote"),
+ "a collection pass during staging must not have reaped the staged object"
+ );
+ let object = store.read(fixture.oid).expect("read after promote");
+ assert_eq!(object.kind, gix_object::Kind::Commit);
+}
crates/backend-conformance/src/ref_store.rs
@@ -1,0 +1,304 @@
+//! Property functions for [`git_backend::RefStore`] implementations — the
+//! same suite run against every backend (`docs/scale-out.adoc`, "Storage
+//! traits" / WS2).
+
+#![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ reason = "assertion helpers for a conformance suite, not application code"
+)]
+
+use std::sync::Arc;
+
+use git_backend::{Expected, RefEdit, RefName, RefStore, TxOutcome};
+use gix_hash::ObjectId;
+
+use crate::FixtureOids;
+
+/// Run every [`RefStore`] property against a fresh backend built by `mk`.
+/// Each property gets its own fresh backend instance (a fresh call to
+/// `mk`) so one property's writes never leak into another's assertions.
+pub fn ref_store_properties<S>(mk: impl Fn() -> S)
+where
+ S: RefStore + FixtureOids + 'static,
+{
+ multi_ref_all_or_nothing(&mk());
+ prefix_iteration_consistency(&mk());
+ reflog_records_transactions(&mk());
+ watch_loss_tolerance(&mk());
+ multi_ref_cas_concurrent_conflict(&mk);
+}
+
+/// One failing edit in a multi-ref transaction rejects the whole batch —
+/// no partial application (`docs/scale-out.adoc`, "RefStore": "Multi-ref
+/// compare-and-swap is in the contract").
+pub fn multi_ref_all_or_nothing<S: RefStore + FixtureOids>(store: &S) {
+ let mut oids = store.fixture_oids(2).into_iter();
+ let new_oid = oids.next().expect("first oid");
+ let mismatched_oid = oids.next().expect("second oid");
+
+ let a = RefName::new("refs/conformance/all-or-nothing/a");
+ let b = RefName::new("refs/conformance/all-or-nothing/b");
+
+ // `b`'s precondition already fails (it doesn't exist), so `a` must not
+ // apply either, even though its own precondition holds.
+ let edits = [
+ RefEdit {
+ name: a.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(new_oid),
+ },
+ RefEdit {
+ name: b.clone(),
+ expected: Expected::MustExistAndMatch(mismatched_oid),
+ new: Some(new_oid),
+ },
+ ];
+ let outcome = store.transaction(&edits).expect("transaction");
+ assert!(
+ matches!(outcome, TxOutcome::Rejected { .. }),
+ "a batch with one failing precondition must reject the whole transaction"
+ );
+ assert_eq!(
+ store.get(&a).expect("get a"),
+ None,
+ "a rejected batch must not partially apply — a's own edit had a valid precondition but must not have landed"
+ );
+ assert_eq!(store.get(&b).expect("get b"), None);
+}
+
+/// `iter_prefix` agrees with `get` after transactions land — additions and
+/// deletions alike.
+pub fn prefix_iteration_consistency<S: RefStore + FixtureOids>(store: &S) {
+ let mut oids = store.fixture_oids(2).into_iter();
+ let inside_oid = oids.next().expect("first oid");
+ let outside_oid = oids.next().expect("second oid");
+
+ let inside = RefName::new("refs/conformance/prefix/inside");
+ let outside = RefName::new("refs/conformance/other/outside");
+ let prefix = RefName::new("refs/conformance/prefix/");
+
+ store
+ .transaction(&[
+ RefEdit {
+ name: inside.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(inside_oid),
+ },
+ RefEdit {
+ name: outside.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(outside_oid),
+ },
+ ])
+ .expect("transaction");
+
+ let listed: Vec<_> = store
+ .iter_prefix(&prefix)
+ .expect("iter_prefix")
+ .map(|item| item.expect("ref entry"))
+ .collect();
+ assert_eq!(
+ listed,
+ vec![(inside.clone(), inside_oid)],
+ "iter_prefix must list exactly the refs under the prefix, agreeing with get"
+ );
+ assert_eq!(store.get(&inside).expect("get inside"), Some(inside_oid));
+
+ // Delete it via a transaction; iter_prefix must reflect the deletion.
+ store
+ .transaction(&[RefEdit {
+ name: inside.clone(),
+ expected: Expected::MustExistAndMatch(inside_oid),
+ new: None,
+ }])
+ .expect("delete transaction");
+ let listed_after_delete: Vec<_> = store
+ .iter_prefix(&prefix)
+ .expect("iter_prefix after delete")
+ .collect();
+ assert!(
+ listed_after_delete.is_empty(),
+ "iter_prefix must not list a ref deleted by a transaction"
+ );
+ assert_eq!(store.get(&inside).expect("get after delete"), None);
+}
+
+/// A transaction appends to the ref's log.
+pub fn reflog_records_transactions<S: RefStore + FixtureOids>(store: &S) {
+ let oid = store.fixture_oids(1).into_iter().next().expect("oid");
+ let name = RefName::new("refs/conformance/reflog/probe");
+ store
+ .transaction(&[RefEdit {
+ name: name.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(oid),
+ }])
+ .expect("transaction");
+
+ let entries: Vec<_> = store
+ .log(&name)
+ .expect("log")
+ .map(|entry| entry.expect("log entry"))
+ .collect();
+ assert!(
+ !entries.is_empty(),
+ "a transaction must append to the ref's log"
+ );
+ let latest = entries.first().expect("at least one entry");
+ assert_eq!(latest.new, Some(oid));
+}
+
+/// `watch` is a hint only. Killing the event stream mid-flight (dropping it
+/// before it delivers anything) must never lose the underlying ref state:
+/// transactions still land and later reads are still correct
+/// (`docs/scale-out.adoc`, "RefStore"). Queue-table recovery on reconnect
+/// is a cloud backend's own concern; this asserts the backend-independent
+/// half — no *state* is lost when the channel drops.
+pub fn watch_loss_tolerance<S: RefStore + FixtureOids>(store: &S) {
+ let mut oids = store.fixture_oids(2).into_iter();
+ let first_oid = oids.next().expect("first oid");
+ let second_oid = oids.next().expect("second oid");
+
+ let prefix = RefName::new("refs/conformance/watch-loss/");
+ let first = RefName::new("refs/conformance/watch-loss/probe-1");
+ let second = RefName::new("refs/conformance/watch-loss/probe-2");
+
+ // Open a watcher, then drop it immediately — simulating a connection
+ // that dies mid-flight before it delivers anything.
+ let watcher = store.watch(&prefix).expect("watch");
+ drop(watcher);
+
+ store
+ .transaction(&[RefEdit {
+ name: first.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(first_oid),
+ }])
+ .expect("transaction after dropping the watcher");
+ assert_eq!(
+ store.get(&first).expect("get after watch drop"),
+ Some(first_oid),
+ "a transaction must land correctly even though its watcher was dropped before delivery"
+ );
+
+ // A fresh watch opened after the drop must still see subsequent
+ // changes: the earlier drop must not have wedged the watch mechanism.
+ let watcher = store.watch(&prefix).expect("watch again");
+ store
+ .transaction(&[RefEdit {
+ name: second.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(second_oid),
+ }])
+ .expect("second transaction");
+ // Best-effort: the hint arriving is not required (that's the whole
+ // point of "hint only"); re-reading afterward must be correct
+ // regardless of whether it did.
+ let _hint = watcher.recv_timeout(std::time::Duration::from_millis(200));
+ assert_eq!(
+ store.get(&second).expect("get after second transaction"),
+ Some(second_oid)
+ );
+}
+
+/// Concurrent, conflicting multi-ref transactions: exactly one wins, no
+/// partial application, and every loser is `Rejected` rather than causing
+/// corruption (`docs/scale-out.adoc`, "RefStore").
+pub fn multi_ref_cas_concurrent_conflict<S>(mk: &impl Fn() -> S)
+where
+ S: RefStore + FixtureOids + 'static,
+{
+ const CONTENDERS: usize = 8;
+
+ let store = Arc::new(mk());
+ let a = RefName::new("refs/conformance/cas-race/a");
+ let b = RefName::new("refs/conformance/cas-race/b");
+
+ let mut oids = store.fixture_oids(CONTENDERS.saturating_add(1)).into_iter();
+ let baseline = oids.next().expect("baseline oid");
+ let candidates: Vec<ObjectId> = oids.collect();
+
+ store
+ .transaction(&[
+ RefEdit {
+ name: a.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(baseline),
+ },
+ RefEdit {
+ name: b.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(baseline),
+ },
+ ])
+ .expect("seed transaction");
+
+ let handles: Vec<_> = candidates
+ .into_iter()
+ .map(|candidate| {
+ let store = Arc::clone(&store);
+ let (a, b) = (a.clone(), b.clone());
+ std::thread::spawn(move || {
+ let outcome = store
+ .transaction(&[
+ RefEdit {
+ name: a,
+ expected: Expected::MustExistAndMatch(baseline),
+ new: Some(candidate),
+ },
+ RefEdit {
+ name: b,
+ expected: Expected::MustExistAndMatch(baseline),
+ new: Some(candidate),
+ },
+ ])
+ .expect("contender transaction");
+ (outcome, candidate)
+ })
+ })
+ .collect();
+
+ let outcomes: Vec<(TxOutcome, ObjectId)> = handles
+ .into_iter()
+ .map(|handle| handle.join().expect("contender thread panicked"))
+ .collect();
+
+ let applied = outcomes
+ .iter()
+ .filter(|(outcome, _)| matches!(outcome, TxOutcome::Applied))
+ .count();
+ assert_eq!(
+ applied, 1,
+ "exactly one conflicting concurrent multi-ref transaction must win the CAS race"
+ );
+ let rejected = outcomes
+ .iter()
+ .filter(|(outcome, _)| matches!(outcome, TxOutcome::Rejected { .. }))
+ .count();
+ assert_eq!(
+ rejected,
+ CONTENDERS.saturating_sub(1),
+ "every losing contender must be Rejected, not corrupted or silently dropped"
+ );
+
+ let winner = outcomes
+ .iter()
+ .find_map(|(outcome, candidate)| {
+ matches!(outcome, TxOutcome::Applied).then_some(*candidate)
+ })
+ .expect("exactly one applied outcome");
+
+ let final_a = store.get(&a).expect("get a after race");
+ let final_b = store.get(&b).expect("get b after race");
+ assert_eq!(
+ final_a,
+ Some(winner),
+ "ref a must reflect the single winning multi-ref transaction"
+ );
+ assert_eq!(
+ final_b,
+ Some(winner),
+ "ref b must move together with a — no partial application under concurrency"
+ );
+}
crates/backend-conformance/src/scratch_repo.rs
@@ -1,0 +1,90 @@
+//! [`WithScratchRepo`]: keeps a file-backed backend alive alongside the
+//! scratch git repository its on-disk state depends on.
+
+#![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ reason = "fixture helper for a conformance suite, not application code"
+)]
+
+use std::path::Path;
+
+use git_backend::{
+ Object, ObjectStore, PackStream, QuarantineId, RefEdit, RefEventStream, RefIter, RefLogIter,
+ RefName, RefStore, Result, TxOutcome,
+};
+use gix_hash::ObjectId;
+
+use crate::FixtureOids;
+use crate::support::commit_oids_into;
+
+/// Bundles a backend with the throwaway git repository it was opened
+/// against, so the repository outlives the backend: struct fields drop in
+/// declaration order, so `store` (which may hold open handles into the
+/// repository) is released before `_dir` deletes it.
+///
+/// Local file-backed backends (`refstore-files`, `odb-files`) need a real
+/// repository on disk to open against; this is a `mk` closure's return
+/// value in a conformance instantiation for that shape of backend. Cloud
+/// backends have no such requirement and do not need this type.
+pub struct WithScratchRepo<S> {
+ store: S,
+ _dir: tempfile::TempDir,
+}
+
+impl<S> WithScratchRepo<S> {
+ /// Create a fresh scratch git repository and hand its path to `open` to
+ /// build the backend, keeping the repository alive for as long as the
+ /// returned value lives.
+ pub fn new<E: std::fmt::Debug>(open: impl FnOnce(&Path) -> std::result::Result<S, E>) -> Self {
+ let dir = git_store::test_support::repo();
+ let store = open(dir.path()).expect("open backend against scratch repo");
+ Self { store, _dir: dir }
+ }
+}
+
+impl<S: RefStore> RefStore for WithScratchRepo<S> {
+ fn get(&self, name: &RefName) -> Result<Option<ObjectId>> {
+ self.store.get(name)
+ }
+
+ fn iter_prefix(&self, prefix: &RefName) -> Result<RefIter> {
+ self.store.iter_prefix(prefix)
+ }
+
+ fn transaction(&self, edits: &[RefEdit]) -> Result<TxOutcome> {
+ self.store.transaction(edits)
+ }
+
+ fn watch(&self, prefix: &RefName) -> Result<RefEventStream> {
+ self.store.watch(prefix)
+ }
+
+ fn log(&self, name: &RefName) -> Result<RefLogIter> {
+ self.store.log(name)
+ }
+}
+
+impl<S> FixtureOids for WithScratchRepo<S> {
+ fn fixture_oids(&self, n: usize) -> Vec<ObjectId> {
+ commit_oids_into(self._dir.path(), n)
+ }
+}
+
+impl<S: ObjectStore> ObjectStore for WithScratchRepo<S> {
+ fn read(&self, id: ObjectId) -> Result<Object> {
+ self.store.read(id)
+ }
+
+ fn contains(&self, id: ObjectId) -> Result<bool> {
+ self.store.contains(id)
+ }
+
+ fn stage_pack(&self, pack: PackStream) -> Result<QuarantineId> {
+ self.store.stage_pack(pack)
+ }
+
+ fn promote(&self, q: QuarantineId) -> Result<()> {
+ self.store.promote(q)
+ }
+}
crates/backend-conformance/src/support.rs
@@ -1,0 +1,91 @@
+//! Shared fixtures: distinct real commit oids, and a valid pack built the
+//! same way a push transmits one. Property functions exercise backends
+//! against real git object bytes rather than synthetic hashes, since a
+//! backend is free to validate what it's handed and no real pack could
+//! ever contain a made-up hash's "content".
+
+#![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ reason = "fixture helpers for a conformance suite, not application code"
+)]
+
+use std::path::Path;
+use std::process::{Command, Stdio};
+
+use git_store::test_support::{commit_all, head, repo};
+use gix_hash::ObjectId;
+
+/// `n` distinct, real commit object ids, built by committing `n` times in
+/// a throwaway repository unrelated to any backend under test. Only usable
+/// as `RefEdit` targets for a `RefStore` that never dereferences into
+/// object storage (e.g. a Postgres-backed one); a backend that resolves a
+/// ref by reading its target object needs [`commit_oids_into`] instead,
+/// since this repository is not the one it reads from.
+pub fn distinct_oids(n: usize) -> Vec<ObjectId> {
+ let dir = repo();
+ commit_oids_into(dir.path(), n)
+}
+
+/// `n` distinct, real commit object ids, built by committing `n` times
+/// into the already-initialized repository at `path`. For a `RefStore`
+/// backend that peels a ref by reading its target object (gitoxide-backed
+/// ones do), `path` must be the same repository the backend was opened
+/// against, so the objects a `RefEdit` points at actually resolve.
+pub fn commit_oids_into(path: &Path, n: usize) -> Vec<ObjectId> {
+ (0..n)
+ .map(|i| {
+ std::fs::write(path.join("file"), i.to_string()).expect("write fixture file");
+ commit_all(path, &format!("conformance fixture {i}"));
+ let hex = head(path);
+ ObjectId::from_hex(hex.as_bytes()).expect("valid oid hex")
+ })
+ .collect()
+}
+
+/// A real commit oid, and a pack containing it and everything it reaches.
+pub struct PackFixture {
+ /// The commit at the tip of [`PackFixture::pack`].
+ pub oid: ObjectId,
+ /// A pack containing `oid` and everything it reaches.
+ pub pack: Vec<u8>,
+}
+
+/// Build a [`PackFixture`]: one commit in a fresh throwaway repository,
+/// packed on its own.
+pub fn oid_and_pack() -> PackFixture {
+ let dir = repo();
+ std::fs::write(dir.path().join("file"), b"content").expect("write fixture file");
+ commit_all(dir.path(), "conformance fixture");
+ let hex = head(dir.path());
+ let oid = ObjectId::from_hex(hex.as_bytes()).expect("valid oid hex");
+ let pack = pack_for(dir.path(), &hex);
+ PackFixture { oid, pack }
+}
+
+/// Pack `commit` and everything it reaches from `dir`, by shelling out to
+/// `git rev-list`/`git pack-objects` — the same bytes a real push
+/// transmits, mirroring `odb-files`'s own test fixture.
+fn pack_for(dir: &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()
+ .expect("spawn git rev-list");
+ let pack_objects = Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(["pack-objects", "--stdout", "-q"])
+ .stdin(rev_list.stdout.take().expect("rev-list stdout"))
+ .stdout(Stdio::piped())
+ .spawn()
+ .expect("spawn git pack-objects");
+ let output = pack_objects
+ .wait_with_output()
+ .expect("wait for pack-objects");
+ assert!(rev_list.wait().expect("wait for rev-list").success());
+ assert!(output.status.success());
+ output.stdout
+}
crates/odb-files/tests/conformance.rs
@@ -1,0 +1,15 @@
+//! This crate's instantiation of the shared backend conformance suite
+//! (`docs/scale-out.adoc`, WS2): every `ObjectStore` property run against
+//! `OdbFiles`, with no GC wired up yet
+//! ([`backend_conformance::NoopCollector`]).
+
+use backend_conformance::{NoopCollector, WithScratchRepo};
+use odb_files::OdbFiles;
+
+#[test]
+fn conforms_to_object_store_properties() {
+ backend_conformance::object_store_properties(
+ || WithScratchRepo::new(OdbFiles::open),
+ &NoopCollector,
+ );
+}
crates/refstore-files/tests/conformance.rs
@@ -1,0 +1,11 @@
+//! This crate's instantiation of the shared backend conformance suite
+//! (`docs/scale-out.adoc`, WS2): every `RefStore` property run against
+//! `FilesRefStore`.
+
+use backend_conformance::WithScratchRepo;
+use refstore_files::FilesRefStore;
+
+#[test]
+fn conforms_to_ref_store_properties() {
+ backend_conformance::ref_store_properties(|| WithScratchRepo::new(FilesRefStore::open));
+}