This repository has no description
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}