crates/kernel/ents-sync/src/lib.rs
| 1 | //! `sync`: remote synchronization for the forge — the one capability |
| 2 | //! `git ents` adds over the local primitives (`docs/spec/sync.adoc`). |
| 3 | //! |
| 4 | //! Sync fetches and pushes `refs/meta/*` and, crucially, turns the gate's |
| 5 | //! verdict into a decision the user acts on before pushing. Its single hard |
| 6 | //! responsibility is the schema-aware three-way merge over typed trees |
| 7 | //! (`crate::merge`); everything else — transfer, pre-flight, inbox routing — |
| 8 | //! is plumbing above traits that already exist by this phase (`RefStore`, |
| 9 | //! `Find`/`Write`, and `ents_gate::verify`). This crate never re-implements |
| 10 | //! the gate's judgment (it *calls* [`ents_gate::verify`], `gate.call-sites`) |
| 11 | //! and never writes to a ref except through the `RefStore` seam it is handed. |
| 12 | //! |
| 13 | //! Divergence resolution and adoption are deliberately *one* machinery |
| 14 | //! (`crate::resolve`), not two code paths (`sync.adoption-machinery`): a |
| 15 | //! member merging their own racing machines, a maintainer folding an inbox |
| 16 | //! entity onto its canonical ref, and a maintainer adopting a contributor's |
| 17 | //! self-run results all go through the same [`resolve::merge_heads`], which |
| 18 | //! keeps the folded-in head as a parent so its author's signature survives — |
| 19 | //! a merge, never a cherry-pick (`sync.adoption-no-cherry-pick`). |
| 20 | //! |
| 21 | //! # Spec coverage |
| 22 | //! |
| 23 | //! From `docs/spec/sync.adoc`: |
| 24 | //! |
| 25 | //! - `sync.forge-transfer` — [`transfer::fetch`], [`transfer::push`]: both |
| 26 | //! copy each meta-ref's full object closure, commit objects verbatim, so |
| 27 | //! history and signatures move with the ref. |
| 28 | //! - `sync.pre-flight` — [`preflight::preflight`]: the identical |
| 29 | //! [`ents_gate::verify`] every call site runs, so a pre-flight verdict is |
| 30 | //! a prediction that can only be stale (`gate.call-sites`). |
| 31 | //! - `sync.inbox-routing` — [`preflight::inbox_route`], surfaced by |
| 32 | //! [`preflight::PreFlight::inbox`] and [`transfer::Pushed::Inbox`]: any |
| 33 | //! negative advisory verdict offers the author's own inbox segment. |
| 34 | //! - `sync.divergence-merge` — [`merge::three_way`] and |
| 35 | //! [`resolve::merge_heads`]: a schema-aware three-way merge whose tip, |
| 36 | //! once signed, satisfies the tip invariant. |
| 37 | //! - `sync.adoption-machinery` — [`resolve::merge_heads`] is the *same* |
| 38 | //! function divergence uses; adoption is only a different pair of heads. |
| 39 | //! - `sync.adoption-no-cherry-pick` — [`resolve::merge_heads`] always keeps |
| 40 | //! `theirs` as a parent; it never re-authors the contributor's commit. |
| 41 | //! - `sync.local-advisory` — sync never blocks a local write on a verdict; |
| 42 | //! the consequence it owns is the inbox offer ([`mod@preflight`], |
| 43 | //! [`transfer::push`] is the sole place a verdict gates a *remote* write). |
| 44 | //! |
| 45 | //! # Examples |
| 46 | //! |
| 47 | //! A same-actor divergence — two of one member's machines each editing a |
| 48 | //! *different* field of the same issue — resolved into a signed merge tip |
| 49 | //! the gate then accepts (`sync.divergence-merge`). |
| 50 | //! |
| 51 | //! ``` |
| 52 | //! use ents_gate::{Config, Update, Verdict, verify}; |
| 53 | //! use ents_model::{Provenance, namespace}; |
| 54 | //! use ents_sync::resolve::{Heads, Merged, merge_heads}; |
| 55 | //! use ents_testutil::{ |
| 56 | //! CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity, |
| 57 | //! }; |
| 58 | //! |
| 59 | //! // A stand-in for `ents-forge`'s `Issue` (this crate cannot depend on |
| 60 | //! // `ents-forge`): any Facet-derived entity exercises the merge. |
| 61 | //! # #[derive(facet::Facet, Clone)] |
| 62 | //! # struct Issue { title: String, body: String, state: String } |
| 63 | //! # |
| 64 | //! let refs = MemRefStore::default(); |
| 65 | //! let objects = ObjectStore::default(); |
| 66 | //! let jdc = Keypair::from_seed(1); |
| 67 | //! |
| 68 | //! // Enroll (bootstrap) and turn verification on by setting the epoch. |
| 69 | //! enroll_member(&refs, &objects, "jdc", &jdc, Provenance::AdminRegistered, 100); |
| 70 | //! let config: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid"); |
| 71 | //! write_meta_entity(&refs, &objects, config, &Config { epoch: Some(200) }, Some(&jdc), 200); |
| 72 | //! |
| 73 | //! let issue = Issue { |
| 74 | //! title: "t".into(), body: "b".into(), state: "open".into(), |
| 75 | //! }; |
| 76 | //! |
| 77 | //! // A common base (the genesis), then two divergent children editing |
| 78 | //! // disjoint fields. The issue's id is the genesis commit's own oid |
| 79 | //! // (`meta-ref.identity-binding`), so the ref is named from it. |
| 80 | //! let base_tree = facet_git_tree::serialize_into(&issue, &objects).expect("ser"); |
| 81 | //! let base = write_commit(&objects, &CommitSpec { tree: base_tree, parents: vec![], message: "Open".into(), seconds: 300 }, Some(&jdc)); |
| 82 | //! let name: gix::refs::FullName = format!("refs/meta/issues/{base}").try_into().expect("valid"); |
| 83 | //! |
| 84 | //! let mut renamed = issue.clone(); |
| 85 | //! renamed.title = "renamed".into(); |
| 86 | //! let ours_tree = facet_git_tree::serialize_into(&renamed, &objects).expect("ser"); |
| 87 | //! let ours = write_commit(&objects, &CommitSpec { tree: ours_tree, parents: vec![base], message: "Rename".into(), seconds: 400 }, Some(&jdc)); |
| 88 | //! |
| 89 | //! let mut closed = issue.clone(); |
| 90 | //! closed.state = "closed".into(); |
| 91 | //! let theirs_tree = facet_git_tree::serialize_into(&closed, &objects).expect("ser"); |
| 92 | //! let theirs = write_commit(&objects, &CommitSpec { tree: theirs_tree, parents: vec![base], message: "Close".into(), seconds: 400 }, Some(&jdc)); |
| 93 | //! |
| 94 | //! let author = gix::actor::Signature { |
| 95 | //! name: "jdc".into(), email: "jdc@ents.test".into(), |
| 96 | //! time: gix::date::Time { seconds: 500, offset: 0 }, |
| 97 | //! }; |
| 98 | //! let heads = Heads { refname: name.clone(), ours: Some(ours), theirs }; |
| 99 | //! let Merged::Tip(tip) = |
| 100 | //! merge_heads(&objects, &heads, &author, "Merge divergent heads", |p| jdc.sign(p)).expect("merges") |
| 101 | //! else { panic!("a same-actor divergence merges cleanly") }; |
| 102 | //! |
| 103 | //! // The merged tree carries *both* disjoint edits — the schema-aware |
| 104 | //! // property this crate's tests pin field-by-field. Here we show the |
| 105 | //! // consequence that matters to sync: the merge tip satisfies the tip |
| 106 | //! // invariant, so the gate accepts it advancing the ref from `ours`. |
| 107 | //! let snapshot = refs.fetched_copy(); |
| 108 | //! snapshot.set(name.as_ref(), ours); |
| 109 | //! let verdict = verify(&snapshot, &objects, &Update { name, new: Some(tip) }).expect("evaluates"); |
| 110 | //! assert!(matches!(verdict, Verdict::Pass(_))); |
| 111 | //! ``` |
| 112 | |
| 113 | mod error; |
| 114 | mod objects; |
| 115 | |
| 116 | pub mod merge; |
| 117 | pub mod preflight; |
| 118 | pub mod resolve; |
| 119 | pub mod transfer; |
| 120 | |
| 121 | pub use error::{Error, Result}; |
| 122 | pub use merge::{Merge, three_way}; |
| 123 | pub use preflight::{PreFlight, inbox_route, preflight}; |
| 124 | pub use resolve::{Heads, Merged, merge_heads}; |
| 125 | pub use transfer::{Diverged, FetchReport, Pushed, fetch, push}; |