1use 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
28pub const DEFAULT_BASE: &str = "main";
33
34pub const DEFAULT_PUBLICATION: MergePolicy = MergePolicy::ChangeOpen;
39
40#[derive(Debug)]
45pub struct Repository<T> {
46 store: T,
47 root: PathBuf,
48 trees: Trees,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum Trees {
54 Named,
57 Created,
59}
60
61pub type MemoryVcs = Repository<MemoryStore<VcsState>>;
68
69pub type FileVcs = Repository<FileStore<VcsState>>;
75
76impl MemoryVcs {
77 pub fn new() -> Self {
79 Self::seeded(VcsState::default())
80 }
81
82 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 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 pub fn create(path: impl Into<PathBuf>) -> Result<Self> {
113 Self::over(FileStore::attach(path, &VcsState::default())?)
114 }
115
116 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 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 let token = SessionToken(format!("s-testing-{}", state.sessions.len() + 1));
171 let run_root = root.join(&token.0);
172 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 "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 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 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 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 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 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 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 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 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
428fn 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
438fn 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
453fn 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
473fn 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 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 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
543fn 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
565fn failed(error: &Error) -> PublishOutcome {
567 PublishOutcome::Failed {
568 kind: FailureKind::of(error),
571 reason: error.to_string(),
572 retained: None,
575 }
576}
577
578fn 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
596fn 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}