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