Commits

David Schleimer committed cd0d14e

layouts: add custom layout for those of us that need weird mappings

This adds a config-driven custom layout, targeted at the case where
you need to fetch a small subset of a large number of subversion
branches, or where your subversion layout doesn't match the standard
trunk/branches/tags layout very well.

Comments (0)

Files changed (12)

hgsubversion/help/subversion.rst

 
  $ hg clone --layout single svn+http://python-nose.googlecode.com/svn nose-hg
 
+Finally, if you want to clone two or more directores as separate
+branches, use the custom layout.  See the documentation below for the
+``hgsubversionbranch.*`` configuration for detailed help.
+
 Pulling new revisions into an already-converted repository is the same
 as from any other Mercurial source. Within the first example above,
 the following three commands are all equivalent::
     repository is converted into a single branch. The default,
     ``auto``, causes hgsubversion to assume a standard layout if any
     of trunk, branches, or tags exist within the specified directory
-    on the server.
+    on the server.  ``custom`` causes hgsubversion to read the
+    ``hgsubversionbranch`` config section to determine the repository
+    layout.
 
   ``hgsubversion.startrev``
 
     you use this option, be sure to carefully check the result of a
     pull afterwards.
 
+    ``hgsubversionbranch.*``
+
+    Use this config section with the custom layout to specify a cusomt
+    mapping of subversion path to Mercurial branch.  This is useful if
+    your layout is substantially different from the standard
+    trunk/branches/tags layout and/or you are only interested in a few
+    branches.
+
+    Example config that pulls in trunk as the default branch,
+    personal/alice as the alice branch, and releases/2.0/2.7 as
+    release-2.7::
+
+        [hgsubversionbranch]
+            default = trunk
+            alice = personal/alice
+            release-2.7 = releases/2.0/2.7
+
+    Note that it is an error to specify more than one branch for a
+    given path, or to sepecify nested paths (e.g. releases/2.0 and
+    releases/2.0/2.7)
+
 Please note that some of these options may be specified as command line options
 as well, and when done so, will override the configuration. If an authormap,
 filemap or branchmap is specified, its contents will be read and stored for use

hgsubversion/layouts/__init__.py

 
 from mercurial import util as hgutil
 
+import custom
 import detect
 import persist
 import single
 # The intention is for extension authors who wish to build their own
 # layout to add it to this dict.
 NAME_TO_CLASS = {
+    "custom": custom.CustomLayout,
     "single": single.SingleLayout,
     "standard": standard.StandardLayout,
 }

hgsubversion/layouts/custom.py

+"""Layout that allows you to define arbitrary subversion to mercurial mappings.
+
+This is the simplest layout to use if your layout is just plain weird.
+Also useful if your layout is pretty normal, but you personally only
+want a couple of branches.
+
+
+"""
+
+import base
+
+
+class CustomLayout(base.BaseLayout):
+
+    def __init__(self, ui):
+        base.BaseLayout.__init__(self, ui)
+
+        self.svn_to_hg = {}
+        self.hg_to_svn = {}
+
+        for hg_branch, svn_path in ui.configitems('hgsubversionbranch'):
+
+            hg_branch = hg_branch.strip()
+            if hg_branch == 'default' or not hg_branch:
+                hg_branch = None
+            svn_path = svn_path.strip('/')
+
+            for other_svn in self.svn_to_hg:
+                if other_svn == svn_path:
+                    msg = 'specified two hg branches for svn path %s: %s and %s'
+                    raise hgutil.Abort(msg % (svn_path, other_hg, hg_branch))
+
+                if (other_svn.startswith(svn_path + '/') or
+                    svn_path.startswith(other_svn + '/')):
+                    msg = 'specified mappings for nested svn paths: %s and %s'
+                    raise hgutl.Abort(msg % (svn_path, other_svn))
+
+            self.svn_to_hg[svn_path] = hg_branch
+            self.hg_to_svn[hg_branch] = svn_path
+
+    def localname(self, path):
+        if path in self.svn_to_hg:
+            return self.svn_to_hg[path]
+        children = []
+        for svn_path in self.svn_to_hg:
+            if svn_path.startswith(path + '/'):
+                children.append(svn_path)
+        if len(children) == 1:
+            return self.svn_to_hg[children[0]]
+
+        return '../%s' % path
+
+    def remotename(self, branch):
+        if branch =='default':
+            branch = None
+        if branch and branch.startswith('../'):
+            return branch[3:]
+        if branch not in self.hg_to_svn:
+            raise KeyError('Unknown mercurial branch: %s' % branch)
+        return self.hg_to_svn[branch]
+
+    def remotepath(self, branch, subdir='/'):
+        if not subdir.endswith('/'):
+            subdir += '/'
+        return subdir + self.remotename(branch)
+
+    def taglocations(self, meta_data_dir):
+        return []
+
+    def get_path_tag(self, path, taglocations):
+        return None
+
+    def split_remote_name(self, path, known_branches):
+        if path in self.svn_to_hg:
+            return path, ''
+        children = []
+        for svn_path in self.svn_to_hg:
+            if path.startswith(svn_path + '/'):
+                return svn_path, path[len(svn_path)+1:]
+            if svn_path.startswith(path + '/'):
+                children.append(svn_path)
+
+        # if the path represents the parent of exactly one of our svn
+        # branches, treat it as though it were that branch, because
+        # that means we are probably pulling in a subproject of an svn
+        # project, and someone copied the parent svn project.
+        if len(children) == 1:
+            return children[0], ''
+
+        for branch in known_branches:
+            if branch and branch.startswith('../'):
+                if path.startswith(branch[3:] + '/'):
+                    # -3 for the leading ../, plus one for the trailing /
+                    return branch[3:], path[len(branch) - 2:]
+                if branch[3:].startswith(path + '/'):
+                    children.append(branch[3:])
+
+        if len(children) == 1:
+            return children[0], ''
+
+
+        # this splits on the rightmost '/' but considers the entire
+        # string to be the branch component of the path if there is no '/'
+        components = path.rsplit('/', 1)
+        return components[0], '/'.join(components[1:])

