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 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 identity: None,
312 kind: EventKind::SessionClosed,
313 payload: object(json!({"token": token.0, "branch": session.branch})),
314 };
315 events::emit(&emission);
319 state.closed_sessions.insert(token.clone());
320 Ok(session)
321 })
322 }
323
324 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 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 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 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
430fn 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
440fn 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
455fn 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
475fn 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 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 title: request
503 .title
504 .as_deref()
505 .map_or_else(|| format!("Publish {}", session.branch), str::to_owned),
506 body: None,
507 })?,
508 };
509 let mut emissions = vec![Emission {
510 stream: token.0.clone(),
511 identity: Some(identity.to_owned()),
512 kind: EventKind::ChangeOpened,
513 payload: object(json!({
514 "url": change.url.to_string(),
515 "host": "github",
516 "id": change.id.0,
517 "base": change.base,
518 "author": author,
519 })),
520 }];
521 if policy == MergePolicy::ChangeOpen {
522 return Ok((PublishOutcome::ChangeOpen(change.url.clone()), emissions));
523 }
524 Ok(match host.merge(&change, policy)? {
525 MergeOutcome::Merged(sha) => {
526 emissions.push(Emission {
527 stream: token.0.clone(),
528 identity: Some(identity.to_owned()),
529 kind: EventKind::ChangeMerged,
530 payload: object(json!({"url": change.url.to_string(), "sha": sha.0})),
531 });
532 emissions.push(Emission {
533 stream: token.0.clone(),
534 identity: Some(identity.to_owned()),
535 kind: EventKind::MergeCompleted,
536 payload: object(json!({"identity": identity, "sha": sha.0})),
537 });
538 (PublishOutcome::Merged(sha), emissions)
539 }
540 MergeOutcome::Queued => (PublishOutcome::Queued(change.url.clone()), emissions),
541 MergeOutcome::Open => (PublishOutcome::ChangeOpen(change.url.clone()), emissions),
542 })
543}
544
545fn refusal(identity: &str) -> PublishOutcome {
553 failed(&if identity.split('/').count() == 3 {
554 Error::NotImplemented {
555 operation: "RemoteHost for a host other than github.com",
556 }
557 } else {
558 Error::Invalid {
559 reason: format!(
560 "identity {identity:?} is not a hosted repository, so it cannot publish a \
561 change request; a local identity publishes with local-direct"
562 ),
563 }
564 })
565}
566
567fn failed(error: &Error) -> PublishOutcome {
569 PublishOutcome::Failed {
570 kind: FailureKind::of(error),
573 reason: error.to_string(),
574 retained: None,
577 }
578}
579
580fn recover_command(branch: &str, checkout: &Path, provenance: Provenance) -> Vec<String> {
582 match provenance {
583 Provenance::IncompleteStep => vec![
584 "onevcs".to_owned(),
585 "recover".to_owned(),
586 branch.to_owned(),
587 "--repo".to_owned(),
588 checkout.display().to_string(),
589 ],
590 Provenance::Complete => vec![
591 "onevcs".to_owned(),
592 "integrate".to_owned(),
593 branch.to_owned(),
594 ],
595 }
596}
597
598fn spell(provenance: Provenance) -> &'static str {
600 match provenance {
601 Provenance::Complete => "complete",
602 Provenance::IncompleteStep => "incomplete-step",
603 }
604}
605
606fn object(value: Value) -> Map<String, Value> {
607 value.as_object().cloned().unwrap_or_default()
608}