changeset 499:1fd3cfa47c5e

Support for single-directory clones.
author Augie Fackler <durin42@gmail.com>
date Fri, 16 Oct 2009 23:33:41 -0400
parents 990e07054f29
children 5ddc212dbc56
files hgsubversion/__init__.py hgsubversion/editor.py hgsubversion/replay.py hgsubversion/stupid.py hgsubversion/svncommands.py hgsubversion/svnmeta.py hgsubversion/svnwrap/svn_swig_wrapper.py hgsubversion/wrappers.py tests/comprehensive/test_stupid_pull.py tests/comprehensive/test_verify.py tests/test_rebuildmeta.py tests/test_single_dir_clone.py tests/test_util.py
diffstat 13 files changed, 293 insertions(+), 79 deletions(-) [+]
line wrap: on
line diff
--- a/hgsubversion/__init__.py
+++ b/hgsubversion/__init__.py
@@ -66,6 +66,8 @@ wrapcmds = { # cmd: generic, target, fix
          'file mapping Subversion usernames to Mercurial authors'),
         ('', 'filemap', '',
          'file containing rules for remapping Subversion repository paths'),
+        ('', 'layout', 'auto', ('import standard layout or single '
+                                'directory? Can be standard, single, or auto.')),
     ]),
 }
 
--- a/hgsubversion/editor.py
+++ b/hgsubversion/editor.py
@@ -94,7 +94,7 @@ class RevisionData(object):
             self.ui.flush()
             if p[-1] == '/':
                 dir = p[len(root):]
-                new = [dir + f for f, k in svn.list_files(dir, r) if k == 'f']
+                new = [p + f for f, k in svn.list_files(dir, r) if k == 'f']
                 files.update(new)
             else:
                 files.add(p[len(root):])
@@ -107,7 +107,7 @@ class RevisionData(object):
             if i % 50 == 0:
                 svn.init_ra_and_client()
             i += 1
-            data, mode = svn.get_file(p, r)
+            data, mode = svn.get_file(p[len(root):], r)
             self.set(p, data, 'x' in mode, 'l' in mode)
 
         self.missing = set()
@@ -304,7 +304,7 @@ class HgEditor(delta.Editor):
     def open_directory(self, path, parent_baton, base_revision, dir_pool=None):
         self.current.batons[path] = path
         p_, branch = self.meta.split_branch_path(path)[:2]
-        if p_ == '':
+        if p_ == '' or (self.meta.layout == 'single' and p_):
             self.current.emptybranches[branch] = False
         return path
 
