Skip to main content

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        let (session, emission) = self.store.with(|state| {
303            let session = state::session_of(state, token)
304                .cloned()
305                .ok_or_else(|| unknown_session(token))?;
306            state.closed_sessions.insert(token.clone());
307            let emission = Emission {
308                stream: token.0.clone(),
309                // No identity label, because the real implementation carries none
310                // here: closing opens the stream fresh, and the label is stamped
311                // where a session is opened.
312                identity: None,
313                kind: EventKind::SessionClosed,
314                payload: object(json!({"token": token.0, "branch": session.branch})),
315            };
316            Ok((session, emission))
317        })?;
318        events::emit(&emission);
319        Ok(session)
320    }
321
322    /// Publish a session's branch, as far as a provider honestly can.
323    ///
324    /// The host side is performed rather than described: a change request is really
325    /// opened against the [`Hosting`] this was handed, really adopted when one is
326    /// already open, and really merged under the policy — so the six host methods
327    /// are exercised and what the host recorded is what a journey reads back.
328    ///
329    /// The repository side is not, and none of it is claimed. There is no origin to
330    /// fetch from, no tree to run a gate in, no push, and no lock to queue behind,
331    /// so no `fetch`, `gate-started`, `gate-verdict`, `push`, `lock-wait`,
332    /// `lock-acquired`, or `merge-queued` event is emitted. What is emitted is what
333    /// was decided: the change that was opened, and the merge that landed.
334    fn publish(
335        &self,
336        token: &SessionToken,
337        request: &PublishRequest,
338        hosting: &dyn Hosting,
339    ) -> Result<Publication> {
340        let (publication, emissions) = self.store.with(|state| {
341            let session = state::session_of(state, token)
342                .cloned()
343                .ok_or_else(|| unknown_session(token))?;
344            let identity = state::identity_for(state, token)?;
345            let resolved = state.policy.unwrap_or(DEFAULT_PUBLICATION);
346            let policy = match request.policy {
347                Some(requested) => resolved.narrow(requested)?,
348                None => resolved,
349            };
350            let published = |outcome, emissions| {
351                (
352                    Publication {
353                        session: token.clone(),
354                        branch: session.branch.clone(),
355                        policy,
356                        outcome,
357                    },
358                    emissions,
359                )
360            };
361            // A session that has already landed has nothing the base does not carry,
362            // which is what the real implementation reports for the same reason. One
363            // whose change request is merely open or queued has *not* landed, and
364            // publishing it again adopts that change rather than opening a second —
365            // so it falls through to the host, as it does there.
366            if state.publications.iter().any(|earlier| {
367                earlier.session == *token && matches!(earlier.outcome, PublishOutcome::Merged(_))
368            }) {
369                let (publication, emissions) =
370                    published(PublishOutcome::NothingToPublish, Vec::new());
371                state.publications.push(publication.clone());
372                return Ok((publication, emissions));
373            }
374
375            let (outcome, emissions) = if policy == MergePolicy::LocalDirect {
376                record_local_landing(&identity, &session, token)
377            } else {
378                match slug(&identity) {
379                    Some(slug) => match publish_as_change(
380                        hosting, &slug, &identity, &session, policy, request, token,
381                    ) {
382                        Ok(published) => published,
383                        // Once a publication has started, what stops it is an outcome
384                        // rather than a refusal — the same split the real
385                        // implementation keeps, so a caller reads one shape.
386                        Err(error) => (failed(&error), Vec::new()),
387                    },
388                    None => (refusal(&identity), Vec::new()),
389                }
390            };
391            let (publication, emissions) = published(outcome, emissions);
392            state.publications.push(publication.clone());
393            Ok((publication, emissions))
394        })?;
395        for emission in &emissions {
396            events::emit(emission);
397        }
398        Ok(publication)
399    }
400
401    fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
402        self.store.with(|state| {
403            let wanted = match &scope {
404                Scope::All => None,
405                Scope::Repo(repo) => Some(
406                    state::identity_of(state, repo)
407                        .map(|identity| identity.origin.clone())
408                        .ok_or_else(|| Error::Invalid {
409                            reason: format!(
410                                "{repo:?} does not name a repository this provider knows; {}",
411                                state::known(state)
412                            ),
413                        })?,
414                ),
415            };
416            // Newest first, as the real implementation reports them.
417            Ok(state
418                .preserved
419                .iter()
420                .rev()
421                .filter(|row| wanted.as_ref().is_none_or(|key| *key == row.identity))
422                .cloned()
423                .collect())
424        })
425    }
426}
427
428/// The refusal a session this provider never opened meets.
429fn unknown_session(token: &SessionToken) -> Error {
430    Error::Invalid {
431        reason: format!(
432            "no session {:?} is open; `onevcs session open` prints a token",
433            token.0
434        ),
435    }
436}
437
438/// The `owner/name` slug an identity key spells, when it is a GitHub one.
439///
440/// The host is checked rather than assumed, exactly as the real implementation
441/// checks it: a GitLab origin has the same three segments, and a provider that
442/// published one anyway would let a journey pass where the real run answers that
443/// nobody has implemented that host.
444fn slug(identity: &str) -> Option<String> {
445    let mut parts = identity.split('/');
446    let (host, owner, name) = (parts.next()?, parts.next()?, parts.next()?);
447    if parts.next().is_some() || host != DEFAULT_HOST || owner.is_empty() || name.is_empty() {
448        return None;
449    }
450    Some(format!("{owner}/{name}"))
451}
452
453/// Record that a `local-direct` publication landed — record, and nothing more.
454///
455/// Landing one is entirely repository-side work, a squash built detached and
456/// pushed, which this provider does not perform and must not be named as if it
457/// did. What it does is decide the outcome and emit the completion that says so.
458fn record_local_landing(
459    identity: &str,
460    session: &Session,
461    token: &SessionToken,
462) -> (PublishOutcome, Vec<Emission>) {
463    let sha = events::stable_sha(&["publish", &token.0, &session.branch]);
464    let emission = Emission {
465        stream: token.0.clone(),
466        identity: Some(identity.to_owned()),
467        kind: EventKind::MergeCompleted,
468        payload: object(json!({"identity": identity, "sha": sha, "base": session.base})),
469    };
470    (PublishOutcome::Merged(Sha(sha)), vec![emission])
471}
472
473/// Publish as a change request: open the session's change on the host, or adopt
474/// the one it already holds, and then do with it what the policy asks — which for
475/// `change-open` is to leave it open and ask the host for nothing more.
476fn publish_as_change(
477    hosting: &dyn Hosting,
478    slug: &str,
479    identity: &str,
480    session: &Session,
481    policy: MergePolicy,
482    request: &PublishRequest,
483    token: &SessionToken,
484) -> Result<(PublishOutcome, Vec<Emission>)> {
485    let host = hosting.for_repo(slug)?;
486    // Who the host believes is calling travels with the change, as it does in the
487    // real publication and for the same reason.
488    let author = host.authenticated_user()?;
489    let existing = host.find_changes(&session.branch, &session.base)?;
490    let change = match existing.into_iter().next() {
491        Some(change) => change,
492        None => host.open_change(ChangeSpec {
493            head: session.branch.clone(),
494            base: session.base.clone(),
495            // A requested title has been checked by the conversion that built it, so
496            // this provider cannot accept one the real publication would refuse. The
497            // real implementation takes the subject from the branch's commits when no
498            // title was requested, and a provider has no commits to read — so an
499            // unrequested title names the branch instead.
500            title: request
501                .title
502                .as_deref()
503                .map_or_else(|| format!("Publish {}", session.branch), str::to_owned),
504            body: None,
505        })?,
506    };
507    let mut emissions = vec![Emission {
508        stream: token.0.clone(),
509        identity: Some(identity.to_owned()),
510        kind: EventKind::ChangeOpened,
511        payload: object(json!({
512            "url": change.url.to_string(),
513            "host": "github",
514            "id": change.id.0,
515            "base": change.base,
516            "author": author,
517        })),
518    }];
519    if policy == MergePolicy::ChangeOpen {
520        return Ok((PublishOutcome::ChangeOpen(change.url.clone()), emissions));
521    }
522    Ok(match host.merge(&change, policy)? {
523        MergeOutcome::Merged(sha) => {
524            emissions.push(Emission {
525                stream: token.0.clone(),
526                identity: Some(identity.to_owned()),
527                kind: EventKind::ChangeMerged,
528                payload: object(json!({"url": change.url.to_string(), "sha": sha.0})),
529            });
530            emissions.push(Emission {
531                stream: token.0.clone(),
532                identity: Some(identity.to_owned()),
533                kind: EventKind::MergeCompleted,
534                payload: object(json!({"identity": identity, "sha": sha.0})),
535            });
536            (PublishOutcome::Merged(sha), emissions)
537        }
538        MergeOutcome::Queued => (PublishOutcome::Queued(change.url.clone()), emissions),
539        MergeOutcome::Open => (PublishOutcome::ChangeOpen(change.url.clone()), emissions),
540    })
541}
542
543/// What a publication answers for an identity no change request can be opened
544/// against.
545///
546/// Two failures rather than one, as the real implementation keeps them: an
547/// identity that is not hosted at all is asking for the wrong policy, while a
548/// hosted one on a host this build does not speak for is asking for an
549/// implementation that has not arrived.
550fn refusal(identity: &str) -> PublishOutcome {
551    failed(&if identity.split('/').count() == 3 {
552        Error::NotImplemented {
553            operation: "RemoteHost for a host other than github.com",
554        }
555    } else {
556        Error::Invalid {
557            reason: format!(
558                "identity {identity:?} is not a hosted repository, so it cannot publish a \
559                 change request; a local identity publishes with local-direct"
560            ),
561        }
562    })
563}
564
565/// One failure, as the outcome a publication that started and did not land is.
566fn failed(error: &Error) -> PublishOutcome {
567    PublishOutcome::Failed {
568        // Through the crate's own mapping, so the kind a caller branches on is the
569        // one the real implementation would report for the same failure.
570        kind: FailureKind::of(error),
571        reason: error.to_string(),
572        // A provider has no execution checkout, so there is nowhere a branch could
573        // have been handed back to and nothing to report about one.
574        retained: None,
575    }
576}
577
578/// The argv that lands a preserved branch, as `recoverable` reports it.
579fn recover_command(branch: &str, checkout: &Path, provenance: Provenance) -> Vec<String> {
580    match provenance {
581        Provenance::IncompleteStep => vec![
582            "onevcs".to_owned(),
583            "recover".to_owned(),
584            branch.to_owned(),
585            "--repo".to_owned(),
586            checkout.display().to_string(),
587        ],
588        Provenance::Complete => vec![
589            "onevcs".to_owned(),
590            "integrate".to_owned(),
591            branch.to_owned(),
592        ],
593    }
594}
595
596/// How a provenance kind is spelled in an event payload.
597fn spell(provenance: Provenance) -> &'static str {
598    match provenance {
599        Provenance::Complete => "complete",
600        Provenance::IncompleteStep => "incomplete-step",
601    }
602}
603
604fn object(value: Value) -> Map<String, Value> {
605    value.as_object().cloned().unwrap_or_default()
606}