Rev 5066: (spiv) Add MergeIntoMerger, in file:///home/pqm/archives/thelove/bzr/2.2/

Canonical.com Patch Queue Manager pqm at pqm.ubuntu.com
Thu Jul 29 09:11:43 BST 2010


At file:///home/pqm/archives/thelove/bzr/2.2/

------------------------------------------------------------
revno: 5066 [merge]
revision-id: pqm at pqm.ubuntu.com-20100729081141-5sryhxyfat6g1ygo
parent: pqm at pqm.ubuntu.com-20100722032854-gfj3a3pp8tapk0rf
parent: andrew.bennetts at canonical.com-20100729064520-lhj40835pwmwpf8p
committer: Canonical.com Patch Queue Manager <pqm at pqm.ubuntu.com>
branch nick: 2.2
timestamp: Thu 2010-07-29 09:11:41 +0100
message:
  (spiv) Add MergeIntoMerger,
   which can merge all or part of a tree from an unrelated branch into a new
   subdirectory. (Andrew Bennetts)
modified:
  NEWS                           NEWS-20050323055033-4e00b5db738777ff
  bzrlib/merge.py                merge.py-20050513021216-953b65a438527106
  bzrlib/tests/test_merge.py     testmerge.py-20050905070950-c1b5aa49ff911024
=== modified file 'NEWS'
--- a/NEWS	2010-07-22 03:28:54 +0000
+++ b/NEWS	2010-07-29 06:45:20 +0000
@@ -56,6 +56,9 @@
 Improvements
 ************
 
+* Add ``bzrlib.merge.MergeIntoMerger``, which can merge part or all of a
+  tree, and works with unrelated branches.  (Andrew Bennetts)
+
 Documentation
 *************
 

=== modified file 'bzrlib/merge.py'
--- a/bzrlib/merge.py	2010-06-04 03:09:35 +0000
+++ b/bzrlib/merge.py	2010-07-29 03:56:54 +0000
@@ -22,7 +22,7 @@
     branch as _mod_branch,
     conflicts as _mod_conflicts,
     debug,
-    errors,
+    generate_ids,
     graph as _mod_graph,
     merge3,
     osutils,
@@ -35,11 +35,13 @@
     tsort,
     ui,
     versionedfile,
