crates/kernel/ents-sync/tests/resolve.rs
resolve.rshistorycomment on this file
| 1 | //! Divergence and adoption over the one merge machinery |
| 2 | //! (`sync.divergence-merge`, `sync.adoption-machinery`, |
| 3 | //! `sync.adoption-no-cherry-pick`). |
| 4 | //! |
| 5 | //! Strategy: **integration harness** — these are enumerable end-to-end |
| 6 | //! scenarios whose point is that a real signed merge tip, built by |
| 7 | //! [`ents_sync::merge_heads`], is accepted by the *real* gate |
| 8 | //! ([`ents_gate::verify`]) and keeps the folded-in commit in ancestry. A |
| 9 | //! property test would not add coverage over the specific shapes the spec |
| 10 | //! names; the value is in exercising the same function the production path |
| 11 | //! runs against genuine git objects and signatures. |
| 12 | |
| 13 | #![expect( |
| 14 | clippy::unwrap_used, |
| 15 | clippy::expect_used, |
| 16 | clippy::panic, |
| 17 | reason = "tests" |
| 18 | )] |
| 19 | |
| 20 | use ents_gate::{Config, Update, Verdict, verify}; |
| 21 | use ents_model::{Provenance, namespace}; |
| 22 | use ents_sync::{Heads, Merged, merge_heads}; |
| 23 | use ents_testutil::{ |
| 24 | CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity, |
| 25 | }; |
| 26 | use gix::refs::FullName; |
| 27 | use gix_hash::ObjectId; |
| 28 | |
| 29 | /// A stand-in for `ents-forge`'s `Issue` (this crate cannot depend on |
| 30 | /// `ents-forge`): any multi-field entity exercises the same divergence and |
| 31 | /// adoption machinery, which is generic over the typed tree. |
| 32 | #[derive(Debug, Clone, PartialEq, Eq, facet::Facet)] |
| 33 | struct Issue { |
| 34 | title: String, |
| 35 | body: String, |
| 36 | state: String, |
| 37 | } |
| 38 | |
| 39 | fn issue(state: &str) -> Issue { |
| 40 | Issue { |
| 41 | title: "t".into(), |
| 42 | body: "b".into(), |
| 43 | state: state.into(), |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | /// Build a signed commit recording `entity` with the given parents. The |
| 48 | /// commit names no ref of its own — the gate recomputes the binding from |
| 49 | /// signed content (`gate.identity-binding`). |
| 50 | fn signed_commit( |
| 51 | objects: &ObjectStore, |
| 52 | entity: &Issue, |
| 53 | parents: Vec<ObjectId>, |
| 54 | key: &Keypair, |
| 55 | seconds: i64, |
| 56 | ) -> ObjectId { |
| 57 | let tree = facet_git_tree::serialize_into(entity, objects).unwrap(); |
| 58 | write_commit( |
| 59 | objects, |
| 60 | &CommitSpec { |
| 61 | tree, |
| 62 | parents, |
| 63 | message: "Mutate issue".into(), |
| 64 | seconds, |
| 65 | }, |
| 66 | Some(key), |
| 67 | ) |
| 68 | } |
| 69 | |
| 70 | /// The canonical issue refname for a genesis commit, keyed by its oid |
| 71 | /// (`meta-ref.identity-binding`). |
| 72 | fn issue_ref(genesis: ObjectId) -> FullName { |
| 73 | format!("refs/meta/issues/{genesis}").try_into().unwrap() |
| 74 | } |
| 75 | |
| 76 | fn author(seconds: i64) -> gix::actor::Signature { |
| 77 | gix::actor::Signature { |
| 78 | name: "placer".into(), |
| 79 | email: "placer@ents.test".into(), |
| 80 | time: gix::date::Time { seconds, offset: 0 }, |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | /// Enroll `admin` and turn verification on by recording the epoch. |
| 85 | fn boot(refs: &MemRefStore, objects: &ObjectStore, admin: &Keypair) { |
| 86 | enroll_member( |
| 87 | refs, |
| 88 | objects, |
| 89 | "admin", |
| 90 | admin, |
| 91 | Provenance::AdminRegistered, |
| 92 | 100, |
| 93 | ); |
| 94 | let config: FullName = namespace::CONFIG_REF.try_into().unwrap(); |
| 95 | write_meta_entity( |
| 96 | refs, |
| 97 | objects, |
| 98 | config, |
| 99 | &Config { epoch: Some(200) }, |
| 100 | Some(admin), |
| 101 | 200, |
| 102 | ); |
| 103 | } |
| 104 | |
| 105 | fn parents_of(objects: &ObjectStore, tip: ObjectId) -> Vec<ObjectId> { |
| 106 | match objects.get(&tip).expect("tip present") { |
| 107 | gix::objs::Object::Commit(c) => c.parents.into_vec(), |
| 108 | _ => panic!("merge tip is a commit"), |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /// Same-actor divergence: two of one member's machines edit disjoint fields |
| 113 | /// of the same single-writer ref. The merge folds both, and the merge tip |
| 114 | /// satisfies the tip invariant — the gate accepts it advancing the ref from |
| 115 | /// the canonical tip (`sync.divergence-merge`, `gate.same-actor-divergence`). |
| 116 | // @relation(sync.divergence-merge, scope=function, role=Verifies) |
| 117 | #[test] |
| 118 | fn same_actor_divergence_merges_to_a_gate_valid_tip() { |
| 119 | let refs = MemRefStore::default(); |
| 120 | let objects = ObjectStore::default(); |
| 121 | let jdc = Keypair::from_seed(1); |
| 122 | boot(&refs, &objects, &jdc); |
| 123 | |
| 124 | let base = signed_commit(&objects, &issue("open"), vec![], &jdc, 300); |
| 125 | // The issue's id is its genesis commit's own oid. |
| 126 | let name = issue_ref(base); |
| 127 | |
| 128 | // Two divergent children of the same base, editing different fields. |
| 129 | let mut ours_issue = issue("open"); |
| 130 | ours_issue.title = "renamed".into(); |
| 131 | let ours = signed_commit(&objects, &ours_issue, vec![base], &jdc, 400); |
| 132 | let theirs = signed_commit(&objects, &issue("closed"), vec![base], &jdc, 400); |
| 133 | |
| 134 | let heads = Heads { |
| 135 | refname: name.clone(), |
| 136 | ours: Some(ours), |
| 137 | theirs, |
| 138 | }; |
| 139 | let Merged::Tip(tip) = merge_heads( |
| 140 | &objects, |
| 141 | &heads, |
| 142 | &author(500), |
| 143 | "Merge divergent heads", |
| 144 | |p| jdc.sign(p), |
| 145 | ) |
| 146 | .unwrap() else { |
| 147 | panic!("a same-actor divergence merges cleanly"); |
| 148 | }; |
| 149 | |
| 150 | // Both disjoint edits survive the merge. |
| 151 | let merged_tree = match objects.get(&tip).unwrap() { |
| 152 | gix::objs::Object::Commit(c) => c.tree, |
| 153 | _ => panic!("commit"), |
| 154 | }; |
| 155 | let got: Issue = facet_git_tree::deserialize(&merged_tree, &objects).unwrap(); |
| 156 | assert_eq!(got.title, "renamed"); |
| 157 | assert_eq!(got.state, "closed"); |
| 158 | |
| 159 | // The merge tip satisfies the tip invariant. |
| 160 | let snapshot = refs.fetched_copy(); |
| 161 | snapshot.set(name.as_ref(), ours); |
| 162 | let verdict = verify( |
| 163 | &snapshot, |
| 164 | &objects, |
| 165 | &Update { |
| 166 | name, |
| 167 | new: Some(tip), |
| 168 | }, |
| 169 | ) |
| 170 | .unwrap(); |
| 171 | assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}"); |
| 172 | } |
| 173 | |
| 174 | /// Adoption of a contributor's brand-new entity onto a canonical ref that |
| 175 | /// has no prior tip: the maintainer merges the contributor's signed commit |
| 176 | /// (`sync.adoption-machinery`, `gate.adoption-merge`), which stays a parent |
| 177 | /// so its signature and attribution survive — a merge, never a cherry-pick |
| 178 | /// (`sync.adoption-no-cherry-pick`). The maintainer's signature on the tip |
| 179 | /// makes it satisfy the tip invariant. |
| 180 | // @relation(sync.adoption-machinery, sync.adoption-no-cherry-pick, scope=function, role=Verifies) |
| 181 | #[test] |
| 182 | fn adoption_merges_the_contributors_commit_without_cherry_picking() { |
| 183 | let refs = MemRefStore::default(); |
| 184 | let objects = ObjectStore::default(); |
| 185 | let admin = Keypair::from_seed(1); |
| 186 | let bob = Keypair::from_seed(2); |
| 187 | boot(&refs, &objects, &admin); |
| 188 | enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250); |
| 189 | |
| 190 | // Bob submits an issue under his own inbox segment (all he may write). |
| 191 | let contribution = signed_commit(&objects, &issue("open"), vec![], &bob, 300); |
| 192 | |
| 193 | // The maintainer adopts it onto the canonical ref via the *same* |
| 194 | // machinery divergence uses — only the heads differ. The canonical id |
| 195 | // is bob's genesis commit oid, which stays the history's sole root. |
| 196 | let canonical = issue_ref(contribution); |
| 197 | let heads = Heads { |
| 198 | refname: canonical.clone(), |
| 199 | ours: None, |
| 200 | theirs: contribution, |
| 201 | }; |
| 202 | let Merged::Tip(tip) = merge_heads(&objects, &heads, &author(400), "Adopt bob's issue", |p| { |
| 203 | admin.sign(p) |
| 204 | }) |
| 205 | .unwrap() else { |
| 206 | panic!("a trivial adoption merges cleanly"); |
| 207 | }; |
| 208 | |
| 209 | // Not a cherry-pick: bob's original signed commit is in ancestry. |
| 210 | assert!( |
| 211 | parents_of(&objects, tip).contains(&contribution), |
| 212 | "the contributor's commit must remain a parent, its signature intact" |
| 213 | ); |
| 214 | |
| 215 | // The maintainer's signature makes the tip satisfy the tip invariant on |
| 216 | // the previously-absent canonical ref. |
| 217 | let verdict = verify( |
| 218 | &refs, |
| 219 | &objects, |
| 220 | &Update { |
| 221 | name: canonical, |
| 222 | new: Some(tip), |
| 223 | }, |
| 224 | ) |
| 225 | .unwrap(); |
| 226 | assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}"); |
| 227 | } |
| 228 | |
| 229 | /// Adoption folding an inbox entity onto an *existing* canonical ref rides |
| 230 | /// the identical [`merge_heads`] path, with a real three-way merge over the |
| 231 | /// two typed trees (`sync.adoption-machinery`). |
| 232 | // @relation(sync.adoption-machinery, scope=function, role=Verifies) |
| 233 | #[test] |
| 234 | fn adoption_onto_existing_canonical_ref_uses_the_merge() { |
| 235 | let refs = MemRefStore::default(); |
| 236 | let objects = ObjectStore::default(); |
| 237 | let admin = Keypair::from_seed(1); |
| 238 | let bob = Keypair::from_seed(2); |
| 239 | boot(&refs, &objects, &admin); |
| 240 | enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250); |
| 241 | |
| 242 | let base = signed_commit(&objects, &issue("open"), vec![], &admin, 300); |
| 243 | let canonical = issue_ref(base); |
| 244 | |
| 245 | // Bob branches from the canonical base and edits a field in his inbox. |
| 246 | let mut contributed = issue("open"); |
| 247 | contributed.title = "bob's title".into(); |
| 248 | let contribution = signed_commit(&objects, &contributed, vec![base], &bob, 350); |
| 249 | |
| 250 | let heads = Heads { |
| 251 | refname: canonical.clone(), |
| 252 | ours: Some(base), |
| 253 | theirs: contribution, |
| 254 | }; |
| 255 | let Merged::Tip(tip) = merge_heads(&objects, &heads, &author(400), "Adopt bob's edit", |p| { |
| 256 | admin.sign(p) |
| 257 | }) |
| 258 | .unwrap() else { |
| 259 | panic!("clean adoption"); |
| 260 | }; |
| 261 | |
| 262 | assert!(parents_of(&objects, tip).contains(&contribution)); |
| 263 | let snapshot = refs.fetched_copy(); |
| 264 | snapshot.set(canonical.as_ref(), base); |
| 265 | let verdict = verify( |
| 266 | &snapshot, |
| 267 | &objects, |
| 268 | &Update { |
| 269 | name: canonical, |
| 270 | new: Some(tip), |
| 271 | }, |
| 272 | ) |
| 273 | .unwrap(); |
| 274 | assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}"); |
| 275 | } |