git-ents.gitmain
⌘K
foforge
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

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

Cargo.lock @@ -532,6 +532,17 @@ "tracing", ] +[[package]] +name = "backend-conformance" +version = "0.0.0" +dependencies = [ + "git-backend", + "git-store", + "gix-hash", + "gix-object", + "tempfile", +] + [[package]] name = "base64" version = "0.22.1" @@ -3161,6 +3172,7 @@ name = "odb-files" version = "0.0.0" dependencies = [ + "backend-conformance", "git-backend", "git-store", "gix", @@ -3500,6 +3512,7 @@ name = "refstore-files" version = "0.0.0" dependencies = [ + "backend-conformance", "git-backend", "git-store", "gix",
Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "3" members = [ + "crates/backend-conformance", "crates/git-anchor", "crates/git-backend", "crates/git-comment", @@ -57,6 +58,7 @@ getrandom = "0.4" facet-git-tree = { git = "https://github.com/git-ents/facet-git-tree" } form_urlencoded = "1" +backend-conformance = { path = "crates/backend-conformance" } git-anchor = { path = "crates/git-anchor" } git-backend = { path = "crates/git-backend" } git-comment = { path = "crates/git-comment" }
crates/odb-files/Cargo.toml @@ -13,6 +13,7 @@ uuid = { workspace = true } [dev-dependencies] +backend-conformance = { workspace = true } git-store = { workspace = true, features = ["test-support"] } gix-object = { workspace = true }
crates/refstore-files/Cargo.toml @@ -11,6 +11,7 @@ gix-hash = { workspace = true } [dev-dependencies] +backend-conformance = { workspace = true } git-store = { workspace = true, features = ["test-support"] } [lints]
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/Cargo.toml @@ -1,0 +1,16 @@ +[package] +name = "backend-conformance" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +git-backend = { workspace = true } +git-store = { workspace = true, features = ["test-support"] } +gix-hash = { workspace = true } +gix-object = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true
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)); +}