changeset 1410:85981b27e740

Merge with stable.
author Augie Fackler <raf@durin42.com>
date Wed, 11 May 2016 10:11:59 -0400
parents c79fdd5f615d (diff) fc80c25bc94b (current diff)
children 025e849d22f0
files hgsubversion/svnexternals.py
diffstat 13 files changed, 744 insertions(+), 171 deletions(-) [+]
line wrap: on
line diff
--- a/hgsubversion/editor.py
+++ b/hgsubversion/editor.py
@@ -578,6 +578,12 @@ class HgEditor(svnwrap.Editor):
             try:
                 if not self.meta.is_path_valid(path):
                     return
+
+                # are we skipping this branch entirely?
+                br_path, branch = self.meta.split_branch_path(path)[:2]
+                if self.meta.skipbranch(branch):
+                    return
+
                 try:
                     handler(window)
                 except AssertionError, e: # pragma: no cover
--- a/hgsubversion/layouts/custom.py
+++ b/hgsubversion/layouts/custom.py
@@ -18,7 +18,9 @@ class CustomLayout(base.BaseLayout):
         self.svn_to_hg = {}
         self.hg_to_svn = {}
 
-        for hg_branch, svn_path in meta.ui.configitems('hgsubversionbranch'):
+        meta._gen_cachedconfig('custombranches', {}, configname='hgsubversionbranch')
+
+        for hg_branch, svn_path in meta.custombranches.iteritems():
 
             hg_branch = hg_branch.strip()
             if hg_branch == 'default' or not hg_branch:
--- a/hgsubversion/maps.py
+++ b/hgsubversion/maps.py
@@ -2,91 +2,222 @@
 
 import errno
 import os
+import re
 from mercurial import util as hgutil
 from mercurial.node import bin, hex, nullid
 
+import subprocess
 import svncommands
 import util
 
-class AuthorMap(dict):
-    '''A mapping from Subversion-style authors to Mercurial-style
-    authors, and back. The data is stored persistently on disk.
+class BaseMap(dict):
+    '''A base class for the different type of mappings: author, branch, and
+    tags.'''
+    def __init__(self, meta):
+        self.meta = meta
+        super(BaseMap, self).__init__()
 
-    If the 'hgsubversion.defaultauthors' configuration option is set to false,
-    attempting to obtain an unknown author will fail with an Abort.
+        self._commentre = re.compile(r'((^|[^\\])(\\\\)*)#.*')
+        self.syntaxes = ('re', 'glob')
 
-    If the 'hgsubversion.caseignoreauthors' configuration option is set to true,
-    the userid from Subversion is always compared lowercase.
-    '''
+        # trickery: all subclasses have the same name as their file and config
+        # names, e.g. AuthorMap is meta.authormap_file for the filename and
+        # 'authormap' for the config option
+        self.mapname = self.__class__.__name__.lower()
+        self.mapfilename = self.mapname + '_file'
+        self.load(self.meta.__getattribute__(self.mapfilename))
 
