onevcs_testing/repository.rs
1//! The repository side: one implementation of [`Vcs`] over either store.
2//!
3//! What it is not: git. It answers the questions the interface asks, records what
4//! it was asked, and emits the events the real implementation emits. What it
5//! cannot do is tell you whether a tree is dirty or whether a merge conflicts,
6//! because there is no tree — a journey that needs those drives the real `Git`.
7//!
8//! Publishing is the one operation that reaches past the repository: the host side
9//! of it is *performed*, against the [`Hosting`] the publication was handed, and
10//! the repository side of it is neither performed nor claimed. What that leaves
11//! out is written where each piece is left out.
12
13use std::path::{Path, PathBuf};
14
15use serde_json::{json, Map, Value};
16
17use onevcs::{
18 ChangeSpec, Error, EventKind, FailureKind, Hosting, Identity, Lifecycle, MergeOutcome,
19 MergePolicy, PreservedBranch, Provenance, Publication, PublishOutcome, PublishRequest,
20 Recoverable, Result, Scope, Session, SessionRecord, SessionRequest, SessionToken, Sha, Vcs,
21};
22
23use crate::events::{self, Emission};
24use crate::remote::DEFAULT_HOST;
25use crate::state::{self, VcsState};
26use crate::store::{FileStore, MemoryStore, Store};
27
28/// The base a session is cut from when the request names none.
29///
30/// The real implementation asks the origin for its default branch, and this
31/// provider has no origin to ask.
32pub const DEFAULT_BASE: &str = "main";
33
34/// The policy a publication takes when nothing was seeded and nothing requested.
35///
36/// The policy the contract's own `default:` names, which is what the real
37/// implementation resolves to for a registry with no rules file.
38pub const DEFAULT_PUBLICATION: MergePolicy = MergePolicy::ChangeOpen;
39
40/// The repository side of a run, over whichever store holds its state.
41///
42/// The two flavours below are this one behaviour with a different store under it,
43/// so neither can learn something the other does not know.
44#[derive(Debug)]
45pub struct Repository<T> {
46 store: T,
47 root: PathBuf,
48 trees: Trees,
49}
50
51/// What a session's worktree path means.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum Trees {
54 /// The path is named and nothing is created there: the in-memory provider
55 /// touches no filesystem beyond the event stream.
56 Named,
57 /// The directory is created, so a journey has somewhere to write work.
58 Created,
59}
60
61/// A repository provider that keeps its state in this process: no disk, no
62/// visibility to a second process, and the fastest of the two.
63///
64/// Its sessions name a worktree under the system temporary directory and **do not
65/// create it** — nothing here touches the filesystem except the event stream, which
66/// is the record a journey reads.
67pub type MemoryVcs = Repository<MemoryStore<VcsState>>;
68
69/// A repository provider that keeps its state in one JSON document, so several
70/// `onevcs` invocations see one another's effects.
71///
72/// Its sessions name a worktree beside that document **and create it**, so a
73/// journey that writes a file into a session's tree has somewhere to write it.
74pub type FileVcs = Repository<FileStore<VcsState>>;
75
76impl MemoryVcs {
77 /// A repository provider knowing nothing.
78 pub fn new() -> Self {
79 Self::seeded(VcsState::default())
80 }
81
82 /// A repository provider that starts from a scenario.
83 pub fn seeded(state: VcsState) -> Self {
84 Self {
85 store: MemoryStore::new(state),
86 root: std::env::temp_dir().join("onevcs-testing-memory"),
87 trees: Trees::Named,
88 }
89 }
90
91 /// Everything it knows.
92 pub fn state(&self) -> VcsState {
93 self.store
94 .snapshot()
95 .expect("an in-memory store always answers")
96 }
97}
98
99impl Default for MemoryVcs {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl FileVcs {
106 /// A repository provider keeping its state at `path`: whatever is already
107 /// there, or nothing.
108 ///
109 /// Attaching rather than replacing, so a second provider over the same path
110 /// picks up what the first one left — which is what a journey driving several
111 /// invocations reaches for this flavour to get.
112 pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
113 Self::over(FileStore::attach(path, &VcsState::default())?)
114 }
115
116 /// A repository provider that starts from a scenario, keeping its state at
117 /// `path` and replacing whatever was there.
118 pub fn seeded(path: impl Into<PathBuf>, state: VcsState) -> Result<Self> {
119 Self::over(FileStore::replace(path, &state)?)
120 }
121
122 fn over(store: FileStore<VcsState>) -> Result<Self> {
123 let root = store
124 .path()
125 .parent()
126 .filter(|parent| !parent.as_os_str().is_empty())
127 .unwrap_or_else(|| Path::new("."))
128 .join("worktrees");
129 Ok(Self {
130 store,
131 root,
132 trees: Trees::Created,
133 })
134 }
135
136 /// Everything it knows, read back out of its document.
137 pub fn state(&self) -> Result<VcsState> {
138 self.store.snapshot()
139 }
140}
141
142impl<T: Store<VcsState>> Vcs for Repository<T> {
143 fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
144 self.store.with(|state| {
145 state::identity_of(state, origin_or_path)
146 .cloned()
147 .ok_or_else(|| Error::Invalid {
148 reason: format!(
149 "{origin_or_path:?} does not name a repository this provider knows; {}",
150 state::known(state)
151 ),
152 })
153 })
154 }
155
156 fn open_session(&self, req: SessionRequest) -> Result<Session> {
157 let root = self.root.clone();
158 let (session, emission) = self.store.with(|state| {
159 let identity = state::identity_of(state, &req.repo)
160 .cloned()
161 .ok_or_else(|| Error::Invalid {
162 reason: format!(
163 "{:?} does not name a repository this provider knows; {}",
164 req.repo,
165 state::known(state)
166 ),
167 })?;
168 // Consecutive and predictable, so a journey can name the session it is
169 // about to open — the one thing a digest-shaped token takes away.
170 let token = SessionToken(format!("s-testing-{}", state.sessions.len() + 1));
171 let run_root = root.join(&token.0);
172 // Both names are checked before they are recorded, because both go on to
173 // spell a ref for whoever holds the session — and a provider that
174 // accepted a name git refuses would let a journey pass where the real
175 // run stops.
176 let base = req.base.clone().unwrap_or_else(|| DEFAULT_BASE.to_owned());
177 state::named_branch(&base, "the base")?;
178 let session = Session {
179 worktree: run_root.join("worktree"),
180 branch: state::requested_branch(&req, &token)?,
181 base,
182 token: token.clone(),
183 };
184 state.sessions.push(session.clone());
185 state
186 .session_identities
187 .insert(token.clone(), identity.origin.clone());
188 let emission = Emission {
189 stream: token.0.clone(),
190 identity: Some(identity.origin.clone()),
191 kind: EventKind::SessionOpened,
192 payload: object(json!({
193 "token": token.0,
194 "identity": identity.origin,
195 "branch": session.branch,
196 "base": session.base,
197 "worktree": session.worktree.display().to_string(),
198 // Synthetic, and named anyway: a consumer reading this event
199 // reads the same keys whichever implementation produced it.
200 "clone": run_root.join("clone").display().to_string(),
201 "execution_checkout": run_root.join("checkout").display().to_string(),
202 "publication_checkout": run_root.join("checkout").display().to_string(),
203 })),
204 };
205 Ok((session, emission))
206 })?;
207 if self.trees == Trees::Created {
208 std::fs::create_dir_all(&session.worktree).map_err(|e| Error::Invalid {
209 reason: format!("cannot create {}: {e}", session.worktree.display()),
210 })?;
211 }
212 events::emit(&emission);
213 Ok(session)
214 }
215
216 fn adopt_session(&self, token: SessionToken) -> Result<Session> {
217 self.store.with(|state| {
218 state::session_of(state, &token)
219 .cloned()
220 .ok_or_else(|| Error::Invalid {
221 reason: format!(
222 "no session {:?} is open; `onevcs session open` prints a token",
223 token.0
224 ),
225 })
226 })
227 }
228
229 fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch> {
230 let (branch, emission) = self.store.with(|state| {
231 let identity = state::identity_for(state, &s.token)?;
232 let branch = PreservedBranch {
233 branch: s.branch.clone(),
234 base: s.base.clone(),
235 provenance,
236 change_url: None,
237 change_base: None,
238 };
239 let row = Recoverable {
240 identity: identity.clone(),
241 branch: branch.clone(),
242 checkout: s.worktree.clone(),
243 stopped_because: format!("session {} was left open", s.token.0),
244 recover_command: recover_command(&s.branch, &s.worktree, provenance),
245 };
246 // Preserving the same branch twice replaces its row rather than listing
247 // it twice, which is what `recoverable` does across the checkouts a
248 // branch is reachable from.
249 state.preserved.retain(|kept| {
250 kept.identity != row.identity || kept.branch.branch != row.branch.branch
251 });
252 state.preserved.push(row);
253 let emission = Emission {
254 stream: s.token.0.clone(),
255 // No identity label, because the real implementation carries none
256 // here: the label is stamped where a session is opened, and work is
257 // preserved against a stream a later process opened fresh. Claiming
258 // it would be drift in the direction that looks like more
259 // information.
260 identity: None,
261 kind: EventKind::CommitPreserved,
262 payload: object(json!({
263 "branch": s.branch,
264 "sha": events::stable_sha(&[&s.token.0, &s.branch, spell(provenance)]),
265 "provenance": spell(provenance),
266 })),
267 };
268 Ok((branch, emission))
269 })?;
270 events::emit(&emission);
271 Ok(branch)
272 }
273
274 fn session(&self, token: &SessionToken) -> Result<SessionRecord> {
275 self.store.with(|state| {
276 let session = state::session_of(state, token)
277 .cloned()
278 .ok_or_else(|| unknown_session(token))?;
279 let identity = state::identity_for(state, token)?;
280 // Read off what was preserved rather than remembered separately: the
281 // real implementation reads the branch, so a session whose work was
282 // preserved behind an incomplete-step marker answers the same here.
283 let provenance = state
284 .preserved
285 .iter()
286 .find(|row| row.identity == identity && row.branch.branch == session.branch)
287 .map_or(Provenance::Complete, |row| row.branch.provenance);
288 Ok(SessionRecord {
289 lifecycle: if state.closed_sessions.contains(token) {
290 Lifecycle::Closed
291 } else {
292 Lifecycle::Open
293 },
294 session,
295 identity,
296 provenance,
297 })
298 })
299 }
300
301 fn close_session(&self, token: &SessionToken) -> Result<Session> {
302 self.store.with(|state| {
303 let session = state::session_of(state, token)
304 .cloned()
305 .ok_or_else(|| unknown_session(token))?;
306 let emission = Emission {
307 stream: token.0.clone(),
308 // No identity label, because the real implementation carries none
309 // here: closing opens the stream fresh, and the label is stamped
310 // where a session is opened.
311 identity: None,
312 kind: EventKind::SessionClosed,
313 payload: object(json!({"token": token.0, "branch": session.branch})),
314 };
315 // Match the real provider's observable ordering: a follower reads the
316 // stream before consulting this state, so the terminator must exist
317 // before `Closed` can be returned to that concurrent reader.
318 events::emit(&emission);
319 state.closed_sessions.insert(token.clone());
320 Ok(session)
321 })
322 }
323
324 /// Publish a session's branch, as far as a provider honestly can.
325 ///
326 /// The host side is performed rather than described: a change request is really
327 /// opened against the [`Hosting`] this was handed, really adopted when one is
328 /// already open, and really merged under the policy — so the six host methods
329 /// are exercised and what the host recorded is what a journey reads back.
330 ///
331 /// The repository side is not, and none of it is claimed. There is no origin to
332 /// fetch from, no tree to run a gate in, no push, and no lock to queue behind,
333 /// so no `fetch`, `gate-started`, `gate-verdict`, `push`, `lock-wait`,
334 /// `lock-acquired`, or `merge-queued` event is emitted. What is emitted is what
335 /// was decided: the change that was opened, and the merge that landed.
336 fn publish(
337 &self,
338 token: &SessionToken,
339 request: &PublishRequest,
340 hosting: &dyn Hosting,
341 ) -> Result<Publication> {
342 let (publication, emissions) = self.store.with(|state| {
343 let session = state::session_of(state, token)
344 .cloned()
345 .ok_or_else(|| unknown_session(token))?;
346 let identity = state::identity_for(state, token)?;
347 let resolved = state.policy.unwrap_or(DEFAULT_PUBLICATION);
348 let policy = match request.policy {
349 Some(requested) => resolved.narrow(requested)?,
350 None => resolved,
351 };
352 let published = |outcome, emissions| {
353 (
354 Publication {
355 session: token.clone(),
356 branch: session.branch.clone(),
357 policy,
358 outcome,
359 },
360 emissions,
361 )
362 };
363 // A session that has already landed has nothing the base does not carry,
364 // which is what the real implementation reports for the same reason. One
365 // whose change request is merely open or queued has *not* landed, and
366 // publishing it again adopts that change rather than opening a second —
367 // so it falls through to the host, as it does there.
368 if state.publications.iter().any(|earlier| {
369 earlier.session == *token && matches!(earlier.outcome, PublishOutcome::Merged(_))
370 }) {
371 let (publication, emissions) =
372 published(PublishOutcome::NothingToPublish, Vec::new());
373 state.publications.push(publication.clone());
374 return Ok((publication, emissions));
375 }
376
377 let (outcome, emissions) = if policy == MergePolicy::LocalDirect {
378 record_local_landing(&identity, &session, token)
379 } else {
380 match slug(&identity) {
381 Some(slug) => match publish_as_change(
382 hosting, &slug, &identity, &session, policy, request, token,
383 ) {
384 Ok(published) => published,
385 // Once a publication has started, what stops it is an outcome
386 // rather than a refusal — the same split the real
387 // implementation keeps, so a caller reads one shape.
388 Err(error) => (failed(&error), Vec::new()),
389 },
390 None => (refusal(&identity), Vec::new()),
391 }
392 };
393 let (publication, emissions) = published(outcome, emissions);
394 state.publications.push(publication.clone());
395 Ok((publication, emissions))
396 })?;
397 for emission in &emissions {
398 events::emit(emission);
399 }
400 Ok(publication)
401 }
402
403 fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
404 self.store.with(|state| {
405 let wanted = match &scope {
406 Scope::All => None,
407 Scope::Repo(repo) => Some(
408 state::identity_of(state, repo)
409 .map(|identity| identity.origin.clone())
410 .ok_or_else(|| Error::Invalid {
411 reason: format!(
412 "{repo:?} does not name a repository this provider knows; {}",
413 state::known(state)
414 ),
415 })?,
416 ),
417 };
418 // Newest first, as the real implementation reports them.
419 Ok(state
420 .preserved
421 .iter()
422 .rev()
423 .filter(|row| wanted.as_ref().is_none_or(|key| *key == row.identity))
424 .cloned()
425 .collect())
426 })
427 }
428}
429
430/// The refusal a session this provider never opened meets.
431fn unknown_session(token: &SessionToken) -> Error {
432 Error::Invalid {
433 reason: format!(
434 "no session {:?} is open; `onevcs session open` prints a token",
435 token.0
436 ),
437 }
438}
439
440/// The `owner/name` slug an identity key spells, when it is a GitHub one.
441///
442/// The host is checked rather than assumed, exactly as the real implementation
443/// checks it: a GitLab origin has the same three segments, and a provider that
444/// published one anyway would let a journey pass where the real run answers that
445/// nobody has implemented that host.
446fn slug(identity: &str) -> Option<String> {
447 let mut parts = identity.split('/');
448 let (host, owner, name) = (parts.next()?, parts.next()?, parts.next()?);
449 if parts.next().is_some() || host != DEFAULT_HOST || owner.is_empty() || name.is_empty() {
450 return None;
451 }
452 Some(format!("{owner}/{name}"))
453}
454
455/// Record that a `local-direct` publication landed — record, and nothing more.
456///
457/// Landing one is entirely repository-side work, a squash built detached and
458/// pushed, which this provider does not perform and must not be named as if it
459/// did. What it does is decide the outcome and emit the completion that says so.
460fn record_local_landing(
461 identity: &str,
462 session: &Session,
463 token: &SessionToken,
464) -> (PublishOutcome, Vec<Emission>) {
465 let sha = events::stable_sha(&["publish", &token.0, &session.branch]);
466 let emission = Emission {
467 stream: token.0.clone(),
468 identity: Some(identity.to_owned()),
469 kind: EventKind::MergeCompleted,
470 payload: object(json!({"identity": identity, "sha": sha, "base": session.base})),
471 };
472 (PublishOutcome::Merged(Sha(sha)), vec![emission])
473}
474
475/// Publish as a change request: open the session's change on the host, or adopt
476/// the one it already holds, and then do with it what the policy asks — which for
477/// `change-open` is to leave it open and ask the host for nothing more.
478fn publish_as_change(
479 hosting: &dyn Hosting,
480 slug: &str,
481 identity: &str,
482 session: &Session,
483 policy: MergePolicy,
484 request: &PublishRequest,
485 token: &SessionToken,
486) -> Result<(PublishOutcome, Vec<Emission>)> {
487 let host = hosting.for_repo(slug)?;
488 // Who the host believes is calling travels with the change, as it does in the
489 // real publication and for the same reason.
490 let author = host.authenticated_user()?;
491 let existing = host.find_changes(&session.branch, &session.base)?;
492 let change = match existing.into_iter().next() {
493 Some(change) => change,
494 None => host.open_change(ChangeSpec {
495 head: session.branch.clone(),
496 base: session.base.clone(),
497 // A requested title has been checked by the conversion that built it, so
498 // this provider cannot accept one the real publication would refuse. The
499 // real implementation takes the subject from the branch's commits when no
500 // title was requested, and a provider has no commits to read — so an
501 // unrequested title names the branch instead.
502 title: request
503 .title
504 .as_deref()
505 .map_or_else(|| format!("Publish {}", session.branch), str::to_owned),
506 // Verbatim, and nothing when there is none — the real publication
507 // composes no body either, so a provider that composed one would be a
508 // consumer's suite proving a change request nobody opens.
509 body: request.body.clone(),
510 })?,
511 };
512 let mut emissions = vec![Emission {
513 stream: token.0.clone(),
514 identity: Some(identity.to_owned()),
515 kind: EventKind::ChangeOpened,
516 payload: object(json!({
517 "url": change.url.to_string(),
518 "host": "github",
519 "id": change.id.0,
520 "base": change.base,
521 "author": author,
522 })),
523 }];
524 if policy == MergePolicy::ChangeOpen {
525 return Ok((PublishOutcome::ChangeOpen(change.url.clone()), emissions));
526 }
527 Ok(match host.merge(&change, policy)? {
528 MergeOutcome::Merged(sha) => {
529 emissions.push(Emission {
530 stream: token.0.clone(),
531 identity: Some(identity.to_owned()),
532 kind: EventKind::ChangeMerged,
533 payload: object(json!({"url": change.url.to_string(), "sha": sha.0})),
534 });
535 emissions.push(Emission {
536 stream: token.0.clone(),
537 identity: Some(identity.to_owned()),
538 kind: EventKind::MergeCompleted,
539 payload: object(json!({"identity": identity, "sha": sha.0})),
540 });
541 (PublishOutcome::Merged(sha), emissions)
542 }
543 MergeOutcome::Queued => (PublishOutcome::Queued(change.url.clone()), emissions),
544 MergeOutcome::Open => (PublishOutcome::ChangeOpen(change.url.clone()), emissions),
545 })
546}
547
548/// What a publication answers for an identity no change request can be opened
549/// against.
550///
551/// Two failures rather than one, as the real implementation keeps them: an
552/// identity that is not hosted at all is asking for the wrong policy, while a
553/// hosted one on a host this build does not speak for is asking for an
554/// implementation that has not arrived.
555fn refusal(identity: &str) -> PublishOutcome {
556 failed(&if identity.split('/').count() == 3 {
557 Error::NotImplemented {
558 operation: "RemoteHost for a host other than github.com",
559 }
560 } else {
561 Error::Invalid {
562 reason: format!(
563 "identity {identity:?} is not a hosted repository, so it cannot publish a \
564 change request; a local identity publishes with local-direct"
565 ),
566 }
567 })
568}
569
570/// One failure, as the outcome a publication that started and did not land is.
571fn failed(error: &Error) -> PublishOutcome {
572 PublishOutcome::Failed {
573 // Through the crate's own mapping, so the kind a caller branches on is the
574 // one the real implementation would report for the same failure.
575 kind: FailureKind::of(error),
576 reason: error.to_string(),
577 // A provider has no execution checkout, so there is nowhere a branch could
578 // have been handed back to and nothing to report about one.
579 retained: None,
580 }
581}
582
583/// The argv that lands a preserved branch, as `recoverable` reports it.
584///
585/// Both verbs take the branch by name and the repository by path, the way the real
586/// implementation reports them: the provenance decides which one, and neither
587/// depends on the directory a reader happens to be standing in.
588fn recover_command(branch: &str, checkout: &Path, provenance: Provenance) -> Vec<String> {
589 let verb = match provenance {
590 Provenance::IncompleteStep => "recover",
591 Provenance::Complete => "publish-branch",
592 };
593 vec![
594 "onevcs".to_owned(),
595 verb.to_owned(),
596 branch.to_owned(),
597 "--repo".to_owned(),
598 checkout.display().to_string(),
599 ]
600}
601
602/// How a provenance kind is spelled in an event payload.
603fn spell(provenance: Provenance) -> &'static str {
604 match provenance {
605 Provenance::Complete => "complete",
606 Provenance::IncompleteStep => "incomplete-step",
607 }
608}
609
610fn object(value: Value) -> Map<String, Value> {
611 value.as_object().cloned().unwrap_or_default()
612}