+    workingtree,
     )
 from bzrlib.cleanup import OperationWithCleanups
 """)
 from bzrlib import (
     decorators,
+    errors,
     hooks,
     )
 from bzrlib.symbol_versioning import (
@@ -426,7 +428,6 @@
         return self._cached_trees[revision_id]
 
     def _get_tree(self, treespec, possible_transports=None):
-        from bzrlib import workingtree
         location, revno = treespec
         if revno is None:
             tree = workingtree.WorkingTree.open_containing(location)[0]
@@ -861,6 +862,13 @@
         finally:
             child_pb.finished()
         self.fix_root()
+        self._finish_computing_transform()
+
+    def _finish_computing_transform(self):
+        """Finalize the transform and report the changes.
+
+        This is the second half of _compute_transform.
+        """
         child_pb = ui.ui_factory.nested_progress_bar()
         try:
             fs_conflicts = transform.resolve_conflicts(self.tt, child_pb,
@@ -1076,7 +1084,6 @@
                           ))
         return result
 
-
     def fix_root(self):
         try:
             self.tt.final_kind(self.tt.root)
@@ -1755,6 +1762,168 @@
             osutils.rmtree(temp_dir)
 
 
+class PathNotInTree(errors.BzrError):
+
+    _fmt = """Merge-into failed because %(tree)s does not contain %(path)s."""
+
+    def __init__(self, path, tree):
+        errors.BzrError.__init__(self, path=path, tree=tree)
+
+
+class MergeIntoMerger(Merger):
+    """Merger that understands other_tree will be merged into a subdir.
+
+    This also changes the Merger api so that it uses real Branch, revision_id,
+    and RevisonTree objects, rather than using revision specs.
+    """
+
+    def __init__(self, this_tree, other_branch, other_tree, target_subdir,
+            source_subpath, other_rev_id=None):
+        """Create a new MergeIntoMerger object.
+
+        source_subpath in other_tree will be effectively copied to
+        target_subdir in this_tree.
+
+        :param this_tree: The tree that we will be merging into.
+        :param other_branch: The Branch we will be merging from.
+        :param other_tree: The RevisionTree object we want to merge.
+        :param target_subdir: The relative path where we want to merge
+            other_tree into this_tree
+        :param source_subpath: The relative path specifying the subtree of
+            other_tree to merge into this_tree.
+        """
+        # It is assumed that we are merging a tree that is not in our current
+        # ancestry, which means we are using the "EmptyTree" as our basis.
+        null_ancestor_tree = this_tree.branch.repository.revision_tree(
+                                _mod_revision.NULL_REVISION)
+        super(MergeIntoMerger, self).__init__(
+            this_branch=this_tree.branch,
+            this_tree=this_tree,
+            other_tree=other_tree,
+            base_tree=null_ancestor_tree,
+            )
+        self._target_subdir = target_subdir
+        self._source_subpath = source_subpath
+        self.other_branch = other_branch
+        if other_rev_id is None:
+            other_rev_id = other_tree.get_revision_id()
+        self.other_rev_id = self.other_basis = other_rev_id
+        self.base_is_ancestor = True
+        self.backup_files = True
+        self.merge_type = Merge3Merger
+        self.show_base = False
+        self.reprocess = False
+        self.interesting_ids = None
+        self.merge_type = _MergeTypeParameterizer(MergeIntoMergeType,
+              target_subdir=self._target_subdir,
+              source_subpath=self._source_subpath)
+        if self._source_subpath != '':
+            # If this isn't a partial merge make sure the revisions will be
+            # present.
+            self._maybe_fetch(self.other_branch, self.this_branch,
+                self.other_basis)
+
+    def set_pending(self):
+        if self._source_subpath != '':
+            return
+        Merger.set_pending(self)
+
+
+class _MergeTypeParameterizer(object):
+    """Wrap a merge-type class to provide extra parameters.
+    
+    This is hack used by MergeIntoMerger to pass some extra parameters to its
+    merge_type.  Merger.do_merge() sets up its own set of parameters to pass to
+    the 'merge_type' member.  It is difficult override do_merge without
+    re-writing the whole thing, so instead we create a wrapper which will pass
+    the extra parameters.
+    """
+
+    def __init__(self, merge_type, **kwargs):
+        self._extra_kwargs = kwargs
+        self._merge_type = merge_type
+
+    def __call__(self, *args, **kwargs):
+        kwargs.update(self._extra_kwargs)
+        return self._merge_type(*args, **kwargs)
+
+    def __getattr__(self, name):
+        return getattr(self._merge_type, name)
+
+
+class MergeIntoMergeType(Merge3Merger):
+    """Merger that incorporates a tree (or part of a tree) into another."""
+
+    def __init__(self, *args, **kwargs):
+        """Initialize the merger object.
+
+        :param args: See Merge3Merger.__init__'s args.
+        :param kwargs: See Merge3Merger.__init__'s keyword args, except for
+            source_subpath and target_subdir.
+        :keyword source_subpath: The relative path specifying the subtree of
+            other_tree to merge into this_tree.
+        :keyword target_subdir: The relative path where we want to merge
+            other_tree into this_tree
+        """
+        # All of the interesting work happens during Merge3Merger.__init__(),
+        # so we have have to hack in to get our extra parameters set.
+        self._source_subpath = kwargs.pop('source_subpath')
+        self._target_subdir = kwargs.pop('target_subdir')
+        super(MergeIntoMergeType, self).__init__(*args, **kwargs)
+
+    def _compute_transform(self):
+        child_pb = ui.ui_factory.nested_progress_bar()
+        try:
+            entries = self._entries_to_incorporate()
+            entries = list(entries)
+            for num, (entry, parent_id) in enumerate(entries):
+                child_pb.update('Preparing file merge', num, len(entries))
+                parent_trans_id = self.tt.trans_id_file_id(parent_id)
+                trans_id = transform.new_by_entry(self.tt, entry,
+                    parent_trans_id, self.other_tree)
+        finally:
+            child_pb.finished()
+        self._finish_computing_transform()
+
+    def _entries_to_incorporate(self):
+        """Yields pairs of (inventory_entry, new_parent)."""
+        other_inv = self.other_tree.inventory
+        subdir_id = other_inv.path2id(self._source_subpath)
+        if subdir_id is None:
+            # XXX: The error would be clearer if it gave the URL of the source
+            # branch, but we don't have a reference to that here.
+            raise PathNotInTree(self._source_subpath, "Source tree")
+        subdir = other_inv[subdir_id]
+        parent_in_target = osutils.dirname(self._target_subdir)
+        target_id = self.this_tree.inventory.path2id(parent_in_target)
+        if target_id is None:
+            raise PathNotInTree(self._target_subdir, "Target tree")
+        name_in_target = osutils.basename(self._target_subdir)
+        merge_into_root = subdir.copy()
+        merge_into_root.name = name_in_target
+        if merge_into_root.file_id in self.this_tree.inventory:
+            # Give the root a new file-id.
+            # This can happen fairly easily if the directory we are
+            # incorporating is the root, and both trees have 'TREE_ROOT' as
+            # their root_id.  Users will expect this to Just Work, so we
+            # change the file-id here.
+            # Non-root file-ids could potentially conflict too.  That's really
+            # an edge case, so we don't do anything special for those.  We let
+            # them cause conflicts.
+            merge_into_root.file_id = generate_ids.gen_file_id(name_in_target)
+        yield (merge_into_root, target_id)
+        if subdir.kind != 'directory':
+            # No children, so we are done.
+            return
+        for ignored_path, entry in other_inv.iter_entries_by_dir(subdir_id):
+            parent_id = entry.parent_id
+            if parent_id == subdir.file_id:
+                # The root's parent ID has changed, so make sure children of
+                # the root refer to the new ID.
+                parent_id = merge_into_root.file_id
+            yield (entry, parent_id)
+
+
 def merge_inner(this_branch, other_tree, base_tree, ignore_zero=False,
                 backup_files=False,
                 merge_type=Merge3Merger,
@@ -1768,10 +1937,11 @@
                 change_reporter=None):
     """Primary interface for merging.
 
