Menu
Public repository / 0e01aafc5042febc63a50aafdb66008ea3187006
AkurAI-Build / src/merge.rs
Latest change 0e01aafc5042febc63a50aafdb66008ea3187006 - Pull requests with reviews, comments and server-side merge/squash/rebase (OLI-267) by Ólafur Búi Ólafsson
//! Server-side merge of one branch into another on a bare repository, without
//! a working tree: trees come from `git merge-tree`, commits from
//! `git commit-tree`, and the base ref moves with a compare-and-swap.
use std::path::Path;
use anyhow::{Context, Result, bail, ensure};
use crate::{config::validate_ref, git_process};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Method {
Merge,
Squash,
Rebase,
}
impl Method {
pub fn parse(value: &str) -> Result<Self> {
match value {
"merge" => Ok(Self::Merge),
"squash" => Ok(Self::Squash),
"rebase" => Ok(Self::Rebase),
other => bail!("unknown merge method {other:?}"),
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum MergeError {
Conflict { base: String, head: String },
NothingToMerge { base: String, head: String },
BaseMoved,
}
impl std::fmt::Display for MergeError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Conflict { base, head } => {
write!(formatter, "merge conflict between {base} and {head}")
}
Self::NothingToMerge { base, head } => {
write!(formatter, "{head} has nothing to merge into {base}")
}
Self::BaseMoved => write!(formatter, "base branch moved during the merge; retry"),
}
}
}
impl std::error::Error for MergeError {}
pub struct Actor<'a> {
pub name: &'a str,
pub email: &'a str,
}
pub struct Merge<'a> {
pub base: &'a str,
pub head: &'a str,
pub method: Method,
pub message: &'a str,
pub actor: Actor<'a>,
}
/// Merges `head` into `base` and returns the new tip of `base`.
pub fn merge(repo: &Path, request: &Merge<'_>) -> Result<String> {
validate_ref(request.base)?;
validate_ref(request.head)?;
ensure!(!request.message.trim().is_empty(), "merge message is empty");
let base = branch_tip(repo, request.base)?;
let head = branch_tip(repo, request.head)?;
if is_ancestor(repo, &head, &base)? {
return Err(MergeError::NothingToMerge {
base: request.base.to_owned(),
head: request.head.to_owned(),
}
.into());
}
let conflict = || MergeError::Conflict {
base: request.base.to_owned(),
head: request.head.to_owned(),
};
let tip = match request.method {
Method::Merge => {
let tree = merge_tree(repo, &base, &head, None)?.ok_or_else(conflict)?;
commit(
repo,
&tree,
&[&base, &head],
request.message,
&request.actor,
None,
)?
}
Method::Squash => {
let tree = merge_tree(repo, &base, &head, None)?.ok_or_else(conflict)?;
commit(repo, &tree, &[&base], request.message, &request.actor, None)?
}
Method::Rebase => rebase(repo, &base, &head, &request.actor).map_err(|error| {
if error.is::<MergeError>() {
conflict().into()
} else {
error
}
})?,
};
let reference = format!("refs/heads/{}", request.base);
let moved = git_process::command(repo)
.args(["update-ref", &reference, &tip, &base])
.output()?;
ensure!(moved.status.success(), MergeError::BaseMoved);
Ok(tip)
}
fn rebase(repo: &Path, base: &str, head: &str, actor: &Actor<'_>) -> Result<String> {
let range = format!("{base}..{head}");
ensure!(
git_process::output(repo, ["rev-list", "--merges", &range])?.is_empty(),
"cannot rebase a branch that contains merge commits"
);
let commits = git_process::output(repo, ["rev-list", "--reverse", &range])?;
let mut tip = base.to_owned();
for original in commits.lines() {
let parent = git_process::output(repo, ["rev-parse", &format!("{original}^")])?;
let tree = merge_tree(repo, &tip, original, Some(&parent))?.ok_or_else(|| {
MergeError::Conflict {
base: tip.clone(),
head: original.to_owned(),
}
})?;
let message = git_process::output(repo, ["log", "-1", "--format=%B", original])?;
let author =
git_process::output(repo, ["log", "-1", "--format=%an%x1f%ae%x1f%aI", original])?;
let mut fields = author.split('\u{1f}');
let identity = (fields.next(), fields.next(), fields.next());
let (Some(name), Some(email), Some(date)) = identity else {
bail!("unreadable author on {original}");
};
tip = commit(
repo,
&tree,
&[&tip],
&message,
actor,
Some((Actor { name, email }, date)),
)?;
}
Ok(tip)
}
fn branch_tip(repo: &Path, branch: &str) -> Result<String> {
git_process::output(
repo,
[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{branch}^{{commit}}"),
],
)
.with_context(|| format!("unknown branch {branch}"))
}
fn is_ancestor(repo: &Path, ancestor: &str, descendant: &str) -> Result<bool> {
let status = git_process::command(repo)
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.status()?;
Ok(status.success())
}
/// Tree of the three-way merge, or `None` when it conflicts.
fn merge_tree(
repo: &Path,
ours: &str,
theirs: &str,
merge_base: Option<&str>,
) -> Result<Option<String>> {
let mut command = git_process::command(repo);
command.args(["merge-tree", "--write-tree", "--no-messages"]);
if let Some(merge_base) = merge_base {
command.arg(format!("--merge-base={merge_base}"));
}
let output = command.args([ours, theirs]).output()?;
match output.status.code() {
Some(0) => Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_owned(),
)),
Some(1) => Ok(None),
_ => bail!(
"git merge-tree failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
}
}
fn commit(
repo: &Path,
tree: &str,
parents: &[&str],
message: &str,
committer: &Actor<'_>,
author: Option<(Actor<'_>, &str)>,
) -> Result<String> {
let mut command = git_process::command(repo);
command
.args(["commit-tree", tree])
.env("GIT_COMMITTER_NAME", committer.name)
.env("GIT_COMMITTER_EMAIL", committer.email);
let (author, date) = author.map_or((None, None), |(actor, date)| (Some(actor), Some(date)));
let author = author.as_ref().unwrap_or(committer);
command
.env("GIT_AUTHOR_NAME", author.name)
.env("GIT_AUTHOR_EMAIL", author.email);
if let Some(date) = date {
command.env("GIT_AUTHOR_DATE", date);
}
for parent in parents {
command.args(["-p", parent]);
}
let output = command.args(["-m", message]).output()?;
ensure!(
output.status.success(),
"git commit-tree failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, process::Command};
use tempfile::{TempDir, tempdir};
fn run(repo: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.status()
.expect("git");
assert!(status.success(), "git {args:?}");
}
fn write_commit(repo: &Path, file: &str, body: &str, message: &str) {
fs::write(repo.join(file), body).expect("write");
run(repo, &["add", "."]);
run(repo, &["commit", "-m", message]);
}
/// main has base.txt then main.txt; topic forks after base.txt with two commits.
fn fixture() -> TempDir {
let dir = tempdir().expect("tempdir");
let repo = dir.path();
run(repo, &["init", "-b", "main"]);
run(repo, &["config", "user.name", "Ada"]);
run(repo, &["config", "user.email", "ada@example.invalid"]);
write_commit(repo, "base.txt", "base\n", "base");
run(repo, &["checkout", "-b", "topic"]);
write_commit(repo, "t1.txt", "one\n", "topic one");
write_commit(repo, "t2.txt", "two\n", "topic two");
run(repo, &["checkout", "main"]);
write_commit(repo, "main.txt", "main\n", "main work");
dir
}
fn request<'a>(method: Method) -> Merge<'a> {
Merge {
base: "main",
head: "topic",
method,
message: "Merge topic",
actor: Actor {
name: "Merger",
email: "merger@example.invalid",
},
}
}
fn rev(repo: &Path, spec: &str) -> String {
git_process::output(repo, ["rev-parse", spec]).expect("rev-parse")
}
fn files(repo: &Path, spec: &str) -> Vec<String> {
git_process::output(repo, ["ls-tree", "--name-only", spec])
.expect("ls-tree")
.lines()
.map(str::to_owned)
.collect()
}
#[test]
fn merge_commit_has_both_parents_and_moves_base() {
let dir = fixture();
let (old_main, topic) = (rev(dir.path(), "main"), rev(dir.path(), "topic"));
let tip = merge(dir.path(), &request(Method::Merge)).expect("merge");
assert_eq!(rev(dir.path(), "main"), tip);
assert_eq!(rev(dir.path(), &format!("{tip}^1")), old_main);
assert_eq!(rev(dir.path(), &format!("{tip}^2")), topic);
assert_eq!(
files(dir.path(), "main"),
["base.txt", "main.txt", "t1.txt", "t2.txt"]
);
assert_eq!(
git_process::output(dir.path(), ["log", "-1", "--format=%cn", "main"])
.expect("fixture"),
"Merger"
);
}
#[test]
fn squash_makes_one_single_parent_commit() {
let dir = fixture();
let old_main = rev(dir.path(), "main");
let tip = merge(dir.path(), &request(Method::Squash)).expect("squash");
assert_eq!(rev(dir.path(), &format!("{tip}^")), old_main);
assert!(
git_process::output(
dir.path(),
["rev-parse", "--verify", "--quiet", &format!("{tip}^2")]
)
.is_err()
);
assert_eq!(
files(dir.path(), "main"),
["base.txt", "main.txt", "t1.txt", "t2.txt"]
);
}
#[test]
fn rebase_replays_commits_keeping_authors_without_a_merge_commit() {
let dir = fixture();
let old_main = rev(dir.path(), "main");
merge(dir.path(), &request(Method::Rebase)).expect("rebase");
let subjects = git_process::output(
dir.path(),
["log", "--format=%s|%an|%cn", &format!("{old_main}..main")],
)
.expect("fixture");
assert_eq!(subjects, "topic two|Ada|Merger\ntopic one|Ada|Merger");
assert_eq!(
files(dir.path(), "main"),
["base.txt", "main.txt", "t1.txt", "t2.txt"]
);
}
#[test]
fn conflicts_leave_base_untouched() {
let dir = fixture();
run(dir.path(), &["checkout", "topic"]);
write_commit(dir.path(), "main.txt", "topic side\n", "clash");
run(dir.path(), &["checkout", "main"]);
let before = rev(dir.path(), "main");
for method in [Method::Merge, Method::Squash, Method::Rebase] {
let error = merge(dir.path(), &request(method)).expect_err("conflict");
assert!(
matches!(
error.downcast_ref::<MergeError>(),
Some(MergeError::Conflict { .. })
),
"{method:?}: {error:#}"
);
assert_eq!(rev(dir.path(), "main"), before);
}
}
#[test]
fn merged_or_unknown_branches_are_rejected() {
let dir = fixture();
merge(dir.path(), &request(Method::Merge)).expect("merge");
let error = merge(dir.path(), &request(Method::Merge)).expect_err("again");
assert!(matches!(
error.downcast_ref::<MergeError>(),
Some(MergeError::NothingToMerge { .. })
));
let mut missing = request(Method::Merge);
missing.head = "nope";
assert!(merge(dir.path(), &missing).is_err());
assert!(Method::parse("octopus").is_err());
}
}