use alloc::vec::Vec;
use crate::diff;
use crate::error::{Error, Result};
use crate::object::tree::FileMode;
use crate::object::{Object, ObjectType, Signature};
use crate::odb::ObjectDatabase;
use crate::oid::ObjectId;
use crate::repository::Repository;
use crate::tree_builder::write_tree_from_entries;
use crate::walk;
pub struct TreeMergeResult {
pub tree: ObjectId,
pub conflicts: Vec<Vec<u8>>,
}
pub fn merge_trees<D: ObjectDatabase>(
odb: &D,
base: Option<&ObjectId>,
ours: &ObjectId,
theirs: &ObjectId,
) -> Result<TreeMergeResult> {
use alloc::collections::{BTreeMap, BTreeSet};
let base_map = match base {
Some(id) => walk::flatten_tree(odb, id)?,
None => BTreeMap::new(),
};
let ours_map = walk::flatten_tree(odb, ours)?;
let theirs_map = walk::flatten_tree(odb, theirs)?;
let mut paths: BTreeSet<&Vec<u8>> = BTreeSet::new();
paths.extend(base_map.keys());
paths.extend(ours_map.keys());
paths.extend(theirs_map.keys());
let mut entries: Vec<(Vec<u8>, FileMode, ObjectId)> = Vec::new();
let mut conflicts: Vec<Vec<u8>> = Vec::new();
for path in paths {
let b = base_map.get(path).copied();
let o = ours_map.get(path).copied();
let t = theirs_map.get(path).copied();
if o == t {
if let Some((mode, id)) = o {
entries.push((path.clone(), mode, id));
}
} else if o == b {
if let Some((mode, id)) = t {
entries.push((path.clone(), mode, id));
}
} else if t == b {
if let Some((mode, id)) = o {
entries.push((path.clone(), mode, id));
}
} else {
match (b, o, t) {
(Some((bm, bid)), Some((om, oid)), Some((tm, tid)))
if is_file(bm) && is_file(om) && is_file(tm) && om == tm =>
{
let base_bytes = read_blob(odb, &bid)?;
let ours_bytes = read_blob(odb, &oid)?;
let theirs_bytes = read_blob(odb, &tid)?;
let m = diff::merge3(&base_bytes, &ours_bytes, &theirs_bytes);
let merged_id = odb.write(ObjectType::Blob, &m.merged)?;
entries.push((path.clone(), om, merged_id));
if m.conflicted {
conflicts.push(path.clone());
}
}
_ => {
conflicts.push(path.clone());
if let Some((mode, id)) = o {
entries.push((path.clone(), mode, id));
} else if let Some((mode, id)) = t {
entries.push((path.clone(), mode, id));
}
}
}
}
}
let tree = write_tree_from_entries(odb, entries)?;
Ok(TreeMergeResult { tree, conflicts })
}
fn is_file(mode: FileMode) -> bool {
matches!(mode, FileMode::Regular | FileMode::Executable)
}
fn read_blob<D: ObjectDatabase>(odb: &D, id: &ObjectId) -> Result<Vec<u8>> {
match Object::parse(
odb.algo(),
ObjectType::Blob,
&odb.read_typed(id, ObjectType::Blob)?,
)? {
Object::Blob(b) => Ok(b),
_ => Err(Error::Parse("merge: expected a blob".into())),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeOutcome {
AlreadyUpToDate,
FastForward(ObjectId),
Merged(ObjectId),
Conflicts(Vec<Vec<u8>>),
}
impl Repository {
pub fn merge(
&self,
theirs: &ObjectId,
author: Signature,
committer: Signature,
message: &[u8],
) -> Result<MergeOutcome> {
let ours = self.head_id()?;
if ours == *theirs {
return Ok(MergeOutcome::AlreadyUpToDate);
}
if walk::is_ancestor(self.objects(), theirs, &ours)? {
return Ok(MergeOutcome::AlreadyUpToDate);
}
if walk::is_ancestor(self.objects(), &ours, theirs)? {
self.update_current_branch(theirs)?;
self.checkout_commit_worktree(theirs)?;
return Ok(MergeOutcome::FastForward(*theirs));
}
let base = walk::merge_base(self.objects(), &ours, theirs)?;
let base_tree = match base {
Some(id) => Some(self.commit_tree(&id)?),
None => None,
};
let ours_tree = self.commit_tree(&ours)?;
let theirs_tree = self.commit_tree(theirs)?;
let result = merge_trees(self.objects(), base_tree.as_ref(), &ours_tree, &theirs_tree)?;
if !result.conflicts.is_empty() {
return Ok(MergeOutcome::Conflicts(result.conflicts));
}
let commit = crate::object::Commit {
tree: result.tree,
parents: alloc::vec![ours, *theirs],
author,
committer,
extra_headers: Vec::new(),
message: message.to_vec(),
};
let id = self
.objects()
.write(ObjectType::Commit, &commit.serialize())?;
self.update_current_branch(&id)?;
self.checkout_commit_worktree(&id)?;
Ok(MergeOutcome::Merged(id))
}
fn commit_tree(&self, commit_id: &ObjectId) -> Result<ObjectId> {
match self.read_object(commit_id)? {
Object::Commit(c) => Ok(c.tree),
other => Err(Error::UnexpectedType {
expected: ObjectType::Commit,
actual: other.object_type(),
}),
}
}
fn checkout_commit_worktree(&self, commit_id: &ObjectId) -> Result<()> {
if let Some(work) = self.work_tree() {
let work = work.to_path_buf();
if let Object::Commit(c) = self.read_object(commit_id)? {
self.switch_worktree(&c.tree, &work)?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sig() -> Signature {
Signature {
name: b"T".to_vec(),
email: b"t@e".to_vec(),
time: 0,
tz: b"+0000".to_vec(),
}
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(alloc::format!("puregit-merge-{name}-{}", core::line!()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn clean_three_way_merge() {
let dir = scratch("clean");
let repo = Repository::init(&dir).unwrap();
std::fs::write(dir.join("x.txt"), b"x-base\n").unwrap();
std::fs::write(dir.join("y.txt"), b"y-base\n").unwrap();
repo.add_path("x.txt").unwrap();
repo.add_path("y.txt").unwrap();
repo.commit(b"base\n", sig(), sig()).unwrap();
repo.create_branch("feature", None).unwrap();
repo.checkout("feature").unwrap();
std::fs::write(dir.join("y.txt"), b"y-feature\n").unwrap();
repo.add_path("y.txt").unwrap();
let their = repo.commit(b"feature edits y\n", sig(), sig()).unwrap();
repo.checkout("main").unwrap();
std::fs::write(dir.join("x.txt"), b"x-main\n").unwrap();
repo.add_path("x.txt").unwrap();
repo.commit(b"main edits x\n", sig(), sig()).unwrap();
let outcome = repo
.merge(&their, sig(), sig(), b"merge feature\n")
.unwrap();
let merge_id = match outcome {
MergeOutcome::Merged(id) => id,
other => panic!("expected a merge commit, got {other:?}"),
};
let commit = match repo.read_object(&merge_id).unwrap() {
Object::Commit(c) => c,
_ => panic!("not a commit"),
};
assert_eq!(commit.parents.len(), 2);
assert_eq!(std::fs::read(dir.join("x.txt")).unwrap(), b"x-main\n");
assert_eq!(std::fs::read(dir.join("y.txt")).unwrap(), b"y-feature\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn conflicting_merge_reports_paths() {
let dir = scratch("conflict");
let repo = Repository::init(&dir).unwrap();
std::fs::write(dir.join("f.txt"), b"line1\nline2\nline3\n").unwrap();
repo.add_path("f.txt").unwrap();
repo.commit(b"base\n", sig(), sig()).unwrap();
repo.create_branch("feature", None).unwrap();
repo.checkout("feature").unwrap();
std::fs::write(dir.join("f.txt"), b"line1\nTHEIRS\nline3\n").unwrap();
repo.add_path("f.txt").unwrap();
let their = repo.commit(b"theirs\n", sig(), sig()).unwrap();
repo.checkout("main").unwrap();
std::fs::write(dir.join("f.txt"), b"line1\nOURS\nline3\n").unwrap();
repo.add_path("f.txt").unwrap();
repo.commit(b"ours\n", sig(), sig()).unwrap();
match repo.merge(&their, sig(), sig(), b"m\n").unwrap() {
MergeOutcome::Conflicts(paths) => assert!(paths.iter().any(|p| p == b"f.txt")),
other => panic!("expected a conflict, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
}