This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / gitmirror / src / merge.rs
12 kB 289 lines
1use gix::bstr::ByteSlice as _; 2use gix::diff::tree_with_rewrites::Change; 3use gix::merge::tree::{Conflict, TreatAsUnresolved}; 4 5use crate::protocol::v1::{MergeCheckResponse, MergeConflict}; 6 7/// Perform a 3-way merge check between `target` (ours) and `source` (theirs), using their 8/// merge base as the common ancestor. 9/// 10/// This is read-only: it inspects only the conflicts of the in-memory merge and never persists 11/// the merged tree. The caller must have opened `repo` with [`gix::Repository::with_object_memory`] 12/// so that any objects `merge_trees` writes while merging land in memory rather than on disk — 13/// the mirrored repositories (reached read-only via alternates) are never mutated. 14pub fn merge_check( 15 repo: &gix::Repository, 16 target_oid: gix::ObjectId, 17 source_oid: gix::ObjectId, 18) -> anyhow::Result<MergeCheckResponse> { 19 let our_tree = repo.find_commit(target_oid)?.tree_id()?.detach(); 20 let their_tree = repo.find_commit(source_oid)?.tree_id()?.detach(); 21 22 // Common ancestor for the 3-way merge. When the histories are unrelated (no merge base), 23 // fall back to the empty tree — the same behaviour as `git merge --allow-unrelated-histories`, 24 // which surfaces add/add conflicts with real filenames instead of erroring out. 25 let (ancestor_tree, unrelated) = match repo.merge_base(target_oid, source_oid) { 26 Ok(base) => (repo.find_commit(base.detach())?.tree_id()?.detach(), false), 27 Err(_) => (gix::ObjectId::empty_tree(repo.object_hash()), true), 28 }; 29 30 let options = repo.tree_merge_options()?; 31 let labels = gix::merge::blob::builtin_driver::text::Labels { 32 ancestor: Some("ancestor".as_bytes().as_bstr()), 33 current: Some("target".as_bytes().as_bstr()), 34 other: Some("source".as_bytes().as_bstr()), 35 }; 36 37 let outcome = repo.merge_trees(ancestor_tree, our_tree, their_tree, labels, options)?; 38 39 // Report only genuinely-unresolved conflicts (matching git's notion), so gix's 40 // auto-resolutions don't show up as false positives. The merged tree is intentionally 41 // never written. 42 let mut conflicts = Vec::new(); 43 for conflict in &outcome.conflicts { 44 if !conflict.is_unresolved(TreatAsUnresolved::git()) { 45 continue; 46 } 47 conflicts.push(MergeConflict { 48 filename: conflict.ours.location().to_string(), 49 reason: conflict_reason(conflict).to_string(), 50 }); 51 } 52 53 let is_conflicted = !conflicts.is_empty(); 54 let message = match (is_conflicted, unrelated) { 55 (true, true) => Some(format!( 56 "{} conflicting file(s); unrelated histories (no common ancestor)", 57 conflicts.len() 58 )), 59 (true, false) => Some(format!("{} conflicting file(s)", conflicts.len())), 60 (false, true) => Some("no common ancestor (unrelated histories)".to_string()), 61 (false, false) => None, 62 }; 63 64 Ok(MergeCheckResponse { 65 is_conflicted, 66 conflicts, 67 message, 68 }) 69} 70 71/// Classify a conflict into a short, human-readable reason from the shape of the two changes. 72/// Derived from the change variants rather than the resolution failure so it stays stable 73/// across gix versions. 74fn conflict_reason(conflict: &Conflict) -> &'static str { 75 match (&conflict.ours, &conflict.theirs) { 76 (Change::Deletion { .. }, _) | (_, Change::Deletion { .. }) => "modify/delete conflict", 77 (Change::Addition { .. }, Change::Addition { .. }) => "add/add conflict", 78 (Change::Rewrite { .. }, _) | (_, Change::Rewrite { .. }) => "rename conflict", 79 _ => "content conflict", 80 } 81} 82 83#[cfg(test)] 84mod tests { 85 use super::*; 86 use std::path::Path; 87 use std::process::Command; 88 89 fn git(dir: &Path, args: &[&str]) { 90 let status = Command::new("git") 91 .args(args) 92 .current_dir(dir) 93 .env("GIT_AUTHOR_NAME", "t") 94 .env("GIT_AUTHOR_EMAIL", "t@t") 95 .env("GIT_COMMITTER_NAME", "t") 96 .env("GIT_COMMITTER_EMAIL", "t@t") 97 .status() 98 .expect("run git"); 99 assert!(status.success(), "git {args:?} failed"); 100 } 101 102 fn write(dir: &Path, name: &str, content: &str) { 103 std::fs::write(dir.join(name), content).unwrap(); 104 } 105 106 fn rev_parse(dir: &Path, rev: &str) -> gix::ObjectId { 107 let out = Command::new("git") 108 .args(["rev-parse", rev]) 109 .current_dir(dir) 110 .output() 111 .expect("rev-parse"); 112 let hex = String::from_utf8(out.stdout).unwrap(); 113 gix::ObjectId::from_hex(hex.trim().as_bytes()).unwrap() 114 } 115 116 /// Count loose object files under `<repo>/.git/objects`, ignoring `info` and `pack`. 117 fn count_loose_objects(git_dir: &Path) -> usize { 118 let objects = git_dir.join(".git").join("objects"); 119 let mut n = 0; 120 for shard in std::fs::read_dir(&objects).unwrap() { 121 let shard = shard.unwrap(); 122 let name = shard.file_name(); 123 if name == "info" || name == "pack" || !shard.file_type().unwrap().is_dir() { 124 continue; 125 } 126 n += std::fs::read_dir(shard.path()).unwrap().count(); 127 } 128 n 129 } 130 131 /// Build a repo with a shared base commit, a `main` (target/ours) tip, and a `source` 132 /// (theirs) tip built by the two closures. 133 fn fixture( 134 target: impl FnOnce(&Path), 135 source: impl FnOnce(&Path), 136 ) -> (tempfile::TempDir, gix::ObjectId, gix::ObjectId) { 137 let dir = tempfile::tempdir().unwrap(); 138 let p = dir.path(); 139 git(p, &["init", "-q", "-b", "main"]); 140 write(p, "a.txt", "line1\nline2\nline3\n"); 141 write(p, "b.txt", "keep\n"); 142 git(p, &["add", "."]); 143 git(p, &["commit", "-q", "-m", "base"]); 144 145 git(p, &["branch", "source"]); 146 // target edits on main 147 target(p); 148 git(p, &["add", "-A"]); 149 git(p, &["commit", "-q", "-m", "target"]); 150 let target_oid = rev_parse(p, "main"); 151 152 git(p, &["checkout", "-q", "source"]); 153 source(p); 154 git(p, &["add", "-A"]); 155 git(p, &["commit", "-q", "-m", "source"]); 156 let source_oid = rev_parse(p, "source"); 157 158 (dir, target_oid, source_oid) 159 } 160 161 fn open_in_memory(dir: &Path) -> gix::Repository { 162 gix::open(dir).unwrap().with_object_memory() 163 } 164 165 /// Mirror of `GitMirror::open_merge_scratch`: a throwaway repo whose alternates point at the 166 /// given object dirs, opened in-memory. 167 fn scratch_with_alternates(object_dirs: &[std::path::PathBuf]) -> (tempfile::TempDir, gix::Repository) { 168 let scratch = tempfile::tempdir().unwrap(); 169 gix::init_bare(scratch.path()).unwrap(); 170 let info = scratch.path().join("objects").join("info"); 171 std::fs::create_dir_all(&info).unwrap(); 172 let mut alternates = String::new(); 173 for d in object_dirs { 174 alternates.push_str(&std::fs::canonicalize(d).unwrap().display().to_string()); 175 alternates.push('\n'); 176 } 177 std::fs::write(info.join("alternates"), alternates).unwrap(); 178 let repo = gix::open(scratch.path()).unwrap().with_object_memory(); 179 (scratch, repo) 180 } 181 182 #[test] 183 fn cross_repo_via_alternates_never_touches_mirrors() { 184 // Two separate repos sharing a base commit: `a` holds the target tip, `b` the source 185 // tip. This is the real gitmirror shape (target and source live in different mirrors). 186 let a = tempfile::tempdir().unwrap(); 187 let ap = a.path(); 188 git(ap, &["init", "-q", "-b", "main"]); 189 write(ap, "a.txt", "line1\nline2\nline3\n"); 190 git(ap, &["add", "."]); 191 git(ap, &["commit", "-q", "-m", "base"]); 192 // target edit in repo `a` 193 write(ap, "a.txt", "line1\nOURS\nline3\n"); 194 git(ap, &["add", "-A"]); 195 git(ap, &["commit", "-q", "-m", "target"]); 196 let target = rev_parse(ap, "main"); 197 198 // Clone `a` → `b`, reset to the shared base, and make a conflicting source edit. 199 let b = tempfile::tempdir().unwrap(); 200 let bp = b.path(); 201 git(bp, &["clone", "-q", ap.to_str().unwrap(), "."]); 202 git(bp, &["checkout", "-q", "-B", "source", &target.to_string()]); 203 git(bp, &["reset", "-q", "--hard", "HEAD~1"]); // back to base 204 write(bp, "a.txt", "line1\nTHEIRS\nline3\n"); 205 git(bp, &["add", "-A"]); 206 git(bp, &["commit", "-q", "-m", "source"]); 207 let source = rev_parse(bp, "source"); 208 209 let a_objs = ap.join(".git").join("objects"); 210 let b_objs = bp.join(".git").join("objects"); 211 let before_a = count_loose_objects(ap); 212 let before_b = count_loose_objects(bp); 213 214 let (_scratch, repo) = scratch_with_alternates(&[a_objs, b_objs]); 215 let out = merge_check(&repo, target, source).unwrap(); 216 217 assert!(out.is_conflicted); 218 assert_eq!(out.conflicts.len(), 1); 219 assert_eq!(out.conflicts[0].filename, "a.txt"); 220 // Neither mirror gained loose objects — the merge was read-only against both. 221 assert_eq!(count_loose_objects(ap), before_a, "target mirror mutated"); 222 assert_eq!(count_loose_objects(bp), before_b, "source mirror mutated"); 223 } 224 225 #[test] 226 fn non_overlapping_edits_are_clean() { 227 let (dir, t, s) = fixture( 228 |p| write(p, "a.txt", "line1\nCHANGED\nline3\n"), 229 |p| write(p, "b.txt", "keep\nappended\n"), 230 ); 231 let repo = open_in_memory(dir.path()); 232 let before = count_loose_objects(dir.path()); 233 let out = merge_check(&repo, t, s).unwrap(); 234 assert!(!out.is_conflicted, "expected clean, got {out:?}"); 235 assert!(out.conflicts.is_empty()); 236 // never-touch-the-mirror: the in-memory merge writes no loose objects to disk. 237 assert_eq!(count_loose_objects(dir.path()), before); 238 } 239 240 #[test] 241 fn same_line_edits_conflict() { 242 let (dir, t, s) = fixture( 243 |p| write(p, "a.txt", "line1\nOURS\nline3\n"), 244 |p| write(p, "a.txt", "line1\nTHEIRS\nline3\n"), 245 ); 246 let repo = open_in_memory(dir.path()); 247 let before = count_loose_objects(dir.path()); 248 let out = merge_check(&repo, t, s).unwrap(); 249 assert!(out.is_conflicted); 250 assert_eq!(out.conflicts.len(), 1); 251 assert_eq!(out.conflicts[0].filename, "a.txt"); 252 assert_eq!(out.conflicts[0].reason, "content conflict"); 253 assert_eq!(count_loose_objects(dir.path()), before); 254 } 255 256 #[test] 257 fn modify_delete_conflict() { 258 let (dir, t, s) = fixture( 259 |p| write(p, "a.txt", "line1\nOURS\nline3\n"), 260 |p| std::fs::remove_file(p.join("a.txt")).unwrap(), 261 ); 262 let repo = open_in_memory(dir.path()); 263 let out = merge_check(&repo, t, s).unwrap(); 264 assert!(out.is_conflicted); 265 assert_eq!(out.conflicts[0].filename, "a.txt"); 266 assert_eq!(out.conflicts[0].reason, "modify/delete conflict"); 267 } 268 269 #[test] 270 fn source_is_ancestor_is_clean() { 271 // Source is an ancestor of target (i.e. already merged / up to date) → no conflicts. 272 let dir = tempfile::tempdir().unwrap(); 273 let p = dir.path(); 274 git(p, &["init", "-q", "-b", "main"]); 275 write(p, "a.txt", "line1\nline2\n"); 276 git(p, &["add", "."]); 277 git(p, &["commit", "-q", "-m", "base"]); 278 let base = rev_parse(p, "main"); 279 write(p, "a.txt", "line1\nline2\nline3\n"); 280 git(p, &["add", "-A"]); 281 git(p, &["commit", "-q", "-m", "target"]); 282 let target = rev_parse(p, "main"); 283 284 let repo = open_in_memory(p); 285 let out = merge_check(&repo, target, base).unwrap(); 286 assert!(!out.is_conflicted, "expected clean, got {out:?}"); 287 assert!(out.conflicts.is_empty()); 288 } 289}