# HG changeset patch # User Augie Fackler # Date 1464039919 14400 # Node ID c161586a6b771b2ae6d61f0cc0152fae75b356f0 # Parent 020917cde9f5ccd052bf79e7aaf0c6f190807cc1# Parent 4f1461428334aaf7aa68116516afb2152cd7e133 Merge with stable. diff --git a/hgsubversion/editor.py b/hgsubversion/editor.py --- 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 diff --git a/hgsubversion/layouts/custom.py b/hgsubversion/layouts/custom.py --- 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: diff --git a/hgsubversion/maps.py b/hgsubversion/maps.py --- 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. - - If the 'hgsubversion.defaultauthors' configuration option is set to false, - attempting to obtain an unknown author will fail with an Abort. +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.caseignoreauthors' configuration option is set to true, - the userid from Subversion is always compared lowercase. - ''' + self._commentre = re.compile(r'((^|[^\\])(\\\\)*)#.*') + self.syntaxes = ('re', 'glob') - def __init__(self, meta): - '''Initialise a new AuthorMap. + # 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)) - 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): - line = line_org.split('#')[0] - if not line.strip(): + if writing: + writing.write(line) + + # 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 @@ -210,6 +355,29 @@ class RevMap(dict): check = lambda x: x[0][1] == branch and x[0][0] < rev.revnum return sorted(filter(check, self.iteritems()), reverse=True) + def revhashes(self, revnum): + for key, value in self.iteritems(): + if key[0] == revnum: + yield value + + def clear(self): + self._write() + dict.clear(self) + self._hashes = None + + def batchset(self, items): + '''Set items in batches + + items is an array of (rev num, branch, binary hash) + + For performance reason, meta.lastpulled and meta.firstpulled + are not updated. + ''' + f = open(self.meta.revmap_file, 'a') + f.write(''.join('%s %s %s\n' % (revnum, hex(binhash), br or '') + for revnum, br, binhash in items)) + f.close() + @classmethod def readmapfile(cls, path, missingok=True): try: @@ -223,6 +391,10 @@ class RevMap(dict): raise hgutil.Abort('revmap too new -- please upgrade') return f + @classmethod + def exists(cls, meta): + return os.path.exists(meta.revmap_file) + @util.gcdisable def _load(self): lastpulled = self.meta.lastpulled @@ -373,7 +545,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 +556,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 +569,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) diff --git a/hgsubversion/replay.py b/hgsubversion/replay.py --- 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 diff --git a/hgsubversion/stupid.py b/hgsubversion/stupid.py --- 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) diff --git a/hgsubversion/svnexternals.py b/hgsubversion/svnexternals.py --- 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() diff --git a/hgsubversion/svnmeta.py b/hgsubversion/svnmeta.py --- 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 @@ -345,6 +348,10 @@ class SVNMeta(object): self._revmap = maps.RevMap(self) return self._revmap + @property + def revmapexists(self): + return maps.RevMap.exists(self) + def fixdate(self, date): if date is not None: date = date.replace('T', ' ').replace('Z', '').split('.')[0] @@ -388,6 +395,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 diff --git a/hgsubversion/svnwrap/subvertpy_wrapper.py b/hgsubversion/svnwrap/subvertpy_wrapper.py --- 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] diff --git a/hgsubversion/svnwrap/svn_swig_wrapper.py b/hgsubversion/svnwrap/svn_swig_wrapper.py --- 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] diff --git a/hgsubversion/util.py b/hgsubversion/util.py --- a/hgsubversion/util.py +++ b/hgsubversion/util.py @@ -320,16 +320,11 @@ def revset_fromsvn(repo, subset, x): rev = repo.changelog.rev bin = node.bin meta = repo.svnmeta(skiperrorcheck=True) - try: - svnrevs = set(rev(bin(l.split(' ', 2)[1])) - for l in maps.RevMap.readmapfile(meta.revmap_file, - missingok=False)) - return filter(svnrevs.__contains__, subset) - except IOError, err: - if err.errno != errno.ENOENT: - raise + if not meta.revmapexists: raise hgutil.Abort("svn metadata is missing - " "run 'hg svn rebuildmeta' to reconstruct it") + svnrevs = set(rev(h) for h in meta.revmap.hashes().keys()) + return filter(svnrevs.__contains__, subset) def revset_svnrev(repo, subset, x): '''``svnrev(number)`` @@ -344,22 +339,16 @@ def revset_svnrev(repo, subset, x): except ValueError: raise error.ParseError("the argument to svnrev() must be a number") - rev = rev + ' ' - revs = [] meta = repo.svnmeta(skiperrorcheck=True) - try: - for l in maps.RevMap.readmapfile(meta.revmap_file, missingok=False): - if l.startswith(rev): - n = l.split(' ', 2)[1] - r = repo[node.bin(n)].rev() - if r in subset: - revs.append(r) - return revs - except IOError, err: - if err.errno != errno.ENOENT: - raise + if not meta.revmapexists: raise hgutil.Abort("svn metadata is missing - " "run 'hg svn rebuildmeta' to reconstruct it") + revs = [] + for n in meta.revmap.revhashes(revnum): + r = repo[n].rev() + if r in subset: + revs.append(r) + return revs revsets = { 'fromsvn': revset_fromsvn, diff --git a/hgsubversion/wrappers.py b/hgsubversion/wrappers.py --- 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'), diff --git a/tests/fixtures/rename-closed-branch-dir.sh b/tests/fixtures/rename-closed-branch-dir.sh 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 diff --git a/tests/fixtures/rename-closed-branch-dir.svndump b/tests/fixtures/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 + + diff --git a/tests/test_fetch_mappings.py b/tests/test_fetch_mappings.py --- 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')