AkurAI Build
Menu

Public repository / main

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());
    }
}