use gix::bstr::ByteSlice as _; use gix::diff::tree_with_rewrites::Change; use gix::merge::tree::{Conflict, TreatAsUnresolved}; use crate::protocol::v1::{MergeCheckResponse, MergeConflict}; /// Perform a 3-way merge check between `target` (ours) and `source` (theirs), using their /// merge base as the common ancestor. /// /// This is read-only: it inspects only the conflicts of the in-memory merge and never persists /// the merged tree. The caller must have opened `repo` with [`gix::Repository::with_object_memory`] /// so that any objects `merge_trees` writes while merging land in memory rather than on disk — /// the mirrored repositories (reached read-only via alternates) are never mutated. pub fn merge_check( repo: &gix::Repository, target_oid: gix::ObjectId, source_oid: gix::ObjectId, ) -> anyhow::Result { let our_tree = repo.find_commit(target_oid)?.tree_id()?.detach(); let their_tree = repo.find_commit(source_oid)?.tree_id()?.detach(); // Common ancestor for the 3-way merge. When the histories are unrelated (no merge base), // fall back to the empty tree — the same behaviour as `git merge --allow-unrelated-histories`, // which surfaces add/add conflicts with real filenames instead of erroring out. let (ancestor_tree, unrelated) = match repo.merge_base(target_oid, source_oid) { Ok(base) => (repo.find_commit(base.detach())?.tree_id()?.detach(), false), Err(_) => (gix::ObjectId::empty_tree(repo.object_hash()), true), }; let options = repo.tree_merge_options()?; let labels = gix::merge::blob::builtin_driver::text::Labels { ancestor: Some("ancestor".as_bytes().as_bstr()), current: Some("target".as_bytes().as_bstr()), other: Some("source".as_bytes().as_bstr()), }; let outcome = repo.merge_trees(ancestor_tree, our_tree, their_tree, labels, options)?; // Report only genuinely-unresolved conflicts (matching git's notion), so gix's // auto-resolutions don't show up as false positives. The merged tree is intentionally // never written. let mut conflicts = Vec::new(); for conflict in &outcome.conflicts { if !conflict.is_unresolved(TreatAsUnresolved::git()) { continue; } conflicts.push(MergeConflict { filename: conflict.ours.location().to_string(), reason: conflict_reason(conflict).to_string(), }); } let is_conflicted = !conflicts.is_empty(); let message = match (is_conflicted, unrelated) { (true, true) => Some(format!( "{} conflicting file(s); unrelated histories (no common ancestor)", conflicts.len() )), (true, false) => Some(format!("{} conflicting file(s)", conflicts.len())), (false, true) => Some("no common ancestor (unrelated histories)".to_string()), (false, false) => None, }; Ok(MergeCheckResponse { is_conflicted, conflicts, message, }) } /// Classify a conflict into a short, human-readable reason from the shape of the two changes. /// Derived from the change variants rather than the resolution failure so it stays stable /// across gix versions. fn conflict_reason(conflict: &Conflict) -> &'static str { match (&conflict.ours, &conflict.theirs) { (Change::Deletion { .. }, _) | (_, Change::Deletion { .. }) => "modify/delete conflict", (Change::Addition { .. }, Change::Addition { .. }) => "add/add conflict", (Change::Rewrite { .. }, _) | (_, Change::Rewrite { .. }) => "rename conflict", _ => "content conflict", } } #[cfg(test)] mod tests { use super::*; use std::path::Path; use std::process::Command; fn git(dir: &Path, args: &[&str]) { let status = Command::new("git") .args(args) .current_dir(dir) .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@t") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@t") .status() .expect("run git"); assert!(status.success(), "git {args:?} failed"); } fn write(dir: &Path, name: &str, content: &str) { std::fs::write(dir.join(name), content).unwrap(); } fn rev_parse(dir: &Path, rev: &str) -> gix::ObjectId { let out = Command::new("git") .args(["rev-parse", rev]) .current_dir(dir) .output() .expect("rev-parse"); let hex = String::from_utf8(out.stdout).unwrap(); gix::ObjectId::from_hex(hex.trim().as_bytes()).unwrap() } /// Count loose object files under `/.git/objects`, ignoring `info` and `pack`. fn count_loose_objects(git_dir: &Path) -> usize { let objects = git_dir.join(".git").join("objects"); let mut n = 0; for shard in std::fs::read_dir(&objects).unwrap() { let shard = shard.unwrap(); let name = shard.file_name(); if name == "info" || name == "pack" || !shard.file_type().unwrap().is_dir() { continue; } n += std::fs::read_dir(shard.path()).unwrap().count(); } n } /// Build a repo with a shared base commit, a `main` (target/ours) tip, and a `source` /// (theirs) tip built by the two closures. fn fixture( target: impl FnOnce(&Path), source: impl FnOnce(&Path), ) -> (tempfile::TempDir, gix::ObjectId, gix::ObjectId) { let dir = tempfile::tempdir().unwrap(); let p = dir.path(); git(p, &["init", "-q", "-b", "main"]); write(p, "a.txt", "line1\nline2\nline3\n"); write(p, "b.txt", "keep\n"); git(p, &["add", "."]); git(p, &["commit", "-q", "-m", "base"]); git(p, &["branch", "source"]); // target edits on main target(p); git(p, &["add", "-A"]); git(p, &["commit", "-q", "-m", "target"]); let target_oid = rev_parse(p, "main"); git(p, &["checkout", "-q", "source"]); source(p); git(p, &["add", "-A"]); git(p, &["commit", "-q", "-m", "source"]); let source_oid = rev_parse(p, "source"); (dir, target_oid, source_oid) } fn open_in_memory(dir: &Path) -> gix::Repository { gix::open(dir).unwrap().with_object_memory() } /// Mirror of `GitMirror::open_merge_scratch`: a throwaway repo whose alternates point at the /// given object dirs, opened in-memory. fn scratch_with_alternates(object_dirs: &[std::path::PathBuf]) -> (tempfile::TempDir, gix::Repository) { let scratch = tempfile::tempdir().unwrap(); gix::init_bare(scratch.path()).unwrap(); let info = scratch.path().join("objects").join("info"); std::fs::create_dir_all(&info).unwrap(); let mut alternates = String::new(); for d in object_dirs { alternates.push_str(&std::fs::canonicalize(d).unwrap().display().to_string()); alternates.push('\n'); } std::fs::write(info.join("alternates"), alternates).unwrap(); let repo = gix::open(scratch.path()).unwrap().with_object_memory(); (scratch, repo) } #[test] fn cross_repo_via_alternates_never_touches_mirrors() { // Two separate repos sharing a base commit: `a` holds the target tip, `b` the source // tip. This is the real gitmirror shape (target and source live in different mirrors). let a = tempfile::tempdir().unwrap(); let ap = a.path(); git(ap, &["init", "-q", "-b", "main"]); write(ap, "a.txt", "line1\nline2\nline3\n"); git(ap, &["add", "."]); git(ap, &["commit", "-q", "-m", "base"]); // target edit in repo `a` write(ap, "a.txt", "line1\nOURS\nline3\n"); git(ap, &["add", "-A"]); git(ap, &["commit", "-q", "-m", "target"]); let target = rev_parse(ap, "main"); // Clone `a` → `b`, reset to the shared base, and make a conflicting source edit. let b = tempfile::tempdir().unwrap(); let bp = b.path(); git(bp, &["clone", "-q", ap.to_str().unwrap(), "."]); git(bp, &["checkout", "-q", "-B", "source", &target.to_string()]); git(bp, &["reset", "-q", "--hard", "HEAD~1"]); // back to base write(bp, "a.txt", "line1\nTHEIRS\nline3\n"); git(bp, &["add", "-A"]); git(bp, &["commit", "-q", "-m", "source"]); let source = rev_parse(bp, "source"); let a_objs = ap.join(".git").join("objects"); let b_objs = bp.join(".git").join("objects"); let before_a = count_loose_objects(ap); let before_b = count_loose_objects(bp); let (_scratch, repo) = scratch_with_alternates(&[a_objs, b_objs]); let out = merge_check(&repo, target, source).unwrap(); assert!(out.is_conflicted); assert_eq!(out.conflicts.len(), 1); assert_eq!(out.conflicts[0].filename, "a.txt"); // Neither mirror gained loose objects — the merge was read-only against both. assert_eq!(count_loose_objects(ap), before_a, "target mirror mutated"); assert_eq!(count_loose_objects(bp), before_b, "source mirror mutated"); } #[test] fn non_overlapping_edits_are_clean() { let (dir, t, s) = fixture( |p| write(p, "a.txt", "line1\nCHANGED\nline3\n"), |p| write(p, "b.txt", "keep\nappended\n"), ); let repo = open_in_memory(dir.path()); let before = count_loose_objects(dir.path()); let out = merge_check(&repo, t, s).unwrap(); assert!(!out.is_conflicted, "expected clean, got {out:?}"); assert!(out.conflicts.is_empty()); // never-touch-the-mirror: the in-memory merge writes no loose objects to disk. assert_eq!(count_loose_objects(dir.path()), before); } #[test] fn same_line_edits_conflict() { let (dir, t, s) = fixture( |p| write(p, "a.txt", "line1\nOURS\nline3\n"), |p| write(p, "a.txt", "line1\nTHEIRS\nline3\n"), ); let repo = open_in_memory(dir.path()); let before = count_loose_objects(dir.path()); let out = merge_check(&repo, t, s).unwrap(); assert!(out.is_conflicted); assert_eq!(out.conflicts.len(), 1); assert_eq!(out.conflicts[0].filename, "a.txt"); assert_eq!(out.conflicts[0].reason, "content conflict"); assert_eq!(count_loose_objects(dir.path()), before); } #[test] fn modify_delete_conflict() { let (dir, t, s) = fixture( |p| write(p, "a.txt", "line1\nOURS\nline3\n"), |p| std::fs::remove_file(p.join("a.txt")).unwrap(), ); let repo = open_in_memory(dir.path()); let out = merge_check(&repo, t, s).unwrap(); assert!(out.is_conflicted); assert_eq!(out.conflicts[0].filename, "a.txt"); assert_eq!(out.conflicts[0].reason, "modify/delete conflict"); } #[test] fn source_is_ancestor_is_clean() { // Source is an ancestor of target (i.e. already merged / up to date) → no conflicts. let dir = tempfile::tempdir().unwrap(); let p = dir.path(); git(p, &["init", "-q", "-b", "main"]); write(p, "a.txt", "line1\nline2\n"); git(p, &["add", "."]); git(p, &["commit", "-q", "-m", "base"]); let base = rev_parse(p, "main"); write(p, "a.txt", "line1\nline2\nline3\n"); git(p, &["add", "-A"]); git(p, &["commit", "-q", "-m", "target"]); let target = rev_parse(p, "main"); let repo = open_in_memory(p); let out = merge_check(&repo, target, base).unwrap(); assert!(!out.is_conflicted, "expected clean, got {out:?}"); assert!(out.conflicts.is_empty()); } }