onevcs/ops.rs
1//! The typed operations the command line renders.
2//!
3//! Every command of this crate answers a *value* first and a rendering second.
4//! `app.rs` calls one of these and turns what it hands back into stdout, stderr
5//! and an exit code; it decides nothing else. That is what keeps the two surfaces
6//! one decision — a consumer embedding the crate and a user reading the command
7//! are told the same thing by the same code, rather than by two readers of one
8//! store that drift a rewording at a time.
9//!
10//! What lives here is the operations that had no home of their own: the ones whose
11//! body used to be inside a command handler, or which composed two private modules
12//! and a rendering. An operation that already belongs to a module — a publication,
13//! a session, a pool, a release — stays there and is re-exported from `lib.rs`
14//! beside these.
15
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use serde::Serialize;
20use url::Url;
21
22use crate::boundary::TermScope;
23use crate::error::{Error, Result};
24use crate::import::Imported;
25use crate::integrate::Outcome as Integration;
26use crate::providers::Providers;
27use crate::publish::{PublishOutcome, Subject};
28use crate::registry::Registry;
29use crate::rules::{Approvals, BaseMerge, MergePolicy, RuleMatch};
30pub use crate::store::Coverage as MergePathCoverage;
31use crate::store::{self, Resolution};
32use crate::stream::Stream;
33use crate::sweep::Report as SweepReport;
34use crate::workspace::Ref;
35use crate::{git, policy, provenance, status, stream};
36
37/// A direct invocation's selector, using its normalized registered identity.
38#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
39#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
40pub enum DirectOperation {
41 /// Publish one completed branch.
42 PublishBranch {
43 /// Normalized registered repository identity.
44 // llmlint: ignore[invalid_states_unrepresentable] The approved DirectOperation
45 // declaration in docs/contract.md fixes this field as String. prepare and
46 // selector validate its normalized registered identity before use.
47 repository: String,
48 /// Nonempty branch selector.
49 // llmlint: ignore[invalid_states_unrepresentable] The approved DirectOperation
50 // declaration fixes this field as String. prepare and selector reject an
51 // empty branch; changing its type would change the approved interface.
52 branch: String,
53 },
54 /// Recover one interrupted branch.
55 Recover {
56 /// Normalized registered repository identity.
57 // llmlint: ignore[invalid_states_unrepresentable] The approved DirectOperation
58 // declaration in docs/contract.md fixes this field as String. prepare and
59 // selector validate its normalized registered identity before use.
60 repository: String,
61 /// Nonempty branch selector.
62 // llmlint: ignore[invalid_states_unrepresentable] The approved DirectOperation
63 // declaration fixes this field as String. prepare and selector reject an
64 // empty branch; changing its type would change the approved interface.
65 branch: String,
66 },
67 /// Integrate a train in the selected repository's current checkout.
68 Integrate {
69 /// Normalized registered repository identity, matched against cwd.
70 // llmlint: ignore[invalid_states_unrepresentable] The approved DirectOperation
71 // declaration in docs/contract.md fixes this field as String. prepare and
72 // selector validate its normalized registered identity before use.
73 repository: String,
74 },
75}
76
77#[derive(serde::Serialize, serde::Deserialize)]
78#[serde(deny_unknown_fields)]
79struct StoredOperation {
80 version: u32,
81 operation: DirectOperation,
82}
83
84fn operation_repository(operation: &DirectOperation) -> &str {
85 match operation {
86 DirectOperation::PublishBranch { repository, .. }
87 | DirectOperation::Recover { repository, .. }
88 | DirectOperation::Integrate { repository } => repository,
89 }
90}
91
92fn validate_selector(operation: &DirectOperation) -> Result<()> {
93 let (repository, branch) = match operation {
94 DirectOperation::PublishBranch { repository, branch }
95 | DirectOperation::Recover { repository, branch } => (repository, Some(branch)),
96 DirectOperation::Integrate { repository } => (repository, None),
97 };
98 if branch.is_some_and(|branch| branch.trim().is_empty()) {
99 return Err(crate::error::invalid("operation branch must be nonempty"));
100 }
101 let registry = store::load()?;
102 let resolved = store::resolve(®istry, repository)?;
103 if &resolved.key != repository {
104 return Err(crate::error::invalid(
105 "operation repository must be a normalized registered identity",
106 ));
107 }
108 Ok(())
109}
110
111/// A reserved invocation identity. Cloning it never reserves another invocation.
112#[derive(Debug, Clone)]
113pub struct OperationHandle {
114 id: String,
115}
116
117impl OperationHandle {
118 /// Reserve an invocation without starting git or host work.
119 pub fn prepare(operation: &DirectOperation) -> Result<Self> {
120 validate_selector(operation)?;
121 let directory = crate::home::root()?.join("operations");
122 crate::home::ensure_dir(&directory)?;
123 let (handle, path) = loop {
124 let handle = Self {
125 id: format!("o-{}", crate::ids::unique()),
126 };
127 let path = handle.directory()?;
128 match std::fs::create_dir(&path) {
129 Ok(()) => break (handle, path),
130 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
131 Err(error) => {
132 return Err(crate::error::at("reserve operation directory", &path)(
133 error,
134 ))
135 }
136 }
137 };
138 let manifest = path.join("operation.json");
139 let bytes = serde_json::to_vec(&StoredOperation {
140 version: 1,
141 operation: operation.clone(),
142 })
143 .map_err(crate::error::at("serialize operation", &manifest))?;
144 std::fs::write(&manifest, bytes)
145 .map_err(crate::error::at("reserve operation", &manifest))?;
146 std::fs::File::create(path.join("events.ndjson"))
147 .map_err(crate::error::at("create operation stream", &path))?;
148 Ok(handle)
149 }
150
151 /// Open an existing prepared invocation, including after its writer died.
152 pub fn open(id: &str) -> Result<Self> {
153 let handle = Self { id: id.to_owned() };
154 handle.selector()?;
155 Ok(handle)
156 }
157
158 /// The opaque identity to persist for another process.
159 pub fn id(&self) -> &str {
160 &self.id
161 }
162
163 pub(crate) fn directory(&self) -> Result<PathBuf> {
164 if !self.id.starts_with("o-") || !crate::ids::is_safe_name(&self.id) {
165 return Err(crate::error::invalid("invalid operation handle"));
166 }
167 Ok(crate::home::root()?.join("operations").join(&self.id))
168 }
169
170 fn selector(&self) -> Result<DirectOperation> {
171 let path = self.directory()?.join("operation.json");
172 let bytes = std::fs::read(&path).map_err(crate::error::at("open operation", &path))?;
173 let stored: StoredOperation =
174 serde_json::from_slice(&bytes).map_err(crate::error::at("read operation", &path))?;
175 if stored.version != 1 {
176 return Err(crate::error::invalid(format!(
177 "unsupported operation schema version {}",
178 stored.version
179 )));
180 }
181 validate_selector(&stored.operation)?;
182 Ok(stored.operation)
183 }
184
185 pub(crate) fn repository(&self) -> Result<String> {
186 Ok(operation_repository(&self.selector()?).to_owned())
187 }
188
189 fn claim(&self, expected: &DirectOperation) -> Result<()> {
190 if self.selector()? != *expected {
191 return Err(crate::error::invalid(
192 "operation handle does not match repository, kind and branch",
193 ));
194 }
195 let path = self.directory()?.join("claimed");
196 std::fs::OpenOptions::new()
197 .write(true)
198 .create_new(true)
199 .open(&path)
200 .map_err(crate::error::at("claim operation exactly once", &path))?;
201 Ok(())
202 }
203}
204
205/// Publish a branch into exactly the invocation prepared by its caller.
206pub fn publish_branch_in_operation(
207 providers: &Providers<'_>,
208 request: &BranchPublishRequest,
209 operation: &OperationHandle,
210) -> Result<PublishOutcome> {
211 let registry = store::load()?;
212 let resolution = store::resolve_path(®istry, &request.repo)?;
213 let expected = DirectOperation::PublishBranch {
214 repository: resolution.key,
215 branch: request.branch.clone(),
216 };
217 let token = crate::status::keyed::Verb::PublishBranch.stream(&request.branch);
218 operation.claim(&expected)?;
219 let mut stream = branch_keyed(®istry, &request.repo, &token)?;
220 stream.operation(operation, operation_repository(&expected))?;
221 crate::publish_branch::run(
222 ®istry,
223 &request.repo,
224 &request.branch,
225 request.title.clone(),
226 request.body.clone(),
227 request.policy,
228 &request.term_scope,
229 providers.hosting,
230 &mut stream,
231 )
232}
233
234/// Recover a branch into exactly the invocation prepared by its caller.
235pub fn recover_in_operation(
236 providers: &Providers<'_>,
237 request: &RecoverRequest,
238 operation: &OperationHandle,
239) -> Result<PublishOutcome> {
240 let registry = store::load()?;
241 let resolution = store::resolve_path(®istry, &request.repo)?;
242 let expected = DirectOperation::Recover {
243 repository: resolution.key,
244 branch: request.branch.clone(),
245 };
246 let token = crate::status::keyed::Verb::Recover.stream(&request.branch);
247 operation.claim(&expected)?;
248 let mut stream = branch_keyed(®istry, &request.repo, &token)?;
249 stream.operation(operation, operation_repository(&expected))?;
250 crate::recover::run(
251 ®istry,
252 &request.repo,
253 &request.branch,
254 request.title.clone(),
255 request.body.clone(),
256 &request.term_scope,
257 providers.hosting,
258 &mut stream,
259 )
260}
261
262/// Integrate in the actual cwd identity, refusing a different prepared identity.
263pub fn integrate_in_operation(
264 request: &IntegrateRequest,
265 operation: &OperationHandle,
266) -> Result<Integration> {
267 let registry = store::load()?;
268 let resolution = store::resolve_here(®istry)?;
269 let token = format!("integrate-{}", policy::branch_slug(&resolution.alias));
270 operation.claim(&DirectOperation::Integrate {
271 repository: resolution.key.clone(),
272 })?;
273 let mut stream = Stream::open(&token)?;
274 stream.operation(operation, &resolution.key)?;
275 crate::integrate::run(
276 &resolution,
277 &request.branches,
278 request.push == BasePush::Push,
279 &request.term_scope,
280 &mut stream,
281 )
282}
283
284/// The publication policy a repository resolves to, and where each half of it came
285/// from.
286///
287/// The two fields travel with their provenance because every rendering of a policy
288/// in this crate states it: a policy read without knowing whether a rule or the
289/// built-in default decided it is one nobody can act on — the edit that changes it
290/// is in a different file in each case.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub struct ResolvedPolicy {
293 /// How this repository publishes.
294 pub publication: MergePolicy,
295 /// What decided `publication`: a numbered rule of the rules file, or the default.
296 pub publication_from: String,
297 /// Whether a change of this repository needs an approval.
298 pub approvals: Approvals,
299 /// What decided `approvals`, in the same words `publication_from` uses.
300 pub approvals_from: String,
301 /// Whether the draft lifecycle is off, so a change request opens ready.
302 pub drafts_disabled: bool,
303 /// What decided `drafts_disabled`: a numbered rule, the default, or the shipped
304 /// default.
305 pub drafts_disabled_from: String,
306 /// Whether an early lift of a draft prints its warning line.
307 pub warn_on_early_lift: bool,
308 /// What decided `warn_on_early_lift`, in the same words.
309 pub warn_on_early_lift_from: String,
310 /// When a moved base is merged into a branch that already exists.
311 pub base_merge: BaseMerge,
312 /// What decided `base_merge`: a numbered rule, the default, or the publication it
313 /// was derived from.
314 pub base_merge_from: String,
315 /// How long a publication watches its change request's checks.
316 pub checks_wait: Duration,
317 /// What decided `checks_wait`: `ONEVCS_CHECKS_TIMEOUT_SECONDS`, a numbered rule,
318 /// the default, or the shipped default.
319 pub checks_wait_from: String,
320}
321
322impl ResolvedPolicy {
323 /// The policy one repository resolved to, with the process-wide check-wait
324 /// override applied — which is why it can fail: a malformed override is refused
325 /// here, as it is before any push.
326 fn of(resolved: &policy::Resolved) -> Result<Self> {
327 let wait = resolved.checks_wait.effective()?;
328 Ok(Self {
329 base_merge: resolved.base_merge.value,
330 base_merge_from: resolved.base_merge.from.clone(),
331 checks_wait: Duration::from_secs_f64(wait.value.get()),
332 checks_wait_from: wait.from,
333 publication: resolved.policy.publication,
334 publication_from: resolved.publication_from.clone(),
335 approvals: resolved.policy.approvals,
336 approvals_from: resolved.approvals_from.clone(),
337 drafts_disabled: resolved.drafts.disabled,
338 drafts_disabled_from: resolved.drafts.disabled_from.clone(),
339 warn_on_early_lift: resolved.drafts.warn_on_early_lift,
340 warn_on_early_lift_from: resolved.drafts.warn_on_early_lift_from.clone(),
341 })
342 }
343}
344
345/// What `onevcs register` recorded, and what the identity it recorded publishes
346/// under.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct Registration {
349 /// The identity key the checkout's origin normalized to.
350 pub identity: String,
351 /// The alias the checkout is addressed by.
352 pub alias: String,
353 /// The publication checkout the identity resolves to.
354 pub checkout: PathBuf,
355 /// The command this repository states as its complete bar.
356 pub gate: String,
357 /// The policy the identity publishes under, as its rules resolve it.
358 pub policy: ResolvedPolicy,
359 /// What runs a gate on the identity's merge path — and
360 /// [`MergePathCoverage::None`] for one where nothing does, which is the state a
361 /// caller warns about.
362 pub coverage: MergePathCoverage,
363}
364
365/// Register a checkout, resolving its origin to a repository identity.
366///
367/// The library form of `onevcs register`. `origin` is the URL to resolve the
368/// identity from where the checkout's own remote is not the one to use.
369pub fn register_checkout(path: &Path, origin: Option<&Url>) -> Result<Registration> {
370 let origin = origin.map(Url::to_string);
371 let resolution = store::register(path, origin.as_deref())?;
372 // Whether it is public is recorded as it is registered, so a public repository
373 // stops contributing terms to the boundary check from the start rather than from
374 // its first publication. Best effort: a host that cannot answer leaves it unknown,
375 // which is private, and every write to it asks again.
376 let _ = crate::boundary::visibility::refresh(Providers::real().hosting, &resolution.key);
377 // What was registered is the identity and its checkout; how it publishes is the
378 // rules file's answer, resolved here so the coverage below is about the merge
379 // path the identity will actually take.
380 let registry = store::load()?;
381 let (file, source) = policy::load(®istry)?;
382 let resolved = policy::resolve_for(&file, &source, &resolution);
383 let coverage = store::merge_path_coverage(
384 &resolution,
385 &resolution.publication,
386 resolved.policy.publication,
387 );
388 Ok(Registration {
389 identity: resolution.key,
390 alias: resolution.alias,
391 checkout: resolution.publication,
392 gate: resolution.identity.gate,
393 policy: ResolvedPolicy::of(&resolved)?,
394 coverage,
395 })
396}
397
398/// One registered repository identity, as `onevcs repos` lists it.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct RegisteredRepository {
401 /// The identity key: the normalized origin every checkout of it shares.
402 pub identity: String,
403 /// The command this repository states as its complete bar.
404 pub gate: String,
405 /// Its registered checkouts, in alias order.
406 pub checkouts: Vec<RegisteredCheckout>,
407 /// What the identity's host requires on its base. `None` is a question that was
408 /// not asked — the audit was not requested, or the identity has no registered
409 /// checkout to ask through — because a listing that did not ask must not read as
410 /// one that asked and was told nothing.
411 pub required_checks: Option<RequiredChecksAnswer>,
412}
413
414/// One registered checkout of an identity.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct RegisteredCheckout {
417 /// The alias it is addressed by.
418 pub alias: String,
419 /// Where it is.
420 pub path: PathBuf,
421 /// The policy it publishes under and what covers its merge path, asked only
422 /// when the audit was.
423 pub audit: Option<CheckoutAudit>,
424}
425
426/// What the gate audit found about one checkout.
427#[derive(Debug, Clone, PartialEq, Eq)]
428pub struct CheckoutAudit {
429 /// The policy this checkout publishes under.
430 pub policy: ResolvedPolicy,
431 /// What runs a gate on its merge path, decided from that policy.
432 pub coverage: MergePathCoverage,
433}
434
435/// What an identity's host says it requires before a merge.
436///
437/// Three answers, and none collapses into another for the reason a release probe's
438/// do not: a consumer that reads "nothing required" stops waiting on a check, and
439/// one that reads "unreadable" knows it has not been told. Whether an *answered*
440/// list is complete is [`crate::RequiredChecks`]'s own question — a host protects a
441/// branch from more than one source, and a credential may be refused one of them.
442#[derive(Debug, Clone, PartialEq, Eq)]
443pub enum RequiredChecksAnswer {
444 /// The identity is not hosted anywhere this build answers for, so no host
445 /// answers for it at all.
446 NotHosted,
447 /// The host was asked and refused, or the base could not be read.
448 Unreadable {
449 /// What refused, in its own words.
450 reason: String,
451 },
452 /// The host answered about this base.
453 Answered {
454 /// The base the checks were read for.
455 base: String,
456 /// What it said, and which of its sources went unconsulted.
457 checks: crate::host::RequiredChecks,
458 },
459}
460
461/// Whether a listing carries the gate audit, which is what `--audit-gates` asks for.
462///
463/// Named rather than a flag, because the two answers are different documents: one is
464/// the registry as it stands, and the other reaches a host and reads this host's
465/// rules — a distinction a caller should have to spell at the call site.
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum GateAudit {
468 /// The registry as it stands: no rules read, no host asked.
469 Skipped,
470 /// Each identity's required checks, and each checkout's policy and what covers
471 /// its merge path.
472 Asked,
473}
474
475/// Every registered repository, in the order `onevcs repos` lists them.
476///
477/// With [`GateAudit::Asked`], each identity carries what its host requires and each
478/// checkout the policy it publishes under and what covers its merge path. With
479/// [`GateAudit::Skipped`] nothing reads the rules file and no host is asked — the
480/// answer is the registry as it stands.
481pub fn repositories(
482 providers: &Providers<'_>,
483 audit: GateAudit,
484) -> Result<Vec<RegisteredRepository>> {
485 let registry = store::load()?;
486 let mut listed = listing(®istry);
487 if audit == GateAudit::Skipped {
488 return Ok(listed);
489 }
490 // The audit is about each identity's merge path, and the merge path is the
491 // resolved policy's: which verifier covers it follows from how it publishes, so
492 // the rules are read once here and resolved per checkout below.
493 let (file, source) = policy::load(®istry)?;
494 // Zipped rather than looked up again: the listing is this document's identities
495 // in this document's order, so the pairing is exact and there is no second read
496 // of the registry to answer from.
497 for (repository, (_, identity)) in listed.iter_mut().zip(registry.identities.iter()) {
498 // The base is a fact about the origin, so the first registered checkout of it
499 // answers; an identity with none is left unasked rather than answered.
500 repository.required_checks = repository
501 .checkouts
502 .first()
503 .map(|first| required_checks(&repository.identity, &first.path, providers));
504 for checkout in &mut repository.checkouts {
505 let resolution = Resolution {
506 key: repository.identity.clone(),
507 identity: identity.clone(),
508 alias: checkout.alias.clone(),
509 publication: checkout.path.clone(),
510 };
511 let resolved = policy::resolve_for(&file, &source, &resolution);
512 let coverage = store::merge_path_coverage(
513 &resolution,
514 &checkout.path,
515 resolved.policy.publication,
516 );
517 checkout.audit = Some(CheckoutAudit {
518 policy: ResolvedPolicy::of(&resolved)?,
519 coverage,
520 });
521 }
522 }
523 Ok(listed)
524}
525
526/// Every repository identity this host has registered, in the order `onevcs repos`
527/// lists them.
528///
529/// The library form of the unindented lines `onevcs repos` prints: one normalized
530/// origin per registered identity — `host/owner/name` for a hosted one, the origin
531/// path for a local one — which is the key every other repository-taking operation
532/// here accepts.
533///
534/// It exists because the enumeration had no library form at all. The migration-aware
535/// loader is private, so a consumer that wanted the registered identities — to
536/// maintain each of them, to sweep across them, to ask what each one releases — had
537/// to spawn the binary and parse the prose `repos` prints, which couples it to a
538/// display line nobody promised to keep.
539///
540/// The order is the registry's own, which is the order the command prints, so the
541/// two readings of one document cannot come apart. A registry an older build wrote
542/// is migrated as this reads it, exactly as the command migrates it, and a document
543/// this build cannot read is an `Err` naming what could not be read rather than an
544/// empty list — "this host has registered nothing" and "this host's registry could
545/// not be read" are opposite facts, and a caller acts on only one of them.
546///
547/// It takes no [`Providers`] for the reason [`crate::session_holders`] does not: the
548/// registry is this host's own document, and there is nothing here for an
549/// implementation of either interface to answer.
550pub fn registered_identities() -> Result<Vec<String>> {
551 Ok(listing(&store::load()?)
552 .into_iter()
553 .map(|repository| repository.identity)
554 .collect())
555}
556
557/// The registry as a listing: the one enumeration both reads above answer from, so
558/// the identities a caller asks for and the rows the command prints cannot come to
559/// be two different sets in two different orders.
560fn listing(registry: &Registry) -> Vec<RegisteredRepository> {
561 registry
562 .identities
563 .iter()
564 .map(|(key, identity)| RegisteredRepository {
565 identity: key.clone(),
566 gate: identity.gate.clone(),
567 checkouts: registry
568 .checkouts
569 .iter()
570 .filter(|(_, checkout)| checkout.identity == *key)
571 .map(|(alias, checkout)| RegisteredCheckout {
572 alias: alias.clone(),
573 path: checkout.path.clone(),
574 audit: None,
575 })
576 .collect(),
577 required_checks: None,
578 })
579 .collect()
580}
581
582/// What one identity's host requires on the base its first checkout tracks.
583fn required_checks(key: &str, checkout: &Path, providers: &Providers<'_>) -> RequiredChecksAnswer {
584 let Some(slug) = crate::gh::slug(key) else {
585 return RequiredChecksAnswer::NotHosted;
586 };
587 let asked = git::default_branch(checkout, "origin").and_then(|base| {
588 providers
589 .hosting
590 .for_repo(&slug)?
591 .required_checks_on(&base)
592 .map(|checks| (base, checks))
593 });
594 match asked {
595 Ok((base, checks)) => RequiredChecksAnswer::Answered { base, checks },
596 Err(error) => RequiredChecksAnswer::Unreadable {
597 reason: error.to_string(),
598 },
599 }
600}
601
602/// What one repository argument resolves to.
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub struct ResolvedRepository {
605 /// The identity key.
606 pub identity: String,
607 /// The alias of the checkout the argument selected.
608 pub alias: String,
609 /// The origin the identity was derived from.
610 pub origin: String,
611 /// The command this repository states as its complete bar.
612 pub gate: String,
613 /// The publication checkout: never worked in, only ever fast-forwarded.
614 pub publication_checkout: PathBuf,
615 /// The policy it publishes under, which is what every verb that routes on
616 /// "local or through the host" reads.
617 pub policy: ResolvedPolicy,
618}
619
620/// Resolve a repository argument — an identity key, a registered alias, an origin
621/// URL, or a path — to the identity it selects.
622///
623/// The library form of `onevcs resolve`. It asks the repository side through the
624/// seam, so a supplied [`crate::Vcs`] answers it as readily as this build's git.
625pub fn resolve_repository(providers: &Providers<'_>, repo: &str) -> Result<ResolvedRepository> {
626 let registry = store::load()?;
627 let resolution = store::resolve(®istry, repo)?;
628 // Through the trait, which is the seam a second implementation replaces.
629 let identity = providers.vcs.resolve_identity(repo)?;
630 debug_assert_eq!(identity, resolution.identity);
631 let (file, source) = policy::load(®istry)?;
632 let resolved = policy::resolve_for(&file, &source, &resolution);
633 Ok(ResolvedRepository {
634 identity: resolution.key,
635 alias: resolution.alias,
636 origin: resolution.identity.origin,
637 gate: resolution.identity.gate,
638 publication_checkout: resolution.publication,
639 policy: ResolvedPolicy::of(&resolved)?,
640 })
641}
642
643/// What to publish, for the branch-keyed verb that takes completed work.
644#[derive(Debug, Clone, PartialEq, Eq)]
645pub struct BranchPublishRequest {
646 /// The repository the branch belongs to: an identity key, a registered alias,
647 /// an origin URL, or a path, exactly as `--repo` takes one.
648 pub repo: PathBuf,
649 /// The branch to publish.
650 pub branch: String,
651 /// The subject to land it under, or none to compose one from its commits.
652 pub title: Option<Subject>,
653 /// The body of the change request, where one is opened.
654 pub body: Option<String>,
655 /// A policy to narrow to; it may only ask for more review than the rules do.
656 pub policy: Option<MergePolicy>,
657 /// Which private repositories the public boundary check derives its terms from.
658 pub term_scope: TermScope,
659}
660
661/// Verify and publish a completed branch no session holds.
662///
663/// The library form of `onevcs publish-branch`. It refuses a branch carrying an
664/// unattested incomplete-step marker, naming [`recover`] — the two verbs are one
665/// path, and provenance is the whole of what separates them.
666pub fn publish_branch(
667 providers: &Providers<'_>,
668 request: &BranchPublishRequest,
669) -> Result<PublishOutcome> {
670 let registry = store::load()?;
671 let token = crate::status::keyed::Verb::PublishBranch.stream(&request.branch);
672 let mut stream = branch_keyed(®istry, &request.repo, &token)?;
673 crate::publish_branch::run(
674 ®istry,
675 &request.repo,
676 &request.branch,
677 request.title.clone(),
678 request.body.clone(),
679 request.policy,
680 &request.term_scope,
681 providers.hosting,
682 &mut stream,
683 )
684}
685
686/// The stream a branch-keyed verb records under, labelled with the identity it is
687/// publishing for.
688///
689/// The token is one per branch *name*, so every repository that publishes that name
690/// writes into the same file; the label is what keeps each event that repository's.
691/// A repository this registry cannot resolve is left unlabelled here — the verb refuses
692/// it itself, with the reason, before it records anything that proves something.
693fn branch_keyed(registry: &crate::registry::Registry, repo: &Path, token: &str) -> Result<Stream> {
694 let mut stream = Stream::open(token)?;
695 if let Ok(resolution) = store::resolve_path(registry, repo) {
696 stream.label("identity", &resolution.key);
697 }
698 Ok(stream)
699}
700
701/// What to recover, for the branch-keyed verb that takes work a step left behind.
702#[derive(Debug, Clone, PartialEq, Eq)]
703pub struct RecoverRequest {
704 /// The repository the branch belongs to, as `--repo` takes one.
705 pub repo: PathBuf,
706 /// The branch to recover.
707 pub branch: String,
708 /// The subject to land it under, or none to compose one from its commits.
709 pub title: Option<Subject>,
710 /// The body of the change request, where one is opened.
711 pub body: Option<String>,
712 /// Which private repositories the public boundary check derives its terms from.
713 pub term_scope: TermScope,
714}
715
716/// Verify and publish a preserved branch that a step left behind, attesting it.
717///
718/// The library form of `onevcs recover`. It requires an unattested incomplete-step
719/// marker and writes the attestation that clears it, so a branch with none is
720/// refused naming [`publish_branch`].
721pub fn recover(providers: &Providers<'_>, request: &RecoverRequest) -> Result<PublishOutcome> {
722 let registry = store::load()?;
723 let token = crate::status::keyed::Verb::Recover.stream(&request.branch);
724 let mut stream = branch_keyed(®istry, &request.repo, &token)?;
725 crate::recover::run(
726 ®istry,
727 &request.repo,
728 &request.branch,
729 request.title.clone(),
730 request.body.clone(),
731 &request.term_scope,
732 providers.hosting,
733 &mut stream,
734 )
735}
736
737/// Which branch to make reachable from an identity's registered checkouts.
738#[derive(Debug, Clone, PartialEq, Eq)]
739pub struct ImportRequest {
740 /// The repository to import into, as `--repo` takes one.
741 pub repo: PathBuf,
742 /// The branch to import.
743 pub branch: String,
744 /// Where to take it from, or none to search the identity's own locations.
745 pub from: Option<String>,
746 /// The name to write it under, or none to keep its own.
747 pub under: Option<String>,
748}
749
750/// Make one branch reachable from an identity's registered checkouts.
751///
752/// The library form of `onevcs import`. It writes refs and nothing else: a name the
753/// destination has checked out is refused rather than written, and a
754/// non-fast-forward is refused naming the commits that would go.
755pub fn import_branch(request: &ImportRequest) -> Result<Imported> {
756 let registry = store::load()?;
757 crate::import::run(
758 ®istry,
759 &request.repo,
760 &request.branch,
761 request.from.as_deref(),
762 request.under.as_deref(),
763 )
764}
765
766/// What the train does with the base once it has advanced it.
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
768pub enum BasePush {
769 /// Push it, which is what the repository's own `pre-push` hook rules on —
770 /// `onevcs integrate --push`.
771 Push,
772 /// Leave it in the checkout, unpushed and unverified.
773 Keep,
774}
775
776/// Which branches to merge into their base, in order.
777#[derive(Debug, Clone, PartialEq, Eq)]
778pub struct IntegrateRequest {
779 /// The branches to take, in the order they are to be taken.
780 pub branches: Vec<String>,
781 /// What becomes of the base the train advanced.
782 pub push: BasePush,
783 /// Which private repositories the public boundary check derives its terms from.
784 pub term_scope: TermScope,
785}
786
787/// Merge finished branches into their base, in order.
788///
789/// The library form of `onevcs integrate`, and it answers for **the repository the
790/// current directory is in**, exactly as the command does: the train reads its
791/// candidates out of one publication checkout, and it is refused for an identity
792/// whose rules do not publish `local-direct`.
793pub fn integrate(request: &IntegrateRequest) -> Result<Integration> {
794 let registry = store::load()?;
795 let resolution = store::resolve_here(®istry)?;
796 let token = format!("integrate-{}", policy::branch_slug(&resolution.alias));
797 let mut stream = Stream::open(&token)?;
798 crate::integrate::run(
799 &resolution,
800 &request.branches,
801 request.push == BasePush::Push,
802 &request.term_scope,
803 &mut stream,
804 )
805}
806
807/// What one fast-forward of a publication checkout did.
808#[derive(Debug, Clone, PartialEq, Eq)]
809pub struct Synced {
810 /// The identity whose checkout was synced.
811 pub identity: String,
812 /// The branch it is on, which is the only branch a sync ever moves.
813 pub branch: String,
814 /// The checkout itself.
815 pub checkout: PathBuf,
816 /// The commit it was on before.
817 pub before: String,
818 /// The commit it is on now.
819 pub after: String,
820}
821
822impl Synced {
823 /// Whether the fast-forward moved the checkout, or found it already level.
824 pub fn moved(&self) -> bool {
825 self.before != self.after
826 }
827}
828
829/// Fast-forward a publication checkout to its origin.
830///
831/// The library form of `onevcs sync`, and like the train it answers for the
832/// repository the current directory is in. `branch` is the branch to sync, and
833/// `None` is the origin's default one; a checkout that does not have that branch
834/// checked out is refused, because a sync only ever fast-forwards the branch a
835/// checkout is already on.
836pub fn sync(branch: Option<&str>) -> Result<Synced> {
837 let registry = store::load()?;
838 let resolution = store::resolve_here(®istry)?;
839 let checkout = &resolution.publication;
840 // The name goes on to spell a ref, so an unusable one is refused here rather
841 // than by whichever git command met it first.
842 let branch = match branch {
843 Some(branch) => Ref::try_from(branch.to_owned()).map_err(|reason| Error::Invalid {
844 reason: format!("{reason}: it is not a valid branch name"),
845 })?,
846 None => Ref::from_git(git::default_branch(checkout, "origin")?),
847 };
848 if git::current_branch(checkout)? != *branch {
849 return Err(Error::Invalid {
850 reason: format!(
851 "{} does not have {branch:?} checked out; sync only ever fast-forwards the \
852 branch a checkout is already on",
853 checkout.display()
854 ),
855 });
856 }
857 let before = git::head_sha(checkout)?;
858 git::fetch(checkout, "origin")?;
859 git::merge_ff_only(checkout, &format!("origin/{branch}"))?;
860 let after = git::head_sha(checkout)?;
861 Ok(Synced {
862 identity: resolution.key,
863 branch: branch.to_string(),
864 checkout: checkout.clone(),
865 before,
866 after,
867 })
868}
869
870/// Whether a sweep performs what it decided, which is what `--dry-run` asks.
871///
872/// Named rather than a flag, because the difference between the two is whether
873/// directories are removed, and a call site that spells it `true` says nothing about
874/// which way round that is.
875#[derive(Debug, Clone, Copy, PartialEq, Eq)]
876pub enum Sweeping {
877 /// Decide everything, remove nothing.
878 Rehearse,
879 /// Decide, and reclaim what was decided.
880 Reclaim,
881}
882
883/// Reclaim the publication workspaces this host has finished with.
884///
885/// The library form of `onevcs sweep`. `min_age` is how long evidence outlives the
886/// failure that produced it. Every outcome it reports is a decision — a directory
887/// somebody else owns is an expected outcome of a shared state root rather than a
888/// failure — so an `Err` here means the sweep could not run at all.
889pub fn sweep(sweeping: Sweeping, min_age: Duration) -> Result<SweepReport> {
890 crate::sweep::run(sweeping == Sweeping::Rehearse, min_age)
891}
892
893/// Read one stored artifact.
894///
895/// The library form of `onevcs artifact cat`. The id is checked before it names a
896/// file, so an id that is not one is refused rather than reaching the state root.
897pub fn read_artifact(id: &crate::event::ArtifactId) -> Result<String> {
898 stream::read_artifact(&id.0)
899}
900
901/// Everything this host knows about one piece of work, as `onevcs status` answers it.
902///
903/// Its two renderings are its surface: [`render`](Self::render) is the report a
904/// person reads, and its serialization is the versioned document `--json` prints,
905/// byte for byte. The sections behind them are deliberately **not** public — they
906/// are a document versioned by `status`'s own report version and held to checked-in
907/// goldens, so a consumer reads the JSON it already reads rather than a dozen Rust
908/// types this crate would then owe semver on.
909#[derive(Debug, Clone, Serialize)]
910#[serde(transparent)]
911pub struct StatusReport(status::Report);
912
913impl StatusReport {
914 /// The report as a human reads it, which is what `onevcs status` prints.
915 pub fn render(&self) -> String {
916 self.0.render()
917 }
918}
919
920/// Report everything this host knows about one piece of work.
921///
922/// The library form of `onevcs status`. `reference` is the four-spelling reference
923/// `onevcs status` takes: a change request's URL, a session token, a branch name, or
924/// a commit.
925///
926/// The host is reached through the seam like every other operation that touches one,
927/// and a host that could not be reached leaves a section of the report unavailable
928/// rather than failing — which is the whole reason this answers at all where `gh pr
929/// checks` would not. Whether one piece of work *landed* is
930/// [`crate::landing_status`]: that is the decision on its own, over every repository
931/// and with no host asked.
932pub fn work_status(providers: &Providers<'_>, reference: &str) -> Result<StatusReport> {
933 let registry = store::load()?;
934 Ok(StatusReport(status::run(
935 ®istry,
936 reference,
937 providers.hosting,
938 )?))
939}
940
941/// The rule a repository matched, and what it matched on.
942#[derive(Debug, Clone, PartialEq, Eq)]
943pub struct MatchedRule {
944 /// Its one-based position in the rules file, which is the order that decided it.
945 pub index: usize,
946 /// What it matches on.
947 pub criteria: RuleMatch,
948}
949
950/// How one repository resolves against this host's rules.
951#[derive(Debug, Clone, PartialEq, Eq)]
952pub struct RulesCheck {
953 /// The identity key the argument selected.
954 pub identity: String,
955 /// The publication checkout a `path:` rule is matched against.
956 pub checkout: PathBuf,
957 /// Where the rules came from: the file that was read, or the built-in default.
958 pub rules: String,
959 /// The rule that decided it, where one did; none means the default applies.
960 pub matched: Option<MatchedRule>,
961 /// What it resolved to.
962 pub policy: ResolvedPolicy,
963 /// The git trailer key provenance is written and read under.
964 pub trailer_prefix: String,
965 /// What set that prefix. It is not part of the matched policy: one vocabulary
966 /// reads and writes every repository's provenance, so it is resolved once, from
967 /// the file or the default.
968 pub trailer_prefix_source: TrailerPrefixSource,
969}
970
971/// What decided the provenance trailer prefix.
972#[derive(Debug, Clone, Copy, PartialEq, Eq)]
973pub enum TrailerPrefixSource {
974 /// This host's rules file names it.
975 RulesFile,
976 /// Nothing names one, so the built-in default applies.
977 BuiltIn,
978}
979
980/// Explain how one repository resolves against this host's rules.
981///
982/// The library form of `onevcs rules check`, and the question asked *before* a
983/// publication rather than deduced from one afterwards.
984pub fn rules_check(repo: &str) -> Result<RulesCheck> {
985 let registry = store::load()?;
986 let resolution = store::resolve(®istry, repo)?;
987 let (file, source) = policy::load(®istry)?;
988 let normalized = store::normalize(&resolution.identity.origin);
989 let resolved = policy::resolve(&file, &source, &normalized, &resolution.publication);
990 Ok(RulesCheck {
991 identity: resolution.key,
992 checkout: resolution.publication,
993 rules: resolved.source.clone(),
994 matched: resolved.matched.as_ref().map(|matched| MatchedRule {
995 index: matched.index,
996 criteria: matched.criteria.clone(),
997 }),
998 policy: ResolvedPolicy::of(&resolved)?,
999 trailer_prefix: provenance::from_rules(&file).prefix().to_string(),
1000 trailer_prefix_source: match file.trailer_prefix.is_some() {
1001 true => TrailerPrefixSource::RulesFile,
1002 false => TrailerPrefixSource::BuiltIn,
1003 },
1004 })
1005}