Skip to content

Commit 738ffd2

Browse files
authored
Merge pull request #364 from jelmer/merge-3.3
Merge 3.3
2 parents babb260 + f50a005 commit 738ffd2

6 files changed

Lines changed: 361 additions & 16 deletions

File tree

breezy/bzr/remote.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
import fastbencode as bencode
3232
import vcsgraph.errors
33+
import vcsgraph.graph
3334
from vcsgraph import known_graph
3435

3536
from .. import (

breezy/git/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ def load_tests(loader, basic_tests, pattern):
216216
"test_git_remote_helper",
217217
"test_mapping",
218218
"test_memorytree",
219+
"test_merge",
219220
"test_object_store",
220221
"test_pristine_tar",
221222
"test_push",

breezy/git/tests/test_merge.py

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
# Copyright (C) 2026 Jelmer Vernooij <jelmer@jelmer.uk>
2+
#
3+
# This program is free software; you can redistribute it and/or modify
4+
# it under the terms of the GNU General Public License as published by
5+
# the Free Software Foundation; either version 2 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU General Public License
14+
# along with this program; if not, write to the Free Software
15+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
16+
17+
"""Tests for merging git branches."""
18+
19+
import os
20+
21+
from ...merge import Merge3Merger
22+
from ...tests import TestCaseWithTransport
23+
24+
LONG_CONTENT = b"\n".join(f"line{i}".encode() for i in range(50)) + b"\n"
25+
26+
27+
class GitMergeTestBase(TestCaseWithTransport):
28+
"""Common setup for git merge tests.
29+
30+
Builds ``local`` (which acts as THIS / working tree) seeded with
31+
``initial_files`` and gives subclasses a helper for forking an
32+
``other`` branch off the base revision.
33+
"""
34+
35+
def _make_local(self, initial_files):
36+
wt = self.make_branch_and_tree("local", format="git")
37+
# Ensure parent dirs exist — build_tree_contents won't create
38+
# them. Collect the unique parent paths in order so deeper
39+
# ancestors come after their parents.
40+
dirs = []
41+
for path, _ in initial_files:
42+
parts = path.split("/")
43+
for i in range(1, len(parts)):
44+
dir_path = "/".join(parts[:i])
45+
if dir_path and dir_path not in dirs:
46+
dirs.append(dir_path)
47+
for d in dirs:
48+
os.mkdir(wt.abspath(d))
49+
self.build_tree_contents(
50+
[("local/" + path, content) for path, content in initial_files]
51+
)
52+
wt.add(dirs + [path for path, _ in initial_files])
53+
wt.commit("base")
54+
return wt
55+
56+
def _fork_other(self, wt, base_rev, mutate):
57+
"""Sprout ``other`` from ``base_rev``, run ``mutate`` on it, return the tree."""
58+
other_cd = wt.branch.controldir.sprout("other", revision_id=base_rev)
59+
other_wt = other_cd.open_workingtree()
60+
mutate(other_wt)
61+
other_rev = other_wt.commit("other change")
62+
return other_cd.open_branch().repository.revision_tree(other_rev), other_rev
63+
64+
def _merge(self, wt, base_rev, other_tree):
65+
base_tree = wt.branch.repository.revision_tree(base_rev)
66+
merger = Merge3Merger(
67+
working_tree=wt,
68+
this_tree=wt,
69+
base_tree=base_tree,
70+
other_tree=other_tree,
71+
do_merge=False,
72+
)
73+
merger.do_merge()
74+
return merger
75+
76+
77+
class GitRenameMergeTests(GitMergeTestBase):
78+
"""Rename scenarios — the regression class that motivated this file.
79+
80+
Git's ``iter_changes`` attaches a synthetic ``file_id`` derived from
81+
BASE/OTHER's path, not THIS's. Routing trans_id resolution through
82+
that synthetic id picks up the wrong path on path-based trees:
83+
``trans_id_file_id(b'git:a.txt')`` resolves to a phantom trans_id at
84+
``"a.txt"`` even though THIS has the file at ``"b.txt"``. The merge
85+
then fails with ``MalformedTransform: unversioned executability``.
86+
87+
``_compute_transform`` keys directly off ``paths3[2]`` (THIS's path)
88+
for path-based trees instead.
89+
"""
90+
91+
def test_rename_in_this_modify_in_other(self):
92+
"""THIS renames a.txt -> b.txt; OTHER modifies a.txt."""
93+
wt = self._make_local([("a.txt", LONG_CONTENT)])
94+
base_rev = wt.last_revision()
95+
96+
os.rename(wt.abspath("a.txt"), wt.abspath("b.txt"))
97+
wt.remove(["a.txt"])
98+
wt.add(["b.txt"])
99+
wt.commit("rename")
100+
101+
def mutate(other_wt):
102+
self.build_tree_contents([("other/a.txt", LONG_CONTENT + b"appended\n")])
103+
104+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
105+
merger = self._merge(wt, base_rev, other_tree)
106+
107+
self.assertEqual([], list(merger.cooked_conflicts))
108+
self.assertFalse(os.path.exists(wt.abspath("a.txt")))
109+
with open(wt.abspath("b.txt"), "rb") as f:
110+
self.assertEqual(LONG_CONTENT + b"appended\n", f.read())
111+
112+
def test_rename_in_other_modify_in_this(self):
113+
"""Symmetric case: OTHER renames; THIS modifies in place.
114+
115+
Without rename detection across this direction, OTHER's rename
116+
looks like a delete + add to the merger. The expected outcome
117+
is therefore that THIS's modification stays at the original
118+
path; the new path from OTHER also lands. (This pins current
119+
behaviour rather than the "ideal" outcome a rename-aware merge
120+
would produce.)
121+
"""
122+
wt = self._make_local([("a.txt", LONG_CONTENT)])
123+
base_rev = wt.last_revision()
124+
125+
# THIS modifies a.txt in place.
126+
self.build_tree_contents([("local/a.txt", LONG_CONTENT + b"this_change\n")])
127+
wt.commit("modify")
128+
129+
def mutate(other_wt):
130+
os.rename(other_wt.abspath("a.txt"), other_wt.abspath("b.txt"))
131+
other_wt.remove(["a.txt"])
132+
other_wt.add(["b.txt"])
133+
134+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
135+
merger = self._merge(wt, base_rev, other_tree)
136+
# The merge must complete (not crash); we don't assert a
137+
# specific resolution because rename-vs-modify is genuinely
138+
# ambiguous. Just check it terminates and produces a tree.
139+
self.assertIsNotNone(merger)
140+
141+
142+
class GitContentMergeTests(GitMergeTestBase):
143+
"""Plain content merges that don't touch path/identity."""
144+
145+
def test_clean_disjoint_changes(self):
146+
"""THIS and OTHER edit different files — no conflict."""
147+
wt = self._make_local([("a.txt", b"a base\n"), ("b.txt", b"b base\n")])
148+
base_rev = wt.last_revision()
149+
150+
self.build_tree_contents([("local/a.txt", b"a base\nthis\n")])
151+
wt.commit("modify a")
152+
153+
def mutate(other_wt):
154+
self.build_tree_contents([("other/b.txt", b"b base\nother\n")])
155+
156+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
157+
merger = self._merge(wt, base_rev, other_tree)
158+
159+
self.assertEqual([], list(merger.cooked_conflicts))
160+
with open(wt.abspath("a.txt"), "rb") as f:
161+
self.assertEqual(b"a base\nthis\n", f.read())
162+
with open(wt.abspath("b.txt"), "rb") as f:
163+
self.assertEqual(b"b base\nother\n", f.read())
164+
165+
def test_text_conflict_same_line(self):
166+
"""THIS and OTHER edit the same line — text conflict markers."""
167+
wt = self._make_local([("a.txt", b"shared\n")])
168+
base_rev = wt.last_revision()
169+
170+
self.build_tree_contents([("local/a.txt", b"this side\n")])
171+
wt.commit("this edit")
172+
173+
def mutate(other_wt):
174+
self.build_tree_contents([("other/a.txt", b"other side\n")])
175+
176+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
177+
merger = self._merge(wt, base_rev, other_tree)
178+
179+
conflicts = list(merger.cooked_conflicts)
180+
self.assertEqual(1, len(conflicts))
181+
self.assertEqual("Text conflict in a.txt", str(conflicts[0]))
182+
with open(wt.abspath("a.txt"), "rb") as f:
183+
merged = f.read()
184+
self.assertIn(b"<<<<<<< TREE", merged)
185+
self.assertIn(b"this side", merged)
186+
self.assertIn(b"other side", merged)
187+
self.assertIn(b">>>>>>> MERGE-SOURCE", merged)
188+
189+
def test_clean_non_overlapping_lines(self):
190+
"""THIS and OTHER edit different lines of the same file — auto-merged."""
191+
base = b"".join(f"line{i}\n".encode() for i in range(10))
192+
wt = self._make_local([("a.txt", base)])
193+
base_rev = wt.last_revision()
194+
195+
# THIS edits line0
196+
this_content = base.replace(b"line0\n", b"line0_this\n")
197+
self.build_tree_contents([("local/a.txt", this_content)])
198+
wt.commit("this edit")
199+
200+
# OTHER edits line9
201+
def mutate(other_wt):
202+
other_content = base.replace(b"line9\n", b"line9_other\n")
203+
self.build_tree_contents([("other/a.txt", other_content)])
204+
205+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
206+
merger = self._merge(wt, base_rev, other_tree)
207+
208+
self.assertEqual([], list(merger.cooked_conflicts))
209+
with open(wt.abspath("a.txt"), "rb") as f:
210+
merged = f.read()
211+
self.assertIn(b"line0_this", merged)
212+
self.assertIn(b"line9_other", merged)
213+
self.assertNotIn(b"<<<<<<<", merged)
214+
215+
216+
class GitAddDeleteMergeTests(GitMergeTestBase):
217+
"""Add/delete scenarios that exercise paths absent on one side."""
218+
219+
def test_add_in_other_only(self):
220+
"""OTHER adds a new file; THIS leaves alone — file lands cleanly."""
221+
wt = self._make_local([("a.txt", b"keep\n")])
222+
base_rev = wt.last_revision()
223+
224+
def mutate(other_wt):
225+
self.build_tree_contents([("other/new.txt", b"brand new\n")])
226+
other_wt.add(["new.txt"])
227+
228+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
229+
merger = self._merge(wt, base_rev, other_tree)
230+
231+
self.assertEqual([], list(merger.cooked_conflicts))
232+
with open(wt.abspath("new.txt"), "rb") as f:
233+
self.assertEqual(b"brand new\n", f.read())
234+
235+
def test_add_in_this_only(self):
236+
"""THIS adds a new file; OTHER unrelated change — both land."""
237+
wt = self._make_local([("a.txt", b"keep\n")])
238+
base_rev = wt.last_revision()
239+
240+
self.build_tree_contents([("local/new.txt", b"this added\n")])
241+
wt.add(["new.txt"])
242+
wt.commit("add new")
243+
244+
def mutate(other_wt):
245+
self.build_tree_contents([("other/a.txt", b"keep\nother\n")])
246+
247+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
248+
merger = self._merge(wt, base_rev, other_tree)
249+
250+
self.assertEqual([], list(merger.cooked_conflicts))
251+
with open(wt.abspath("new.txt"), "rb") as f:
252+
self.assertEqual(b"this added\n", f.read())
253+
with open(wt.abspath("a.txt"), "rb") as f:
254+
self.assertEqual(b"keep\nother\n", f.read())
255+
256+
def test_delete_in_other_unchanged_in_this(self):
257+
"""OTHER deletes a file THIS leaves alone — file is removed."""
258+
wt = self._make_local([("a.txt", b"a\n"), ("b.txt", b"b\n")])
259+
base_rev = wt.last_revision()
260+
261+
def mutate(other_wt):
262+
other_wt.remove(["b.txt"], keep_files=False)
263+
264+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
265+
merger = self._merge(wt, base_rev, other_tree)
266+
267+
self.assertEqual([], list(merger.cooked_conflicts))
268+
self.assertFalse(os.path.exists(wt.abspath("b.txt")))
269+
self.assertTrue(os.path.exists(wt.abspath("a.txt")))
270+
271+
def test_delete_in_other_modified_in_this(self):
272+
"""OTHER deletes; THIS modifies — content conflict."""
273+
wt = self._make_local([("a.txt", b"original\n")])
274+
base_rev = wt.last_revision()
275+
276+
self.build_tree_contents([("local/a.txt", b"modified\n")])
277+
wt.commit("modify")
278+
279+
def mutate(other_wt):
280+
other_wt.remove(["a.txt"], keep_files=False)
281+
282+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
283+
merger = self._merge(wt, base_rev, other_tree)
284+
285+
# A conflict is expected; the test pins that the merge
286+
# completes rather than crashing, and that something was
287+
# reported.
288+
self.assertNotEqual([], list(merger.cooked_conflicts))
289+
290+
291+
class GitSubdirMergeTests(GitMergeTestBase):
292+
"""Files in subdirectories — exercises the dirname/basename path."""
293+
294+
def test_add_file_in_subdir_in_other(self):
295+
"""OTHER adds a file in a new subdir; THIS untouched."""
296+
wt = self._make_local([("a.txt", b"top\n")])
297+
base_rev = wt.last_revision()
298+
299+
def mutate(other_wt):
300+
os.mkdir(other_wt.abspath("sub"))
301+
self.build_tree_contents([("other/sub/file.txt", b"in sub\n")])
302+
other_wt.add(["sub", "sub/file.txt"])
303+
304+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
305+
merger = self._merge(wt, base_rev, other_tree)
306+
307+
self.assertEqual([], list(merger.cooked_conflicts))
308+
with open(wt.abspath("sub/file.txt"), "rb") as f:
309+
self.assertEqual(b"in sub\n", f.read())
310+
311+
def test_modify_in_subdir_both_sides_disjoint(self):
312+
"""THIS and OTHER modify different files in the same subdir."""
313+
wt = self._make_local(
314+
[
315+
("sub/x.txt", b"x base\n"),
316+
("sub/y.txt", b"y base\n"),
317+
]
318+
)
319+
base_rev = wt.last_revision()
320+
321+
self.build_tree_contents([("local/sub/x.txt", b"x base\nthis\n")])
322+
wt.commit("this in sub")
323+
324+
def mutate(other_wt):
325+
self.build_tree_contents([("other/sub/y.txt", b"y base\nother\n")])
326+
327+
other_tree, _ = self._fork_other(wt, base_rev, mutate)
328+
merger = self._merge(wt, base_rev, other_tree)
329+
330+
self.assertEqual([], list(merger.cooked_conflicts))
331+
with open(wt.abspath("sub/x.txt"), "rb") as f:
332+
self.assertEqual(b"x base\nthis\n", f.read())
333+
with open(wt.abspath("sub/y.txt"), "rb") as f:
334+
self.assertEqual(b"y base\nother\n", f.read())

breezy/merge.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,16 +1013,22 @@ def _compute_transform(self):
10131013
executable3 = (None, executable3[1], None)
10141014
changed = True
10151015
copied = False
1016-
# Resolve a trans_id for this entry. When the tree carries
1017-
# file ids, go through the file-id map so that downstream
1018-
# parent lookups (see _merge_names) pick up the same trans_id
1019-
# as a sibling rename / creation. When file_id is None the
1020-
# tree is path-based, so fall back to this_path; if that's
1021-
# also absent, assign a fresh id.
1022-
if file_id is not None:
1016+
# Resolve a trans_id for this entry. Inventory-backed
1017+
# trees route through ``trans_id_file_id`` so downstream
1018+
# parent lookups (see ``_merge_names``) pick up the same
1019+
# trans_id as a sibling rename / creation. Path-based
1020+
# trees (git, MemoryTree) key directly off the path —
1021+
# any ``file_id`` the iterator might have attached is
1022+
# synthetic and redundant. Prefer THIS's path; fall back
1023+
# to OTHER's (for new-in-OTHER files) so siblings that
1024+
# subsequently look up the same path hit the same
1025+
# trans_id.
1026+
if file_id is not None and self.this_tree.supports_file_ids:
10231027
trans_id = self.tt.trans_id_file_id(file_id)
10241028
elif paths3[2]:
10251029
trans_id = self.tt.trans_id_tree_path(paths3[2])
1030+
elif paths3[1]:
1031+
trans_id = self.tt.trans_id_tree_path(paths3[1])
10261032
else:
10271033
trans_id = self.tt.assign_id()
10281034
# Try merging each entry

0 commit comments

Comments
 (0)