use std::path::{Path, PathBuf};
use serde_json::json;
use crate::error::{Error, Result};
use crate::event::EventKind;
use crate::registry::{RepoType, Workflow};
use crate::store::{self, Resolution};
use crate::stream::{self, Stream};
use crate::workspace::{object, Ref};
use crate::{gate, git, home, ids, lock, policy, provenance, publish, queue};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Merged,
AlreadyMerged,
Skipped(String),
}
impl Status {
pub fn describe(&self) -> String {
match self {
Status::Merged => "merged".to_owned(),
Status::AlreadyMerged => "already-merged".to_owned(),
Status::Skipped(reason) => format!("skipped ({reason})"),
}
}
}
#[derive(Debug, Clone)]
pub struct BranchOutcome {
pub branch: Ref,
pub status: Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ending {
Unchanged,
Advanced,
AdvancedAndPushed,
}
impl Ending {
pub fn advanced(self) -> bool {
self != Ending::Unchanged
}
pub fn pushed(self) -> bool {
self == Ending::AdvancedAndPushed
}
}
#[derive(Debug, Clone)]
pub struct Outcome {
pub base: Ref,
pub branches: Vec<BranchOutcome>,
pub ending: Ending,
}
pub fn run(
resolution: &Resolution,
candidates: &[String],
push: bool,
gate_override: Option<&Vec<String>>,
stream: &mut Stream,
) -> Result<Outcome> {
if resolution.identity.repo_type == RepoType::Team {
return Err(Error::Invalid {
reason: format!(
"direct integration is refused for identity {:?} (repo_type: team); publish \
through its change-request path",
resolution.key
),
});
}
if resolution.identity.workflow == Workflow::Remote {
return Err(Error::Invalid {
reason: format!(
"direct integration is refused for identity {:?} (workflow: remote); publish \
through its change-request path",
resolution.key
),
});
}
let root = &resolution.publication;
let base = git::current_branch(root)?;
if git::is_dirty(root)? {
return Err(Error::Invalid {
reason: format!("the base worktree {} is dirty", root.display()),
});
}
for branch in candidates {
if !git::is_valid_branch_name(branch) {
return Err(Error::Invalid {
reason: format!("{branch:?} is not a valid branch name"),
});
}
if branch == &base {
return Err(Error::Invalid {
reason: format!("the base branch {base:?} cannot also be a candidate"),
});
}
if !git::branch_exists(root, branch) {
return Err(Error::Invalid {
reason: format!(
"{root:?} has no local branch {branch:?}",
root = root.display()
),
});
}
}
if candidates
.iter()
.collect::<std::collections::BTreeSet<_>>()
.len()
!= candidates.len()
{
return Err(Error::Invalid {
reason: "a branch is offered to the train twice".to_owned(),
});
}
let identity = lock::git_identity(&git::common_dir(root)?);
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 = train(resolution, &base, candidates, push, gate_override, stream);
drop(turn);
outcome
}
fn train(
resolution: &Resolution,
base: &str,
candidates: &[String],
push: bool,
gate_override: Option<&Vec<String>>,
stream: &mut Stream,
) -> Result<Outcome> {
let root = &resolution.publication;
let has_remote = git::has_remote(root, "origin");
if has_remote {
git::fetch(root, "origin")?;
stream.emit(
EventKind::Fetch,
object(json!({"remote": "origin", "checkout": root.display().to_string()})),
);
}
let remote_base = crate::vcs::base_ref(root, base);
let environment = gate::comparison_env("origin", base);
let registry = store::load()?;
let (file, source) = policy::load(®istry)?;
let normalized = store::normalize(&resolution.identity.origin);
let resolved = policy::resolve(&file, &source, &normalized, root);
let gate_command = gate_override
.cloned()
.or_else(|| gate::own_command(&resolved.policy.gate).cloned());
let initial = git::head_sha(root)?;
let workspace = home::workspaces_dir()?
.join("integrations")
.join(ids::unique());
home::ensure_dir(&workspace)?;
let train = Train {
resolution,
base,
remote_base: &remote_base,
workspace: &workspace,
gate_command: gate_command.as_ref(),
environment: &environment,
};
let mut branches = Vec::new();
for branch in candidates {
branches.push(one(&train, branch, stream)?);
}
let mut ending = if git::head_sha(root)? == initial {
Ending::Unchanged
} else {
Ending::Advanced
};
if push && ending.advanced() {
if !has_remote {
return Err(Error::Invalid {
reason: format!("{} has no origin to push to", root.display()),
});
}
let result = git::push(root, base, "origin", &environment)?;
stream.emit(
EventKind::Push,
object(json!({
"branch": base,
"remote": "origin",
"accepted": result.is_ok(),
})),
);
result.map_err(|output| Error::GateFailed {
reason: format!(
"the push of {base:?} was rejected by the merge path: {}",
output.lines().next_back().unwrap_or("").trim()
),
})?;
ending = Ending::AdvancedAndPushed;
}
let _ = std::fs::remove_dir_all(&workspace);
Ok(Outcome {
base: Ref::from_git(base),
branches,
ending,
})
}
struct Train<'a> {
resolution: &'a Resolution,
base: &'a str,
remote_base: &'a str,
workspace: &'a Path,
gate_command: Option<&'a Vec<String>>,
environment: &'a [(String, String)],
}
fn one(train: &Train, branch: &str, stream: &mut Stream) -> Result<BranchOutcome> {
let Train {
resolution,
base,
remote_base,
workspace,
gate_command,
environment,
} = *train;
let root = &resolution.publication;
let unattested = provenance::unattested(root, base, branch)?;
if !unattested.is_empty() {
return Ok(BranchOutcome {
branch: Ref::from_git(branch),
status: Status::Skipped(format!(
"incomplete provenance ({} unattested commit(s)); this branch belongs to \
`onevcs recover {branch} --repo {}`",
unattested.len(),
root.display()
)),
});
}
let parent: PathBuf = workspace.join(policy::branch_slug(branch));
home::ensure_dir(&parent)?;
let worktree = parent.join("worktree");
git::worktree_add_existing(root, &worktree, branch)?;
let outcome = (|| -> Result<BranchOutcome> {
if !git::merge_into_branch(
&worktree,
remote_base,
&format!("Merge {remote_base} into {branch}"),
)? {
return Ok(skipped(branch, "conflict with the current base"));
}
if !git::merge_into_branch(
&worktree,
base,
&format!("Merge integration train {base} into {branch}"),
)? {
return Ok(skipped(branch, "conflict with an earlier candidate"));
}
if let Some(command) = gate_command {
stream.emit(
EventKind::GateStarted,
object(json!({"command": command.join(" "), "branch": branch})),
);
let verdict = gate::run(&worktree, command, environment);
let artifact = stream::store_artifact("log", &verdict.output)?;
let preserved = gate::preserve_log(workspace, branch, &verdict.output)?;
stream.emit_with(
EventKind::GateVerdict,
object(json!({
"verdict": verdict.ruling.describe(),
"command": verdict.command,
"branch": branch,
"preserved_log": preserved.display().to_string(),
})),
vec![artifact],
);
if !verdict.ruling.passed() {
return Ok(skipped(branch, "gate-failed"));
}
}
if !git::is_ancestor(root, &git::head_sha(root)?, branch)? {
return Ok(skipped(
branch,
"not-ready: the base advanced during the gate run",
));
}
let subject = match provenance::publication_subject(&worktree, base, "HEAD", None)? {
Ok(subject) => subject,
Err(reason) => return Ok(skipped(branch, &reason)),
};
let trailers = provenance::attestation_trailers(&worktree, base, "HEAD")?;
let message = publish::compose_message(&subject, &trailers);
let landed = squash_publish(root, base, branch, &message, workspace)?;
Ok(BranchOutcome {
branch: Ref::from_git(branch),
status: if landed {
Status::Merged
} else {
Status::AlreadyMerged
},
})
})();
git::worktree_remove(root, &worktree)?;
let _ = std::fs::remove_dir_all(&parent);
outcome
}
fn skipped(branch: &str, reason: &str) -> BranchOutcome {
BranchOutcome {
branch: Ref::from_git(branch),
status: Status::Skipped(reason.to_owned()),
}
}
fn squash_publish(
root: &Path,
base: &str,
branch: &str,
message: &str,
workspace: &Path,
) -> Result<bool> {
let parent = workspace.join(format!("publish-{}", ids::unique()));
home::ensure_dir(&parent)?;
let scratch = parent.join("worktree");
git::worktree_add_detached(root, &scratch, base)?;
let landed = (|| -> Result<bool> {
let Some(sha) = git::merge_squash(&scratch, branch, message)? else {
return Ok(false);
};
git::merge_ff_only(root, &sha)?;
Ok(true)
})();
git::worktree_remove(root, &scratch)?;
let _ = std::fs::remove_dir_all(&parent);
landed
}