git-ents.gitmain
⌘K
foforge
transfer.rs243 lines · 10.4 KB · rusthistorycomment on this file
1//! Fetch and push over `refs/meta/*` — the routine plumbing that moves the
2//! forge itself, not merely code (`sync.forge-transfer`).
3//!
4//! Both directions copy the *complete* object closure of every meta-ref —
5//! each ref's whole commit chain and all its trees and blobs, commit
6//! objects verbatim so their signatures travel too — so a clone plus
7//! `refs/meta/*` carries the entire audit history and the signatures needed
8//! to verify it, with nothing left server-side (`sync.forge-transfer`).
9//!
10//! A remote and a local repository are each just a ([`RefStoreRead`]/
11//! [`RefStore`], `Find`/`Write`) pair; transfer is expressed directly over
12//! those seams, with no bespoke transport type. [`fetch`] advances every
13//! local meta-ref that the remote fast-forwards and reports the ones that
14//! diverged, feeding them to the merge machinery ([`crate::resolve`]).
15//! [`push`] runs pre-flight against the remote's own policy before moving a
16//! ref, so a rejected canonical push surfaces the inbox alternative instead
17//! (`sync.pre-flight`, `sync.inbox-routing`).
18//!
19//! Both directions advance the destination ref through
20//! [`RefStore::transaction`] directly rather than through `receive()`,
21//! which `receive.unit` scopes to *origination*. [`fetch`] is
22//! *replication*: every ref it lands was already admitted by the source's
23//! own `receive`, so re-verification here is an opt-in audit, not an
24//! obligation, and effect obligations for arrived refs are recovered by
25//! the boot-time reconciliation scan (`receive.reconstructible`). The
26//! merges the machinery authors itself are origination and go through the
27//! gate ([`crate::resolve`]). [`push`]'s destination side alone is
28//! stand-in plumbing: pushing *is* origination at the destination, whose
29//! own `receive()` — the hosted hook of the phase-6 single-node root —
30//! does not exist yet; until it does, pre-flight is the judgment a push
31//! destination gets.
32
33use ents_gate::Update;
34use ents_model::MemberId;
35use gix::refs::FullName;
36use gix_hash::ObjectId;
37use gix_object::{Find, Write};
38use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead, TxOutcome};
39
40use crate::error::Result;
41use crate::objects::{copy_closure, descends_from};
42use crate::preflight::{PreFlight, preflight};
43
44/// The meta-ref prefix that scopes every forge transfer.
45const META_PREFIX: &str = "refs/meta/";
46
47/// A remote meta-ref that has moved out from under the local tip: neither
48/// side descends from the other, so a fast-forward is impossible and the
49/// answer is a merge ([`crate::resolve::merge_heads`], `sync.divergence-merge`).
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Diverged {
52 /// The ref that diverged.
53 pub name: FullName,
54 /// The local tip.
55 pub local: ObjectId,
56 /// The remote tip.
57 pub remote: ObjectId,
58}
59
60/// What [`fetch`] did to the local `refs/meta/*` set.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct FetchReport {
63 /// Refs advanced to the remote tip (created, or fast-forwarded).
64 pub updated: Vec<FullName>,
65 /// Refs already at the remote tip; nothing to do.
66 pub unchanged: Vec<FullName>,
67 /// Refs whose local and remote tips diverged — resolve by merging.
68 pub diverged: Vec<Diverged>,
69 /// Refs whose CAS was rejected: another local writer moved the ref
70 /// between this fetch's read and its transaction, so nothing was
71 /// written. Re-running fetch re-classifies them against the new tip.
72 pub stale: Vec<FullName>,
73}
74
75/// Fetch every `refs/meta/*` ref from `remote` into `local`, moving the
76/// whole forge (`sync.forge-transfer`).
77///
78/// For each remote meta-ref the full object closure is copied into
79/// `local_objects` first — so the ref never points at an object the local
80/// store lacks — then the local ref is advanced if the remote is a
81/// fast-forward, left alone if already current, and reported as
82/// [`Diverged`] otherwise. Object copy is unconditional even for a
83/// divergence, since the subsequent merge needs both heads present locally.
84///
85/// # Errors
86///
87/// Propagates ref-store and object failures. A rejected CAS — another
88/// local writer moved a ref between this fetch's read and its transaction
89/// — is not an error: the ref is reported in [`FetchReport::stale`] and
90/// nothing is written for it.
91///
92/// # Examples
93///
94/// ```
95/// use ents_model::Provenance;
96/// use ents_sync::transfer::fetch;
97/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member};
98/// use gix_ref_store::RefStoreRead;
99///
100/// // The "remote" is just another ref-store / object-store pair.
101/// let remote_refs = MemRefStore::default();
102/// let remote_objects = ObjectStore::default();
103/// let key = Keypair::from_seed(1);
104/// enroll_member(&remote_refs, &remote_objects, "jdc", &key, Provenance::AdminRegistered, 100);
105///
106/// let local_refs = MemRefStore::default();
107/// let local_objects = ObjectStore::default();
108/// let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).expect("fetches");
109/// assert_eq!(report.updated.len(), 1);
110///
111/// let name: gix::refs::FullName = "refs/meta/member/jdc".try_into().expect("valid");
112/// assert!(local_refs.get(name.as_ref()).expect("readable").is_some());
113/// ```
114// @relation(sync.forge-transfer, scope=function)
115pub fn fetch(
116 remote_refs: &dyn RefStoreRead,
117 remote_objects: &impl Find,
118 local_refs: &dyn RefStore,
119 local_objects: &(impl Find + Write),
120) -> Result<FetchReport> {
121 let mut report = FetchReport::default();
122 for entry in remote_refs.iter_prefix(META_PREFIX)? {
123 let (name, remote_tip) = entry?;
124 copy_closure(remote_objects, local_objects, remote_tip)?;
125
126 let local_tip = local_refs.get(name.as_ref())?;
127 match local_tip {
128 Some(local) if local == remote_tip => report.unchanged.push(name),
129 Some(local) if descends_from(local_objects, remote_tip, local)? => {
130 let expected = Expected::MustExistAndMatch(local);
131 match advance(local_refs, &name, expected, remote_tip)? {
132 TxOutcome::Applied => report.updated.push(name),
133 TxOutcome::Rejected { .. } => report.stale.push(name),
134 }
135 }
136 Some(local) => report.diverged.push(Diverged {
137 name,
138 local,
139 remote: remote_tip,
140 }),
141 None => match advance(local_refs, &name, Expected::MustNotExist, remote_tip)? {
142 TxOutcome::Applied => report.updated.push(name),
143 TxOutcome::Rejected { .. } => report.stale.push(name),
144 },
145 }
146 }
147 Ok(report)
148}
149
150/// The outcome of pushing one local meta-ref to a remote ([`push`]).
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum Pushed {
153 /// Pre-flight passed and the ref was transferred and advanced on the
154 /// remote.
155 Advanced(FullName),
156 /// Pre-flight predicted a rejection the inbox can absorb; the ref was
157 /// *not* pushed, and this is the inbox route offered instead
158 /// (`sync.inbox-routing`).
159 Inbox(FullName),
160 /// Pre-flight predicted a rejection the inbox cannot absorb (a
161 /// divergence — merge first — or a refname mismatch). The ref was not
162 /// pushed; the prediction is carried for the caller to render.
163 Refused(Box<PreFlight>),
164 /// The pre-flight prediction went stale between judgment and CAS:
165 /// another writer advanced the remote ref, the transaction was
166 /// rejected, and nothing was written. This is exactly the staleness a
167 /// prediction admits (`sync.pre-flight`) — fetch, merge if divergent,
168 /// and push again.
169 Stale(FullName),
170}
171
172/// Push one local meta-ref `name` to `remote`, pre-flighting against the
173/// remote's own policy first (`sync.pre-flight`).
174///
175/// The local tip's object closure is copied to the remote *before* the
176/// verdict is computed — the gate must be able to read the proposed
177/// objects, exactly as the hosted CAS judges after ingest — so a refused
178/// push deliberately leaves those objects in the remote object store even
179/// though no ref comes to point at them. That residue matters for
180/// redaction: recorded redaction targets refuse re-ingest at the `receive`
181/// boundary (`receive.redaction-ingest`), and purging unreferenced objects
182/// is the store's garbage collection, not this function's. Only the *ref*
183/// is gated: a predicted rejection routes to the inbox
184/// (`sync.inbox-routing`) or is reported, and the remote's refs are
185/// untouched. Pre-flight runs the identical gate the remote will run at
186/// CAS time (`gate.call-sites`), so the result is a prediction that can
187/// only be stale, never wrong about the rules — and when it *does* go
188/// stale (a racing writer advances the remote between judgment and CAS)
189/// the rejected transaction is reported as [`Pushed::Stale`], never as
190/// success. Local writes are never blocked by any of this — that is
191/// [`mod@crate::preflight`]'s and the local store's concern
192/// (`sync.local-advisory`); push is the one place a verdict gates an
193/// actual (remote) write.
194///
195/// # Errors
196///
197/// Propagates pre-flight, ref-store, and object failures.
198// @relation(sync.pre-flight, sync.inbox-routing, sync.forge-transfer, scope=function)
199pub fn push(
200 remote_refs: &dyn RefStore,
201 remote_objects: &(impl Find + Write),
202 local_objects: &impl Find,
203 name: &FullName,
204 local_tip: ObjectId,
205 author: &MemberId,
206) -> Result<Pushed> {
207 let update = Update {
208 name: name.clone(),
209 new: Some(local_tip),
210 };
211 // Pre-flight needs the proposed objects visible in the store it reads,
212 // exactly as the hosted CAS would after ingest; copy first, then judge.
213 copy_closure(local_objects, remote_objects, local_tip)?;
214 let pf = preflight(remote_refs, remote_objects, &update, author)?;
215 if !pf.is_pass() {
216 return Ok(match pf.inbox {
217 Some(inbox) => Pushed::Inbox(inbox),
218 None => Pushed::Refused(Box::new(pf)),
219 });
220 }
221
222 let expected = remote_refs
223 .get(name.as_ref())?
224 .map_or(Expected::MustNotExist, Expected::MustExistAndMatch);
225 match advance(remote_refs, name, expected, local_tip)? {
226 TxOutcome::Applied => Ok(Pushed::Advanced(name.clone())),
227 TxOutcome::Rejected { name } => Ok(Pushed::Stale(name)),
228 }
229}
230
231/// Apply one ref advance as a single-edit CAS transaction.
232fn advance(
233 refs: &dyn RefStore,
234 name: &FullName,
235 expected: Expected,
236 new: ObjectId,
237) -> Result<TxOutcome> {
238 Ok(refs.transaction(&[RefEdit {
239 name: name.clone(),
240 expected,
241 new: Some(new),
242 }])?)
243}