git-ents.gitmain
⌘K
foforge
resolve.rs275 lines · 9.0 KB · rusthistorycomment 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
20use ents_gate::{Config, Update, Verdict, verify};
21use ents_model::{Provenance, namespace};
22use ents_sync::{Heads, Merged, merge_heads};
23use ents_testutil::{
24 CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity,
25};
26use gix::refs::FullName;
27use 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)]
33struct Issue {
34 title: String,
35 body: String,
36 state: String,
37}
38
39fn 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`).
50fn 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`).
72fn issue_ref(genesis: ObjectId) -> FullName {
73 format!("refs/meta/issues/{genesis}").try_into().unwrap()
74}
75
76fn 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.
85fn 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
105fn 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]
118fn 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]
182fn 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]
234fn 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}