-    def __init__(self, meta):
-        '''Initialise a new AuthorMap.
-
-        The ui argument is used to print diagnostic messages.
+        # append mappings specified from the commandline
+        clmap = util.configpath(self.meta.ui, self.mapname)
+        if clmap:
+            self.load(clmap)
 
-        The path argument is the location of the backing store,
-        typically .hg/svn/authors.
+    def _findkey(self, key):
+        '''Takes a string and finds the first corresponding key that matches
+        via regex'''
+        if not key:
+            return None
+
+        # compile a new regex key if we're given a string; can't use
+        # hgutil.compilere since we need regex.sub
+        k = key
+        if isinstance(key, str):
+            k = re.compile(re.escape(key))
+
+        # preference goes to matching the exact pattern, i.e. 'foo' should
+        # first match 'foo' before trying regexes
+        for regex in self:
+            if regex.pattern == k.pattern:
+                return regex
+
+        # if key isn't a string, then we are done; nothing matches
+        if not isinstance(key, str):
+            return None
+
+        # now we test the regex; the above loop will be faster and is
+        # equivalent to not having regexes (i.e. just doing string compares)
+        for regex in self:
+            if regex.search(key):
+                return regex
+        return None
+
+    def get(self, key, default=None):
+        '''Similar to dict.get, except we use our own matcher, _findkey.'''
+        if self._findkey(key):
+            return self[key]
+        return default
+
+    def __getitem__(self, key):
+        '''Similar to dict.get, except we use our own matcher, _findkey. If the key is
+        a string, then we can use our regex matching to map its value.
         '''
-        self.meta = meta
-        self.defaulthost = ''
-        if meta.defaulthost:
-            self.defaulthost = '@%s' % meta.defaulthost.lstrip('@')
+        k = self._findkey(key)
+        val = super(BaseMap, self).__getitem__(k)
 
-        self.super = super(AuthorMap, self)
-        self.super.__init__()
-        self.load(self.meta.authors_file)
+        # if key is a string then we can transform it using our regex, else we
+        # don't have enough information, so we just return the val
+        if isinstance(key, str):
+            val = k.sub(val, key)
 
-        # append authors specified from the commandline
-        clmap = util.configpath(self.meta.ui, 'authormap')
-        if clmap:
-            self.load(clmap)
+        return val
 
-    def load(self, path):
-        ''' Load mappings from a file at the specified path. '''
+    def __setitem__(self, key, value):
+        '''Similar to dict.__setitem__, except we compile the string into a regex, if
+        need be.
+        '''
+        # try to find the regex already in the map
+        k = self._findkey(key)
+        # if we found one, then use it
+        if k:
+            key = k
+        # else make a new regex
+        if isinstance(key, str):
+            key = re.compile(re.escape(key))
+        super(BaseMap, self).__setitem__(key, value)
+
+    def __contains__(self, key):
+        '''Similar to dict.get, except we use our own matcher, _findkey.'''
+        return self._findkey(key) is not None
 
+    def load(self, path):
+        '''Load mappings from a file at the specified path.'''
         path = os.path.expandvars(path)
         if not os.path.exists(path):
             return
 
         writing = False
-        if path != self.meta.authors_file:
-            writing = open(self.meta.authors_file, 'a')
+        mapfile = self.meta.__getattribute__(self.mapfilename)
+        if path != mapfile:
+            writing = open(mapfile, 'a')
 
-        self.meta.ui.debug('reading authormap from %s\n' % path)
+        self.meta.ui.debug('reading %s from %s\n' % (self.mapname , path))
         f = open(path, 'r')
-        for number, line_org in enumerate(f):
+        syntax = ''
+        for number, line in enumerate(f):
+
+            if writing:
+                writing.write(line)
 
-            line = line_org.split('#')[0]
-            if not line.strip():
+            # strip out comments
+            if "#" in line:
+                # remove comments prefixed by an even number of escapes
+                line = self._commentre.sub(r'\1', line)
+                # fixup properly escaped comments that survived the above
+                line = line.replace("\\#", "#")
+            line = line.rstrip()
+            if not line:
                 continue
 
+            if line.startswith('syntax:'):
+                s = line[7:].strip()
+                syntax = ''
+                if s in self.syntaxes:
+                    syntax = s
+                continue
+            pat = syntax
+            for s in self.syntaxes:
+                if line.startswith(s + ':'):
+                    pat = s
+                    line = line[len(s) + 1:]
+                    break
+
+            # split on the first '='
             try:
                 src, dst = line.split('=', 1)
             except (IndexError, ValueError):
-                msg = 'ignoring line %i in author map %s: %s\n'
-                self.meta.ui.status(msg % (number, path, line.rstrip()))
+                msg = 'ignoring line %i in %s %s: %s\n'
+                self.meta.ui.status(msg % (number, self.mapname, path,
+                                           line.rstrip()))
                 continue
 
             src = src.strip()
             dst = dst.strip()
 
-            if self.meta.caseignoreauthors:
-                src = src.lower()
-
-            if writing:
-                if not src in self:
-                    self.meta.ui.debug('adding author %s to author map\n' % src)
-                elif dst != self[src]:
-                    msg = 'overriding author: "%s" to "%s" (%s)\n'
-                    self.meta.ui.status(msg % (self[src], dst, src))
-                writing.write(line_org)
-
+            if pat != 're':
+                src = re.escape(src)
+            if pat == 'glob':
+                src = src.replace('\\*', '.*')
+            src = re.compile(src)
+
+            if src not in self:
+                self.meta.ui.debug('adding %s to %s\n' % (src, self.mapname))
+            elif dst != self[src]:
+                msg = 'overriding %s: "%s" to "%s" (%s)\n'
+                self.meta.ui.status(msg % (self.mapname, self[src], dst, src))
             self[src] = dst
 
         f.close()
         if writing:
             writing.close()
 
+class AuthorMap(BaseMap):
+    '''A mapping from Subversion-style authors to Mercurial-style
+    authors, and back. The data is stored persistently on disk.
+
+    If the 'hgsubversion.defaultauthors' configuration option is set to false,
+    attempting to obtain an unknown author will fail with an Abort.
+
+    If the 'hgsubversion.caseignoreauthors' configuration option is set to true,
+    the userid from Subversion is always compared lowercase.
+    '''
+
+    def __init__(self, meta):
+        '''Initialise a new AuthorMap.
+
+        The ui argument is used to print diagnostic messages.
+
+        The path argument is the location of the backing store,
+        typically .hg/svn/authors.
+        '''
+        self.defaulthost = ''
+        if meta.defaulthost:
+            self.defaulthost = '@%s' % meta.defaulthost.lstrip('@')
+
+        super(AuthorMap, self).__init__(meta)
+
+    def _lowercase(self, key):
+        '''Determine whether or not to lowercase a str or regex using the
+        meta.caseignoreauthors.'''
+        k = key
+        if self.meta.caseignoreauthors:
+            if isinstance(key, str):
+                k = key.lower()
+            else:
+                k = re.compile(key.pattern.lower())
+        return k
+
+    def __setitem__(self, key, value):
+        '''Similar to dict.__setitem__, except we check caseignoreauthors to
+        use lowercase string or not
+        '''
+        super(AuthorMap, self).__setitem__(self._lowercase(key), value)
+
+    def __contains__(self, key):
+        '''Similar to dict.__contains__, except we check caseignoreauthors to
+        use lowercase string or not
+        '''
+        return super(AuthorMap, self).__contains__(self._lowercase(key))
+
     def __getitem__(self, author):
         ''' Similar to dict.__getitem__, except in case of an unknown author.
         In such cases, a new value is generated and added to the dictionary