--- a/hgsubversion/replay.py
+++ b/hgsubversion/replay.py
@@ -29,14 +29,13 @@ def convert_rev(ui, meta, svn, r, tbdelt
     current.findmissing(svn)
 
     # update externals
-
-    if current.externals:
+    # TODO fix and re-enable externals for single-directory clones
+    if current.externals and not meta.layout == 'single':
 
         # accumulate externals records for all branches
         revnum = current.rev.revnum
         branches = {}
         for path, entry in current.externals.iteritems():
-
             if not meta.is_path_valid(path):
                 ui.warn('WARNING: Invalid path %s in externals\n' % path)
                 continue
@@ -56,7 +55,9 @@ def convert_rev(ui, meta, svn, r, tbdelt
 
         # register externals file changes
         for bp, external in branches.iteritems():
-            path = bp + '/.hgsvnexternals'
+            if bp and bp[-1] != '/':
+                bp += '/'
+            path = (bp and bp + '.hgsvnexternals') or '.hgsvnexternals'
             if external:
                 current.set(path, external.write(), False, False)
             else:
--- a/hgsubversion/stupid.py
+++ b/hgsubversion/stupid.py
@@ -103,6 +103,8 @@ def diff_branchrev(ui, svn, meta, branch
     error.
     """
     def make_diff_path(branch):
+        if meta.layout == 'single':
+            return ''
         if branch == 'trunk' or branch is None:
             return 'trunk'
         elif branch.startswith('../'):
@@ -203,7 +205,10 @@ def diff_branchrev(ui, svn, meta, branch
 
     for p in r.paths:
         if p.startswith(diff_path) and r.paths[p].action == 'D':
-            p2 = p[len(diff_path)+1:].strip('/')
+            if diff_path:
+                p2 = p[len(diff_path)+1:].strip('/')
+            else:
+                p2 = p
             if p2 in parentctx:
                 files_data[p2] = None
                 continue
@@ -221,7 +226,10 @@ def diff_branchrev(ui, svn, meta, branch
             raise IOError()
 
         if path in binary_files:
-            data, mode = svn.get_file(diff_path + '/' + path, r.revnum)
+            pa = path
+            if diff_path:
+                pa = diff_path + '/' + path
+            data, mode = svn.get_file(pa, r.revnum)
             isexe = 'x' in mode
             islink = 'l' in mode
         else:
@@ -236,6 +244,10 @@ def diff_branchrev(ui, svn, meta, branch
                 data = parentctx[path].data()
 
         copied = copies.get(path)
+        # TODO this branch feels like it should not be required,
+        # and this may actually imply a bug in getcopies
+        if copied not in parentctx.manifest():
+            copied = None
         return context.memfilectx(path=path, data=data, islink=islink,
                                   isexec=isexe, copied=copied)
 
@@ -347,7 +359,7 @@ def fetch_externals(svn, branchpath, r, 
         dirs.update([p for p,k in svn.list_files(branchpath, r.revnum) if k == 'd'])
         dirs.add('')
     else:
-        branchprefix = branchpath + '/'
+        branchprefix = (branchpath and branchpath + '/') or branchpath
         for path, e in r.paths.iteritems():
             if e.action == 'D':
                 continue
@@ -369,7 +381,8 @@ def fetch_externals(svn, branchpath, r, 
     # Retrieve new or updated values
     for dir in dirs:
         try:
-            values = svn.list_props(branchpath + '/' + dir, r.revnum)
+            dpath = (branchpath and branchpath + '/' + dir) or dir
+            values = svn.list_props(dpath, r.revnum)
             externals[dir] = values.get('svn:externals', '')
         except IOError:
             externals[dir] = ''
@@ -394,7 +407,7 @@ def fetch_branchrev(svn, meta, branch, b
             if kind == 'f':
                 files.append(path)
     else:
-        branchprefix = branchpath + '/'
+        branchprefix = (branchpath and branchpath + '/') or ''
         for path, e in r.paths.iteritems():
             if not path.startswith(branchprefix):
                 continue
@@ -423,7 +436,10 @@ def fetch_branchrev(svn, meta, branch, b
     copies = getcopies(svn, meta, branch, branchpath, r, files, parentctx)
 
     def filectxfn(repo, memctx, path):
-        data, mode = svn.get_file(branchpath + '/' + path, r.revnum)
+        svnpath = path
+        if branchpath:
+            svnpath = branchpath + '/' + path
+        data, mode = svn.get_file(svnpath, r.revnum)
         isexec = 'x' in mode
         islink = 'l' in mode
         copied = copies.get(path)
@@ -509,7 +525,6 @@ def branches_in_paths(meta, tbdelta, pat
             if branchname and branchname.startswith('../'):
                 continue
             branches[branchname] = branchpath
-
     return branches
 
 def convert_rev(ui, meta, svn, r, tbdelta):
@@ -549,7 +564,6 @@ def convert_rev(ui, meta, svn, r, tbdelt
     date = meta.fixdate(r.date)
     check_deleted_branches = set(tbdelta['branches'][1])
     for b in branches:
-
         parentctx = meta.repo[meta.get_parent_revision(r.revnum, b)]
         if parentctx.branch() != (b or 'default'):
             check_deleted_branches.add(b)
@@ -570,9 +584,10 @@ def convert_rev(ui, meta, svn, r, tbdelt
             files_touched, filectxfn2 = fetch_branchrev(
                 svn, meta, b, branches[b], r, parentctx)
 
-        externals = fetch_externals(svn, branches[b], r, parentctx)
-        if externals is not None:
-            files_touched.append('.hgsvnexternals')
+        if meta.layout != 'single':
+            externals = fetch_externals(svn, branches[b], r, parentctx)
+            if externals is not None:
+                files_touched.append('.hgsvnexternals')
 
         def filectxfn(repo, memctx, path):
             if path == '.hgsvnexternals':
--- a/hgsubversion/svncommands.py
+++ b/hgsubversion/svncommands.py
@@ -32,17 +32,23 @@ def verify(ui, repo, *args, **opts):
     if args:
         url = args[0]
     svn = svnrepo.svnremoterepo(ui, url).svn
+    meta = repo.svnmeta(svn.uuid, svn.subdir)
 
     btypes = {'default': 'trunk'}
-    branchpath = btypes.get(ctx.branch(), 'branches/%s' % ctx.branch())
-    branchpath = posixpath.normpath(branchpath)
+    if meta.layout == 'standard':
+        branchpath = btypes.get(ctx.branch(), 'branches/%s' % ctx.branch())
+    else:
+        branchpath = ''
     svnfiles = set()
     result = 0
-    for fn, type in svn.list_files(branchpath, srev):
+    for fn, type in svn.list_files(posixpath.normpath(branchpath), srev):
         if type != 'f':
             continue
         svnfiles.add(fn)
-        data, mode = svn.get_file(branchpath + '/'  + fn, srev)
+        fp = fn
+        if branchpath:
+            fp = branchpath + '/'  + fn
+        data, mode = svn.get_file(posixpath.normpath(fp), srev)
         fctx = ctx[fn]
         dmatch = fctx.data() == data
         mmatch = fctx.flags() == mode
@@ -87,6 +93,8 @@ def rebuildmeta(ui, repo, hg_repo_path, 
         os.unlink(maps.TagMap.filepath(repo))
     tags = maps.TagMap(repo)
 
+    layout = None
+
     skipped = set()
 
     for rev in repo:
@@ -126,6 +134,18 @@ def rebuildmeta(ui, repo, hg_repo_path, 
         assert revpath.startswith(subdir), ('That does not look like the '
                                             'right location in the repo.')
 
+        if layout is None:
+            if (subdir or '/') == revpath:
+                layout = 'single'
+            else:
+                layout = 'standard'
+            f = open(os.path.join(svnmetadir, 'layout'), 'w')
+            f.write(layout)
+            f.close()
+        elif layout == 'single':
+            assert (subdir or '/') == revpath, ('Possible layout detection'
+                                                ' defect in replay')
+
         # write repository uuid if required
         if uuid is None:
             uuid = convinfo[4:40]
@@ -142,17 +162,17 @@ def rebuildmeta(ui, repo, hg_repo_path, 
 
         # find commitpath, write to revmap
         commitpath = revpath[len(subdir)+1:]
-        bp = posixpath.normpath('/'.join([subdir, 'branches', ctx.branch()]))
-        if revpath == bp:
-            commitpath = ctx.branch()
-        elif commitpath == 'trunk':
-            commitpath = ''
-        elif commitpath.startswith('tags'):
-            if ctx.extra().get('close'):
-                continue
-            commitpath = '../' + commitpath
+        if layout == 'standard':
+            if commitpath.startswith('branches/'):
+                commitpath = commitpath[len('branches/'):]
+            elif commitpath == 'trunk':
+                commitpath = ''
+            else:
+                if commitpath.startswith('tags/') and ctx.extra().get('close'):
+                    continue
+                commitpath = '../' + commitpath
         else:
-            assert False, 'unhandled rev %s: %s' % (rev, convinfo)
+            commitpath = ''
         revmap.write('%s %s %s\n' % (revision, ctx.hex(), commitpath))
 
         revision = int(revision)
--- a/hgsubversion/svnmeta.py
+++ b/hgsubversion/svnmeta.py
@@ -69,6 +69,13 @@ class SVNMeta(object):
             f.close()
         else:
             self.tag_locations = tag_locations
+        if os.path.exists(self.layoutfile):
+            f = open(self.layoutfile)
+            self._layout = f.read().strip()
+            f.close()
+            self.repo.ui.setconfig('hgsubversion', 'layout', self._layout)
+        else:
+            self._layout = None
         pickle_atomic(self.tag_locations, self.tag_locations_file,
                       self.meta_data_dir)
         # ensure nested paths are handled properly
@@ -82,6 +89,21 @@ class SVNMeta(object):
         self.lastdate = '1970-01-01 00:00:00 -0000'
         self.filemap = maps.FileMap(repo)
 
+    @property
+    def layout(self):
+        # this method can't determine the layout, but it needs to be
+        # resolved into something other than auto before this ever
+        # gets called
+        if not self._layout or self._layout == 'auto':
+            lo = self.repo.ui.config('hgsubversion', 'layout', default='auto')
+            if lo == 'auto':
+                raise hgutil.Abort('layout not yet determined')
+            self._layout = lo
+            f = open(self.layoutfile, 'w')
+            f.write(self._layout)
+            f.close()
+        return self._layout
+
     @property
     def editor(self):
         if not hasattr(self, '_editor'):
@@ -127,6 +149,10 @@ class SVNMeta(object):
     def authors_file(self):
         return os.path.join(self.meta_data_dir, 'authors')
 
+    @property
+    def layoutfile(self):
+        return os.path.join(self.meta_data_dir, 'layout')
+
     def fixdate(self, date):
         if date is not None:
             date = date.replace('T', ' ').replace('Z', '').split('.')[0]
@@ -145,6 +171,8 @@ class SVNMeta(object):
     def localname(self, path):
         """Compute the local name for a branch located at path.
         """
+        if self.layout == 'single':
+            return 'default'
         if path == 'trunk':
             return None
         elif path.startswith('branches/'):
@@ -152,6 +180,8 @@ class SVNMeta(object):
         return  '../%s' % path
 
     def remotename(self, branch):
+        if self.layout == 'single':
+            return ''
         if branch == 'default' or branch is None:
             return 'trunk'
         elif branch.startswith('../'):
@@ -160,23 +190,27 @@ class SVNMeta(object):
 
     def genextra(self, revnum, branch):
         extra = {}
-        branchpath = 'trunk'
-        if branch:
-            extra['branch'] = branch
-            if branch.startswith('../'):
-                branchpath = branch[3:]
-            else:
-                branchpath = 'branches/%s' % branch
-
         subdir = self.subdir
         if subdir and subdir[-1] == '/':
             subdir = subdir[:-1]
         if subdir and subdir[0] != '/':
             subdir = '/' + subdir
 
+        if self.layout == 'single':
+            path = subdir or '/'
+        else:
+            branchpath = 'trunk'
+            if branch:
+                extra['branch'] = branch
+                if branch.startswith('../'):
+                    branchpath = branch[3:]
+                else:
+                    branchpath = 'branches/%s' % branch
+            path = '%s/%s' % (subdir , branchpath)
+
         extra['convert_revision'] = 'svn:%(uuid)s%(path)s@%(rev)s' % {
             'uuid': self.uuid,
-            'path': '%s/%s' % (subdir , branchpath),
+            'path': path,
             'rev': revnum,
         }
         return extra
@@ -185,6 +219,8 @@ class SVNMeta(object):
         '''Normalize a path to strip of leading slashes and our subdir if we
         have one.
         '''
+        if self.subdir and path == self.subdir[:-1]:
+            return ''
         if path and path[0] == '/':
             path = path[1:]
         if path and path.startswith(self.subdir):
@@ -200,6 +236,8 @@ class SVNMeta(object):
         Note that it's only a tag if it was copied from the path '' in a branch
         (or tag) we have, for our purposes.
         """
+        if self.layout == 'single':
+            return False
         path = self.normalize(path)
         for tagspath in self.tag_locations:
             onpath = path.startswith(tagspath)
@@ -217,9 +255,11 @@ class SVNMeta(object):
 
         If existing=True, will return None, None, None if the file isn't on some known
         branch. If existing=False, then it will guess what the branch would be if it were
-        known.
+        known. Server-side branch path should be relative to our subdirectory.
         """
         path = self.normalize(path)
+        if self.layout == 'single':
+            return (path, None, '')
         if self.is_path_tag(path):
             tag = self.is_path_tag(path)
             matched = [t for t in self.tags.iterkeys() if tag.startswith(t+'/')]
@@ -325,6 +365,19 @@ class SVNMeta(object):
         return int(revnum), self.localname(self.normalize(branch))
 
     def update_branch_tag_map_for_rev(self, revision):
+        """Given a revision object, determine changes to branches and tags.
+
+        Returns: a dict of {
+            'tags': (added_tags, rmtags),
+            'branches': (added_branches, self.closebranches),
+        } where adds are dicts where the keys are branch/tag names and
+        values are the place the branch/tag came from. The deletions are
+        sets of the deleted branches.
+        """
+        if self.layout == 'single':
+            return {'tags': ({}, set()),
+                    'branches': ({None: (None, 0, -1), }, set()),
+                    }
         paths = revision.paths
         added_branches = {}
         added_tags = {}
--- a/hgsubversion/svnwrap/svn_swig_wrapper.py
+++ b/hgsubversion/svnwrap/svn_swig_wrapper.py
@@ -345,7 +345,8 @@ class SubversionRepo(object):
           dir: the directory to list, no leading slash
           rev: the revision at which to list the directory, defaults to HEAD
         """
-        if dir[-1] == '/':
+        # TODO this should just not accept leading slashes like the docstring says
+        if dir and dir[-1] == '/':
             dir = dir[:-1]
         if revision is None:
             revision = self.HEAD
@@ -555,6 +556,7 @@ class SubversionRepo(object):
         otherwise. If the file does not exist at this revision, raise
         IOError.
         """
+        assert not path.startswith('/')
         mode = ''
         try:
             out = cStringIO.StringIO()
@@ -633,7 +635,9 @@ class SubversionRepo(object):
     def path2url(self, path):
         """Build svn URL for path, URL-escaping path.
         """
-        assert path[0] != '/'
+        if not path or path == '.':
+            return self.svn_url
+        assert path[0] != '/', path
         return '/'.join((self.svn_url,
                          urllib.quote(path).rstrip('/'),
                          ))
--- a/hgsubversion/wrappers.py
+++ b/hgsubversion/wrappers.py
@@ -228,6 +228,16 @@ def pull(repo, source, heads=[], force=F
     svn = svnrepo.svnremoterepo(repo.ui, svn_url).svn
     meta = repo.svnmeta(svn.uuid, svn.subdir)
 
+    layout = repo.ui.config('hgsubversion', 'layout', 'auto')
+    if layout == 'auto':
+        rootlist = svn.list_dir('', revision=(stopat_rev or None))
+        if sum(map(lambda x: x in rootlist, ('branches', 'tags', 'trunk'))):
+            layout = 'standard'
+        else:
+            layout = 'single'
+        repo.ui.setconfig('hgsubversion', 'layout', layout)
+        repo.ui.note('using %s layout\n' % layout)
+
     start = max(meta.revmap.seen, skipto_rev)
     initializing_repo = meta.revmap.seen <= 0
     ui = repo.ui
@@ -351,9 +361,10 @@ optionmap = {
     'defaulthost': ('hgsubversion', 'defaulthost'),
     'defaultauthors': ('hgsubversion', 'defaultauthors'),
     'usebranchnames': ('hgsubversion', 'usebranchnames'),
+    'layout': ('hgsubversion', 'layout'),
 }
 
-dontretain = { 'hgsubversion': set(['authormap', 'filemap']) }
+dontretain = { 'hgsubversion': set(['authormap', 'filemap', 'layout', ]) }
 
 def clone(orig, ui, source, dest=None, **opts):
     """
--- a/tests/comprehensive/test_stupid_pull.py
+++ b/tests/comprehensive/test_stupid_pull.py
@@ -9,9 +9,9 @@ from tests import test_util
 from hgsubversion import wrappers
 
 
-def _do_case(self, name):
+def _do_case(self, name, layout):
     subdir = test_util.subdir.get(name, '')
-    self._load_fixture_and_fetch(name, subdir=subdir, stupid=False)
+    self._load_fixture_and_fetch(name, subdir=subdir, stupid=False, layout=layout)
     assert len(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()
@@ -19,22 +19,28 @@ def _do_case(self, name):
     if subdir:
         checkout_path += '/' + subdir
     u.setconfig('hgsubversion', 'stupid', '1')
+    u.setconfig('hgsubversion', 'layout', layout)
     hg.clone(u, test_util.fileurl(checkout_path), wc2_path, update=False)
+    if layout == 'single':
+        self.assertEqual(len(self.repo.heads()), 1)
     self.repo2 = hg.repository(ui.ui(), wc2_path)
     self.assertEqual(self.repo.heads(), self.repo2.heads())
 
 
-def buildmethod(case, name):
-    m = lambda self: self._do_case(case)
+def buildmethod(case, name, layout):
+    m = lambda self: self._do_case(case, layout)
     m.__name__ = name
-    m.__doc__ = 'Test stupid produces same as real on %s.' % case
+    m.__doc__ = 'Test stupid produces same as real on %s. (%s)' % (case, layout)
     return m
 
 attrs = {'_do_case': _do_case,
          }
 for case in (f for f in os.listdir(test_util.FIXTURES) if f.endswith('.svndump')):
     name = 'test_' + case[:-len('.svndump')]
-    attrs[name] = buildmethod(case, name)
+    attrs[name] = buildmethod(case, name, 'auto')
+    name += '_single'
+    attrs[name] = buildmethod(case, name, 'single')
+
 StupidPullTests = type('StupidPullTests', (test_util.TestBase, ), attrs)
 
 
--- a/tests/comprehensive/test_verify.py
+++ b/tests/comprehensive/test_verify.py
@@ -14,19 +14,19 @@ from mercurial import ui
 
 from hgsubversion import svncommands
 
-def _do_case(self, name, stupid):
+def _do_case(self, name, stupid, layout):
     subdir = test_util.subdir.get(name, '')
-    repo = self._load_fixture_and_fetch(name, subdir=subdir, stupid=stupid)
+    repo = self._load_fixture_and_fetch(name, subdir=subdir, stupid=stupid, layout=layout)
     assert len(self.repo) > 0
     for i in repo:
         ctx = repo[i]
         self.assertEqual(svncommands.verify(repo.ui, repo, rev=ctx.node()), 0)
 
-def buildmethod(case, name, stupid):
-    m = lambda self: self._do_case(case, stupid)
+def buildmethod(case, name, stupid, layout):
+    m = lambda self: self._do_case(case, stupid, layout)
     m.__name__ = name
-    bits = case, stupid and 'stupid' or 'real'
-    m.__doc__ = 'Test verify on %s with %s replay.' % bits
+    bits = case, stupid and 'stupid' or 'real', layout
+    m.__doc__ = 'Test verify on %s with %s replay. (%s)' % bits
     return m
 
 attrs = {'_do_case': _do_case}
@@ -35,10 +35,16 @@ for case in fixtures:
     # this fixture results in an empty repository, don't use it
     if case == 'project_root_not_repo_root.svndump':
         continue
-    name = 'test_' + case[:-len('.svndump')]
-    attrs[name] = buildmethod(case, name, False)
-    name += '_stupid'
-    attrs[name] = buildmethod(case, name, True)
+    bname = 'test_' + case[:-len('.svndump')]
+    attrs[bname] = buildmethod(case, bname, False, 'standard')
+    name = bname + '_stupid'
+    attrs[name] = buildmethod(case, name, True, 'standard')
+    name = bname + '_single'
+    attrs[name] = buildmethod(case, name, False, 'single')
+    # Disabled because the "stupid and real are the same" tests
+    # verify this plus even more.
+    # name = bname + '_single_stupid'
+    # attrs[name] = buildmethod(case, name, True, 'single')
 
 VerifyTests = type('VerifyTests', (test_util.TestBase,), attrs)
 
--- a/tests/test_rebuildmeta.py
+++ b/tests/test_rebuildmeta.py
@@ -10,9 +10,12 @@ from mercurial import ui
 from hgsubversion import svncommands
 from hgsubversion import svnmeta
 
-def _do_case(self, name, stupid):
+def _do_case(self, name, stupid, single):
     subdir = test_util.subdir.get(name, '')
-    self._load_fixture_and_fetch(name, subdir=subdir, stupid=stupid)
+    layout = 'auto'
+    if single:
+        layout = 'single'
+    self._load_fixture_and_fetch(name, subdir=subdir, stupid=stupid, layout=layout)
     assert len(self.repo) > 0
     wc2_path = self.wc_path + '_clone'
     u = ui.ui()
@@ -27,7 +30,7 @@ def _do_case(self, name, stupid):
     self.assertTrue(os.path.isdir(os.path.join(src.path, 'svn')),
                     'no .hg/svn directory in the destination!')
     dest = hg.repository(u, os.path.dirname(dest.path))
-    for tf in ('rev_map', 'uuid', 'tagmap', ):
+    for tf in ('rev_map', 'uuid', 'tagmap', 'layout', ):
         stf = os.path.join(src.path, 'svn', tf)
         self.assertTrue(os.path.isfile(stf), '%r is missing!' % stf)
         dtf = os.path.join(dest.path, 'svn', tf)
@@ -54,11 +57,15 @@ def _do_case(self, name, stupid):
             self.assertEqual(srcinfo[2], destinfo[2])
 
 
-def buildmethod(case, name, stupid):
-    m = lambda self: self._do_case(case, stupid)
+def buildmethod(case, name, stupid, single):
+    m = lambda self: self._do_case(case, stupid, single)
     m.__name__ = name
-    m.__doc__ = ('Test rebuildmeta on %s with %s replay.' %
-                 (case, (stupid and 'stupid') or 'real'))
+    m.__doc__ = ('Test rebuildmeta on %s with %s replay. (%s)' %
+                 (case,
+                  (stupid and 'stupid') or 'real',
+                  (single and 'single') or 'standard',
+                  )
+                 )
     return m
 
 
@@ -68,10 +75,13 @@ for case in [f for f in os.listdir(test_
     # this fixture results in an empty repository, don't use it
     if case == 'project_root_not_repo_root.svndump':
         continue
-    name = 'test_' + case[:-len('.svndump')]
-    attrs[name] = buildmethod(case, name, False)
-    name += '_stupid'
-    attrs[name] = buildmethod(case, name, True)
+    bname = 'test_' + case[:-len('.svndump')]
+    attrs[bname] = buildmethod(case, bname, False, False)
+    name = bname + '_stupid'
+    attrs[name] = buildmethod(case, name, True, False)
+    name = bname + '_single'
+    attrs[name] = buildmethod(case, name, False, True)
+
 RebuildMetaTests = type('RebuildMetaTests', (test_util.TestBase, ), attrs)
 
 
new file mode 100644
--- /dev/null
+++ b/tests/test_single_dir_clone.py
@@ -0,0 +1,72 @@
+import shutil
+
+import test_util
+
+
+class TestSingleDir(test_util.TestBase):
+    def test_clone_single_dir_simple(self):
+        repo = self._load_fixture_and_fetch('branch_from_tag.svndump',
+                                            stupid=False,
+                                            layout='single',
+                                            subdir='')
+        self.assertEqual(repo.branchtags().keys(), ['default'])
+        self.assertEqual(repo['tip'].manifest().keys(),
+                         ['trunk/beta',
+                          'tags/copied_tag/alpha',
+                          'trunk/alpha',
+                          'tags/copied_tag/beta',
+                          'branches/branch_from_tag/alpha',
+                          'tags/tag_r3/alpha',
+                          'tags/tag_r3/beta',
+                          'branches/branch_from_tag/beta'])
+
+    def test_auto_detect_single(self):
+        repo = self._load_fixture_and_fetch('branch_from_tag.svndump',
+                                            stupid=False,
+                                            layout='auto')
+        self.assertEqual(repo.branchtags().keys(), ['default',
+                                                    'branch_from_tag'])
+        oldmanifest = test_util.filtermanifest(repo['default'].manifest().keys())
+        # remove standard layout
+        shutil.rmtree(self.wc_path)
+        # try again with subdir to get single dir clone
+        repo = self._load_fixture_and_fetch('branch_from_tag.svndump',
+                                            stupid=False,
+                                            layout='auto',
+                                            subdir='trunk')
+        self.assertEqual(repo.branchtags().keys(), ['default', ])
+        self.assertEqual(repo['default'].manifest().keys(), oldmanifest)
+
+    def test_externals_single(self):
+        repo = self._load_fixture_and_fetch('externals.svndump',
+                                            stupid=False,
+                                            layout='single')
+        for rev in repo:
+            assert '.hgsvnexternals' not in repo[rev].manifest()
+        return # TODO enable test when externals in single are fixed
+        expect = """[.]
+ -r2 ^/externals/project2@2 deps/project2
+[subdir]
+ ^/externals/project1 deps/project1
+[subdir2]
+ ^/externals/project1 deps/project1
+"""
+        test = 2
+        self.assertEqual(self.repo[test]['.hgsvnexternals'].data(), expect)
+
+    def test_externals_single_whole_repo(self):
+        # This is the test which demonstrates the brokenness of externals
+        return # TODO enable test when externals in single are fixed
+        repo = self._load_fixture_and_fetch('externals.svndump',
+                                            stupid=False,
+                                            layout='single',
+                                            subdir='')
+        for rev in repo:
+            rc = repo[rev]
+            if '.hgsvnexternals' in rc:
+                extdata = rc['.hgsvnexternals'].data()
+                assert '[.]' not in extdata
+                print extdata
+        expect = '' # Not honestly sure what this should be...
+        test = 4
+        self.assertEqual(self.repo[test]['.hgsvnexternals'].data(), expect)
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -82,6 +82,10 @@ subdir = {'truncatedhistory.svndump': '/
 FIXTURES = os.path.join(os.path.abspath(os.path.dirname(__file__)),
                         'fixtures')
 
+def filtermanifest(manifest):
+    return filter(lambda x: x not in ('.hgtags', '.hgsvnexternals', ),
+                  manifest)
+
 def fileurl(path):
     path = os.path.abspath(path)
     drive, path = os.path.splitdrive(path)
@@ -103,16 +107,21 @@ def load_svndump_fixture(path, fixture_n
                             stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
     proc.communicate()
 
-def load_fixture_and_fetch(fixture_name, repo_path, wc_path, stupid=False, subdir='', noupdate=True):
+def load_fixture_and_fetch(fixture_name, repo_path, wc_path, stupid=False, subdir='',
+                           noupdate=True, layout='auto'):
     load_svndump_fixture(repo_path, fixture_name)
     if subdir:
         repo_path += '/' + subdir
 
-    _ui = ui.ui()
-    _ui.setconfig('hgsubversion', 'stupid', str(stupid))
+    confvars = locals()
+    def conf():
+        for var in ('stupid', 'layout'):
+            _ui = ui.ui()
+            _ui.setconfig('hgsubversion', var, confvars[var])
+        return _ui
+    _ui = conf()
     commands.clone(_ui, fileurl(repo_path), wc_path, noupdate=noupdate)
-    _ui = ui.ui()
-    _ui.setconfig('hgsubversion', 'stupid', str(stupid))
+    _ui = conf()
     return hg.repository(_ui, wc_path)
 
 def rmtree(path):
@@ -170,10 +179,15 @@ class TestBase(unittest.TestCase):
         os.chdir(self.oldwd)
         setattr(ui.ui, self.patch[0].func_name, self.patch[0])
 
-    def _load_fixture_and_fetch(self, fixture_name, subdir='', stupid=False):
+    def _load_fixture_and_fetch(self, fixture_name, subdir=None, stupid=False, layout='auto'):
+        if layout == 'single':
+            if subdir is None:
+                subdir = 'trunk'
+        elif subdir is None:
+            subdir = ''
         return load_fixture_and_fetch(fixture_name, self.repo_path,
                                       self.wc_path, subdir=subdir,
-                                      stupid=stupid)
+                                      stupid=stupid, layout=layout)
 
     # define this as a property so that it reloads anytime we need it
     @property