use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::boundary::TermScope;
use crate::error::{self, Error, Result};
use crate::event::EventKind;
use crate::git::{self, Integrated};
use crate::guidance;
use crate::host::Sha;
use crate::provenance::{self, Trailers};
use crate::providers::Providers;
use crate::registry::Registry;
use crate::session::{OpenConflict, SessionRequest, SessionToken};
use crate::store::{self, Resolution};
use crate::stream::Stream;
use crate::workspace::{self, object, BaseMerge, Opening};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StackBaseRequest {
pub repo: String,
pub branch: String,
pub root: String,
pub parents: Vec<String>,
#[serde(default)]
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StackParent {
pub branch: String,
pub head: Sha,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StackBase {
pub branch: String,
pub head: Sha,
pub parents: Vec<StackParent>,
pub outcome: StackBaseOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
#[non_exhaustive]
pub enum StackBaseOutcome {
Created,
Advanced,
Unchanged,
Conflicted {
session: SessionToken,
conflict: OpenConflict,
},
}
impl StackBaseOutcome {
fn wire(&self) -> &'static str {
match self {
StackBaseOutcome::Created => "created",
StackBaseOutcome::Advanced => "advanced",
StackBaseOutcome::Unchanged => "unchanged",
StackBaseOutcome::Conflicted { .. } => "conflicted",
}
}
}
const ORIGIN: &str = "origin";
const NAMESPACE: &str = "stack-base";
pub(crate) fn is_stack_base(branch: &str) -> bool {
let segments: Vec<&str> = branch.split('/').collect();
segments
.iter()
.position(|segment| *segment == NAMESPACE)
.is_some_and(|at| {
let rest = &segments[at + 1..];
rest.len() >= 2 && rest.iter().all(|segment| !segment.is_empty())
})
}
pub fn assemble(providers: &Providers<'_>, request: &StackBaseRequest) -> Result<StackBase> {
validate(request)?;
let registry = store::load()?;
let resolution = store::resolve(®istry, &request.repo)?;
let (rules, _source) = crate::policy::load(®istry)?;
let trailers = provenance::from_rules(&rules);
let execution = workspace::execution_checkout(®istry, &resolution, None)?;
if git::has_remote(&execution, ORIGIN) {
git::fetch(&execution, ORIGIN)?;
}
for name in std::iter::once(&request.root).chain(&request.parents) {
if git::tip(&execution, &tracking(name)).is_none() {
return Err(not_on_origin(&resolution, name, &request.root));
}
}
let opening = open(®istry, request)?;
let token = opening.record.token.to_string();
match assemble_in(providers, request, &resolution, &trailers, opening) {
Ok(assembled) => Ok(assembled),
Err(Failed {
error,
session,
rewind,
}) => {
if let Some((worktree, start)) = &rewind {
git::abort_merge(worktree);
if let Err(rewinding) = git::reset_hard(worktree, start) {
eprintln!(
"onevcs: stack base session {token} could not be put back at {start}: \
{rewinding}"
);
}
}
if session == Session::Close {
if let Err(closing) = workspace::close(&token) {
eprintln!("onevcs: stack base session {token} could not be closed: {closing}");
}
}
Err(error)
}
}
}
struct Failed {
error: Error,
session: Session,
rewind: Option<(PathBuf, String)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Session {
Close,
Keep,
}
fn assemble_in(
providers: &Providers<'_>,
request: &StackBaseRequest,
resolution: &Resolution,
trailers: &Trailers,
opening: Opening,
) -> std::result::Result<StackBase, Failed> {
let Opening {
record,
mut stream,
conflict,
cut,
} = opening;
let worktree = record.worktree.clone();
let clone = record.clone.clone();
let branch = request.branch.as_str();
let fail = |error: Error| {
Err(Failed {
error,
session: Session::Close,
rewind: None,
})
};
let execution = &record.execution_checkout;
let read = |name: &str| -> Result<String> {
git::tip(execution, &tracking(name))
.ok_or_else(|| not_on_origin(resolution, name, &request.root))
};
let heads = match request
.parents
.iter()
.map(|parent| read(parent).map(|head| (parent.clone(), head)))
.collect::<Result<Vec<(String, String)>>>()
{
Ok(heads) => heads,
Err(error) => return fail(error),
};
let parents: Vec<StackParent> = heads
.iter()
.map(|(branch, head)| StackParent {
branch: branch.clone(),
head: Sha(head.clone()),
})
.collect();
if let Some(conflict) = conflict {
return Ok(conflicted(
&mut stream,
request,
&record.token,
parents,
conflict,
));
}
match git::merge_in_progress(&worktree) {
Ok(None) => {}
Ok(Some(_)) => {
return Err(Failed {
error: Error::SyncConflict {
reason: format!(
"session {token} holds the merge stack base {branch:?} stopped on with \
every conflict staged and nothing committed. Conclude it with `{commit}` \
and run this again",
token = record.token,
commit = guidance::command([
"git",
"-C",
&worktree.to_string_lossy(),
"commit",
"--no-edit",
]),
),
},
session: Session::Keep,
rewind: None,
})
}
Err(error) => return fail(error),
}
let start = match git::head_sha(&worktree) {
Ok(start) => start,
Err(error) => return fail(error),
};
let undo = |error: Error| {
Err(Failed {
error,
session: Session::Close,
rewind: Some((worktree.clone(), start.clone())),
})
};
let root = match read(&request.root) {
Ok(root) => root,
Err(error) => return fail(error),
};
let merges = std::iter::once((request.root.clone(), root.clone())).chain(heads);
for (name, head) in merges {
match merge(&worktree, branch, &name, &head, trailers) {
Ok(None) => {}
Ok(Some(conflict)) => {
return Ok(conflicted(
&mut stream,
request,
&record.token,
parents,
conflict,
));
}
Err(error) => return undo(error),
}
}
let head = match git::head_sha(&worktree) {
Ok(head) => head,
Err(error) => return undo(error),
};
let on_origin = git::tip(execution, &tracking(branch));
let absent = match (cut, &on_origin) {
(true, _) => true,
(false, Some(_)) => false,
(false, None) => match git::is_ancestor(&worktree, &start, &root) {
Ok(behind) => behind,
Err(error) => return undo(error),
},
};
let outcome = if on_origin.as_deref() == Some(head.as_str()) {
StackBaseOutcome::Unchanged
} else {
if let Err(error) = push(providers, resolution, &clone, request) {
return undo(error);
}
match absent {
true => StackBaseOutcome::Created,
false => StackBaseOutcome::Advanced,
}
};
if let Err(closing) = workspace::close(&record.token) {
eprintln!(
"onevcs: stack base {branch:?} was assembled, and its session {token} could not be \
closed: {closing}",
token = record.token
);
}
let assembled = StackBase {
branch: branch.to_owned(),
head: Sha(head),
parents,
outcome,
};
record_assembly(&mut stream, request, &assembled);
Ok(assembled)
}
fn conflicted(
stream: &mut Stream,
request: &StackBaseRequest,
token: &workspace::Token,
parents: Vec<StackParent>,
conflict: OpenConflict,
) -> StackBase {
let assembled = StackBase {
branch: request.branch.clone(),
head: Sha(conflict.branch_tip.clone()),
parents,
outcome: StackBaseOutcome::Conflicted {
session: SessionToken(token.to_string()),
conflict,
},
};
record_assembly(stream, request, &assembled);
assembled
}
fn validate(request: &StackBaseRequest) -> Result<()> {
if request.parents.len() < 2 {
return Err(Error::Invalid {
reason: format!(
"a stack base merges at least two parents and {count} {named} named, so nothing \
was assembled or pushed. With one unlanded parent, base the change on that \
parent's branch; with none, on {root:?}",
count = request.parents.len(),
named = match request.parents.len() {
1 => "was",
_ => "were",
},
root = request.root,
),
});
}
for name in std::iter::once(&request.branch)
.chain(std::iter::once(&request.root))
.chain(&request.parents)
{
if !git::is_valid_branch_name(name) {
return Err(error::invalid(format!(
"{name:?} is not a valid branch name, so nothing was assembled or pushed"
)));
}
}
if !is_stack_base(&request.branch) {
return Err(error::invalid(format!(
"{branch:?} is not in the stack-base namespace, so nothing was assembled or \
pushed; name the branch `<host prefix>{NAMESPACE}/<plan>/<node>`, which is how \
every host recognises a stack base and offers it no landing",
branch = request.branch,
)));
}
if request.branch == request.root || request.parents.contains(&request.branch) {
return Err(error::invalid(format!(
"stack base {branch:?} is also named as its own root or parent, so it would be \
merged into itself and nothing was assembled or pushed; name the stack base's own \
branch with --branch and what it merges with --root and --parent",
branch = request.branch,
)));
}
if request.parents.contains(&request.root) {
return Err(error::invalid(format!(
"{root:?} is named as both the root and a parent, so nothing was assembled or \
pushed; the root is merged first on its own, so name only the branches above it \
with --parent",
root = request.root,
)));
}
let mut seen: Vec<&String> = Vec::new();
for parent in &request.parents {
if seen.contains(&parent) {
return Err(error::invalid(format!(
"parent {parent:?} is named twice, so nothing was assembled or pushed; each \
parent is merged once, in the order named"
)));
}
seen.push(parent);
}
Ok(())
}
fn open(registry: &Registry, request: &StackBaseRequest) -> Result<Opening> {
workspace::open_with(
registry,
&SessionRequest {
repo: request.repo.clone(),
branch: Some(request.branch.clone()),
branch_name: None,
branch_prefix: None,
base: Some(request.root.clone()),
execution_checkout: None,
pool: None,
overflow: None,
labels: request.labels.clone(),
refuse_conflicts: false,
},
BaseMerge::Leave,
)
}
fn merge(
worktree: &Path,
branch: &str,
name: &str,
head: &str,
trailers: &Trailers,
) -> Result<Option<OpenConflict>> {
if git::is_ancestor(worktree, head, "HEAD")? {
return Ok(None);
}
let before = git::head_sha(worktree)?;
let message = format!(
"Merge {name} into stack base {branch}\n\n{}",
provenance::stack_base_trailer(trailers, name, head)
);
match git::merge_commit_leaving_conflict(worktree, head, &message)? {
Integrated::Settled => Ok(None),
Integrated::Conflicted(conflict) => Ok(Some(OpenConflict {
paths: conflict.paths().to_vec(),
base_commit: head.to_owned(),
branch_tip: before,
})),
}
}
fn push(
providers: &Providers<'_>,
resolution: &Resolution,
clone: &Path,
request: &StackBaseRequest,
) -> Result<()> {
let branch = request.branch.as_str();
let reference = format!("refs/heads/{branch}");
let compared = format!("{ORIGIN}/{}", request.root);
crate::boundary::evidence::guard_publication(
providers.hosting,
&resolution.key,
&crate::boundary::screen::Outgoing {
repo: clone,
base: Some(compared.as_str()),
tip: &reference,
branch: Some(branch),
title: None,
body: None,
},
&TermScope::default(),
)?;
let pushed = git::push_preserving(clone, branch, ORIGIN)?;
if pushed.accepted() {
return Ok(());
}
let summary = pushed
.refusal()
.unwrap_or_else(|| pushed.output().lines().next_back().unwrap_or("").trim());
Err(Error::PushRejected {
reason: format!(
"the origin of {key:?} declined stack base {branch:?}: {summary}. Nothing was \
replaced — a stack base is never force-pushed — so a branch somebody else moved \
is reported rather than overwritten",
key = resolution.key,
),
})
}
fn record_assembly(stream: &mut Stream, request: &StackBaseRequest, assembled: &StackBase) {
let payload = object(json!({
"branch": assembled.branch,
"root": request.root,
"parents": assembled.parents,
"head": assembled.head,
"outcome": assembled.outcome.wire(),
}));
stream.emit(EventKind::StackBaseAssembled, payload);
}
fn tracking(name: &str) -> String {
format!("refs/remotes/{ORIGIN}/{name}")
}
fn not_on_origin(resolution: &Resolution, name: &str, root: &str) -> Error {
let what = match name == root {
true => "root",
false => "parent",
};
error::invalid(format!(
"{what} {name:?} is not a branch on the origin of {key:?}, so nothing was assembled or \
pushed. A stack base merges only what the origin carries: push {name:?} first, or \
name a branch the origin has",
key = resolution.key,
))
}