Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
Rust
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296use gix::bstr::ByteSlice as _;use gix::diff::tree_with_rewrites::Change;use gix::merge::tree::{Conflict, TreatAsUnresolved};
use bobbin_types::sh_tangled::git::temp2::merge_check::{ Conflict as MergeConflict, MergeCheckOutput,};
/// 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<MergeCheckOutput> { 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().into(), reason: conflict_reason(conflict).into(), extra_data: Default::default(), }); }
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(MergeCheckOutput { is_conflicted, conflicts, message: message.map(Into::into), extra_data: Default::default(), })}
/// 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 `<repo>/.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 `crate::git::open_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()); }}