Rev 3554: Several updates. in http://bzr.arbash-meinel.com/branches/bzr/1.7-dev/merge_lca_multi
John Arbash Meinel
john at arbash-meinel.com
Thu Jul 31 20:13:07 BST 2008
At http://bzr.arbash-meinel.com/branches/bzr/1.7-dev/merge_lca_multi
------------------------------------------------------------
revno: 3554
revision-id: john at arbash-meinel.com-20080731191259-81qy04c3h4bxir0s
parent: john at arbash-meinel.com-20080730223829-2wd0dm1ca4tqlguw
committer: John Arbash Meinel <john at arbash-meinel.com>
branch nick: merge_lca_multi
timestamp: Thu 2008-07-31 14:12:59 -0500
message:
Several updates.
Add a document describing the merge algorithm, and the rationale
for the different steps.
Change the sha1 algorithm to use get_file_sha1() rather than assuming the
inventory entries text_sha1 value is correct. (It is always None for working trees.)
For sha1-values specifically, don't allow overruling an LCA value when one tip
seems to supersede the other's value. We will still do this for names and parent_ids.
Add tests that we conflict if lcas disagree and tips do not have the same content,
even if one of them is an lca value, and one is newer.
-------------- next part --------------
=== modified file 'bzrlib/merge.py'
--- a/bzrlib/merge.py 2008-07-30 22:37:49 +0000
+++ b/bzrlib/merge.py 2008-07-31 19:12:59 +0000
@@ -711,16 +711,19 @@
if interesting_ids is not None and file_id not in interesting_ids:
continue
- # I believe we can actually change this to see if last_rev is
- # identical to *any* of the lca values. Though we should actually
- # use the _lca_multi_way logic. However, it may be worthwhile to
- # shortcut entries that are identical in all of LCA + OTHER, just
- # to avoid the overhead of looking up information in BASE and THIS.
+ # If other_revision is found in any of the lcas, that means this
+ # node is uninteresting. This is because when merging, if there are
+ # multiple heads(), we have to create a new node. So if we didn't,
+ # we know that the ancestry is linear, and that OTHER did not
+ # modify anything
+ # See doc/developers/lca_merge_resolution.txt for details
other_revision = other_ie.revision
+ is_unmodified = False
for lca_path, ie in lca_values:
- if ie is None or ie.revision != other_revision:
+ if ie is not None and ie.revision == other_revision:
+ is_unmodified = True
break
- else: # Identical in all trees
+ if is_unmodified:
continue
lca_entries = []
@@ -744,13 +747,11 @@
lca_parent_ids = []
lca_names = []
lca_executable = []
- lca_sha1s = []
for lca_ie in lca_entries:
lca_kinds.append(lca_ie.kind)
lca_parent_ids.append(lca_ie.parent_id)
lca_names.append(lca_ie.name)
lca_executable.append(lca_ie.executable)
- lca_sha1s.append(lca_ie.text_sha1)
kind_winner = Merge3Merger._lca_multi_way(
(base_ie.kind, lca_kinds),
@@ -775,9 +776,18 @@
continue
content_changed = False
elif other_ie.kind == 'file':
+ def get_sha1(ie, tree):
+ if ie.kind is None:
+ return None
+ return tree.get_file_sha1(file_id)
+ base_sha1 = get_sha1(base_ie, self.base_tree)
+ lca_sha1s = [get_sha1(ie, tree) for ie, tree
+ in zip(lca_entries, self._lca_trees)]
+ this_sha1 = get_sha1(this_ie, self.this_tree)
+ other_sha1 = get_sha1(other_ie, self.other_tree)
sha1_winner = Merge3Merger._lca_multi_way(
- (base_ie.text_sha1, lca_sha1s),
- other_ie.text_sha1, this_ie.text_sha1)
+ (base_sha1, lca_sha1s), other_sha1, this_sha1,
+ allow_overriding_lca=False)
exec_winner = Merge3Merger._lca_multi_way(
(base_ie.executable, lca_executable),
other_ie.executable, this_ie.executable)
@@ -907,7 +917,7 @@
return "other"
@staticmethod
- def _lca_multi_way(bases, other, this):
+ def _lca_multi_way(bases, other, this, allow_overriding_lca=True):
"""Consider LCAs when determining whether a change has occurred.
If LCAS are all identical, this is the same as a _three_way comparison.
@@ -915,9 +925,15 @@
:param bases: value in (BASE, [LCAS])
:param other: value in OTHER
:param this: value in THIS
+ :param allow_overriding_lca: If there is more than one unique lca
+ value, allow OTHER to override THIS if it has a new value, and
+ THIS only has an lca value, or vice versa. This is appropriate for
+ truly scalar values, not as much for non-scalars.
:return: 'this', 'other', or 'conflict' depending on whether an entry
changed or not.
"""
+ # See doc/developers/lca_merge_resolution.txt for details about this
+ # algorithm.
if other == this:
# Either Ambiguously clean, or nothing was actually changed. We
# don't really care
@@ -928,25 +944,24 @@
if lca_val != base_val]
if len(filtered_lca_vals) == 0:
return Merge3Merger._three_way(base_val, other, this)
- else:
- first_lca_val = filtered_lca_vals[0]
- for lca_val in filtered_lca_vals[1:]:
- if lca_val != first_lca_val:
- break
- else: # all lcas are equal, use 3-way logic
- return Merge3Merger._three_way(first_lca_val, other, this)
- if other in filtered_lca_vals:
- if this in filtered_lca_vals:
- # Each side picked a different lca, conflict
- return 'conflict'
- else:
- # This has a value which supersedes both lca values, and other
+
+ unique_lca_vals = set(filtered_lca_vals)
+ if len(unique_lca_vals) == 1:
+ return Merge3Merger._three_way(unique_lca_vals.pop(), other, this)
+
+ if allow_overriding_lca:
+ if other in unique_lca_vals:
+ if this in unique_lca_vals:
+ # Each side picked a different lca, conflict
+ return 'conflict'
+ else:
+ # This has a value which supersedes both lca values, and
+ # other only has an lca value
+ return 'this'
+ elif this in unique_lca_vals:
+ # OTHER has a value which supersedes both lca values, and this
# only has an lca value
- return 'this'
- elif this in filtered_lca_vals:
- # OTHER has a value which supersedes both lca values, and this only
- # has an lca value
- return 'other'
+ return 'other'
# At this point, the lcas disagree, and the tips disagree
return 'conflict'
=== modified file 'bzrlib/tests/test_merge.py'
--- a/bzrlib/tests/test_merge.py 2008-07-30 22:38:29 +0000
+++ b/bzrlib/tests/test_merge.py 2008-07-31 19:12:59 +0000
@@ -1238,6 +1238,44 @@
self.assertIsInstance(merge_obj, UnsupportedLCATreesMerger)
self.assertFalse('lca_trees' in merge_obj.kwargs)
+# XXX: Thing left to test:
+# Double-criss-cross merge, the ultimate base value is different from the
+# intermediate.
+# A value 'foo'
+# |\
+# B C B value 'bar', C = 'foo'
+# |X|
+# D E D = 'bar', E supersedes to 'bing'
+# |X|
+# F G F = 'bing', G supersedes to 'barry'
+#
+# In this case, we technically should not care about the value 'bar' for
+# D, because it was clearly superseded by E's 'bing'. The
+# per-file/attribute graph would actually look like:
+# A
+# |
+# B
+# |
+# E
+# |
+# G
+#
+# Because the other side of the merge never modifies the value, it just
+# takes the value from the merge.
+#
+# ATM I expect this to fail because we will prune 'foo' from the LCAs,
+# and not 'bar'. I don't know if this is strictly a problem, as it is a
+# distinctly edge case.
+#
+# Another incorrect resolution from the same basic flaw:
+# A value 'foo'
+# |\
+# B C B value 'bar', C = 'foo'
+# |X|
+# D E D = 'bar', E reverts to 'foo'
+# |X|
+# F G F = 'bar', G reverts to 'foo'
+
class TestMergerEntriesLCA(TestMergerBase):
@@ -1383,6 +1421,32 @@
((False, [False, False]), None, False)),
], entries)
+ def test_not_in_other_or_lca(self):
+ # A base, introduces 'foo'
+ # |\
+ # B C B nothing, C deletes foo
+ # |X|
+ # D E D restores foo (same as B), E leaves it deleted
+ # We should emit an entry for this
+ builder = self.get_builder()
+ builder.build_snapshot('A-id', None,
+ [('add', (u'', 'a-root-id', 'directory', None)),
+ ('add', (u'foo', 'foo-id', 'file', 'content\n'))])
+ builder.build_snapshot('B-id', ['A-id'], [])
+ builder.build_snapshot('C-id', ['A-id'],
+ [('unversion', 'foo-id')])
+ builder.build_snapshot('E-id', ['C-id', 'B-id'], [])
+ builder.build_snapshot('D-id', ['B-id', 'C-id'], [])
+ merge_obj = self.make_merge_obj(builder, 'E-id')
+
+ entries = list(merge_obj._entries_lca())
+ root_id = 'a-root-id'
+ self.assertEqual([('foo-id', True,
+ ((root_id, [root_id, None]), None, root_id),
+ ((u'foo', [u'foo', None]), None, 'foo'),
+ ((False, [False, None]), None, False)),
+ ], entries)
+
def test_only_in_one_lca(self):
builder = self.get_builder()
builder.build_snapshot('A-id', None,
@@ -1422,6 +1486,133 @@
((None, [None, None]), False, None)),
], entries)
+ def test_one_lca_supersedes(self):
+ # One LCA supersede's the other LCA's last modified value, but the
+ # value is not the same as BASE.
+ # A base, introduces 'foo', last mod A
+ # |\
+ # B C B modifies 'foo' (mod B), C does nothing (mod A)
+ # |X|
+ # D E D does nothing (mod B), E updates 'foo' (mod E)
+ # |X|
+ # F G F updates 'foo' (mod F). G does nothing (mod E)
+ #
+ # At this point, G should not be considered to modify 'foo', even
+ # though its LCAs disagree. This is because the modification in E
+ # completely supersedes the value in D.
+ builder = self.get_builder()
+ builder.build_snapshot('A-id', None,
+ [('add', (u'', 'a-root-id', 'directory', None)),
+ ('add', (u'foo', 'foo-id', 'file', 'A content\n'))])
+ builder.build_snapshot('C-id', ['A-id'], [])
+ builder.build_snapshot('B-id', ['A-id'],
+ [('modify', ('foo-id', 'B content\n'))])
+ builder.build_snapshot('D-id', ['B-id', 'C-id'], [])
+ builder.build_snapshot('E-id', ['C-id', 'B-id'],
+ [('modify', ('foo-id', 'E content\n'))])
+ builder.build_snapshot('G-id', ['E-id', 'D-id'], [])
+ builder.build_snapshot('F-id', ['D-id', 'E-id'],
+ [('modify', ('foo-id', 'F content\n'))])
+ merge_obj = self.make_merge_obj(builder, 'G-id')
+
+ self.assertEqual([], list(merge_obj._entries_lca()))
+
+ def test_both_sides_revert(self):
+ # Both sides of a criss-cross revert the text to the lca
+ # A base, introduces 'foo'
+ # |\
+ # B C B modifies 'foo', C modifies 'foo'
+ # |X|
+ # D E D reverts to B, E reverts to C
+ # This should conflict
+ builder = self.get_builder()
+ builder.build_snapshot('A-id', None,
+ [('add', (u'', 'a-root-id', 'directory', None)),
+ ('add', (u'foo', 'foo-id', 'file', 'A content\n'))])
+ builder.build_snapshot('B-id', ['A-id'],
+ [('modify', ('foo-id', 'B content\n'))])
+ builder.build_snapshot('C-id', ['A-id'],
+ [('modify', ('foo-id', 'C content\n'))])
+ builder.build_snapshot('E-id', ['C-id', 'B-id'], [])
+ builder.build_snapshot('D-id', ['B-id', 'C-id'], [])
+ merge_obj = self.make_merge_obj(builder, 'E-id')
+
+ entries = list(merge_obj._entries_lca())
+ root_id = 'a-root-id'
+ self.assertEqual([('foo-id', True,
+ ((root_id, [root_id, root_id]), root_id, root_id),
+ ((u'foo', [u'foo', u'foo']), u'foo', u'foo'),
+ ((False, [False, False]), False, False)),
+ ], entries)
+
+ def test_different_lca_resolve_one_side_updates_content(self):
+ # Both sides converge, but then one side updates the text.
+ # A base, introduces 'foo'
+ # |\
+ # B C B modifies 'foo', C modifies 'foo'
+ # |X|
+ # D E D reverts to B, E reverts to C
+ # |
+ # F F updates to a new value
+ # We need to emit an entry for 'foo', because D & E differed on the
+ # merge resolution
+ builder = self.get_builder()
+ builder.build_snapshot('A-id', None,
+ [('add', (u'', 'a-root-id', 'directory', None)),
+ ('add', (u'foo', 'foo-id', 'file', 'A content\n'))])
+ builder.build_snapshot('B-id', ['A-id'],
+ [('modify', ('foo-id', 'B content\n'))])
+ builder.build_snapshot('C-id', ['A-id'],
+ [('modify', ('foo-id', 'C content\n'))])
+ builder.build_snapshot('E-id', ['C-id', 'B-id'], [])
+ builder.build_snapshot('D-id', ['B-id', 'C-id'], [])
+ builder.build_snapshot('F-id', ['D-id'],
+ [('modify', ('foo-id', 'F content\n'))])
+ merge_obj = self.make_merge_obj(builder, 'E-id')
+
+ entries = list(merge_obj._entries_lca())
+ root_id = 'a-root-id'
+ self.assertEqual([('foo-id', True,
+ ((root_id, [root_id, root_id]), root_id, root_id),
+ ((u'foo', [u'foo', u'foo']), u'foo', u'foo'),
+ ((False, [False, False]), False, False)),
+ ], entries)
+
+ def test_same_lca_resolution_one_side_updates_content(self):
+ # Both sides converge, but then one side updates the text.
+ # A base, introduces 'foo'
+ # |\
+ # B C B modifies 'foo', C modifies 'foo'
+ # |X|
+ # D E D and E use C's value
+ # |
+ # F F updates to a new value
+ # I think it is a bug that this conflicts, but we don't have a way to
+ # detect otherwise. And because of:
+ # test_different_lca_resolve_one_side_updates_content
+ # We need to conflict.
+
+ builder = self.get_builder()
+ builder.build_snapshot('A-id', None,
+ [('add', (u'', 'a-root-id', 'directory', None)),
+ ('add', (u'foo', 'foo-id', 'file', 'A content\n'))])
+ builder.build_snapshot('B-id', ['A-id'],
+ [('modify', ('foo-id', 'B content\n'))])
+ builder.build_snapshot('C-id', ['A-id'],
+ [('modify', ('foo-id', 'C content\n'))])
+ builder.build_snapshot('E-id', ['C-id', 'B-id'], [])
+ builder.build_snapshot('D-id', ['B-id', 'C-id'],
+ [('modify', ('foo-id', 'C content\n'))]) # Same as E
+ builder.build_snapshot('F-id', ['D-id'],
+ [('modify', ('foo-id', 'F content\n'))])
+ merge_obj = self.make_merge_obj(builder, 'E-id')
+
+ entries = list(merge_obj._entries_lca())
+ root_id = 'a-root-id'
+ self.expectFailure("We don't detect that LCA resolution was the"
+ " same on both sides",
+ self.assertEqual, [], entries)
+
def test_only_path_changed(self):
builder = self.get_builder()
builder.build_snapshot('A-id', None,
@@ -1761,6 +1952,35 @@
self.assertEqual('foo-id', wt.path2id('foo'))
self.assertEqual('bar', wt.get_symlink_target('foo-id'))
+ def test_both_sides_revert(self):
+ # Both sides of a criss-cross revert the text to the lca
+ # A base, introduces 'foo'
+ # |\
+ # B C B modifies 'foo', C modifies 'foo'
+ # |X|
+ # D E D reverts to B, E reverts to C
+ # This should conflict
+ # This must be done with a real WorkingTree, because normally their
+ # inventory contains "None" rather than a real sha1
+ builder = self.get_builder()
+ builder.build_snapshot('A-id', None,
+ [('add', (u'', 'a-root-id', 'directory', None)),
+ ('add', (u'foo', 'foo-id', 'file', 'A content\n'))])
+ builder.build_snapshot('B-id', ['A-id'],
+ [('modify', ('foo-id', 'B content\n'))])
+ builder.build_snapshot('C-id', ['A-id'],
+ [('modify', ('foo-id', 'C content\n'))])
+ builder.build_snapshot('E-id', ['C-id', 'B-id'], [])
+ builder.build_snapshot('D-id', ['B-id', 'C-id'], [])
+ wt, conflicts = self.do_merge(builder, 'E-id')
+ self.assertEqual(1, conflicts)
+ self.assertEqualDiff('<<<<<<< TREE\n'
+ 'B content\n'
+ '=======\n'
+ 'C content\n'
+ '>>>>>>> MERGE-SOURCE\n',
+ wt.get_file_text('foo-id'))
+
def test_modified_symlink(self):
self.requireFeature(tests.SymlinkFeature)
# A Create symlink foo => bar
@@ -1983,9 +2203,11 @@
class TestLCAMultiWay(tests.TestCase):
- def assertLCAMultiWay(self, expected, base, lcas, other, this):
+ def assertLCAMultiWay(self, expected, base, lcas, other, this,
+ allow_overriding_lca=True):
self.assertEqual(expected, _mod_merge.Merge3Merger._lca_multi_way(
- (base, lcas), other, this))
+ (base, lcas), other, this,
+ allow_overriding_lca=allow_overriding_lca))
def test_other_equal_equal_lcas(self):
"""Test when OTHER=LCA and all LCAs are identical."""
@@ -2061,12 +2283,24 @@
'bval', ['lca1val', 'lca2val'], 'lca1val', 'newval')
self.assertLCAMultiWay('this',
'bval', ['lca1val', 'lca2val', 'lca3val'], 'lca1val', 'newval')
+ self.assertLCAMultiWay('conflict',
+ 'bval', ['lca1val', 'lca2val'], 'lca1val', 'newval',
+ allow_overriding_lca=False)
+ self.assertLCAMultiWay('conflict',
+ 'bval', ['lca1val', 'lca2val', 'lca3val'], 'lca1val', 'newval',
+ allow_overriding_lca=False)
# THIS reverted back to BASE, but that is an explicit supersede of all
# LCAs
self.assertLCAMultiWay('this',
'bval', ['lca1val', 'lca2val', 'lca3val'], 'lca1val', 'bval')
self.assertLCAMultiWay('this',
'bval', ['lca1val', 'lca2val', 'bval'], 'lca1val', 'bval')
+ self.assertLCAMultiWay('conflict',
+ 'bval', ['lca1val', 'lca2val', 'lca3val'], 'lca1val', 'bval',
+ allow_overriding_lca=False)
+ self.assertLCAMultiWay('conflict',
+ 'bval', ['lca1val', 'lca2val', 'bval'], 'lca1val', 'bval',
+ allow_overriding_lca=False)
def test_this_in_lca(self):
# THIS takes a value of one of the LCAs, OTHER takes a new value, which
@@ -2075,10 +2309,19 @@
'bval', ['lca1val', 'lca2val'], 'oval', 'lca1val')
self.assertLCAMultiWay('other',
'bval', ['lca1val', 'lca2val'], 'oval', 'lca2val')
+ self.assertLCAMultiWay('conflict',
+ 'bval', ['lca1val', 'lca2val'], 'oval', 'lca1val',
+ allow_overriding_lca=False)
+ self.assertLCAMultiWay('conflict',
+ 'bval', ['lca1val', 'lca2val'], 'oval', 'lca2val',
+ allow_overriding_lca=False)
# OTHER reverted back to BASE, but that is an explicit supersede of all
# LCAs
self.assertLCAMultiWay('other',
'bval', ['lca1val', 'lca2val', 'lca3val'], 'bval', 'lca3val')
+ self.assertLCAMultiWay('conflict',
+ 'bval', ['lca1val', 'lca2val', 'lca3val'], 'bval', 'lca3val',
+ allow_overriding_lca=False)
def test_all_differ(self):
self.assertLCAMultiWay('conflict',
=== added file 'doc/developers/lca_merge_resolution.txt'
--- a/doc/developers/lca_merge_resolution.txt 1970-01-01 00:00:00 +0000
+++ b/doc/developers/lca_merge_resolution.txt 2008-07-31 19:12:59 +0000
@@ -0,0 +1,127 @@
+====================
+LCA Merge Resolution
+====================
+
+There are 2 ways that you get LCA merge resolution in bzr. First, if you use
+``bzr merge --lca``, the content of files will be resolved using a Least Common
+Ancestors algorithm. That is not being described here.
+
+This is describing how we handle merging tree-shape when there is not a single
+unique ancestor (criss-cross merge). With a single LCA, we use simple
+3-way-merge logic.
+
+When there are multiple possible LCAs, we use a different algorithm for
+handling tree-shape merging. Described here.
+
+As a simple example, here is a revision graph which we will refer to often::
+
+ . BASE
+ . / \
+ . LCA1 LCA2
+ . | \ / |
+ . | X |
+ . | / \ |
+ . THIS OTHER
+
+In this graph, ``THIS`` and ``OTHER`` both have ``LCA1`` and ``LCA2`` in their
+ancestry but neither is an ancestor of the other, so we have 2 least common
+ancestors. The unique common ancestor is ``BASE``. (It should be noted that in
+this text we will talk directly about ``LCA1`` and ``LCA2``, but the algorithms
+are designed to cope with more than 2 LCAs.)
+
+
+Scalars
+=======
+
+Definition
+----------
+
+I'm defining scalar values as ones that cannot be 'merged' on their own. For
+example, the name of a file is "scalar". If one person changes "foo.txt" to
+"foo.c" and someone else changes "foo.txt" to "bar.txt" we don't merge the
+changes to be "bar.c", we simply conflict and expect the user to sort it out.
+
+We use a slightly different algorithm for scalars.
+
+
+Resolution Algorithm
+--------------------
+
+(This can be seen as ``bzrlib.merge.Merge3Merger._lca_multi_way```
+
+1. If ``THIS`` and ``OTHER`` have the same value, use it. There is no need to
+ inspect any other values in this case. Either nothing was changed (all
+ interesting nodes would have the same value), or we have "accidental
+ convergence" (both sides made the same change.).
+
+2. Find the values from ``LCA1`` and ``LCA2`` which are not the same as
+ ``BASE``. The idea here is to provide a rudimentary "heads" comparison.
+ Often, the whole tree graph will have a criss-cross, but the per-file
+ (per-scalar) graph would be linear. And the value in one LCA strictly
+ dominates the other. It is possible to construct a scenario where one side
+ dominates the other, but the dominated value is not ``BASE``, but a second
+ intermediate value. Most scalars are rarely changed, so this is unlikely to
+ be an issue. And the trade-off is having to generate and inspect the
+ per-scalar graph.
+
+ If there are no LCA values that are different from ``BASE``, we use a simple
+ 3-way merge with ``BASE`` as the base value.
+
+3. Find the unique set of LCA values that do not include the ``BASE`` value.
+ If there is only one unique LCA value, we again use three-way merge logic
+ using that unique value as the base.
+
+4. At this point we have determined that we have at least 2 unique values in
+ our LCAs which means that ``THIS`` and ``OTHER`` would both have to resolve
+ the conflict. If they resolved it in the same way, we would have caught that
+ in step 1. So they either both picked a different LCA value, or one (or
+ both) chose a new value to use.
+
+ So at this point, if ``OTHER`` and ``THIS`` both picked a different LCA
+ value, we conflict.
+
+ If ``OTHER`` and ``THIS`` both have values that are not LCA values, we also
+ conflict. (Same as 3-way, both sides modified a value in different ways.)
+
+5. (optional) The only tricky part is this, if ``OTHER`` has a LCA value, but
+ ``THIS`` does not, then we go with ``THIS``, and conversely if ``THIS`` has
+ an LCA value, but ``OTHER`` does not, then we go with ``OTHER``. The idea is
+ that ``THIS`` and ``OTHER`` may have resolved things in the same way, and
+ then later changed the value to something newer. (They could have also
+ resolved it differently, and then one side updated again.)
+
+
+``InventoryEntry.revision``
+---------------------------
+
+The last-modified revision for an entry gets treated differently. This is
+because how it is generated allows us to infer more information. Specifically,
+any time there is a change to an entry (rename, or content change) the last
+modified revision is updated. Further, if we are merging, and both sides
+updated the entry, then we update the last-modified revision at the merge
+point.
+
+For a picture example::
+
+ . A
+ . / \
+ . B C
+ . \ /
+ . D
+
+
+For a single entry, the last modified revision in ``D`` is:
+
+1) ``A`` if neither ``B`` or ``C`` modified it
+2) ``B`` if ``B`` modified and ``C`` did not
+3) ``C`` if ``C`` modified and ``B`` did not
+4) ``D`` if ``B`` and ``C`` modified it
+
+This means that if the last modified revision is the same, there have been no
+changes in the intermediate time. And if ``OTHER`` has the same last modified
+revision as *any* LCA, then we know that all other LCAs' last-modified
+revisions are in the ancestry of that value. (Otherwise, when ``OTHER`` would
+need to create a new last modified revision as part of the merge.)
+
+..
+ vim: ft=rst tw=74 ai
More information about the bazaar-commits
mailing list