🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! 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.
    /// Also includes the snapshot OIDs (base and head) for diff preservation.
    Success {
        new_target_oid: String,
        snapshot_base_oid: String,
        snapshot_head_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,
                &merge_base,
                &source_oid,
            )
            .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,
                    &merge_base,
                    &source_oid,
                )
                .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,
                &merge_base,
                &source_oid,
            )
            .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,
    snapshot_base_oid: &str,
    snapshot_head_oid: &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(),
        snapshot_base_oid: snapshot_base_oid.to_string(),
        snapshot_head_oid: snapshot_head_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,
        snapshot_base_oid: merge_base.to_string(),
        snapshot_head_oid: source_oid.to_string(),
    })
}

/// 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,
    snapshot_base_oid: &str,
    snapshot_head_oid: &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,
            snapshot_base_oid,
            snapshot_head_oid,
        )
        .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, &current_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, &[&current_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,
        &current_base,
    )
    .await?
    {
        return Ok(MergeResult::Refused {
            reason: "concurrent push to target branch, please retry".to_string(),
        });
    }

    Ok(MergeResult::Success {
        new_target_oid: current_base,
        snapshot_base_oid: snapshot_base_oid.to_string(),
        snapshot_head_oid: snapshot_head_oid.to_string(),
    })
}

/// 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
    }
}