use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::error::{Error, Result};
use crate::event::{EventKind, Phase};
use crate::gate_run::{self, Gate, Moment};
use url::Url;
use std::collections::BTreeSet;
use crate::host::{
ChangeRequest, ChangeSpec, Check, CheckState, Hosting, MergeOutcome, Mergeability, RemoteHost,
Sha,
};
use crate::releases::TargetName;
use crate::rules::{MergePolicy, Policy};
use crate::session::{Lifecycle, Provenance, SessionToken};
use crate::store::Resolution;
use crate::stream::{self, Stream};
use crate::workspace::{object, Ref};
use crate::{
gh, git, guidance, home, ids, lock, merge_path, policy, provenance, queue, store, vcs,
workspace,
};
pub const SYNC_ATTEMPTS: usize = 3;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublishRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy: Option<MergePolicy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<Subject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft: Option<DraftReason>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum DraftReason {
AwaitingRelease {
awaiting: String,
target: TargetName,
reference: String,
because: String,
},
Held {
because: String,
},
}
impl DraftReason {
pub fn checked(&self) -> Result<()> {
let printed: Vec<(&str, &String)> = match self {
DraftReason::AwaitingRelease {
awaiting,
reference,
because,
..
} => vec![
("the repository whose release is awaited", awaiting),
("the reference the change is pinned to", reference),
("the reason the change is not ready", because),
],
DraftReason::Held { because } => {
vec![("the reason the session is holding the change", because)]
}
};
for (what, value) in printed {
if value.is_empty() {
return Err(crate::error::invalid(format!(
"a draft change request must say {what}, and this one names none: a draft \
whose reason cannot be read is a change request nobody knows how to finish"
)));
}
if value.chars().any(char::is_control) {
return Err(crate::error::invalid(format!(
"{what} is {value:?}, which carries a control character: every field of a \
draft's reason is printed on one line, in a refusal and in the publication \
record, and a value that renders as something other than itself is not one"
)));
}
}
Ok(())
}
pub(crate) fn because(&self) -> &str {
match self {
DraftReason::AwaitingRelease { because, .. } | DraftReason::Held { because } => because,
}
}
pub(crate) fn kind(&self) -> String {
self.fields()["kind"]
.as_str()
.expect("a tagged enum serializes its kind as a string")
.to_owned()
}
fn fields(&self) -> serde_json::Value {
serde_json::to_value(self).expect("a draft reason serializes: strings and a name")
}
pub(crate) fn asked_for(&self) -> String {
match self {
DraftReason::AwaitingRelease {
awaiting, target, ..
} => format!("a draft awaiting {awaiting} {target}"),
DraftReason::Held { because } => {
format!("a draft held by the session that opened it ({because})")
}
}
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Subject(String);
impl std::fmt::Debug for Subject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Debug::fmt(&self.0, f)
}
}
impl TryFrom<String> for Subject {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, String> {
provenance::checked_subject(&value).map(Subject)
}
}
impl std::str::FromStr for Subject {
type Err = String;
fn from_str(value: &str) -> std::result::Result<Self, String> {
Subject::try_from(value.to_owned())
}
}
impl From<Subject> for String {
fn from(subject: Subject) -> Self {
subject.0
}
}
impl std::ops::Deref for Subject {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Subject {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Publication {
pub session: SessionToken,
pub branch: String,
pub policy: MergePolicy,
pub outcome: PublishOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PublishOutcome {
Merged(Sha),
ChangeOpen(Url),
ChangeDraft(Url),
ChangeReviewDraft(Url),
Queued(Url),
NothingToPublish,
Failed {
kind: FailureKind,
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
retained: Option<Retention>,
},
}
impl PublishOutcome {
#[must_use]
pub fn describe(&self) -> String {
match self {
PublishOutcome::Merged(sha) => format!("merged at {}", sha.0),
PublishOutcome::ChangeOpen(url) => format!("change request open at {url}"),
PublishOutcome::ChangeDraft(url) => {
format!(
"change request open as a draft at {url}, which cannot land while it is one"
)
}
PublishOutcome::ChangeReviewDraft(url) => format!(
"change request's checks are green at {url}, and it is kept as a draft for its \
user's review"
),
PublishOutcome::Queued(url) => format!("merge queued for {url}"),
PublishOutcome::NothingToPublish => {
"nothing to publish: the base already carries this branch's content".to_owned()
}
PublishOutcome::Failed { reason, .. } => reason.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FailureKind {
Gate,
Invalid,
SyncConflict,
NotImplemented,
ChecksFailed,
ChecksUnsettled,
PushRejected,
PushedUnverified,
HostPrerequisite,
Cancelled,
}
impl FailureKind {
#[must_use]
pub fn exit_code(self) -> u8 {
match self {
FailureKind::Gate
| FailureKind::ChecksFailed
| FailureKind::ChecksUnsettled
| FailureKind::PushRejected
| FailureKind::PushedUnverified
| FailureKind::HostPrerequisite => 1,
FailureKind::Invalid => 2,
FailureKind::SyncConflict => 3,
FailureKind::NotImplemented => 70,
FailureKind::Cancelled => 75,
}
}
#[must_use]
pub fn of(error: &Error) -> Self {
match error {
Error::GateFailed { .. } => FailureKind::Gate,
Error::SyncConflict { .. } => FailureKind::SyncConflict,
Error::NotImplemented { .. } => FailureKind::NotImplemented,
Error::ChecksFailed { .. } => FailureKind::ChecksFailed,
Error::ChecksUnsettled { .. } => FailureKind::ChecksUnsettled,
Error::PushRejected { .. } => FailureKind::PushRejected,
Error::PushedUnverified { .. } => FailureKind::PushedUnverified,
Error::HostPrerequisite { .. } => FailureKind::HostPrerequisite,
Error::Cancelled { .. } => FailureKind::Cancelled,
_ => FailureKind::Invalid,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Retention {
HandedBack(PathBuf),
Refused(PathBuf),
}
pub trait PublicationCancellation {
fn is_cancelled(&self) -> bool;
}
pub(crate) struct NeverCancelled;
impl PublicationCancellation for NeverCancelled {
fn is_cancelled(&self) -> bool {
false
}
}
pub(crate) const CANCELLATION_GRAIN: std::time::Duration = std::time::Duration::from_millis(100);
pub(crate) fn pause_unless_cancelled(
span: std::time::Duration,
cancellation: &dyn PublicationCancellation,
cancelled: impl Fn() -> Error,
) -> Result<()> {
let until = std::time::Instant::now() + span;
loop {
if cancellation.is_cancelled() {
return Err(cancelled());
}
let left = until.saturating_duration_since(std::time::Instant::now());
if left.is_zero() {
return Ok(());
}
std::thread::sleep(left.min(CANCELLATION_GRAIN));
}
}
pub(crate) fn cancelled_while(doing: &str) -> Error {
Error::Cancelled {
reason: format!(
"the caller cancelled the publication while {doing}. Nothing was undone: the \
branch stays where it is, any change request stays open, and publishing the \
session again continues it"
),
}
}
pub fn run_for_session(
token: &SessionToken,
request: &PublishRequest,
hosting: &dyn Hosting,
cancellation: &dyn PublicationCancellation,
) -> Result<Publication> {
let record = workspace::load(&token.0)?;
vcs::refuse_unfinished_merge(
&record.worktree,
&record.branch,
&format!(
"publish it again with `{}`",
crate::guidance::command(["onevcs", "publish", &record.token])
),
)?;
let registry = store::load()?;
let resolution = store::resolve(®istry, &record.identity)?;
let (file, source) = policy::load(®istry)?;
let normalized = store::normalize(&resolution.identity.origin);
let resolved = policy::resolve(&file, &source, &normalized, &resolution.publication);
let effective = effective_policy(&resolved.policy, request.policy)?;
let mut stream = Stream::open(&record.token)?;
stream.label("identity", &record.identity);
if git::is_dirty(&record.worktree)? {
vcs::preserve_into(&record, &mut stream, Provenance::Complete)?;
}
let target = recorded_target(&record, &resolution.publication)?;
let context = Context {
resolution,
policy: resolved.policy.clone(),
effective,
repo: record.clone.clone(),
worktree: record.worktree.clone(),
branch: record.branch.clone(),
target,
push: Push::Forward,
run_root: record.run_root.clone(),
preserved_into: record.execution_checkout.clone(),
title: request.title.clone(),
body: request.body.clone(),
draft: request.draft.clone(),
drafts: resolved.drafts.clone(),
trailers: Vec::new(),
provenance: provenance::from_rules(&file),
hosting,
cancellation,
built: Built::InWorkspace,
};
let branch = record.branch.to_string();
let outcome = match run_resolving(&context, &mut stream) {
Ok((outcome, landed)) => {
let moved = landed != context.target;
let still_working = matches!(outcome, PublishOutcome::ChangeDraft(_))
&& matches!(context.draft, Some(DraftReason::Held { .. }));
if !still_working || moved {
workspace::update(&record.token, |stored| {
if moved {
stored.change_base = Some(landed.base().clone());
stored.stack_tip = None;
}
if !still_working {
stored.state = Lifecycle::Closed;
}
})?;
}
outcome
}
Err(error) => {
let checkout = record.execution_checkout.clone();
let retained = match git::copy_branch(&record.clone, &checkout, &record.branch) {
Ok(true) => Retention::HandedBack(checkout),
_ => Retention::Refused(checkout),
};
PublishOutcome::Failed {
kind: FailureKind::of(&error),
reason: error.to_string(),
retained: Some(retained),
}
}
};
Ok(Publication {
session: token.clone(),
branch,
policy: effective,
outcome,
})
}
pub struct Context<'a> {
pub resolution: Resolution,
pub policy: Policy,
pub effective: MergePolicy,
pub repo: PathBuf,
pub worktree: PathBuf,
pub branch: Ref,
pub target: Target,
pub push: Push,
pub run_root: PathBuf,
pub preserved_into: PathBuf,
pub title: Option<Subject>,
pub body: Option<String>,
pub draft: Option<DraftReason>,
pub drafts: policy::DraftLifecycle,
pub trailers: Vec<String>,
pub provenance: provenance::Trailers,
pub hosting: &'a dyn Hosting,
pub cancellation: &'a dyn PublicationCancellation,
pub built: Built,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Built {
InWorkspace,
Resumed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Push {
Forward,
Replacing {
replaced: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Target {
Base(Ref),
Stacked {
below: Ref,
root: Ref,
tip: String,
},
}
impl Target {
pub fn base(&self) -> &Ref {
match self {
Target::Base(base) => base,
Target::Stacked { below, .. } => below,
}
}
}
fn recorded_target(record: &workspace::Record, publication: &Path) -> Result<Target> {
let base = preserved_change_base(&record.base, record.change_base.as_ref());
let Some(recorded) = record.stack_tip.as_deref() else {
return Ok(Target::Base(base));
};
let Some(tip) = git::tip(&record.clone, recorded) else {
return Err(Error::Invalid {
reason: format!(
"the record for session {token} names {recorded:?} as the commit branch {branch:?} \
was cut from, and the clone at {clone} does not have it, so nothing can tell \
which of that branch's commits belong to the change below it. The branch is \
whole — publish it by name with `{command}`, which reads the stack off the \
branch instead",
token = record.token,
branch = record.branch,
clone = record.clone.display(),
command = guidance::command([
"onevcs",
"publish-branch",
&record.branch,
"--repo",
&record.publication_checkout.to_string_lossy(),
]),
),
});
};
let Some(root) = git::default_branch(publication, "origin").ok() else {
return Ok(Target::Base(base));
};
let root = Ref::from_git(root);
if root == base {
return Ok(Target::Base(base));
}
Ok(Target::Stacked {
below: base,
root,
tip,
})
}
pub(crate) fn root_is_known_to_carry_the_stack(
repo: &Path,
branch: &str,
root: &Ref,
tip: &str,
) -> Result<bool> {
let root = vcs::base_ref(repo, root);
if !git::ref_exists(repo, &format!("refs/remotes/{root}")) && !git::branch_exists(repo, &root) {
return Ok(false);
}
if !git::is_ancestor(repo, tip, branch)? {
return Ok(false);
}
if git::is_ancestor(repo, tip, &root)? {
return Ok(false);
}
let Some(fork) = git::merge_base(repo, &root, tip)? else {
return Ok(false);
};
git::known_to_carry_changes(repo, &root, &fork, tip)
}
pub(crate) fn landed_target(
repo: &Path,
branch: &Ref,
target: &Target,
) -> Result<(Target, Option<String>)> {
if let Target::Stacked { root, tip, .. } = target {
if root_is_known_to_carry_the_stack(repo, branch, root, tip)? {
return Ok((Target::Base(root.clone()), Some(tip.clone())));
}
}
Ok((target.clone(), None))
}
pub(crate) fn session_target(record: &workspace::Record) -> Result<Target> {
if git::has_remote(&record.clone, "origin") {
git::fetch(&record.clone, "origin")?;
}
standing_target(record)
}
pub(crate) fn standing_target(record: &workspace::Record) -> Result<Target> {
let recorded = recorded_target(record, &record.publication_checkout)?;
let (target, _replay) = landed_target(&record.clone, &record.branch, &recorded)?;
Ok(target)
}
impl<'a> Context<'a> {
fn onto(&self, target: Target) -> Context<'a> {
Context {
resolution: self.resolution.clone(),
policy: self.policy.clone(),
effective: self.effective,
repo: self.repo.clone(),
worktree: self.worktree.clone(),
branch: self.branch.clone(),
target,
push: self.push.clone(),
run_root: self.run_root.clone(),
preserved_into: self.preserved_into.clone(),
title: self.title.clone(),
body: self.body.clone(),
draft: self.draft.clone(),
drafts: self.drafts.clone(),
trailers: self.trailers.clone(),
provenance: self.provenance.clone(),
hosting: self.hosting,
cancellation: self.cancellation,
built: self.built,
}
}
}
pub fn run(context: &Context<'_>, stream: &mut Stream) -> Result<PublishOutcome> {
run_resolving(context, stream).map(|(outcome, _)| outcome)
}
fn run_resolving(context: &Context<'_>, stream: &mut Stream) -> Result<(PublishOutcome, Target)> {
if let Some(reason) = &context.draft {
reason.checked()?;
if context.effective == MergePolicy::LocalDirect {
return Err(crate::error::invalid(format!(
"{branch:?} was asked to publish as {asked}, and this identity publishes with \
local-direct, which squashes the branch onto {base:?} and opens no change \
request at all — so there is nothing to draft and {lands}. Publish it under a \
change-* policy, or publish it without a draft",
branch = context.branch,
asked = reason.asked_for(),
base = context.target.base(),
lands = match reason {
DraftReason::AwaitingRelease { .. } => {
"the work would land with the very pin the draft exists to hold back"
}
DraftReason::Held { .. } => {
"the work would land while the session is still making it"
}
},
)));
}
}
take_up_a_late_merge(context);
stream.begin_attempt();
let last_seen = git::tip(&context.repo, &format!("origin/{}", context.branch));
if git::has_remote(&context.repo, "origin") {
git::fetch(&context.repo, "origin")?;
stream.emit(
EventKind::Fetch,
object(json!({"remote": "origin", "checkout": context.repo.display().to_string()})),
);
}
let (target, replay) = landed_target(&context.repo, &context.branch, &context.target)?;
let landed;
let mut context = context;
if replay.is_some() {
landed = context.onto(target.clone());
context = &landed;
}
let remote_base = format!("origin/{}", context.target.base());
let compared = if git::ref_exists(&context.repo, &format!("refs/remotes/{remote_base}")) {
remote_base.clone()
} else {
context.target.base().to_string()
};
sync(context, stream, &compared, replay.as_deref())?;
if nothing_to_publish(context, &compared)? {
return Ok((PublishOutcome::NothingToPublish, target));
}
let push = match (&replay, &last_seen) {
(Some(_), Some(replaced)) => Push::Replacing {
replaced: replaced.clone(),
},
_ => context.push.clone(),
};
let (subject, _) = describe(context, &compared)?;
let environment = merge_path::comparison_env("origin", context.target.base());
let outcome = match context.effective {
MergePolicy::LocalDirect => publish_locally(context, stream, &compared, &environment)?,
_ => publish_as_change(context, stream, &subject, &environment, push)?,
};
Ok((outcome, target))
}
pub(crate) fn subject_for(
repo: &Path,
compared: &str,
branch: &str,
title: Option<&str>,
trailers: &provenance::Trailers,
) -> Result<String> {
let subject = match provenance::publication_subject(repo, compared, branch, title, trailers)? {
Ok(subject) => subject,
Err(reason) => {
return Err(Error::Invalid {
reason: format!(
"cannot publish {branch:?}: {reason}. A subject that names no change would \
make the base branch a worse record than this refusal does."
),
})
}
};
hold_to_repository_policy(repo, branch, &subject)?;
Ok(subject)
}
pub(crate) fn hold_to_repository_policy(repo: &Path, branch: &str, subject: &str) -> Result<()> {
let git::MessagePolicy::Rejected { status, output } = git::message_policy(repo, subject)?
else {
return Ok(());
};
let said = guidance::quoted_output(output.trim());
let said = if said.is_empty() {
"<no output>".to_owned()
} else {
said
};
Err(Error::GateFailed {
reason: format!(
"the repository's own commit-msg hook rejected the subject publishing {branch:?} \
would land, {subject:?} (exit {status}). Reword the commit the subject comes from, \
or publish with an explicit title that satisfies it. The hook said:\n{said}"
),
})
}
fn nothing_to_publish(context: &Context<'_>, compared: &str) -> Result<bool> {
if git::log_messages(&context.repo, compared, &context.branch)?.is_empty() {
return Ok(true);
}
Ok(!git::trees_differ(
&context.repo,
compared,
&context.branch,
)?)
}
fn describe(context: &Context<'_>, compared: &str) -> Result<(String, Vec<String>)> {
let subject = subject_for(
&context.repo,
compared,
&context.branch,
context.title.as_deref(),
&context.provenance,
)?;
let mut trailers = context.trailers.clone();
trailers.extend(provenance::attestation_trailers(
&context.repo,
compared,
&context.branch,
&context.provenance,
)?);
if let Some(tip) = git::tip(&context.repo, &context.branch) {
trailers.push(format!("{} {tip}", context.provenance.landed()));
}
Ok((subject, trailers))
}
pub(crate) enum Reconciled {
Settled,
Conflicted(Reconciliation, git::Conflict),
}
pub(crate) enum Reconciliation {
Merge,
Replay {
from: String,
},
}
pub(crate) fn reconcile(
worktree: &Path,
compared: &str,
branch: &str,
replay_from: Option<&str>,
) -> Result<Reconciled> {
let (shape, integrated) = match replay_from {
Some(tip) => (
Reconciliation::Replay {
from: tip.to_owned(),
},
git::rebase_onto(worktree, compared, tip, branch)?,
),
None => (
Reconciliation::Merge,
git::merge_into_branch(worktree, compared, &merge_message(compared, branch))?,
),
};
Ok(match integrated {
git::Integrated::Settled => Reconciled::Settled,
git::Integrated::Conflicted(conflict) => Reconciled::Conflicted(shape, conflict),
})
}
pub(crate) fn merge_message(compared: &str, branch: &str) -> String {
format!("Merge {compared} into {branch}")
}
pub(crate) fn report_conflict(
stream: &mut Stream,
branch: &Ref,
base: &Ref,
conflict: &git::Conflict,
attempts: Option<usize>,
) {
let artifacts = match stream::store_artifact("diff", conflict.hunks()) {
Ok(artifact) => vec![artifact],
Err(error) => {
eprintln!(
"onevcs: warning: the conflict on {branch:?} is recorded without its \
hunks: {error}"
);
Vec::new()
}
};
let mut payload = object(json!({
"branch": branch,
"base": base,
"paths": conflict.paths(),
}));
if let Some(attempts) = attempts {
payload.insert("attempts".to_owned(), json!(attempts));
}
stream.emit_with(EventKind::SyncConflict, payload, artifacts);
}
fn sync(
context: &Context<'_>,
stream: &mut Stream,
compared: &str,
replay_from: Option<&str>,
) -> Result<()> {
if !git::ref_exists(&context.repo, &format!("refs/remotes/{compared}"))
&& !git::branch_exists(&context.repo, compared)
{
return Ok(());
}
let mut last: Option<(Reconciliation, git::Conflict)> = None;
for attempt in 1..=SYNC_ATTEMPTS {
match reconcile(&context.worktree, compared, &context.branch, replay_from)? {
Reconciled::Settled => return Ok(()),
Reconciled::Conflicted(shape, found) => last = Some((shape, found)),
}
if attempt < SYNC_ATTEMPTS && git::has_remote(&context.repo, "origin") {
git::fetch(&context.repo, "origin")?;
}
}
let Some((attempted, conflict)) = last else {
return Ok(());
};
report_conflict(
stream,
&context.branch,
context.target.base(),
&conflict,
Some(SYNC_ATTEMPTS),
);
let conflicting = guidance::listed(conflict.paths());
let land = guidance::command([
"onevcs",
"publish-branch",
&context.branch,
"--repo",
&context.resolution.publication.to_string_lossy(),
]);
Err(Error::SyncConflict {
reason: match attempted {
Reconciliation::Replay { from } => {
format!(
"{compared} conflicts with {branch:?} in {conflicting} after {SYNC_ATTEMPTS} \
bounded attempts; the branch is retained. {compared} already carries what \
{branch:?} was stacked on, so only its own commits are replayed onto it. Resolve \
the conflict on it — replay it with `{}` — and then land it with `{land}`",
guidance::command([
"git",
"rebase",
"--onto",
compared,
&from,
&context.branch
]),
branch = context.branch,
)
}
Reconciliation::Merge => format!(
"{compared} conflicts with {branch:?} in {conflicting} after {SYNC_ATTEMPTS} \
bounded attempts; the branch is retained. Resolve the conflict on it — merge \
{compared} into {branch} — and then land it with `{land}`",
branch = context.branch,
),
},
})
}
fn publish_locally(
context: &Context<'_>,
stream: &mut Stream,
compared: &str,
environment: &[(String, String)],
) -> Result<PublishOutcome> {
let publication = &context.resolution.publication;
require_publication_checkout_ready(publication, context.target.base())?;
let synced = git::tip(&context.repo, compared);
let identity = lock::git_identity(&git::common_dir(publication)?);
let turn = queue::turn(&identity, context.cancellation)?;
stream.emit(
EventKind::LockWait,
object(json!({
"identity": identity,
"elapsed": turn.waited.as_secs_f64(),
"queue_position": turn.position,
})),
);
stream.emit(
EventKind::LockAcquired,
object(json!({"identity": identity})),
);
stream.emit(
EventKind::MergeQueued,
object(json!({"identity": identity, "queue_position": turn.position})),
);
let outcome = (|| -> Result<PublishOutcome> {
if git::has_remote(&context.repo, "origin") {
git::fetch(&context.repo, "origin")?;
}
if git::tip(&context.repo, compared) != synced {
sync(context, stream, compared, None)?;
}
let (subject, trailers) = describe(context, compared)?;
let message = compose_squash(&subject, &closing_lines(context, compared)?, &trailers);
let scratch_parent = context.run_root.join(format!("publish-{}", ids::unique()));
home::ensure_dir(&scratch_parent)?;
let scratch = scratch_parent.join("worktree");
git::worktree_add_detached(&context.repo, &scratch, compared)?;
let landed = (|| -> Result<PublishOutcome> {
let Some(sha) = git::merge_squash(&scratch, &context.branch, &message)? else {
return Ok(PublishOutcome::NothingToPublish);
};
let hooked = gate_run::runs_pre_push(&scratch);
let started = Moment::now();
let pushed = git::push(
&scratch,
&format!("HEAD:refs/heads/{}", context.target.base()),
"origin",
environment,
)?;
let ended = Moment::now();
let kept = record_push(
stream,
&context.branch,
&pushed,
Some(&context.run_root),
Phase::Integrate,
None,
)?;
record_pre_push(stream, hooked, started, ended, &pushed);
if !pushed.accepted() {
return Err(rejected(
&publishing(&context.branch),
&pushed,
&kept,
Some(&scratch_parent),
));
}
fast_forward_publication(publication, context.target.base())?;
let mut completed =
object(json!({"identity": identity, "sha": sha, "base": context.target.base()}));
gate_run::landed(&mut completed, Some(ended), &sha);
stream.emit(EventKind::MergeCompleted, completed);
record_baselines(context, &sha, stream);
Ok(PublishOutcome::Merged(Sha(sha)))
})();
git::worktree_remove(&context.repo, &scratch)?;
let _ = std::fs::remove_dir_all(&scratch_parent);
landed
})();
drop(turn);
outcome
}
fn publishing(branch: &Ref) -> String {
format!("the publishing push of {branch:?}")
}
pub(crate) fn rejected(
what: &str,
pushed: &git::Pushed,
kept: &Kept,
removed: Option<&Path>,
) -> Error {
let summary = pushed
.refusal()
.unwrap_or_else(|| pushed.output().lines().next_back().unwrap_or("").trim());
let where_it_is = if kept.anywhere() {
kept.evidence()
} else {
" What it wrote could not be stored anywhere, so the excerpt below is all that \
survives of it."
.to_owned()
};
let wrote = pushed.output().trim();
let missing = host_prerequisite(wrote);
let said = if wrote.is_empty() {
String::new()
} else {
format!(
" It said:\n{}",
guidance::quoted_output(&excerpt(
wrote,
"the whole of it is where this refusal says"
))
)
};
if wrote.is_empty() && missing.is_none() {
return Error::PushRejected {
reason: outliving(
&format!(
"{what} was rejected by the merge path, and the merge path wrote nothing: \
{diagnosis}.{where_it_is}",
diagnosis = diagnosis(pushed),
),
removed,
),
};
}
match missing {
Some(missing) => Error::HostPrerequisite {
reason: outliving(
&format!(
"{what} was refused by the merge path because this host is missing a \
prerequisite: {missing}.{where_it_is}{said}",
missing = missing.trim_end_matches('.'),
),
removed,
),
},
None => Error::PushRejected {
reason: outliving(
&format!("{what} was rejected by the merge path: {summary}.{where_it_is}{said}"),
removed,
),
},
}
}
fn diagnosis(pushed: &git::Pushed) -> String {
let Some(diagnostics) = pushed.diagnostics() else {
return "nothing was recorded about how the push itself ended".to_owned();
};
let mut said = format!("the push {}", diagnostics.ended);
if !diagnostics.read_failures.is_empty() {
said.push_str(&format!(
", and its output could not be read whole ({})",
diagnostics.read_failures.join("; "),
));
}
said
}
pub const HOST_PREREQUISITE_MARKER: &str = "onevcs: host-prerequisite:";
fn host_prerequisite(output: &str) -> Option<&str> {
output.lines().find_map(|line| {
let line = line.trim();
let line = line.strip_prefix("remote:").map_or(line, str::trim_start);
line.strip_prefix(HOST_PREREQUISITE_MARKER)
.map(str::trim)
.filter(|missing| !missing.is_empty())
})
}
fn outliving(reason: &str, removed: Option<&Path>) -> String {
let Some(removed) = removed else {
return reason.to_owned();
};
let doomed = removed.display().to_string();
if doomed.is_empty() {
return reason.to_owned();
}
let mut left = reason;
let mut answer = String::with_capacity(reason.len());
while let Some(at) = left.find(&doomed) {
answer.push_str(&left[..at]);
let rest = &left[at..];
let end = rest
.find(|c: char| c.is_whitespace() || ENDS_A_PATH.contains(&c))
.unwrap_or(rest.len());
let end = rest[..end].trim_end_matches('.').len().max(doomed.len());
answer.push_str(GONE);
left = &rest[end..];
}
answer.push_str(left);
answer
}
const ENDS_A_PATH: [char; 6] = [')', '"', '\'', '`', ',', ';'];
const GONE: &str = "(gone with the publication worktree this ran in)";
enum Declined {
Moved(git::ObjectId),
Gone,
}
fn declined_the_lease(
context: &Context<'_>,
pushed: &git::Pushed,
replaced: &str,
environment: &[(String, String)],
) -> Result<Option<Declined>> {
if !pushed.refused_branch(&context.branch) {
return Ok(None);
}
Ok(
match git::remote_tip(&context.worktree, "origin", &context.branch, environment)? {
git::RemoteTip::At(tip) if tip.as_str() == replaced => None,
git::RemoteTip::At(tip) => Some(Declined::Moved(tip)),
git::RemoteTip::Absent => Some(Declined::Gone),
git::RemoteTip::Unknown => None,
},
)
}
fn publish_as_change(
context: &Context<'_>,
stream: &mut Stream,
subject: &str,
environment: &[(String, String)],
push: Push,
) -> Result<PublishOutcome> {
refuse_an_unhosted_identity(&context.resolution.key)?;
if let Some(reason) = &context.draft {
if let Some(refusal) = refuse_a_draft_over_a_reviewed_change(context, reason) {
return Err(refusal);
}
}
gh::checks_timeout()?;
gh::checks_poll()?;
crate::host::check_source_names_a_source()?;
if !context.drafts.disabled {
gh::draft_grace()?;
}
let replacing = match &push {
Push::Replacing { replaced } => Some(replaced.as_str()),
Push::Forward => None,
};
if context.cancellation.is_cancelled() {
return Err(cancelled_while(&format!(
"it prepared {:?} and before anything was pushed",
context.branch
)));
}
let hooked = gate_run::runs_pre_push(&context.worktree);
let started = Moment::now();
let pushed = git::push_replacing(
&context.worktree,
&context.branch,
"origin",
replacing,
environment,
)?;
let ended = Moment::now();
let kept = record_push(
stream,
&context.branch,
&pushed,
Some(&context.run_root),
Phase::Development,
git::tip(&context.worktree, &format!("refs/heads/{}", context.branch)).as_deref(),
)?;
record_pre_push(stream, hooked, started, ended, &pushed);
if !pushed.accepted() {
let declined = match replacing {
Some(replaced) => declined_the_lease(context, &pushed, replaced, environment)?,
None => None,
};
let (Some(replaced), Some(declined)) = (replacing, declined) else {
return Err(rejected(&publishing(&context.branch), &pushed, &kept, None));
};
let branch = &context.branch;
let base = context.target.base();
let command = guidance::command([
"onevcs",
"publish-branch",
branch,
"--repo",
&context.resolution.publication.to_string_lossy(),
]);
return Err(Error::SyncConflict {
reason: match declined {
Declined::Moved(tip) => format!(
"{branch:?} moved on the host since this run last had it at {replaced} — it \
is at {tip} now — and this publication replayed it onto {base:?}, so pushing \
would replace whatever was pushed there in between. Nothing was overwritten \
and the branch is retained. Reconcile the two — fetch {branch}, and replay \
or merge what the host has into it — and then land it with `{command}`",
tip = tip.as_str(),
),
Declined::Gone => format!(
"{branch:?} is gone from the host, which this run last had at {replaced}, and \
this publication replayed it onto {base:?} — so the push would put a branch \
back that somebody deleted, out of a history nobody there has seen. Nothing \
was pushed and the branch is retained. Fetch {branch} so this run sees the \
host as it stands — every fetch here prunes — and then land it with \
`{command}`"
),
},
});
}
let pushed_at = git::tip(&context.worktree, &context.branch);
if context.cancellation.is_cancelled() {
return Err(cancelled_while(&format!(
"its publishing push ran; {:?} is on origin{}",
context.branch,
pushed_at
.as_deref()
.map_or_else(String::new, |sha| format!(" at {sha}")),
)));
}
match pushed_at.as_deref() {
Some(sha) => land_as_change(context, stream, subject, &Sha(sha.to_owned())),
None => Err(crate::error::invalid(format!(
"{:?} was pushed and this worktree will not say which commit it is at, so which of \
the host's checks are about what was just pushed cannot be decided",
context.branch
))),
}
.map_err(|unread| unverified(context, pushed_at.as_deref(), unread))
}
fn refuse_an_unhosted_identity(identity: &str) -> Result<()> {
match change_host(identity) {
Ok(_) | Err(Error::NotImplemented { .. }) => Ok(()),
Err(no_host) => Err(no_host),
}
}
fn unverified(context: &Context<'_>, pushed_at: Option<&str>, unread: Error) -> Error {
match unread {
verdict @ (Error::ChecksFailed { .. }
| Error::ChecksUnsettled { .. }
| Error::GateFailed { .. }
| Error::NotImplemented { .. }
| Error::SyncConflict { .. }
| Error::Cancelled { .. }) => verdict,
unread => Error::PushedUnverified {
reason: format!(
"{branch:?} is on origin{at} and the merge path could not be read: {unread}. The \
work reached the remote, so re-publishing it would repeat what already landed \
there — read the change request on the host, and land the branch with \
`{command}` once the host answers",
branch = context.branch,
at = pushed_at.map_or_else(String::new, |sha| format!(" at {sha}")),
command = guidance::command([
"onevcs",
"publish-branch",
&context.branch,
"--repo",
&context.resolution.publication.to_string_lossy(),
]),
),
},
}
}
fn land_as_change(
context: &Context<'_>,
stream: &mut Stream,
subject: &str,
pushed: &Sha,
) -> Result<PublishOutcome> {
let slug = change_host(&context.resolution.key)?;
let host = context.hosting.for_repo(&slug)?;
let author = host.authenticated_user()?;
let lifecycle = !context.drafts.disabled;
let existing = host.find_changes(&context.branch, context.target.base())?;
let adopted = !existing.is_empty();
let change = match existing.into_iter().next() {
Some(change) => change,
None => host.open_change(ChangeSpec {
head: context.branch.to_string(),
base: context.target.base().to_string(),
title: subject.to_owned(),
body: context.body.clone(),
draft: context.draft.clone(),
draft_awaiting_checks: lifecycle && context.draft.is_none(),
})?,
};
record_boundary(context, &change, pushed);
land_opened_change(
context,
stream,
host.as_ref(),
&author,
&change,
adopted,
pushed,
)
}
pub(crate) fn resume(
context: &Context<'_>,
change: &ChangeRequest,
pushed: &Sha,
stream: &mut Stream,
) -> Result<PublishOutcome> {
gh::checks_timeout()?;
gh::checks_poll()?;
crate::host::check_source_names_a_source()?;
if !context.drafts.disabled {
gh::draft_grace()?;
}
stream.begin_attempt();
(|| -> Result<PublishOutcome> {
let slug = change_host(&context.resolution.key)?;
let host = context.hosting.for_repo(&slug)?;
let author = host.authenticated_user()?;
land_opened_change(
context,
stream,
host.as_ref(),
&author,
change,
true,
pushed,
)
})()
.map_err(|unread| unverified(context, Some(&pushed.0), unread))
}
fn record_boundary(context: &Context<'_>, change: &ChangeRequest, pushed: &Sha) {
let base = context.target.base();
let Some(base_commit) = git::tip(&context.repo, &vcs::base_ref(&context.repo, base)) else {
return;
};
let inputs = git::configured_hooks(&context.worktree)
.and_then(|hooks| crate::verified::inputs(hooks.as_deref(), base, &context.provenance));
let inputs = match inputs {
Ok(inputs) => inputs,
Err(failure) => {
eprintln!(
"onevcs: warning: what verified {branch:?} could not be read back, so publishing \
it again verifies it again: {failure}",
branch = context.branch,
);
return;
}
};
crate::verified::Boundary {
identity: context.resolution.key.clone(),
branch: context.branch.to_string(),
tip: pushed.clone(),
base: base.to_string(),
base_commit: Sha(base_commit),
inputs,
change: change.id.clone(),
change_url: change.url.clone(),
}
.record();
}
fn land_opened_change(
context: &Context<'_>,
stream: &mut Stream,
host: &dyn RemoteHost,
author: &str,
change: &ChangeRequest,
adopted: bool,
pushed: &Sha,
) -> Result<PublishOutcome> {
let lifecycle = !context.drafts.disabled;
stream.emit(
EventKind::ChangeOpened,
object(json!({
"url": change.url.to_string(),
"host": "github",
"id": change.id.0,
"base": change.base,
"author": author,
})),
);
if let Some(reason) = &context.draft {
return hold_as_draft(context, host, change, reason, stream);
}
let drafted = if lifecycle {
await_as_draft(host, change, stream)?
} else {
if adopted {
lift_any_draft(host, change, stream)?;
}
false
};
let mut watcher = Watcher::new(
host,
change,
pushed,
context.drafts.warn_on_early_lift,
context.cancellation,
)?;
let green = policy::GreenDraft::of(context.effective, context.policy.approvals);
if context.effective == MergePolicy::ChangeOpen {
if settle(&mut watcher, stream, drafted)? {
if green == policy::GreenDraft::KeepForReview {
stream.emit(
EventKind::DraftKeptForReview,
object(json!({
"url": change.url.to_string(),
"id": change.id.0,
"base": change.base,
})),
);
return Ok(PublishOutcome::ChangeReviewDraft(change.url.clone()));
}
lift_draft(host, change, stream)?;
}
return Ok(PublishOutcome::ChangeOpen(change.url.clone()));
}
let identity = lock::git_identity(&git::common_dir(&context.resolution.publication)?);
let turn = queue::turn(&identity, context.cancellation)?;
stream.emit(
EventKind::LockWait,
object(json!({
"identity": identity,
"elapsed": turn.waited.as_secs_f64(),
"queue_position": turn.position,
})),
);
stream.emit(
EventKind::LockAcquired,
object(json!({"identity": identity})),
);
let outcome = (|| -> Result<PublishOutcome> {
if (drafted || context.effective == MergePolicy::ChangeDirect)
&& settle(&mut watcher, stream, drafted)?
{
lift_draft(host, change, stream)?;
}
stream.emit(
EventKind::MergeQueued,
object(json!({
"identity": identity,
"queue_position": turn.position,
"url": change.url.to_string(),
})),
);
let sha = if context.effective == MergePolicy::ChangeAuto {
watch_the_merge(&mut watcher, stream)?
} else {
match host.merge(change, context.effective)? {
MergeOutcome::Merged(sha) => sha,
MergeOutcome::Queued => return Ok(PublishOutcome::Queued(change.url.clone())),
MergeOutcome::Open => return Ok(PublishOutcome::ChangeOpen(change.url.clone())),
}
};
let landed_at = merge_time(host, change);
let mut merged = object(json!({"url": change.url.to_string(), "sha": sha.0}));
gate_run::landed(&mut merged, landed_at, &sha.0);
stream.emit(EventKind::ChangeMerged, merged);
let mut completed = object(json!({"identity": identity, "sha": sha.0}));
gate_run::landed(&mut completed, landed_at, &sha.0);
stream.emit(EventKind::MergeCompleted, completed);
crate::verified::forget(&context.resolution.key, &context.branch);
record_landing(context, &sha);
record_baselines(context, &sha.0, stream);
fast_forward_publication(&context.resolution.publication, context.target.base())?;
Ok(PublishOutcome::Merged(sha))
})();
drop(turn);
outcome
}
fn await_as_draft(
host: &dyn RemoteHost,
change: &ChangeRequest,
stream: &mut Stream,
) -> Result<bool> {
match host.is_draft(change) {
Ok(true) => {}
Ok(false) | Err(Error::NotImplemented { .. }) => return Ok(false),
Err(unreadable) => return Err(unreadable),
}
stream.emit(
EventKind::ChangeDrafted,
object(json!({
"url": change.url.to_string(),
"id": change.id.0,
"base": change.base,
"kind": AWAITING_CHECKS,
})),
);
Ok(true)
}
const AWAITING_CHECKS: &str = "awaiting-checks";
fn hold_as_draft(
context: &Context<'_>,
host: &dyn RemoteHost,
change: &ChangeRequest,
reason: &DraftReason,
stream: &mut Stream,
) -> Result<PublishOutcome> {
if !host.is_draft(change)? {
return Err(already_open_for_review(context, change, reason));
}
let mut payload = reason.fields();
let fields = payload.as_object_mut().expect("the reason is an object");
fields.insert("url".to_owned(), json!(change.url.to_string()));
fields.insert("id".to_owned(), json!(change.id.0));
fields.insert("base".to_owned(), json!(change.base));
stream.emit(EventKind::ChangeDrafted, object(payload));
Ok(PublishOutcome::ChangeDraft(change.url.clone()))
}
fn already_open_for_review(
context: &Context<'_>,
change: &ChangeRequest,
reason: &DraftReason,
) -> Error {
crate::error::invalid(format!(
"{url} is open for review on the host, and this publication asked for {asked}. A change \
that is open can land, so reporting it as a draft would say the work is held back when \
nothing is holding it. Lift nothing and publish {branch:?} without a draft, or close \
that change request and publish again",
url = change.url,
asked = reason.asked_for(),
branch = context.branch,
))
}
fn refuse_a_draft_over_a_reviewed_change(
context: &Context<'_>,
reason: &DraftReason,
) -> Option<Error> {
let host = change_host(&context.resolution.key)
.ok()
.and_then(|slug| context.hosting.for_repo(&slug).ok())?;
let open = host
.find_changes(&context.branch, context.target.base())
.ok()?
.into_iter()
.next()?;
match host.is_draft(&open) {
Ok(false) => Some(already_open_for_review(context, &open, reason)),
_ => None,
}
}
pub(crate) fn lift_any_draft(
host: &dyn RemoteHost,
change: &ChangeRequest,
stream: &mut Stream,
) -> Result<()> {
match host.is_draft(change) {
Ok(true) => {}
Ok(false) => return Ok(()),
Err(Error::NotImplemented { .. }) => return Ok(()),
Err(unreadable) => return Err(unreadable),
}
lift_draft(host, change, stream)
}
fn lift_draft(host: &dyn RemoteHost, change: &ChangeRequest, stream: &mut Stream) -> Result<()> {
host.ready_for_review(change)?;
stream.emit(
EventKind::DraftLifted,
object(json!({"url": change.url.to_string(), "id": change.id.0})),
);
Ok(())
}
fn record_landing(context: &Context<'_>, sha: &Sha) {
if let Err(error) = write_landing(context, sha) {
eprintln!(
"onevcs: warning: {branch:?} merged at {merged}, and the landing was not recorded on \
the branch: {error}",
branch = context.branch,
merged = sha.0,
);
}
}
fn record_baselines(context: &Context<'_>, sha: &str, stream: &mut Stream) {
match crate::store::load() {
Ok(registry) => {
crate::release::record_baselines(®istry, &context.resolution.key, sha, stream)
}
Err(failure) => eprintln!(
"onevcs: warning: the release baselines for {identity} at landing {sha} were not \
captured: {failure}",
identity = context.resolution.key,
),
}
}
fn write_landing(context: &Context<'_>, sha: &Sha) -> Result<()> {
let message = format!(
"chore: record the landing of {branch}\n\n{key} {merged}",
branch = context.branch,
key = context.provenance.landed(),
merged = sha.0,
);
if context.built == Built::Resumed {
git::commit_empty_on_branch(&context.repo, &context.branch, &message)?;
return Ok(());
}
git::commit_empty(&context.worktree, &message)?;
if !git::copy_branch(&context.repo, &context.preserved_into, &context.branch)? {
return Err(crate::error::invalid(format!(
"the checkout {} would not take the branch, so nothing outside this run carries the \
record",
context.preserved_into.display()
)));
}
Ok(())
}
const LOG_EXCERPT: usize = 2048;
fn excerpt(log: &str, whole: &str) -> String {
let trimmed = log.trim_end();
if trimmed.len() <= LOG_EXCERPT {
return trimmed.to_owned();
}
let mut cut = trimmed.len() - LOG_EXCERPT;
while cut < trimmed.len() && !trimmed.is_char_boundary(cut) {
cut += 1;
}
let tail = &trimmed[cut..];
let tail = tail.find('\n').map_or(tail, |at| &tail[at + 1..]);
format!("[…earlier output omitted; {whole}…]\n{tail}")
}
enum Reported {
About(Vec<Check>),
NotYet,
}
impl Reported {
fn of(answered: Vec<Check>, commit: &Sha) -> Self {
let reported = !answered.is_empty();
let about: Vec<Check> = answered
.into_iter()
.filter(|check| is_about(check, commit))
.collect();
if about.is_empty() && reported {
return Self::NotYet;
}
Self::About(about)
}
fn checks(&self) -> &[Check] {
match self {
Self::About(checks) => checks,
Self::NotYet => &[],
}
}
}
fn is_about(check: &Check, commit: &Sha) -> bool {
check.head.as_ref().is_none_or(|head| head == commit)
}
struct Reading {
reported: Reported,
complete: bool,
}
impl Reading {
fn standing(&self, declared: &Declared) -> Vec<Standing<'_>> {
let about: Vec<&Check> = self.reported.checks().iter().collect();
standings(&about, declared)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Declared {
Nothing,
Names(BTreeSet<String>),
Unknown(String),
}
fn declared_required(host: &dyn RemoteHost, change: &ChangeRequest) -> Declared {
match host.required_checks_on(&change.base) {
Ok(answer) if answer.checks.is_empty() && answer.complete() => Declared::Nothing,
Ok(answer) if !answer.checks.is_empty() => Declared::Names(answer.checks),
Ok(answer) => Declared::Unknown(format!(
"the host could not read {} for which checks a merge into {} requires",
answer
.unconsulted
.keys()
.map(|source| source.describe())
.collect::<Vec<_>>()
.join(" or "),
change.base
)),
Err(refused) => Declared::Unknown(format!(
"the host would not say which checks a merge into {} requires: {refused}",
change.base
)),
}
}
impl Declared {
fn given(&self, reading: &Reading) -> Declared {
match (self, &reading.reported) {
(Declared::Unknown(_), Reported::About(checks))
if reading.complete
&& !checks.is_empty()
&& checks.iter().all(|check| !check.required) =>
{
Declared::Nothing
}
_ => self.clone(),
}
}
fn unread(&self) -> Option<&str> {
match self {
Declared::Unknown(because) => Some(because),
Declared::Nothing | Declared::Names(_) => None,
}
}
}
fn lets_through(standing: &[Standing<'_>], declared: &Declared) -> bool {
(*declared == Declared::Nothing || !standing.is_empty())
&& standing.iter().all(|standing| {
matches!(
standing.state,
Some(CheckState::Passed | CheckState::Skipped)
)
})
}
struct Standing<'c> {
name: String,
state: Option<CheckState>,
check: Option<&'c Check>,
}
fn standings<'c>(checks: &[&'c Check], declared: &Declared) -> Vec<Standing<'c>> {
let names: BTreeSet<String> = match declared {
Declared::Names(names) => names.clone(),
Declared::Nothing => BTreeSet::new(),
Declared::Unknown(_) => checks
.iter()
.filter(|check| check.required)
.map(|check| check.name.clone())
.collect(),
};
let rank = |state: CheckState| match state {
CheckState::Failed => 0,
CheckState::Pending => 1,
CheckState::Passed => 2,
CheckState::NoVerdict => 3,
CheckState::Skipped => 4,
};
names
.into_iter()
.map(|name| {
let decided = checks
.iter()
.copied()
.filter(|check| check.name == name)
.min_by_key(|check| rank(check.state()));
Standing {
state: decided.map(Check::state),
check: decided,
name,
}
})
.collect()
}
fn red<'c>(standings: &[Standing<'c>]) -> Option<&'c Check> {
standings
.iter()
.find(|standing| standing.state == Some(CheckState::Failed))
.and_then(|standing| standing.check)
}
type Transition = (String, Option<String>, Option<String>);
struct Watcher<'a> {
host: &'a dyn RemoteHost,
change: &'a ChangeRequest,
pushed: &'a Sha,
cancellation: &'a dyn PublicationCancellation,
bound: std::time::Duration,
poll: std::time::Duration,
started: std::time::Instant,
began: Moment,
gate_recorded: bool,
reported: Vec<(String, Transition)>,
logs: Vec<(String, crate::event::ArtifactId)>,
settled: bool,
warn_on_early_lift: bool,
declaration: Option<Declared>,
}
impl<'a> Watcher<'a> {
fn new(
host: &'a dyn RemoteHost,
change: &'a ChangeRequest,
pushed: &'a Sha,
warn_on_early_lift: bool,
cancellation: &'a dyn PublicationCancellation,
) -> Result<Self> {
Ok(Self {
warn_on_early_lift,
host,
change,
pushed,
cancellation,
bound: std::time::Duration::from_secs_f64(gh::checks_timeout()?),
poll: std::time::Duration::from_secs_f64(gh::checks_poll()?),
started: std::time::Instant::now(),
began: Moment::now(),
gate_recorded: false,
reported: Vec::new(),
logs: Vec::new(),
settled: false,
declaration: None,
})
}
fn declared(&mut self) -> Declared {
self.declaration
.get_or_insert_with(|| declared_required(self.host, self.change))
.clone()
}
fn read(&mut self, stream: &mut Stream) -> Result<Reading> {
let answered = self.host.change_checks(self.change)?;
let complete = answered.complete();
let reported = Reported::of(answered.checks, self.pushed);
for check in reported.checks() {
let now = (
check.status.clone(),
check.conclusion.clone(),
check.started_at.clone(),
);
let previous = self
.reported
.iter()
.find(|(name, _)| name == &check.name)
.map(|(_, was)| was.clone());
if previous.as_ref() == Some(&now) {
continue;
}
let mut artifacts = Vec::new();
if check.settled() {
match self.host.check_log(self.change, check) {
Ok(id) => {
self.logs.retain(|(name, _)| name != &check.name);
self.logs.push((check.name.clone(), id.clone()));
artifacts.push(crate::event::ArtifactRef {
id: id.0,
kind: "log".to_owned(),
bytes: 0,
});
}
Err(error) => eprintln!(
"onevcs: warning: check {:?} on {} is recorded without its log: {error}",
check.name, self.change.url
),
}
}
stream.emit_with(
EventKind::ChangeCheck,
object(json!({
"name": check.name,
"required": check.required,
"status": check.status,
"from_status": previous.map(|(status, _, _)| status),
"conclusion": check.conclusion,
"state": check.state(),
})),
artifacts,
);
self.reported.retain(|(name, _)| name != &check.name);
self.reported.push((check.name.clone(), now));
}
Ok(Reading { reported, complete })
}
fn failed(&mut self, stream: &mut Stream, standing: &[Standing<'_>], check: &Check) -> Error {
self.record_gate(stream, gate_run::Ruling::Failed, standing);
checks_failed(check, &self.logs)
}
fn record_gate(
&mut self,
stream: &mut Stream,
verdict: gate_run::Ruling,
standing: &[Standing<'_>],
) {
if std::mem::replace(&mut self.gate_recorded, true) {
return;
}
stream.emit_gate_run(&gate_run::Run {
gate: Gate::RequiredChecks,
started: self.began,
ended: Moment::now(),
verdict,
checks: standing
.iter()
.map(|standing| gate_run::listed(&standing.name, standing.check))
.collect(),
});
}
fn out_of_time(&self) -> bool {
self.started.elapsed() >= self.bound
}
fn pause(&self) -> Result<()> {
pause_unless_cancelled(self.poll, self.cancellation, || {
cancelled_while(&format!(
"it watched {url} at {commit}",
url = self.change.url,
commit = self.pushed.0,
))
})
}
fn conflicting(&self, reported: &str) -> Error {
Error::SyncConflict {
reason: format!(
"{url} conflicts with its base {base:?}: the host reports it {reported}, so the \
merge it was armed to perform can never happen. The branch stays on its remote \
at {commit} and the change request stays open — merge {base} into the branch, \
resolve the conflict, and publish the session again",
url = self.change.url,
base = self.change.base,
commit = self.pushed.0,
),
}
}
fn unsettled(
&mut self,
stream: &mut Stream,
reading: &Reading,
standing: &[Standing<'_>],
declared: &Declared,
awaited: &str,
) -> Error {
self.record_gate(stream, gate_run::Ruling::NoVerdict, standing);
unsettled(
self.change,
self.pushed,
&reading.reported,
Requirement { standing, declared },
self.bound,
awaited,
)
}
fn record_settled(
&mut self,
stream: &mut Stream,
standing: &[Standing<'_>],
unread: Option<&str>,
) {
if std::mem::replace(&mut self.settled, true) {
return;
}
let skipped = skipped_in(standing);
let ruled = gate_run::passed(!skipped.is_empty());
let verdict = if skipped.is_empty() {
"passed"
} else {
"passed-with-skipped"
};
let mut payload = object(json!({
"url": self.change.url.to_string(),
"id": self.change.id.0,
"head": self.pushed.0,
"verdict": verdict,
"skipped": skipped,
}));
if let Some(because) = unread {
eprintln!(
"onevcs: warning: the required checks on {url} were read from the host's own \
per-check marking, because the declaration could not be read: {because}. The \
host's merge path still rules on any merge",
url = self.change.url,
);
payload.insert(
"requirement".to_owned(),
json!({"read_from": "host-marking", "because": because}),
);
}
stream.emit(EventKind::ChecksSettled, payload);
self.record_gate(stream, ruled, standing);
}
}
fn settle(watcher: &mut Watcher<'_>, stream: &mut Stream, drafted: bool) -> Result<bool> {
if drafted {
settle_draft(watcher, stream)
} else {
settle_ready(watcher, stream).map(|()| false)
}
}
fn settle_ready(watcher: &mut Watcher<'_>, stream: &mut Stream) -> Result<()> {
let declared = watcher.declared();
loop {
let reading = watcher.read(stream)?;
let declared = declared.given(&reading);
if let Reported::About(checks) = &reading.reported {
let about: Vec<&Check> = checks.iter().collect();
let standing = standings(&about, &declared);
if let Some(failed) = red(&standing) {
return Err(watcher.failed(stream, &standing, failed));
}
if lets_through(&standing, &declared) {
watcher.record_settled(stream, &standing, declared.unread());
return Ok(());
}
}
if watcher.out_of_time() {
return Err(watcher.unsettled(
stream,
&reading,
&reading.standing(&declared),
&declared,
"settled its required checks on",
));
}
watcher.pause()?;
}
}
fn skipped_in(standing: &[Standing<'_>]) -> Vec<String> {
standing
.iter()
.filter(|standing| standing.state == Some(CheckState::Skipped))
.map(|standing| standing.name.clone())
.collect()
}
fn settle_draft(watcher: &mut Watcher<'_>, stream: &mut Stream) -> Result<bool> {
let grace = std::time::Duration::from_secs_f64(gh::draft_grace()?);
let declared = watcher.declared();
loop {
let reading = watcher.read(stream)?;
let declared = declared.given(&reading);
if declared == Declared::Nothing {
watcher.record_settled(stream, &[], None);
return Ok(true);
}
let marked = declared.unread();
let about: Vec<&Check> = reading.reported.checks().iter().collect();
let standing = standings(&about, &declared);
if let Some(failed) = red(&standing) {
return Err(watcher.failed(stream, &standing, failed));
}
if !standing.is_empty()
&& standing
.iter()
.all(|standing| standing.state == Some(CheckState::Passed))
{
watcher.record_settled(stream, &standing, marked);
return Ok(true);
}
let not_run: Vec<String> = standing
.iter()
.filter(|standing| matches!(standing.state, None | Some(CheckState::Skipped)))
.map(|standing| standing.name.clone())
.collect();
let running = standing.iter().any(|standing| {
matches!(
standing.state,
Some(CheckState::Pending | CheckState::NoVerdict)
)
});
let unseen = matches!(declared, Declared::Unknown(_)) && standing.is_empty();
let grace_elapsed = watcher.started.elapsed() >= grace;
let about = reading.reported.checks();
if unseen
&& grace_elapsed
&& !about.is_empty()
&& about
.iter()
.all(|check| check.state() != CheckState::Skipped)
{
watcher.record_settled(stream, &standing, marked);
return Ok(true);
}
if grace_elapsed && !running && (!not_run.is_empty() || unseen) {
let snapshot = reading.reported.checks().to_vec();
lift_early(watcher, stream, ¬_run, grace)?;
return settle_after_early_lift(watcher, stream, &declared, &snapshot, grace)
.map(|()| false);
}
if watcher.out_of_time() {
return Err(watcher.unsettled(
stream,
&reading,
&standing,
&declared,
"settled its required checks on",
));
}
watcher.pause()?;
}
}
fn lift_early(
watcher: &mut Watcher<'_>,
stream: &mut Stream,
awaited: &[String],
grace: std::time::Duration,
) -> Result<()> {
let change = watcher.change;
lift_draft(watcher.host, change, stream)?;
let warned = watcher.warn_on_early_lift;
if warned {
eprintln!(
"onevcs: warning: {url} was lifted out of its draft before its required checks ran \
on it ({awaited} had not run within {seconds}s), which is what a workflow that \
skips drafts looks like. It is watched as a ready change from here; set \
`drafts: {{warn_on_early_lift: false}}` in the rules file to silence this",
url = change.url,
awaited = if awaited.is_empty() {
"the host named none".to_owned()
} else {
guidance::listed(awaited)
},
seconds = grace.as_secs_f64(),
);
}
stream.emit(
EventKind::DraftLiftedEarly,
object(json!({
"url": change.url.to_string(),
"id": change.id.0,
"base": change.base,
"awaited": awaited,
"grace_seconds": grace.as_secs_f64(),
"warned": warned,
})),
);
Ok(())
}
fn ran_after_the_lift(check: &Check, snapshot: &[Check]) -> bool {
let earlier: Vec<&Check> = snapshot
.iter()
.filter(|seen| seen.name == check.name)
.collect();
if earlier.is_empty() {
return true;
}
check.started_at.as_ref().is_some_and(|started| {
earlier
.iter()
.all(|seen| seen.started_at.as_ref() != Some(started))
})
}
fn settle_after_early_lift(
watcher: &mut Watcher<'_>,
stream: &mut Stream,
declared: &Declared,
snapshot: &[Check],
grace: std::time::Duration,
) -> Result<()> {
let lifted = std::time::Instant::now();
let from_the_draft = |check: &Check| !ran_after_the_lift(check, snapshot);
loop {
let reading = watcher.read(stream)?;
let checks = reading.reported.checks();
let counted: Vec<&Check> = checks
.iter()
.filter(|check| !(from_the_draft(check) && check.state() == CheckState::Skipped))
.collect();
let standing = standings(&counted, declared);
if let Some(failed) = red(&standing) {
return Err(watcher.failed(stream, &standing, failed));
}
let rerun = checks.iter().any(|check| {
!from_the_draft(check)
&& (standing.is_empty() || standing.iter().any(|s| s.name == check.name))
});
if rerun
&& standing.iter().all(|standing| {
matches!(
standing.state,
Some(CheckState::Passed | CheckState::Skipped)
)
})
{
watcher.record_settled(stream, &standing, declared.unread());
return Ok(());
}
if !rerun && lifted.elapsed() >= grace {
watcher.record_gate(stream, gate_run::Ruling::NoVerdict, &standing);
return Err(Error::ChecksUnsettled {
reason: format!(
"the required checks on {url} did not re-run after its draft was lifted: no \
run the host attached to {commit} after the lift appeared within {seconds}s, \
and the runs from while it was a draft were skipped, which is not a verdict. \
The likely cause is a workflow that does not trigger on `ready_for_review` \
— add it to the workflow's pull_request types, or run the checks by hand",
url = watcher.change.url,
commit = watcher.pushed.0,
seconds = grace.as_secs_f64(),
),
});
}
if watcher.out_of_time() {
return Err(watcher.unsettled(
stream,
&reading,
&standing,
declared,
"settled its required checks on",
));
}
watcher.pause()?;
}
}
fn watch_the_merge(watcher: &mut Watcher<'_>, stream: &mut Stream) -> Result<Sha> {
let declared = watcher.declared();
let mut armed = false;
loop {
let reading = watcher.read(stream)?;
let declared = declared.given(&reading);
if let Reported::About(checks) = &reading.reported {
let about: Vec<&Check> = checks.iter().collect();
let standing = standings(&about, &declared);
if let Some(failed) = red(&standing) {
return Err(watcher.failed(stream, &standing, failed));
}
if lets_through(&standing, &declared) {
watcher.record_settled(stream, &standing, declared.unread());
}
}
if !armed {
watcher
.host
.merge(watcher.change, MergePolicy::ChangeAuto)?;
armed = true;
}
if let Some(sha) = watcher.host.merged_at(watcher.change)? {
return Ok(sha);
}
match watcher.host.mergeability(watcher.change) {
Ok(Mergeability::Conflicting { reported }) => {
return Err(watcher.conflicting(&reported))
}
Ok(_) | Err(Error::NotImplemented { .. }) => {}
Err(unreadable) => return Err(unreadable),
}
if watcher.out_of_time() {
return Err(watcher.unsettled(
stream,
&reading,
&reading.standing(&declared),
&declared,
"merged",
));
}
watcher.pause()?;
}
}
fn checks_failed(check: &Check, logs: &[(String, crate::event::ArtifactId)]) -> Error {
let stored = logs.iter().find(|(name, _)| name == &check.name);
let said = stored
.and_then(|(_, id)| stream::read_artifact(&id.0).ok())
.map(|log| excerpt(&log, "the whole log is the check's artifact"))
.filter(|log| !log.trim().is_empty())
.map(|log| format!(" It said:\n{}", guidance::quoted_output(&log)))
.unwrap_or_default();
let at = check
.url
.as_ref()
.map(|url| format!(" It ran at {url}."))
.unwrap_or_default();
let evidence = stored
.map(|(_, id)| {
format!(
" Its whole log is artifact {id} — `{command}`.",
id = id.0,
command = guidance::command(["onevcs", "artifact", "cat", &id.0]),
)
})
.unwrap_or_default();
Error::ChecksFailed {
reason: format!(
"required check {:?} concluded {}.{at}{evidence}{said}",
check.name,
check
.conclusion
.as_deref()
.unwrap_or("without a conclusion"),
),
}
}
struct Requirement<'s, 'c> {
standing: &'s [Standing<'c>],
declared: &'s Declared,
}
fn unsettled(
change: &ChangeRequest,
pushed: &Sha,
reported: &Reported,
requirement: Requirement<'_, '_>,
bound: std::time::Duration,
awaited: &str,
) -> Error {
if let Reported::NotYet = reported {
return Error::ChecksUnsettled {
reason: format!(
"the host had not {awaited} {url} within {seconds}s; it has reported no check at \
all on {commit}, the commit this publication pushed — every check it does report \
is attached to some other commit",
url = change.url,
seconds = bound.as_secs_f64(),
commit = pushed.0,
),
};
}
let Requirement { standing, declared } = requirement;
let pending: Vec<String> = standing
.iter()
.filter_map(|standing| match standing.state {
Some(CheckState::Pending) => Some(format!("{:?}", standing.name)),
None => Some(format!("{:?} (no run yet)", standing.name)),
_ => None,
})
.collect();
let no_verdict: Vec<String> = standing
.iter()
.filter(|standing| standing.state == Some(CheckState::NoVerdict))
.map(|standing| {
format!(
"{:?}: {}",
standing.name,
standing
.check
.and_then(|check| check.conclusion.as_deref())
.unwrap_or("unknown")
)
})
.collect();
let skipped: Vec<&str> = standing
.iter()
.filter(|standing| standing.state == Some(CheckState::Skipped))
.map(|standing| standing.name.as_str())
.collect();
let named = if !pending.is_empty() {
let verdicts = if no_verdict.is_empty() {
String::new()
} else {
format!("; completed with no verdict: {}", no_verdict.join(", "))
};
format!("still unsettled: {}{verdicts}", pending.join(", "))
} else if !no_verdict.is_empty() {
format!("completed with no verdict: {}", no_verdict.join(", "))
} else if !skipped.is_empty() {
format!(
"skipped on the draft, so not run on it: {}",
guidance::listed(&skipped)
)
} else if standing.is_empty() {
match declared {
Declared::Nothing => "the host declared no required check on it at all".to_owned(),
_ => "it has marked no check required on it".to_owned(),
}
} else {
"every required check it declared had settled".to_owned()
};
let read_from = declared
.unread()
.map(|because| {
format!(
". Which checks it requires could not be read, so they were read from the \
checks it marked required: {because}"
)
})
.unwrap_or_default();
Error::ChecksUnsettled {
reason: format!(
"the host had not {awaited} {url} within {seconds}s; {named}{read_from}",
url = change.url,
seconds = bound.as_secs_f64(),
),
}
}
pub(crate) fn record_pre_push(
stream: &mut Stream,
hooked: bool,
started: Moment,
ended: Moment,
pushed: &git::Pushed,
) {
if !hooked {
return;
}
stream.emit_gate_run(&gate_run::Run {
gate: Gate::PrePush,
started,
ended,
verdict: if pushed.accepted() {
gate_run::Ruling::Passed
} else {
gate_run::Ruling::Failed
},
checks: Vec::new(),
});
}
fn merge_time(host: &dyn RemoteHost, change: &ChangeRequest) -> Option<Moment> {
host.merge_time(change)
.ok()
.flatten()
.and_then(|spelled| Moment::reported(&spelled))
}
pub(crate) fn record_push(
stream: &mut Stream,
branch: &Ref,
pushed: &git::Pushed,
preserve_under: Option<&Path>,
phase: Phase,
head: Option<&str>,
) -> Result<Kept> {
let output = pushed.output();
let stored = stream::store_artifact("log", output);
let kept = preserve_under.map(|root| merge_path::preserve_log(root, branch, output));
for error in [
stored.as_ref().err(),
kept.as_ref().and_then(|kept| kept.as_ref().err()),
]
.into_iter()
.flatten()
{
eprintln!(
"onevcs: warning: the push of {branch:?} is recorded without what it wrote: {error}"
);
}
let artifact = stored.ok();
let mut payload = object(json!({
"branch": branch,
"remote": "origin",
"accepted": pushed.accepted(),
"output": output,
}));
if let Some(Ok(preserved)) = &kept {
payload.insert(
"preserved_log".to_owned(),
json!(preserved.display().to_string()),
);
}
if let Some(head) = head {
payload.insert("head".to_owned(), json!(head));
}
let evidence = Kept {
artifact: artifact
.as_ref()
.map(|stored| crate::event::ArtifactId(stored.id.clone())),
preserved: match kept {
Some(Ok(preserved)) => Some(preserved),
Some(Err(_)) | None => None,
},
};
stream.emit_push(phase, payload, artifact.into_iter().collect());
Ok(evidence)
}
pub(crate) struct Kept {
artifact: Option<crate::event::ArtifactId>,
preserved: Option<PathBuf>,
}
impl Kept {
fn evidence(&self) -> String {
let artifact = self.artifact.as_ref().map(|id| {
format!(
"artifact {id} — `{command}`",
id = id.0,
command = guidance::command(["onevcs", "artifact", "cat", &id.0]),
)
});
let preserved = self
.preserved
.as_ref()
.map(|path| format!("preserved at {}", path.display()));
match (artifact, preserved) {
(Some(artifact), Some(preserved)) => {
format!(" Its whole output is {artifact}, and {preserved}.")
}
(Some(one), None) | (None, Some(one)) => format!(" Its whole output is {one}."),
(None, None) => String::new(),
}
}
fn anywhere(&self) -> bool {
self.artifact.is_some() || self.preserved.is_some()
}
}
fn require_publication_checkout_ready(publication: &Path, base: &str) -> Result<()> {
let current = git::current_branch(publication)?;
if current != base {
return Err(Error::Invalid {
reason: format!(
"the publication checkout {} has {current:?} checked out, not the base {base:?}; \
it is never worked in and only ever fast-forwarded",
publication.display()
),
});
}
if git::is_dirty(publication)? {
return Err(Error::Invalid {
reason: format!(
"the publication checkout {} is dirty; it is never worked in",
publication.display()
),
});
}
Ok(())
}
pub fn fast_forward_publication(publication: &Path, base: &str) -> Result<()> {
if !git::has_remote(publication, "origin") {
return Ok(());
}
git::fetch(publication, "origin")?;
if git::current_branch(publication)? != base {
return Ok(());
}
git::merge_ff_only(publication, &format!("origin/{base}"))
}
pub(crate) fn change_host(identity: &str) -> Result<String> {
if let Some(slug) = gh::slug(identity) {
return Ok(slug);
}
let hosted = identity.split('/').count() == 3;
if hosted {
return Err(Error::NotImplemented {
operation: "RemoteHost for a host other than github.com",
});
}
Err(Error::Invalid {
reason: format!(
"identity {identity:?} is not a hosted repository, so it cannot publish a change \
request; a local identity publishes with local-direct"
),
})
}
pub fn compose_message(subject: &str, trailers: &[String]) -> String {
if trailers.is_empty() {
subject.to_owned()
} else {
format!("{subject}\n\n{}", trailers.join("\n"))
}
}
fn closing_lines(context: &Context<'_>, compared: &str) -> Result<Vec<String>> {
let commits = git::log_messages(&context.repo, compared, &context.branch)?;
Ok(crate::closing::lines(
commits.iter().map(|commit| commit.message.as_str()),
))
}
fn compose_squash(subject: &str, closing: &[String], trailers: &[String]) -> String {
if closing.is_empty() {
return compose_message(subject, trailers);
}
compose_message(&format!("{subject}\n\n{}", closing.join("\n")), trailers)
}
pub fn effective_policy(resolved: &Policy, requested: Option<MergePolicy>) -> Result<MergePolicy> {
match requested {
Some(requested) => policy::narrow(resolved, requested),
None => Ok(resolved.publication),
}
}
pub fn exit_code(error: &Error) -> u8 {
FailureKind::of(error).exit_code()
}
pub fn preserved_change_base(record_base: &Ref, recorded: Option<&Ref>) -> Ref {
recorded.unwrap_or(record_base).clone()
}
pub(crate) struct Watched<'a> {
pub identity: &'a str,
pub branch: &'a str,
pub url: &'a Url,
pub id: &'a str,
pub base: &'a str,
pub head: &'a str,
pub stream: Option<&'a str>,
}
pub(crate) fn reconcile_late_merge(
registry: &crate::registry::Registry,
watched: &Watched<'_>,
hosting: &dyn Hosting,
) -> Option<String> {
let host = hosting
.for_repo(&change_host(watched.identity).ok()?)
.ok()?;
let change = ChangeRequest {
id: crate::host::ChangeId(watched.id.to_owned()),
url: watched.url.clone(),
head_sha: Sha(watched.head.to_owned()),
base: watched.base.to_owned(),
};
let merged = host.merged_at(&change).ok()??;
record_late_merge(
registry,
watched,
&merged,
merge_time(host.as_ref(), &change),
)
}
pub(crate) fn late_merge_time(host: &dyn RemoteHost, change: &ChangeRequest) -> Option<Moment> {
merge_time(host, change)
}
pub(crate) fn record_late_merge(
registry: &crate::registry::Registry,
watched: &Watched<'_>,
merged: &Sha,
landed_at: Option<Moment>,
) -> Option<String> {
let located = match crate::release::for_repository(registry, watched.identity) {
Ok(located) => located,
Err(failure) => return warn_unreconciled(watched, &merged.0, &failure),
};
let mut stream = match Stream::releases(watched.identity) {
Ok(stream) => stream,
Err(failure) => return warn_unreconciled(watched, &merged.0, &failure),
};
crate::release::reconcile_landing(
registry,
&located,
watched.branch,
watched.stream,
crate::release::Landing {
commit: &merged.0,
at: landed_at,
},
watched.url,
&mut stream,
);
Some(merged.0.clone())
}
fn warn_unreconciled(
watched: &Watched<'_>,
commit: &str,
failure: &dyn std::fmt::Display,
) -> Option<String> {
eprintln!(
"onevcs: warning: the host reports {url} merged at {commit}, and this host could not \
record that landing for {branch} ({failure}); `onevcs status {branch}` still answers \
from what is recorded",
url = watched.url,
branch = watched.branch,
);
None
}
fn take_up_a_late_merge(context: &Context<'_>) {
let Ok(registry) = store::load() else {
return;
};
let _ = crate::status::landing_of_within(
®istry,
&context.branch,
Some(&context.resolution.key),
Some(context.hosting),
);
}