use crate::error::{GitError, Result};
use crate::repository::Repository;
use crate::types::Hash;
use crate::utils::{git, git_raw};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum MergeStatus {
Success(Hash),
FastForward(Hash),
UpToDate,
Conflicts(Vec<PathBuf>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FastForwardMode {
Auto,
Only,
Never,
}
impl FastForwardMode {
pub const fn as_str(&self) -> &'static str {
match self {
FastForwardMode::Auto => "",
FastForwardMode::Only => "--ff-only",
FastForwardMode::Never => "--no-ff",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MergeStrategy {
Recursive,
Ours,
Theirs,
}
impl MergeStrategy {
pub const fn as_str(&self) -> &'static str {
match self {
MergeStrategy::Recursive => "recursive",
MergeStrategy::Ours => "ours",
MergeStrategy::Theirs => "theirs",
}
}
}
#[derive(Debug, Clone)]
pub struct MergeOptions {
fast_forward: FastForwardMode,
strategy: Option<MergeStrategy>,
commit_message: Option<String>,
no_commit: bool,
}
impl MergeOptions {
pub fn new() -> Self {
Self {
fast_forward: FastForwardMode::Auto,
strategy: None,
commit_message: None,
no_commit: false,
}
}
pub fn with_fast_forward(mut self, mode: FastForwardMode) -> Self {
self.fast_forward = mode;
self
}
pub fn with_strategy(mut self, strategy: MergeStrategy) -> Self {
self.strategy = Some(strategy);
self
}
pub fn with_message(mut self, message: String) -> Self {
self.commit_message = Some(message);
self
}
pub fn with_no_commit(mut self) -> Self {
self.no_commit = true;
self
}
}
impl Default for MergeOptions {
fn default() -> Self {
Self::new()
}
}
pub fn merge<P: AsRef<Path>>(
repo_path: P,
branch: &str,
options: &MergeOptions,
) -> Result<MergeStatus> {
let mut args = vec!["merge"];
let ff_option = options.fast_forward.as_str();
if !ff_option.is_empty() {
args.push(ff_option);
}
if let Some(strategy) = options.strategy {
args.push("-s");
args.push(strategy.as_str());
}
if options.no_commit {
args.push("--no-commit");
}
if let Some(ref message) = options.commit_message {
args.push("-m");
args.push(message);
}
args.push(branch);
let output = git_raw(&args, Some(repo_path.as_ref()))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
Ok(MergeStatus::UpToDate)
} else if stdout.contains("Fast-forward") {
if let Some(hash_line) = stdout.lines().find(|line| line.contains(".."))
&& let Some(hash_part) = hash_line.split("..").nth(1)
&& let Some(hash_str) = hash_part.split_whitespace().next()
{
let hash = Hash::from(hash_str);
return Ok(MergeStatus::FastForward(hash));
}
let head_output = git(&["rev-parse", "HEAD"], Some(repo_path.as_ref()))?;
let hash = Hash::from(head_output.trim());
Ok(MergeStatus::FastForward(hash))
} else {
let head_output = git(&["rev-parse", "HEAD"], Some(repo_path.as_ref()))?;
let hash = Hash::from(head_output.trim());
Ok(MergeStatus::Success(hash))
}
} else if stderr.contains("CONFLICT")
|| stderr.contains("Automatic merge failed")
|| stdout.contains("CONFLICT")
|| stdout.contains("Automatic merge failed")
{
let conflicts = extract_conflicted_files(repo_path.as_ref())?;
Ok(MergeStatus::Conflicts(conflicts))
} else {
Err(GitError::CommandFailed(format!(
"git {} failed: stdout='{}' stderr='{}'",
args.join(" "),
stdout,
stderr
)))
}
}
fn extract_conflicted_files<P: AsRef<Path>>(repo_path: P) -> Result<Vec<PathBuf>> {
let output = git(
&["diff", "--name-only", "--diff-filter=U"],
Some(repo_path.as_ref()),
)?;
let conflicts: Vec<PathBuf> = output
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| PathBuf::from(line.trim()))
.collect();
Ok(conflicts)
}
pub fn merge_in_progress<P: AsRef<Path>>(repo_path: P) -> Result<bool> {
let git_dir = repo_path.as_ref().join(".git");
let merge_head = git_dir.join("MERGE_HEAD");
Ok(merge_head.exists())
}
pub fn abort_merge<P: AsRef<Path>>(repo_path: P) -> Result<()> {
git(&["merge", "--abort"], Some(repo_path.as_ref()))?;
Ok(())
}
impl Repository {
pub fn merge(&self, branch: &str) -> Result<MergeStatus> {
Self::ensure_git()?;
merge(self.repo_path(), branch, &MergeOptions::new())
}
pub fn merge_with_options(&self, branch: &str, options: MergeOptions) -> Result<MergeStatus> {
Self::ensure_git()?;
merge(self.repo_path(), branch, &options)
}
pub fn merge_in_progress(&self) -> Result<bool> {
Self::ensure_git()?;
merge_in_progress(self.repo_path())
}
pub fn abort_merge(&self) -> Result<()> {
Self::ensure_git()?;
abort_merge(self.repo_path())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Repository;
use std::path::PathBuf;
use std::{env, fs};
fn create_test_repo(test_name: &str) -> (PathBuf, Repository) {
let temp_dir = env::temp_dir().join(format!("rustic_git_merge_test_{}", test_name));
if temp_dir.exists() {
fs::remove_dir_all(&temp_dir).unwrap();
}
let repo = Repository::init(&temp_dir, false).unwrap();
repo.config()
.set_user("Test User", "test@example.com")
.unwrap();
(temp_dir, repo)
}
fn create_file_and_commit(
repo: &Repository,
temp_dir: &Path,
filename: &str,
content: &str,
message: &str,
) -> String {
let file_path = temp_dir.join(filename);
fs::write(&file_path, content).unwrap();
repo.add(&[filename]).unwrap();
repo.commit(message).unwrap().to_string()
}
#[test]
fn test_fast_forward_mode_as_str() {
assert_eq!(FastForwardMode::Auto.as_str(), "");
assert_eq!(FastForwardMode::Only.as_str(), "--ff-only");
assert_eq!(FastForwardMode::Never.as_str(), "--no-ff");
}
#[test]
fn test_merge_strategy_as_str() {
assert_eq!(MergeStrategy::Recursive.as_str(), "recursive");
assert_eq!(MergeStrategy::Ours.as_str(), "ours");
assert_eq!(MergeStrategy::Theirs.as_str(), "theirs");
}
#[test]
fn test_merge_options_builder() {
let options = MergeOptions::new()
.with_fast_forward(FastForwardMode::Never)
.with_strategy(MergeStrategy::Ours)
.with_message("Custom merge message".to_string())
.with_no_commit();
assert_eq!(options.fast_forward, FastForwardMode::Never);
assert_eq!(options.strategy, Some(MergeStrategy::Ours));
assert_eq!(
options.commit_message,
Some("Custom merge message".to_string())
);
assert!(options.no_commit);
}
#[test]
fn test_merge_fast_forward() {
let (temp_dir, repo) = create_test_repo("merge_fast_forward");
create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
repo.checkout_new("feature", None).unwrap();
create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit");
let branches = repo.branches().unwrap();
let master_branch = branches.find("master").unwrap();
repo.checkout(master_branch).unwrap();
let status = repo.merge("feature").unwrap();
match status {
MergeStatus::FastForward(_) => {
assert!(temp_dir.join("file2.txt").exists());
}
_ => panic!("Expected fast-forward merge, got: {:?}", status),
}
fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_merge_no_fast_forward() {
let (temp_dir, repo) = create_test_repo("merge_no_ff");
create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
repo.checkout_new("feature", None).unwrap();
create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit");
let branches = repo.branches().unwrap();
let master_branch = branches.find("master").unwrap();
repo.checkout(master_branch).unwrap();
let options = MergeOptions::new().with_fast_forward(FastForwardMode::Never);
let status = repo.merge_with_options("feature", options).unwrap();
match status {
MergeStatus::Success(_) => {
assert!(temp_dir.join("file1.txt").exists());
assert!(temp_dir.join("file2.txt").exists());
}
_ => panic!("Expected merge commit, got: {:?}", status),
}
fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_merge_up_to_date() {
let (temp_dir, repo) = create_test_repo("merge_up_to_date");
create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
repo.checkout_new("feature", None).unwrap();
let branches = repo.branches().unwrap();
let master_branch = branches.find("master").unwrap();
repo.checkout(master_branch).unwrap();
let status = repo.merge("feature").unwrap();
assert_eq!(status, MergeStatus::UpToDate);
fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_merge_in_progress_false() {
let (temp_dir, repo) = create_test_repo("merge_in_progress_false");
create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
assert!(!repo.merge_in_progress().unwrap());
fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_merge_conflicts() {
let (temp_dir, repo) = create_test_repo("merge_conflicts");
create_file_and_commit(
&repo,
&temp_dir,
"file1.txt",
"line1\nline2\nline3",
"Initial commit",
);
repo.checkout_new("feature", None).unwrap();
create_file_and_commit(
&repo,
&temp_dir,
"file1.txt",
"line1\nfeature_line\nline3",
"Feature changes",
);
let branches = repo.branches().unwrap();
let master_branch = branches.find("master").unwrap();
repo.checkout(master_branch).unwrap();
create_file_and_commit(
&repo,
&temp_dir,
"file1.txt",
"line1\nmaster_line\nline3",
"Master changes",
);
let status = repo.merge("feature").unwrap();
match status {
MergeStatus::Conflicts(files) => {
assert!(!files.is_empty());
assert!(files.iter().any(|f| f.file_name().unwrap() == "file1.txt"));
assert!(repo.merge_in_progress().unwrap());
repo.abort_merge().unwrap();
assert!(!repo.merge_in_progress().unwrap());
}
_ => panic!("Expected conflicts, got: {:?}", status),
}
fs::remove_dir_all(&temp_dir).unwrap();
}
#[test]
fn test_merge_with_custom_message() {
let (temp_dir, repo) = create_test_repo("merge_custom_message");
create_file_and_commit(&repo, &temp_dir, "file1.txt", "content1", "Initial commit");
repo.checkout_new("feature", None).unwrap();
create_file_and_commit(&repo, &temp_dir, "file2.txt", "content2", "Feature commit");
let branches = repo.branches().unwrap();
let master_branch = branches.find("master").unwrap();
repo.checkout(master_branch).unwrap();
let options = MergeOptions::new()
.with_fast_forward(FastForwardMode::Never)
.with_message("Custom merge commit message".to_string());
let status = repo.merge_with_options("feature", options).unwrap();
match status {
MergeStatus::Success(_) => {
let commits = repo.recent_commits(1).unwrap();
let latest_commit = commits.iter().next().unwrap();
assert!(
latest_commit
.message
.subject
.contains("Custom merge commit message")
);
}
_ => panic!("Expected successful merge, got: {:?}", status),
}
fs::remove_dir_all(&temp_dir).unwrap();
}
}