@@ -94,19 +225,33 @@ class AuthorMap(dict):
         if author is None:
             author = '(no author)'
 
+        if not isinstance(author, str):
+            return super(AuthorMap, self).__getitem__(author)
+
         search_author = author
         if self.meta.caseignoreauthors:
             search_author = author.lower()
 
+        result = None
         if search_author in self:
-            result = self.super.__getitem__(search_author)
-        elif self.meta.defaultauthors:
-            self[author] = result = '%s%s' % (author, self.defaulthost)
-            msg = 'substituting author "%s" for default "%s"\n'
-            self.meta.ui.debug(msg % (author, result))
-        else:
-            msg = 'author %s has no entry in the author map!'
-            raise hgutil.Abort(msg % author)
+            result = super(AuthorMap, self).__getitem__(search_author)
+        elif self.meta.mapauthorscmd:
+            cmd = self.meta.mapauthorscmd % author
+            process = subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE)
+            output, err = process.communicate()
+            retcode = process.poll()
+            if retcode:
+                msg = 'map author command "%s" exited with error'
+                raise hgutil.Abort(msg % cmd)
+            self[author] = result = output.strip()
+        if not result:
+            if self.meta.defaultauthors:
+                self[author] = result = '%s%s' % (author, self.defaulthost)
+                msg = 'substituting author "%s" for default "%s"\n'
+                self.meta.ui.debug(msg % (author, result))
+            else:
+                msg = 'author %s has no entry in the author map!'
+                raise hgutil.Abort(msg % author)
         self.meta.ui.debug('mapping author "%s" to "%s"\n' % (author, result))
         return result
 
@@ -373,7 +518,7 @@ class FileMap(object):
         f.write('%s\n' % self.VERSION)
         f.close()
 
-class BranchMap(dict):
+class BranchMap(BaseMap):
     '''Facility for controlled renaming of branch names. Example:
 
     oldname = newname
@@ -384,62 +529,9 @@ class BranchMap(dict):
     '''
 
     def __init__(self, meta):
-        self.meta = meta
-        self.super = super(BranchMap, self)
-        self.super.__init__()
-        self.load(self.meta.branchmap_file)
-
-        # append branch mapping specified from the commandline
-        clmap = util.configpath(self.meta.ui, 'branchmap')
-        if clmap:
-            self.load(clmap)
-
-    def load(self, path):
-        '''Load mappings from a file at the specified path.'''
-        if not os.path.exists(path):
-            return
-
-        writing = False
-        if path != self.meta.branchmap_file:
-            writing = open(self.meta.branchmap_file, 'a')
-
-        self.meta.ui.debug('reading branchmap from %s\n' % path)
-        f = open(path, 'r')
-        for number, line in enumerate(f):
-
-            if writing:
-                writing.write(line)
-
-            line = line.split('#')[0]
-            if not line.strip():
-                continue
-
-            try:
-                src, dst = line.split('=', 1)
-            except (IndexError, ValueError):
-                msg = 'ignoring line %i in branch map %s: %s\n'
-                self.meta.ui.status(msg % (number, path, line.rstrip()))
-                continue
-
-            src = src.strip()
-            dst = dst.strip()
-            self.meta.ui.debug('adding branch %s to branch map\n' % src)
-
-            if not dst:
-                # prevent people from assuming such lines are valid
-                raise hgutil.Abort('removing branches is not supported, yet\n'
-                                   '(line %i in branch map %s)'
-                                   % (number, path))
-            elif src in self and dst != self[src]:
-                msg = 'overriding branch: "%s" to "%s" (%s)\n'
-                self.meta.ui.status(msg % (self[src], dst, src))
-            self[src] = dst
-
-        f.close()
-        if writing:
-            writing.close()
+        super(BranchMap, self).__init__(meta)
 
-class TagMap(dict):
+class TagMap(BaseMap):
     '''Facility for controlled renaming of tags. Example:
 
     oldname = newname
@@ -450,52 +542,4 @@ class TagMap(dict):
     '''
 
     def __init__(self, meta):