hgsubversion/svncommands.py

                                                        ctx.branch(), ui)
             existing_layout = layouts.detect.layout_from_file(svnmetadir)
             if layout != existing_layout:
+                if existing_layout == 'custom' and layout == 'standard':
+                    import pdb
+                    pdb.set_trace()
                 layouts.persist.layout_to_file(svnmetadir, layout)
             layoutobj = layouts.layout_from_name(layout, ui)
         elif layout == 'single':

hgsubversion/wrappers.py

     'startrev': ('hgsubversion', 'startrev'),
 }
 
-dontretain = { 'hgsubversion': set(['authormap', 'filemap', 'layout', ]) }
+extrasections = set(['hgsubversionbranch'])
+
+
+dontretain = {
+    'hgsubversion': set(['authormap', 'filemap', 'layout', ]),
+    'hgsubversionbranch': set(),
+    }
 
 def clone(orig, ui, source, dest=None, **opts):
     """
             fd = dstrepo.opener("hgrc", "a", text=True)
         else:
             fd = dst.opener("hgrc", "a", text=True)
-        for section in set(s for s, v in optionmap.itervalues()):
+        preservesections = set(s for s, v in optionmap.itervalues())
+        preservesections |= extrasections
+        for section in preservesections:
             config = dict(ui.configitems(section))
             for name in dontretain[section]:
                 config.pop(name, None)

tests/comprehensive/test_custom_layout.py

+import os
+import pickle
+import sys
+import unittest
+
+from mercurial import hg
+from mercurial import ui
+
+# wrapped in a try/except because of weirdness in how
+# run.py works as compared to nose.
+try:
+    import test_util
+except ImportError:
+    sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
+    import test_util
+
+from hgsubversion import wrappers
+
+
+def _do_case(self, name, stupid):
+    subdir = test_util.subdir.get(name, '')
+    config = {
+        'hgsubversion.stupid': stupid and '1' or '0',
+        }
+    repo, repo_path = self.load_and_fetch(name,
+                                          subdir=subdir,
+                                          layout='auto',
+                                          config=config)
+    assert test_util.repolen(self.repo) > 0, \
+        'Repo had no changes, maybe you need to add a subdir entry in test_util?'
+    wc2_path = self.wc_path + '_custom'
+    checkout_path = repo_path
+    if subdir:
+        checkout_path += '/' + subdir
+    u = ui.ui()
+    if stupid:
+        u.setconfig('hgsubversion', 'stupid', '1')
+    u.setconfig('hgsubversion', 'layout', 'custom')
+    for branch, path in test_util.custom.get(name, {}).iteritems():
+        u.setconfig('hgsubversionbranch', branch, path)
+    test_util.hgclone(u,
+                      test_util.fileurl(checkout_path),
+                      wc2_path,
+                      update=False)
+    self.repo2 = hg.repository(ui.ui(), wc2_path)
+    self.assertEqual(self.repo.heads(), self.repo2.heads())
+
+
+def buildmethod(case, name, stupid):
+    m = lambda self: self._do_case(case, stupid)
+    m.__name__ = name
+    replay = stupid and 'stupid' or 'regular'
+    m.__doc__ = 'Test custom produces same as standard on %s. (%s)' % (case,
+                                                                       replay)
+    return m
+
+attrs = {'_do_case': _do_case,
+         }
+for case in test_util.custom:
+    name = 'test_' + case[:-len('.svndump')].replace('-', '_')
+    attrs[name] = buildmethod(case, name, stupid=False)
+    name += '_stupid'
+    attrs[name] = buildmethod(case, name, stupid=True)
+
+CustomPullTests = type('CustomPullTests', (test_util.TestBase,), attrs)

tests/comprehensive/test_rebuildmeta.py

 
 
 
-def _do_case(self, name, single):
+def _do_case(self, name, layout):
     subdir = test_util.subdir.get(name, '')
-    layout = 'auto'
-    if single:
-        layout = 'single'
+    single = layout == 'single'
+    u = ui.ui()
+    config = {}
+    if layout == 'custom':
+        for branch, path in test_util.custom.get(name, {}).iteritems():
+            config['hgsubversionbranch.%s' % branch] = path
+            u.setconfig('hgsubversionbranch', branch, path)
     repo, repo_path = self.load_and_fetch(name, subdir=subdir, layout=layout)
     assert test_util.repolen(self.repo) > 0
     wc2_path = self.wc_path + '_clone'
-    u = ui.ui()
     src, dest = test_util.hgclone(u, self.wc_path, wc2_path, update=False)
     src = test_util.getlocalpeer(src)
     dest = test_util.getlocalpeer(dest)
             self.assertEqual(srcinfo[2], destinfo[2])
 
 
-def buildmethod(case, name, single):
-    m = lambda self: self._do_case(case, single)
+def buildmethod(case, name, layout):
+    m = lambda self: self._do_case(case, layout)
     m.__name__ = name
     m.__doc__ = ('Test rebuildmeta on %s (%s)' %
-                 (case, (single and 'single') or 'standard'))
+                 (case, layout))
     return m
 
 
     if case in skip:
         continue
     bname = 'test_' + case[:-len('.svndump')]
-    attrs[bname] = buildmethod(case, bname, False)
-    name = bname + '_single'
-    attrs[name] = buildmethod(case, name, True)
+    attrs[bname] = buildmethod(case, bname, 'auto')
+    attrs[bname + '_single'] = buildmethod(case, bname + '_single', 'single')
+    if case in test_util.custom:
+            attrs[bname + '_custom'] = buildmethod(case,
+                                                   bname + '_custom',
+                                                   'single')
 
 RebuildMetaTests = type('RebuildMetaTests', (test_util.TestBase,), attrs)

tests/comprehensive/test_stupid_pull.py

 
 def _do_case(self, name, layout):
     subdir = test_util.subdir.get(name, '')
-    repo, repo_path = self.load_and_fetch(name, subdir=subdir, layout=layout)
+    config = {}
+    u = ui.ui()
+    for branch, path in test_util.custom.get(name, {}).iteritems():
+        config['hgsubversionbranch.%s' % branch] = path
+        u.setconfig('hgsubversionbranch', branch, path)
+    repo, repo_path = self.load_and_fetch(name,
+                                          subdir=subdir,
+                                          layout=layout,
+                                          config=config)
     assert test_util.repolen(self.repo) > 0, \
         'Repo had no changes, maybe you need to add a subdir entry in test_util?'
     wc2_path = self.wc_path + '_stupid'
-    u = ui.ui()
     checkout_path = repo_path
     if subdir:
         checkout_path += '/' + subdir
     # here, but since it isn't a regression we suppress the test case.
     if case != 'branchtagcollision.svndump':
         attrs[name] = buildmethod(case, name, 'auto')
-    name += '_single'
-    attrs[name] = buildmethod(case, name, 'single')
+    attrs[name + '_single'] = buildmethod(case, name + '_single', 'single')
+    if case in test_util.custom:
+        attrs[name + '_custom'] = buildmethod(case, name + '_custom', 'custom')
 
 StupidPullTests = type('StupidPullTests', (test_util.TestBase,), attrs)

tests/comprehensive/test_updatemeta.py

 
 
 
-def _do_case(self, name, single):
+def _do_case(self, name, layout):
     subdir = test_util.subdir.get(name, '')
-    layout = 'auto'
-    if single:
-        layout = 'single'
-    repo, repo_path = self.load_and_fetch(name, subdir=subdir, layout=layout)
+    single = layout == 'single'
+    u = ui.ui()
+    config = {}
+    if layout == 'custom':
+        config['hgsubversion.layout'] = 'custom'
+        u.setconfig('hgsubversion', 'layout', 'custom')
+        for branch, path in test_util.custom.get(name, {}).iteritems():
+            config['hgsubversionbranch.%s' % branch] = path
+            u.setconfig('hgsubversionbranch', branch, path)
+
+    repo, repo_path = self.load_and_fetch(name,
+                                          subdir=subdir,
+                                          layout=layout,
+                                          config=config)
     assert test_util.repolen(self.repo) > 0
     wc2_path = self.wc_path + '_clone'
-    u = ui.ui()
     src, dest = test_util.hgclone(u, self.wc_path, wc2_path, update=False)
     src = test_util.getlocalpeer(src)
     dest = test_util.getlocalpeer(dest)
     if case in skip:
         continue
     bname = 'test_' + case[:-len('.svndump')]
-    attrs[bname] = test_rebuildmeta.buildmethod(case, bname, False)
-    name = bname + '_single'
-    attrs[name] = test_rebuildmeta.buildmethod(case, name, True)
+    attrs[bname] = test_rebuildmeta.buildmethod(case, bname, 'auto')
+    attrs[bname + '_single'] = test_rebuildmeta.buildmethod(case,
+                                                            bname + '_single',
+                                                            'single')
+    if case in test_util.custom:
+        attrs[bname + '_custom'] = test_rebuildmeta.buildmethod(case,
+                                                                bname + '_custom',
+                                                                'custom')
+
 
 UpdateMetaTests = type('UpdateMetaTests', (test_util.TestBase,), attrs)

tests/comprehensive/test_verify_and_startrev.py

 
 def _do_case(self, name, layout):
     subdir = test_util.subdir.get(name, '')
-    repo, svnpath = self.load_and_fetch(name, subdir=subdir, layout=layout)
+    config = {}
+    for branch, path in test_util.custom.get(name, {}).iteritems():
+        config['hgsubversionbranch.%s' % branch] = path
+    repo, svnpath = self.load_and_fetch(name,
+                                        subdir=subdir,
+                                        layout=layout,
+                                        config=config)
     assert test_util.repolen(self.repo) > 0
     for i in repo:
         ctx = repo[i]
     bname = 'test_' + case[:-len('.svndump')]
     if case not in _skipstandard:
         attrs[bname] = buildmethod(case, bname, 'standard')
-    name = bname + '_single'
-    attrs[name] = buildmethod(case, name, 'single')
+    attrs[bname + '_single'] = buildmethod(case, bname + '_single', 'single')
+    if case in test_util.custom:
+        attrs[bname + '_custom'] = buildmethod(case, bname + '_custom', 'custom')
 
 VerifyTests = type('VerifyTests', (test_util.TestBase,), attrs)

tests/test_util.py

           'non_ascii_path_2.svndump': '/b%C3%B8b',
           'subdir_is_file_prefix.svndump': '/flaf',
           }
+# map defining the layouts of the fixtures we can use with custom layout
+# these are really popular layouts, so I gave them names
+trunk_only = {
+    'default': 'trunk',
+    }
+trunk_dev_branch = {
+    'default': 'trunk',
+    'dev_branch': 'branches/dev_branch',
+    }
+custom = {
+    'addspecial.svndump': {
+        'default': 'trunk',
+        'foo': 'branches/foo',
+        },
+    'binaryfiles.svndump': trunk_only,
+    'branch_create_with_dir_delete.svndump': trunk_dev_branch,
+    'branch_delete_parent_dir.svndump': trunk_dev_branch,
+    'branchmap.svndump': {
+        'default': 'trunk',
+        'badname': 'branches/badname',
+        'feature': 'branches/feature',
+        },
+    'branch_prop_edit.svndump': trunk_dev_branch,
+    'branch_rename_to_trunk.svndump': {
+        'default': 'trunk',
+        'dev_branch': 'branches/dev_branch',
+        'old_trunk': 'branches/old_trunk',
+        },
+    'copies.svndump': trunk_only,
+    'copybeforeclose.svndump': {
+        'default': 'trunk',
+        'test': 'branches/test'
+        },
+    'delentries.svndump': trunk_only,
+    'delete_restore_trunk.svndump': trunk_only,
+    'empty_dir_in_trunk_not_repo_root.svndump': trunk_only,
+    'executebit.svndump': trunk_only,
+    'filecase.svndump': trunk_only,
+    'file_not_in_trunk_root.svndump': trunk_only,
+    'project_name_with_space.svndump': trunk_dev_branch,
+    'pushrenames.svndump': trunk_only,
+    'rename_branch_parent_dir.svndump': trunk_dev_branch,
+    'renamedproject.svndump': {
+        'default': 'trunk',
+        'branch': 'branches/branch',
+        },
+    'renames.svndump': {
+        'default': 'trunk',
+        'branch1': 'branches/branch1',
+        },
+    'replace_branch_with_branch.svndump': {
+        'default': 'trunk',
+        'branch1': 'branches/branch1',
+        'branch2': 'branches/branch2',
+        },
+    'replace_trunk_with_branch.svndump': {
+        'default': 'trunk',
+        'test': 'branches/test',
+        },
+    'revert.svndump': trunk_only,
+    'siblingbranchfix.svndump': {
+        'default': 'trunk',
+        'wrongbranch': 'branches/wrongbranch',
+        },
+    'simple_branch.svndump': {
+        'default': 'trunk',
+        'the_branch': 'branches/the_branch',
+        },
+    'spaces-in-path.svndump': trunk_dev_branch,
+    'symlinks.svndump': trunk_only,
+    'truncatedhistory.svndump': trunk_only,
+    'unorderedbranch.svndump': {
+        'default': 'trunk',
+        'branch': 'branches/branch',
+        },
+    'unrelatedbranch.svndump': {
+        'default': 'trunk',
+        'branch1': 'branches/branch1',
+        'branch2': 'branches/branch2',
+        },
+}
 
 FIXTURES = os.path.join(os.path.abspath(os.path.dirname(__file__)),
                         'fixtures')

tests/test_utility_commands.py

 class UtilityTests(test_util.TestBase):
     stupid_mode_tests = True
 
-    def test_info_output(self):
-        repo, repo_path = self.load_and_fetch('two_heads.svndump')
+    def test_info_output(self, custom=False):
+        if custom:
+            config = {
+                'hgsubversionbranch.default': 'trunk',
+                'hgsubversionbranch.the_branch': 'branches/the_branch',
+                }
+        else:
+            config = {}
+        repo, repo_path = self.load_and_fetch('two_heads.svndump', config=config)
         hg.update(self.repo, 'the_branch')
         u = self.ui()
         u.pushbuffer()
                      })
         self.assertMultiLineEqual(actual, expected)
 
-    def test_info_single(self):
-        repo, repo_path = self.load_and_fetch('two_heads.svndump', subdir='trunk')
+    def test_info_output_custom(self):
+        self.test_info_output(custom=True)
+
+    def test_info_single(self, custom=False):
+        if custom:
+            subdir=None
+            config = {
+                'hgsubversionbranch.default': 'trunk/'
+                }
+        else:
+            subdir='trunk'
+            config = {}
+        repo, repo_path = self.load_and_fetch('two_heads.svndump',
+                                              subdir=subdir,
+                                              config=config)
         hg.update(self.repo, 'tip')
         u = self.ui()
         u.pushbuffer()
                      })
         self.assertMultiLineEqual(expected, actual)
 
+    def test_info_custom_single(self):
+        self.test_info_single(custom=True)
+
     def test_missing_metadata(self):
         self._load_fixture_and_fetch('two_heads.svndump')
         os.remove(self.repo.join('svn/branch_info'))
         self.assertEqual(self.repo['tip'].parents()[0].parents()[0], self.repo[0])
         self.assertNotEqual(beforerebasehash, self.repo['tip'].node())
 
-    def test_genignore(self):
+    def test_genignore(self, layout='auto'):
         """ Test generation of .hgignore file. """
-        repo = self._load_fixture_and_fetch('ignores.svndump', noupdate=False)
+        if layout == 'custom':
+            config = {
+                'hgsubversionbranch.default': 'trunk',
+                }
+        else:
+            config = {}
+        repo = self._load_fixture_and_fetch('ignores.svndump',
+                                            layout=layout,
+                                            noupdate=False,
+                                            config=config)
         u = self.ui()
         u.pushbuffer()
         svncommands.genignore(u, repo, self.wc_path)
                          '.hgignore\nsyntax:glob\nblah\notherblah\nbaz/magic\n')
 
     def test_genignore_single(self):
-        self._load_fixture_and_fetch('ignores.svndump', subdir='trunk')
-        hg.update(self.repo, 'tip')
-        u = self.ui()
-        u.pushbuffer()
-        svncommands.genignore(u, self.repo, self.wc_path)
-        self.assertMultiLineEqual(open(os.path.join(self.wc_path, '.hgignore')).read(),
-                               '.hgignore\nsyntax:glob\nblah\notherblah\nbaz/magic\n')
+        self.test_genignore(layout='single')
+
+    def test_genignore_custom(self):
+        self.test_genignore(layout='custom')
 
     def test_list_authors(self):
         repo_path = self.load_svndump('replace_trunk_with_branch.svndump')