Skip to main content

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(&registry, 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(&registry, &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(&registry, &request.repo, &token)?;
220    stream.operation(operation, operation_repository(&expected))?;
221    crate::publish_branch::run(
222        &registry,
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(&registry, &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(&registry, &request.repo, &token)?;
249    stream.operation(operation, operation_repository(&expected))?;
250    crate::recover::run(
251        &registry,
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(&registry)?;
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(&registry)?;
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(&registry);
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(&registry)?;
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(&registry, 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(&registry)?;
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(&registry, &request.repo, &token)?;
673    crate::publish_branch::run(
674        &registry,
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(&registry, &request.repo, &token)?;
725    crate::recover::run(
726        &registry,
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        &registry,
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(&registry)?;
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(&registry)?;
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        &registry,
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(&registry, repo)?;
987    let (file, source) = policy::load(&registry)?;
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}