changeset 4362:465b9ea02868

Import 3-way merge code from bzr merge3.py is imported as contrib/simplemerge test_merge3.py is imported as tests/test-simplemerge.py
author Alexis S. L. Carvalho <alexis@cecm.usp.br>
date Mon, 16 Apr 2007 20:17:39 -0300
parents 99c853a1408c
children 2e3c54fb79a3
files contrib/simplemerge tests/test-simplemerge.py
diffstat 2 files changed, 823 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/contrib/simplemerge	Mon Apr 16 20:17:39 2007 -0300
@@ -0,0 +1,438 @@
+# Copyright (C) 2004, 2005 Canonical Ltd
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+# mbp: "you know that thing where cvs gives you conflict markers?"
+# s: "i hate that."
+
+
+from bzrlib.errors import CantReprocessAndShowBase
+import bzrlib.patiencediff
+from bzrlib.textfile import check_text_lines
+
+
+def intersect(ra, rb):
+    """Given two ranges return the range where they intersect or None.
+
+    >>> intersect((0, 10), (0, 6))
+    (0, 6)
+    >>> intersect((0, 10), (5, 15))
+    (5, 10)
+    >>> intersect((0, 10), (10, 15))
+    >>> intersect((0, 9), (10, 15))
+    >>> intersect((0, 9), (7, 15))
+    (7, 9)
+    """
+    assert ra[0] <= ra[1]
+    assert rb[0] <= rb[1]
+    
+    sa = max(ra[0], rb[0])
+    sb = min(ra[1], rb[1])
+    if sa < sb:
+        return sa, sb
+    else:
+        return None
+
+
+def compare_range(a, astart, aend, b, bstart, bend):
+    """Compare a[astart:aend] == b[bstart:bend], without slicing.
+    """
+    if (aend-astart) != (bend-bstart):
+        return False
+    for ia, ib in zip(xrange(astart, aend), xrange(bstart, bend)):
+        if a[ia] != b[ib]:
+            return False
+    else:
+        return True
+        
+
+
+
+class Merge3(object):
+    """3-way merge of texts.
+
+    Given BASE, OTHER, THIS, tries to produce a combined text
+    incorporating the changes from both BASE->OTHER and BASE->THIS.
+    All three will typically be sequences of lines."""
+    def __init__(self, base, a, b):
+        check_text_lines(base)
+        check_text_lines(a)
+        check_text_lines(b)
+        self.base = base
+        self.a = a
+        self.b = b
+
+
+
+    def merge_lines(self,
+                    name_a=None,
+                    name_b=None,
+                    name_base=None,
+                    start_marker='<<<<<<<',
+                    mid_marker='=======',
+                    end_marker='>>>>>>>',
+                    base_marker=None,
+                    reprocess=False):
+        """Return merge in cvs-like form.
+        """
+        newline = '\n'
+        if len(self.a) > 0:
+            if self.a[0].endswith('\r\n'):
+                newline = '\r\n'
+            elif self.a[0].endswith('\r'):
+                newline = '\r'
+        if base_marker and reprocess:
+            raise CantReprocessAndShowBase()
+        if name_a:
+            start_marker = start_marker + ' ' + name_a
+        if name_b:
+            end_marker = end_marker + ' ' + name_b
+        if name_base and base_marker:
+            base_marker = base_marker + ' ' + name_base
+        merge_regions = self.merge_regions()
+        if reprocess is True:
+            merge_regions = self.reprocess_merge_regions(merge_regions)
+        for t in merge_regions:
+            what = t[0]
+            if what == 'unchanged':
+                for i in range(t[1], t[2]):
+                    yield self.base[i]
+            elif what == 'a' or what == 'same':
+                for i in range(t[1], t[2]):
+                    yield self.a[i]
+            elif what == 'b':
+                for i in range(t[1], t[2]):
+                    yield self.b[i]
+            elif what == 'conflict':
+                yield start_marker + newline
+                for i in range(t[3], t[4]):
+                    yield self.a[i]
+                if base_marker is not None:
+                    yield base_marker + newline
+                    for i in range(t[1], t[2]):
+                        yield self.base[i]
+                yield mid_marker + newline
+                for i in range(t[5], t[6]):
+                    yield self.b[i]
+                yield end_marker + newline
+            else:
+                raise ValueError(what)
+        
+        
+
+
+
+    def merge_annotated(self):
+        """Return merge with conflicts, showing origin of lines.
+
+        Most useful for debugging merge.        
+        """
+        for t in self.merge_regions():
+            what = t[0]
+            if what == 'unchanged':
+                for i in range(t[1], t[2]):
+                    yield 'u | ' + self.base[i]
+            elif what == 'a' or what == 'same':
+                for i in range(t[1], t[2]):
+                    yield what[0] + ' | ' + self.a[i]
+            elif what == 'b':
+                for i in range(t[1], t[2]):
+                    yield 'b | ' + self.b[i]
+            elif what == 'conflict':
+                yield '<<<<\n'
+                for i in range(t[3], t[4]):
+                    yield 'A | ' + self.a[i]
+                yield '----\n'
+                for i in range(t[5], t[6]):
+                    yield 'B | ' + self.b[i]
+                yield '>>>>\n'
+            else:
+                raise ValueError(what)
+        
+        
+
+
+
+    def merge_groups(self):
+        """Yield sequence of line groups.  Each one is a tuple:
+
+        'unchanged', lines
+             Lines unchanged from base
+
+        'a', lines
+             Lines taken from a
+
+        'same', lines
+             Lines taken from a (and equal to b)
+
+        'b', lines
+             Lines taken from b
+
+        'conflict', base_lines, a_lines, b_lines
+             Lines from base were changed to either a or b and conflict.
+        """
+        for t in self.merge_regions():
+            what = t[0]
+            if what == 'unchanged':
+                yield what, self.base[t[1]:t[2]]
+            elif what == 'a' or what == 'same':
+                yield what, self.a[t[1]:t[2]]
+            elif what == 'b':
+                yield what, self.b[t[1]:t[2]]
+            elif what == 'conflict':
+                yield (what,
+                       self.base[t[1]:t[2]],
+                       self.a[t[3]:t[4]],
+                       self.b[t[5]:t[6]])
+            else:
+                raise ValueError(what)
+
+
+    def merge_regions(self):
+        """Return sequences of matching and conflicting regions.
+
+        This returns tuples, where the first value says what kind we
+        have:
+
+        'unchanged', start, end
+             Take a region of base[start:end]
+
+        'same', astart, aend
+             b and a are different from base but give the same result
+
+        'a', start, end
+             Non-clashing insertion from a[start:end]
+
+        Method is as follows:
+
+        The two sequences align only on regions which match the base
+        and both descendents.  These are found by doing a two-way diff
+        of each one against the base, and then finding the
+        intersections between those regions.  These "sync regions"
+        are by definition unchanged in both and easily dealt with.
+
+        The regions in between can be in any of three cases:
+        conflicted, or changed on only one side.
+        """
+
+        # section a[0:ia] has been disposed of, etc
+        iz = ia = ib = 0
+        
+        for zmatch, zend, amatch, aend, bmatch, bend in self.find_sync_regions():
+            #print 'match base [%d:%d]' % (zmatch, zend)
+            
+            matchlen = zend - zmatch
+            assert matchlen >= 0
+            assert matchlen == (aend - amatch)
+            assert matchlen == (bend - bmatch)
+            
+            len_a = amatch - ia
+            len_b = bmatch - ib
+            len_base = zmatch - iz
+            assert len_a >= 0
+            assert len_b >= 0
+            assert len_base >= 0
+
+            #print 'unmatched a=%d, b=%d' % (len_a, len_b)
+
+            if len_a or len_b:
+                # try to avoid actually slicing the lists
+                equal_a = compare_range(self.a, ia, amatch,
+                                        self.base, iz, zmatch)
+                equal_b = compare_range(self.b, ib, bmatch,
+                                        self.base, iz, zmatch)
+                same = compare_range(self.a, ia, amatch,
+                                     self.b, ib, bmatch)
+
+                if same:
+                    yield 'same', ia, amatch
+                elif equal_a and not equal_b:
+                    yield 'b', ib, bmatch
+                elif equal_b and not equal_a:
+                    yield 'a', ia, amatch
+                elif not equal_a and not equal_b:
+                    yield 'conflict', iz, zmatch, ia, amatch, ib, bmatch
+                else:
+                    raise AssertionError("can't handle a=b=base but unmatched")
+
+                ia = amatch
+                ib = bmatch
+            iz = zmatch
+
+            # if the same part of the base was deleted on both sides
+            # that's OK, we can just skip it.
+
+                
+            if matchlen > 0:
+                assert ia == amatch
+                assert ib == bmatch
+                assert iz == zmatch
+                
+                yield 'unchanged', zmatch, zend
+                iz = zend
+                ia = aend
+                ib = bend
+    
+
+    def reprocess_merge_regions(self, merge_regions):
+        """Where there are conflict regions, remove the agreed lines.
+
+        Lines where both A and B have made the same changes are 
+        eliminated.
+        """
+        for region in merge_regions:
+            if region[0] != "conflict":
+                yield region
+                continue
+            type, iz, zmatch, ia, amatch, ib, bmatch = region
+            a_region = self.a[ia:amatch]
+            b_region = self.b[ib:bmatch]
+            matches = bzrlib.patiencediff.PatienceSequenceMatcher(
+                    None, a_region, b_region).get_matching_blocks()
+            next_a = ia
+            next_b = ib
+            for region_ia, region_ib, region_len in matches[:-1]:
+                region_ia += ia
+                region_ib += ib
+                reg = self.mismatch_region(next_a, region_ia, next_b,
+                                           region_ib)
+                if reg is not None:
+                    yield reg
+                yield 'same', region_ia, region_len+region_ia
+                next_a = region_ia + region_len
+                next_b = region_ib + region_len
+            reg = self.mismatch_region(next_a, amatch, next_b, bmatch)
+            if reg is not None:
+                yield reg
+
+
+    @staticmethod
+    def mismatch_region(next_a, region_ia,  next_b, region_ib):
+        if next_a < region_ia or next_b < region_ib:
+            return 'conflict', None, None, next_a, region_ia, next_b, region_ib
+            
+
+    def find_sync_regions(self):
+        """Return a list of sync regions, where both descendents match the base.
+
+        Generates a list of (base1, base2, a1, a2, b1, b2).  There is
+        always a zero-length sync region at the end of all the files.
+        """
+
+        ia = ib = 0
+        amatches = bzrlib.patiencediff.PatienceSequenceMatcher(
+                None, self.base, self.a).get_matching_blocks()
+        bmatches = bzrlib.patiencediff.PatienceSequenceMatcher(
+                None, self.base, self.b).get_matching_blocks()
+        len_a = len(amatches)
+        len_b = len(bmatches)
+
+        sl = []
+
+        while ia < len_a and ib < len_b:
+            abase, amatch, alen = amatches[ia]
+            bbase, bmatch, blen = bmatches[ib]
+
+            # there is an unconflicted block at i; how long does it
+            # extend?  until whichever one ends earlier.
+            i = intersect((abase, abase+alen), (bbase, bbase+blen))
+            if i:
+                intbase = i[0]
+                intend = i[1]
+                intlen = intend - intbase
+
+                # found a match of base[i[0], i[1]]; this may be less than
+                # the region that matches in either one
+                assert intlen <= alen
+                assert intlen <= blen
+                assert abase <= intbase
+                assert bbase <= intbase
+
+                asub = amatch + (intbase - abase)
+                bsub = bmatch + (intbase - bbase)
+                aend = asub + intlen
+                bend = bsub + intlen
+
+                assert self.base[intbase:intend] == self.a[asub:aend], \
+                       (self.base[intbase:intend], self.a[asub:aend])
+
+                assert self.base[intbase:intend] == self.b[bsub:bend]
+
+                sl.append((intbase, intend,
+                           asub, aend,
+                           bsub, bend))
+
+            # advance whichever one ends first in the base text
+            if (abase + alen) < (bbase + blen):
+                ia += 1
+            else:
+                ib += 1
+            
+        intbase = len(self.base)
+        abase = len(self.a)
+        bbase = len(self.b)
+        sl.append((intbase, intbase, abase, abase, bbase, bbase))
+
+        return sl
+
+
+
+    def find_unconflicted(self):
+        """Return a list of ranges in base that are not conflicted."""
+        am = bzrlib.patiencediff.PatienceSequenceMatcher(
+                None, self.base, self.a).get_matching_blocks()
+        bm = bzrlib.patiencediff.PatienceSequenceMatcher(
+                None, self.base, self.b).get_matching_blocks()
+
+        unc = []
+
+        while am and bm:
+            # there is an unconflicted block at i; how long does it
+            # extend?  until whichever one ends earlier.
+            a1 = am[0][0]
+            a2 = a1 + am[0][2]
+            b1 = bm[0][0]
+            b2 = b1 + bm[0][2]
+            i = intersect((a1, a2), (b1, b2))
+            if i:
+                unc.append(i)
+
+            if a2 < b2:
+                del am[0]
+            else:
+                del bm[0]
+                
+        return unc
+
+
+def main(argv):
+    # as for diff3 and meld the syntax is "MINE BASE OTHER"
+    a = file(argv[1], 'rt').readlines()
+    base = file(argv[2], 'rt').readlines()
+    b = file(argv[3], 'rt').readlines()
+
+    m3 = Merge3(base, a, b)
+
+    #for sr in m3.find_sync_regions():
+    #    print sr
+
+    # sys.stdout.writelines(m3.merge_lines(name_a=argv[1], name_b=argv[3]))
+    sys.stdout.writelines(m3.merge_annotated())
+
+
+if __name__ == '__main__':
+    import sys
+    sys.exit(main(sys.argv))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-simplemerge.py	Mon Apr 16 20:17:39 2007 -0300
@@ -0,0 +1,385 @@
+# Copyright (C) 2004, 2005 Canonical Ltd
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+
+
+from bzrlib.tests import TestCaseInTempDir, TestCase
+from bzrlib.merge3 import Merge3
+from bzrlib.errors import CantReprocessAndShowBase, BinaryFile
+
+def split_lines(t):
+    from cStringIO import StringIO
+    return StringIO(t).readlines()
+
+############################################################
+# test case data from the gnu diffutils manual
+# common base
+TZU = split_lines("""     The Nameless is the origin of Heaven and Earth;
+     The named is the mother of all things.
+     
+     Therefore let there always be non-being,
+       so we may see their subtlety,
+     And let there always be being,
+       so we may see their outcome.
+     The two are the same,
+     But after they are produced,
+       they have different names.
+     They both may be called deep and profound.
+     Deeper and more profound,
+     The door of all subtleties!
+""")
+
+LAO = split_lines("""     The Way that can be told of is not the eternal Way;
+     The name that can be named is not the eternal name.
+     The Nameless is the origin of Heaven and Earth;
+     The Named is the mother of all things.
+     Therefore let there always be non-being,
+       so we may see their subtlety,
+     And let there always be being,
+       so we may see their outcome.
+     The two are the same,
+     But after they are produced,
+       they have different names.
+""")
+
+
+TAO = split_lines("""     The Way that can be told of is not the eternal Way;
+     The name that can be named is not the eternal name.
+     The Nameless is the origin of Heaven and Earth;
+     The named is the mother of all things.
+     
+     Therefore let there always be non-being,
+       so we may see their subtlety,
+     And let there always be being,
+       so we may see their result.
+     The two are the same,
+     But after they are produced,
+       they have different names.
+     
+       -- The Way of Lao-Tzu, tr. Wing-tsit Chan
+
+""")
+
+MERGED_RESULT = split_lines("""     The Way that can be told of is not the eternal Way;
+     The name that can be named is not the eternal name.
+     The Nameless is the origin of Heaven and Earth;
+     The Named is the mother of all things.
+     Therefore let there always be non-being,
+       so we may see their subtlety,
+     And let there always be being,
+       so we may see their result.
+     The two are the same,
+     But after they are produced,
+       they have different names.
+<<<<<<< LAO
+=======
+     
+       -- The Way of Lao-Tzu, tr. Wing-tsit Chan
+
+>>>>>>> TAO
+""")
+
+class TestMerge3(TestCase):
+
+    def test_no_changes(self):
+        """No conflicts because nothing changed"""
+        m3 = Merge3(['aaa', 'bbb'],
+                    ['aaa', 'bbb'],
+                    ['aaa', 'bbb'])
+
+        self.assertEquals(m3.find_unconflicted(),
+                          [(0, 2)])
+
+        self.assertEquals(list(m3.find_sync_regions()),
+                          [(0, 2,
+                            0, 2,
+                            0, 2),
+                           (2,2, 2,2, 2,2)])
+
+        self.assertEquals(list(m3.merge_regions()),
+                          [('unchanged', 0, 2)])
+
+        self.assertEquals(list(m3.merge_groups()),
+                          [('unchanged', ['aaa', 'bbb'])])
+
+    def test_front_insert(self):
+        m3 = Merge3(['zz'],
+                    ['aaa', 'bbb', 'zz'],
+                    ['zz'])
+
+        # todo: should use a sentinal at end as from get_matching_blocks
+        # to match without zz
+        self.assertEquals(list(m3.find_sync_regions()),
+                          [(0,1, 2,3, 0,1),
+                           (1,1, 3,3, 1,1),])
+
+        self.assertEquals(list(m3.merge_regions()),
+                          [('a', 0, 2),
+                           ('unchanged', 0, 1)])
+
+        self.assertEquals(list(m3.merge_groups()),
+                          [('a', ['aaa', 'bbb']),
+                           ('unchanged', ['zz'])])
+        
+    def test_null_insert(self):
+        m3 = Merge3([],
+                    ['aaa', 'bbb'],
+                    [])
+        # todo: should use a sentinal at end as from get_matching_blocks
+        # to match without zz
+        self.assertEquals(list(m3.find_sync_regions()),
+                          [(0,0, 2,2, 0,0)])
+
+        self.assertEquals(list(m3.merge_regions()),
+                          [('a', 0, 2)])
+
+        self.assertEquals(list(m3.merge_lines()),
+                          ['aaa', 'bbb'])
+
+    def test_no_conflicts(self):
+        """No conflicts because only one side changed"""
+        m3 = Merge3(['aaa', 'bbb'],
+                    ['aaa', '111', 'bbb'],
+                    ['aaa', 'bbb'])
+
+        self.assertEquals(m3.find_unconflicted(),
+                          [(0, 1), (1, 2)])
+
+        self.assertEquals(list(m3.find_sync_regions()),
+                          [(0,1, 0,1, 0,1),
+                           (1,2, 2,3, 1,2),
+                           (2,2, 3,3, 2,2),])
+
+        self.assertEquals(list(m3.merge_regions()),
+                          [('unchanged', 0, 1),
+                           ('a', 1, 2),
+                           ('unchanged', 1, 2),])
+
+    def test_append_a(self):
+        m3 = Merge3(['aaa\n', 'bbb\n'],
+                    ['aaa\n', 'bbb\n', '222\n'],
+                    ['aaa\n', 'bbb\n'])
+
+        self.assertEquals(''.join(m3.merge_lines()),
+                          'aaa\nbbb\n222\n')
+
+    def test_append_b(self):
+        m3 = Merge3(['aaa\n', 'bbb\n'],
+                    ['aaa\n', 'bbb\n'],
+                    ['aaa\n', 'bbb\n', '222\n'])
+
+        self.assertEquals(''.join(m3.merge_lines()),
+                          'aaa\nbbb\n222\n')
+
+    def test_append_agreement(self):
+        m3 = Merge3(['aaa\n', 'bbb\n'],
+                    ['aaa\n', 'bbb\n', '222\n'],
+                    ['aaa\n', 'bbb\n', '222\n'])
+
+        self.assertEquals(''.join(m3.merge_lines()),
+                          'aaa\nbbb\n222\n')
+
+    def test_append_clash(self):
+        m3 = Merge3(['aaa\n', 'bbb\n'],
+                    ['aaa\n', 'bbb\n', '222\n'],
+                    ['aaa\n', 'bbb\n', '333\n'])
+
+        ml = m3.merge_lines(name_a='a',
+                            name_b='b',
+                            start_marker='<<',
+                            mid_marker='--',
+                            end_marker='>>')
+        self.assertEquals(''.join(ml),
+'''\
+aaa
+bbb
+<< a
+222
+--
+333
+>> b
+''')
+
+    def test_insert_agreement(self):
+        m3 = Merge3(['aaa\n', 'bbb\n'],
+                    ['aaa\n', '222\n', 'bbb\n'],
+                    ['aaa\n', '222\n', 'bbb\n'])
+
+        ml = m3.merge_lines(name_a='a',
+                            name_b='b',
+                            start_marker='<<',
+                            mid_marker='--',
+                            end_marker='>>')
+        self.assertEquals(''.join(ml), 'aaa\n222\nbbb\n')
+        
+
+    def test_insert_clash(self):
+        """Both try to insert lines in the same place."""
+        m3 = Merge3(['aaa\n', 'bbb\n'],
+                    ['aaa\n', '111\n', 'bbb\n'],
+                    ['aaa\n', '222\n', 'bbb\n'])
+
+        self.assertEquals(m3.find_unconflicted(),
+                          [(0, 1), (1, 2)])
+
+        self.assertEquals(list(m3.find_sync_regions()),
+                          [(0,1, 0,1, 0,1),
+                           (1,2, 2,3, 2,3),
+                           (2,2, 3,3, 3,3),])
+
+        self.assertEquals(list(m3.merge_regions()),
+                          [('unchanged', 0,1),
+                           ('conflict', 1,1, 1,2, 1,2),
+                           ('unchanged', 1,2)])
+
+        self.assertEquals(list(m3.merge_groups()),
+                          [('unchanged', ['aaa\n']),
+                           ('conflict', [], ['111\n'], ['222\n']),
+                           ('unchanged', ['bbb\n']),
+                           ])
+
+        ml = m3.merge_lines(name_a='a',
+                            name_b='b',
+                            start_marker='<<',
+                            mid_marker='--',
+                            end_marker='>>')
+        self.assertEquals(''.join(ml),
+'''aaa
+<< a
+111
+--
+222
+>> b
+bbb
+''')
+
+    def test_replace_clash(self):
+        """Both try to insert lines in the same place."""
+        m3 = Merge3(['aaa', '000', 'bbb'],
+                    ['aaa', '111', 'bbb'],
+                    ['aaa', '222', 'bbb'])
+
+        self.assertEquals(m3.find_unconflicted(),
+                          [(0, 1), (2, 3)])
+
+        self.assertEquals(list(m3.find_sync_regions()),
+                          [(0,1, 0,1, 0,1),
+                           (2,3, 2,3, 2,3),
+                           (3,3, 3,3, 3,3),])
+
+    def test_replace_multi(self):
+        """Replacement with regions of different size."""
+        m3 = Merge3(['aaa', '000', '000', 'bbb'],
+                    ['aaa', '111', '111', '111', 'bbb'],
+                    ['aaa', '222', '222', '222', '222', 'bbb'])
+
+        self.assertEquals(m3.find_unconflicted(),
+                          [(0, 1), (3, 4)])
+
+
+        self.assertEquals(list(m3.find_sync_regions()),
+                          [(0,1, 0,1, 0,1),
+                           (3,4, 4,5, 5,6),
+                           (4,4, 5,5, 6,6),])
+
+    def test_merge_poem(self):
+        """Test case from diff3 manual"""
+        m3 = Merge3(TZU, LAO, TAO)
+        ml = list(m3.merge_lines('LAO', 'TAO'))
+        self.log('merge result:')
+        self.log(''.join(ml))
+        self.assertEquals(ml, MERGED_RESULT)
+
+    def test_minimal_conflicts_common(self):
+        """Reprocessing"""
+        base_text = ("a\n" * 20).splitlines(True)
+        this_text = ("a\n"*10+"b\n" * 10).splitlines(True)
+        other_text = ("a\n"*10+"c\n"+"b\n" * 8 + "c\n").splitlines(True)
+        m3 = Merge3(base_text, other_text, this_text)
+        m_lines = m3.merge_lines('OTHER', 'THIS', reprocess=True)
+        merged_text = "".join(list(m_lines))
+        optimal_text = ("a\n" * 10 + "<<<<<<< OTHER\nc\n"
+            + 8* "b\n" + "c\n=======\n"
+            + 10*"b\n" + ">>>>>>> THIS\n")
+        self.assertEqualDiff(optimal_text, merged_text)
+
+    def test_minimal_conflicts_unique(self):
+        def add_newline(s):
+            """Add a newline to each entry in the string"""
+            return [(x+'\n') for x in s]
+
+        base_text = add_newline("abcdefghijklm")
+        this_text = add_newline("abcdefghijklmNOPQRSTUVWXYZ")
+        other_text = add_newline("abcdefghijklm1OPQRSTUVWXY2")
+        m3 = Merge3(base_text, other_text, this_text)
+        m_lines = m3.merge_lines('OTHER', 'THIS', reprocess=True)
+        merged_text = "".join(list(m_lines))
+        optimal_text = ''.join(add_newline("abcdefghijklm")
+            + ["<<<<<<< OTHER\n1\n=======\nN\n>>>>>>> THIS\n"]
+            + add_newline('OPQRSTUVWXY')
+            + ["<<<<<<< OTHER\n2\n=======\nZ\n>>>>>>> THIS\n"]
+            )
+        self.assertEqualDiff(optimal_text, merged_text)
+
+    def test_minimal_conflicts_nonunique(self):
+        def add_newline(s):
+            """Add a newline to each entry in the string"""
+            return [(x+'\n') for x in s]
+
+        base_text = add_newline("abacddefgghij")
+        this_text = add_newline("abacddefgghijkalmontfprz")
+        other_text = add_newline("abacddefgghijknlmontfprd")
+        m3 = Merge3(base_text, other_text, this_text)
+        m_lines = m3.merge_lines('OTHER', 'THIS', reprocess=True)
+        merged_text = "".join(list(m_lines))
+        optimal_text = ''.join(add_newline("abacddefgghijk")
+            + ["<<<<<<< OTHER\nn\n=======\na\n>>>>>>> THIS\n"]
+            + add_newline('lmontfpr')
+            + ["<<<<<<< OTHER\nd\n=======\nz\n>>>>>>> THIS\n"]
+            )
+        self.assertEqualDiff(optimal_text, merged_text)
+
+    def test_reprocess_and_base(self):
+        """Reprocessing and showing base breaks correctly"""
+        base_text = ("a\n" * 20).splitlines(True)
+        this_text = ("a\n"*10+"b\n" * 10).splitlines(True)
+        other_text = ("a\n"*10+"c\n"+"b\n" * 8 + "c\n").splitlines(True)
+        m3 = Merge3(base_text, other_text, this_text)
+        m_lines = m3.merge_lines('OTHER', 'THIS', reprocess=True, 
+                                 base_marker='|||||||')
+        self.assertRaises(CantReprocessAndShowBase, list, m_lines)
+
+    def test_binary(self):
+        self.assertRaises(BinaryFile, Merge3, ['\x00'], ['a'], ['b'])
+
+    def test_dos_text(self):
+        base_text = 'a\r\n'
+        this_text = 'b\r\n'
+        other_text = 'c\r\n'
+        m3 = Merge3(base_text.splitlines(True), other_text.splitlines(True),
+                    this_text.splitlines(True))
+        m_lines = m3.merge_lines('OTHER', 'THIS')
+        self.assertEqual('<<<<<<< OTHER\r\nc\r\n=======\r\nb\r\n'
+            '>>>>>>> THIS\r\n'.splitlines(True), list(m_lines))
+
+    def test_mac_text(self):
+        base_text = 'a\r'
+        this_text = 'b\r'
+        other_text = 'c\r'
+        m3 = Merge3(base_text.splitlines(True), other_text.splitlines(True),
+                    this_text.splitlines(True))
+        m_lines = m3.merge_lines('OTHER', 'THIS')
+        self.assertEqual('<<<<<<< OTHER\rc\r=======\rb\r'
+            '>>>>>>> THIS\r'.splitlines(True), list(m_lines))