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::Stream;
use crate::workspace::{object, Ref};
use crate::{git, guidance, home, ids, lock, merge_path, 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,
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 each \
branch through its change-request path instead: {}",
resolution.key,
change_request_route(resolution, candidates),
),
});
}
if resolution.identity.workflow == Workflow::Remote {
return Err(Error::Invalid {
reason: format!(
"direct integration is refused for identity {:?} (workflow: remote); publish each \
branch through its change-request path instead: {}",
resolution.key,
change_request_route(resolution, candidates),
),
});
}
let root = &resolution.publication;
if store::merge_path_coverage(resolution, root) == store::Coverage::None {
eprintln!(
"onevcs: warning: nothing on this identity's merge path runs a gate, so what this \
train lands is unproven. Install an executable pre-push hook in {}, or confirm \
what covers it with `{}`",
root.display(),
guidance::command(["onevcs", "repos", "--audit-gates"]),
);
}
let base = git::current_branch(root)?;
if git::is_dirty(root)? {
return Err(Error::Invalid {
reason: format!(
"the base worktree {} is dirty; the train advances that base and will not \
build on work nobody recorded. Commit or stash what it holds, then re-run `{}`",
root.display(),
train_command(candidates, push),
),
});
}
for branch in candidates {
if !git::is_valid_branch_name(branch) {
return Err(Error::Invalid {
reason: format!(
"{branch:?} is not a valid branch name; `onevcs recoverable` lists every \
preserved branch by name and the checkout it is in"
),
});
}
if branch == &base {
return Err(Error::Invalid {
reason: format!(
"the base branch {base:?} cannot also be a candidate; re-run `onevcs \
integrate` naming only the branches to land on it"
),
});
}
if !git::branch_exists(root, branch) {
return Err(Error::Invalid {
reason: format!(
"{root:?} has no local branch {branch:?}; `onevcs recoverable` lists this \
identity's unpublished branches and the checkout each is in",
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; re-run `onevcs integrate` naming \
each branch once, in the order they should land"
.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, stream);
drop(turn);
outcome
}
fn change_request_route(resolution: &Resolution, candidates: &[String]) -> String {
let repo = resolution.publication.to_string_lossy();
candidates
.iter()
.map(|branch| {
format!(
"`{}`",
guidance::command(["onevcs", "publish-branch", branch, "--repo", &repo])
)
})
.collect::<Vec<_>>()
.join(", ")
}
fn train_command(candidates: &[String], push: bool) -> String {
let mut argv: Vec<&str> = vec!["onevcs", "integrate"];
argv.extend(candidates.iter().map(String::as_str));
if push {
argv.push("--push");
}
guidance::command(argv)
}
fn train(
resolution: &Resolution,
base: &str,
candidates: &[String],
push: bool,
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 = merge_path::comparison_env("origin", base);
let registry = store::load()?;
let (file, _) = policy::load(®istry)?;
let trailers = provenance::from_rules(&file);
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,
trailers: &trailers,
};
let mut branches = Vec::new();
for branch in candidates {
branches.push(one(&train, branch)?);
}
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; the base advanced locally, so re-run \
`onevcs integrate` without --push, or give the checkout an origin",
root.display()
),
});
}
let result = git::push(root, base, "origin", &environment)?;
let kept = publish::record_push(
stream,
&Ref::from_git(base),
&result,
None,
crate::event::Phase::Integrate,
)?;
if !result.accepted() {
return Err(publish::rejected(
&format!("the push of {base:?}"),
&result,
&kept,
None,
));
}
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,
trailers: &'a provenance::Trailers,
}
fn one(train: &Train, branch: &str) -> Result<BranchOutcome> {
let Train {
resolution,
base,
remote_base,
workspace,
trailers,
} = *train;
let root = &resolution.publication;
if let Some(prefix) = provenance::unrecognized(root, base, branch, trailers)?.first() {
return Ok(skipped(
branch,
&format!(
"provenance under the trailer prefix {prefix:?}, which this host is not \
configured to read; set trailer_prefix to {prefix:?} in the rules file, then \
land it with `{}`",
guidance::command([
"onevcs",
"recover",
branch,
"--repo",
&root.to_string_lossy()
])
),
));
}
let unattested = provenance::unattested(root, base, branch, trailers)?;
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 `{}`",
unattested.len(),
guidance::command([
"onevcs",
"recover",
branch,
"--repo",
&root.to_string_lossy()
])
)),
});
}
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 let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
&worktree,
remote_base,
&format!("Merge {remote_base} into {branch}"),
)? {
return Ok(skipped(
branch,
&format!(
"conflict with the current base in {}",
guidance::listed(conflict.paths())
),
));
}
if let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
&worktree,
base,
&format!("Merge integration train {base} into {branch}"),
)? {
return Ok(skipped(
branch,
&format!(
"conflict with an earlier candidate in {}",
guidance::listed(conflict.paths())
),
));
}
if !git::is_ancestor(root, &git::head_sha(root)?, branch)? {
return Ok(skipped(
branch,
"not-ready: the base advanced while this candidate was being built",
));
}
let subject =
match provenance::publication_subject(&worktree, base, "HEAD", None, trailers)? {
Ok(subject) => subject,
Err(reason) => {
return Ok(skipped(
branch,
&format!(
"{reason}; publish it with `{} --title <T>`",
guidance::command([
"onevcs",
"publish-branch",
branch,
"--repo",
&root.to_string_lossy()
])
),
))
}
};
let attested = provenance::attestation_trailers(&worktree, base, "HEAD", trailers)?;
let message = publish::compose_message(&subject, &attested);
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
}