use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::error::{Error, Result};
use crate::event::{EventKind, Phase};
use url::Url;
use crate::host::{ChangeRequest, ChangeSpec, Check, Hosting, MergeOutcome, 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),
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::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,
}
impl FailureKind {
#[must_use]
pub fn exit_code(self) -> u8 {
match self {
FailureKind::Gate
| FailureKind::ChecksFailed
| FailureKind::ChecksUnsettled
| FailureKind::PushRejected
| FailureKind::PushedUnverified => 1,
FailureKind::Invalid => 2,
FailureKind::SyncConflict => 3,
FailureKind::NotImplemented => 70,
}
}
#[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,
_ => FailureKind::Invalid,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Retention {
HandedBack(PathBuf),
Refused(PathBuf),
}
pub fn run_for_session(
token: &SessionToken,
request: &PublishRequest,
hosting: &dyn Hosting,
) -> Result<Publication> {
let mut record = workspace::load(&token.0)?;
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(),
trailers: Vec::new(),
provenance: provenance::from_rules(&file),
hosting,
};
let branch = record.branch.to_string();
let outcome = match run_resolving(&context, &mut stream) {
Ok((outcome, landed)) => {
if landed != context.target {
record.change_base = Some(landed.base().clone());
record.stack_tip = None;
}
let still_working = matches!(outcome, PublishOutcome::ChangeDraft(_))
&& matches!(context.draft, Some(DraftReason::Held { .. }));
if !still_working {
record.state = Lifecycle::Closed;
}
if !still_working || landed != context.target {
workspace::save(&record)?;
}
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 trailers: Vec<String>,
pub provenance: provenance::Trailers,
pub hosting: &'a dyn Hosting,
}
#[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(),
trailers: self.trailers.clone(),
provenance: self.provenance.clone(),
hosting: self.hosting,
}
}
}
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"
}
},
)));
}
}
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,
&format!("Merge {compared} into {branch}"),
)?,
),
};
Ok(match integrated {
git::Integrated::Settled => Reconciled::Settled,
git::Integrated::Conflicted(conflict) => Reconciled::Conflicted(shape, conflict),
})
}
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)?;
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_message(&subject, &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 pushed = git::push(
&scratch,
&format!("HEAD:refs/heads/{}", context.target.base()),
"origin",
environment,
)?;
let kept = record_push(
stream,
&context.branch,
&pushed,
Some(&context.run_root),
Phase::Integrate,
)?;
if !pushed.accepted() {
return Err(rejected(
&publishing(&context.branch),
&pushed,
&kept,
Some(&scratch_parent),
));
}
fast_forward_publication(publication, context.target.base())?;
stream.emit(
EventKind::MergeCompleted,
object(json!({"identity": identity, "sha": sha, "base": context.target.base()})),
);
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 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"
))
)
};
Error::PushRejected {
reason: outliving(
&format!("{what} was rejected by the merge path: {summary}.{where_it_is}{said}"),
removed,
),
}
}
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);
}
}
if context.effective != MergePolicy::ChangeOpen {
gh::checks_timeout()?;
gh::checks_poll()?;
crate::host::check_source_names_a_source()?;
}
let replacing = match &push {
Push::Replacing { replaced } => Some(replaced.as_str()),
Push::Forward => None,
};
let pushed = git::push_replacing(
&context.worktree,
&context.branch,
"origin",
replacing,
environment,
)?;
let kept = record_push(
stream,
&context.branch,
&pushed,
Some(&context.run_root),
Phase::Development,
)?;
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);
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 { .. }) => 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 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(),
})?,
};
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.as_ref(), &change, reason, stream);
}
if adopted {
lift_any_draft(host.as_ref(), &change, stream)?;
}
if context.effective == MergePolicy::ChangeOpen {
return Ok(PublishOutcome::ChangeOpen(change.url.clone()));
}
let identity = lock::git_identity(&git::common_dir(&context.resolution.publication)?);
let turn = queue::turn(&identity)?;
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 context.effective == MergePolicy::ChangeDirect {
await_checks(host.as_ref(), &change, pushed, 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 {
let mut armed = false;
watch(
host.as_ref(),
&change,
pushed,
stream,
"merged",
|host, _| {
if !armed {
host.merge(&change, MergePolicy::ChangeAuto)?;
armed = true;
}
host.merged_at(&change)
},
)?
} 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())),
}
};
stream.emit(
EventKind::ChangeMerged,
object(json!({"url": change.url.to_string(), "sha": sha.0})),
);
stream.emit(
EventKind::MergeCompleted,
object(json!({"identity": identity, "sha": sha.0})),
);
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 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),
}
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<()> {
git::commit_empty(
&context.worktree,
&format!(
"chore: record the landing of {branch}\n\n{key} {merged}",
branch = context.branch,
key = context.provenance.landed(),
merged = sha.0,
),
)?;
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}")
}
fn await_checks(
host: &dyn RemoteHost,
change: &ChangeRequest,
pushed: &Sha,
stream: &mut Stream,
) -> Result<()> {
watch(
host,
change,
pushed,
stream,
"settled its required checks on",
|_, reported| {
let Reported::About(checks) = reported else {
return Ok(None);
};
let settled = checks
.iter()
.filter(|check| check.required)
.all(|check| check.green());
Ok(settled.then_some(()))
},
)
}
enum Reported<'a> {
About(Vec<&'a Check>),
NotYet,
}
impl<'a> Reported<'a> {
fn of(answered: &'a [Check], commit: &Sha) -> Self {
let about: Vec<&Check> = answered
.iter()
.filter(|check| is_about(check, commit))
.collect();
if about.is_empty() && !answered.is_empty() {
return Self::NotYet;
}
Self::About(about)
}
fn checks(&self) -> &[&'a 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)
}
fn watch<T>(
host: &dyn RemoteHost,
change: &ChangeRequest,
pushed: &Sha,
stream: &mut Stream,
awaited: &str,
mut ending: impl FnMut(&dyn RemoteHost, &Reported<'_>) -> Result<Option<T>>,
) -> Result<T> {
let bound = std::time::Duration::from_secs_f64(gh::checks_timeout()?);
let poll = std::time::Duration::from_secs_f64(gh::checks_poll()?);
let started = std::time::Instant::now();
let mut reported: Vec<(String, String)> = Vec::new();
let mut logs: Vec<(String, crate::event::ArtifactId)> = Vec::new();
loop {
let answered = host.change_checks(change)?.checks;
let about = Reported::of(&answered, pushed);
for check in about.checks() {
let previous = reported
.iter()
.find(|(name, _)| name == &check.name)
.map(|(_, status)| status.clone());
if previous.as_deref() == Some(check.status.as_str()) {
continue;
}
let mut artifacts = Vec::new();
if check.settled() {
match host.check_log(change, check) {
Ok(id) => {
logs.retain(|(name, _)| name != &check.name);
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, change.url
),
}
}
stream.emit_with(
EventKind::ChangeCheck,
object(json!({
"name": check.name,
"required": check.required,
"status": check.status,
"from_status": previous,
"conclusion": check.conclusion,
})),
artifacts,
);
reported.retain(|(name, _)| name != &check.name);
reported.push((check.name.clone(), check.status.clone()));
}
if let Some(failed) = about
.checks()
.iter()
.find(|check| check.required && check.red())
{
return Err(checks_failed(failed, &logs));
}
if let Some(answer) = ending(host, &about)? {
return Ok(answer);
}
if started.elapsed() >= bound {
return Err(unsettled(change, pushed, &about, bound, awaited));
}
std::thread::sleep(poll);
}
}
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"),
),
}
}
fn unsettled(
change: &ChangeRequest,
pushed: &Sha,
reported: &Reported<'_>,
bound: std::time::Duration,
awaited: &str,
) -> Error {
let Reported::About(checks) = reported else {
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 required: Vec<&Check> = checks
.iter()
.copied()
.filter(|check| check.required)
.collect();
let pending: Vec<&str> = required
.iter()
.filter(|check| !check.green() && !check.red())
.map(|check| check.name.as_str())
.collect();
let no_verdict: Vec<String> = required
.iter()
.filter(|check| check.no_verdict())
.map(|check| {
format!(
"{:?}: {}",
check.name,
check.conclusion.as_deref().unwrap_or("unknown")
)
})
.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}", guidance::listed(&pending))
} else if required.is_empty() {
"the host declared no required check on it at all".to_owned()
} else {
"every required check it declared had settled".to_owned()
};
Error::ChecksUnsettled {
reason: format!(
"the host had not {awaited} {url} within {seconds}s; {named}",
url = change.url,
seconds = bound.as_secs_f64(),
),
}
}
pub(crate) fn record_push(
stream: &mut Stream,
branch: &Ref,
pushed: &git::Pushed,
preserve_under: Option<&Path>,
phase: Phase,
) -> 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()),
);
}
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"))
}
}
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()
}