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 five questions the interface asks, records
4//! what 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
8use std::path::{Path, PathBuf};
9
10use serde_json::{json, Map, Value};
11
12use onevcs::{
13    Error, EventKind, Identity, PreservedBranch, Provenance, Recoverable, Result, Scope, Session,
14    SessionRequest, SessionToken, Vcs,
15};
16
17use crate::events::{self, Emission};
18use crate::state::{self, VcsState};
19use crate::store::{FileStore, MemoryStore, Store};
20
21/// The base a session is cut from when the request names none.
22///
23/// The real implementation asks the origin for its default branch, and this
24/// provider has no origin to ask.
25pub const DEFAULT_BASE: &str = "main";
26
27/// The repository side of a run, over whichever store holds its state.
28///
29/// The two flavours below are this one behaviour with a different store under it,
30/// so neither can learn something the other does not know.
31#[derive(Debug)]
32pub struct Repository<T> {
33    store: T,
34    root: PathBuf,
35    trees: Trees,
36}
37
38/// What a session's worktree path means.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum Trees {
41    /// The path is named and nothing is created there: the in-memory provider
42    /// touches no filesystem beyond the event stream.
43    Named,
44    /// The directory is created, so a journey has somewhere to write work.
45    Created,
46}
47
48/// A repository provider that keeps its state in this process: no disk, no
49/// visibility to a second process, and the fastest of the two.
50///
51/// Its sessions name a worktree under the system temporary directory and **do not
52/// create it** — nothing here touches the filesystem except the event stream, which
53/// is the record a journey reads.
54pub type MemoryVcs = Repository<MemoryStore<VcsState>>;
55
56/// A repository provider that keeps its state in one JSON document, so several
57/// `onevcs` invocations see one another's effects.
58///
59/// Its sessions name a worktree beside that document **and create it**, so a
60/// journey that writes a file into a session's tree has somewhere to write it.
61pub type FileVcs = Repository<FileStore<VcsState>>;
62
63impl MemoryVcs {
64    /// A repository provider knowing nothing.
65    pub fn new() -> Self {
66        Self::seeded(VcsState::default())
67    }
68
69    /// A repository provider that starts from a scenario.
70    pub fn seeded(state: VcsState) -> Self {
71        Self {
72            store: MemoryStore::new(state),
73            root: std::env::temp_dir().join("onevcs-testing-memory"),
74            trees: Trees::Named,
75        }
76    }
77
78    /// Everything it knows.
79    pub fn state(&self) -> VcsState {
80        self.store
81            .snapshot()
82            .expect("an in-memory store always answers")
83    }
84}
85
86impl Default for MemoryVcs {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl FileVcs {
93    /// A repository provider keeping its state at `path`: whatever is already
94    /// there, or nothing.
95    ///
96    /// Attaching rather than replacing, so a second provider over the same path
97    /// picks up what the first one left — which is what a journey driving several
98    /// invocations reaches for this flavour to get.
99    pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
100        Self::over(FileStore::attach(path, &VcsState::default())?)
101    }
102
103    /// A repository provider that starts from a scenario, keeping its state at
104    /// `path` and replacing whatever was there.
105    pub fn seeded(path: impl Into<PathBuf>, state: VcsState) -> Result<Self> {
106        Self::over(FileStore::replace(path, &state)?)
107    }
108
109    fn over(store: FileStore<VcsState>) -> Result<Self> {
110        let root = store
111            .path()
112            .parent()
113            .filter(|parent| !parent.as_os_str().is_empty())
114            .unwrap_or_else(|| Path::new("."))
115            .join("worktrees");
116        Ok(Self {
117            store,
118            root,
119            trees: Trees::Created,
120        })
121    }
122
123    /// Everything it knows, read back out of its document.
124    pub fn state(&self) -> Result<VcsState> {
125        self.store.snapshot()
126    }
127}
128
129impl<T: Store<VcsState>> Vcs for Repository<T> {
130    fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
131        self.store.with(|state| {
132            state::identity_of(state, origin_or_path)
133                .cloned()
134                .ok_or_else(|| Error::Invalid {
135                    reason: format!(
136                        "{origin_or_path:?} does not name a repository this provider knows; {}",
137                        state::known(state)
138                    ),
139                })
140        })
141    }
142
143    fn open_session(&self, req: SessionRequest) -> Result<Session> {
144        let root = self.root.clone();
145        let (session, emission) = self.store.with(|state| {
146            let identity = state::identity_of(state, &req.repo)
147                .cloned()
148                .ok_or_else(|| Error::Invalid {
149                    reason: format!(
150                        "{:?} does not name a repository this provider knows; {}",
151                        req.repo,
152                        state::known(state)
153                    ),
154                })?;
155            // Consecutive and predictable, so a journey can name the session it is
156            // about to open — the one thing a digest-shaped token takes away.
157            let token = SessionToken(format!("s-testing-{}", state.sessions.len() + 1));
158            let run_root = root.join(&token.0);
159            // Both names are checked before they are recorded, because both go on to
160            // spell a ref for whoever holds the session — and a provider that
161            // accepted a name git refuses would let a journey pass where the real
162            // run stops.
163            let base = req.base.clone().unwrap_or_else(|| DEFAULT_BASE.to_owned());
164            state::named_branch(&base, "the base")?;
165            let session = Session {
166                worktree: run_root.join("worktree"),
167                branch: state::requested_branch(&req, &token)?,
168                base,
169                token: token.clone(),
170            };
171            state.sessions.push(session.clone());
172            state
173                .session_identities
174                .insert(token.clone(), identity.origin.clone());
175            let emission = Emission {
176                stream: token.0.clone(),
177                identity: Some(identity.origin.clone()),
178                kind: EventKind::SessionOpened,
179                payload: object(json!({
180                    "token": token.0,
181                    "identity": identity.origin,
182                    "branch": session.branch,
183                    "base": session.base,
184                    "worktree": session.worktree.display().to_string(),
185                    // Synthetic, and named anyway: a consumer reading this event
186                    // reads the same keys whichever implementation produced it.
187                    "clone": run_root.join("clone").display().to_string(),
188                    "execution_checkout": run_root.join("checkout").display().to_string(),
189                    "publication_checkout": run_root.join("checkout").display().to_string(),
190                })),
191            };
192            Ok((session, emission))
193        })?;
194        if self.trees == Trees::Created {
195            std::fs::create_dir_all(&session.worktree).map_err(|e| Error::Invalid {
196                reason: format!("cannot create {}: {e}", session.worktree.display()),
197            })?;
198        }
199        events::emit(&emission);
200        Ok(session)
201    }
202
203    fn adopt_session(&self, token: SessionToken) -> Result<Session> {
204        self.store.with(|state| {
205            state::session_of(state, &token)
206                .cloned()
207                .ok_or_else(|| Error::Invalid {
208                    reason: format!(
209                        "no session {:?} is open; `onevcs session open` prints a token",
210                        token.0
211                    ),
212                })
213        })
214    }
215
216    fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch> {
217        let (branch, emission) = self.store.with(|state| {
218            let identity = state
219                .session_identities
220                .get(&s.token)
221                .cloned()
222                .ok_or_else(|| Error::Invalid {
223                    reason: format!(
224                        "this provider has no record of session {:?}, so it cannot say which \
225                         identity a branch preserved from it belongs to",
226                        s.token.0
227                    ),
228                })?;
229            let branch = PreservedBranch {
230                branch: s.branch.clone(),
231                base: s.base.clone(),
232                provenance,
233                change_url: None,
234                change_base: None,
235            };
236            let row = Recoverable {
237                identity: identity.clone(),
238                branch: branch.clone(),
239                checkout: s.worktree.clone(),
240                stopped_because: format!("session {} was left open", s.token.0),
241                recover_command: recover_command(&s.branch, &s.worktree, provenance),
242            };
243            // Preserving the same branch twice replaces its row rather than listing
244            // it twice, which is what `recoverable` does across the checkouts a
245            // branch is reachable from.
246            state.preserved.retain(|kept| {
247                kept.identity != row.identity || kept.branch.branch != row.branch.branch
248            });
249            state.preserved.push(row);
250            let emission = Emission {
251                stream: s.token.0.clone(),
252                // No identity label, because the real implementation carries none
253                // here: the label is stamped where a session is opened, and work is
254                // preserved against a stream a later process opened fresh. Claiming
255                // it would be drift in the direction that looks like more
256                // information.
257                identity: None,
258                kind: EventKind::CommitPreserved,
259                payload: object(json!({
260                    "branch": s.branch,
261                    "sha": events::stable_sha(&[&s.token.0, &s.branch, spell(provenance)]),
262                    "provenance": spell(provenance),
263                })),
264            };
265            Ok((branch, emission))
266        })?;
267        events::emit(&emission);
268        Ok(branch)
269    }
270
271    fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
272        self.store.with(|state| {
273            let wanted = match &scope {
274                Scope::All => None,
275                Scope::Repo(repo) => Some(
276                    state::identity_of(state, repo)
277                        .map(|identity| identity.origin.clone())
278                        .ok_or_else(|| Error::Invalid {
279                            reason: format!(
280                                "{repo:?} does not name a repository this provider knows; {}",
281                                state::known(state)
282                            ),
283                        })?,
284                ),
285            };
286            // Newest first, as the real implementation reports them.
287            Ok(state
288                .preserved
289                .iter()
290                .rev()
291                .filter(|row| wanted.as_ref().is_none_or(|key| *key == row.identity))
292                .cloned()
293                .collect())
294        })
295    }
296}
297
298/// The argv that lands a preserved branch, as `recoverable` reports it.
299fn recover_command(branch: &str, checkout: &Path, provenance: Provenance) -> Vec<String> {
300    match provenance {
301        Provenance::IncompleteStep => vec![
302            "onevcs".to_owned(),
303            "recover".to_owned(),
304            branch.to_owned(),
305            "--repo".to_owned(),
306            checkout.display().to_string(),
307        ],
308        Provenance::Complete => vec![
309            "onevcs".to_owned(),
310            "integrate".to_owned(),
311            branch.to_owned(),
312        ],
313    }
314}
315
316/// How a provenance kind is spelled in an event payload.
317fn spell(provenance: Provenance) -> &'static str {
318    match provenance {
319        Provenance::Complete => "complete",
320        Provenance::IncompleteStep => "incomplete-step",
321    }
322}
323
324fn object(value: Value) -> Map<String, Value> {
325    value.as_object().cloned().unwrap_or_default()
326}