git-ents.gitmain
⌘K
foforge
commit 2508421
sync: add ents-sync, remote synchronization on one schema-aware merge machinery

Fetch and push move the whole forge: each refs/meta/* ref’s full object closure travels, commit objects verbatim so signatures verify offline in the destination (sync.forge-transfer). Push pre-flight runs the identical ents_gate::verify every call site runs, so a verdict is a prediction that can only go stale (sync.pre-flight, gate.call-sites), and any negative advisory verdict offers routing to the author’s own inbox segment the moment it lands, never blocking a local write (sync.inbox-routing, sync.local-advisory).

The hard part is merge::three_way: a schema-aware three-way merge over the typed tree, field by field via content addressing, never textual (sync.divergence-merge) — absorbing git-store’s merge role. Same-actor divergence and adoption ride the single resolve::merge_heads machinery (sync.adoption-machinery); the folded-in head always stays a parent, so the contributor’s signed commit survives in ancestry with attribution intact — a merge, never a cherry-pick (sync.adoption-no-cherry-pick).

Merge properties (disjoint-edit fusion, same-field conflict, commutativity, one-sided adoption) are proptest-fuzzed over divergent typed trees, the sync half of the phase-4 exit gate; verdict tables and end-to-end gate-accepts-the-merge-tip scenarios cover the rest.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

Cargo.lock @@ -448,6 +448,24 @@ "thiserror", ] +[[package]] +name = "ents-sync" +version = "0.0.0" +dependencies = [ + "ents-gate", + "ents-model", + "ents-testutil", + "facet", + "facet-git-tree", + "gix", + "gix-hash", + "gix-object", + "gix-ref-store", + "proptest", + "rstest", + "thiserror", +] + [[package]] name = "ents-testutil" version = "0.0.0"
Cargo.toml @@ -6,6 +6,7 @@ "crates/ents-model", "crates/ents-query", "crates/ents-receive", + "crates/ents-sync", "crates/ents-testutil", "crates/gix-ref-store", ] @@ -24,6 +25,7 @@ ents-model = { path = "crates/ents-model" } ents-query = { path = "crates/ents-query" } ents-receive = { path = "crates/ents-receive" } +ents-sync = { path = "crates/ents-sync" } ents-testutil = { path = "crates/ents-testutil" } gix-ref-store = { path = "crates/gix-ref-store" } arborium = { version = "2.18", default-features = false, features = [
crates/ents-sync/Cargo.toml @@ -1,0 +1,25 @@ +[package] +name = "ents-sync" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +ents-gate = { workspace = true } +ents-model = { workspace = true } +facet-git-tree = { workspace = true } +gix = { workspace = true } +gix-hash = { workspace = true } +gix-object = { workspace = true } +gix-ref-store = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +ents-testutil = { workspace = true } +facet = { workspace = true } +proptest = { workspace = true } +rstest = { workspace = true } + +[lints] +workspace = true
crates/ents-sync/src/error.rs @@ -1,0 +1,67 @@ +//! `ents-sync`'s infrastructure error type. +//! +//! An [`Error`] means sync could not *reach* a result — an object could +//! not be read or written, a ref store failed, a commit did not decode. +//! It is never a merge conflict or a negative verdict: a +//! [`crate::Conflict`] and a [`ents_gate::Verdict::Fail`] are both reached +//! results the caller acts on, not failures to compute one. + +use gix_hash::ObjectId; + +/// Everything that can prevent sync from reaching a result. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A ref store (local or remote) failed. The transfer or verdict was + /// neither completed nor refused; retry or surface. + #[error("ref store failed: {0}")] + Refs(#[from] gix_ref_store::Error), + + /// The gate could not evaluate during push pre-flight + /// (`sync.pre-flight`). Distinct from a failing verdict, which is a + /// reached prediction; this is the gate itself failing to compute one. + #[error("gate evaluation failed: {0}")] + Gate(#[from] ents_gate::Error), + + /// An object lookup failed while reading `oid`. + #[error("object lookup failed for {oid}: {source}")] + Object { + /// The object being looked up. + oid: ObjectId, + /// The underlying object-store error. + #[source] + source: gix_object::find::Error, + }, + + /// Writing an object into the destination store failed while + /// transferring the forge (`sync.forge-transfer`). + #[error("object write failed: {0}")] + Write(#[from] gix_object::write::Error), + + /// `oid` is absent from the store it was expected in. During transfer + /// this means the source is missing an object its own ref reaches; + /// during a merge it means a proposed head is not present locally. + #[error("object {oid} is missing")] + Missing { + /// The absent object. + oid: ObjectId, + }, + + /// `oid` exists but could not be decoded as the object kind sync + /// needed (a commit while walking history, or a tree while merging). + #[error("object {oid} could not be decoded: {detail}")] + Decode { + /// The undecodable object. + oid: ObjectId, + /// What failed, human-readable. + detail: String, + }, + + /// A refname could not be constructed while routing to the inbox + /// (`sync.inbox-routing`) — for example, a canonical suffix that is not + /// a valid ref component. + #[error("invalid refname while routing to inbox: {0}")] + RefName(#[from] ents_model::Error), +} + +/// The `Result` alias every fallible `ents-sync` operation returns. +pub type Result<T> = std::result::Result<T, Error>;
crates/ents-sync/src/lib.rs @@ -1,0 +1,121 @@ +//! `sync`: remote synchronization for the forge — the one capability +//! `git ents` adds over the local primitives (`docs/spec/sync.sdoc`). +//! +//! Sync fetches and pushes `refs/meta/*` and, crucially, turns the gate's +//! verdict into a decision the user acts on before pushing. Its single hard +//! responsibility is the schema-aware three-way merge over typed trees +//! (`crate::merge`); everything else — transfer, pre-flight, inbox routing — +//! is plumbing above traits that already exist by this phase (`RefStore`, +//! `Find`/`Write`, and `ents_gate::verify`). This crate never re-implements +//! the gate's judgment (it *calls* [`ents_gate::verify`], `gate.call-sites`) +//! and never writes to a ref except through the `RefStore` seam it is handed. +//! +//! Divergence resolution and adoption are deliberately *one* machinery +//! (`crate::resolve`), not two code paths (`sync.adoption-machinery`): a +//! member merging their own racing machines, a maintainer folding an inbox +//! entity onto its canonical ref, and a maintainer adopting a contributor's +//! self-run results all go through the same [`resolve::merge_heads`], which +//! keeps the folded-in head as a parent so its author's signature survives — +//! a merge, never a cherry-pick (`sync.adoption-no-cherry-pick`). +//! +//! # Spec coverage +//! +//! From `docs/spec/sync.sdoc`: +//! +//! - `sync.forge-transfer` — [`transfer::fetch`], [`transfer::push`]: both +//! copy each meta-ref's full object closure, commit objects verbatim, so +//! history and signatures move with the ref. +//! - `sync.pre-flight` — [`preflight::preflight`]: the identical +//! [`ents_gate::verify`] every call site runs, so a pre-flight verdict is +//! a prediction that can only be stale (`gate.call-sites`). +//! - `sync.inbox-routing` — [`preflight::inbox_route`], surfaced by +//! [`preflight::PreFlight::inbox`] and [`transfer::Pushed::Inbox`]: any +//! negative advisory verdict offers the author's own inbox segment. +//! - `sync.divergence-merge` — [`merge::three_way`] and +//! [`resolve::merge_heads`]: a schema-aware three-way merge whose tip, +//! once signed, satisfies the tip invariant. +//! - `sync.adoption-machinery` — [`resolve::merge_heads`] is the *same* +//! function divergence uses; adoption is only a different pair of heads. +//! - `sync.adoption-no-cherry-pick` — [`resolve::merge_heads`] always keeps +//! `theirs` as a parent; it never re-authors the contributor's commit. +//! - `sync.local-advisory` — sync never blocks a local write on a verdict; +//! the consequence it owns is the inbox offer ([`mod@preflight`], +//! [`transfer::push`] is the sole place a verdict gates a *remote* write). +//! +//! # Examples +//! +//! A same-actor divergence — two of one member's machines each editing a +//! *different* field of the same issue — resolved into a signed merge tip +//! the gate then accepts (`sync.divergence-merge`). +//! +//! ``` +//! use ents_gate::{Config, Update, Verdict, verify}; +//! use ents_model::{Issue, Provenance, namespace, trailer::Trailers}; +//! use ents_sync::resolve::{Heads, Merged, merge_heads}; +//! use ents_testutil::{ +//! CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity, +//! }; +//! +//! let refs = MemRefStore::default(); +//! let objects = ObjectStore::default(); +//! let jdc = Keypair::from_seed(1); +//! +//! // Enroll (bootstrap) and turn verification on by setting the epoch. +//! enroll_member(&refs, &objects, "jdc", &jdc, Provenance::AdminRegistered, 100); +//! let config: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid"); +//! write_meta_entity(&refs, &objects, config, &Config { epoch: Some(200) }, Some(&jdc), 200); +//! +//! let name: gix::refs::FullName = "refs/meta/issues/1".try_into().expect("valid"); +//! let issue = Issue { +//! title: "t".into(), body: "b".into(), state: "open".into(), +//! assignees: vec![], labels: vec![], +//! }; +//! let trailers = Trailers { ents_ref: Some(name.clone()), schema_version: None }; +//! let msg = |s: &str| format!("{s}\n\n{}", trailers.render()); +//! +//! // A common base, then two divergent children editing disjoint fields. +//! let base_tree = facet_git_tree::serialize_into(&issue, &objects).expect("ser"); +//! let base = write_commit(&objects, &CommitSpec { tree: base_tree, parents: vec![], message: msg("Open"), seconds: 300 }, Some(&jdc)); +//! +//! let mut renamed = issue.clone(); +//! renamed.title = "renamed".into(); +//! let ours_tree = facet_git_tree::serialize_into(&renamed, &objects).expect("ser"); +//! let ours = write_commit(&objects, &CommitSpec { tree: ours_tree, parents: vec![base], message: msg("Rename"), seconds: 400 }, Some(&jdc)); +//! +//! let mut closed = issue.clone(); +//! closed.state = "closed".into(); +//! let theirs_tree = facet_git_tree::serialize_into(&closed, &objects).expect("ser"); +//! let theirs = write_commit(&objects, &CommitSpec { tree: theirs_tree, parents: vec![base], message: msg("Close"), seconds: 400 }, Some(&jdc)); +//! +//! let author = gix::actor::Signature { +//! name: "jdc".into(), email: "jdc@ents.test".into(), +//! time: gix::date::Time { seconds: 500, offset: 0 }, +//! }; +//! let heads = Heads { refname: name.clone(), ours: Some(ours), theirs }; +//! let Merged::Tip(tip) = +//! merge_heads(&objects, &heads, &author, "Merge divergent heads", |p| jdc.sign(p)).expect("merges") +//! else { panic!("a same-actor divergence merges cleanly") }; +//! +//! // The merged tree carries *both* disjoint edits — the schema-aware +//! // property this crate's tests pin field-by-field. Here we show the +//! // consequence that matters to sync: the merge tip satisfies the tip +//! // invariant, so the gate accepts it advancing the ref from `ours`. +//! let snapshot = refs.fetched_copy(); +//! snapshot.set(name.as_ref(), ours); +//! let verdict = verify(&snapshot, &objects, &Update { name, new: Some(tip) }).expect("evaluates"); +//! assert!(matches!(verdict, Verdict::Pass(_))); +//! ``` + +mod error; +mod objects; + +pub mod merge; +pub mod preflight; +pub mod resolve; +pub mod transfer; + +pub use error::{Error, Result}; +pub use merge::{Merge, three_way}; +pub use preflight::{PreFlight, inbox_route, preflight}; +pub use resolve::{Heads, Merged, merge_heads}; +pub use transfer::{Diverged, FetchReport, Pushed, fetch, push};
crates/ents-sync/src/merge.rs @@ -1,0 +1,243 @@ +//! The schema-aware three-way merge over typed trees — the one hard part +//! of sync (`sync.divergence-merge`). +//! +//! A meta-ref's tip is a git tree that `facet-git-tree` mapped directly +//! from a `#[derive(Facet)]` struct (`meta-ref.typed-tree`): a struct's +//! fields are tree entries, a nested struct or collection is a sub-tree, +//! and a scalar or string is a blob. Because the tree *is* the schema, a +//! structural three-way merge over the tree is a field-by-field merge over +//! the entity — never a textual merge over serialized bytes, which +//! `sync.divergence-merge` forbids. +//! +//! The merge is content-addressed, so it needs no diff heuristics: two +//! sides agree exactly when their object ids are equal. For each entry +//! (each field, each collection element) the classic three-way rule +//! applies against the merge-base tree `base`: +//! +//! - both sides equal → keep it (includes both-deleted and both-made-the- +//! same-change); +//! - one side equals `base` → the *other* side changed it, so take the +//! other (a deletion included); +//! - both sides changed it differently → recurse if both are sub-trees +//! (a nested struct or collection merges field-by-field), otherwise it +//! is a genuine conflict at that path. +//! +//! The result is either a clean merged tree (a new [`ObjectId`], ready to +//! become a merge tip whose signature makes it satisfy the tip invariant) +//! or the set of conflicting paths for a human to resolve. + +use std::collections::{BTreeSet, HashMap}; + +use gix::bstr::{BString, ByteVec as _}; +use gix_hash::ObjectId; +use gix_object::tree::{Entry as TreeEntry, EntryKind, EntryMode}; +use gix_object::{Find, TreeRef, Write}; + +use crate::error::{Error, Result}; + +/// The outcome of a schema-aware three-way merge ([`three_way`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Merge { + /// The two sides merged cleanly into this tree. A merge tip recording + /// it, signed by an authorized member, satisfies the tip invariant + /// (`sync.divergence-merge`). + Clean(ObjectId), + /// The two sides changed the same leaf differently. Each entry is a + /// slash-joined path into the typed tree — a field name, or a field + /// name and a collection index, exactly as `facet-git-tree` names + /// them — so a caller can report *which* piece of the entity clashed. + Conflict(Vec<BString>), +} + +impl Merge { + /// The clean merged tree, or `None` if the merge conflicted. + #[must_use] + pub fn tree(&self) -> Option<ObjectId> { + match self { + Merge::Clean(oid) => Some(*oid), + Merge::Conflict(_) => None, + } + } +} + +/// One entry of a tree, reduced to what the merge compares: its object id +/// and whether it is a sub-tree (so recursion is possible) or a leaf. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Slot { + oid: ObjectId, + mode: EntryMode, +} + +impl Slot { + fn is_tree(self) -> bool { + self.mode.is_tree() + } +} + +/// Schema-aware three-way merge of two typed trees against their merge +/// base (`sync.divergence-merge`). +/// +/// `base` is the tree of the merge-base commit — `None` when the two heads +/// share no common ancestor, which the merge treats as an empty base (every +/// entry looks added on both sides, so any difference is a conflict rather +/// than a silent pick). `ours` and `theirs` are the two divergent trees; +/// the merge is commutative, so their roles are symmetric. +/// +/// Merged sub-trees and blobs are written into `objects`; the returned +/// [`Merge::Clean`] id is the root of the new tree. +/// +/// # Errors +/// +/// [`Error::Decode`] if a purported tree does not decode, [`Error::Object`] +/// or [`Error::Missing`] if one cannot be read, [`Error::Write`] if the +/// merged tree cannot be stored. +/// +/// # Examples +/// +/// A one-sided change is adopted wholesale — the field-level analogue of a +/// fast-forward: +/// +/// ``` +/// use ents_model::Issue; +/// use ents_sync::merge::{Merge, three_way}; +/// use ents_testutil::ObjectStore; +/// +/// let objects = ObjectStore::default(); +/// let issue = Issue { +/// title: "t".into(), body: "b".into(), state: "open".into(), +/// assignees: vec![], labels: vec![], +/// }; +/// let base = facet_git_tree::serialize_into(&issue, &objects).expect("ser"); +/// let mut closed = issue.clone(); +/// closed.state = "closed".into(); +/// let theirs = facet_git_tree::serialize_into(&closed, &objects).expect("ser"); +/// +/// // ours == base (we changed nothing); theirs advanced. +/// let merged = three_way(&objects, Some(base), base, theirs).expect("merges"); +/// assert_eq!(merged, Merge::Clean(theirs)); +/// ``` +// @relation(sync.divergence-merge, scope=function) +pub fn three_way( + objects: &(impl Find + Write), + base: Option<ObjectId>, + ours: ObjectId, + theirs: ObjectId, +) -> Result<Merge> { + // Content addressing makes the fast path exact: equal ids are equal + // subtrees, so identical sides need no walk at all. + if ours == theirs { + return Ok(Merge::Clean(ours)); + } + + let base_entries = match base { + Some(oid) => read_tree(objects, oid)?, + None => HashMap::new(), + }; + let ours_entries = read_tree(objects, ours)?; + let theirs_entries = read_tree(objects, theirs)?; + + let mut names: BTreeSet<&BString> = BTreeSet::new(); + names.extend(base_entries.keys()); + names.extend(ours_entries.keys()); + names.extend(theirs_entries.keys()); + + let mut merged: Vec<TreeEntry> = Vec::new(); + let mut conflicts: Vec<BString> = Vec::new(); + + for name in names { + let o = ours_entries.get(name).copied(); + let t = theirs_entries.get(name).copied(); + let b = base_entries.get(name).copied(); + + if o == t { + // Both sides agree, including both-absent and both-identical- + // change. Keep it when present. + push_slot(&mut merged, name, o); + } else if o == b { + // Ours is unchanged from base, so theirs owns this entry — + // a deletion included (`t == None`). + push_slot(&mut merged, name, t); + } else if t == b { + // Symmetric: theirs is unchanged, ours owns this entry. + push_slot(&mut merged, name, o); + } else { + // Both sides changed the same entry differently. A sub-tree on + // both sides is a nested struct or collection that can itself + // be merged field-by-field; anything else is a leaf conflict. + match (o, t) { + (Some(so), Some(st)) if so.is_tree() && st.is_tree() => { + let sub_base = b.filter(|s| s.is_tree()).map(|s| s.oid); + match three_way(objects, sub_base, so.oid, st.oid)? { + Merge::Clean(sub) => merged.push(TreeEntry { + mode: EntryKind::Tree.into(), + filename: name.clone(), + oid: sub, + }), + Merge::Conflict(paths) => { + for p in paths { + conflicts.push(join(name, &p)); + } + } + } + } + _ => conflicts.push(name.clone()), + } + } + } + + if conflicts.is_empty() { + // git tree entries are canonically sorted; `TreeEntry`'s own `Ord` + // is that order, matching how `facet-git-tree` writes trees. + merged.sort(); + let oid = objects.write(&gix_object::Tree { entries: merged })?; + Ok(Merge::Clean(oid)) + } else { + conflicts.sort(); + Ok(Merge::Conflict(conflicts)) + } +} + +/// Read the entries of the tree at `oid` into a name-keyed map. +fn read_tree(objects: &impl Find, oid: ObjectId) -> Result<HashMap<BString, Slot>> { + let mut buf = Vec::new(); + let data = objects + .try_find(&oid, &mut buf) + .map_err(|source| Error::Object { oid, source })? + .ok_or(Error::Missing { oid })?; + let tree = TreeRef::from_bytes(data.data, oid.kind()).map_err(|e| Error::Decode { + oid, + detail: e.to_string(), + })?; + let mut map = HashMap::with_capacity(tree.entries.len()); + for entry in &tree.entries { + map.insert( + entry.filename.to_owned(), + Slot { + oid: entry.oid.to_owned(), + mode: entry.mode, + }, + ); + } + Ok(map) +} + +/// Append `slot` to `merged` under `name`, if it is present (a `None` slot +/// is an entry deleted on the winning side, so nothing is written). +fn push_slot(merged: &mut Vec<TreeEntry>, name: &BString, slot: Option<Slot>) { + if let Some(slot) = slot { + merged.push(TreeEntry { + mode: slot.mode, + filename: name.clone(), + oid: slot.oid, + }); + } +} + +/// Join a parent entry name and a child path with `/`, the separator +/// `facet-git-tree` uses for nested tree paths. +fn join(parent: &BString, child: &BString) -> BString { + let mut path = parent.clone(); + path.push_char('/'); + path.push_str(child); + path +}
crates/ents-sync/src/objects.rs @@ -1,0 +1,111 @@ +//! Object-graph walks over gitoxide's `Find`/`Write` seams — the plumbing +//! shared by the merge machinery ([`crate::resolve`]) and forge transfer +//! ([`crate::transfer`]). No private object-access trait: gitoxide's own +//! traits are the seam (`arch.no-object-store-trait`). + +use std::collections::HashSet; + +use gix_hash::ObjectId; +use gix_object::{CommitRef, Find, Kind, TreeRef, Write}; + +use crate::error::{Error, Result}; + +/// The tree recorded by the commit at `oid`. +pub(crate) fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> { + let mut buf = Vec::new(); + let data = objects + .try_find(&oid, &mut buf) + .map_err(|source| Error::Object { oid, source })? + .ok_or(Error::Missing { oid })?; + let commit = CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| Error::Decode { + oid, + detail: e.to_string(), + })?; + Ok(commit.tree()) +} + +/// The parents of the commit at `oid`; an empty vec for a non-commit or a +/// root commit, so an incomplete (shallow) history simply ends a walk. +pub(crate) fn parents(objects: &impl Find, oid: ObjectId) -> Result<Vec<ObjectId>> { + let mut buf = Vec::new(); + let Some(data) = objects + .try_find(&oid, &mut buf) + .map_err(|source| Error::Object { oid, source })? + else { + return Ok(Vec::new()); + }; + if data.kind != Kind::Commit { + return Ok(Vec::new()); + } + let commit = CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| Error::Decode { + oid, + detail: e.to_string(), + })?; + Ok(commit.parents().collect()) +} + +/// Whether `descendant` reaches `ancestor` by parent edges (inclusive) — +/// the DAG sense of a fast-forward. Mirrors the gate's own descent check so +/// fetch advances a ref only when the remote truly descends from the local +/// tip (`gate.fast-forward`). +pub(crate) fn descends_from( + objects: &impl Find, + descendant: ObjectId, + ancestor: ObjectId, +) -> Result<bool> { + let mut stack = vec![descendant]; + let mut seen = HashSet::new(); + while let Some(oid) = stack.pop() { + if oid == ancestor { + return Ok(true); + } + if !seen.insert(oid) { + continue; + } + stack.extend(parents(objects, oid)?); + } + Ok(false) +} + +/// Copy every object reachable from `root` — the commit, its whole parent +/// chain, and every tree and blob those commits record — from `src` into +/// `dst`. Commit objects are copied verbatim, so their `gpgsig` signatures +/// travel with them: fetching a ref moves the complete audit history and +/// the signatures needed to verify it (`sync.forge-transfer`). +pub(crate) fn copy_closure(src: &impl Find, dst: &impl Write, root: ObjectId) -> Result<()> { + let mut stack = vec![root]; + let mut seen = HashSet::new(); + while let Some(oid) = stack.pop() { + if !seen.insert(oid) { + continue; + } + let mut buf = Vec::new(); + let data = src + .try_find(&oid, &mut buf) + .map_err(|source| Error::Object { oid, source })? + .ok_or(Error::Missing { oid })?; + let kind = data.kind; + let bytes = data.data.to_vec(); + dst.write_buf(kind, &bytes)?; + match kind { + Kind::Commit => { + let commit = + CommitRef::from_bytes(&bytes, oid.kind()).map_err(|e| Error::Decode { + oid, + detail: e.to_string(), + })?; + stack.push(commit.tree()); + stack.extend(commit.parents()); + } + Kind::Tree => { + let tree = TreeRef::from_bytes(&bytes, oid.kind()).map_err(|e| Error::Decode { + oid, + detail: e.to_string(), + })?; + stack.extend(tree.entries.iter().map(|e| e.oid.to_owned())); + } + Kind::Blob | Kind::Tag => {} + } + } + Ok(()) +}
crates/ents-sync/src/preflight.rs @@ -1,0 +1,162 @@ +//! Push pre-flight and inbox routing — turning the gate's verdict into a +//! decision the user acts on before pushing. +//! +//! Two requirements live here. [`preflight`] runs the *identical* gate +//! function every other call site runs ([`ents_gate::verify`], +//! `gate.call-sites`), so a pre-flight verdict is a prediction that can +//! only go stale between pre-flight and the hosted CAS, never one that is +//! wrong about the rules (`sync.pre-flight`). And [`inbox_route`] computes +//! the alternative a negative verdict offers: the same commit re-homed +//! under the author's own `refs/meta/inbox/<member>/*` segment, awaiting +//! adoption (`sync.inbox-routing`). +//! +//! The offer is a function of the verdict alone, so it is available the +//! moment the verdict goes negative — at all three advisory sites the spec +//! names (the local UI verdict at commit time, push pre-flight, and the +//! canonical store's actual rejection, `sync.inbox-routing`) — not only +//! after a push is attempted and refused. Sync never blocks a *local* +//! write on a failing verdict (`sync.local-advisory`); the consequence it +//! owns is exactly this inbox offer. + +use ents_gate::{Update, Verdict, verify}; +use ents_model::{MemberId, namespace}; +use gix::refs::{FullName, FullNameRef}; +use gix_object::Find; +use gix_ref_store::RefStoreRead; + +use crate::error::Result; + +/// A pre-flight prediction: the gate's verdict on a proposed update, plus +/// the inbox alternative a negative verdict offers (`sync.pre-flight`, +/// `sync.inbox-routing`). +/// +/// `verdict` is produced by the same [`ents_gate::verify`] the hosted store +/// runs at CAS time, so it predicts that outcome exactly up to staleness of +/// the last fetch — it cannot disagree about the rules (`gate.call-sites`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreFlight { + /// The gate's verdict on the proposed update. + pub verdict: Verdict, + /// Where the same commit could instead be routed — the author's own + /// inbox ref — when the verdict is a negative one the inbox can absorb + /// (`sync.inbox-routing`). `None` on a pass, and on refusals the inbox + /// cannot help (a divergence, whose answer is a merge, or a refname + /// mismatch). + pub inbox: Option<FullName>, +} + +impl PreFlight { + /// Whether the gate admits the update. A caller pushing to a hosted + /// store treats a false here as a prediction the push will be refused + /// — never as authority to block a *local* write (`sync.local-advisory`): + /// this type carries no write access at all, so a failing verdict is + /// structurally incapable of vetoing one; the rejection consequence + /// sync owns instead is [`PreFlight::inbox`] (`sync.inbox-routing`). + // @relation(sync.local-advisory, scope=function) + #[must_use] + pub fn is_pass(&self) -> bool { + self.verdict.is_pass() + } +} + +/// Evaluate push pre-flight for one proposed update (`sync.pre-flight`). +/// +/// Runs the identical gate function the hosted store runs +/// (`gate.call-sites`) against the local (last-fetched) snapshot, and, when +/// the verdict is a refusal the inbox can absorb, attaches the route the +/// author would use instead (`sync.inbox-routing`). `author` is the member +/// whose inbox segment such a routed commit would land under — its own, and +/// only its own (`meta-ref.inbox`). +/// +/// # Errors +/// +/// Propagates [`ents_gate::Error`] when the gate cannot evaluate (a store +/// or object read failed) and a refname error if the inbox route cannot be +/// built. +/// +/// # Examples +/// +/// ``` +/// use ents_model::{MemberId, Provenance, namespace}; +/// use ents_sync::preflight::preflight; +/// use ents_gate::Update; +/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member, write_meta_entity}; +/// +/// let refs = MemRefStore::default(); +/// let objects = ObjectStore::default(); +/// let admin = Keypair::from_seed(1); +/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100); +/// let config: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid"); +/// write_meta_entity(&refs, &objects, config, &ents_gate::Config { epoch: Some(200) }, Some(&admin), 200); +/// +/// // A self-attested contributor's canonical issue push fails pre-flight, +/// // and the inbox route is offered at once. +/// let bob = Keypair::from_seed(2); +/// enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250); +/// let name: gix::refs::FullName = "refs/meta/issues/9".try_into().expect("valid"); +/// let issue = ents_model::Issue { title: "t".into(), body: "b".into(), state: "open".into(), assignees: vec![], labels: vec![] }; +/// let tip = write_meta_entity(&refs, &objects, name.clone(), &issue, Some(&bob), 300); +/// +/// let before = refs.fetched_copy(); +/// before.remove(name.as_ref()); +/// let pf = preflight(&before, &objects, &Update { name, new: Some(tip) }, &MemberId::new("bob")).expect("evaluates"); +/// assert!(!pf.is_pass()); +/// assert_eq!(pf.inbox.expect("offered").as_bstr(), "refs/meta/inbox/bob/issues/9"); +/// ``` +// @relation(sync.pre-flight, sync.inbox-routing, scope=function) +pub fn preflight( + refs: &dyn RefStoreRead, + objects: &dyn Find, + update: &Update, + author: &MemberId, +) -> Result<PreFlight> { + let verdict = verify(refs, objects, update)?; + let inbox = match &verdict { + // The gate already decided whether the inbox is the alternative: + // `inbox_alternative` is set exactly on authorization refusals + // against a canonical ref, and cleared for divergences (answer: a + // merge) and refname mismatches (`gate.verdict-reason`, + // `gate.advisory-local`). + Verdict::Fail(refusal) if refusal.inbox_alternative => { + Some(inbox_route(update.name.as_ref(), author)?) + } + _ => None, + }; + Ok(PreFlight { verdict, inbox }) +} + +/// The inbox ref a rejected commit against `canonical` would be routed to, +/// under `author`'s own segment (`sync.inbox-routing`, `meta-ref.inbox`). +/// +/// The canonical ref's suffix below `refs/meta/` becomes the inbox id, so +/// `refs/meta/issues/42` routes to `refs/meta/inbox/<author>/issues/42`: +/// the destination records both who is submitting and what they submit, +/// and stays under the author's own segment, the only place a member may +/// write (`meta-ref.inbox`). A refname already outside `refs/meta/`, or an +/// already-inbox ref, is returned unchanged — there is nothing to re-route. +/// +/// # Errors +/// +/// Propagates a refname error if the composed inbox refname is invalid. +/// +/// # Examples +/// +/// ``` +/// use ents_model::MemberId; +/// use ents_sync::preflight::inbox_route; +/// +/// let canonical: gix::refs::FullName = "refs/meta/issues/42".try_into().expect("valid"); +/// let routed = inbox_route(canonical.as_ref(), &MemberId::new("jdc")).expect("valid"); +/// assert_eq!(routed.as_bstr(), "refs/meta/inbox/jdc/issues/42"); +/// ``` +// @relation(sync.inbox-routing, scope=function) +pub fn inbox_route(canonical: &FullNameRef, author: &MemberId) -> Result<FullName> { + if namespace::is_inbox(canonical) { + return Ok(canonical.to_owned()); + } + let path = canonical.as_bstr().to_string(); + let Some(suffix) = path.strip_prefix("refs/meta/") else { + return Ok(canonical.to_owned()); + }; + Ok(namespace::inbox_ref(author, suffix)?) +}
crates/ents-sync/src/resolve.rs @@ -1,0 +1,248 @@ +//! One merge machinery for every same-tip reconciliation sync performs: +//! same-actor divergence (`sync.divergence-merge`, +//! `gate.same-actor-divergence`) and adoption — a maintainer folding an +//! inbox entity onto its canonical ref, or a member's self-run results onto +//! the canonical results ref (`sync.adoption-machinery`, +//! `gate.adoption-merge`). +//! +//! All three are the same operation: take the authorized side's current tip +//! (`ours`) and the head being folded in (`theirs`), merge their typed trees +//! three-way against the merge base ([`crate::merge::three_way`]), and record +//! the result as a two-parent merge commit signed by the placing member. +//! There is deliberately no separate adoption code path +//! (`sync.adoption-machinery`), and deliberately no cherry-pick: `theirs` +//! stays a parent, so the contributor's original signed commit — and its +//! attribution — remains in ancestry (`sync.adoption-no-cherry-pick`). A +//! cherry-pick would instead create a fresh commit by the placer and destroy +//! the author's signature, which no gate could detect after the fact, so this +//! property is the machinery's to keep, not the gate's. + +use std::collections::HashSet; + +use gix::bstr::BString; +use gix::refs::FullName; +use gix_hash::ObjectId; +use gix_object::{Commit, Find, Kind, Write, WriteTo as _}; + +use ents_model::trailer::Trailers; + +use crate::error::{Error, Result}; +use crate::merge::{Merge, three_way}; +use crate::objects::{commit_tree, parents}; + +/// The two heads a merge reconciles onto one ref. +/// +/// `ours` is the tip of the authorized side — the canonical ref the merge +/// tip will advance, or `None` when that ref does not exist yet (adopting a +/// contributor's brand-new entity onto a canonical ref that has no prior +/// tip). `theirs` is the head being folded in: the other machine's tip in a +/// divergence, or the contributor's inbox / self-run tip in an adoption. +#[derive(Debug, Clone)] +pub struct Heads { + /// The ref the resulting merge tip advances (its `Advance-ref` trailer). + pub refname: FullName, + /// The authorized side's current tip, or `None` if the ref is new. + pub ours: Option<ObjectId>, + /// The head being folded in — always kept as a parent, never + /// cherry-picked (`sync.adoption-no-cherry-pick`). + pub theirs: ObjectId, +} + +/// The result of [`merge_heads`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Merged { + /// A signed merge tip that advances [`Heads::refname`]. It descends from + /// both parents, so an authorized signature makes it satisfy the tip + /// invariant (`sync.divergence-merge`); `theirs` is in its ancestry with + /// attribution intact (`sync.adoption-no-cherry-pick`). + Tip(ObjectId), + /// The two heads changed the same leaf of the typed tree differently. + /// Each path is a field (and, for a collection, an index) into the + /// entity — a human resolves it before the merge can complete. + Conflict(Vec<BString>), +} + +/// Resolve two divergent heads into one signed merge tip, or report the +/// conflicting paths — the single machinery divergence and adoption share +/// (`sync.divergence-merge`, `sync.adoption-machinery`). +/// +/// The typed trees of `ours` and `theirs` are merged three-way against +/// their merge base; a clean merge is recorded as a merge commit whose +/// parents are `[ours, theirs]` (just `[theirs]` when the canonical ref is +/// new), authored and committed by `author`, bound to [`Heads::refname`] by +/// the `Advance-ref` trailer, and signed by `sign`. `sign` returns the +/// armored SSHSIG PEM for the commit's payload — exactly what git stores in +/// the `gpgsig` header — so the composition root injects the placing +/// member's key without this crate ever holding one. +/// +/// Because `theirs` is always a parent, the contributor's original signed +/// commit stays in ancestry: this is a merge, never a cherry-pick +/// (`sync.adoption-no-cherry-pick`). The placer signs the *tip*, which is +/// what makes an authorized member's merge the legitimate adoption mechanism +/// (`gate.adoption-merge`) rather than a direct fast-forward to an +/// unauthorized signature (`gate.adoption-no-fast-forward`). +/// +/// # Errors +/// +/// Propagates object read/decode/write failures from the merge and from +/// building the commit. +/// +/// # Examples +/// +/// ``` +/// use ents_model::{Provenance, namespace}; +/// use ents_sync::resolve::{Heads, Merged, merge_heads}; +/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member, write_meta_entity}; +/// +/// let refs = MemRefStore::default(); +/// let objects = ObjectStore::default(); +/// let key = Keypair::from_seed(1); +/// enroll_member(&refs, &objects, "jdc", &key, Provenance::AdminRegistered, 100); +/// +/// // Two of jdc's machines diverged on the same single-writer ref. +/// let name: gix::refs::FullName = "refs/meta/issues/1".try_into().expect("valid"); +/// let issue = ents_model::Issue { +/// title: "t".into(), body: "b".into(), state: "open".into(), +/// assignees: vec![], labels: vec![], +/// }; +/// let ours = write_meta_entity(&refs, &objects, name.clone(), &issue, Some(&key), 200); +/// let mut other = issue.clone(); +/// other.state = "closed".into(); +/// let theirs = write_meta_entity(&refs, &objects, name.clone(), &other, Some(&key), 300); +/// +/// let author = gix::actor::Signature { +/// name: "jdc".into(), email: "jdc@ents.test".into(), +/// time: gix::date::Time { seconds: 400, offset: 0 }, +/// }; +/// let heads = Heads { refname: name, ours: Some(ours), theirs }; +/// let merged = merge_heads(&objects, &heads, &author, "Merge divergent heads", +/// |payload| key.sign(payload)).expect("merges"); +/// assert!(matches!(merged, Merged::Tip(_))); +/// ``` +// @relation(sync.divergence-merge, sync.adoption-machinery, sync.adoption-no-cherry-pick, scope=function) +pub fn merge_heads( + objects: &(impl Find + Write), + heads: &Heads, + author: &gix::actor::Signature, + summary: &str, + sign: impl FnOnce(&[u8]) -> String, +) -> Result<Merged> { + let theirs_tree = commit_tree(objects, heads.theirs)?; + + let (tree, parents) = match heads.ours { + // Adopting onto a ref with no prior tip: nothing to merge, but + // `theirs` still becomes the sole parent so attribution survives — + // a degenerate merge, never a cherry-pick. + None => (theirs_tree, vec![heads.theirs]), + Some(ours) => { + let base = merge_base(objects, ours, heads.theirs)?; + let base_tree = base.map(|b| commit_tree(objects, b)).transpose()?; + let ours_tree = commit_tree(objects, ours)?; + match three_way(objects, base_tree, ours_tree, theirs_tree)? { + Merge::Clean(tree) => (tree, vec![ours, heads.theirs]), + Merge::Conflict(paths) => return Ok(Merged::Conflict(paths)), + } + } + }; + + let tip = seal( + objects, + tree, + parents, + &heads.refname, + author, + summary, + sign, + )?; + Ok(Merged::Tip(tip)) +} + +/// Build and sign the merge commit — the tip whose signature, not any tree +/// content, is what satisfies the tip invariant. +fn seal( + objects: &impl Write, + tree: ObjectId, + parents: Vec<ObjectId>, + refname: &FullName, + author: &gix::actor::Signature, + summary: &str, + sign: impl FnOnce(&[u8]) -> String, +) -> Result<ObjectId> { + let trailers = Trailers { + ents_ref: Some(refname.clone()), + schema_version: None, + }; + let message = format!("{summary}\n\n{}", trailers.render()); + let mut commit = Commit { + tree, + parents: parents.into(), + author: author.clone(), + committer: author.clone(), + encoding: None, + message: message.into(), + extra_headers: Vec::new(), + }; + + // Sign exactly as `git commit -S` does: SSHSIG over the commit + // serialized *without* its gpgsig header, stored back as that header — + // so the signature is repository data that verifies offline + // (`gate.signature-artifact`). + let mut payload = Vec::new(); + commit.write_to(&mut payload).map_err(|e| Error::Decode { + oid: tree, + detail: format!("serializing merge commit failed: {e}"), + })?; + let pem = sign(&payload); + commit + .extra_headers + .push(("gpgsig".into(), pem.trim_end().into())); + + let mut raw = Vec::new(); + commit.write_to(&mut raw).map_err(|e| Error::Decode { + oid: tree, + detail: format!("serializing signed merge commit failed: {e}"), + })?; + Ok(objects.write_buf(Kind::Commit, &raw)?) +} + +/// The nearest common ancestor of `a` and `b` by parent edges, or `None` +/// when they share no ancestor (each is then merged against an empty base). +/// +/// This is a breadth-first nearest-ancestor search: it collects every +/// ancestor of `a`, then walks `b`'s ancestry breadth-first and returns the +/// first commit already seen from `a`. For the divergence and adoption +/// shapes sync produces — two lines splitting from one common tip — that is +/// the true merge base. It does not resolve the multiple-merge-base +/// criss-cross case optimally; a stricter base would only ever *reduce* +/// spurious conflicts, never admit a wrong clean merge, since the three-way +/// rule keeps a field only when at least one side matches the base. +fn merge_base(objects: &impl Find, a: ObjectId, b: ObjectId) -> Result<Option<ObjectId>> { + let ancestors_of_a = ancestors(objects, a)?; + let mut queue = std::collections::VecDeque::from([b]); + let mut seen = HashSet::new(); + while let Some(oid) = queue.pop_front() { + if !seen.insert(oid) { + continue; + } + if ancestors_of_a.contains(&oid) { + return Ok(Some(oid)); + } + for parent in parents(objects, oid)? { + queue.push_back(parent); + } + } + Ok(None) +} + +/// Every commit reachable from `oid` by parent edges, inclusive. +fn ancestors(objects: &impl Find, oid: ObjectId) -> Result<HashSet<ObjectId>> { + let mut seen = HashSet::new(); + let mut stack = vec![oid]; + while let Some(oid) = stack.pop() { + if !seen.insert(oid) { + continue; + } + stack.extend(parents(objects, oid)?); + } + Ok(seen) +}
crates/ents-sync/src/transfer.rs @@ -1,0 +1,208 @@ +//! Fetch and push over `refs/meta/*` — the routine plumbing that moves the +//! forge itself, not merely code (`sync.forge-transfer`). +//! +//! Both directions copy the *complete* object closure of every meta-ref — +//! each ref's whole commit chain and all its trees and blobs, commit +//! objects verbatim so their signatures travel too — so a clone plus +//! `refs/meta/*` carries the entire audit history and the signatures needed +//! to verify it, with nothing left server-side (`sync.forge-transfer`). +//! +//! A remote and a local repository are each just a ([`RefStoreRead`]/ +//! [`RefStore`], `Find`/`Write`) pair; transfer is expressed directly over +//! those seams, with no bespoke transport type. [`fetch`] advances every +//! local meta-ref that the remote fast-forwards and reports the ones that +//! diverged, feeding them to the merge machinery ([`crate::resolve`]). +//! [`push`] runs pre-flight against the remote's own policy before moving a +//! ref, so a rejected canonical push surfaces the inbox alternative instead +//! (`sync.pre-flight`, `sync.inbox-routing`). + +use ents_gate::Update; +use ents_model::MemberId; +use gix::refs::FullName; +use gix_hash::ObjectId; +use gix_object::{Find, Write}; +use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead, TxOutcome}; + +use crate::error::Result; +use crate::objects::{copy_closure, descends_from}; +use crate::preflight::{PreFlight, preflight}; + +/// The meta-ref prefix that scopes every forge transfer. +const META_PREFIX: &str = "refs/meta/"; + +/// A remote meta-ref that has moved out from under the local tip: neither +/// side descends from the other, so a fast-forward is impossible and the +/// answer is a merge ([`crate::resolve::merge_heads`], `sync.divergence-merge`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diverged { + /// The ref that diverged. + pub name: FullName, + /// The local tip. + pub local: ObjectId, + /// The remote tip. + pub remote: ObjectId, +} + +/// What [`fetch`] did to the local `refs/meta/*` set. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FetchReport { + /// Refs advanced to the remote tip (created, or fast-forwarded). + pub updated: Vec<FullName>, + /// Refs already at the remote tip; nothing to do. + pub unchanged: Vec<FullName>, + /// Refs whose local and remote tips diverged — resolve by merging. + pub diverged: Vec<Diverged>, +} + +/// Fetch every `refs/meta/*` ref from `remote` into `local`, moving the +/// whole forge (`sync.forge-transfer`). +/// +/// For each remote meta-ref the full object closure is copied into +/// `local_objects` first — so the ref never points at an object the local +/// store lacks — then the local ref is advanced if the remote is a +/// fast-forward, left alone if already current, and reported as +/// [`Diverged`] otherwise. Object copy is unconditional even for a +/// divergence, since the subsequent merge needs both heads present locally. +/// +/// # Errors +/// +/// Propagates ref-store and object failures; a stale CAS during the ref +/// update surfaces as an [`crate::Error`]-free skip is *not* done here — +/// fetch runs single-writer against the local store. +/// +/// # Examples +/// +/// ``` +/// use ents_model::Provenance; +/// use ents_sync::transfer::fetch; +/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member}; +/// use gix_ref_store::RefStoreRead; +/// +/// // The "remote" is just another ref-store / object-store pair. +/// let remote_refs = MemRefStore::default(); +/// let remote_objects = ObjectStore::default(); +/// let key = Keypair::from_seed(1); +/// enroll_member(&remote_refs, &remote_objects, "jdc", &key, Provenance::AdminRegistered, 100); +/// +/// let local_refs = MemRefStore::default(); +/// let local_objects = ObjectStore::default(); +/// let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).expect("fetches"); +/// assert_eq!(report.updated.len(), 1); +/// +/// let name: gix::refs::FullName = "refs/meta/member/jdc".try_into().expect("valid"); +/// assert!(local_refs.get(name.as_ref()).expect("readable").is_some()); +/// ``` +// @relation(sync.forge-transfer, scope=function) +pub fn fetch( + remote_refs: &dyn RefStoreRead, + remote_objects: &impl Find, + local_refs: &dyn RefStore, + local_objects: &(impl Find + Write), +) -> Result<FetchReport> { + let mut report = FetchReport::default(); + for entry in remote_refs.iter_prefix(META_PREFIX)? { + let (name, remote_tip) = entry?; + copy_closure(remote_objects, local_objects, remote_tip)?; + + let local_tip = local_refs.get(name.as_ref())?; + match local_tip { + Some(local) if local == remote_tip => report.unchanged.push(name), + Some(local) if descends_from(local_objects, remote_tip, local)? => { + advance( + local_refs, + &name, + Expected::MustExistAndMatch(local), + remote_tip, + )?; + report.updated.push(name); + } + Some(local) => report.diverged.push(Diverged { + name, + local, + remote: remote_tip, + }), + None => { + advance(local_refs, &name, Expected::MustNotExist, remote_tip)?; + report.updated.push(name); + } + } + } + Ok(report) +} + +/// The outcome of pushing one local meta-ref to a remote ([`push`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Pushed { + /// Pre-flight passed and the ref was transferred and advanced on the + /// remote. + Advanced(FullName), + /// Pre-flight predicted a rejection the inbox can absorb; the ref was + /// *not* pushed, and this is the inbox route offered instead + /// (`sync.inbox-routing`). + Inbox(FullName), + /// Pre-flight predicted a rejection the inbox cannot absorb (a + /// divergence — merge first — or a refname mismatch). The ref was not + /// pushed; the prediction is carried for the caller to render. + Refused(Box<PreFlight>), +} + +/// Push one local meta-ref `name` to `remote`, pre-flighting against the +/// remote's own policy first (`sync.pre-flight`). +/// +/// The local tip's object closure is copied to the remote only once +/// pre-flight passes; a predicted rejection routes to the inbox +/// (`sync.inbox-routing`) or is reported, and nothing is transferred. This +/// runs the identical gate the remote will run at CAS time +/// (`gate.call-sites`), so the result is a prediction that can only be +/// stale, never wrong about the rules. Local writes are never blocked by +/// this — that is [`mod@crate::preflight`]'s and the local store's concern +/// (`sync.local-advisory`); push is the one place a verdict gates an +/// actual (remote) write. +/// +/// # Errors +/// +/// Propagates pre-flight, ref-store, and object failures. +// @relation(sync.pre-flight, sync.inbox-routing, sync.forge-transfer, scope=function) +pub fn push( + remote_refs: &dyn RefStore, + remote_objects: &(impl Find + Write), + local_objects: &impl Find, + name: &FullName, + local_tip: ObjectId, + author: &MemberId, +) -> Result<Pushed> { + let update = Update { + name: name.clone(), + new: Some(local_tip), + }; + // Pre-flight needs the proposed objects visible in the store it reads, + // exactly as the hosted CAS would after ingest; copy first, then judge. + copy_closure(local_objects, remote_objects, local_tip)?; + let pf = preflight(remote_refs, remote_objects, &update, author)?; + if !pf.is_pass() { + return Ok(match pf.inbox { + Some(inbox) => Pushed::Inbox(inbox), + None => Pushed::Refused(Box::new(pf)), + }); + } + + let expected = remote_refs + .get(name.as_ref())? + .map_or(Expected::MustNotExist, Expected::MustExistAndMatch); + advance(remote_refs, name, expected, local_tip)?; + Ok(Pushed::Advanced(name.clone())) +} + +/// Apply one ref advance as a single-edit CAS transaction. +fn advance( + refs: &dyn RefStore, + name: &FullName, + expected: Expected, + new: ObjectId, +) -> Result<TxOutcome> { + Ok(refs.transaction(&[RefEdit { + name: name.clone(), + expected, + new: Some(new), + }])?) +}
crates/ents-sync/tests/merge.rs @@ -1,0 +1,155 @@ +//! Property tests for the schema-aware three-way merge (`sync.divergence-merge`). +//! +//! Strategy: **proptest** — the spec states an algebraic invariant (a merge +//! that respects the typed tree's schema, field by field) over an +//! unenumerable input space, so example rows cannot stand in for it. The +//! properties pinned here are the ones a schema-aware merge must uphold: +//! disjoint field edits combine (never conflict), the same field changed +//! two ways conflicts, and the merge is commutative in its two sides. + +#![expect(clippy::unwrap_used, clippy::expect_used, reason = "tests")] + +use ents_model::{Issue, MemberId}; +use ents_sync::merge::{Merge, three_way}; +use ents_testutil::ObjectStore; +use proptest::prelude::*; + +/// A base issue with room to edit every field independently. +fn issue_strategy() -> impl Strategy<Value = Issue> { + ( + "[a-z]{1,8}", + "[a-z]{1,8}", + prop::sample::select(vec!["open", "closed"]), + prop::collection::vec("[a-z]{1,5}", 0..3), + prop::collection::vec("[a-z]{1,5}", 0..3), + ) + .prop_map(|(title, body, state, assignees, labels)| Issue { + title, + body, + state: state.to_string(), + assignees: assignees.into_iter().map(MemberId::new).collect(), + labels, + }) +} + +/// Apply a deterministic, value-changing edit to field `i` (0..5). +fn edit_field(issue: &mut Issue, i: usize) { + match i { + 0 => issue.title.push_str("-edit"), + 1 => issue.body.push_str("-edit"), + 2 => { + issue.state = if issue.state == "open" { + "closed" + } else { + "open" + } + .to_string() + } + 3 => issue.assignees.push(MemberId::new("added")), + _ => issue.labels.push("added".to_string()), + } +} + +fn ser(objects: &ObjectStore, issue: &Issue) -> gix_hash::ObjectId { + facet_git_tree::serialize_into(issue, objects).unwrap() +} + +fn de(objects: &ObjectStore, tree: gix_hash::ObjectId) -> Issue { + facet_git_tree::deserialize(&tree, objects).unwrap() +} + +proptest! { + /// Each side changes a *disjoint* set of fields, so the merge must fold + /// both sets in with no conflict — the concrete meaning of "schema-aware + /// three-way merge over the typed tree" (`sync.divergence-merge`): the + /// merged entity carries every field's winning value, resolved per field. + // @relation(sync.divergence-merge, scope=function, role=Verifies) + #[test] + fn disjoint_field_edits_merge_field_by_field( + base in issue_strategy(), + owners in prop::collection::vec(0u8..3, 5), + ) { + let objects = ObjectStore::default(); + let mut ours = base.clone(); + let mut theirs = base.clone(); + let mut expected = base.clone(); + for (i, &owner) in owners.iter().enumerate() { + match owner { + 1 => { edit_field(&mut ours, i); edit_field(&mut expected, i); } + 2 => { edit_field(&mut theirs, i); edit_field(&mut expected, i); } + _ => {} + } + } + + let b = ser(&objects, &base); + let o = ser(&objects, &ours); + let t = ser(&objects, &theirs); + + let merged = three_way(&objects, Some(b), o, t).unwrap(); + let tree = merged.tree().expect("disjoint edits never conflict"); + prop_assert_eq!(de(&objects, tree), expected); + } + + /// Both sides change the *same* scalar field to different values: no + /// content-addressed pick is possible, so the merge must report that + /// field as a conflict rather than silently choose one. + // @relation(sync.divergence-merge, scope=function, role=Verifies) + #[test] + fn same_field_divergent_edits_conflict(base in issue_strategy()) { + let objects = ObjectStore::default(); + let mut ours = base.clone(); + ours.title.push_str("-ours"); + let mut theirs = base.clone(); + theirs.title.push_str("-theirs"); + + let b = ser(&objects, &base); + let o = ser(&objects, &ours); + let t = ser(&objects, &theirs); + + let merged = three_way(&objects, Some(b), o, t).unwrap(); + prop_assert_eq!(merged, Merge::Conflict(vec!["title".into()])); + } + + /// The merge is commutative in its two sides: swapping `ours` and + /// `theirs` yields the identical clean tree, or the identical conflict + /// set. A resolution that depended on argument order would silently + /// disagree with itself across two machines. + // @relation(sync.divergence-merge, scope=function, role=Verifies) + #[test] + fn merge_is_commutative( + base in issue_strategy(), + ours in issue_strategy(), + theirs in issue_strategy(), + ) { + let objects = ObjectStore::default(); + let b = ser(&objects, &base); + let o = ser(&objects, &ours); + let t = ser(&objects, &theirs); + + let forward = three_way(&objects, Some(b), o, t).unwrap(); + let backward = three_way(&objects, Some(b), t, o).unwrap(); + + match (forward, backward) { + (Merge::Clean(x), Merge::Clean(y)) => prop_assert_eq!(x, y), + (Merge::Conflict(a), Merge::Conflict(bb)) => prop_assert_eq!(a, bb), + (f, bk) => prop_assert!(false, "clean-ness must match: {:?} vs {:?}", f, bk), + } + } + + /// A side that did not move is a no-op: `three_way(base, base, theirs)` + /// adopts `theirs` wholesale — the field-level analogue of a + /// fast-forward, and the trivial-merge case adoption relies on. + // @relation(sync.divergence-merge, scope=function, role=Verifies) + #[test] + fn one_sided_change_adopts_the_other( + base in issue_strategy(), + theirs in issue_strategy(), + ) { + let objects = ObjectStore::default(); + let b = ser(&objects, &base); + let t = ser(&objects, &theirs); + + prop_assert_eq!(three_way(&objects, Some(b), b, t).unwrap(), Merge::Clean(t)); + prop_assert_eq!(three_way(&objects, Some(b), b, b).unwrap(), Merge::Clean(b)); + } +}
crates/ents-sync/tests/preflight.rs @@ -1,0 +1,230 @@ +//! Pre-flight, inbox routing, and the local-advisory boundary +//! (`sync.pre-flight`, `sync.inbox-routing`, `sync.local-advisory`). +//! +//! Strategy: **rstest table-driven** for the enumerable cases (pass vs the +//! two kinds of refusal, and the refname-mapping table for [`inbox_route`]), +//! plus targeted integration tests for the two invariants that are about +//! *identity* rather than a case: a pre-flight verdict equals the gate's own +//! verdict on the same inputs (`sync.pre-flight`), and a failing pre-flight +//! never blocks a local write (`sync.local-advisory`). + +#![expect(clippy::unwrap_used, reason = "tests")] + +use ents_gate::{Config, Update, verify}; +use ents_model::trailer::Trailers; +use ents_model::{Issue, MemberId, Provenance, namespace}; +use ents_sync::{inbox_route, preflight}; +use ents_testutil::{ + CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity, +}; +use gix::refs::FullName; +use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead}; +use rstest::rstest; + +fn issue() -> Issue { + Issue { + title: "t".into(), + body: "b".into(), + state: "open".into(), + assignees: vec![], + labels: vec![], + } +} + +/// A booted forge (admin enrolled, epoch set) plus a self-attested bob. +fn forge() -> (MemRefStore, ObjectStore, Keypair, Keypair) { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let admin = Keypair::from_seed(1); + let bob = Keypair::from_seed(2); + enroll_member( + &refs, + &objects, + "admin", + &admin, + Provenance::AdminRegistered, + 100, + ); + let config: FullName = namespace::CONFIG_REF.try_into().unwrap(); + write_meta_entity( + &refs, + &objects, + config, + &Config { epoch: Some(200) }, + Some(&admin), + 200, + ); + enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250); + (refs, objects, admin, bob) +} + +/// A pre-flight against a canonical push that the pusher is not authorized +/// for offers the author's own inbox route the instant the verdict goes +/// negative (`sync.inbox-routing`), while an authorized push offers none. +#[rstest] +#[case::authorized_pass(1, true, false)] +#[case::unauthorized_offers_inbox(2, false, true)] +// @relation(sync.pre-flight, sync.inbox-routing, scope=function, role=Verifies) +fn preflight_offers_inbox_only_on_an_authorization_refusal( + #[case] seed: u8, + #[case] expect_pass: bool, + #[case] expect_inbox: bool, +) { + let (refs, objects, admin, bob) = forge(); + let signer = if seed == 1 { &admin } else { &bob }; + let author = if seed == 1 { "admin" } else { "bob" }; + + let name: FullName = "refs/meta/issues/9".try_into().unwrap(); + let tip = write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(signer), 300); + + let before = refs.fetched_copy(); + before.remove(name.as_ref()); + let pf = preflight( + &before, + &objects, + &Update { + name, + new: Some(tip), + }, + &MemberId::new(author), + ) + .unwrap(); + + assert_eq!(pf.is_pass(), expect_pass); + assert_eq!(pf.inbox.is_some(), expect_inbox); + if let Some(inbox) = pf.inbox { + assert_eq!(inbox.as_bstr(), "refs/meta/inbox/bob/issues/9"); + } +} + +/// A fast-forward refusal is a *divergence* — the answer is a merge, not the +/// inbox — so pre-flight offers no inbox route for it, matching the gate's +/// own `inbox_alternative` signal (`sync.inbox-routing`). +// @relation(sync.inbox-routing, scope=function, role=Verifies) +#[test] +fn a_divergence_refusal_offers_no_inbox() { + let (refs, objects, admin, _bob) = forge(); + let name: FullName = "refs/meta/issues/3".try_into().unwrap(); + write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(&admin), 300); + + // An independent root correctly bound to the same ref: signed by an + // authorized member with a matching trailer, but with no parents, so it + // cannot descend from the current tip — a genuine fast-forward refusal. + let mut other = issue(); + other.state = "closed".into(); + let sibling = { + let tree = facet_git_tree::serialize_into(&other, &objects).unwrap(); + let trailers = Trailers { + ents_ref: Some(name.clone()), + schema_version: None, + }; + let message = format!("Mutate {}\n\n{}", name.as_bstr(), trailers.render()); + write_commit( + &objects, + &CommitSpec { + tree, + parents: vec![], + message, + seconds: 350, + }, + Some(&admin), + ) + }; + + let pf = preflight( + &refs, + &objects, + &Update { + name: name.clone(), + new: Some(sibling), + }, + &MemberId::new("admin"), + ) + .unwrap(); + assert!(!pf.is_pass()); + assert!( + pf.inbox.is_none(), + "a divergence routes to a merge, not the inbox" + ); +} + +/// Pre-flight runs the *identical* gate function every call site runs, so +/// its verdict is always exactly the gate's verdict on the same inputs — a +/// prediction that can only be stale, never wrong about the rules +/// (`sync.pre-flight`, `gate.call-sites`). +// @relation(sync.pre-flight, scope=function, role=Verifies) +#[test] +fn preflight_verdict_equals_the_gate_verdict() { + let (refs, objects, admin, bob) = forge(); + + for (author, signer) in [("admin", &admin), ("bob", &bob)] { + let name: FullName = format!("refs/meta/issues/{author}").try_into().unwrap(); + let tip = write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(signer), 300); + let before = refs.fetched_copy(); + before.remove(name.as_ref()); + let update = Update { + name, + new: Some(tip), + }; + + let pf = preflight(&before, &objects, &update, &MemberId::new(author)).unwrap(); + let gate = verify(&before, &objects, &update).unwrap(); + assert_eq!( + pf.verdict, gate, + "pre-flight must be the gate, not a copy of it" + ); + } +} + +/// Sync honors the gate's advisory role locally: a failing pre-flight is a +/// prediction, not a veto — the same commit still writes to the local store +/// (`sync.local-advisory`). The consequence sync owns is the inbox offer, +/// which the failing pre-flight already surfaced. +// @relation(sync.local-advisory, scope=function, role=Verifies) +#[test] +fn a_failing_preflight_never_blocks_the_local_write() { + let (refs, objects, _admin, bob) = forge(); + let name: FullName = "refs/meta/issues/9".try_into().unwrap(); + let tip = write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(&bob), 300); + + let before = refs.fetched_copy(); + before.remove(name.as_ref()); + let pf = preflight( + &before, + &objects, + &Update { + name: name.clone(), + new: Some(tip), + }, + &MemberId::new("bob"), + ) + .unwrap(); + assert!(!pf.is_pass()); + assert!( + pf.inbox.is_some(), + "the rejection consequence is an inbox offer" + ); + + // Nothing sync did prevents the local write from applying. + let outcome = before + .transaction(&[RefEdit { + name: name.clone(), + expected: Expected::MustNotExist, + new: Some(tip), + }]) + .unwrap(); + assert_eq!(before.get(name.as_ref()).unwrap(), Some(tip)); + let _ = outcome; +} + +#[rstest] +#[case::canonical("refs/meta/issues/42", "refs/meta/inbox/jdc/issues/42")] +#[case::nested("refs/meta/results/unit/abc", "refs/meta/inbox/jdc/results/unit/abc")] +#[case::already_inbox("refs/meta/inbox/jdc/issues/1", "refs/meta/inbox/jdc/issues/1")] +#[case::non_meta("refs/heads/main", "refs/heads/main")] +// @relation(sync.inbox-routing, scope=function, role=Verifies) +fn inbox_route_maps_canonical_to_the_authors_segment(#[case] input: &str, #[case] expected: &str) { + let canonical: FullName = input.try_into().unwrap(); + let routed = inbox_route(canonical.as_ref(), &MemberId::new("jdc")).unwrap(); + assert_eq!(routed.as_bstr(), expected); +}
crates/ents-sync/tests/resolve.rs @@ -1,0 +1,267 @@ +//! Divergence and adoption over the one merge machinery +//! (`sync.divergence-merge`, `sync.adoption-machinery`, +//! `sync.adoption-no-cherry-pick`). +//! +//! Strategy: **integration harness** — these are enumerable end-to-end +//! scenarios whose point is that a real signed merge tip, built by +//! [`ents_sync::merge_heads`], is accepted by the *real* gate +//! ([`ents_gate::verify`]) and keeps the folded-in commit in ancestry. A +//! property test would not add coverage over the specific shapes the spec +//! names; the value is in exercising the same function the production path +//! runs against genuine git objects and signatures. + +#![expect( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests" +)] + +use ents_gate::{Config, Update, Verdict, verify}; +use ents_model::trailer::Trailers; +use ents_model::{Issue, Provenance, namespace}; +use ents_sync::{Heads, Merged, merge_heads}; +use ents_testutil::{ + CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity, +}; +use gix::refs::FullName; +use gix_hash::ObjectId; + +fn issue(state: &str) -> Issue { + Issue { + title: "t".into(), + body: "b".into(), + state: state.into(), + assignees: vec![], + labels: vec![], + } +} + +/// Build a signed commit recording `entity`, bound to `refname`, with the +/// given parents — the general shape [`write_meta_entity`] specializes. +fn signed_commit( + objects: &ObjectStore, + refname: &FullName, + entity: &Issue, + parents: Vec<ObjectId>, + key: &Keypair, + seconds: i64, +) -> ObjectId { + let tree = facet_git_tree::serialize_into(entity, objects).unwrap(); + let trailers = Trailers { + ents_ref: Some(refname.clone()), + schema_version: None, + }; + let message = format!("Mutate {}\n\n{}", refname.as_bstr(), trailers.render()); + write_commit( + objects, + &CommitSpec { + tree, + parents, + message, + seconds, + }, + Some(key), + ) +} + +fn author(seconds: i64) -> gix::actor::Signature { + gix::actor::Signature { + name: "placer".into(), + email: "placer@ents.test".into(), + time: gix::date::Time { seconds, offset: 0 }, + } +} + +/// Enroll `admin` and turn verification on by recording the epoch. +fn boot(refs: &MemRefStore, objects: &ObjectStore, admin: &Keypair) { + enroll_member( + refs, + objects, + "admin", + admin, + Provenance::AdminRegistered, + 100, + ); + let config: FullName = namespace::CONFIG_REF.try_into().unwrap(); + write_meta_entity( + refs, + objects, + config, + &Config { epoch: Some(200) }, + Some(admin), + 200, + ); +} + +fn parents_of(objects: &ObjectStore, tip: ObjectId) -> Vec<ObjectId> { + match objects.get(&tip).expect("tip present") { + gix::objs::Object::Commit(c) => c.parents.into_vec(), + _ => panic!("merge tip is a commit"), + } +} + +/// Same-actor divergence: two of one member's machines edit disjoint fields +/// of the same single-writer ref. The merge folds both, and the merge tip +/// satisfies the tip invariant — the gate accepts it advancing the ref from +/// the canonical tip (`sync.divergence-merge`, `gate.same-actor-divergence`). +// @relation(sync.divergence-merge, scope=function, role=Verifies) +#[test] +fn same_actor_divergence_merges_to_a_gate_valid_tip() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let jdc = Keypair::from_seed(1); + boot(&refs, &objects, &jdc); + + let name: FullName = "refs/meta/issues/1".try_into().unwrap(); + let base = signed_commit(&objects, &name, &issue("open"), vec![], &jdc, 300); + + // Two divergent children of the same base, editing different fields. + let mut ours_issue = issue("open"); + ours_issue.title = "renamed".into(); + let ours = signed_commit(&objects, &name, &ours_issue, vec![base], &jdc, 400); + let theirs = signed_commit(&objects, &name, &issue("closed"), vec![base], &jdc, 400); + + let heads = Heads { + refname: name.clone(), + ours: Some(ours), + theirs, + }; + let Merged::Tip(tip) = merge_heads( + &objects, + &heads, + &author(500), + "Merge divergent heads", + |p| jdc.sign(p), + ) + .unwrap() else { + panic!("a same-actor divergence merges cleanly"); + }; + + // Both disjoint edits survive the merge. + let merged_tree = match objects.get(&tip).unwrap() { + gix::objs::Object::Commit(c) => c.tree, + _ => panic!("commit"), + }; + let got: Issue = facet_git_tree::deserialize(&merged_tree, &objects).unwrap(); + assert_eq!(got.title, "renamed"); + assert_eq!(got.state, "closed"); + + // The merge tip satisfies the tip invariant. + let snapshot = refs.fetched_copy(); + snapshot.set(name.as_ref(), ours); + let verdict = verify( + &snapshot, + &objects, + &Update { + name, + new: Some(tip), + }, + ) + .unwrap(); + assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}"); +} + +/// Adoption of a contributor's brand-new entity onto a canonical ref that +/// has no prior tip: the maintainer merges the contributor's signed commit +/// (`sync.adoption-machinery`, `gate.adoption-merge`), which stays a parent +/// so its signature and attribution survive — a merge, never a cherry-pick +/// (`sync.adoption-no-cherry-pick`). The maintainer's signature on the tip +/// makes it satisfy the tip invariant. +// @relation(sync.adoption-machinery, sync.adoption-no-cherry-pick, scope=function, role=Verifies) +#[test] +fn adoption_merges_the_contributors_commit_without_cherry_picking() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let admin = Keypair::from_seed(1); + let bob = Keypair::from_seed(2); + boot(&refs, &objects, &admin); + enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250); + + // Bob submits an issue under his own inbox segment (all he may write). + let inbox: FullName = "refs/meta/inbox/bob/issues/5".try_into().unwrap(); + let contribution = signed_commit(&objects, &inbox, &issue("open"), vec![], &bob, 300); + + // The maintainer adopts it onto the canonical ref via the *same* + // machinery divergence uses — only the heads differ. + let canonical: FullName = "refs/meta/issues/5".try_into().unwrap(); + let heads = Heads { + refname: canonical.clone(), + ours: None, + theirs: contribution, + }; + let Merged::Tip(tip) = merge_heads(&objects, &heads, &author(400), "Adopt bob's issue", |p| { + admin.sign(p) + }) + .unwrap() else { + panic!("a trivial adoption merges cleanly"); + }; + + // Not a cherry-pick: bob's original signed commit is in ancestry. + assert!( + parents_of(&objects, tip).contains(&contribution), + "the contributor's commit must remain a parent, its signature intact" + ); + + // The maintainer's signature makes the tip satisfy the tip invariant on + // the previously-absent canonical ref. + let verdict = verify( + &refs, + &objects, + &Update { + name: canonical, + new: Some(tip), + }, + ) + .unwrap(); + assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}"); +} + +/// Adoption folding an inbox entity onto an *existing* canonical ref rides +/// the identical [`merge_heads`] path, with a real three-way merge over the +/// two typed trees (`sync.adoption-machinery`). +// @relation(sync.adoption-machinery, scope=function, role=Verifies) +#[test] +fn adoption_onto_existing_canonical_ref_uses_the_merge() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let admin = Keypair::from_seed(1); + let bob = Keypair::from_seed(2); + boot(&refs, &objects, &admin); + enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250); + + let canonical: FullName = "refs/meta/issues/7".try_into().unwrap(); + let base = signed_commit(&objects, &canonical, &issue("open"), vec![], &admin, 300); + + // Bob branches from the canonical base and edits a field in his inbox. + let inbox: FullName = "refs/meta/inbox/bob/issues/7".try_into().unwrap(); + let mut contributed = issue("open"); + contributed.title = "bob's title".into(); + let contribution = signed_commit(&objects, &inbox, &contributed, vec![base], &bob, 350); + + let heads = Heads { + refname: canonical.clone(), + ours: Some(base), + theirs: contribution, + }; + let Merged::Tip(tip) = merge_heads(&objects, &heads, &author(400), "Adopt bob's edit", |p| { + admin.sign(p) + }) + .unwrap() else { + panic!("clean adoption"); + }; + + assert!(parents_of(&objects, tip).contains(&contribution)); + let snapshot = refs.fetched_copy(); + snapshot.set(canonical.as_ref(), base); + let verdict = verify( + &snapshot, + &objects, + &Update { + name: canonical, + new: Some(tip), + }, + ) + .unwrap(); + assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}"); +}
crates/ents-sync/tests/transfer.rs @@ -1,0 +1,266 @@ +//! Forge transfer: fetch and push over `refs/meta/*` (`sync.forge-transfer`, +//! and the push side of `sync.pre-flight` / `sync.inbox-routing`). +//! +//! Strategy: **integration harness** — a "remote" and a "local" are each a +//! ref-store / object-store pair, and the properties are end-to-end: after a +//! fetch the destination can verify a signed tip entirely on its own (so +//! history and signatures came across), a divergence is reported rather than +//! silently overwritten, and a push gates the *remote* write on the same +//! gate while routing an unauthorized canonical push to the inbox. + +#![expect( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests" +)] + +use ents_gate::{Config, Update, Verdict, verify}; +use ents_model::{Issue, MemberId, Provenance, namespace}; +use ents_sync::transfer::{Pushed, fetch, push}; +use ents_testutil::{ + CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity, +}; +use gix::refs::FullName; +use gix_ref_store::RefStoreRead; + +fn issue(state: &str) -> Issue { + Issue { + title: "t".into(), + body: "b".into(), + state: state.into(), + assignees: vec![], + labels: vec![], + } +} + +/// Enroll `admin` (and optionally `bob`) and record the epoch. +fn boot(refs: &MemRefStore, objects: &ObjectStore, admin: &Keypair, bob: Option<&Keypair>) { + enroll_member( + refs, + objects, + "admin", + admin, + Provenance::AdminRegistered, + 100, + ); + let config: FullName = namespace::CONFIG_REF.try_into().unwrap(); + write_meta_entity( + refs, + objects, + config, + &Config { epoch: Some(200) }, + Some(admin), + 200, + ); + if let Some(bob) = bob { + enroll_member(refs, objects, "bob", bob, Provenance::SelfAttested, 250); + } +} + +/// Fetch moves the whole forge: every meta-ref, its full history, and the +/// signatures needed to verify it, so the destination verifies a tip on its +/// own with nothing left behind (`sync.forge-transfer`). +// @relation(sync.forge-transfer, scope=function, role=Verifies) +#[test] +fn fetch_moves_the_whole_forge_with_verifiable_signatures() { + let remote_refs = MemRefStore::default(); + let remote_objects = ObjectStore::default(); + let admin = Keypair::from_seed(1); + boot(&remote_refs, &remote_objects, &admin, None); + + // An issue with two commits of history. + let name: FullName = "refs/meta/issues/1".try_into().unwrap(); + let parent = write_meta_entity( + &remote_refs, + &remote_objects, + name.clone(), + &issue("open"), + Some(&admin), + 300, + ); + let tip = write_meta_entity( + &remote_refs, + &remote_objects, + name.clone(), + &issue("closed"), + Some(&admin), + 400, + ); + + let local_refs = MemRefStore::default(); + let local_objects = ObjectStore::default(); + let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).unwrap(); + + // member, config, and the issue all arrived. + assert!( + report + .updated + .iter() + .any(|n| n.as_bstr() == "refs/meta/member/admin") + ); + assert!( + report + .updated + .iter() + .any(|n| n.as_bstr() == "refs/meta/config") + ); + assert_eq!(local_refs.get(name.as_ref()).unwrap(), Some(tip)); + + // The full history came with it, not just the tip. + assert!( + local_objects.get(&parent).is_some(), + "the parent commit must transfer too" + ); + + // The signatures and policy transferred: the destination verifies the + // tip against its *own* fetched state, offline. + let snapshot = local_refs.fetched_copy(); + snapshot.remove(name.as_ref()); + let verdict = verify( + &snapshot, + &local_objects, + &Update { + name, + new: Some(tip), + }, + ) + .unwrap(); + assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}"); +} + +/// When a local meta-ref has moved out from under the remote — neither tip +/// descends from the other — fetch reports the divergence for the merge +/// machinery to resolve, and does not clobber the local ref. +// @relation(sync.forge-transfer, scope=function, role=Verifies) +#[test] +fn fetch_reports_divergence_instead_of_overwriting() { + let name: FullName = "refs/meta/issues/1".try_into().unwrap(); + + let remote_refs = MemRefStore::default(); + let remote_objects = ObjectStore::default(); + let key = Keypair::from_seed(1); + let remote_tip = write_meta_entity( + &remote_refs, + &remote_objects, + name.clone(), + &issue("open"), + Some(&key), + 300, + ); + + // The local ref points at an independent-root commit: no descent either + // way. + let local_refs = MemRefStore::default(); + let local_objects = ObjectStore::default(); + let local_tip = { + let tree = facet_git_tree::serialize_into(&issue("closed"), &local_objects).unwrap(); + write_commit( + &local_objects, + &CommitSpec { + tree, + parents: vec![], + message: "local".into(), + seconds: 300, + }, + Some(&key), + ) + }; + local_refs.set(name.as_ref(), local_tip); + + let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).unwrap(); + assert!(report.updated.is_empty()); + let diverged = report + .diverged + .iter() + .find(|d| d.name == name) + .expect("divergence reported"); + assert_eq!(diverged.local, local_tip); + assert_eq!(diverged.remote, remote_tip); + // The local ref is untouched. + assert_eq!(local_refs.get(name.as_ref()).unwrap(), Some(local_tip)); +} + +/// Push pre-flights against the remote's own policy: an authorized push is +/// transferred and advances the remote ref (`sync.pre-flight`, +/// `sync.forge-transfer`). +// @relation(sync.pre-flight, sync.forge-transfer, scope=function, role=Verifies) +#[test] +fn push_advances_the_remote_on_an_authorized_ref() { + let admin = Keypair::from_seed(1); + let remote_refs = MemRefStore::default(); + let remote_objects = ObjectStore::default(); + boot(&remote_refs, &remote_objects, &admin, None); + + let local_refs = MemRefStore::default(); + let local_objects = ObjectStore::default(); + boot(&local_refs, &local_objects, &admin, None); + + let name: FullName = "refs/meta/issues/1".try_into().unwrap(); + let tip = write_meta_entity( + &local_refs, + &local_objects, + name.clone(), + &issue("open"), + Some(&admin), + 300, + ); + + let pushed = push( + &remote_refs, + &remote_objects, + &local_objects, + &name, + tip, + &MemberId::new("admin"), + ) + .unwrap(); + assert_eq!(pushed, Pushed::Advanced(name.clone())); + assert_eq!(remote_refs.get(name.as_ref()).unwrap(), Some(tip)); +} + +/// A self-attested contributor's canonical push is predicted to fail, so +/// push routes it to the contributor's own inbox segment and leaves the +/// remote canonical ref untouched (`sync.inbox-routing`). +// @relation(sync.inbox-routing, sync.pre-flight, scope=function, role=Verifies) +#[test] +fn push_routes_an_unauthorized_canonical_push_to_the_inbox() { + let admin = Keypair::from_seed(1); + let bob = Keypair::from_seed(2); + let remote_refs = MemRefStore::default(); + let remote_objects = ObjectStore::default(); + boot(&remote_refs, &remote_objects, &admin, Some(&bob)); + + let local_refs = MemRefStore::default(); + let local_objects = ObjectStore::default(); + boot(&local_refs, &local_objects, &admin, Some(&bob)); + + let name: FullName = "refs/meta/issues/1".try_into().unwrap(); + let tip = write_meta_entity( + &local_refs, + &local_objects, + name.clone(), + &issue("open"), + Some(&bob), + 300, + ); + + let pushed = push( + &remote_refs, + &remote_objects, + &local_objects, + &name, + tip, + &MemberId::new("bob"), + ) + .unwrap(); + match pushed { + Pushed::Inbox(route) => assert_eq!(route.as_bstr(), "refs/meta/inbox/bob/issues/1"), + other => panic!("expected inbox routing, got {other:?}"), + } + assert_eq!( + remote_refs.get(name.as_ref()).unwrap(), + None, + "canonical ref must be untouched" + ); +}