Skip to main content

onevcs/
publish.rs

1//! Publication: verifying a branch and landing it under its repository's policy.
2//!
3//! One path, two endings. `local-direct` builds the branch-to-base squash in a
4//! **detached scratch worktree** and pushes that exact tree, so the registered
5//! publication checkout is never worked in and only ever fast-forwarded. Every
6//! `change-*` policy pushes the branch, opens (or adopts) a change request, and
7//! then differs only in what it asks the host to do with it.
8//!
9//! Everything automated goes through the FIFO merge queue keyed by the publication
10//! checkout's git common directory, which is the one thing every worktree and alias
11//! of an identity shares.
12
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17
18use crate::error::{Error, Result};
19use crate::event::{EventKind, Phase};
20use url::Url;
21
22use std::collections::BTreeSet;
23
24use crate::host::{
25    ChangeRequest, ChangeSpec, Check, CheckState, Hosting, MergeOutcome, Mergeability, RemoteHost,
26    Sha,
27};
28use crate::releases::TargetName;
29use crate::rules::{MergePolicy, Policy};
30use crate::session::{Lifecycle, Provenance, SessionToken};
31use crate::store::Resolution;
32use crate::stream::{self, Stream};
33use crate::workspace::{object, Ref};
34use crate::{
35    gh, git, guidance, home, ids, lock, merge_path, policy, provenance, queue, store, vcs,
36    workspace,
37};
38
39/// How many times a base that moved under a publication is re-merged before the
40/// attempt is abandoned. Bounded, because a base advancing on every retry is a
41/// conflict that resolving again will not settle.
42pub const SYNC_ATTEMPTS: usize = 3;
43
44/// What one publication was asked for beyond the session it publishes.
45///
46/// The options `onevcs publish` takes, and nothing else: everything else about a
47/// publication comes from the session and from the repository's rules.
48#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49pub struct PublishRequest {
50    /// A per-run policy. It may narrow the repository's but never widen it.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub policy: Option<MergePolicy>,
53    /// An explicit title, which replaces the subject synthesized from the branch.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub title: Option<Subject>,
56    /// The body the change request is opened with, verbatim.
57    ///
58    /// Absent opens it with no body at all. Nothing here composes one: every
59    /// change request this crate opened used to carry the same three lines — the
60    /// branch's own subject echoed back under `## What`, and `Published by
61    /// onevcs.` under `## Why` — which told a reviewer nothing the title had not.
62    /// Unvalidated, because a body is prose and a host places no shape on it; a
63    /// title is a [`Subject`] for the opposite reason.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub body: Option<String>,
66    /// Open the change request as a **draft**, and why it is not ready.
67    ///
68    /// Absent is a publication giving no reason, which takes the draft lifecycle: its
69    /// change request opens as a draft while the required checks run and is lifted or
70    /// kept for review on their verdict — or opens ready, where the rules file says
71    /// `drafts: {disabled: true}`. Present is one of two things, and the reason
72    /// says which: a change whose work is as far along as it can go while something
73    /// outside this repository has not happened yet, or a change the session that
74    /// opened it is still making. The reason travels with it, because a draft
75    /// nobody can read the reason for is a change request nobody knows how to
76    /// finish.
77    ///
78    /// A draft is unmergeable in that state, and this crate keeps it so: nothing
79    /// merges it, arms the host's own merge on it, or advances a base from it while
80    /// the draft stands, under any `drafts:` setting. A later publication of the same
81    /// branch carrying **no** `DraftReason` adopts it as awaiting its checks and lifts
82    /// it only on their verdict, or keeps it for review; `onevcs change ready` lifts it
83    /// at once, for a session that wants its draft lifted without landing anything.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub draft: Option<DraftReason>,
86}
87
88/// Why a change request is a draft, in the shape a machine reads it.
89///
90/// A draft is a change request that must not land yet, and there are two reasons one
91/// is held. A change that has gone as far as it can and stopped short of the one step
92/// that would make something temporary permanent — a dependency pinned to a branch
93/// rather than to a release — is [`AwaitingRelease`](DraftReason::AwaitingRelease),
94/// and what it is waiting for is the whole of the reason: which repository, which of
95/// that repository's release targets, and the reference the change is pinned to in
96/// the meantime. A change whose work is *still being made* — evidence gathered, a
97/// description started — is [`Held`](DraftReason::Held) by the session that opened
98/// it, and the one line a person reads is the whole of that reason. The `kind` tag
99/// is what a consumer routes on; `because` is the same fact as a sentence, for
100/// whoever reads it rather than routes on it.
101///
102/// It is recorded on the session's own event stream — the publication record — and
103/// nowhere else. **Nothing is written into the change request's body**, under a
104/// marker heading or anywhere: a body is prose a reviewer reads and a drafting
105/// caller may rewrite, and deciding a control action from it would turn an editorial
106/// act into one. The cost of that is carried openly: somebody looking at the draft
107/// change request without access to this host sees that it **is** a draft and not
108/// why.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(tag = "kind", rename_all = "kebab-case")]
111pub enum DraftReason {
112    /// A change pinned to a dependency's branch until that dependency releases —
113    /// what a fast-adoption publication carries.
114    AwaitingRelease {
115        /// The repository identity whose release is awaited, as the registry keys one.
116        ///
117        /// A `String` rather than a newtype because an identity key is one everywhere
118        /// this crate spells one — `Identity::origin`, `Recoverable::identity`, the
119        /// registry document's own map key — so a narrower type here would disagree
120        /// with every type it is compared against. What must not be representable is
121        /// a value that renders as something *other* than itself in a refusal or an
122        /// event payload, and `checked` refuses exactly that at every boundary one
123        /// arrives at.
124        // llmlint: ignore[invalid_states_unrepresentable] the doc above: the key's own type, checked.
125        awaiting: String,
126        /// Which release target of it is awaited.
127        target: TargetName,
128        /// The reference this change is pinned to until that release arrives — the
129        /// branch of the awaited repository the pin names.
130        ///
131        /// A `String` for the reason `awaiting` above is, and one more of its own:
132        /// this is the *awaited* repository's branch name rather than one this
133        /// repository's git could be asked about, so the parser that would decide it
134        /// is not this host's. `checked` refuses the values that would render as
135        /// something else.
136        // llmlint: ignore[invalid_states_unrepresentable] the doc above: another host's name, checked.
137        reference: String,
138        /// One line a person reads, saying why the change is not ready.
139        ///
140        /// Prose, so there is nothing for a type to narrow. The one shape that matters
141        /// is where it lands — a single rendered line — and `checked` is what holds it
142        /// to that.
143        // llmlint: ignore[invalid_states_unrepresentable] the doc above: prose, checked where it lands.
144        because: String,
145    },
146    /// A change request the session that opened it is holding open as a draft while
147    /// its work is still being made: evidence gathered, description started. Lifted
148    /// by `onevcs change ready`, or taken by a later publication of the same session
149    /// carrying no reason into the draft lifecycle — which keeps it a draft when green
150    /// on a `change-open` identity whose approvals are required.
151    Held {
152        /// One line a person reads, saying why the session is holding it. Prose,
153        /// held to the one line it is printed on by `checked`, as the line above is.
154        // llmlint: ignore[invalid_states_unrepresentable] the doc above: prose, checked where it lands.
155        because: String,
156    },
157}
158
159impl DraftReason {
160    /// The rule a publication holds a reason to, or the refusal that this is not one.
161    ///
162    /// Every field here is printed: into a refusal, into an event payload a consumer
163    /// reads back, and — through `--draft` — at a host. So the check is the one that
164    /// decides whether a value renders as itself: nothing empty, and nothing carrying
165    /// a control character, which is what turns one line of a record into two.
166    ///
167    /// Public for the reason [`MergePolicy::narrow`] and [`FailureKind::of`] are: it
168    /// belongs to publication rather than to any one implementation of it, so a
169    /// supplied [`Vcs`](crate::Vcs) applies *this* rule at its own boundary rather
170    /// than a restatement of it that could accept what the real one refuses. It is a
171    /// method rather than a conversion because the contract fixes the fields as
172    /// public and settable, so there is no constructor for a check to live in.
173    ///
174    /// # Errors
175    ///
176    /// [`Error::Invalid`] naming the field that would not render as itself.
177    pub fn checked(&self) -> Result<()> {
178        let printed: Vec<(&str, &String)> = match self {
179            DraftReason::AwaitingRelease {
180                awaiting,
181                reference,
182                because,
183                ..
184            } => vec![
185                ("the repository whose release is awaited", awaiting),
186                ("the reference the change is pinned to", reference),
187                ("the reason the change is not ready", because),
188            ],
189            DraftReason::Held { because } => {
190                vec![("the reason the session is holding the change", because)]
191            }
192        };
193        for (what, value) in printed {
194            if value.is_empty() {
195                return Err(crate::error::invalid(format!(
196                    "a draft change request must say {what}, and this one names none: a draft \
197                     whose reason cannot be read is a change request nobody knows how to finish"
198                )));
199            }
200            if value.chars().any(char::is_control) {
201                return Err(crate::error::invalid(format!(
202                    "{what} is {value:?}, which carries a control character: every field of a \
203                     draft's reason is printed on one line, in a refusal and in the publication \
204                     record, and a value that renders as something other than itself is not one"
205                )));
206            }
207        }
208        Ok(())
209    }
210
211    /// The one line a person reads, whichever kind of reason this is.
212    pub(crate) fn because(&self) -> &str {
213        match self {
214            DraftReason::AwaitingRelease { because, .. } | DraftReason::Held { because } => because,
215        }
216    }
217
218    /// The word the `kind` tag travels as: `awaiting-release` or `held`.
219    ///
220    /// Read off the serialized form rather than restated, so a rendering cannot
221    /// spell a kind the record does not.
222    pub(crate) fn kind(&self) -> String {
223        self.fields()["kind"]
224            .as_str()
225            .expect("a tagged enum serializes its kind as a string")
226            .to_owned()
227    }
228
229    /// The reason as the fields an event payload and a refusal both name it by:
230    /// the `kind` tag and every field of that kind, which is exactly the serialized
231    /// form.
232    fn fields(&self) -> serde_json::Value {
233        serde_json::to_value(self).expect("a draft reason serializes: strings and a name")
234    }
235
236    /// The reason as a refusal spells what was asked for — "a draft awaiting … " or
237    /// "a draft held by the session that opened it" — so the two refusals a draft
238    /// can meet name the same thing the same way whichever kind of reason it was.
239    pub(crate) fn asked_for(&self) -> String {
240        match self {
241            DraftReason::AwaitingRelease {
242                awaiting, target, ..
243            } => format!("a draft awaiting {awaiting} {target}"),
244            DraftReason::Held { because } => {
245                format!("a draft held by the session that opened it ({because})")
246            }
247        }
248    }
249}
250
251/// A title that can be the subject of the commit a publication lands.
252///
253/// The check is in the conversion, for the reason every other validated name in
254/// this crate has one there: a publication commits the session's work and merges
255/// its base before it composes a message, so a title rejected where the message is
256/// composed is rejected *after* those. A [`PublishRequest`] carrying an unusable
257/// title is therefore unrepresentable rather than representable-and-refused-later
258/// — and a caller embedding this crate meets the refusal where it built the
259/// request, not after a commit it cannot undo.
260#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(try_from = "String", into = "String")]
262pub struct Subject(String);
263
264/// The title itself, quoted — never the wrapper. A refusal that names one writes
265/// it with `{:?}`, and a derived `Debug` would spell it `Subject("feat: …")` at
266/// the operator rather than the title they typed.
267impl std::fmt::Debug for Subject {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        std::fmt::Debug::fmt(&self.0, f)
270    }
271}
272
273impl TryFrom<String> for Subject {
274    type Error = String;
275
276    fn try_from(value: String) -> std::result::Result<Self, String> {
277        provenance::checked_subject(&value).map(Subject)
278    }
279}
280
281/// So a title arriving on a command line is checked by the same conversion a title
282/// arriving through the library is.
283impl std::str::FromStr for Subject {
284    type Err = String;
285
286    fn from_str(value: &str) -> std::result::Result<Self, String> {
287        Subject::try_from(value.to_owned())
288    }
289}
290
291impl From<Subject> for String {
292    fn from(subject: Subject) -> Self {
293        subject.0
294    }
295}
296
297impl std::ops::Deref for Subject {
298    type Target = str;
299
300    fn deref(&self) -> &str {
301        &self.0
302    }
303}
304
305impl std::fmt::Display for Subject {
306    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        f.write_str(&self.0)
308    }
309}
310
311/// What one publication did.
312///
313/// The value `onevcs publish` renders and a caller embedding this crate branches
314/// on. Every ending is a case of [`PublishOutcome`] rather than a sentence to
315/// match on, because the sentence is what a consumer read wrongly.
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct Publication {
318    /// The session that was published.
319    pub session: SessionToken,
320    /// The branch that carried the change.
321    // llmlint: ignore[invalid_states_unrepresentable] this is the session's own branch,
322    // and the contract declares that field verbatim as `pub branch: String` on `Session`;
323    // spelling it as a validated ref here would disagree with the type the contract fixes
324    // and add a public item it does not name. The validated `workspace::Ref` is what the
325    // publication path carries internally, and every value written here is one — it is
326    // `record.branch`, which git's own parser accepted before the session was opened.
327    pub branch: String,
328    /// The policy it was published under, after the repository's rules and any
329    /// per-run narrowing have both had their say.
330    pub policy: MergePolicy,
331    /// What happened.
332    pub outcome: PublishOutcome,
333}
334
335/// How a publication ended.
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "kebab-case")]
338pub enum PublishOutcome {
339    /// The change reached its base, at this commit.
340    Merged(Sha),
341    /// A change request is open, which the policy asked for.
342    ChangeOpen(Url),
343    /// A change request is open **as a draft**, carrying the reason it is not ready.
344    ///
345    /// Its own case rather than a shade of [`ChangeOpen`](PublishOutcome::ChangeOpen),
346    /// deliberately: the two differ in the one thing a caller acts on — whether this
347    /// change can land — and folding them together would make every exhaustive match
348    /// on this enum go on compiling while meaning something else.
349    ChangeDraft(Url),
350    /// A change request whose required checks came back green, kept **as a draft**
351    /// for its own user's review: what the draft lifecycle ends in under `change-open`
352    /// with approvals required.
353    ///
354    /// Its own case rather than a shade of [`ChangeDraft`](PublishOutcome::ChangeDraft)
355    /// or [`ChangeOpen`](PublishOutcome::ChangeOpen), for the reason `ChangeDraft` is
356    /// one: a consumer acts on the difference. This work is done — green, and waiting
357    /// for the person who dispatched it — while a `ChangeDraft` is held back by a
358    /// reason, and a `ChangeOpen` is already asking the team for review.
359    ChangeReviewDraft(Url),
360    /// The host queued the merge and will land it once its checks pass.
361    Queued(Url),
362    /// The branch had nothing the base did not already carry.
363    NothingToPublish,
364    /// The change did not land, and this is why.
365    ///
366    /// A case rather than an `Err`, in exactly the places the CLI reports a
367    /// non-zero exit rather than a refusal — so the two surfaces cannot disagree
368    /// about which failures are which.
369    Failed {
370        /// Which failure it is, which is what a caller branches on.
371        kind: FailureKind,
372        /// The failure as it reads, which is what the CLI writes to stderr.
373        reason: String,
374        /// What became of the branch. The work is the only record of itself, so
375        /// whether it outlived the session is the first thing a caller asks.
376        #[serde(default, skip_serializing_if = "Option::is_none")]
377        retained: Option<Retention>,
378    },
379}
380
381impl PublishOutcome {
382    /// How the publication is reported to a human.
383    #[must_use]
384    pub fn describe(&self) -> String {
385        match self {
386            PublishOutcome::Merged(sha) => format!("merged at {}", sha.0),
387            PublishOutcome::ChangeOpen(url) => format!("change request open at {url}"),
388            PublishOutcome::ChangeDraft(url) => {
389                format!(
390                    "change request open as a draft at {url}, which cannot land while it is one"
391                )
392            }
393            PublishOutcome::ChangeReviewDraft(url) => format!(
394                "change request's checks are green at {url}, and it is kept as a draft for its \
395                 user's review"
396            ),
397            PublishOutcome::Queued(url) => format!("merge queued for {url}"),
398            PublishOutcome::NothingToPublish => {
399                "nothing to publish: the base already carries this branch's content".to_owned()
400            }
401            PublishOutcome::Failed { reason, .. } => reason.clone(),
402        }
403    }
404}
405
406/// Which failure ended a publication, and therefore which exit code says so.
407#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(rename_all = "kebab-case")]
409pub enum FailureKind {
410    /// A publication was refused by something that judged it, where no narrower
411    /// kind says which — the repository's own `commit-msg` hook turning down the
412    /// subject, or a host that took a merge and then reported it unperformed. What
413    /// the *host's checks* reported is [`ChecksFailed`](FailureKind::ChecksFailed)
414    /// or [`ChecksUnsettled`](FailureKind::ChecksUnsettled), what its `pre-push`
415    /// hook said is [`PushRejected`](FailureKind::PushRejected), and a push that
416    /// landed with the merge path unreadable behind it is
417    /// [`PushedUnverified`](FailureKind::PushedUnverified).
418    ///
419    /// Named for the tier this crate used to run itself and kept under that name:
420    /// the contract fixes this vocabulary across the three libraries that route on
421    /// it, so a variant is not renamed because the tier behind it went away.
422    // llmlint: ignore[names_match_behavior] the approved contract fixes the spelling.
423    Gate,
424    /// Input was rejected at a trust boundary.
425    Invalid,
426    /// The base moved under the publication and the bounded resolve-and-requeue
427    /// did not converge.
428    SyncConflict,
429    /// The request was well-formed and the seam behind it has no implementation.
430    NotImplemented,
431    /// A required check the host reports concluded red. The reason names it.
432    ChecksFailed,
433    /// The bound on watching the host elapsed with the change still outstanding.
434    /// The reason names what was: the required checks that had not settled, that
435    /// the host declared none, or that it never performed the merge it took.
436    // The vocabulary is fixed across the three libraries that route on it, and it
437    // gives the bound one kind whichever of its endings this was; `reason` says which.
438    // llmlint: ignore[names_match_behavior] one fixed kind for the bound's three endings.
439    ChecksUnsettled,
440    /// The publishing push was refused by the merge path. The reason carries git's
441    /// own per-ref refusal.
442    PushRejected,
443    /// The publishing push **reached the remote**, and the merge path could not
444    /// then be read. The reason names both: where the push landed, and what stopped
445    /// the read.
446    ///
447    /// Widening the vocabulary rather than adding a clause to the three above is
448    /// the decision recorded on [`Error::PushedUnverified`], and the reasoning is
449    /// there: a router branches on the kind, so only a kind can stop work that is
450    /// on the remote reading as work that never landed.
451    PushedUnverified,
452    /// The publishing push was refused by the merge path because this host is
453    /// missing a tool or a credential a hook needs, which the hook said on the line
454    /// [`HOST_PREREQUISITE_MARKER`] begins. The reason carries what it said is
455    /// missing and how to install it, and the same evidence pointers
456    /// [`PushRejected`](FailureKind::PushRejected) does.
457    ///
458    /// A kind rather than a clause on `PushRejected`, for the reason
459    /// [`PushedUnverified`](FailureKind::PushedUnverified) is one: a router branches
460    /// on the kind, and this is a refusal no change to the work can clear.
461    HostPrerequisite,
462    /// The caller cancelled the publication while it waited — on the host's checks,
463    /// on a merge the host was armed to perform, or on the identity's merge queue —
464    /// through [`publish_with_cancellation`](crate::publish_with_cancellation). The
465    /// reason names what it was waiting on.
466    ///
467    /// Nothing is undone: the branch stays on its remote, any change request stays
468    /// open, and the session is left so that publishing it again continues the same
469    /// branch. A kind of its own, with an exit code of its own, because it is neither a
470    /// verdict on the work nor a merge path that could not be read, and a router that
471    /// read it as either would retry or abandon work nobody judged.
472    Cancelled,
473}
474
475impl FailureKind {
476    /// The exit code the contract fixes for this failure.
477    ///
478    /// The one statement of it: `onevcs publish` reports this, and a caller
479    /// embedding the crate reads the same mapping rather than rediscovering it.
480    #[must_use]
481    pub fn exit_code(self) -> u8 {
482        match self {
483            // The contract fixes `1` for every verification failure and the codes are
484            // the published surface, so which one it was travels on the kind instead.
485            // Widening the set is an amendment, reported rather than taken here.
486            // llmlint: ignore-block[cli_output_contract] the approved contract fixes this code.
487            FailureKind::Gate
488            | FailureKind::ChecksFailed
489            | FailureKind::ChecksUnsettled
490            | FailureKind::PushRejected
491            | FailureKind::PushedUnverified
492            | FailureKind::HostPrerequisite => 1,
493            // llmlint: ignore-end[cli_output_contract]
494            FailureKind::Invalid => 2,
495            FailureKind::SyncConflict => 3,
496            FailureKind::NotImplemented => 70,
497            // `EX_TEMPFAIL`: nothing failed, and the same publication can be asked for
498            // again. Distinct from every other kind's, so a caller reading `$?` alone
499            // cannot take a cancellation for a verdict.
500            FailureKind::Cancelled => 75,
501        }
502    }
503
504    /// Which failure an error is.
505    ///
506    /// Public because a supplied implementation of [`Vcs`](crate::Vcs) has to
507    /// report the same kind for the same failure — a publication that started and
508    /// did not land is an outcome on every backend, and which one it is cannot be
509    /// left to a restatement of this match.
510    #[must_use]
511    pub fn of(error: &Error) -> Self {
512        match error {
513            Error::GateFailed { .. } => FailureKind::Gate,
514            Error::SyncConflict { .. } => FailureKind::SyncConflict,
515            Error::NotImplemented { .. } => FailureKind::NotImplemented,
516            Error::ChecksFailed { .. } => FailureKind::ChecksFailed,
517            Error::ChecksUnsettled { .. } => FailureKind::ChecksUnsettled,
518            Error::PushRejected { .. } => FailureKind::PushRejected,
519            Error::PushedUnverified { .. } => FailureKind::PushedUnverified,
520            Error::HostPrerequisite { .. } => FailureKind::HostPrerequisite,
521            Error::Cancelled { .. } => FailureKind::Cancelled,
522            _ => FailureKind::Invalid,
523        }
524    }
525}
526
527/// What became of a branch whose publication did not land.
528///
529/// The session's clone is disposable, so a branch that is not handed back goes
530/// with it. Which of these happened decides whether there is anything left to
531/// recover, and the CLI says so on stderr for the same reason.
532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
533#[serde(rename_all = "kebab-case")]
534pub enum Retention {
535    /// The branch was handed back to this checkout, which now carries the work.
536    HandedBack(PathBuf),
537    /// This checkout refused the branch, so nothing outside the session carries it.
538    Refused(PathBuf),
539}
540
541/// Whether the caller of a publication has asked it to stop.
542///
543/// Handed to [`publish_with_cancellation`](crate::publish_with_cancellation) and asked,
544/// at least once a second, by every phase of a publication that waits: the watch of the
545/// host's checks, a draft's settle and the one after an early lift, the watch of a
546/// merge the host was armed to perform, and the wait for the identity's merge queue.
547/// A cancelled publication ends as [`FailureKind::Cancelled`] and undoes nothing.
548///
549/// A git command already running — the publishing push and its `pre-push` hook
550/// included — is never interrupted: the answer is asked again once it returns.
551pub trait PublicationCancellation {
552    /// Whether the publication should stop at the next point it is asked.
553    fn is_cancelled(&self) -> bool;
554}
555
556/// The cancellation nobody can trigger, which is every publication [`crate::publish`]
557/// and the command line run.
558pub(crate) struct NeverCancelled;
559
560impl PublicationCancellation for NeverCancelled {
561    fn is_cancelled(&self) -> bool {
562        false
563    }
564}
565
566/// The longest a waiting phase sleeps between two askings of its cancellation, so a
567/// cancellation is observed within it whatever `ONEVCS_CHECKS_POLL_SECONDS` says.
568pub(crate) const CANCELLATION_GRAIN: std::time::Duration = std::time::Duration::from_millis(100);
569
570/// Sleep for `span`, asking `cancellation` at least every [`CANCELLATION_GRAIN`], and
571/// answer `cancelled()` the first time it says so.
572pub(crate) fn pause_unless_cancelled(
573    span: std::time::Duration,
574    cancellation: &dyn PublicationCancellation,
575    cancelled: impl Fn() -> Error,
576) -> Result<()> {
577    let until = std::time::Instant::now() + span;
578    loop {
579        if cancellation.is_cancelled() {
580            return Err(cancelled());
581        }
582        let left = until.saturating_duration_since(std::time::Instant::now());
583        if left.is_zero() {
584            return Ok(());
585        }
586        std::thread::sleep(left.min(CANCELLATION_GRAIN));
587    }
588}
589
590/// The failure a cancelled publication ends with, saying what it was doing and that
591/// nothing was undone.
592pub(crate) fn cancelled_while(doing: &str) -> Error {
593    Error::Cancelled {
594        reason: format!(
595            "the caller cancelled the publication while {doing}. Nothing was undone: the \
596             branch stays where it is, any change request stays open, and publishing the \
597             session again continues it"
598        ),
599    }
600}
601
602/// Publish one session's branch, as the git implementation does it.
603///
604/// The whole of `onevcs publish` behind the interface, which is where it had to
605/// move: it starts from the session record, and a record only this implementation
606/// writes is what made a session opened through the seam unpublishable.
607///
608/// The split between `Err` and [`PublishOutcome::Failed`] is the CLI's own, kept
609/// exactly: a publication that could not *start* — an unknown token, an unreadable
610/// registry, a `--policy` that widens — is a refusal, and one that started and did
611/// not land is an outcome carrying what became of the branch.
612///
613/// `cancellation` is asked by every phase that waits, and a publication it stops ends
614/// as [`FailureKind::Cancelled`] with the record left open, exactly as every other
615/// failure leaves it — so publishing the session again continues the same branch.
616pub fn run_for_session(
617    token: &SessionToken,
618    request: &PublishRequest,
619    hosting: &dyn Hosting,
620    cancellation: &dyn PublicationCancellation,
621) -> Result<Publication> {
622    let record = workspace::load(&token.0)?;
623    // Before anything is read, locked or committed: a session opened over a conflicted
624    // merge is one whose worker has not concluded it yet, and the preserve below would
625    // conclude it for them, markers and all.
626    vcs::refuse_unfinished_merge(
627        &record.worktree,
628        &record.branch,
629        &format!(
630            "publish it again with `{}`",
631            crate::guidance::command(["onevcs", "publish", &record.token])
632        ),
633    )?;
634    let registry = store::load()?;
635    let resolution = store::resolve(&registry, &record.identity)?;
636    let (file, source) = policy::load(&registry)?;
637    let normalized = store::normalize(&resolution.identity.origin);
638    let resolved = policy::resolve(&file, &source, &normalized, &resolution.publication);
639    let effective = effective_policy(&resolved.policy, request.policy)?;
640
641    let mut stream = Stream::open(&record.token)?;
642    stream.label("identity", &record.identity);
643
644    // On the stream this publication is already writing, deliberately: a second
645    // stream over the same file resumes the same sequence number, and the two would
646    // then emit one `seq` twice — which is exactly the gap a consumer reads as loss.
647    if git::is_dirty(&record.worktree)? {
648        vcs::preserve_into(&record, &mut stream, Provenance::Complete)?;
649    }
650    let target = recorded_target(&record, &resolution.publication)?;
651    let context = Context {
652        resolution,
653        policy: resolved.policy.clone(),
654        effective,
655        repo: record.clone.clone(),
656        worktree: record.worktree.clone(),
657        branch: record.branch.clone(),
658        target,
659        // Nothing has rewritten this branch yet; a replay below decides otherwise.
660        push: Push::Forward,
661        run_root: record.run_root.clone(),
662        preserved_into: record.execution_checkout.clone(),
663        title: request.title.clone(),
664        body: request.body.clone(),
665        draft: request.draft.clone(),
666        drafts: resolved.drafts.clone(),
667        trailers: Vec::new(),
668        provenance: provenance::from_rules(&file),
669        hosting,
670        cancellation,
671    };
672    let branch = record.branch.to_string();
673    let outcome = match run_resolving(&context, &mut stream) {
674        Ok((outcome, landed)) => {
675            // A stacked change the root carried was replayed onto the root, and the
676            // record learns that: every later read and publication of this session
677            // then resolves the root directly rather than a stack the replayed
678            // branch no longer shows — the tip it was cut from is out of its history
679            // now, so there is no stack left to record. Recorded rather than
680            // inferred, exactly as the stack itself was: this publication is the one
681            // that moved it.
682            let moved = landed != context.target;
683            // A publication is the end of a session's work, so the record closes
684            // with it — with one exception. A draft the session itself is holding
685            // is a session still being worked in: its worktree is still the
686            // dispatch's, and `session close` is what ends it. That matters because
687            // `workspace::reclaim` keeps a run root only while an *open* record names
688            // it, so a held draft that closed the record would let the next sibling
689            // `session open` reap the worktree the worker is committing in. A draft
690            // held for a release is the other thing: the work is finished, and the
691            // next worker opens a session of its own.
692            //
693            // Written through `workspace::update`, which re-reads the record under its
694            // lock: the copy loaded when this publication began is minutes old by now,
695            // and a session opened over the same branch meanwhile has recorded on it
696            // which session continued the work.
697            let still_working = matches!(outcome, PublishOutcome::ChangeDraft(_))
698                && matches!(context.draft, Some(DraftReason::Held { .. }));
699            if !still_working || moved {
700                workspace::update(&record.token, |stored| {
701                    if moved {
702                        stored.change_base = Some(landed.base().clone());
703                        stored.stack_tip = None;
704                    }
705                    if !still_working {
706                        stored.state = Lifecycle::Closed;
707                    }
708                })?;
709            }
710            outcome
711        }
712        Err(error) => {
713            // The branch is the only record of the work, so it is handed back to the
714            // execution checkout whatever refused it — the alternative is a rejected
715            // tree that exists only in a run root about to be reclaimed.
716            let checkout = record.execution_checkout.clone();
717            let retained = match git::copy_branch(&record.clone, &checkout, &record.branch) {
718                Ok(true) => Retention::HandedBack(checkout),
719                _ => Retention::Refused(checkout),
720            };
721            PublishOutcome::Failed {
722                kind: FailureKind::of(&error),
723                reason: error.to_string(),
724                retained: Some(retained),
725            }
726        }
727    };
728    Ok(Publication {
729        session: token.clone(),
730        branch,
731        policy: effective,
732        outcome,
733    })
734}
735
736/// Everything one publication needs to know about itself.
737pub struct Context<'a> {
738    /// The identity and checkouts the branch belongs to.
739    pub resolution: Resolution,
740    /// The resolved policy, before any per-run narrowing.
741    pub policy: Policy,
742    /// The policy actually being published under.
743    pub effective: MergePolicy,
744    /// The repository the branch lives in — a session clone, or a recovery clone.
745    pub repo: PathBuf,
746    /// The tree this publication is built in.
747    pub worktree: PathBuf,
748    /// The branch carrying the change.
749    pub branch: Ref,
750    /// Where this publication lands, and what it is compared against on the way.
751    pub target: Target,
752    /// How its branch reaches the host, for a caller that rewrote the branch before
753    /// handing it here. A publication that replays during its own run decides this
754    /// for itself.
755    pub push: Push,
756    /// Where preserved merge-path logs are written.
757    pub run_root: PathBuf,
758    /// The checkout that keeps this branch once the run root is gone — a session's
759    /// execution checkout, or the checkout a branch-keyed verb read the branch out
760    /// of.
761    ///
762    /// Written to for one thing only: the landing record. Everything else this
763    /// publication does happens in [`repo`](Context::repo), which is disposable.
764    pub preserved_into: PathBuf,
765    /// An explicit title, which replaces the synthesized subject. Checked where it
766    /// was built, so nothing here can compose a message from one that is not a
767    /// subject.
768    pub title: Option<Subject>,
769    /// The body the change request is opened with, verbatim, or none at all. A
770    /// branch-keyed verb has no caller to take one from and publishes without one.
771    pub body: Option<String>,
772    /// Why this change request is opened as a draft, or none at all — which is both
773    /// an ordinary publication and the thing that *lifts* a draft one.
774    ///
775    /// A branch-keyed verb has no caller to take one from and publishes without one,
776    /// as it does with a body: landing a branch somebody else drafted is exactly the
777    /// call that says the reason no longer holds.
778    pub draft: Option<DraftReason>,
779    /// How the draft lifecycle resolved for this identity: whether a reasonless
780    /// publication opens a draft while its checks run, and whether an early lift
781    /// warns.
782    pub drafts: policy::DraftLifecycle,
783    /// Trailers the publication commit must carry.
784    pub trailers: Vec<String>,
785    /// The provenance trailer keys this host reads and writes, which decide which
786    /// of the branch's commits describe the change and which record the session.
787    pub provenance: provenance::Trailers,
788    /// Where the host that lands a change request comes from. The seam, carried on
789    /// the context rather than reached for at the call site, so every publication —
790    /// a session's and a recovery's — goes through the one a caller supplied.
791    pub hosting: &'a dyn Hosting,
792    /// Whether the caller has asked this publication to stop, asked by every phase
793    /// that waits. A branch-keyed verb has no caller to ask and is never cancelled.
794    pub cancellation: &'a dyn PublicationCancellation,
795}
796
797/// How a publication's branch reaches the host.
798#[derive(Debug, Clone, PartialEq, Eq)]
799pub enum Push {
800    /// Forward or not at all, which is every publication that rewrote nothing.
801    Forward,
802    /// Replacing one commit and no other, because this publication replayed the
803    /// branch's own commits onto the root and what it pushes is therefore no
804    /// descendant of what the host has.
805    Replacing {
806        /// The commit this repository last saw the host's copy of the branch at,
807        /// which is the only one this may replace. Read before the fetch rather than
808        /// after: a host that moved *since* is the thing this exists to catch, and a
809        /// value taken from a later fetch would name that move and allow it.
810        // llmlint: ignore[invalid_states_unrepresentable] `git::tip` answered it.
811        replaced: String,
812    },
813}
814
815/// Where a publication lands, and what it is compared against on the way there.
816///
817/// One or the other and never a mixture: everything that follows from being stacked
818/// — what the branch is compared against, what its change request targets, where it
819/// lands, and which commit its own work begins after — comes from this one value, so
820/// a publication cannot hold a stack and a root that disagree about any of it.
821///
822/// Stacked is *recorded*, never inferred: a branch that carries the change below it
823/// commit for commit reads exactly like a branch that wrote those commits itself, so
824/// a stack read off content would rewrite branches nobody stacked.
825#[derive(Debug, Clone, PartialEq, Eq)]
826pub enum Target {
827    /// Onto one branch, which is also what the change is compared against: the base
828    /// a session was opened with, or the root a stacked change moved onto once the
829    /// change below it landed.
830    Base(Ref),
831    /// Onto the change below this one, until the root carries that change.
832    Stacked {
833        /// The branch below: what this change targets and is compared against.
834        below: Ref,
835        /// The identity's root base, which this moves onto once the root carries
836        /// the change below.
837        root: Ref,
838        /// The commit the branch was cut from, after which its own work begins.
839        // llmlint: ignore[invalid_states_unrepresentable] `git::tip` answered it.
840        tip: String,
841    },
842}
843
844impl Target {
845    /// The branch this change is compared against and lands on.
846    pub fn base(&self) -> &Ref {
847        match self {
848            Target::Base(base) => base,
849            Target::Stacked { below, .. } => below,
850        }
851    }
852}
853
854/// The stack a session's record wrote down, or `None` for the branch every ordinary
855/// session cuts.
856///
857/// Two things have to be there: the tip the session was cut at, which `session open`
858/// records only for a base that is not the identity's root, and a root to land on
859/// once the change below has landed.
860///
861/// The recorded tip is resolved through git before it is trusted — a record is a file
862/// under the state root — and one this session's clone does not have is refused rather
863/// than read as no stack: answering "no stack" from a value nothing could read is the
864/// merge this whole path exists to stop.
865///
866/// A root nobody can name is different and is no stack: nothing here fails, there is
867/// simply nowhere to move the change to, and the publication is the one it always
868/// was.
869fn recorded_target(record: &workspace::Record, publication: &Path) -> Result<Target> {
870    let base = preserved_change_base(&record.base, record.change_base.as_ref());
871    let Some(recorded) = record.stack_tip.as_deref() else {
872        return Ok(Target::Base(base));
873    };
874    let Some(tip) = git::tip(&record.clone, recorded) else {
875        return Err(Error::Invalid {
876            reason: format!(
877                "the record for session {token} names {recorded:?} as the commit branch {branch:?} \
878                 was cut from, and the clone at {clone} does not have it, so nothing can tell \
879                 which of that branch's commits belong to the change below it. The branch is \
880                 whole — publish it by name with `{command}`, which reads the stack off the \
881                 branch instead",
882                token = record.token,
883                branch = record.branch,
884                clone = record.clone.display(),
885                command = guidance::command([
886                    "onevcs",
887                    "publish-branch",
888                    &record.branch,
889                    "--repo",
890                    &record.publication_checkout.to_string_lossy(),
891                ]),
892            ),
893        });
894    };
895    let Some(root) = git::default_branch(publication, "origin").ok() else {
896        return Ok(Target::Base(base));
897    };
898    let root = Ref::from_git(root);
899    if root == base {
900        return Ok(Target::Base(base));
901    }
902    Ok(Target::Stacked {
903        below: base,
904        root,
905        tip,
906    })
907}
908
909/// Whether the root base already carries everything the change below this one
910/// contributed, while carrying none of the commits that contributed it.
911///
912/// Three things past the record: the branch was cut from that tip, the root does not
913/// hold the tip's commits under their own names, and the root does hold what they
914/// changed. What that establishes is exactly its name and no more — it is what a
915/// squash-merge of the change below leaves, and a root that came by the same content
916/// some other way is indistinguishable from one here, because content equality is all
917/// git can be asked. Both are answered the same way deliberately: the commits a
918/// replay drops are commits whose content the root already has, so the branch's own
919/// work is what is left either way. A change still open whose content has *not*
920/// reached the root fails the last test, and one merged as its own commits fails the
921/// second.
922pub(crate) fn root_is_known_to_carry_the_stack(
923    repo: &Path,
924    branch: &str,
925    root: &Ref,
926    tip: &str,
927) -> Result<bool> {
928    let root = vcs::base_ref(repo, root);
929    if !git::ref_exists(repo, &format!("refs/remotes/{root}")) && !git::branch_exists(repo, &root) {
930        return Ok(false);
931    }
932    if !git::is_ancestor(repo, tip, branch)? {
933        return Ok(false);
934    }
935    // Still in the root as the commits it was written as: merging the root brings
936    // them in the way it always has, and there is nothing to replay past.
937    if git::is_ancestor(repo, tip, &root)? {
938        return Ok(false);
939    }
940    let Some(fork) = git::merge_base(repo, &root, tip)? else {
941        return Ok(false);
942    };
943    git::known_to_carry_changes(repo, &root, &fork, tip)
944}
945
946/// Where a publication lands once the root has been consulted: the target it was
947/// handed, or the root once the root carries the change below — and, in that case,
948/// the tip the branch's own work begins after, which is what a replay onto the root
949/// starts from.
950///
951/// Asked after a fetch, deliberately: the root is what decides it, and a clone's own
952/// copy of the root is only as current as its last fetch. Every target that is not a
953/// stacked one is handed back as it was, with nothing to replay.
954pub(crate) fn landed_target(
955    repo: &Path,
956    branch: &Ref,
957    target: &Target,
958) -> Result<(Target, Option<String>)> {
959    if let Target::Stacked { root, tip, .. } = target {
960        if root_is_known_to_carry_the_stack(repo, branch, root, tip)? {
961            return Ok((Target::Base(root.clone()), Some(tip.clone())));
962        }
963    }
964    Ok((target.clone(), None))
965}
966
967/// The change request a session's publication opens or adopts: the branch it pushes,
968/// into the base it resolves for it, stacked or not.
969///
970/// One computation for `publish` and for the `change` verbs that address the same
971/// change request afterwards, so no session can have the two naming different ones.
972/// It is the resolution [`run_for_session`] and [`run`] make between them — the
973/// target the record wrote down, then the root once the root carries the change
974/// below — made after the same fetch a publication makes, because the root decides
975/// the second step and the clone's own copy of it is only as current as its last
976/// fetch. A `change show` on a stacked session therefore answers the change request
977/// the stack publishes onto, which is the one its next `publish` adopts.
978pub(crate) fn session_target(record: &workspace::Record) -> Result<Target> {
979    if git::has_remote(&record.clone, "origin") {
980        git::fetch(&record.clone, "origin")?;
981    }
982    standing_target(record)
983}
984
985/// The same resolution as [`session_target`], from what the session's clone already
986/// holds, without asking the remote.
987///
988/// What a *read* of the session judges its branch against: the base its publication
989/// would resolve from the clone's own view, which is the branch below until the
990/// clone has seen the root carry it. A stacked session whose branch below has
991/// landed and gone loses that name from the clone at the next fetch — its
992/// publication's own fetch prunes — and a read that kept comparing against the
993/// recorded base would then fail on a branch nothing has any more, for a session
994/// whose work is exactly where its publication put it.
995pub(crate) fn standing_target(record: &workspace::Record) -> Result<Target> {
996    let recorded = recorded_target(record, &record.publication_checkout)?;
997    let (target, _replay) = landed_target(&record.clone, &record.branch, &recorded)?;
998    Ok(target)
999}
1000
1001impl<'a> Context<'a> {
1002    /// The same publication, landing where `target` says instead.
1003    fn onto(&self, target: Target) -> Context<'a> {
1004        Context {
1005            resolution: self.resolution.clone(),
1006            policy: self.policy.clone(),
1007            effective: self.effective,
1008            repo: self.repo.clone(),
1009            worktree: self.worktree.clone(),
1010            branch: self.branch.clone(),
1011            target,
1012            push: self.push.clone(),
1013            run_root: self.run_root.clone(),
1014            preserved_into: self.preserved_into.clone(),
1015            title: self.title.clone(),
1016            body: self.body.clone(),
1017            draft: self.draft.clone(),
1018            drafts: self.drafts.clone(),
1019            trailers: self.trailers.clone(),
1020            provenance: self.provenance.clone(),
1021            hosting: self.hosting,
1022            cancellation: self.cancellation,
1023        }
1024    }
1025}
1026
1027/// Verify and publish a branch.
1028pub fn run(context: &Context<'_>, stream: &mut Stream) -> Result<PublishOutcome> {
1029    run_resolving(context, stream).map(|(outcome, _)| outcome)
1030}
1031
1032/// [`run`], also answering where the branch landed: the target it was handed, or
1033/// the root a stacked change was replayed onto because the root already carried the
1034/// change below.
1035///
1036/// The second is what a session's own record has to learn, because a replay changes
1037/// the branch's shape: the tip it was cut from is no longer in its history, so the
1038/// record's stack cannot be recognised on the branch again — and a later read or
1039/// publication of the same session that still resolved the recorded stack would
1040/// compare the branch against a branch below it that has landed and gone.
1041fn run_resolving(context: &Context<'_>, stream: &mut Stream) -> Result<(PublishOutcome, Target)> {
1042    // Input, rejected at its boundary: before the fetch, before the sync, and before
1043    // anything reaches a remote. A draft is a state of a *change request*, so a
1044    // publication that opens none cannot be in it, and a reason nobody can read is
1045    // not a reason.
1046    if let Some(reason) = &context.draft {
1047        reason.checked()?;
1048        if context.effective == MergePolicy::LocalDirect {
1049            return Err(crate::error::invalid(format!(
1050                "{branch:?} was asked to publish as {asked}, and this identity publishes with \
1051                 local-direct, which squashes the branch onto {base:?} and opens no change \
1052                 request at all — so there is nothing to draft and {lands}. Publish it under a \
1053                 change-* policy, or publish it without a draft",
1054                branch = context.branch,
1055                asked = reason.asked_for(),
1056                base = context.target.base(),
1057                lands = match reason {
1058                    DraftReason::AwaitingRelease { .. } => {
1059                        "the work would land with the very pin the draft exists to hold back"
1060                    }
1061                    DraftReason::Held { .. } => {
1062                        "the work would land while the session is still making it"
1063                    }
1064                },
1065            )));
1066        }
1067    }
1068    // Before anything is compared, because what it may establish is that this branch
1069    // already landed: a change request a `checks-unsettled` close stopped watching
1070    // merges on the host's clock, and a publication that met it again republished the
1071    // work the base already carried.
1072    take_up_a_late_merge(context);
1073    // Where this repository last saw the host's copy of the branch, read before the
1074    // fetch that is about to update it: a publication that replays its own commits
1075    // pushes over that copy, and what it may replace is what it had already seen —
1076    // never whatever arrived in between, which is the thing worth refusing over.
1077    let last_seen = git::tip(&context.repo, &format!("origin/{}", context.branch));
1078    if git::has_remote(&context.repo, "origin") {
1079        // Outside every exclusive section, deliberately.
1080        git::fetch(&context.repo, "origin")?;
1081        stream.emit(
1082            EventKind::Fetch,
1083            object(json!({"remote": "origin", "checkout": context.repo.display().to_string()})),
1084        );
1085    }
1086    // Asked after the fetch and before anything else: a change whose stack the root
1087    // already carries is a change onto the root base, and everything below — what it is compared
1088    // against, what its change request targets — follows from that rather than from
1089    // the branch it was opened against.
1090    let (target, replay) = landed_target(&context.repo, &context.branch, &context.target)?;
1091    let landed;
1092    let mut context = context;
1093    if replay.is_some() {
1094        landed = context.onto(target.clone());
1095        context = &landed;
1096    }
1097    let remote_base = format!("origin/{}", context.target.base());
1098    let compared = if git::ref_exists(&context.repo, &format!("refs/remotes/{remote_base}")) {
1099        remote_base.clone()
1100    } else {
1101        context.target.base().to_string()
1102    };
1103
1104    sync(context, stream, &compared, replay.as_deref())?;
1105
1106    if nothing_to_publish(context, &compared)? {
1107        return Ok((PublishOutcome::NothingToPublish, target));
1108    }
1109
1110    let push = match (&replay, &last_seen) {
1111        (Some(_), Some(replaced)) => Push::Replacing {
1112            replaced: replaced.clone(),
1113        },
1114        // Otherwise it is whatever the caller knows: a branch-keyed verb replays
1115        // before it hands a publication here, and its branch is rewritten just the
1116        // same.
1117        _ => context.push.clone(),
1118    };
1119    // The trailers are the publication *commit*'s, so only the local path takes them
1120    // here — and it re-describes the branch after the queue anyway. Asking now is
1121    // still what refuses a branch with no usable subject before anything is pushed.
1122    let (subject, _) = describe(context, &compared)?;
1123    let environment = merge_path::comparison_env("origin", context.target.base());
1124
1125    let outcome = match context.effective {
1126        MergePolicy::LocalDirect => publish_locally(context, stream, &compared, &environment)?,
1127        _ => publish_as_change(context, stream, &subject, &environment, push)?,
1128    };
1129    Ok((outcome, target))
1130}
1131
1132/// The subject a publication of one branch would carry, or the refusal that none
1133/// of its commits can supply one.
1134///
1135/// Public to this crate because it is also a *precondition*: a branch-keyed verb
1136/// asks it before it writes anything to the branch, so a refusal an explicit
1137/// `--title` answers is met before the work rather than after a commit the operator
1138/// then has to reason about.
1139pub(crate) fn subject_for(
1140    repo: &Path,
1141    compared: &str,
1142    branch: &str,
1143    title: Option<&str>,
1144    trailers: &provenance::Trailers,
1145) -> Result<String> {
1146    let subject = match provenance::publication_subject(repo, compared, branch, title, trailers)? {
1147        Ok(subject) => subject,
1148        Err(reason) => {
1149            return Err(Error::Invalid {
1150                reason: format!(
1151                    "cannot publish {branch:?}: {reason}. A subject that names no change would \
1152                     make the base branch a worse record than this refusal does."
1153                ),
1154            })
1155        }
1156    };
1157    hold_to_repository_policy(repo, branch, &subject)?;
1158    Ok(subject)
1159}
1160
1161/// Put the composed subject to the repository's own `commit-msg` hook, and refuse
1162/// the publication when it turns the subject down.
1163///
1164/// This crate states no subject policy of its own and never will: a squash-merge
1165/// subject decides whether a release cuts, and *which* subjects release is a fact
1166/// about the repository rather than about publishing. So the question is asked of
1167/// the repository, in the one place it can be asked at all — the subject a merge
1168/// lands under comes from a change request's title, which no local hook ever sees.
1169///
1170/// A repository with no `commit-msg` hook expresses no policy and acquires none by
1171/// being published through here: [`git::MessagePolicy::Unstated`] passes silently.
1172pub(crate) fn hold_to_repository_policy(repo: &Path, branch: &str, subject: &str) -> Result<()> {
1173    let git::MessagePolicy::Rejected { status, output } = git::message_policy(repo, subject)?
1174    else {
1175        return Ok(());
1176    };
1177    let said = guidance::quoted_output(output.trim());
1178    let said = if said.is_empty() {
1179        "<no output>".to_owned()
1180    } else {
1181        said
1182    };
1183    Err(Error::GateFailed {
1184        reason: format!(
1185            "the repository's own commit-msg hook rejected the subject publishing {branch:?} \
1186             would land, {subject:?} (exit {status}). Reword the commit the subject comes from, \
1187             or publish with an explicit title that satisfies it. The hook said:\n{said}"
1188        ),
1189    })
1190}
1191
1192/// Whether the base already carries everything this branch has, once the base has
1193/// been merged into it.
1194///
1195/// Two shapes, and only one of them is "no commits": a branch squash-merged under
1196/// somebody else's change request keeps every commit it had and adds nothing to the
1197/// tree, so the tree is what decides. Opening a change request for one produces an
1198/// empty diff, which every path-filtered required check skips rather than runs and
1199/// the host then blocks forever.
1200///
1201/// Asked here, on the one path both policies go through and before anything is
1202/// pushed, so every caller of a publication gets it.
1203fn nothing_to_publish(context: &Context<'_>, compared: &str) -> Result<bool> {
1204    if git::log_messages(&context.repo, compared, &context.branch)?.is_empty() {
1205        return Ok(true);
1206    }
1207    Ok(!git::trees_differ(
1208        &context.repo,
1209        compared,
1210        &context.branch,
1211    )?)
1212}
1213
1214/// The subject one publication commit carries, and what it must carry forward.
1215fn describe(context: &Context<'_>, compared: &str) -> Result<(String, Vec<String>)> {
1216    let subject = subject_for(
1217        &context.repo,
1218        compared,
1219        &context.branch,
1220        context.title.as_deref(),
1221        &context.provenance,
1222    )?;
1223    let mut trailers = context.trailers.clone();
1224    trailers.extend(provenance::attestation_trailers(
1225        &context.repo,
1226        compared,
1227        &context.branch,
1228        &context.provenance,
1229    )?);
1230    // What this commit lands, named on the base itself. A squash leaves the base
1231    // carrying the branch's content under no name it kept, so whether the work
1232    // reached the base could only ever be *inferred* from content afterwards — and
1233    // that inference stops being true the moment anything edits those paths. This is
1234    // the record that answers it instead, and it is written here because this path is
1235    // the one landing that opens no change request: everything the host lands carries
1236    // the change request's own number.
1237    if let Some(tip) = git::tip(&context.repo, &context.branch) {
1238        trailers.push(format!("{} {tip}", context.provenance.landed()));
1239    }
1240    Ok((subject, trailers))
1241}
1242
1243/// What bringing a branch level with what it lands on did.
1244pub(crate) enum Reconciled {
1245    /// The branch carries what it lands on now.
1246    Settled,
1247    /// It conflicted, doing this — which is what a refusal about it has to name,
1248    /// since the resolution an operator is sent to is the shape that was attempted
1249    /// — and *what* conflicted, which is what makes the refusal actionable rather
1250    /// than merely true.
1251    Conflicted(Reconciliation, git::Conflict),
1252}
1253
1254/// Which shape bringing a branch level with what it lands on takes.
1255pub(crate) enum Reconciliation {
1256    /// The base is merged into the branch.
1257    Merge,
1258    /// Only the branch's own commits are replayed onto the base, because the base
1259    /// already carries its history up to this commit.
1260    Replay {
1261        /// The commit where the branch's own work begins, as `git` spelled it.
1262        // llmlint: ignore[invalid_states_unrepresentable] `git::tip` answered it.
1263        from: String,
1264    },
1265}
1266
1267/// Bring a branch level with the base it is published onto, once.
1268///
1269/// Two shapes, and the record decides which rather than this: ordinarily the base is
1270/// merged into the branch, and a branch whose recorded stack has landed replays only
1271/// its own commits onto the base instead — `git rebase --onto <base> <stack tip>
1272/// <branch>`, which is what an operator ends up running by hand. Merging there would
1273/// replay the change below against its own squashed equivalent, which conflicts in
1274/// every file both touched and conflicts again on every bounded retry.
1275///
1276/// Shared by `publish` and by the branch-keyed verbs, so the two cannot come to
1277/// disagree about what a sync does.
1278pub(crate) fn reconcile(
1279    worktree: &Path,
1280    compared: &str,
1281    branch: &str,
1282    replay_from: Option<&str>,
1283) -> Result<Reconciled> {
1284    let (shape, integrated) = match replay_from {
1285        Some(tip) => (
1286            Reconciliation::Replay {
1287                from: tip.to_owned(),
1288            },
1289            git::rebase_onto(worktree, compared, tip, branch)?,
1290        ),
1291        None => (
1292            Reconciliation::Merge,
1293            git::merge_into_branch(worktree, compared, &merge_message(compared, branch))?,
1294        ),
1295    };
1296    Ok(match integrated {
1297        git::Integrated::Settled => Reconciled::Settled,
1298        git::Integrated::Conflicted(conflict) => Reconciled::Conflicted(shape, conflict),
1299    })
1300}
1301
1302/// The subject of the commit a sync's merge writes, so a session's opening merge
1303/// and a publication's read the same in `git log`.
1304pub(crate) fn merge_message(compared: &str, branch: &str) -> String {
1305    format!("Merge {compared} into {branch}")
1306}
1307
1308/// Report the conflict that stopped a publication: which paths, and the hunks.
1309///
1310/// The paths go in the payload because they are a short list an operator acts on
1311/// directly — "what conflicts" is the question a refusal that only said *that*
1312/// something conflicted left unanswered. The hunks go beside it as an artifact,
1313/// because a diff is evidence rather than a field, and the envelope's own rule is
1314/// that large evidence is referenced by id.
1315///
1316/// Shared with the branch-keyed verbs, which conflict at the same place for the
1317/// same reasons: a second emitter would be a second answer to what conflicts.
1318///
1319/// A hunk artifact that could not be stored is a warning on stderr and no artifact,
1320/// never a failure: the conflict is the answer, and reporting a filesystem problem
1321/// in place of it would lose the diagnosis this exists to keep.
1322pub(crate) fn report_conflict(
1323    stream: &mut Stream,
1324    branch: &Ref,
1325    base: &Ref,
1326    conflict: &git::Conflict,
1327    attempts: Option<usize>,
1328) {
1329    let artifacts = match stream::store_artifact("diff", conflict.hunks()) {
1330        Ok(artifact) => vec![artifact],
1331        Err(error) => {
1332            eprintln!(
1333                "onevcs: warning: the conflict on {branch:?} is recorded without its \
1334                 hunks: {error}"
1335            );
1336            Vec::new()
1337        }
1338    };
1339    let mut payload = object(json!({
1340        "branch": branch,
1341        "base": base,
1342        "paths": conflict.paths(),
1343    }));
1344    if let Some(attempts) = attempts {
1345        payload.insert("attempts".to_owned(), json!(attempts));
1346    }
1347    stream.emit_with(EventKind::SyncConflict, payload, artifacts);
1348}
1349
1350/// Sync the branch with the current base, bounded, before anything is published.
1351///
1352/// `replay_from` is the recorded stack tip when the change below this one has
1353/// landed, and `None` — every publication that is not a stacked one — is the merge
1354/// this has always been.
1355fn sync(
1356    context: &Context<'_>,
1357    stream: &mut Stream,
1358    compared: &str,
1359    replay_from: Option<&str>,
1360) -> Result<()> {
1361    if !git::ref_exists(&context.repo, &format!("refs/remotes/{compared}"))
1362        && !git::branch_exists(&context.repo, compared)
1363    {
1364        return Ok(());
1365    }
1366    let mut last: Option<(Reconciliation, git::Conflict)> = None;
1367    for attempt in 1..=SYNC_ATTEMPTS {
1368        match reconcile(&context.worktree, compared, &context.branch, replay_from)? {
1369            Reconciled::Settled => return Ok(()),
1370            Reconciled::Conflicted(shape, found) => last = Some((shape, found)),
1371        }
1372        if attempt < SYNC_ATTEMPTS && git::has_remote(&context.repo, "origin") {
1373            git::fetch(&context.repo, "origin")?;
1374        }
1375    }
1376    // Every path out of the loop above either returned or wrote this, and the bound
1377    // is at least one attempt — so the refusal below is always about a conflict this
1378    // run actually met, rather than about a value standing in for one.
1379    let Some((attempted, conflict)) = last else {
1380        return Ok(());
1381    };
1382    report_conflict(
1383        stream,
1384        &context.branch,
1385        context.target.base(),
1386        &conflict,
1387        Some(SYNC_ATTEMPTS),
1388    );
1389    let conflicting = guidance::listed(conflict.paths());
1390    let land = guidance::command([
1391        "onevcs",
1392        "publish-branch",
1393        &context.branch,
1394        "--repo",
1395        &context.resolution.publication.to_string_lossy(),
1396    ]);
1397    // The branch is retained rather than lost, and the refusal says what would
1398    // change the answer: another attempt resolves nothing a bounded retry has
1399    // already tried, and an operator told only that the two conflict is an operator
1400    // reaching for raw `git` to land the work. Which resolution it names follows
1401    // what was attempted — telling a branch whose stack parent has landed to merge
1402    // the base is telling it to reproduce the conflict it is refusing.
1403    Err(Error::SyncConflict {
1404        reason: match attempted {
1405            Reconciliation::Replay { from } => {
1406                format!(
1407                    "{compared} conflicts with {branch:?} in {conflicting} after {SYNC_ATTEMPTS} \
1408                 bounded attempts; the branch is retained. {compared} already carries what \
1409                 {branch:?} was stacked on, so only its own commits are replayed onto it. Resolve \
1410                 the conflict on it — replay it with `{}` — and then land it with `{land}`",
1411                    guidance::command([
1412                        "git",
1413                        "rebase",
1414                        "--onto",
1415                        compared,
1416                        &from,
1417                        &context.branch
1418                    ]),
1419                    branch = context.branch,
1420                )
1421            }
1422            Reconciliation::Merge => format!(
1423                "{compared} conflicts with {branch:?} in {conflicting} after {SYNC_ATTEMPTS} \
1424                 bounded attempts; the branch is retained. Resolve the conflict on it — merge \
1425                 {compared} into {branch} — and then land it with `{land}`",
1426                branch = context.branch,
1427            ),
1428        },
1429    })
1430}
1431
1432/// Land the branch as one squashed commit on the base, built detached.
1433fn publish_locally(
1434    context: &Context<'_>,
1435    stream: &mut Stream,
1436    compared: &str,
1437    environment: &[(String, String)],
1438) -> Result<PublishOutcome> {
1439    let publication = &context.resolution.publication;
1440    require_publication_checkout_ready(publication, context.target.base())?;
1441    let synced = git::tip(&context.repo, compared);
1442
1443    let identity = lock::git_identity(&git::common_dir(publication)?);
1444    let turn = queue::turn(&identity, context.cancellation)?;
1445    stream.emit(
1446        EventKind::LockWait,
1447        object(json!({
1448            "identity": identity,
1449            "elapsed": turn.waited.as_secs_f64(),
1450            "queue_position": turn.position,
1451        })),
1452    );
1453    stream.emit(
1454        EventKind::LockAcquired,
1455        object(json!({"identity": identity})),
1456    );
1457    stream.emit(
1458        EventKind::MergeQueued,
1459        object(json!({"identity": identity, "queue_position": turn.position})),
1460    );
1461
1462    let outcome = (|| -> Result<PublishOutcome> {
1463        // The base can have advanced while this turn was queued — the writer ahead
1464        // of it in the queue is the usual reason. The tree that lands is therefore
1465        // re-synced here rather than silently reconciled: what was brought level with
1466        // the base is no longer what would be published, and the merge path is about
1467        // to rule on whatever this pushes.
1468        if git::has_remote(&context.repo, "origin") {
1469            git::fetch(&context.repo, "origin")?;
1470        }
1471        if git::tip(&context.repo, compared) != synced {
1472            sync(context, stream, compared, None)?;
1473        }
1474        let (subject, trailers) = describe(context, compared)?;
1475        let message = compose_squash(&subject, &closing_lines(context, compared)?, &trailers);
1476
1477        let scratch_parent = context.run_root.join(format!("publish-{}", ids::unique()));
1478        home::ensure_dir(&scratch_parent)?;
1479        let scratch = scratch_parent.join("worktree");
1480        git::worktree_add_detached(&context.repo, &scratch, compared)?;
1481        let landed = (|| -> Result<PublishOutcome> {
1482            let Some(sha) = git::merge_squash(&scratch, &context.branch, &message)? else {
1483                return Ok(PublishOutcome::NothingToPublish);
1484            };
1485            let pushed = git::push(
1486                &scratch,
1487                &format!("HEAD:refs/heads/{}", context.target.base()),
1488                "origin",
1489                environment,
1490            )?;
1491            // The squash goes onto the base rather than onto the branch it landed,
1492            // so this push is the work being integrated whatever branch the payload
1493            // names.
1494            let kept = record_push(
1495                stream,
1496                &context.branch,
1497                &pushed,
1498                Some(&context.run_root),
1499                Phase::Integrate,
1500                None,
1501            )?;
1502            if !pushed.accepted() {
1503                // The tree this push was built in goes when this closure returns,
1504                // so the refusal must not send anybody to a path inside it.
1505                return Err(rejected(
1506                    &publishing(&context.branch),
1507                    &pushed,
1508                    &kept,
1509                    Some(&scratch_parent),
1510                ));
1511            }
1512            fast_forward_publication(publication, context.target.base())?;
1513            stream.emit(
1514                EventKind::MergeCompleted,
1515                object(json!({"identity": identity, "sha": sha, "base": context.target.base()})),
1516            );
1517            record_baselines(context, &sha, stream);
1518            Ok(PublishOutcome::Merged(Sha(sha)))
1519        })();
1520        git::worktree_remove(&context.repo, &scratch)?;
1521        let _ = std::fs::remove_dir_all(&scratch_parent);
1522        landed
1523    })();
1524
1525    drop(turn);
1526    outcome
1527}
1528
1529/// How a publication names the push it was refused, so the one refusal builder is
1530/// handed the same phrase from both of its call sites here.
1531fn publishing(branch: &Ref) -> String {
1532    format!("the publishing push of {branch:?}")
1533}
1534
1535/// A push the merge path refused: what git said it was, everywhere the merge path's
1536/// own account of it can be read, and the end of that account.
1537///
1538/// [`Error::PushRejected`] rather than [`Error::GateFailed`]: the same exit code, and a
1539/// kind a caller can route on.
1540///
1541/// **The pointers are the point.** What the hook or the remote wrote is a run of the
1542/// repository's whole verification and does not belong inline, so `record_push`
1543/// stored it a moment earlier — as an artifact, and where the caller had a run root,
1544/// as a file that outlives the tree the publication was built in. This used to name
1545/// neither, and a refusal that reports git's three generic lines while the diagnosis
1546/// sits in two places nothing points at is a refusal an operator has to go searching
1547/// behind: one landing here cost an hour that way, to find four redundant comment
1548/// lines. So the failure names both, and each only where it exists.
1549///
1550/// It quotes an excerpt rather than the whole, because the whole is what the
1551/// pointers above are for; [`excerpt`] is where the bound and the end it is taken
1552/// from are reasoned about.
1553///
1554/// **A refusal that captured nothing is a different sentence.** There is no excerpt
1555/// to quote and no per-ref summary to name, so the two clauses above collapse into
1556/// prose that describes nothing — `rejected by the merge path: .` is what one
1557/// seventeen-minute publishing push was reported as, and its caller had to run the
1558/// whole gate again to learn anything. What is said instead is that the merge path
1559/// wrote nothing, and what the run itself reported: the exit status or the signal
1560/// that terminated the push, and any read of its pipes that stopped short. A
1561/// refusal that *did* capture output is reported exactly as it always was.
1562///
1563/// This is also what an *unclassified* rejection is reported as, deliberately: it
1564/// names the push and hands over git's own per-ref summary without deciding what
1565/// produced it. The fallback below it is for a failure that never reached a ref at
1566/// all — no credential, no remote — where git's last line is the whole answer.
1567///
1568/// `what` is the push, named as its own caller names it: the branch a publication
1569/// was landing, or the base a merge train advanced.
1570///
1571/// `removed` is the tree the push was built in where its caller removes that tree
1572/// before returning, and is what [`outliving`] scrubs the message of: every path
1573/// this refusal names has to be openable by the person reading it.
1574pub(crate) fn rejected(
1575    what: &str,
1576    pushed: &git::Pushed,
1577    kept: &Kept,
1578    removed: Option<&Path>,
1579) -> Error {
1580    let summary = pushed
1581        .refusal()
1582        .unwrap_or_else(|| pushed.output().lines().next_back().unwrap_or("").trim());
1583    // Said rather than left to be inferred. A refusal that names nowhere reads as one
1584    // that simply did not bother to; "there is nothing to go and read" and "there is,
1585    // and this failure has lost the address of it" are the same sentence to an
1586    // operator and different next moves. `record_push` has already said on stderr why
1587    // each store refused the bytes.
1588    let where_it_is = if kept.anywhere() {
1589        kept.evidence()
1590    } else {
1591        " What it wrote could not be stored anywhere, so the excerpt below is all that \
1592         survives of it."
1593            .to_owned()
1594    };
1595    let wrote = pushed.output().trim();
1596    let missing = host_prerequisite(wrote);
1597    let said = if wrote.is_empty() {
1598        String::new()
1599    } else {
1600        format!(
1601            " It said:\n{}",
1602            guidance::quoted_output(&excerpt(
1603                wrote,
1604                "the whole of it is where this refusal says"
1605            ))
1606        )
1607    };
1608    // The one case the sentence above cannot describe: nothing was captured, so
1609    // `summary` is empty and `said` is empty, and what an operator is handed reads
1610    // `rejected by the merge path: .` — which is how a seventeen-minute push was
1611    // reported, and why the whole gate had to be run again to learn anything about
1612    // it. The run's own account of itself is what is left, so it is said outright.
1613    if wrote.is_empty() && missing.is_none() {
1614        return Error::PushRejected {
1615            reason: outliving(
1616                &format!(
1617                    "{what} was rejected by the merge path, and the merge path wrote nothing: \
1618                     {diagnosis}.{where_it_is}",
1619                    diagnosis = diagnosis(pushed),
1620                ),
1621                removed,
1622            ),
1623        };
1624    }
1625    match missing {
1626        Some(missing) => Error::HostPrerequisite {
1627            reason: outliving(
1628                &format!(
1629                    "{what} was refused by the merge path because this host is missing a \
1630                     prerequisite: {missing}.{where_it_is}{said}",
1631                    missing = missing.trim_end_matches('.'),
1632                ),
1633                removed,
1634            ),
1635        },
1636        None => Error::PushRejected {
1637            reason: outliving(
1638                &format!("{what} was rejected by the merge path: {summary}.{where_it_is}{said}"),
1639                removed,
1640            ),
1641        },
1642    }
1643}
1644
1645/// What the push's own run says about itself: how it ended, and any read of its
1646/// pipes that stopped short.
1647///
1648/// Both, and neither inferred from the other — a push terminated by a signal and a
1649/// push whose output this process could not read are different failures with
1650/// different next moves, and a push can be both. A refusal reaching here with no
1651/// diagnostics at all is one no `git push` produced, and says so rather than
1652/// claiming an ending nobody reported.
1653fn diagnosis(pushed: &git::Pushed) -> String {
1654    let Some(diagnostics) = pushed.diagnostics() else {
1655        return "nothing was recorded about how the push itself ended".to_owned();
1656    };
1657    let mut said = format!("the push {}", diagnostics.ended);
1658    if !diagnostics.read_failures.is_empty() {
1659        said.push_str(&format!(
1660            ", and its output could not be read whole ({})",
1661            diagnostics.read_failures.join("; "),
1662        ));
1663    }
1664    said
1665}
1666
1667/// How a merge-path hook begins the one line saying a refusal is this host's rather
1668/// than the work's: a tool or a credential it needs is missing, and what follows the
1669/// marker says which and how to install it.
1670///
1671/// The contract's spelling, stated once. A hook prints it only for a failure that does
1672/// not depend on the tree being pushed, because the reader on the other side settles
1673/// such a refusal without sending anybody to change the work.
1674pub const HOST_PREREQUISITE_MARKER: &str = "onevcs: host-prerequisite:";
1675
1676/// What a merge path's host-prerequisite line says is missing, where its output
1677/// carries one.
1678///
1679/// Read line by line, and the line has to *begin* with the marker — after the
1680/// `remote:` git puts before what a server-side hook printed — so a hook quoting the
1681/// marker in prose, or a test log mentioning it, is not read as one. A marker naming
1682/// nothing is not the line the contract describes, and the refusal is classified as
1683/// though it were absent.
1684fn host_prerequisite(output: &str) -> Option<&str> {
1685    output.lines().find_map(|line| {
1686        let line = line.trim();
1687        let line = line.strip_prefix("remote:").map_or(line, str::trim_start);
1688        line.strip_prefix(HOST_PREREQUISITE_MARKER)
1689            .map(str::trim)
1690            .filter(|missing| !missing.is_empty())
1691    })
1692}
1693
1694/// The same refusal with every path into a tree that will not outlive this command
1695/// replaced by the fact that it did not.
1696///
1697/// A merge path keeps its own account where it runs — `.logs/nx.log` under the tree
1698/// the publishing push was built from is one real spelling — and says so in the
1699/// output quoted above. That tree is a scratch worktree removed before the command
1700/// returns, so the refusal named a path an operator opened and found nothing at,
1701/// while the same output sat under the publication's own retained gate logs, which
1702/// the sentence beside it already names. A path that is not there is worse than no
1703/// path: it is the one thing in a failure a reader trusts enough to go and open.
1704///
1705/// What is replaced is the whole path and not its prefix, because half of one still
1706/// reads as somewhere to look. Nothing else is touched — the preserved log and the
1707/// checkouts this names are under neither this tree nor any other that is going.
1708fn outliving(reason: &str, removed: Option<&Path>) -> String {
1709    let Some(removed) = removed else {
1710        return reason.to_owned();
1711    };
1712    let doomed = removed.display().to_string();
1713    if doomed.is_empty() {
1714        return reason.to_owned();
1715    }
1716    let mut left = reason;
1717    let mut answer = String::with_capacity(reason.len());
1718    while let Some(at) = left.find(&doomed) {
1719        answer.push_str(&left[..at]);
1720        let rest = &left[at..];
1721        // To the end of the path and no further: a path ends where the prose around
1722        // it resumes, and the sentence-final stop of the line it sits in is prose.
1723        let end = rest
1724            .find(|c: char| c.is_whitespace() || ENDS_A_PATH.contains(&c))
1725            .unwrap_or(rest.len());
1726        let end = rest[..end].trim_end_matches('.').len().max(doomed.len());
1727        answer.push_str(GONE);
1728        left = &rest[end..];
1729    }
1730    answer.push_str(left);
1731    answer
1732}
1733
1734/// What a path is quoted or punctuated with once it is over, in the prose a merge
1735/// path writes around one.
1736const ENDS_A_PATH: [char; 6] = [')', '"', '\'', '`', ',', ';'];
1737
1738/// What stands where such a path stood: not a path, and the reason it is not.
1739const GONE: &str = "(gone with the publication worktree this ran in)";
1740
1741/// Why git turned a leased push down, when the lease is what it turned down.
1742///
1743/// The two are told apart because the operator's next move differs: one branch has
1744/// somebody else's work on it to reconcile with, and the other has nothing on the
1745/// host at all.
1746enum Declined {
1747    /// The branch is on the host at a commit this run never saw.
1748    Moved(git::ObjectId),
1749    /// The host has no branch of that name any more.
1750    Gone,
1751}
1752
1753/// Whether git turned this push down *because the lease was stale*, decided from
1754/// what git and the remote report and never from the prose beside it.
1755///
1756/// Two structural facts have to agree, because neither alone is enough. git has to
1757/// have declined this very ref: its `--porcelain` line for the branch carries the
1758/// `!` flag, which rules out a push that failed before any ref was negotiated. And
1759/// the lease has to be genuinely stale: `--force-with-lease=<branch>:<seen>` is
1760/// declined exactly when the remote's value for the branch is not `<seen>`, so the
1761/// remote's own answer to "where is this branch" settles whether that is what
1762/// happened. A remote that will not say answers `None`, leaving the rejection
1763/// unclassified — assuming a stale lease from an answer nobody gave is the same
1764/// mistake as reading it out of a message a locale could translate.
1765fn declined_the_lease(
1766    context: &Context<'_>,
1767    pushed: &git::Pushed,
1768    replaced: &str,
1769    environment: &[(String, String)],
1770) -> Result<Option<Declined>> {
1771    if !pushed.refused_branch(&context.branch) {
1772        return Ok(None);
1773    }
1774    Ok(
1775        match git::remote_tip(&context.worktree, "origin", &context.branch, environment)? {
1776            git::RemoteTip::At(tip) if tip.as_str() == replaced => None,
1777            git::RemoteTip::At(tip) => Some(Declined::Moved(tip)),
1778            // The host no longer has the branch at all, which is not where this run
1779            // last saw it either — the lease named a commit, and git declines it
1780            // against a ref that is gone just as it does against one that moved.
1781            git::RemoteTip::Absent => Some(Declined::Gone),
1782            git::RemoteTip::Unknown => None,
1783        },
1784    )
1785}
1786
1787/// Push the branch, open or adopt its change request, and ask the host to land it.
1788fn publish_as_change(
1789    context: &Context<'_>,
1790    stream: &mut Stream,
1791    subject: &str,
1792    environment: &[(String, String)],
1793    push: Push,
1794) -> Result<PublishOutcome> {
1795    // Input, rejected at its boundary rather than half way through the watch below,
1796    // where the branch would already be on the remote and the refusal would read as a
1797    // merge path nobody could verify.
1798    refuse_an_unhosted_identity(&context.resolution.key)?;
1799    if let Some(reason) = &context.draft {
1800        if let Some(refusal) = refuse_a_draft_over_a_reviewed_change(context, reason) {
1801            return Err(refusal);
1802        }
1803    }
1804    // Every change policy watches its checks now, `change-open` included, so every one
1805    // is held to the knobs that bound the watch — before anything is pushed.
1806    gh::checks_timeout()?;
1807    gh::checks_poll()?;
1808    crate::host::check_source_names_a_source()?;
1809    if !context.drafts.disabled {
1810        gh::draft_grace()?;
1811    }
1812
1813    // A branch this publication replayed is not a descendant of the one the host has
1814    // for it — the change below's commits are gone from it — so the push replaces one
1815    // commit and no other, and git refuses it if the host is anywhere else. Every
1816    // other publication pushes as it always has: forward or not at all.
1817    let replacing = match &push {
1818        Push::Replacing { replaced } => Some(replaced.as_str()),
1819        Push::Forward => None,
1820    };
1821    if context.cancellation.is_cancelled() {
1822        return Err(cancelled_while(&format!(
1823            "it prepared {:?} and before anything was pushed",
1824            context.branch
1825        )));
1826    }
1827    let pushed = git::push_replacing(
1828        &context.worktree,
1829        &context.branch,
1830        "origin",
1831        replacing,
1832        environment,
1833    )?;
1834    // The session's own branch, on its way to being proposed: the work being made.
1835    let kept = record_push(
1836        stream,
1837        &context.branch,
1838        &pushed,
1839        Some(&context.run_root),
1840        Phase::Development,
1841        git::tip(&context.worktree, &format!("refs/heads/{}", context.branch)).as_deref(),
1842    )?;
1843    if !pushed.accepted() {
1844        // Which refusal this is depends on what git declined, and that is decided
1845        // from what git and the host *report* rather than from the sentence either
1846        // wrote. A declined lease means somebody else's commit is on this branch
1847        // while the work here is a replay that would have written over it. Every
1848        // other rejection of the same push is what it has always been — a
1849        // credential, a hook, or the host's own policy — and so is every rejection
1850        // this cannot tell apart: reading one of those as a branch that moved would
1851        // send an operator to reconcile histories that never diverged.
1852        let declined = match replacing {
1853            Some(replaced) => declined_the_lease(context, &pushed, replaced, environment)?,
1854            None => None,
1855        };
1856        let (Some(replaced), Some(declined)) = (replacing, declined) else {
1857            // The session's own worktree, which outlives this command: what it
1858            // wrote about itself stays readable.
1859            return Err(rejected(&publishing(&context.branch), &pushed, &kept, None));
1860        };
1861        let branch = &context.branch;
1862        let base = context.target.base();
1863        let command = guidance::command([
1864            "onevcs",
1865            "publish-branch",
1866            branch,
1867            "--repo",
1868            &context.resolution.publication.to_string_lossy(),
1869        ]);
1870        return Err(Error::SyncConflict {
1871            // What the host has instead is what the operator has to do something
1872            // about, so the refusal says which of the two it is rather than one
1873            // sentence that is only true of the commoner one.
1874            reason: match declined {
1875                Declined::Moved(tip) => format!(
1876                    "{branch:?} moved on the host since this run last had it at {replaced} — it \
1877                     is at {tip} now — and this publication replayed it onto {base:?}, so pushing \
1878                     would replace whatever was pushed there in between. Nothing was overwritten \
1879                     and the branch is retained. Reconcile the two — fetch {branch}, and replay \
1880                     or merge what the host has into it — and then land it with `{command}`",
1881                    tip = tip.as_str(),
1882                ),
1883                Declined::Gone => format!(
1884                    "{branch:?} is gone from the host, which this run last had at {replaced}, and \
1885                     this publication replayed it onto {base:?} — so the push would put a branch \
1886                     back that somebody deleted, out of a history nobody there has seen. Nothing \
1887                     was pushed and the branch is retained. Fetch {branch} so this run sees the \
1888                     host as it stands — every fetch here prunes — and then land it with \
1889                     `{command}`"
1890                ),
1891            },
1892        });
1893    }
1894
1895    // Everything past here reads the *host*, and the push above has already reached
1896    // the remote: from this point a failure is a merge path this build could not
1897    // read rather than a publication that never landed. The commit is taken from the
1898    // tree that was pushed rather than asked of the remote, because asking the remote
1899    // is one more read that can fail for the very reason being reported.
1900    let pushed_at = git::tip(&context.worktree, &context.branch);
1901    // The push — and the `pre-push` hook it ran — is never interrupted, so a caller
1902    // that cancelled while it ran is answered here, once it has returned and before
1903    // anything on the host is opened or watched.
1904    if context.cancellation.is_cancelled() {
1905        return Err(cancelled_while(&format!(
1906            "its publishing push ran; {:?} is on origin{}",
1907            context.branch,
1908            pushed_at
1909                .as_deref()
1910                .map_or_else(String::new, |sha| format!(" at {sha}")),
1911        )));
1912    }
1913    // The commit is what the watch below decides *which* of the host's checks are
1914    // about this publication, so a tree that will not say what it just pushed is a
1915    // merge path this build cannot read rather than one it reads about some other
1916    // commit. It reaches the operator as exactly that, through `unverified`.
1917    match pushed_at.as_deref() {
1918        Some(sha) => land_as_change(context, stream, subject, &Sha(sha.to_owned())),
1919        None => Err(crate::error::invalid(format!(
1920            "{:?} was pushed and this worktree will not say which commit it is at, so which of \
1921             the host's checks are about what was just pushed cannot be decided",
1922            context.branch
1923        ))),
1924    }
1925    .map_err(|unread| unverified(context, pushed_at.as_deref(), unread))
1926}
1927
1928/// Refuse an identity that has no host at all, before anything is pushed.
1929///
1930/// Half of [`change_host`]'s question, and only that half: a **hosted** identity this
1931/// build has no implementation for is left until after the push, because there the
1932/// branch reaching the origin is not what is missing, and `edges.rs` holds that.
1933fn refuse_an_unhosted_identity(identity: &str) -> Result<()> {
1934    match change_host(identity) {
1935        Ok(_) | Err(Error::NotImplemented { .. }) => Ok(()),
1936        Err(no_host) => Err(no_host),
1937    }
1938}
1939
1940/// A push that reached the remote and a merge path that could not then be read,
1941/// reported as the one thing it is rather than as a publication that failed.
1942///
1943/// This covers the answers nobody got. What passes through is every failure the
1944/// contract already fixes a kind for — [`Error::GateFailed`] included, which it fixes
1945/// for a host that took a merge and then reported it unperformed: that shares this
1946/// defect's shape, and re-pointing a meaning is an amendment somebody approves. A
1947/// conflict the host confirmed and a caller's cancellation pass through too: both are
1948/// answers, and neither is a merge path that could not be read.
1949fn unverified(context: &Context<'_>, pushed_at: Option<&str>, unread: Error) -> Error {
1950    match unread {
1951        verdict @ (Error::ChecksFailed { .. }
1952        | Error::ChecksUnsettled { .. }
1953        | Error::GateFailed { .. }
1954        | Error::NotImplemented { .. }
1955        | Error::SyncConflict { .. }
1956        | Error::Cancelled { .. }) => verdict,
1957        unread => Error::PushedUnverified {
1958            reason: format!(
1959                "{branch:?} is on origin{at} and the merge path could not be read: {unread}. The \
1960                 work reached the remote, so re-publishing it would repeat what already landed \
1961                 there — read the change request on the host, and land the branch with \
1962                 `{command}` once the host answers",
1963                branch = context.branch,
1964                at = pushed_at.map_or_else(String::new, |sha| format!(" at {sha}")),
1965                command = guidance::command([
1966                    "onevcs",
1967                    "publish-branch",
1968                    &context.branch,
1969                    "--repo",
1970                    &context.resolution.publication.to_string_lossy(),
1971                ]),
1972            ),
1973        },
1974    }
1975}
1976
1977/// Open or adopt the change request for a branch **already on the remote**, and ask
1978/// the host to land it.
1979///
1980/// Separate from the push above so that what every failure in here has in common is a
1981/// property of the function rather than a comment somebody has to keep true.
1982///
1983/// **The draft lifecycle** is here. A publication carrying no [`DraftReason`] opens its
1984/// change request as a draft while the pushed commit's required checks run, records
1985/// `change-drafted` with `kind: "awaiting-checks"`, and once they settle lifts or keeps
1986/// it by the policy's row of [`policy::GreenDraft`]: every change policy watches, and
1987/// the lift comes **before** the merge is armed or asked for, because a host will do
1988/// neither to a draft. Adopting a draft is the same — it is not lifted on the spot but
1989/// watched into a lift or a keep. Adopting a change the host holds ready never
1990/// re-drafts it: the lift is one-way. `drafts: {disabled: true}` opens ready and lifts
1991/// an adopted draft at once, as before the lifecycle.
1992fn land_as_change(
1993    context: &Context<'_>,
1994    stream: &mut Stream,
1995    subject: &str,
1996    pushed: &Sha,
1997) -> Result<PublishOutcome> {
1998    let slug = change_host(&context.resolution.key)?;
1999    let host = context.hosting.for_repo(&slug)?;
2000    // Who the host believes is calling travels with the change: a change request
2001    // opened by an identity nobody expected is the thing an operator reads this to
2002    // find out.
2003    let author = host.authenticated_user()?;
2004    let lifecycle = !context.drafts.disabled;
2005
2006    let existing = host.find_changes(&context.branch, context.target.base())?;
2007    // Whether the host already held this change request: with the lifecycle off,
2008    // that decides whether there can be a draft to lift, since a change this
2009    // publication just opened without a reason is one nobody drafted.
2010    let adopted = !existing.is_empty();
2011    let change = match existing.into_iter().next() {
2012        Some(change) => change,
2013        None => host.open_change(ChangeSpec {
2014            head: context.branch.to_string(),
2015            base: context.target.base().to_string(),
2016            title: subject.to_owned(),
2017            // Exactly what the caller passed, or nothing. The caller is the layer
2018            // that knows what the change is for; this one only knows the branch,
2019            // and everything it could say from that the title already says.
2020            body: context.body.clone(),
2021            // The reason travels to the host so that the host can open it as a
2022            // draft, and no further: what the host renders of a draft is the state,
2023            // never the reason. The reason is recorded below, on the stream.
2024            draft: context.draft.clone(),
2025            // No reason, and the lifecycle on: a draft while the checks run.
2026            draft_awaiting_checks: lifecycle && context.draft.is_none(),
2027        })?,
2028    };
2029    stream.emit(
2030        EventKind::ChangeOpened,
2031        object(json!({
2032            "url": change.url.to_string(),
2033            "host": "github",
2034            "id": change.id.0,
2035            "base": change.base,
2036            "author": author,
2037        })),
2038    );
2039
2040    if let Some(reason) = &context.draft {
2041        return hold_as_draft(context, host.as_ref(), &change, reason, stream);
2042    }
2043    let drafted = if lifecycle {
2044        await_as_draft(host.as_ref(), &change, stream)?
2045    } else {
2046        if adopted {
2047            lift_any_draft(host.as_ref(), &change, stream)?;
2048        }
2049        false
2050    };
2051
2052    let mut watcher = Watcher::new(
2053        host.as_ref(),
2054        &change,
2055        pushed,
2056        context.drafts.warn_on_early_lift,
2057        context.cancellation,
2058    )?;
2059    let green = policy::GreenDraft::of(context.effective, context.policy.approvals);
2060    if context.effective == MergePolicy::ChangeOpen {
2061        // No merge is asked for, so the identity's merge queue is not taken: what
2062        // this waits for is the verdict a person reviews against, not a turn.
2063        if settle(&mut watcher, stream, drafted)? {
2064            if green == policy::GreenDraft::KeepForReview {
2065                stream.emit(
2066                    EventKind::DraftKeptForReview,
2067                    object(json!({
2068                        "url": change.url.to_string(),
2069                        "id": change.id.0,
2070                        "base": change.base,
2071                    })),
2072                );
2073                return Ok(PublishOutcome::ChangeReviewDraft(change.url.clone()));
2074            }
2075            lift_draft(host.as_ref(), &change, stream)?;
2076        }
2077        return Ok(PublishOutcome::ChangeOpen(change.url.clone()));
2078    }
2079
2080    // Everything automated is serialized against the identity, so two sessions of
2081    // one repository cannot ask the host to land two changes at once.
2082    let identity = lock::git_identity(&git::common_dir(&context.resolution.publication)?);
2083    let turn = queue::turn(&identity, context.cancellation)?;
2084    stream.emit(
2085        EventKind::LockWait,
2086        object(json!({
2087            "identity": identity,
2088            "elapsed": turn.waited.as_secs_f64(),
2089            "queue_position": turn.position,
2090        })),
2091    );
2092    stream.emit(
2093        EventKind::LockAcquired,
2094        object(json!({"identity": identity})),
2095    );
2096
2097    let outcome = (|| -> Result<PublishOutcome> {
2098        // What is watched, and until when, follows the **merge policy** and nothing
2099        // else — least of all anything a repository *calls* its verification. A
2100        // change-direct publication asks for the merge itself, so it waits for the
2101        // checks the host says block one first; a draft waits for its checks under
2102        // either policy, because it is lifted on their verdict and a host will
2103        // neither merge a draft nor arm its own merge on one.
2104        if (drafted || context.effective == MergePolicy::ChangeDirect)
2105            && settle(&mut watcher, stream, drafted)?
2106        {
2107            lift_draft(host.as_ref(), &change, stream)?;
2108        }
2109        stream.emit(
2110            EventKind::MergeQueued,
2111            object(json!({
2112                "identity": identity,
2113                "queue_position": turn.position,
2114                "url": change.url.to_string(),
2115            })),
2116        );
2117        let sha = if context.effective == MergePolicy::ChangeAuto {
2118            // Arming happens inside the watch, and after its first reading of the
2119            // checks: a host that will not say what its checks are is refused with
2120            // nothing armed against it, and one loop rather than a read followed by a
2121            // watch reports each transition exactly once.
2122            watch_the_merge(&mut watcher, stream)?
2123        } else {
2124            match host.merge(&change, context.effective)? {
2125                MergeOutcome::Merged(sha) => sha,
2126                MergeOutcome::Queued => return Ok(PublishOutcome::Queued(change.url.clone())),
2127                MergeOutcome::Open => return Ok(PublishOutcome::ChangeOpen(change.url.clone())),
2128            }
2129        };
2130        stream.emit(
2131            EventKind::ChangeMerged,
2132            object(json!({"url": change.url.to_string(), "sha": sha.0})),
2133        );
2134        stream.emit(
2135            EventKind::MergeCompleted,
2136            object(json!({"identity": identity, "sha": sha.0})),
2137        );
2138        record_landing(context, &sha);
2139        record_baselines(context, &sha.0, stream);
2140        fast_forward_publication(&context.resolution.publication, context.target.base())?;
2141        Ok(PublishOutcome::Merged(sha))
2142    })();
2143    drop(turn);
2144    outcome
2145}
2146
2147/// Whether the change a reasonless publication holds is a draft awaiting its checks,
2148/// recording it as one where it is.
2149///
2150/// Asked of the host rather than assumed from having asked for one, both for a change
2151/// this publication just opened — a host written before `draft_awaiting_checks`
2152/// opens it ready, and a ready change is watched as one — and for one it adopted,
2153/// whatever drafted it: a held draft, a release-awaiting one, or one an earlier red run
2154/// left awaiting its checks is watched into a lift or a keep rather than lifted on the
2155/// spot. A change the host holds **ready** is never asked to be a draft again, and
2156/// records nothing: lifting is one-way.
2157///
2158/// A host that was never taught to say whether it drafted anything has drafted
2159/// nothing this can lift, so it answers "not a draft" exactly as [`lift_any_draft`]
2160/// reads it; a host that could not say is a refusal.
2161fn await_as_draft(
2162    host: &dyn RemoteHost,
2163    change: &ChangeRequest,
2164    stream: &mut Stream,
2165) -> Result<bool> {
2166    match host.is_draft(change) {
2167        Ok(true) => {}
2168        Ok(false) | Err(Error::NotImplemented { .. }) => return Ok(false),
2169        Err(unreadable) => return Err(unreadable),
2170    }
2171    stream.emit(
2172        EventKind::ChangeDrafted,
2173        object(json!({
2174            "url": change.url.to_string(),
2175            "id": change.id.0,
2176            "base": change.base,
2177            "kind": AWAITING_CHECKS,
2178        })),
2179    );
2180    Ok(true)
2181}
2182
2183/// The `kind` a `change-drafted` payload carries for a draft the lifecycle opened
2184/// while the checks run — the one kind that is no [`DraftReason`], because nobody
2185/// asked for it.
2186const AWAITING_CHECKS: &str = "awaiting-checks";
2187
2188/// Hold a change request open as a draft, and record why.
2189///
2190/// The publication stops here under **every** change policy: a draft is unmergeable
2191/// in that state, so nothing below this asks the host to merge it, arms the host's
2192/// own merge on it, takes the identity's merge queue, or fast-forwards a base from
2193/// it. Which policy the identity publishes under decides what happens once the draft
2194/// is lifted, not what happens while it stands.
2195fn hold_as_draft(
2196    context: &Context<'_>,
2197    host: &dyn RemoteHost,
2198    change: &ChangeRequest,
2199    reason: &DraftReason,
2200    stream: &mut Stream,
2201) -> Result<PublishOutcome> {
2202    // Asked of the host rather than assumed from having said `--draft`, and the two
2203    // cases it separates are both real: a host whose `open_change` was written before
2204    // this field silently ignores it, and a change request already open for review is
2205    // one this crate will not put back into a draft — the seam has no method for it,
2206    // and inventing one would be a public item nobody approved. Either way the answer
2207    // is the same refusal, because either way the change on the host can land while
2208    // its caller believes it cannot.
2209    if !host.is_draft(change)? {
2210        return Err(already_open_for_review(context, change, reason));
2211    }
2212    // The publication record, and the only place the reason is written: see
2213    // [`DraftReason`].
2214    let mut payload = reason.fields();
2215    let fields = payload.as_object_mut().expect("the reason is an object");
2216    fields.insert("url".to_owned(), json!(change.url.to_string()));
2217    fields.insert("id".to_owned(), json!(change.id.0));
2218    fields.insert("base".to_owned(), json!(change.base));
2219    stream.emit(EventKind::ChangeDrafted, object(payload));
2220    Ok(PublishOutcome::ChangeDraft(change.url.clone()))
2221}
2222
2223/// The refusal for a draft asked over a change request the host has open for review.
2224///
2225/// One sentence, two places: `hold_as_draft` reaches it once the change is adopted,
2226/// and the pre-push check below reaches it before anything is on the remote. A
2227/// refusal that read differently depending on which noticed first would be two rules
2228/// about one state.
2229fn already_open_for_review(
2230    context: &Context<'_>,
2231    change: &ChangeRequest,
2232    reason: &DraftReason,
2233) -> Error {
2234    crate::error::invalid(format!(
2235        "{url} is open for review on the host, and this publication asked for {asked}. A change \
2236         that is open can land, so reporting it as a draft would say the work is held back when \
2237         nothing is holding it. Lift nothing and publish {branch:?} without a draft, or close \
2238         that change request and publish again",
2239        url = change.url,
2240        asked = reason.asked_for(),
2241        branch = context.branch,
2242    ))
2243}
2244
2245/// Refuse a draft over a change request the host already has open for review, before
2246/// anything reaches the remote.
2247///
2248/// The same question [`hold_as_draft`] asks after the change is adopted, asked at the
2249/// boundary as well — because everything past the publishing push is reported as a
2250/// merge path this build could not read, and this is not one of those. The merge path
2251/// ruled on the push perfectly well; what stopped the publication is a state the host
2252/// *answered*, and an operator told their push is unverified would go looking for a
2253/// failure that never happened.
2254///
2255/// **Only a definite answer refuses.** A host this build has no implementation for,
2256/// one that cannot be reached, and one that will not say whether a change is a draft
2257/// are each left exactly where they are met today — after the push, by the paths that
2258/// already handle them — so this can only ever move a refusal *earlier*, never invent
2259/// one. `hold_as_draft` stays the authoritative check for the same reason it existed
2260/// before this: a host may take `--draft` and open an ordinary change anyway.
2261fn refuse_a_draft_over_a_reviewed_change(
2262    context: &Context<'_>,
2263    reason: &DraftReason,
2264) -> Option<Error> {
2265    let host = change_host(&context.resolution.key)
2266        .ok()
2267        .and_then(|slug| context.hosting.for_repo(&slug).ok())?;
2268    let open = host
2269        .find_changes(&context.branch, context.target.base())
2270        .ok()?
2271        .into_iter()
2272        .next()?;
2273    match host.is_draft(&open) {
2274        Ok(false) => Some(already_open_for_review(context, &open, reason)),
2275        _ => None,
2276    }
2277}
2278
2279/// Lift any draft on a change request, at once.
2280///
2281/// What `onevcs change ready` asks for, and what a publication carrying no
2282/// [`DraftReason`] does with a draft it adopts when the rules file says `drafts:
2283/// {disabled: true}` — the behaviour before the draft lifecycle. With the lifecycle
2284/// on, an adopted draft is watched into a lift or a keep instead (`await_as_draft`).
2285///
2286/// **Idempotent, because the host decides.** A change request that is not a draft is
2287/// asked for nothing, so a second publication after a lift makes no call and reports
2288/// exactly what the first one did.
2289///
2290/// Asked only of a change request the host already held. One this publication opened
2291/// moments ago, without a reason, is one nobody drafted — so a publication that
2292/// opens its own change request asks the host nothing extra, and every implementation
2293/// written against the earlier surface goes on publishing exactly as it did.
2294///
2295/// A host that cannot say is a host that was never taught to draft one, and it is
2296/// passed over rather than refused — `is_draft` is defaulted to
2297/// [`Error::NotImplemented`], so this is the answer every implementation written
2298/// against the earlier surface gives, and refusing it would break publications that
2299/// have nothing to do with drafts. The cost is bounded and safe in the one direction
2300/// that matters: a host that drafts a change and cannot be asked about it leaves the
2301/// draft standing, and a draft that stands is a change that does not land.
2302pub(crate) fn lift_any_draft(
2303    host: &dyn RemoteHost,
2304    change: &ChangeRequest,
2305    stream: &mut Stream,
2306) -> Result<()> {
2307    match host.is_draft(change) {
2308        Ok(true) => {}
2309        Ok(false) => return Ok(()),
2310        Err(Error::NotImplemented { .. }) => return Ok(()),
2311        Err(unreadable) => return Err(unreadable),
2312    }
2313    lift_draft(host, change, stream)
2314}
2315
2316/// Ask the host to take a change it holds as a draft out of it, and record the lift.
2317///
2318/// The one place a lift is performed and recorded, whichever path decided on it: a
2319/// publication lifting on green checks or early, `onevcs change ready`, and a
2320/// publication under `drafts: {disabled: true}` lifting an adopted draft.
2321fn lift_draft(host: &dyn RemoteHost, change: &ChangeRequest, stream: &mut Stream) -> Result<()> {
2322    host.ready_for_review(change)?;
2323    stream.emit(
2324        EventKind::DraftLifted,
2325        object(json!({"url": change.url.to_string(), "id": change.id.0})),
2326    );
2327    Ok(())
2328}
2329
2330/// Record the commit the host merged this change at, on the branch itself.
2331///
2332/// The most certain answer to "did this branch land": a landing that was *recorded*
2333/// rather than inferred from what the base happens to carry. The record is one
2334/// provenance trailer, `<prefix>Landed-Commit: <sha>`, under the same configured
2335/// prefix every other trailer this crate reads and writes uses — so a host that
2336/// spells its provenance differently reads its own landings and not somebody
2337/// else's. It goes on an otherwise empty commit, because the branch's content is
2338/// exactly what merged and a record that changed it would leave the branch
2339/// disagreeing with the base it has just reached.
2340///
2341/// Best effort, deliberately. The change has already merged by the time this runs,
2342/// and reporting the publication as failed because its own footnote could not be
2343/// written would be a worse lie than the missing line — the same rule the event
2344/// stream is written under. What went wrong is said on stderr, where the operator
2345/// running the command sees it.
2346fn record_landing(context: &Context<'_>, sha: &Sha) {
2347    if let Err(error) = write_landing(context, sha) {
2348        eprintln!(
2349            "onevcs: warning: {branch:?} merged at {merged}, and the landing was not recorded on \
2350             the branch: {error}",
2351            branch = context.branch,
2352            merged = sha.0,
2353        );
2354    }
2355}
2356
2357/// Record what each of this identity's **automated** release targets had at the
2358/// moment this change landed.
2359///
2360/// A baseline is what makes "has this change been released" answerable later: a
2361/// probe answering a strictly greater version afterwards is the release that
2362/// carries this work. It is captured here, at the landing, because a reading taken
2363/// any later cannot tell a release that carries this change from one that predates
2364/// it.
2365///
2366/// Nothing is probed for a human-step target, because there is nothing to probe —
2367/// what a landing starts for one of those is a wait a person ends. And nothing at
2368/// all happens for an identity with no release targets, which is every identity on
2369/// a host with no release-targets file.
2370///
2371/// Best effort, exactly as [`record_landing`] above is and for the same reason: the
2372/// change has already merged, and reporting the publication as failed because its
2373/// own baseline could not be captured would be a worse lie than the missing record.
2374fn record_baselines(context: &Context<'_>, sha: &str, stream: &mut Stream) {
2375    match crate::store::load() {
2376        Ok(registry) => {
2377            crate::release::record_baselines(&registry, &context.resolution.key, sha, stream)
2378        }
2379        Err(failure) => eprintln!(
2380            "onevcs: warning: the release baselines for {identity} at landing {sha} were not \
2381             captured: {failure}",
2382            identity = context.resolution.key,
2383        ),
2384    }
2385}
2386
2387fn write_landing(context: &Context<'_>, sha: &Sha) -> Result<()> {
2388    git::commit_empty(
2389        &context.worktree,
2390        &format!(
2391            "chore: record the landing of {branch}\n\n{key} {merged}",
2392            branch = context.branch,
2393            key = context.provenance.landed(),
2394            merged = sha.0,
2395        ),
2396    )?;
2397    // The repository this publication worked in goes with its run root, so the
2398    // record has to reach the checkout that keeps the branch. Fast-forward only,
2399    // like every other hand-back here: a checkout holding work this run does not
2400    // have keeps it.
2401    if !git::copy_branch(&context.repo, &context.preserved_into, &context.branch)? {
2402        return Err(crate::error::invalid(format!(
2403            "the checkout {} would not take the branch, so nothing outside this run carries the \
2404             record",
2405            context.preserved_into.display()
2406        )));
2407    }
2408    Ok(())
2409}
2410
2411/// How much of a verifier's log travels on the failure that names it.
2412///
2413/// Well under the envelope's own 4096-byte payload limit, because this is a
2414/// pointer to the evidence and not a second copy of it: the whole log is the
2415/// artifact the event beside it already carries, fetched with `onevcs artifact
2416/// cat`. One bound for both verifiers this crate reports on — a host's required
2417/// check and the repository's own merge path — because the reason is the same for
2418/// each: how long a verification's log runs is the verifier's business, and neither
2419/// failure is the place to carry the whole of one.
2420const LOG_EXCERPT: usize = 2048;
2421
2422/// The end of a verifier's log, bounded at a line boundary.
2423///
2424/// The *end*, because a verification prints its diagnosis last and its setup first —
2425/// an excerpt taken from the top of a twenty-thousand-line log is the part nobody
2426/// needed. That is not a guess about CI: a merge path that refused a publication
2427/// here put its one finding in the last twelve lines of seventy-six thousand bytes,
2428/// and the bounded head that travelled on the `push` event was the toolchain warming
2429/// up. What was cut is marked, so a reader can tell an excerpt from a short log, and
2430/// `whole` says where the rest of it is — which the failure has already named.
2431fn excerpt(log: &str, whole: &str) -> String {
2432    let trimmed = log.trim_end();
2433    if trimmed.len() <= LOG_EXCERPT {
2434        return trimmed.to_owned();
2435    }
2436    let mut cut = trimmed.len() - LOG_EXCERPT;
2437    while cut < trimmed.len() && !trimmed.is_char_boundary(cut) {
2438        cut += 1;
2439    }
2440    let tail = &trimmed[cut..];
2441    let tail = tail.find('\n').map_or(tail, |at| &tail[at + 1..]);
2442    format!("[…earlier output omitted; {whole}…]\n{tail}")
2443}
2444
2445/// What a host's answer about a change request says about **one commit**.
2446///
2447/// The publication path's whole defence against a verdict that is about the wrong
2448/// thing, and it is a type rather than a filter so the distinction cannot be
2449/// dropped by accident: "the host has said nothing about this commit yet" and "the
2450/// host says nothing blocks it" are the same empty list and opposite answers. A
2451/// caller reaches the checks only by having said which of the two it is holding.
2452///
2453/// A publication watches the commit it just pushed. The host attaches that commit's
2454/// checks seconds to minutes later, and until it does, the change request still
2455/// carries the *previous* head's — a red one of which used to end the publication
2456/// with a verdict that predated the check it claimed to have read.
2457enum Reported {
2458    /// What this answer holds about the commit: the checks the host attached to it,
2459    /// and the checks the host attached to no commit at all. Empty is an answer —
2460    /// the one a repository that declares no check gives — and it is only ever
2461    /// reached when the host reported no checks whatsoever.
2462    About(Vec<Check>),
2463    /// Every check the host reported names some *other* commit, so it has said
2464    /// nothing about this one. What a head pushed moments ago looks like.
2465    NotYet,
2466}
2467
2468impl Reported {
2469    /// Read one host answer as what it says about `commit`.
2470    fn of(answered: Vec<Check>, commit: &Sha) -> Self {
2471        let reported = !answered.is_empty();
2472        let about: Vec<Check> = answered
2473            .into_iter()
2474            .filter(|check| is_about(check, commit))
2475            .collect();
2476        if about.is_empty() && reported {
2477            return Self::NotYet;
2478        }
2479        Self::About(about)
2480    }
2481
2482    /// The checks that are about the commit, which is none of them until the host
2483    /// has said anything about it.
2484    fn checks(&self) -> &[Check] {
2485        match self {
2486            Self::About(checks) => checks,
2487            Self::NotYet => &[],
2488        }
2489    }
2490}
2491
2492/// Whether one check is the host's answer *about* `commit`.
2493///
2494/// Two cases, and the second is why this is not equality: a check the host attached
2495/// to `commit` is about it, and so is a check the host named no commit for at all.
2496/// A host that will not say which head its checks belong to is a host whose checks
2497/// are read exactly as they were before any of them carried one — declining to
2498/// answer is not a reason to stall a publication until its bound. Only a check
2499/// naming some *other* commit is set aside.
2500///
2501/// Private, and it belongs here rather than on [`Check`]: it is this path's reading
2502/// of the host's answer, not a property of the answer, and the approved surface
2503/// declares the field and no method over it.
2504fn is_about(check: &Check, commit: &Sha) -> bool {
2505    check.head.as_ref().is_none_or(|head| head == commit)
2506}
2507
2508/// One reading of the host, narrowed to the pushed commit, with whether the answer
2509/// came from the host's complete rollup.
2510struct Reading {
2511    reported: Reported,
2512    /// Whether the checks were read from the source that sees every check — which is
2513    /// what makes "none of them is required" the host's own answer that nothing is.
2514    complete: bool,
2515}
2516
2517/// Which checks a repository requires before a merge, as far as the host said.
2518///
2519/// Three answers, never two: "none is required" is the one a draft may act on at once,
2520/// and it is only ever the host's **complete** answer. An incomplete or unreadable one
2521/// is [`Declared::Unknown`], which waits.
2522#[derive(Debug, Clone, PartialEq, Eq)]
2523enum Declared {
2524    /// The host answered, completely, that nothing is required.
2525    Nothing,
2526    /// The checks the host names as required, from every source that answered.
2527    Names(BTreeSet<String>),
2528    /// The host did not say — a source it could not read, or no answer at all. What
2529    /// is required is then read off the checks' own `required` as they arrive.
2530    Unknown,
2531}
2532
2533/// What the host says it requires before a merge into this change's base.
2534///
2535/// Asked once, before the draft's checks are watched, through the same seam the audit
2536/// reads. A host that was never taught to answer, or would not, has not said nothing
2537/// is required.
2538///
2539/// Beside a [`Declared::Unknown`] is why the declaration could not be read, which is
2540/// what a settlement read from the host's own per-check marking has to say it was: by
2541/// the manager's ruling on the amendment, such a green must never read as an ordinary
2542/// complete answer.
2543fn declared_required(host: &dyn RemoteHost, change: &ChangeRequest) -> (Declared, Option<String>) {
2544    match host.required_checks_on(&change.base) {
2545        Ok(answer) if answer.checks.is_empty() && answer.complete() => (Declared::Nothing, None),
2546        Ok(answer) if !answer.checks.is_empty() => (Declared::Names(answer.checks), None),
2547        Ok(answer) => (
2548            Declared::Unknown,
2549            Some(format!(
2550                "the host could not read {} for which checks a merge into {} requires",
2551                answer
2552                    .unconsulted
2553                    .keys()
2554                    .map(|source| source.describe())
2555                    .collect::<Vec<_>>()
2556                    .join(" or "),
2557                change.base
2558            )),
2559        ),
2560        Err(refused) => (
2561            Declared::Unknown,
2562            Some(format!(
2563                "the host would not say which checks a merge into {} requires: {refused}",
2564                change.base
2565            )),
2566        ),
2567    }
2568}
2569
2570impl Declared {
2571    /// This answer, sharpened by one reading: where the host would not say what it
2572    /// requires, a **complete** rollup reporting checks of which none is required is
2573    /// that same answer given another way — `gh pr checks --required`'s "no required
2574    /// checks reported" — and is read as it.
2575    fn given(&self, reading: &Reading) -> Declared {
2576        match (self, &reading.reported) {
2577            (Declared::Unknown, Reported::About(checks))
2578                if reading.complete
2579                    && !checks.is_empty()
2580                    && checks.iter().all(|check| !check.required) =>
2581            {
2582                Declared::Nothing
2583            }
2584            _ => self.clone(),
2585        }
2586    }
2587}
2588
2589/// Where one required check stands in a reading: its name, the state its entries
2590/// add up to — `None` where the host reported none — and the entry that decided it.
2591struct Standing<'c> {
2592    name: String,
2593    state: Option<CheckState>,
2594    check: Option<&'c Check>,
2595}
2596
2597/// Each required check's standing in `checks`.
2598///
2599/// The names come from the host's declaration where it made one, so a required
2600/// check it has not reported yet is *absent* rather than overlooked; otherwise from
2601/// the checks' own `required`. A name reported more than once — a run re-attached
2602/// after a lift, a run that ended with no verdict and was re-run — stands at the most
2603/// decisive of its entries: a red one fails it, a running one holds it, and a passed
2604/// one outranks a run that ended with no verdict or was skipped, which is how a later
2605/// green run of a check that was cancelled or skipped proceeds.
2606fn standings<'c>(checks: &[&'c Check], declared: &Declared) -> Vec<Standing<'c>> {
2607    let names: BTreeSet<String> = match declared {
2608        Declared::Names(names) => names.clone(),
2609        Declared::Nothing => BTreeSet::new(),
2610        Declared::Unknown => checks
2611            .iter()
2612            .filter(|check| check.required)
2613            .map(|check| check.name.clone())
2614            .collect(),
2615    };
2616    let rank = |state: CheckState| match state {
2617        CheckState::Failed => 0,
2618        CheckState::Pending => 1,
2619        CheckState::Passed => 2,
2620        CheckState::NoVerdict => 3,
2621        CheckState::Skipped => 4,
2622    };
2623    names
2624        .into_iter()
2625        .map(|name| {
2626            let decided = checks
2627                .iter()
2628                .copied()
2629                .filter(|check| check.name == name)
2630                .min_by_key(|check| rank(check.state()));
2631            Standing {
2632                state: decided.map(Check::state),
2633                check: decided,
2634                name,
2635            }
2636        })
2637        .collect()
2638}
2639
2640/// The standings' red one, where a required check concluded red.
2641fn red<'c>(standings: &[Standing<'c>]) -> Option<&'c Check> {
2642    standings
2643        .iter()
2644        .find(|standing| standing.state == Some(CheckState::Failed))
2645        .and_then(|standing| standing.check)
2646}
2647
2648/// A publication watching one change request at one commit, across every phase of
2649/// its watch — the draft's, the one after an early lift, and the ready change's up to
2650/// the merge — so that each check transition is reported once and one bound covers
2651/// the whole of it.
2652///
2653/// Three ways out of any phase, and each one says which it was: the phase's own
2654/// ending, a **required** check concluding red ([`Error::ChecksFailed`], naming it and
2655/// quoting its log), or the bound elapsing ([`Error::ChecksUnsettled`], naming what was
2656/// still pending). A caller routing a failure has to be able to tell "CI said no" from
2657/// "nobody answered in an hour".
2658///
2659/// `pushed` is what the checks are read *about*, and it is the commit this run put on
2660/// the remote rather than the head the host reports for the change request — which is
2661/// the same value only once the host has noticed the push. [`Reported`] is where that
2662/// is decided, and everything past it — the stream, the red-check verdict, and the
2663/// bound's own sentence — sees only checks about `pushed`.
2664/// Where one check stood when it was last reported: its status, its conclusion, and
2665/// when the run started.
2666type Transition = (String, Option<String>, Option<String>);
2667
2668struct Watcher<'a> {
2669    host: &'a dyn RemoteHost,
2670    change: &'a ChangeRequest,
2671    pushed: &'a Sha,
2672    /// Asked between readings, at least every [`CANCELLATION_GRAIN`], so a caller's
2673    /// cancellation ends any phase of the watch whatever the poll interval is.
2674    cancellation: &'a dyn PublicationCancellation,
2675    bound: std::time::Duration,
2676    poll: std::time::Duration,
2677    started: std::time::Instant,
2678    /// What each check was last reported as, so only a transition is reported. Keyed
2679    /// on the status, the conclusion *and* the run's start, because a run the host
2680    /// re-attaches after a lift can complete exactly as the skipped one had, and it is
2681    /// still a run of its own.
2682    reported: Vec<(String, Transition)>,
2683    /// What each settled check's log was stored as, so the refusal that names a red
2684    /// check can quote it without fetching it from the host a second time.
2685    logs: Vec<(String, crate::event::ArtifactId)>,
2686    /// Whether `checks-settled` has been recorded, which it is once per watch.
2687    settled: bool,
2688    /// Whether an early lift prints its warning line: the resolved
2689    /// `drafts.warn_on_early_lift`.
2690    warn_on_early_lift: bool,
2691}
2692
2693impl<'a> Watcher<'a> {
2694    fn new(
2695        host: &'a dyn RemoteHost,
2696        change: &'a ChangeRequest,
2697        pushed: &'a Sha,
2698        warn_on_early_lift: bool,
2699        cancellation: &'a dyn PublicationCancellation,
2700    ) -> Result<Self> {
2701        Ok(Self {
2702            warn_on_early_lift,
2703            host,
2704            change,
2705            pushed,
2706            cancellation,
2707            bound: std::time::Duration::from_secs_f64(gh::checks_timeout()?),
2708            poll: std::time::Duration::from_secs_f64(gh::checks_poll()?),
2709            started: std::time::Instant::now(),
2710            reported: Vec::new(),
2711            logs: Vec::new(),
2712            settled: false,
2713        })
2714    }
2715
2716    /// Ask the host once, narrow its answer to the pushed commit, and report every
2717    /// check that moved.
2718    fn read(&mut self, stream: &mut Stream) -> Result<Reading> {
2719        // What was consulted travels with the checks and is deliberately not acted
2720        // on here: a credential that can see only GitHub Actions still gates a merge
2721        // on what it *can* see, and a credential that could see nothing was a
2722        // refusal rather than an empty answer.
2723        let answered = self.host.change_checks(self.change)?;
2724        let complete = answered.complete();
2725        // Narrowed to the commit this publication pushed before anything is reported
2726        // or acted on, so a check the host attached to a head this run replaced
2727        // reaches neither the stream nor the verdict.
2728        let reported = Reported::of(answered.checks, self.pushed);
2729        for check in reported.checks() {
2730            let now = (
2731                check.status.clone(),
2732                check.conclusion.clone(),
2733                check.started_at.clone(),
2734            );
2735            let previous = self
2736                .reported
2737                .iter()
2738                .find(|(name, _)| name == &check.name)
2739                .map(|(_, was)| was.clone());
2740            if previous.as_ref() == Some(&now) {
2741                continue;
2742            }
2743            let mut artifacts = Vec::new();
2744            if check.settled() {
2745                // The log records what the check printed; `conclusion`, already read,
2746                // is what decides whether it blocks. So a host that will not hand
2747                // one over is reported the way a stream that cannot be written is —
2748                // on stderr, without failing the command over it.
2749                match self.host.check_log(self.change, check) {
2750                    Ok(id) => {
2751                        self.logs.retain(|(name, _)| name != &check.name);
2752                        self.logs.push((check.name.clone(), id.clone()));
2753                        artifacts.push(crate::event::ArtifactRef {
2754                            id: id.0,
2755                            kind: "log".to_owned(),
2756                            bytes: 0,
2757                        });
2758                    }
2759                    Err(error) => eprintln!(
2760                        "onevcs: warning: check {:?} on {} is recorded without its log: {error}",
2761                        check.name, self.change.url
2762                    ),
2763                }
2764            }
2765            stream.emit_with(
2766                EventKind::ChangeCheck,
2767                object(json!({
2768                    "name": check.name,
2769                    "required": check.required,
2770                    "status": check.status,
2771                    "from_status": previous.map(|(status, _, _)| status),
2772                    "conclusion": check.conclusion,
2773                    "state": check.state(),
2774                })),
2775                artifacts,
2776            );
2777            self.reported.retain(|(name, _)| name != &check.name);
2778            self.reported.push((check.name.clone(), now));
2779        }
2780        Ok(Reading { reported, complete })
2781    }
2782
2783    /// The refusal for a required check that concluded red.
2784    fn failed(&self, check: &Check) -> Error {
2785        checks_failed(check, &self.logs)
2786    }
2787
2788    /// Whether the bound on the whole watch has elapsed.
2789    fn out_of_time(&self) -> bool {
2790        self.started.elapsed() >= self.bound
2791    }
2792
2793    /// Wait out one interval between readings, or end the watch as
2794    /// [`Error::Cancelled`] the moment the caller cancels it.
2795    fn pause(&self) -> Result<()> {
2796        pause_unless_cancelled(self.poll, self.cancellation, || {
2797            cancelled_while(&format!(
2798                "it watched {url} at {commit}",
2799                url = self.change.url,
2800                commit = self.pushed.0,
2801            ))
2802        })
2803    }
2804
2805    /// The refusal for a change request the host confirms conflicts with its base,
2806    /// quoting what the host reported.
2807    fn conflicting(&self, reported: &str) -> Error {
2808        Error::SyncConflict {
2809            reason: format!(
2810                "{url} conflicts with its base {base:?}: the host reports it {reported}, so the \
2811                 merge it was armed to perform can never happen. The branch stays on its remote \
2812                 at {commit} and the change request stays open — merge {base} into the branch, \
2813                 resolve the conflict, and publish the session again",
2814                url = self.change.url,
2815                base = self.change.base,
2816                commit = self.pushed.0,
2817            ),
2818        }
2819    }
2820
2821    /// The bound elapsed, naming what the host had not settled.
2822    fn unsettled(&self, reading: &Reading, awaited: &str) -> Error {
2823        unsettled(
2824            self.change,
2825            self.pushed,
2826            &reading.reported,
2827            self.bound,
2828            awaited,
2829        )
2830    }
2831
2832    /// Record, once, that the required checks stopped blocking — `passed`, or
2833    /// `passed-with-skipped` naming the required checks that concluded skipped.
2834    ///
2835    /// `unread` is set where the draft's requirement was read from the host's own
2836    /// per-check `required` marking because its declaration could not be read — and
2837    /// then the record says so, in the payload's `requirement` and on stderr, so the
2838    /// settlement never reads as the host's ordinary complete answer. Omitted
2839    /// otherwise, so every other settlement's payload is the five fields it always was.
2840    fn record_settled(&mut self, stream: &mut Stream, skipped: Vec<String>, unread: Option<&str>) {
2841        if std::mem::replace(&mut self.settled, true) {
2842            return;
2843        }
2844        let verdict = if skipped.is_empty() {
2845            "passed"
2846        } else {
2847            "passed-with-skipped"
2848        };
2849        let mut payload = object(json!({
2850            "url": self.change.url.to_string(),
2851            "id": self.change.id.0,
2852            "head": self.pushed.0,
2853            "verdict": verdict,
2854            "skipped": skipped,
2855        }));
2856        if let Some(because) = unread {
2857            eprintln!(
2858                "onevcs: warning: the required checks on {url} were read from the host's own \
2859                 per-check marking, because the declaration could not be read: {because}. The \
2860                 host's merge path still rules on any merge",
2861                url = self.change.url,
2862            );
2863            payload.insert(
2864                "requirement".to_owned(),
2865                json!({"read_from": "host-marking", "because": because}),
2866            );
2867        }
2868        stream.emit(EventKind::ChecksSettled, payload);
2869    }
2870}
2871
2872/// Wait until the change's required checks stop blocking, and answer whether it is
2873/// still a draft then.
2874///
2875/// A change that is not a draft is watched as it always was — [`settle_ready`]. A
2876/// draft is watched by the draft's rule, [`settle_draft`]: a skipped required check
2877/// on it has **not run**, and a draft none of whose required checks has run by the
2878/// grace window is lifted so that they can, after which it is a ready change whose
2879/// checks must run again. `true` means the checks came back green on the draft, which
2880/// is still standing for the caller to lift or keep.
2881fn settle(watcher: &mut Watcher<'_>, stream: &mut Stream, drafted: bool) -> Result<bool> {
2882    if drafted {
2883        settle_draft(watcher, stream)
2884    } else {
2885        settle_ready(watcher, stream).map(|()| false)
2886    }
2887}
2888
2889/// Wait until every required check the host reports has settled without blocking.
2890///
2891/// What a `change-direct` publication does before it asks the host to merge, and what
2892/// every ready change does before its policy's ending: this run performs the merge,
2893/// or reports the change as open, so an answer the host's own checks have already
2894/// refused is one to report rather than act past.
2895///
2896/// A skipped required check satisfies this, as it satisfies the host's own merge path
2897/// — and the watch then records `checks-settled` as `passed-with-skipped`, naming it,
2898/// so the record says what the host accepted rather than calling it green. A host that
2899/// reports **no required check at all** is not a stall here. It has answered, and its
2900/// answer is that nothing blocks; `change-auto` is the policy that fails closed on
2901/// that, because its watch ends at a merge the host performs.
2902fn settle_ready(watcher: &mut Watcher<'_>, stream: &mut Stream) -> Result<()> {
2903    loop {
2904        let reading = watcher.read(stream)?;
2905        if let Reported::About(checks) = &reading.reported {
2906            let about: Vec<&Check> = checks.iter().collect();
2907            let standing = standings(&about, &Declared::Unknown);
2908            if let Some(failed) = red(&standing) {
2909                return Err(watcher.failed(failed));
2910            }
2911            if standing.iter().all(|standing| {
2912                matches!(
2913                    standing.state,
2914                    Some(CheckState::Passed | CheckState::Skipped)
2915                )
2916            }) {
2917                watcher.record_settled(stream, skipped_in(&standing), None);
2918                return Ok(());
2919            }
2920        }
2921        // Otherwise the host has posted nothing about the commit this publication
2922        // pushed, or something it requires is still running. The first is a clock and
2923        // not an answer — the checks it *is* reporting belong to a head this run
2924        // replaced — so the watch keeps waiting rather than merging on an emptiness
2925        // that is really somebody else's verdict having been filtered out.
2926        if watcher.out_of_time() {
2927            return Err(watcher.unsettled(&reading, "settled its required checks on"));
2928        }
2929        watcher.pause()?;
2930    }
2931}
2932
2933/// The required checks that concluded skipped, by name.
2934fn skipped_in(standing: &[Standing<'_>]) -> Vec<String> {
2935    standing
2936        .iter()
2937        .filter(|standing| standing.state == Some(CheckState::Skipped))
2938        .map(|standing| standing.name.clone())
2939        .collect()
2940}
2941
2942/// Watch a draft's required checks until they are green on it, lifting it early where
2943/// none of them has run by the grace window. `true` is green on the draft; `false` is
2944/// a draft lifted early whose checks then ran green as a ready change's.
2945///
2946/// **A skipped required check has not run** while the change is a draft: a workflow
2947/// that gates a job on the change not being one reports `skipped` without having
2948/// verified anything. So green on a draft is every required check `passed`, and a
2949/// draft where the only thing stopping that is checks that have not run — absent, or
2950/// skipped — is the draft-skipping repository: once the grace window elapses it is
2951/// lifted, `draft-lifted` and `draft-lifted-early` are recorded, one warning line is
2952/// printed unless `drafts.warn_on_early_lift` is off, and the watch goes on as
2953/// [`settle_after_early_lift`].
2954///
2955/// A host that says, completely, that nothing is required is green at once: no grace
2956/// window and no warning.
2957fn settle_draft(watcher: &mut Watcher<'_>, stream: &mut Stream) -> Result<bool> {
2958    let grace = std::time::Duration::from_secs_f64(gh::draft_grace()?);
2959    let (declared, unread) = declared_required(watcher.host, watcher.change);
2960    loop {
2961        let reading = watcher.read(stream)?;
2962        let declared = declared.given(&reading);
2963        if declared == Declared::Nothing {
2964            watcher.record_settled(stream, Vec::new(), None);
2965            return Ok(true);
2966        }
2967        // Whether what is required is being read off the host's marking because the
2968        // declaration could not be read, which a settlement then has to say.
2969        let marked = (declared == Declared::Unknown)
2970            .then_some(unread.as_deref())
2971            .flatten();
2972        let about: Vec<&Check> = reading.reported.checks().iter().collect();
2973        let standing = standings(&about, &declared);
2974        if let Some(failed) = red(&standing) {
2975            return Err(watcher.failed(failed));
2976        }
2977        if !standing.is_empty()
2978            && standing
2979                .iter()
2980                .all(|standing| standing.state == Some(CheckState::Passed))
2981        {
2982            watcher.record_settled(stream, Vec::new(), marked);
2983            return Ok(true);
2984        }
2985        let not_run: Vec<String> = standing
2986            .iter()
2987            .filter(|standing| matches!(standing.state, None | Some(CheckState::Skipped)))
2988            .map(|standing| standing.name.clone())
2989            .collect();
2990        let running = standing.iter().any(|standing| {
2991            matches!(
2992                standing.state,
2993                Some(CheckState::Pending | CheckState::NoVerdict)
2994            )
2995        });
2996        // Nothing is known to be required and nothing reported is marked required: a
2997        // host that will not say what a merge needs. That is never read as "none", so
2998        // it is not green at once — it waits the grace window. Then, where checks ran
2999        // on the draft and none of them was skipped, the host's own marking is the
3000        // answer, exactly as a ready change's watch reads it; where nothing ran, or
3001        // something was skipped, it is the draft-skipping repository, and only a lift
3002        // can show what runs.
3003        let unseen = declared == Declared::Unknown && standing.is_empty();
3004        let grace_elapsed = watcher.started.elapsed() >= grace;
3005        let about = reading.reported.checks();
3006        if unseen
3007            && grace_elapsed
3008            && !about.is_empty()
3009            && about
3010                .iter()
3011                .all(|check| check.state() != CheckState::Skipped)
3012        {
3013            watcher.record_settled(stream, Vec::new(), marked);
3014            return Ok(true);
3015        }
3016        if grace_elapsed && !running && (!not_run.is_empty() || unseen) {
3017            let snapshot = reading.reported.checks().to_vec();
3018            lift_early(watcher, stream, &not_run, grace)?;
3019            return settle_after_early_lift(watcher, stream, &declared, marked, &snapshot, grace)
3020                .map(|()| false);
3021        }
3022        if watcher.out_of_time() {
3023            return Err(watcher.unsettled(&reading, "settled its required checks on"));
3024        }
3025        watcher.pause()?;
3026    }
3027}
3028
3029/// Lift a draft whose required checks had not run on it, and say so.
3030fn lift_early(
3031    watcher: &mut Watcher<'_>,
3032    stream: &mut Stream,
3033    awaited: &[String],
3034    grace: std::time::Duration,
3035) -> Result<()> {
3036    let change = watcher.change;
3037    lift_draft(watcher.host, change, stream)?;
3038    let warned = watcher.warn_on_early_lift;
3039    if warned {
3040        eprintln!(
3041            "onevcs: warning: {url} was lifted out of its draft before its required checks ran \
3042             on it ({awaited} had not run within {seconds}s), which is what a workflow that \
3043             skips drafts looks like. It is watched as a ready change from here; set \
3044             `drafts: {{warn_on_early_lift: false}}` in the rules file to silence this",
3045            url = change.url,
3046            awaited = if awaited.is_empty() {
3047                "the host named none".to_owned()
3048            } else {
3049                guidance::listed(awaited)
3050            },
3051            seconds = grace.as_secs_f64(),
3052        );
3053    }
3054    stream.emit(
3055        EventKind::DraftLiftedEarly,
3056        object(json!({
3057            "url": change.url.to_string(),
3058            "id": change.id.0,
3059            "base": change.base,
3060            "awaited": awaited,
3061            "grace_seconds": grace.as_secs_f64(),
3062            "warned": warned,
3063        })),
3064    );
3065    Ok(())
3066}
3067
3068/// Whether `check` is a run the host attached after the lift, rather than the run of
3069/// that check it reported on the draft — `snapshot`.
3070///
3071/// Decided by the run's own identity as the host reports it, never by what it
3072/// concluded or where it stands: a re-run a workflow starts on `ready_for_review` can
3073/// conclude `skipped` exactly as the draft's run did, and it is still a run. So a check
3074/// the draft never reported is new, and one it did report is new only where the host
3075/// reports a start for it that no draft-era run of that check had — running or settled
3076/// alike. One reporting no start, or the draft's own start, is the draft's — which is
3077/// the side a wrong answer must fall on, since a draft-era skip read as a post-lift one
3078/// is a merge nothing verified. A re-run queued without a start yet therefore counts
3079/// once the host says it has started.
3080fn ran_after_the_lift(check: &Check, snapshot: &[Check]) -> bool {
3081    let earlier: Vec<&Check> = snapshot
3082        .iter()
3083        .filter(|seen| seen.name == check.name)
3084        .collect();
3085    if earlier.is_empty() {
3086        return true;
3087    }
3088    check.started_at.as_ref().is_some_and(|started| {
3089        earlier
3090            .iter()
3091            .all(|seen| seen.started_at.as_ref() != Some(started))
3092    })
3093}
3094
3095/// Watch a change lifted early until a run the host attached **after** the lift
3096/// settles its required checks.
3097///
3098/// What was reported while it was a draft is `snapshot`, and a draft-era `skipped` in
3099/// it is not a verdict: nothing ran. So the watch needs a run the host attached since
3100/// — told from the draft's by [`ran_after_the_lift`] — and where none of the required
3101/// checks has one within another grace window, it ends [`Error::ChecksUnsettled`]
3102/// saying they did not re-run — never merging, and never lifting anything, on skips
3103/// from the draft. Once one has, the ordinary rule applies, under which a skipped
3104/// required check from a post-lift run satisfies the watch and is recorded as
3105/// `passed-with-skipped`.
3106fn settle_after_early_lift(
3107    watcher: &mut Watcher<'_>,
3108    stream: &mut Stream,
3109    declared: &Declared,
3110    unread: Option<&str>,
3111    snapshot: &[Check],
3112    grace: std::time::Duration,
3113) -> Result<()> {
3114    let lifted = std::time::Instant::now();
3115    let from_the_draft = |check: &Check| !ran_after_the_lift(check, snapshot);
3116    loop {
3117        let reading = watcher.read(stream)?;
3118        let checks = reading.reported.checks();
3119        // A draft-era skip is not a verdict, so it is as though it were not there.
3120        let counted: Vec<&Check> = checks
3121            .iter()
3122            .filter(|check| !(from_the_draft(check) && check.state() == CheckState::Skipped))
3123            .collect();
3124        let standing = standings(&counted, declared);
3125        if let Some(failed) = red(&standing) {
3126            return Err(watcher.failed(failed));
3127        }
3128        let rerun = checks.iter().any(|check| {
3129            !from_the_draft(check)
3130                && (standing.is_empty() || standing.iter().any(|s| s.name == check.name))
3131        });
3132        if rerun
3133            && standing.iter().all(|standing| {
3134                matches!(
3135                    standing.state,
3136                    Some(CheckState::Passed | CheckState::Skipped)
3137                )
3138            })
3139        {
3140            watcher.record_settled(stream, skipped_in(&standing), unread);
3141            return Ok(());
3142        }
3143        if !rerun && lifted.elapsed() >= grace {
3144            return Err(Error::ChecksUnsettled {
3145                reason: format!(
3146                    "the required checks on {url} did not re-run after its draft was lifted: no \
3147                     run the host attached to {commit} after the lift appeared within {seconds}s, \
3148                     and the runs from while it was a draft were skipped, which is not a verdict. \
3149                     The likely cause is a workflow that does not trigger on `ready_for_review` \
3150                     — add it to the workflow's pull_request types, or run the checks by hand",
3151                    url = watcher.change.url,
3152                    commit = watcher.pushed.0,
3153                    seconds = grace.as_secs_f64(),
3154                ),
3155            });
3156        }
3157        if watcher.out_of_time() {
3158            return Err(watcher.unsettled(&reading, "settled its required checks on"));
3159        }
3160        watcher.pause()?;
3161    }
3162}
3163
3164/// Arm the host's own merge and watch until it performs it.
3165///
3166/// Arming happens after the first reading of the checks: a host that will not say
3167/// what its checks are is refused with nothing armed against it. Only required checks
3168/// may end it: a non-blocking check never holds or fails a merge.
3169///
3170/// A change request the host confirms **conflicts** with its base ends it too, as
3171/// [`Error::SyncConflict`], at the first reading that says so: the base moved under the
3172/// armed merge and the host can never perform it, so waiting out the bound would only
3173/// report later what is known now. Its branch and its change request are left as they
3174/// are for the session to resolve. A mergeability the host has not computed yet is
3175/// never read as a conflict.
3176fn watch_the_merge(watcher: &mut Watcher<'_>, stream: &mut Stream) -> Result<Sha> {
3177    let mut armed = false;
3178    loop {
3179        let reading = watcher.read(stream)?;
3180        if let Reported::About(checks) = &reading.reported {
3181            let about: Vec<&Check> = checks.iter().collect();
3182            let standing = standings(&about, &Declared::Unknown);
3183            if let Some(failed) = red(&standing) {
3184                return Err(watcher.failed(failed));
3185            }
3186            if standing.iter().all(|standing| {
3187                matches!(
3188                    standing.state,
3189                    Some(CheckState::Passed | CheckState::Skipped)
3190                )
3191            }) {
3192                watcher.record_settled(stream, skipped_in(&standing), None);
3193            }
3194        }
3195        if !armed {
3196            watcher
3197                .host
3198                .merge(watcher.change, MergePolicy::ChangeAuto)?;
3199            armed = true;
3200        }
3201        if let Some(sha) = watcher.host.merged_at(watcher.change)? {
3202            return Ok(sha);
3203        }
3204        // Asked after the merge, so a change the host landed is never reported as a
3205        // conflict it had before it landed. A host that has not computed it yet is
3206        // waited on like any other "not yet", and one that was never taught to answer
3207        // has said nothing, which is no conflict.
3208        match watcher.host.mergeability(watcher.change) {
3209            Ok(Mergeability::Conflicting { reported }) => {
3210                return Err(watcher.conflicting(&reported))
3211            }
3212            // Every other state, any added later included, is not a confirmed conflict.
3213            Ok(_) | Err(Error::NotImplemented { .. }) => {}
3214            Err(unreadable) => return Err(unreadable),
3215        }
3216        if watcher.out_of_time() {
3217            return Err(watcher.unsettled(&reading, "merged"));
3218        }
3219        watcher.pause()?;
3220    }
3221}
3222
3223/// A required check that concluded red, named, with everywhere its evidence is and
3224/// a bounded excerpt of what it printed.
3225///
3226/// The excerpt is on the failure itself rather than only in the artifact beside it,
3227/// because a caller routing this failure — or a person reading stderr — otherwise
3228/// learns only that a check called `gate` failed and has to go and fetch the log by
3229/// hand to find out why. A log this crate could not read back is simply not quoted:
3230/// an excerpt naming the reason there is no excerpt would read as the check's own
3231/// output.
3232///
3233/// The other two are *pointers*, and they are here because the excerpt is bounded
3234/// and this failure is usually read by whatever gets dispatched next: where the
3235/// check is on the host, so a person can open it, and the artifact this watch
3236/// already stored the whole log as, with the command that prints it. A retry handed
3237/// the word `checks-failed` and nothing else has to rediscover both.
3238fn checks_failed(check: &Check, logs: &[(String, crate::event::ArtifactId)]) -> Error {
3239    let stored = logs.iter().find(|(name, _)| name == &check.name);
3240    let said = stored
3241        .and_then(|(_, id)| stream::read_artifact(&id.0).ok())
3242        .map(|log| excerpt(&log, "the whole log is the check's artifact"))
3243        .filter(|log| !log.trim().is_empty())
3244        .map(|log| format!(" It said:\n{}", guidance::quoted_output(&log)))
3245        .unwrap_or_default();
3246    let at = check
3247        .url
3248        .as_ref()
3249        .map(|url| format!(" It ran at {url}."))
3250        .unwrap_or_default();
3251    let evidence = stored
3252        .map(|(_, id)| {
3253            format!(
3254                " Its whole log is artifact {id} — `{command}`.",
3255                id = id.0,
3256                command = guidance::command(["onevcs", "artifact", "cat", &id.0]),
3257            )
3258        })
3259        .unwrap_or_default();
3260    Error::ChecksFailed {
3261        reason: format!(
3262            "required check {:?} concluded {}.{at}{evidence}{said}",
3263            check.name,
3264            check
3265                .conclusion
3266                .as_deref()
3267                .unwrap_or("without a conclusion"),
3268        ),
3269    }
3270}
3271
3272/// The bound elapsed, naming what the host had not settled.
3273///
3274/// It names them because the alternative is what the bound used to do: stop after
3275/// an hour with a sentence that reads the same whether one check is still running,
3276/// the repository declares none, or the host has simply never landed the change.
3277/// Those are three different next moves.
3278fn unsettled(
3279    change: &ChangeRequest,
3280    pushed: &Sha,
3281    reported: &Reported,
3282    bound: std::time::Duration,
3283    awaited: &str,
3284) -> Error {
3285    let Reported::About(checks) = reported else {
3286        // A fourth ending, and the one that must not read as any of the three below:
3287        // the host answered, and everything it said was about a head this run
3288        // replaced. "Nothing blocks the merge" is what that used to look like.
3289        return Error::ChecksUnsettled {
3290            reason: format!(
3291                "the host had not {awaited} {url} within {seconds}s; it has reported no check at \
3292                 all on {commit}, the commit this publication pushed — every check it does report \
3293                 is attached to some other commit",
3294                url = change.url,
3295                seconds = bound.as_secs_f64(),
3296                commit = pushed.0,
3297            ),
3298        };
3299    };
3300    let required: Vec<&Check> = checks.iter().filter(|check| check.required).collect();
3301    let pending: Vec<&str> = required
3302        .iter()
3303        .filter(|check| check.state() == CheckState::Pending)
3304        .map(|check| check.name.as_str())
3305        .collect();
3306    let no_verdict: Vec<String> = required
3307        .iter()
3308        .filter(|check| check.no_verdict())
3309        .map(|check| {
3310            format!(
3311                "{:?}: {}",
3312                check.name,
3313                check.conclusion.as_deref().unwrap_or("unknown")
3314            )
3315        })
3316        .collect();
3317    let skipped: Vec<&str> = required
3318        .iter()
3319        .filter(|check| check.state() == CheckState::Skipped)
3320        .map(|check| check.name.as_str())
3321        .collect();
3322    let named = if !pending.is_empty() {
3323        let verdicts = if no_verdict.is_empty() {
3324            String::new()
3325        } else {
3326            format!("; completed with no verdict: {}", no_verdict.join(", "))
3327        };
3328        format!("still unsettled: {}{verdicts}", guidance::listed(&pending))
3329    } else if !no_verdict.is_empty() {
3330        format!("completed with no verdict: {}", no_verdict.join(", "))
3331    } else if !skipped.is_empty() {
3332        format!(
3333            "skipped on the draft, so not run on it: {}",
3334            guidance::listed(&skipped)
3335        )
3336    } else if required.is_empty() {
3337        "the host declared no required check on it at all".to_owned()
3338    } else {
3339        "every required check it declared had settled".to_owned()
3340    };
3341    // llmlint: ignore[names_match_behavior] one fixed kind for the bound's three endings.
3342    Error::ChecksUnsettled {
3343        reason: format!(
3344            "the host had not {awaited} {url} within {seconds}s; {named}",
3345            url = change.url,
3346            seconds = bound.as_secs_f64(),
3347        ),
3348    }
3349}
3350
3351/// Record one publishing push, and what it wrote.
3352///
3353/// **Unconditionally**, which is the point of this function. The publishing push is
3354/// where the repository's own `pre-push` hook rules on the change, so what git and
3355/// that hook wrote is the verdict as well as the only account of why a push was
3356/// refused — and it lives in a pipe until the process ends. Preserving it only where
3357/// the rules named `gate: {kind: pre-push}` meant no repository on a host whose
3358/// rules named commands: what a policy calls its verification cannot decide whether
3359/// a failure is diagnosable.
3360///
3361/// Storing it is best effort: the push has already happened, so a state root that
3362/// would not take the bytes says so on stderr rather than turning a push git
3363/// accepted into a publication that failed.
3364///
3365/// Every publishing push in this crate goes through here — the merge train's push of
3366/// its advanced base included, because that push is where the hook rules on a train.
3367/// One producer, so a second one cannot come to emit a thinner `push` event than the
3368/// contract says a push verdict is.
3369///
3370/// `preserve_under` is the run root whose per-branch directory keeps a copy that
3371/// outlives the tree the push was built in, and is `None` for a caller whose scratch
3372/// workspace is removed when it returns — there the stored artifact is what persists,
3373/// and a copy under a directory about to go would only look like evidence.
3374///
3375/// `phase` is which branch this push updated, which is the one thing about a push
3376/// that its own payload cannot say: `branch` here names the work being published,
3377/// and a `local-direct` squash and a merge train both push that work onto the *base*
3378/// under it. So the caller that made the push says whether it was the session's own
3379/// branch — the work being made, [`Phase::Development`] — or another, which is that
3380/// work being integrated.
3381pub(crate) fn record_push(
3382    stream: &mut Stream,
3383    branch: &Ref,
3384    pushed: &git::Pushed,
3385    preserve_under: Option<&Path>,
3386    phase: Phase,
3387    head: Option<&str>,
3388) -> Result<Kept> {
3389    let output = pushed.output();
3390    let stored = stream::store_artifact("log", output);
3391    let kept = preserve_under.map(|root| merge_path::preserve_log(root, branch, output));
3392    for error in [
3393        stored.as_ref().err(),
3394        kept.as_ref().and_then(|kept| kept.as_ref().err()),
3395    ]
3396    .into_iter()
3397    .flatten()
3398    {
3399        eprintln!(
3400            "onevcs: warning: the push of {branch:?} is recorded without what it wrote: {error}"
3401        );
3402    }
3403    let artifact = stored.ok();
3404    let mut payload = object(json!({
3405        "branch": branch,
3406        "remote": "origin",
3407        "accepted": pushed.accepted(),
3408        "output": output,
3409    }));
3410    if let Some(Ok(preserved)) = &kept {
3411        payload.insert(
3412            "preserved_log".to_owned(),
3413            json!(preserved.display().to_string()),
3414        );
3415    }
3416    // The commit a push of the branch's own name put there, which is the head a change
3417    // request opened from it carries — and what a retirement later asks the branch's
3418    // content to be contained in. Only the push of the branch itself names one: a push
3419    // of the base under it lands a commit no change request is opened from.
3420    if let Some(head) = head {
3421        payload.insert("head".to_owned(), json!(head));
3422    }
3423    let evidence = Kept {
3424        artifact: artifact
3425            .as_ref()
3426            .map(|stored| crate::event::ArtifactId(stored.id.clone())),
3427        preserved: match kept {
3428            Some(Ok(preserved)) => Some(preserved),
3429            Some(Err(_)) | None => None,
3430        },
3431    };
3432    stream.emit_push(phase, payload, artifact.into_iter().collect());
3433    Ok(evidence)
3434}
3435
3436/// Where a recorded push's output can be read once the push is over.
3437///
3438/// Handed back rather than only written, because the refusal that reports a push
3439/// the merge path turned down is read by whoever has to diagnose it — and until it
3440/// named these, an operator was told three lines of git's generic message while the
3441/// whole verification sat in two places nothing pointed at. An hour went on
3442/// rediscovering four redundant comment lines that way.
3443///
3444/// Either may be absent and both may be: the artifact is missing where the state
3445/// root would not take the bytes, and the preserved copy where the caller had no
3446/// run root to outlive its tree or the write failed. A refusal that has neither
3447/// must say there is nothing to read rather than name a place that holds nothing.
3448pub(crate) struct Kept {
3449    /// The artifact the whole output was stored as, fetched with `onevcs artifact
3450    /// cat`.
3451    artifact: Option<crate::event::ArtifactId>,
3452    /// The file under the run root that outlives the tree the push was built in.
3453    preserved: Option<PathBuf>,
3454}
3455
3456impl Kept {
3457    /// Where the whole of what the push wrote can be read, as a refusal says it.
3458    ///
3459    /// Both when both are there, because they answer different questions: the
3460    /// artifact survives the run root being reaped and is fetched through this
3461    /// crate's own CLI, and the preserved file is a path an operator can open
3462    /// directly, beside every other attempt on the same branch.
3463    fn evidence(&self) -> String {
3464        let artifact = self.artifact.as_ref().map(|id| {
3465            format!(
3466                "artifact {id} — `{command}`",
3467                id = id.0,
3468                command = guidance::command(["onevcs", "artifact", "cat", &id.0]),
3469            )
3470        });
3471        let preserved = self
3472            .preserved
3473            .as_ref()
3474            .map(|path| format!("preserved at {}", path.display()));
3475        match (artifact, preserved) {
3476            (Some(artifact), Some(preserved)) => {
3477                format!(" Its whole output is {artifact}, and {preserved}.")
3478            }
3479            (Some(one), None) | (None, Some(one)) => format!(" Its whole output is {one}."),
3480            // Both stores refused the bytes, and each said why on stderr as it
3481            // happened. Naming neither is the point: a path that holds nothing sends
3482            // an operator to look at it.
3483            (None, None) => String::new(),
3484        }
3485    }
3486
3487    /// Whether the output was kept anywhere at all.
3488    fn anywhere(&self) -> bool {
3489        self.artifact.is_some() || self.preserved.is_some()
3490    }
3491}
3492
3493/// The publication checkout must be clean and on its root branch before anything
3494/// is published onto it; a safety clone never makes an arbitrary active branch the
3495/// fast-forward target.
3496fn require_publication_checkout_ready(publication: &Path, base: &str) -> Result<()> {
3497    let current = git::current_branch(publication)?;
3498    if current != base {
3499        return Err(Error::Invalid {
3500            reason: format!(
3501                "the publication checkout {} has {current:?} checked out, not the base {base:?}; \
3502                 it is never worked in and only ever fast-forwarded",
3503                publication.display()
3504            ),
3505        });
3506    }
3507    if git::is_dirty(publication)? {
3508        return Err(Error::Invalid {
3509            reason: format!(
3510                "the publication checkout {} is dirty; it is never worked in",
3511                publication.display()
3512            ),
3513        });
3514    }
3515    Ok(())
3516}
3517
3518/// Advance the publication checkout, and only ever forwards.
3519pub fn fast_forward_publication(publication: &Path, base: &str) -> Result<()> {
3520    if !git::has_remote(publication, "origin") {
3521        return Ok(());
3522    }
3523    git::fetch(publication, "origin")?;
3524    if git::current_branch(publication)? != base {
3525        return Ok(());
3526    }
3527    git::merge_ff_only(publication, &format!("origin/{base}"))
3528}
3529
3530/// The host slug a change request is opened against, or the reason there is none.
3531///
3532/// Two different failures, deliberately not collapsed: a local identity has no
3533/// host at all and is asking for the wrong policy, while a hosted identity on a
3534/// host this build does not speak for is asking for an implementation that has not
3535/// arrived. The second is the seam `Error::NotImplemented` exists for.
3536pub(crate) fn change_host(identity: &str) -> Result<String> {
3537    if let Some(slug) = gh::slug(identity) {
3538        return Ok(slug);
3539    }
3540    let hosted = identity.split('/').count() == 3;
3541    if hosted {
3542        return Err(Error::NotImplemented {
3543            operation: "RemoteHost for a host other than github.com",
3544        });
3545    }
3546    Err(Error::Invalid {
3547        reason: format!(
3548            "identity {identity:?} is not a hosted repository, so it cannot publish a change \
3549             request; a local identity publishes with local-direct"
3550        ),
3551    })
3552}
3553
3554/// One publication commit message: the subject, then whatever it must carry
3555/// forward.
3556pub fn compose_message(subject: &str, trailers: &[String]) -> String {
3557    if trailers.is_empty() {
3558        subject.to_owned()
3559    } else {
3560        format!("{subject}\n\n{}", trailers.join("\n"))
3561    }
3562}
3563
3564/// The issue-closing lines the commits a squash lands carry, each once.
3565///
3566/// Read from the branch's own commits past what it lands on, which after the sync is
3567/// exactly the set the squash folds into one; see [`closing`](crate::closing) for why
3568/// these lines, and only these, outlive the squash.
3569fn closing_lines(context: &Context<'_>, compared: &str) -> Result<Vec<String>> {
3570    let commits = git::log_messages(&context.repo, compared, &context.branch)?;
3571    Ok(crate::closing::lines(
3572        commits.iter().map(|commit| commit.message.as_str()),
3573    ))
3574}
3575
3576/// A squash's message: the subject, the issue-closing lines its commits carried, then
3577/// the trailers — so a branch with no closing lines lands exactly as
3578/// [`compose_message`] composes it.
3579fn compose_squash(subject: &str, closing: &[String], trailers: &[String]) -> String {
3580    if closing.is_empty() {
3581        return compose_message(subject, trailers);
3582    }
3583    compose_message(&format!("{subject}\n\n{}", closing.join("\n")), trailers)
3584}
3585
3586/// The policy a run publishes under, once the rules and any `--policy` have both
3587/// had their say.
3588pub fn effective_policy(resolved: &Policy, requested: Option<MergePolicy>) -> Result<MergePolicy> {
3589    match requested {
3590        Some(requested) => policy::narrow(resolved, requested),
3591        None => Ok(resolved.publication),
3592    }
3593}
3594
3595/// The exit code an error reports, as the contract fixes them.
3596pub fn exit_code(error: &Error) -> u8 {
3597    FailureKind::of(error).exit_code()
3598}
3599
3600/// A session's own record, once publication has decided what it publishes onto.
3601pub fn preserved_change_base(record_base: &Ref, recorded: Option<&Ref>) -> Ref {
3602    recorded.unwrap_or(record_base).clone()
3603}
3604
3605/// A change request this host opened, stopped watching, and may meet again.
3606///
3607/// Everything the question below is asked from, read out of the records this host
3608/// already holds rather than derived a second way: the event stream's own
3609/// `change-opened`, and the branch as some copy of it here stands. One value rather
3610/// than seven arguments, for the reason [`Advance`](crate::status) is one.
3611pub(crate) struct Watched<'a> {
3612    /// The identity the work belongs to.
3613    pub identity: &'a str,
3614    /// The branch that carries it.
3615    pub branch: &'a str,
3616    /// Where a human reads the change request, as `change-opened` recorded it.
3617    pub url: &'a Url,
3618    /// The host's own identifier for it, as `change-opened` recorded it.
3619    pub id: &'a str,
3620    /// The branch it targets, as `change-opened` recorded it.
3621    pub base: &'a str,
3622    /// The commit this host last saw the branch at, which is the head the change
3623    /// request was opened from.
3624    pub head: &'a str,
3625    /// The stream that recorded it being opened, which is where a landing found
3626    /// after that publication ended is recorded.
3627    pub stream: Option<&'a str>,
3628}
3629
3630/// Ask the host **once** whether a change request this host stopped watching has
3631/// merged since, and record the landing where it has.
3632///
3633/// A `change-auto` publication watches the host for a fixed span and closes
3634/// [`ChecksUnsettled`](FailureKind::ChecksUnsettled) when that span runs out. The
3635/// merge often happens seconds later, and nothing afterwards asks: the landing tiers
3636/// read stored records and the base's history, a `status` deliberately never fetches,
3637/// and the host is asked only which change requests are *open* — where a merged one
3638/// is simply absent. So a change request merged five seconds after its last required
3639/// check went on reading `closed without landing`, and the next attempt republished
3640/// work the base already carried.
3641///
3642/// The question is asked of exactly one state and asked once per read: a change
3643/// request this host recorded, which it asked the host to land, and for which no
3644/// landing is recorded — which is what a `checks-unsettled` close leaves behind.
3645/// [`RemoteHost::merged_at`] answers it, and its three answers are three outcomes: a
3646/// merge commit is recorded as the landing, `None` and every refusal leave the record
3647/// exactly as it stands. A change request the host reports open or closed-unmerged is
3648/// the `None`.
3649///
3650/// What a recorded landing then gets is what a witnessed one gets, through the one
3651/// function that gives it: the publication checkout fast-forwarded, each automated
3652/// target's baseline captured at the landing commit, and the merge recorded on the
3653/// stream that opened the change. The caller recomputes from that record, which is
3654/// what puts the landing into the same read rather than into the next one.
3655pub(crate) fn reconcile_late_merge(
3656    registry: &crate::registry::Registry,
3657    watched: &Watched<'_>,
3658    hosting: &dyn Hosting,
3659) -> Option<String> {
3660    let host = hosting
3661        .for_repo(&change_host(watched.identity).ok()?)
3662        .ok()?;
3663    let merged = host
3664        .merged_at(&ChangeRequest {
3665            id: crate::host::ChangeId(watched.id.to_owned()),
3666            url: watched.url.clone(),
3667            head_sha: Sha(watched.head.to_owned()),
3668            base: watched.base.to_owned(),
3669        })
3670        .ok()??;
3671    record_late_merge(registry, watched, &merged)
3672}
3673
3674/// Record a merge the host reported for a change request this host stopped watching,
3675/// the way [`reconcile_late_merge`] records the one it finds — for a caller that asked
3676/// the host itself, because it has to tell a refusal from "not merged". Answers the
3677/// landing where it was recorded, and `None` where it could not be, for the reason
3678/// [`warn_unreconciled`] gives.
3679pub(crate) fn record_late_merge(
3680    registry: &crate::registry::Registry,
3681    watched: &Watched<'_>,
3682    merged: &Sha,
3683) -> Option<String> {
3684    let located = match crate::release::for_repository(registry, watched.identity) {
3685        Ok(located) => located,
3686        // Best effort throughout, as every part of recording a landing after the
3687        // fact is: the change has merged whatever this host can write about it, and
3688        // refusing the read that found it would be the worse answer.
3689        Err(failure) => return warn_unreconciled(watched, &merged.0, &failure),
3690    };
3691    let mut stream = match Stream::releases(watched.identity) {
3692        Ok(stream) => stream,
3693        Err(failure) => return warn_unreconciled(watched, &merged.0, &failure),
3694    };
3695    crate::release::reconcile_landing(
3696        registry,
3697        &located,
3698        watched.branch,
3699        watched.stream,
3700        &merged.0,
3701        watched.url,
3702        &mut stream,
3703    );
3704    Some(merged.0.clone())
3705}
3706
3707/// Say on stderr that a merge the host reported could not be recorded here, and
3708/// answer as though it had not been found.
3709///
3710/// Answering `None` is the point: the caller goes on to decide the landing from the
3711/// records as they stand, which is what they still say. Reporting the merge without
3712/// having recorded it would leave a read answering `landed` that the next read,
3713/// reading the same records, answers `closed` — one landing, two answers.
3714fn warn_unreconciled(
3715    watched: &Watched<'_>,
3716    commit: &str,
3717    failure: &dyn std::fmt::Display,
3718) -> Option<String> {
3719    eprintln!(
3720        "onevcs: warning: the host reports {url} merged at {commit}, and this host could not \
3721         record that landing for {branch} ({failure}); `onevcs status {branch}` still answers \
3722         from what is recorded",
3723        url = watched.url,
3724        branch = watched.branch,
3725    );
3726    None
3727}
3728
3729/// Take up a merge the host performed after a publication of this branch stopped
3730/// watching for it, before this one decides what there is to publish.
3731///
3732/// The third of the three reads that meet such a change request, and it asks through
3733/// the *same* path the other two ask through — `status`'s landing read — so a
3734/// publication and a status cannot come to two answers about one change request.
3735/// What that read does with a merge the host reports is recorded on
3736/// [`reconcile_late_merge`]: the landing is written, the publication checkout is
3737/// fast-forwarded, and each automated target's baseline is captured at the merge
3738/// commit. The fetch below then brings the merge into this publication's own
3739/// comparison, which is what answers `nothing to publish` instead of opening a
3740/// second change request for work already on the base.
3741///
3742/// Best effort, and deliberately silent about its own failures: everything it could
3743/// establish is established again by the comparison this publication is about to
3744/// make, and a publication refused because a question about the host could not be
3745/// asked would be a worse answer than one that simply asks nobody.
3746fn take_up_a_late_merge(context: &Context<'_>) {
3747    let Ok(registry) = store::load() else {
3748        return;
3749    };
3750    let _ = crate::status::landing_of_within(
3751        &registry,
3752        &context.branch,
3753        Some(&context.resolution.key),
3754        Some(context.hosting),
3755    );
3756}