-        typical use is probably
-        'merge_inner(branch, branch.get_revision_tree(other_revision),
-                     branch.get_revision_tree(base_revision))'
-        """
+    Typical use is probably::
+
+        merge_inner(branch, branch.get_revision_tree(other_revision),
+                    branch.get_revision_tree(base_revision))
+    """
     if this_tree is None:
         raise errors.BzrError("bzrlib.merge.merge_inner requires a this_tree "
                               "parameter as of bzrlib version 0.8.")

=== modified file 'bzrlib/tests/test_merge.py'
--- a/bzrlib/tests/test_merge.py	2010-02-23 07:43:11 +0000
+++ b/bzrlib/tests/test_merge.py	2010-07-29 04:07:27 +0000
@@ -18,13 +18,16 @@
 from StringIO import StringIO
 
 from bzrlib import (
+    branch as _mod_branch,
+    cleanup,
     conflicts,
     errors,
+    inventory,
     knit,
     memorytree,
     merge as _mod_merge,
     option,
-    progress,
+    revision as _mod_revision,
     tests,
     transform,
     versionedfile,
@@ -32,7 +35,7 @@
 from bzrlib.conflicts import ConflictList, TextConflict
 from bzrlib.errors import UnrelatedBranches, NoCommits
 from bzrlib.merge import transform_tree, merge_inner, _PlanMerge
-from bzrlib.osutils import pathjoin, file_kind
+from bzrlib.osutils import basename, pathjoin, file_kind
 from bzrlib.tests import (
     TestCaseWithMemoryTransport,
     TestCaseWithTransport,
@@ -2917,3 +2920,240 @@
         conflicts = builder.merge()
         # The hook should not call the merge_text() method
         self.assertEqual([], self.calls)
+
+
+class TestMergeIntoBase(tests.TestCaseWithTransport):
+
+    def setup_simple_branch(self, relpath, shape=None, root_id=None):
+        """One commit, containing tree specified by optional shape.
+        
+        Default is empty tree (just root entry).
+        """
+        if root_id is None:
+            root_id = '%s-root-id' % (relpath,)
+        wt = self.make_branch_and_tree(relpath)
+        wt.set_root_id(root_id)
+        if shape is not None:
+            adjusted_shape = [relpath + '/' + elem for elem in shape]
+            self.build_tree(adjusted_shape)
+            ids = ['%s-%s-id' % (relpath, basename(elem.rstrip('/')))
+                   for elem in shape]
+            wt.add(shape, ids=ids)
+        rev_id = 'r1-%s' % (relpath,)
+        wt.commit("Initial commit of %s" % (relpath,), rev_id=rev_id)
+        self.assertEqual(root_id, wt.path2id(''))
+        return wt
+
+    def setup_two_branches(self, custom_root_ids=True):
+        """Setup 2 branches, one will be a library, the other a project."""
+        if custom_root_ids:
+            root_id = None
+        else:
+            root_id = inventory.ROOT_ID
+        project_wt = self.setup_simple_branch(
+            'project', ['README', 'dir/', 'dir/file.c'],
+            root_id)
+        lib_wt = self.setup_simple_branch(
+            'lib1', ['README', 'Makefile', 'foo.c'], root_id)
+
+        return project_wt, lib_wt
+
+    def do_merge_into(self, location, merge_as):
+        """Helper for using MergeIntoMerger.
+        
+        :param location: location of directory to merge from, either the
+            location of a branch or of a path inside a branch.
+        :param merge_as: the path in a tree to add the new directory as.
+        :returns: the conflicts from 'do_merge'.
+        """
+        operation = cleanup.OperationWithCleanups(self._merge_into)
+        return operation.run(location, merge_as)
+
+    def _merge_into(self, op, location, merge_as):
+        # Open and lock the various tree and branch objects
+        wt, subdir_relpath = WorkingTree.open_containing(merge_as)
+        op.add_cleanup(wt.lock_write().unlock)
+        branch_to_merge, subdir_to_merge = _mod_branch.Branch.open_containing(
+            location)
+        op.add_cleanup(branch_to_merge.lock_read().unlock)
+        other_tree = branch_to_merge.basis_tree()
+        op.add_cleanup(other_tree.lock_read().unlock)
+        # Perform the merge
+        merger = _mod_merge.MergeIntoMerger(this_tree=wt, other_tree=other_tree,
+            other_branch=branch_to_merge, target_subdir=subdir_relpath,
+            source_subpath=subdir_to_merge)
+        merger.set_base_revision(_mod_revision.NULL_REVISION, branch_to_merge)
+        conflicts = merger.do_merge()
+        merger.set_pending()
+        return conflicts
+
+    def assertTreeEntriesEqual(self, expected_entries, tree):
+        """Assert that 'tree' contains the expected inventory entries.
+
+        :param expected_entries: sequence of (path, file-id) pairs.
+        """
+        files = [(path, ie.file_id) for path, ie in tree.iter_entries_by_dir()]
+        self.assertEqual(expected_entries, files)
+
+
+class TestMergeInto(TestMergeIntoBase):
+
+    def test_newdir_with_unique_roots(self):
+        """Merge a branch with a unique root into a new directory."""
+        project_wt, lib_wt = self.setup_two_branches()
+        self.do_merge_into('lib1', 'project/lib1')
+        project_wt.lock_read()
+        self.addCleanup(project_wt.unlock)
+        # The r1-lib1 revision should be merged into this one
+        self.assertEqual(['r1-project', 'r1-lib1'], project_wt.get_parent_ids())
+        self.assertTreeEntriesEqual(
+            [('', 'project-root-id'),
+             ('README', 'project-README-id'),
+             ('dir', 'project-dir-id'),
+             ('lib1', 'lib1-root-id'),
+             ('dir/file.c', 'project-file.c-id'),
+             ('lib1/Makefile', 'lib1-Makefile-id'),
+             ('lib1/README', 'lib1-README-id'),
+             ('lib1/foo.c', 'lib1-foo.c-id'),
+            ], project_wt)
+
+    def test_subdir(self):
+        """Merge a branch into a subdirectory of an existing directory."""
+        project_wt, lib_wt = self.setup_two_branches()
+        self.do_merge_into('lib1', 'project/dir/lib1')
+        project_wt.lock_read()
+        self.addCleanup(project_wt.unlock)
+        # The r1-lib1 revision should be merged into this one
+        self.assertEqual(['r1-project', 'r1-lib1'], project_wt.get_parent_ids())
+        self.assertTreeEntriesEqual(
+            [('', 'project-root-id'),
+             ('README', 'project-README-id'),
+             ('dir', 'project-dir-id'),
+             ('dir/file.c', 'project-file.c-id'),
+             ('dir/lib1', 'lib1-root-id'),
+             ('dir/lib1/Makefile', 'lib1-Makefile-id'),
+             ('dir/lib1/README', 'lib1-README-id'),
+             ('dir/lib1/foo.c', 'lib1-foo.c-id'),
+            ], project_wt)
+
+    def test_newdir_with_repeat_roots(self):
+        """If the file-id of the dir to be merged already exists a new ID will
+        be allocated to let the merge happen.
+        """
+        project_wt, lib_wt = self.setup_two_branches(custom_root_ids=False)
+        root_id = project_wt.path2id('')
+        self.do_merge_into('lib1', 'project/lib1')
+        project_wt.lock_read()
+        self.addCleanup(project_wt.unlock)
+        # The r1-lib1 revision should be merged into this one
+        self.assertEqual(['r1-project', 'r1-lib1'], project_wt.get_parent_ids())
+        new_lib1_id = project_wt.path2id('lib1')
+        self.assertNotEqual(None, new_lib1_id)
+        self.assertTreeEntriesEqual(
+            [('', root_id),
+             ('README', 'project-README-id'),
+             ('dir', 'project-dir-id'),
+             ('lib1', new_lib1_id),
+             ('dir/file.c', 'project-file.c-id'),
+             ('lib1/Makefile', 'lib1-Makefile-id'),
+             ('lib1/README', 'lib1-README-id'),
+             ('lib1/foo.c', 'lib1-foo.c-id'),
+            ], project_wt)
+
+    def test_name_conflict(self):
+        """When the target directory name already exists a conflict is
+        generated and the original directory is renamed to foo.moved.
+        """
+        dest_wt = self.setup_simple_branch('dest', ['dir/', 'dir/file.txt'])
+        src_wt = self.setup_simple_branch('src', ['README'])
+        conflicts = self.do_merge_into('src', 'dest/dir')
+        self.assertEqual(1, conflicts)
+        dest_wt.lock_read()
+        self.addCleanup(dest_wt.unlock)
+        # The r1-lib1 revision should be merged into this one
+        self.assertEqual(['r1-dest', 'r1-src'], dest_wt.get_parent_ids())
+        self.assertTreeEntriesEqual(
+            [('', 'dest-root-id'),
+             ('dir', 'src-root-id'),
+             ('dir.moved', 'dest-dir-id'),
+             ('dir/README', 'src-README-id'),
+             ('dir.moved/file.txt', 'dest-file.txt-id'),
+            ], dest_wt)
+
+    def test_file_id_conflict(self):
+        """A conflict is generated if the merge-into adds a file (or other
+        inventory entry) with a file-id that already exists in the target tree.
+        """
+        dest_wt = self.setup_simple_branch('dest', ['file.txt'])
+        # Make a second tree with a file-id that will clash with file.txt in
+        # dest.
+        src_wt = self.make_branch_and_tree('src')
+        self.build_tree(['src/README'])
+        src_wt.add(['README'], ids=['dest-file.txt-id'])
+        src_wt.commit("Rev 1 of src.", rev_id='r1-src')
+        conflicts = self.do_merge_into('src', 'dest/dir')
+        # This is an edge case that shouldn't happen to users very often.  So
+        # we don't care really about the exact presentation of the conflict,
+        # just that there is one.
+        self.assertEqual(1, conflicts)
+
+    def test_only_subdir(self):
+        """When the location points to just part of a tree, merge just that
+        subtree.
+        """
+        dest_wt = self.setup_simple_branch('dest')
+        src_wt = self.setup_simple_branch(
+            'src', ['hello.txt', 'dir/', 'dir/foo.c'])
+        conflicts = self.do_merge_into('src/dir', 'dest/dir')
+        dest_wt.lock_read()
+        self.addCleanup(dest_wt.unlock)
+        # The r1-lib1 revision should NOT be merged into this one (this is a
+        # partial merge).
+        self.assertEqual(['r1-dest'], dest_wt.get_parent_ids())
+        self.assertTreeEntriesEqual(
+            [('', 'dest-root-id'),
+             ('dir', 'src-dir-id'),
+             ('dir/foo.c', 'src-foo.c-id'),
+            ], dest_wt)
+
+    def test_only_file(self):
+        """An edge case: merge just one file, not a whole dir."""
+        dest_wt = self.setup_simple_branch('dest')
+        two_file_wt = self.setup_simple_branch(
+            'two-file', ['file1.txt', 'file2.txt'])
+        conflicts = self.do_merge_into('two-file/file1.txt', 'dest/file1.txt')
+        dest_wt.lock_read()
+        self.addCleanup(dest_wt.unlock)
+        # The r1-lib1 revision should NOT be merged into this one
+        self.assertEqual(['r1-dest'], dest_wt.get_parent_ids())
+        self.assertTreeEntriesEqual(
+            [('', 'dest-root-id'), ('file1.txt', 'two-file-file1.txt-id')],
+            dest_wt)
+
+    def test_no_such_source_path(self):
+        """PathNotInTree is raised if the specified path in the source tree
+        does not exist.
+        """
+        dest_wt = self.setup_simple_branch('dest')
+        two_file_wt = self.setup_simple_branch('src', ['dir/'])
+        self.assertRaises(_mod_merge.PathNotInTree, self.do_merge_into,
+            'src/no-such-dir', 'dest/foo')
+        dest_wt.lock_read()
+        self.addCleanup(dest_wt.unlock)
+        # The dest tree is unmodified.
+        self.assertEqual(['r1-dest'], dest_wt.get_parent_ids())
+        self.assertTreeEntriesEqual([('', 'dest-root-id')], dest_wt)
+
+    def test_no_such_target_path(self):
+        """PathNotInTree is also raised if the specified path in the target
+        tree does not exist.
+        """
+        dest_wt = self.setup_simple_branch('dest')
+        two_file_wt = self.setup_simple_branch('src', ['file.txt'])
+        self.assertRaises(_mod_merge.PathNotInTree, self.do_merge_into,
+            'src', 'dest/no-such-dir/foo')
+        dest_wt.lock_read()
+        self.addCleanup(dest_wt.unlock)
+        # The dest tree is unmodified.
+        self.assertEqual(['r1-dest'], dest_wt.get_parent_ids())
+        self.assertTreeEntriesEqual([('', 'dest-root-id')], dest_wt)




More information about the bazaar-commits mailing list