//! Merge logic for patch proposals.
//!
//! This module implements the four merge policies:
//! - prefer-fast-forward: FF when possible, else merge commit
//! - fast-forward-only: only allow FF, refuse otherwise
//! - merge-commit-only: always create a merge commit
//! - rebase-then-fast-forward: rebase onto target, then FF merge
use std::path::Path;
use crate::entities::patch_proposals::MergePolicy;
/// Result of attempting to merge a patch proposal.
#[derive(Debug)]
pub enum MergeResult {
/// Merge succeeded with the new target commit OID.
Success { new_target_oid: String },
/// Merge failed due to conflicts.
Conflict { reason: String },
/// Merge refused by policy (e.g., fast-forward-only with divergence).
Refused { reason: String },
}
/// Attempts to merge the source ref into the target branch according to the policy.
///
/// Returns a `MergeResult` indicating success, conflict, or policy refusal.
#[tracing::instrument(level = "debug", skip(bare), fields(bare = %bare.display()))]
pub async fn merge(
bare: &Path,
source_ref: &str,
target_branch: &str,
policy: MergePolicy,
) -> std::io::Result<MergeResult> {
// Get the current target OID
let target_oid = match git_rev_parse(bare, &format!("refs/heads/{target_branch}")).await? {
Some(oid) => oid,
None => {
return Ok(MergeResult::Refused {
reason: format!("target branch {target_branch} does not exist"),
});
}
};
// Get the source OID (prepend refs/ since DB stores it without the prefix)
let source_ref_full = if source_ref.starts_with("refs/") {
source_ref.to_string()
} else {
format!("refs/{source_ref}")
};
let source_oid = match git_rev_parse(bare, &source_ref_full).await? {
Some(oid) => oid,
None => {
return Ok(MergeResult::Refused {
reason: format!("source ref {source_ref_full} does not exist"),
});
}
};
// Find the merge base
let merge_base = match git_merge_base(bare, &target_oid, &source_oid).await? {
Some(base) => base,
None => {
return Ok(MergeResult::Refused {
reason: "no common ancestor found".to_string(),
});
}
};
// Check if source is already an ancestor of target (already merged)
if is_ancestor(bare, &source_oid, &target_oid).await? {
return Ok(MergeResult::Refused {
reason: "source is already merged into target".to_string(),
});
}
match policy {
MergePolicy::FastForwardOnly => {
try_fast_forward(bare, &target_oid, &source_oid, target_branch).await
}
MergePolicy::PreferFastForward => {
// Try FF first, fall back to merge commit
if is_ancestor(bare, &target_oid, &source_oid).await? {
try_fast_forward(bare, &target_oid, &source_oid, target_branch).await
} else {
create_merge_commit(bare, &merge_base, &target_oid, &source_oid, target_branch)
.await
}
}
MergePolicy::MergeCommitOnly => {
create_merge_commit(bare, &merge_base, &target_oid, &source_oid, target_branch).await
}
MergePolicy::RebaseThenFastForward => {
// Rebase source onto target, then FF
try_rebase_and_ff(bare, &merge_base, &target_oid, &source_oid, target_branch).await
}
}
}
/// Tries to fast-forward the target branch to the source OID.
async fn try_fast_forward(
bare: &Path,
target_oid: &str,
source_oid: &str,
target_branch: &str,
) -> std::io::Result<MergeResult> {
// Check if target is an ancestor of source (can FF)
if !is_ancestor(bare, target_oid, source_oid).await? {
return Ok(MergeResult::Refused {
reason: "cannot fast-forward: target has diverged from source".to_string(),
});
}
// Update the target branch to point to source
if !git_update_ref_compare_and_swap(
bare,
&format!("refs/heads/{target_branch}"),
target_oid,
source_oid,
)
.await?
{
return Ok(MergeResult::Refused {
reason: "concurrent push to target branch, please retry".to_string(),
});
}
Ok(MergeResult::Success {
new_target_oid: source_oid.to_string(),
})
}
/// Creates a merge commit with target as the first parent and source as the second.
async fn create_merge_commit(
bare: &Path,
merge_base: &str,
target_oid: &str,
source_oid: &str,
target_branch: &str,
) -> std::io::Result<MergeResult> {
// Use git merge-tree to compute the merge result
let merge_tree_output = match git_merge_tree(bare, merge_base, target_oid, source_oid).await? {
Some(output) => output,
None => {
return Ok(MergeResult::Conflict {
reason: "merge-tree failed".to_string(),
});
}
};
// Check for conflicts in the output
if merge_tree_output.contains("changed in both branches")
|| merge_tree_output.contains("CONFLICT")
{
return Ok(MergeResult::Conflict {
reason: "merge conflicts detected".to_string(),
});
}
// Get the tree OID from merge-tree output
let tree_oid = match parse_merge_tree_output(&merge_tree_output) {
Some(oid) => oid,
None => {
return Ok(MergeResult::Conflict {
reason: "failed to parse merge-tree output".to_string(),
});
}
};
// Create a merge commit with commit-tree
let message = format!("Merge patch into {target_branch}");
let commit_oid =
match git_commit_tree(bare, &tree_oid, &[target_oid, source_oid], &message).await {
Some(oid) => oid,
None => {
return Ok(MergeResult::Conflict {
reason: "commit-tree failed".to_string(),
});
}
};
// Update the target branch with compare-and-swap
if !git_update_ref_compare_and_swap(
bare,
&format!("refs/heads/{target_branch}"),
target_oid,
&commit_oid,
)
.await?
{
return Ok(MergeResult::Refused {
reason: "concurrent push to target branch, please retry".to_string(),
});
}
Ok(MergeResult::Success {
new_target_oid: commit_oid,
})
}
/// Tries to rebase the source onto the target, then fast-forward.
async fn try_rebase_and_ff(
bare: &Path,
merge_base: &str,
target_oid: &str,
source_oid: &str,
target_branch: &str,
) -> std::io::Result<MergeResult> {
// Try to rebase: replay commits from merge_base..source onto target
// For simplicity, we'll use git format-patch and git am
// In a real implementation, you'd want to handle this more carefully
let commits = match git_rev_list(bare, &format!("{merge_base}..{source_oid}")).await? {
Some(commits) => commits,
None => {
return Ok(MergeResult::Refused {
reason: "failed to list commits for rebase".to_string(),
});
}
};
if commits.is_empty() {
// No commits to rebase, just FF
return try_fast_forward(bare, target_oid, source_oid, target_branch).await;
}
// For each commit, create a patch and apply it
let mut current_base = target_oid.to_string();
for commit in commits.iter().rev() {
// Get the tree for this commit rebased onto current_base
let patch_tree = match git_cherry_pick_tree(bare, ¤t_base, commit).await {
Some(tree) => tree,
None => {
return Ok(MergeResult::Conflict {
reason: format!("rebase conflict at commit {commit}"),
});
}
};
// Get the commit message
let message = match git_log_format(bare, commit, "%s%n%n%b").await? {
Some(msg) => msg,
None => "Rebased commit".to_string(),
};
// Create a new commit with the rebased tree
let new_commit = match git_commit_tree(bare, &patch_tree, &[¤t_base], &message).await
{
Some(oid) => oid,
None => {
return Ok(MergeResult::Conflict {
reason: "failed to create rebased commit".to_string(),
});
}
};
current_base = new_commit;
}
// Now fast-forward to the rebased tip
if !git_update_ref_compare_and_swap(
bare,
&format!("refs/heads/{target_branch}"),
target_oid,
¤t_base,
)
.await?
{
return Ok(MergeResult::Refused {
reason: "concurrent push to target branch, please retry".to_string(),
});
}
Ok(MergeResult::Success {
new_target_oid: current_base,
})
}
/// Checks if `ancestor` is an ancestor of `descendant`.
async fn is_ancestor(bare: &Path, ancestor: &str, descendant: &str) -> std::io::Result<bool> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(bare)
.arg("merge-base")
.arg("--is-ancestor")
.arg(ancestor)
.arg(descendant)
.output()
.await?;
Ok(output.status.success())
}
/// Runs `git rev-parse` and returns the OID, or None if it fails.
async fn git_rev_parse(bare: &Path, rev: &str) -> std::io::Result<Option<String>> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(bare)
.arg("rev-parse")
.arg("--verify")
.arg(rev)
.output()
.await?;
if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
Ok(None)
}
}
/// Runs `git merge-tree --write-tree` to compute a merge without checking out.
/// Returns the tree OID on success, or None on conflict/failure.
async fn git_merge_tree(
bare: &Path,
base: &str,
target: &str,
source: &str,
) -> std::io::Result<Option<String>> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(bare)
.arg("merge-tree")
.arg("--write-tree")
.arg(base)
.arg(target)
.arg(source)
.output()
.await?;
if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
Ok(None)
}
}
/// Parses the output of `git merge-tree --write-tree` to extract the tree OID.
fn parse_merge_tree_output(output: &str) -> Option<String> {
// The output format is just the tree OID on a single line
let trimmed = output.trim();
if trimmed.len() == 40 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
Some(trimmed.to_string())
} else {
None
}
}
/// Creates a commit with the given tree and parents.
async fn git_commit_tree(
bare: &Path,
tree_oid: &str,
parents: &[&str],
message: &str,
) -> Option<String> {
let mut cmd = tokio::process::Command::new("git");
cmd.arg("-C").arg(bare).arg("commit-tree").arg(tree_oid);
for parent in parents {
cmd.arg("-p").arg(parent);
}
cmd.arg("-m").arg(message);
let output = cmd.output().await.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
/// Updates a ref with compare-and-swap semantics.
async fn git_update_ref_compare_and_swap(
bare: &Path,
ref_name: &str,
old_oid: &str,
new_oid: &str,
) -> std::io::Result<bool> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(bare)
.arg("update-ref")
.arg(ref_name)
.arg(new_oid)
.arg(old_oid)
.output()
.await?;
Ok(output.status.success())
}
/// Finds the merge base of two commits.
async fn git_merge_base(
bare: &Path,
commit1: &str,
commit2: &str,
) -> std::io::Result<Option<String>> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(bare)
.arg("merge-base")
.arg(commit1)
.arg(commit2)
.output()
.await?;
if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
Ok(None)
}
}
/// Lists commits in a range.
async fn git_rev_list(bare: &Path, range: &str) -> std::io::Result<Option<Vec<String>>> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(bare)
.arg("rev-list")
.arg(range)
.output()
.await?;
if output.status.success() {
let commits = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|s| s.to_string())
.collect();
Ok(Some(commits))
} else {
Ok(None)
}
}
/// Gets the formatted log message for a commit.
async fn git_log_format(
bare: &Path,
commit: &str,
format: &str,
) -> std::io::Result<Option<String>> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(bare)
.arg("log")
.arg("-1")
.arg(format!("--format={format}"))
.arg(commit)
.output()
.await?;
if output.status.success() {
Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
} else {
Ok(None)
}
}
/// Cherry-picks a commit onto a base, returning the resulting tree OID.
async fn git_cherry_pick_tree(bare: &Path, base: &str, commit: &str) -> Option<String> {
// This is a simplified implementation. In reality, you'd need to handle
// the cherry-pick more carefully, potentially using git apply or manual
// tree manipulation.
//
// For now, we'll use a simple approach: get the diff of the commit and
// apply it to the base.
// Get the parent of the commit
let parent = git_rev_parse(bare, &format!("{commit}^")).await.ok()??;
// Get the tree of the commit
let commit_tree = git_rev_parse(bare, &format!("{commit}^{{tree}}"))
.await
.ok()??;
// Get the parent tree
let parent_tree = git_rev_parse(bare, &format!("{parent}^{{tree}}"))
.await
.ok()??;
// Get the base tree
let base_tree = git_rev_parse(bare, &format!("{base}^{{tree}}"))
.await
.ok()??;
// Use git merge-tree to apply the diff
let merge_output = tokio::process::Command::new("git")
.arg("-C")
.arg(bare)
.arg("merge-tree")
.arg("--write-tree")
.arg(&parent_tree)
.arg(&base_tree)
.arg(&commit_tree)
.output()
.await
.ok()?;
if merge_output.status.success() {
let output = String::from_utf8_lossy(&merge_output.stdout);
parse_merge_tree_output(&output)
} else {
None
}
}