-        self.meta = meta
-        self.super = super(TagMap, self)
-        self.super.__init__()
-        self.load(self.meta.tagmap_file)
-
-        # append tag mapping specified from the commandline
-        clmap = util.configpath(self.meta.ui, 'tagmap')
-        if clmap:
-            self.load(clmap)
-
-    def load(self, path):
-        '''Load mappings from a file at the specified path.'''
-        if not os.path.exists(path):
-            return
-
-        writing = False
-        if path != self.meta.tagmap_file:
-            writing = open(self.meta.tagmap_file, 'a')
-
-        self.meta.ui.debug('reading tag renames from %s\n' % path)
-        f = open(path, 'r')
-        for number, line in enumerate(f):
-
-            if writing:
-                writing.write(line)
-
-            line = line.split('#')[0]
-            if not line.strip():
-                continue
-
-            try:
-                src, dst = line.split('=', 1)
-            except (IndexError, ValueError):
-                msg = 'ignoring line %i in tag renames %s: %s\n'
-                self.meta.ui.status(msg % (number, path, line.rstrip()))
-                continue
-
-            src = src.strip()
-            dst = dst.strip()
-            self.meta.ui.debug('adding tag %s to tag renames\n' % src)
-
-            if src in self and dst != self[src]:
-                msg = 'overriding tag rename: "%s" to "%s" (%s)\n'
-                self.meta.ui.status(msg % (self[src], dst, src))
-            self[src] = dst
-
-        f.close()
-        if writing:
-            writing.close()
+        super(TagMap, self).__init__(meta)
--- a/hgsubversion/replay.py
+++ b/hgsubversion/replay.py
@@ -121,6 +121,12 @@ def _convert_rev(ui, meta, svn, r, tbdel
         if branch in current.emptybranches and files:
             del current.emptybranches[branch]
 
+        if meta.skipbranch(branch):
+            # make sure we also get rid of it from emptybranches
+            if branch in current.emptybranches:
+                del current.emptybranches[branch]
+            continue
+
         files = dict(files)
         parents = meta.get_parent_revision(rev.revnum, branch), revlog.nullid
         if parents[0] in closedrevs and branch in meta.closebranches:
@@ -195,6 +201,9 @@ def _convert_rev(ui, meta, svn, r, tbdel
     # 2. handle branches that need to be committed without any files
     for branch in current.emptybranches:
 
+        if meta.skipbranch(branch):
+            continue
+
         ha = meta.get_parent_revision(rev.revnum, branch)
         if ha == node.nullid:
             continue
--- a/hgsubversion/stupid.py
+++ b/hgsubversion/stupid.py
@@ -689,6 +689,10 @@ def convert_rev(ui, meta, svn, r, tbdelt
     date = meta.fixdate(r.date)
     check_deleted_branches = set(tbdelta['branches'][1])
     for b in branches:
+
+        if meta.skipbranch(b):
+            continue
+
         parentctx = meta.repo[meta.get_parent_revision(r.revnum, b)]
         tag = meta.get_path_tag(meta.remotename(b))
         kind = svn.checkpath(branches[b], r.revnum)
--- a/hgsubversion/svnexternals.py
+++ b/hgsubversion/svnexternals.py
@@ -120,13 +120,84 @@ def parsedefinition(line):
 class RelativeSourceError(Exception):
     pass
 
+def resolvedots(url):
+    """
+    Fix references that include .. entries.
+    Scans a URL for .. type entries and resolves them but will not allow any
+    number of ..s to take us out of domain so http://.. will raise an exception.
+    
+    Tests, (Don't know how to construct a round trip for this so doctest):
+    >>> # Relative URL within servers svn area
+    >>> resolvedots(
+    ...    "http://some.svn.server/svn/some_repo/../other_repo")
+    'http://some.svn.server/svn/other_repo'
+    >>> # Complex One
+    >>> resolvedots(
+    ...    "http://some.svn.server/svn/repo/../other/repo/../../other_repo")
+    'http://some.svn.server/svn/other_repo'
+    >>> # Another Complex One
+    >>> resolvedots(
+    ...    "http://some.svn.server/svn/repo/dir/subdir/../../../other_repo/dir")
+    'http://some.svn.server/svn/other_repo/dir'
+    >>> # Last Complex One - SVN Allows this & seen it used even if it is BAD!
+    >>> resolvedots(
+    ...    "http://svn.server/svn/my_repo/dir/subdir/../../other_dir")
+    'http://svn.server/svn/my_repo/other_dir'
+    >>> # Outside the SVN Area might be OK
+    >>> resolvedots(
+    ...    "http://svn.server/svn/some_repo/../../other_svn_repo")
+    'http://svn.server/other_svn_repo'
+    >>> # Complex One
+    >>> resolvedots(
+    ...    "http://some.svn.server/svn/repo/../other/repo/../../other_repo")
+    'http://some.svn.server/svn/other_repo'
+    >>> # On another server is not a relative URL should give an exception
+    >>> resolvedots(
+    ...    "http://some.svn.server/svn/some_repo/../../../other_server")
+    Traceback (most recent call last):
+        ...
+    RelativeSourceError: Relative URL cannot be to another server
+    """
+    orig = url.split('/')
+    fixed = []
+    for item in orig:
+        if item != '..':
+            fixed.append(item)
+        elif len(fixed) > 3:  # Don't allow things to go out of domain
+            fixed.pop()
+        else:
+            raise RelativeSourceError(
+                'Relative URL cannot be to another server')
+    return '/'.join(fixed)
+
+
+
 def resolvesource(ui, svnroot, source):
+    """ Resolve the source as either matching the scheme re or by resolving
+    relative URLs which start with ^ and my include relative .. references.
+    
+    >>> root = 'http://some.svn.server/svn/some_repo'
+    >>> resolvesource(None, root, 'http://other.svn.server')
+    'http://other.svn.server'
+    >>> resolvesource(None, root, 'ssh://other.svn.server')
+    'ssh://other.svn.server'
+    >>> resolvesource(None, root, '^/other_repo')
+    'http://some.svn.server/svn/some_repo/other_repo'
+    >>> resolvesource(None, root, '^/sub_repo')
+    'http://some.svn.server/svn/some_repo/sub_repo'
+    >>> resolvesource(None, root, '^/../other_repo')
+    'http://some.svn.server/svn/other_repo'
+    >>> resolvesource(None, root, '^/../../../server/other_repo')
+    Traceback (most recent call last):
+        ...
+    RelativeSourceError: Relative URL cannot be to another server
+    """
     if re_scheme.search(source):
         return source
     if source.startswith('^/'):
         if svnroot is None:
             raise RelativeSourceError()
-        return svnroot + source[1:]
+        return resolvedots(svnroot + source[1:])
     ui.warn(_('ignoring unsupported non-fully qualified external: %r\n'
               % source))
     return None
@@ -424,7 +495,7 @@ class svnsubrepo(subrepo.svnsubrepo):
     def dirty(self, ignoreupdate=False):
         # You cannot compare anything with HEAD. Just accept it
         # can be anything.
-        if hasattr(self, '_wcrevs'):
+        if hgutil.safehasattr(self, '_wcrevs'):
             wcrevs = self._wcrevs()
         else:
             wcrev = self._wcrev()
@@ -447,3 +518,7 @@ class svnsubrepo(subrepo.svnsubrepo):
         if self._state[1] == 'HEAD':
             return 'HEAD'
         return super(svnsubrepo, self).basestate()
+    
+if __name__ == "__main__":
+    import doctest
+    doctest.testmod()
--- a/hgsubversion/svnmeta.py
+++ b/hgsubversion/svnmeta.py
@@ -55,6 +55,7 @@ class SVNMeta(object):
         self._gen_cachedconfig('lastpulled', 0, configname=False)
         self._gen_cachedconfig('defaultauthors', True)
         self._gen_cachedconfig('caseignoreauthors', False)
+        self._gen_cachedconfig('mapauthorscmd', None)
         self._gen_cachedconfig('defaulthost', self.uuid)
         self._gen_cachedconfig('usebranchnames', True)
         self._gen_cachedconfig('defaultmessage', '')
@@ -94,6 +95,8 @@ class SVNMeta(object):
                     c = self.ui.configint('hgsubversion', configname, default)
                 elif isinstance(default, list):
                     c = self.ui.configlist('hgsubversion', configname, default)
+                elif isinstance(default, dict):
+                    c = dict(self.ui.configitems(configname))
                 else:
                     c = self.ui.config('hgsubversion', configname, default)
 
@@ -218,7 +221,7 @@ class SVNMeta(object):
 
     @property
     def editor(self):
-        if not hasattr(self, '_editor'):
+        if not hgutil.safehasattr(self, '_editor'):
             self._editor = editor.HgEditor(self)
         return self._editor
 
@@ -284,7 +287,7 @@ class SVNMeta(object):
         return os.path.join(self.metapath, 'branch_info')
 
     @property
-    def authors_file(self):
+    def authormap_file(self):
         return os.path.join(self.metapath, 'authors')
 
     @property
@@ -388,6 +391,19 @@ class SVNMeta(object):
         }
         return extra
 
+    def skipbranch(self, name):
+        '''Returns whether or not we're skipping a branch.'''
+        # sometimes it's easier to pass the path instead of just the branch
+        # name, so we test for that here
+        if name:
+            bname = self.split_branch_path(name)
+            if bname != (None, None, None):
+                name = bname[1]
+
+        # if the mapped branch == '' and the original branch name == '' then we
+        # won't commit this branch
+        return name and not self.branchmap.get(name, True)
+
     def mapbranch(self, extra, close=False):
         if close:
             extra['close'] = 1
--- a/hgsubversion/svnwrap/subvertpy_wrapper.py
+++ b/hgsubversion/svnwrap/subvertpy_wrapper.py
@@ -186,7 +186,8 @@ class SubversionRepo(object):
     Note that password stores do not work, the parameter is only here
     to ensure that the API is the same as for the SWIG wrapper.
     """
-    def __init__(self, url='', username='', password='', head=None, password_stores=None):
+    def __init__(self, url='', username='', password='', head=None,
+                 password_stores=None):
         parsed = common.parse_url(url, username, password)
         # --username and --password override URL credentials
         self.username = parsed[0]
--- a/hgsubversion/svnwrap/svn_swig_wrapper.py
+++ b/hgsubversion/svnwrap/svn_swig_wrapper.py
@@ -170,7 +170,7 @@ def _create_auth_baton(pool, password_st
                     providers.append(p)
     else:
         for p in platform_specific:
-            if hasattr(core, p):
+            if getattr(core, p, None) is not None:
                 try:
                     providers.append(getattr(core, p)())
                 except RuntimeError:
@@ -205,7 +205,8 @@ class SubversionRepo(object):
 
     It uses the SWIG Python bindings, see above for requirements.
     """
-    def __init__(self, url='', username='', password='', head=None, password_stores=None):
+    def __init__(self, url='', username='', password='', head=None,
+                 password_stores=None):
         parsed = common.parse_url(url, username, password)
         # --username and --password override URL credentials
         self.username = parsed[0]
--- a/hgsubversion/wrappers.py
+++ b/hgsubversion/wrappers.py
@@ -204,6 +204,7 @@ def push(repo, dest, force, revs):
         hasobsolete = False
 
     temporary_commits = []
+    obsmarkers = []
     try:
         # TODO: implement --rev/#rev support
         # TODO: do credentials specified in the URL still work?
@@ -300,9 +301,7 @@ def push(repo, dest, force, revs):
                     if meta.get_source_rev(ctx=c)[0] == pushedrev.revnum:
                         # This is corresponds to the changeset we just pushed
                         if hasobsolete:
-                            ui.note('marking %s as obsoleted by %s\n' %
-                                    (original_ctx.hex(), c.hex()))
-                            obsolete.createmarkers(repo, [(original_ctx, [c])])
+                            obsmarkers.append([(original_ctx, [c])])
 
                     tip_ctx = c
 
@@ -343,7 +342,14 @@ def push(repo, dest, force, revs):
         finally:
             util.swap_out_encoding()
 
-        if not hasobsolete:
+        if hasobsolete:
+            for marker in obsmarkers:
+                obsolete.createmarkers(repo, marker)
+                beforepush = marker[0][0]
+                afterpush = marker[0][1][0]
+                ui.note('marking %s as obsoleted by %s\n' %
+                        (beforepush.hex(), afterpush.hex()))
+        else:
             # strip the original changesets since the push was
             # successful and changeset obsolescence is unavailable
             util.strip(ui, repo, outgoing, "all")
@@ -397,6 +403,7 @@ def pull(repo, source, heads=[], force=F
         svn = source.svn
         if meta is None:
             meta = repo.svnmeta(svn.uuid, svn.subdir)
+        svn.meta = meta
 
         stopat_rev = util.parse_revnum(svn, checkout)
 
@@ -590,6 +597,7 @@ def rebase(orig, ui, repo, **opts):
 optionmap = {
     'tagpaths': ('hgsubversion', 'tagpaths'),
     'authors': ('hgsubversion', 'authormap'),
+    'mapauthorscmd': ('hgsubversion', 'mapauthorscmd'),
     'branchdir': ('hgsubversion', 'branchdir'),
     'trunkdir': ('hgsubversion', 'trunkdir'),
     'infix': ('hgsubversion', 'infix'),
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/rename-closed-branch-dir.sh
@@ -0,0 +1,69 @@
+#!/bin/sh
+#
+# Generate rename-closed-branch-dir.svndump
+#
+
+mkdir temp
+cd temp
+
+mkdir project
+cd project
+mkdir trunk
+mkdir branches
+mkdir tags
+cd ..
+
+svnadmin create testrepo
+CURRENT_DIR=`pwd`
+svnurl=file://"$CURRENT_DIR"/testrepo
+#svn import project-orig $svnurl -m "init project"
+
+svn co $svnurl project
+cd project
+svn add *
+svn ci -m "init project"
+
+cd trunk
+echo a > a.txt
+svn add a.txt
+svn ci -m "add a.txt in trunk"
+
+# Create a branch
+svn up
+cd ../branches
+svn copy ../trunk async-db
+svn ci -m "add branch async-db"
+svn up
+
+# Implement feature
+cd async-db
+echo b > b.txt
+svn add b.txt
+svn ci -m "Async functionality"
+
+# Merge feature branch
+cd ../../trunk
+svn merge $svnurl/branches/async-db
+svn ci -m "Merged branch async-db"
+cd ..
+svn up
+
+# Create branch folder for unnecessary branches
+svn mkdir $svnurl/branches/dead -m "Create branch folder for unnecessary branches"
+svn up
+
+#  We don't need the 'async-db' branch, anymore.
+svn copy $svnurl/branches/async-db $svnurl/branches/dead -m "We don't need the 'async-db' branch, anymore."
+svn up
+
+# Rename 'dead' folder to 'closed'
+svn move $svnurl/branches/dead $svnurl/branches/closed -m "Renamed 'dead' folder to 'closed'"
+svn up
+
+# Move 'branches/closed' to 'tags/closed'
+svn move $svnurl/branches/closed $svnurl/tags/closed -m "Moved 'branches/closed' to 'tags/closed'."
+svn up
+
+# Dump repository
+cd ..
+svnadmin dump testrepo > ../rename-closed-branch-dir.svndump
new file mode 100644
--- /dev/null
+++ b/tests/fixtures/rename-closed-branch-dir.svndump
@@ -0,0 +1,296 @@
+SVN-fs-dump-format-version: 2
+
+UUID: 2efdcfe9-9dfd-40a7-a9cc-bf5b70806ff3
+
+Revision-number: 0
+Prop-content-length: 56
+Content-length: 56
+
+K 8
+svn:date
+V 27
+2016-01-27T15:35:29.673334Z
+PROPS-END
+
+Revision-number: 1
+Prop-content-length: 112
+Content-length: 112
+
+K 10
+svn:author
+V 5
+augie
+K 8
+svn:date
+V 27
+2016-01-27T15:35:30.079847Z
+K 7
+svn:log
+V 12
+init project
+PROPS-END
+
+Node-path: branches
+Node-kind: dir
+Node-action: add
+Prop-content-length: 10
+Content-length: 10
+
+PROPS-END
+
+
+Node-path: tags
+Node-kind: dir
+Node-action: add
+Prop-content-length: 10
+Content-length: 10
+
+PROPS-END
+
+
+Node-path: trunk
+Node-kind: dir
+Node-action: add
+Prop-content-length: 10
+Content-length: 10
+
+PROPS-END
+
+
+Revision-number: 2
+Prop-content-length: 118
+Content-length: 118
+
+K 10
+svn:author
+V 5
+augie
+K 8
+svn:date
+V 27
+2016-01-27T15:35:31.065912Z
+K 7
+svn:log
+V 18
+add a.txt in trunk
+PROPS-END
+
+Node-path: trunk/a.txt
+Node-kind: file
+Node-action: add
+Prop-content-length: 10
+Text-content-length: 2
+Text-content-md5: 60b725f10c9c85c70d97880dfe8191b3
+Text-content-sha1: 3f786850e387550fdab836ed7e6dc881de23001b
+Content-length: 12
+
+PROPS-END
+a
+
+
+Revision-number: 3
+Prop-content-length: 119
+Content-length: 119
+
+K 10
+svn:author
+V 5
+augie
+K 8
+svn:date
+V 27
+2016-01-27T15:35:34.051261Z
+K 7
+svn:log
+V 19
+add branch async-db
+PROPS-END
+
+Node-path: branches/async-db
+Node-kind: dir
+Node-action: add
+Node-copyfrom-rev: 2
+Node-copyfrom-path: trunk
+
+
+Revision-number: 4
+Prop-content-length: 119
+Content-length: 119
+
+K 10
+svn:author
+V 5
+augie
+K 8
+svn:date
+V 27
+2016-01-27T15:35:36.101507Z
+K 7
+svn:log
+V 19
+Async functionality
+PROPS-END
+
+Node-path: branches/async-db/b.txt
+Node-kind: file
+Node-action: add
+Prop-content-length: 10
+Text-content-length: 2
+Text-content-md5: 3b5d5c3712955042212316173ccf37be
+Text-content-sha1: 89e6c98d92887913cadf06b2adb97f26cde4849b
+Content-length: 12
+
+PROPS-END
+b
+
+
+Revision-number: 5
+Prop-content-length: 122
+Content-length: 122
+
+K 10
+svn:author
+V 5
+augie
+K 8
+svn:date
+V 27
+2016-01-27T15:35:38.055736Z
+K 7
+svn:log
+V 22
+Merged branch async-db
+PROPS-END
+
+Node-path: trunk
+Node-kind: dir
+Node-action: change
+Prop-content-length: 57
+Content-length: 57
+
+K 13
+svn:mergeinfo
+V 22
+/branches/async-db:3-4
+PROPS-END
+
+
+Node-path: trunk/b.txt
+Node-kind: file
+Node-action: add
+Node-copyfrom-rev: 4
+Node-copyfrom-path: branches/async-db/b.txt
+Text-copy-source-md5: 3b5d5c3712955042212316173ccf37be
+Text-copy-source-sha1: 89e6c98d92887913cadf06b2adb97f26cde4849b
+
+
+Revision-number: 6
+Prop-content-length: 145
+Content-length: 145
+
+K 10
+svn:author
+V 5
+augie
+K 8
+svn:date
+V 27
+2016-01-27T15:35:40.046670Z
+K 7
+svn:log
+V 45
+Create branch folder for unnecessary branches
+PROPS-END
+
+Node-path: branches/dead
+Node-kind: dir
+Node-action: add
+Prop-content-length: 10
+Content-length: 10
+
+PROPS-END
+
+
+Revision-number: 7
+Prop-content-length: 145
+Content-length: 145
+
+K 10
+svn:author
+V 5
+augie
+K 8
+svn:date
+V 27
+2016-01-27T15:35:41.048576Z
+K 7
+svn:log
+V 45
+We don't need the 'async-db' branch, anymore.
+PROPS-END
+
+Node-path: branches/dead/async-db
+Node-kind: dir
+Node-action: add
+Node-copyfrom-rev: 6
+Node-copyfrom-path: branches/async-db
+
+
+Revision-number: 8
+Prop-content-length: 133
+Content-length: 133
+
+K 10
+svn:author
+V 5
+augie
+K 8
+svn:date
+V 27
+2016-01-27T15:35:42.046536Z
+K 7
+svn:log
+V 33
+Renamed 'dead' folder to 'closed'
+PROPS-END
+
+Node-path: branches/closed
+Node-kind: dir
+Node-action: add
+Node-copyfrom-rev: 7
+Node-copyfrom-path: branches/dead
+
+
+Node-path: branches/dead
+Node-action: delete
+
+
+Revision-number: 9
+Prop-content-length: 141
+Content-length: 141
+
+K 10
+svn:author
+V 5
+augie
+K 8
+svn:date
+V 27
+2016-01-27T15:35:43.048056Z
+K 7
+svn:log
+V 41
+Moved 'branches/closed' to 'tags/closed'.
+PROPS-END
+
+Node-path: branches/closed
+Node-action: delete
+
+
+Node-path: tags/closed
+Node-kind: dir
+Node-action: add
+Node-copyfrom-rev: 8
+Node-copyfrom-path: branches/closed
+
+
--- a/tests/test_fetch_mappings.py
+++ b/tests/test_fetch_mappings.py
@@ -114,6 +114,15 @@ class MapTests(test_util.TestBase):
         self.assertEqual(self.repo['tip'].user(),
                         'evil@5b65bade-98f3-4993-a01f-b7a6710da339')
 
+    def test_author_map_mapauthorscmd(self):
+        repo_path = self.load_svndump('replace_trunk_with_branch.svndump')
+        ui = self.ui()
+        ui.setconfig('hgsubversion', 'mapauthorscmd', 'echo "svn: %s"')
+        commands.clone(ui, test_util.fileurl(repo_path),
+                       self.wc_path)
+        self.assertEqual(self.repo[0].user(), 'svn: Augie')
+        self.assertEqual(self.repo['tip'].user(), 'svn: evil')
+
     def _loadwithfilemap(self, svndump, filemapcontent,
             failonmissing=True):
         repo_path = self.load_svndump(svndump)
@@ -200,6 +209,22 @@ class MapTests(test_util.TestBase):
         self.assert_('good-name' in branches)
         self.assertEquals(self.repo[2].branch(), 'default')
 
+    def test_branchmap_regex_and_glob(self):
+        repo_path = self.load_svndump('branchmap.svndump')
+        branchmap = open(self.branchmap, 'w')
+        branchmap.write("syntax:re\n")
+        branchmap.write("bad(.*) = good-\\1 # stuffy\n")
+        branchmap.write("glob:feat* = default\n")
+        branchmap.close()
+        ui = self.ui()
+        ui.setconfig('hgsubversion', 'branchmap', self.branchmap)
+        commands.clone(ui, test_util.fileurl(repo_path),
+                       self.wc_path, branchmap=self.branchmap)
+        branches = set(self.repo[i].branch() for i in self.repo)
+        self.assert_('badname' not in branches)
+        self.assert_('good-name' in branches)
+        self.assertEquals(self.repo[2].branch(), 'default')
+
     def test_branchmap_tagging(self):
         '''test tagging a renamed branch, which used to raise an exception'''
         repo_path = self.load_svndump('commit-to-tag.svndump')
@@ -290,6 +315,23 @@ class MapTests(test_util.TestBase):
         for r in repo:
             self.assertEquals(verify.verify(ui, repo, rev=r), 0)
 
+    def test_branchmap_no_replacement(self):
+        '''test that empty mappings are accepted
+
+        Empty mappings are lines like 'this ='. We check that such branches are
+        not converted.
+        '''
+        repo_path = self.load_svndump('branchmap.svndump')
+        branchmap = open(self.branchmap, 'w')
+        branchmap.write("badname =\n")
+        branchmap.close()
+        ui = self.ui()
+        ui.setconfig('hgsubversion', 'branchmap', self.branchmap)
+        commands.clone(ui, test_util.fileurl(repo_path),
+                       self.wc_path, branchmap=self.branchmap)
+        branches = set(self.repo[i].branch() for i in self.repo)
+        self.assertEquals(sorted(branches), ['default', 'feature'])
+
     def test_tagmap(self):
         repo_path = self.load_svndump('basic_tag_tests.svndump')
         tagmap = open(self.tagmap, 'w')