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(®istry, &record.identity)?;
636 let (file, source) = policy::load(®istry)?;
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(®istry, &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, ¬_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 ®istry,
3752 &context.branch,
3753 Some(&context.resolution.key),
3754 Some(context.hosting),
3755 );
3756}