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