# HG changeset patch # User Augie Fackler # Date 1372029512 18000 # Node ID f67f9d28b0accdce0f6d19f99a88be6fac6a6fc1 # Parent c6e9889dba27694eb8cc90150992c51e626ea642# Parent b5b1fce26f1f8139236b901ca55165707c464f0b Merge with stable. diff --git a/hgsubversion/__init__.py b/hgsubversion/__init__.py --- a/hgsubversion/__init__.py +++ b/hgsubversion/__init__.py @@ -180,6 +180,9 @@ def reposetup(ui, repo): for tunnel in ui.configlist('hgsubversion', 'tunnels'): hg.schemes['svn+' + tunnel] = svnrepo + if revset and ui.configbool('hgsubversion', 'nativerevs'): + extensions.wrapfunction(revset, 'stringset', util.revset_stringset) + _old_local = hg.schemes['file'] def _lookup(url): if util.islocalrepo(url): diff --git a/hgsubversion/editor.py b/hgsubversion/editor.py --- a/hgsubversion/editor.py +++ b/hgsubversion/editor.py @@ -570,13 +570,16 @@ class HgEditor(svnwrap.Editor): msg += _TXDELT_WINDOW_HANDLER_FAILURE_MSG e.args = (msg,) + others - raise e + + # re-raising ensures that we show the full stack trace + raise # window being None means commit this file if not window: self._openfiles[file_baton] = ( path, target, isexec, islink, copypath) except svnwrap.SubversionException, e: # pragma: no cover + self.ui.traceback() if e.args[1] == svnwrap.ERR_INCOMPLETE_DATA: self.addmissing(path) else: # pragma: no cover diff --git a/hgsubversion/layouts/__init__.py b/hgsubversion/layouts/__init__.py new file mode 100644 --- /dev/null +++ b/hgsubversion/layouts/__init__.py @@ -0,0 +1,44 @@ +"""Code for dealing with subversion layouts + +This package is intended to encapsulate everything about subversion +layouts. This includes detecting the layout based on looking at +subversion, mapping subversion paths to hg branches, and doing any +other path translation necessary. + +NB: this has a long way to go before it does everything it claims to + +""" + +from mercurial import util as hgutil + +import detect +import persist +import single +import standard + +__all__ = [ + "detect", + "layout_from_name", + "persist", + ] + +# This is the authoritative store of what layouts are available. +# The intention is for extension authors who wish to build their own +# layout to add it to this dict. +NAME_TO_CLASS = { + "single": single.SingleLayout, + "standard": standard.StandardLayout, +} + + +def layout_from_name(name): + """Returns a layout module given the layout name + + You should use one of the layout.detect.* functions to get the + name to pass to this function. + + """ + + if name not in NAME_TO_CLASS: + raise hgutil.Abort('Unknown hgsubversion layout: %s' %name) + return NAME_TO_CLASS[name]() diff --git a/hgsubversion/layouts/base.py b/hgsubversion/layouts/base.py new file mode 100644 --- /dev/null +++ b/hgsubversion/layouts/base.py @@ -0,0 +1,40 @@ +"""Module to hold the base API for layout classes. + +This module should not contain any implementation, just a definition +of the API concrete layouts are expected to implement. + +""" + +from mercurial import util as hgutil + +class BaseLayout(object): + + def __unimplemented(self, method_name): + raise NotImplementedError( + "Incomplete layout implementation: %s.%s doesn't implement %s" % + (self.__module__, self.__name__, method_name)) + + def localname(self, path): + """Compute the local name for a branch located at path. + + path should be relative to the repo url. + + """ + self.__unimplemented('localname') + + def remotename(self, branch): + """Compute a subversion path for a mercurial branch name + + This should return a path relative to the repo url + + """ + self.__unimplemented('remotename') + + def remotepath(self, branch, subdir='/'): + """Compute a subversion path for a mercurial branch name. + + This should return an absolute path, assuming our repo root is at subdir + A false subdir shall be taken to mean /. + + """ + self.__unimplemented('remotepath') diff --git a/hgsubversion/layouts/detect.py b/hgsubversion/layouts/detect.py new file mode 100644 --- /dev/null +++ b/hgsubversion/layouts/detect.py @@ -0,0 +1,87 @@ +""" Layout detection for subversion repos. + +Figure out what layout we should be using, based on config, command +line flags, subversion contents, and anything else we decide to base +it on. + +""" + +import os.path + +from mercurial import util as hgutil + +import hgsubversion.svnwrap + +def layout_from_subversion(svn, revision=None, ui=None): + """ Guess what layout to use based on directories under the svn root. + + This is intended for use during bootstrapping. It guesses which + layout to use based on the presence or absence of the conventional + trunk, branches, tags dirs immediately under the path your are + cloning. + + Additionally, this will write the layout in use to the ui object + passed, if any. + + """ + + try: + rootlist = svn.list_dir('', revision=revision) + except svnwrap.SubversionException, e: + err = "%s (subversion error: %d)" % (e.args[0], e.args[1]) + raise hgutil.Abort(err) + if sum(map(lambda x: x in rootlist, ('branches', 'tags', 'trunk'))): + layout = 'standard' + else: + layout = 'single' + ui.setconfig('hgsubversion', 'layout', layout) + return layout + +def layout_from_config(ui, allow_auto=False): + """ Load the layout we are using based on config + + We will read the config from the ui object. Pass allow_auto=True + if you are doing bootstrapping and can detect the layout in + another manner if you get auto. Otherwise, we will abort if we + detect the layout as auto. + """ + + layout = ui.config('hgsubversion', 'layout', default='auto') + if layout == 'auto' and not allow_auto: + raise hgutil.Abort('layout not yet determined') + elif layout not in ('auto', 'single', 'standard'): + raise hgutil.Abort("unknown layout '%s'" % layout) + return layout + +def layout_from_file(meta_data_dir, ui=None): + """ Load the layout in use from the metadata file. + + If you pass the ui arg, we will also write the layout to the + config for that ui. + + """ + + layout = None + layoutfile = os.path.join(meta_data_dir, 'layout') + if os.path.exists(layoutfile): + f = open(layoutfile) + layout = f.read().strip() + f.close() + if ui: + ui.setconfig('hgsubversion', 'layout', layout) + return layout + +def layout_from_commit(subdir, revpath): + """ Guess what the layout is based existing commit info + + Specifically, this compares the subdir for the repository and the + revpath as extracted from the convinfo in the commit. + + """ + + if (subdir or '/') == revpath: + layout = 'single' + else: + layout = 'standard' + + return layout diff --git a/hgsubversion/layouts/persist.py b/hgsubversion/layouts/persist.py new file mode 100644 --- /dev/null +++ b/hgsubversion/layouts/persist.py @@ -0,0 +1,16 @@ +"""Code for persisting the layout config in various locations. + +Basically, if you want to save the layout, this is where you should go +to do it. + +""" + +import os.path + +def layout_to_file(meta_data_dir, layout): + """Save the given layout to a file under the given meta_data_dir""" + + layoutfile = os.path.join(meta_data_dir, 'layout') + f = open(layoutfile, 'w') + f.write(layout) + f.close() diff --git a/hgsubversion/layouts/single.py b/hgsubversion/layouts/single.py new file mode 100644 --- /dev/null +++ b/hgsubversion/layouts/single.py @@ -0,0 +1,15 @@ + + +import base + +class SingleLayout(base.BaseLayout): + """A layout with only the default branch""" + + def localname(self, path): + return 'default' + + def remotename(self, branch): + return '' + + def remotepath(self, branch, subdir='/'): + return subdir or '/' diff --git a/hgsubversion/layouts/standard.py b/hgsubversion/layouts/standard.py new file mode 100644 --- /dev/null +++ b/hgsubversion/layouts/standard.py @@ -0,0 +1,31 @@ + + +import base + + +class StandardLayout(base.BaseLayout): + """The standard trunk, branches, tags layout""" + + def localname(self, path): + if path == 'trunk': + return None + elif path.startswith('branches/'): + return path[len('branches/'):] + return '../%s' % path + + def remotename(self, branch): + if branch == 'default' or branch is None: + return 'trunk' + elif branch.startswith('../'): + return branch[3:] + return 'branches/%s' % branch + + def remotepath(self, branch, subdir='/'): + branchpath = 'trunk' + if branch: + if branch.startswith('../'): + branchpath = branch[3:] + else: + branchpath = 'branches/%s' % branch + + return '%s/%s' % (subdir or '', branchpath) diff --git a/hgsubversion/maps.py b/hgsubversion/maps.py --- a/hgsubversion/maps.py +++ b/hgsubversion/maps.py @@ -45,7 +45,7 @@ class AuthorMap(dict): if path != self.path: writing = open(self.path, 'a') - self.ui.note('reading authormap from %s\n' % path) + self.ui.debug('reading authormap from %s\n' % path) f = open(path, 'r') for number, line_org in enumerate(f): @@ -88,7 +88,7 @@ class AuthorMap(dict): elif self.ui.configbool('hgsubversion', 'defaultauthors', True): self[author] = result = '%s%s' % (author, self.defaulthost) msg = 'substituting author "%s" for default "%s"\n' - self.ui.note(msg % (author, result)) + self.ui.debug(msg % (author, result)) else: msg = 'author %s has no entry in the author map!' raise hgutil.Abort(msg % author) @@ -333,7 +333,7 @@ class FileMap(object): f.close() def load(self, fn): - self.ui.note('reading file map from %s\n' % fn) + self.ui.debug('reading file map from %s\n' % fn) f = open(fn, 'r') self.load_fd(f, fn) f.close() @@ -355,7 +355,7 @@ class FileMap(object): self.ui.warn(msg % (fn, line.rstrip())) def _load(self): - self.ui.note('reading in-repo file map from %s\n' % self.path) + self.ui.debug('reading in-repo file map from %s\n' % self.path) f = open(self.path) ver = int(f.readline()) if ver != self.VERSION: @@ -394,7 +394,7 @@ class BranchMap(dict): if path != self.path: writing = open(self.path, 'a') - self.ui.note('reading branchmap from %s\n' % path) + self.ui.debug('reading branchmap from %s\n' % path) f = open(path, 'r') for number, line in enumerate(f): @@ -456,7 +456,7 @@ class TagMap(dict): if path != self.path: writing = open(self.path, 'a') - self.ui.note('reading tag renames from %s\n' % path) + self.ui.debug('reading tag renames from %s\n' % path) f = open(path, 'r') for number, line in enumerate(f): diff --git a/hgsubversion/pushmod.py b/hgsubversion/pushmod.py --- a/hgsubversion/pushmod.py +++ b/hgsubversion/pushmod.py @@ -99,12 +99,7 @@ def commit(ui, repo, rev_ctx, meta, base file_data = {} parent = rev_ctx.parents()[0] parent_branch = rev_ctx.parents()[0].branch() - branch_path = 'trunk' - - if meta.layout == 'single': - branch_path = '' - elif parent_branch and parent_branch != 'default': - branch_path = 'branches/%s' % parent_branch + branch_path = meta.layoutobj.remotename(parent_branch) extchanges = svnexternals.diff(svnexternals.parse(ui, parent), svnexternals.parse(ui, rev_ctx)) @@ -139,7 +134,7 @@ def commit(ui, repo, rev_ctx, meta, base copies[file] = renamed[0] base_data = parent[renamed[0]].data() else: - autoprops = svn.autoprops_config.properties(file) + autoprops = svn.autoprops_config.properties(file) if autoprops: props.setdefault(file, {}).update(autoprops) @@ -210,6 +205,9 @@ def commit(ui, repo, rev_ctx, meta, base raise hgutil.Abort('Outgoing changesets parent is not at ' 'subversion HEAD\n' '(pull again and rebase on a newer revision)') + elif len(e.args) > 0 and e.args[1] == svnwrap.ERR_REPOS_HOOK_FAILURE: + # Special handling for svn hooks blocking error + raise hgutil.Abort(e.args[0]) else: raise diff --git a/hgsubversion/svncommands.py b/hgsubversion/svncommands.py --- a/hgsubversion/svncommands.py +++ b/hgsubversion/svncommands.py @@ -4,6 +4,7 @@ import cPickle as pickle import sys import traceback import urlparse +import errno from mercurial import commands from mercurial import hg @@ -11,6 +12,7 @@ from mercurial import node from mercurial import util as hgutil from mercurial import error +import layouts import maps import svnwrap import svnrepo @@ -37,6 +39,22 @@ def rebuildmeta(ui, repo, args, unsafe_s return _buildmeta(ui, repo, args, partial=False, skipuuid=unsafe_skip_uuid_check) +def read_if_exists(path): + try: + fp = open(path, 'rb') + d = fp.read() + fp.close() + return d + except IOError, err: + if err.errno != errno.ENOENT: + raise + +def write_if_needed(path, content): + if read_if_exists(path) != content: + fp = open(path, 'wb') + fp.write(content) + fp.close() + def _buildmeta(ui, repo, args, partial=False, skipuuid=False): if repo is None: @@ -44,18 +62,27 @@ def _buildmeta(ui, repo, args, partial=F " here (.hg not found)") dest = None + validateuuid = False if len(args) == 1: dest = args[0] + validateuuid = True elif len(args) > 1: raise hgutil.Abort('rebuildmeta takes 1 or no arguments') - uuid = None url = repo.ui.expandpath(dest or repo.ui.config('paths', 'default-push') or repo.ui.config('paths', 'default') or '') - svn = svnrepo.svnremoterepo(ui, url).svn - subdir = svn.subdir svnmetadir = os.path.join(repo.path, 'svn') if not os.path.exists(svnmetadir): os.makedirs(svnmetadir) + uuidpath = os.path.join(svnmetadir, 'uuid') + uuid = read_if_exists(uuidpath) + + subdirpath = os.path.join(svnmetadir, 'subdir') + subdir = read_if_exists(subdirpath) + svn = None + if subdir is None: + svn = svnrepo.svnremoterepo(ui, url).svn + subdir = svn.subdir + open(subdirpath, 'wb').write(subdir.strip('/')) youngest = 0 startrev = 0 @@ -80,9 +107,9 @@ def _buildmeta(ui, repo, args, partial=F except IOError, err: if err.errno != errno.ENOENT: raise - ui.status('missing some metadata -- doing a full rebuild') + ui.status('missing some metadata -- doing a full rebuild\n') except AttributeError: - ui.status('no metadata available -- doing a full rebuild') + ui.status('no metadata available -- doing a full rebuild\n') lastpulled = open(os.path.join(svnmetadir, 'lastpulled'), 'wb') @@ -102,10 +129,6 @@ def _buildmeta(ui, repo, args, partial=F numrevs = len(repo) - startrev - subdirfile = open(os.path.join(svnmetadir, 'subdir'), 'w') - subdirfile.write(subdir.strip('/')) - subdirfile.close() - # ctx.children() visits all revisions in the repository after ctx. Calling # it would make us use O(revisions^2) time, so we perform an extra traversal # of the repository instead. During this traversal, we find all converted @@ -186,27 +209,25 @@ def _buildmeta(ui, repo, args, partial=F '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() + layout = layouts.detect.layout_from_commit(subdir, revpath) + existing_layout = layouts.detect.layout_from_file(svnmetadir) + if layout != existing_layout: + layouts.persist.layout_to_file(svnmetadir, layout) elif layout == 'single': assert (subdir or '/') == revpath, ('Possible layout detection' ' defect in replay') # write repository uuid if required - if uuid is None: + if uuid is None or validateuuid: + validateuuid = False uuid = convinfo[4:40] if not skipuuid: + if svn is None: + svn = svnrepo.svnremoterepo(ui, url).svn if uuid != svn.uuid: raise hgutil.Abort('remote svn repository identifier ' 'does not match') - uuidfile = open(os.path.join(svnmetadir, 'uuid'), 'w') - uuidfile.write(svn.uuid) - uuidfile.close() + write_if_needed(uuidpath, uuid) # don't reflect closed branches if (ctx.extra().get('close') and not ctx.files() or @@ -363,10 +384,9 @@ def genignore(ui, repo, force=False, **o hashes = meta.revmap.hashes() parent = util.parentrev(ui, repo, meta, hashes) r, br = hashes[parent.node()] - if meta.layout == 'single': - branchpath = '' - else: - branchpath = br and ('branches/%s/' % br) or 'trunk/' + branchpath = meta.layoutobj.remotename(br) + if branchpath: + branchpath += '/' ignorelines = ['.hgignore', 'syntax:glob'] dirs = [''] + [d[0] for d in svn.list_files(branchpath, r) if d[1] == 'd'] @@ -403,17 +423,8 @@ def info(ui, repo, **opts): return 0 r, br = hashes[pn] subdir = util.getsvnrev(parent)[40:].split('@')[0] - if meta.layout == 'single': - branchpath = '' - elif br == None: - branchpath = '/trunk' - elif br.startswith('../'): - branchpath = '/%s' % br[3:] - subdir = subdir.replace('branches/../', '') - else: - branchpath = '/branches/%s' % br remoterepo = svnrepo.svnremoterepo(repo.ui) - url = '%s%s' % (remoterepo.svnurl, branchpath) + url = meta.layoutobj.remotepath(br, remoterepo.svnurl) author = meta.authors.reverselookup(parent.user()) # cleverly figure out repo root w/o actually contacting the server reporoot = url[:len(url)-len(subdir)] diff --git a/hgsubversion/svnmeta.py b/hgsubversion/svnmeta.py --- a/hgsubversion/svnmeta.py +++ b/hgsubversion/svnmeta.py @@ -10,6 +10,7 @@ from mercurial import node import util import maps +import layouts import editor @@ -69,13 +70,9 @@ 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 + self._layout = layouts.detect.layout_from_file(self.meta_data_dir, + ui=self.repo.ui) + self._layoutobj = None pickle_atomic(self.tag_locations, self.tag_locations_file) # ensure nested paths are handled properly self.tag_locations.sort() @@ -107,15 +104,16 @@ class SVNMeta(object): # 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() + self._layout = layouts.detect.layout_from_config(self.repo.ui) + layouts.persist.layout_to_file(self.meta_data_dir, self._layout) return self._layout + @property + def layoutobj(self): + if not self._layoutobj: + self._layoutobj = layouts.layout_from_name(self.layout) + return self._layoutobj + @property def editor(self): if not hasattr(self, '_editor'): @@ -206,10 +204,6 @@ class SVNMeta(object): # called tag-renames for backwards compatibility return os.path.join(self.meta_data_dir, 'tag-renames') - @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] @@ -228,22 +222,10 @@ 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/'): - return path[len('branches/'):] - return '../%s' % path + return self.layoutobj.localname(path) def remotename(self, branch): - if self.layout == 'single': - return '' - if branch == 'default' or branch is None: - return 'trunk' - elif branch.startswith('../'): - return branch[3:] - return 'branches/%s' % branch + return self.layoutobj.remotename(branch) def genextra(self, revnum, branch): extra = {} @@ -253,17 +235,10 @@ class SVNMeta(object): 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) + path = self.layoutobj.remotepath(branch, subdir) + + if branch: + extra['branch'] = branch extra['convert_revision'] = 'svn:%(uuid)s%(path)s@%(rev)s' % { 'uuid': self.uuid, diff --git a/hgsubversion/svnrepo.py b/hgsubversion/svnrepo.py --- a/hgsubversion/svnrepo.py +++ b/hgsubversion/svnrepo.py @@ -122,7 +122,7 @@ class svnremoterepo(peerrepository): if path is None: path = self.ui.config('paths', 'default') if not path: - raise hgutil.Abort('no Subversion URL specified') + raise hgutil.Abort('no Subversion URL specified. Expect[path] default= or [path] default-push= SVN URL entries in hgrc.') self.path = path self.capabilities = set(['lookup', 'subversion']) pws = self.ui.config('hgsubversion', 'password_stores', None) @@ -218,7 +218,7 @@ class SubversionPrompt(object): username = default_username else: username = self.ui.prompt('Username: ', default='') - password = self.ui.getpass('Password for \'%s\': ' % (username,), default='') + password = self.ui.getpass("Password for '%s': " % (username,), default='') return (username, password, bool(may_save)) def ssl_client_cert(self, realm, may_save, pool=None): @@ -227,7 +227,7 @@ class SubversionPrompt(object): return (cert_file, bool(may_save)) def ssl_client_cert_pw(self, realm, may_save, pool=None): - password = self.ui.getpass('Passphrase for \'%s\': ' % (realm,), default='') + password = self.ui.getpass("Passphrase for '%s': " % (realm,), default='') return (password, bool(may_save)) def insecure(fn): @@ -252,7 +252,7 @@ class SubversionPrompt(object): @insecure def ssl_server_trust(self, realm, failures, cert_info, may_save, pool=None): - msg = 'Error validating server certificate for \'%s\':\n' % (realm,) + msg = "Error validating server certificate for '%s':\n" % (realm,) if failures & svnwrap.SSL_UNKNOWNCA: msg += ( ' - The certificate is not issued by a trusted authority. Use the\n' @@ -293,4 +293,3 @@ class SubversionPrompt(object): else: creds = None return creds - 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 @@ -58,6 +58,7 @@ ERR_FS_TXN_OUT_OF_DATE = subvertpy.ERR_F ERR_INCOMPLETE_DATA = subvertpy.ERR_INCOMPLETE_DATA ERR_RA_DAV_PATH_NOT_FOUND = subvertpy.ERR_RA_DAV_PATH_NOT_FOUND ERR_RA_DAV_REQUEST_FAILED = subvertpy.ERR_RA_DAV_REQUEST_FAILED +ERR_REPOS_HOOK_FAILURE = subvertpy.ERR_REPOS_HOOK_FAILURE SSL_UNKNOWNCA = subvertpy.SSL_UNKNOWNCA SSL_CNMISMATCH = subvertpy.SSL_CNMISMATCH SSL_NOTYETVALID = subvertpy.SSL_NOTYETVALID @@ -95,7 +96,7 @@ class PathAdapter(object): if self.copyfrom_path: self.copyfrom_path = intern(self.copyfrom_path) -class AbstractEditor(object): +class BaseEditor(object): __slots__ = ('editor', 'baton') def __init__(self, editor, baton=None): @@ -116,7 +117,9 @@ class AbstractEditor(object): def close(self): del self.editor -class FileEditor(AbstractEditor): +class FileEditor(BaseEditor): + __slots__ = () + def __init__(self, editor, baton): super(FileEditor, self).__init__(editor, baton) @@ -130,7 +133,9 @@ class FileEditor(AbstractEditor): self.editor.close_file(self.baton, checksum) super(FileEditor, self).close() -class DirectoryEditor(AbstractEditor): +class DirectoryEditor(BaseEditor): + __slots__ = () + def __init__(self, editor, baton): super(DirectoryEditor, self).__init__(editor, baton) @@ -417,7 +422,7 @@ class SubversionRepo(object): editor.delete_entry(path, base_revision) continue else: - assert False, 'invalid action \'%s\'' % action + assert False, "invalid action '%s'" % action if path in props: if props[path].get('svn:special', None): @@ -453,15 +458,15 @@ class SubversionRepo(object): rooteditor = commiteditor.open_root() visitdir(rooteditor, '', paths, 0) rooteditor.close() - commiteditor.close() except: commiteditor.abort() raise + commiteditor.close() def get_replay(self, revision, editor, oldestrev=0): try: - self.remote.replay(revision, oldestrev, AbstractEditor(editor)) + self.remote.replay(revision, oldestrev, BaseEditor(editor)) except (SubversionException, NotImplementedError), e: # pragma: no cover # can I depend on this number being constant? if (isinstance(e, NotImplementedError) or @@ -476,7 +481,7 @@ class SubversionRepo(object): def get_revision(self, revision, editor): ''' feed the contents of the given revision to the given editor ''' reporter = self.remote.do_update(revision, '', True, - AbstractEditor(editor)) + BaseEditor(editor)) reporter.set_path('', revision, True) reporter.finish() 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 @@ -42,6 +42,7 @@ ERR_FS_NOT_FOUND = core.SVN_ERR_FS_NOT_F ERR_FS_TXN_OUT_OF_DATE = core.SVN_ERR_FS_TXN_OUT_OF_DATE ERR_INCOMPLETE_DATA = core.SVN_ERR_INCOMPLETE_DATA ERR_RA_DAV_REQUEST_FAILED = core.SVN_ERR_RA_DAV_REQUEST_FAILED +ERR_REPOS_HOOK_FAILURE = core.SVN_ERR_REPOS_HOOK_FAILURE SSL_UNKNOWNCA = core.SVN_AUTH_SSL_UNKNOWNCA SSL_CNMISMATCH = core.SVN_AUTH_SSL_CNMISMATCH SSL_NOTYETVALID = core.SVN_AUTH_SSL_NOTYETVALID @@ -436,13 +437,14 @@ class SubversionRepo(object): try: delta.path_driver(editor, edit_baton, base_revision, paths, driver_cb, self.pool) - editor.close_edit(edit_baton, self.pool) except: # If anything went wrong on the preceding lines, we should # abort the in-progress transaction. editor.abort_edit(edit_baton, self.pool) raise + editor.close_edit(edit_baton, self.pool) + def get_replay(self, revision, editor, oldest_rev_i_have=0): # this method has a tendency to chew through RAM if you don't re-init self.init_ra_and_client() diff --git a/hgsubversion/util.py b/hgsubversion/util.py --- a/hgsubversion/util.py +++ b/hgsubversion/util.py @@ -340,6 +340,11 @@ revsets = { 'svnrev': revset_svnrev, } +def revset_stringset(orig, repo, subset, x): + if x.startswith('r') and x[1:].isdigit(): + return revset_svnrev(repo, subset, ('string', x[1:])) + return orig(repo, subset, x) + def getfilestoresize(ui): """Return the replay or stupid file memory store size in megabytes or -1""" size = ui.configint('hgsubversion', 'filestoresize', 200) diff --git a/hgsubversion/verify.py b/hgsubversion/verify.py --- a/hgsubversion/verify.py +++ b/hgsubversion/verify.py @@ -1,3 +1,4 @@ +import difflib import posixpath from mercurial import util as hgutil @@ -38,6 +39,18 @@ def verify(ui, repo, args=None, **opts): ui.write('verifying %s against %s@%i\n' % (ctx, branchurl, srev)) + def diff_file(path, svndata): + fctx = ctx[path] + + if ui.verbose and not fctx.isbinary(): + svndesc = '%s/%s/%s@%d' % (svn.svn_url, branchpath, path, srev) + hgdesc = '%s@%s' % (path, ctx) + + for c in difflib.unified_diff(svndata.splitlines(True), + fctx.data().splitlines(True), + svndesc, hgdesc): + ui.note(c) + if opts.get('stupid', ui.configbool('hgsubversion', 'stupid')): svnfiles = set() result = 0 @@ -62,6 +75,7 @@ def verify(ui, repo, args=None, **opts): continue if not fctx.data() == data: ui.write('difference in: %s\n' % fn) + diff_file(fn, data) result = 1 if not fctx.flags() == mode: ui.write('wrong flags for: %s\n' % fn) @@ -154,6 +168,7 @@ def verify(ui, repo, args=None, **opts): if hgdata != svndata: self.ui.warn('difference in: %s\n' % self.file) + diff_file(self.file, svndata) self.failed = True if self.file is not None: diff --git a/hgsubversion/wrappers.py b/hgsubversion/wrappers.py --- a/hgsubversion/wrappers.py +++ b/hgsubversion/wrappers.py @@ -14,6 +14,8 @@ from mercurial import i18n from mercurial import extensions from mercurial import repair +import layouts +import os import replay import pushmod import stupid as stupidmod @@ -98,7 +100,15 @@ def incoming(orig, ui, repo, origsource= meta = repo.svnmeta(svn.uuid, svn.subdir) ui.status('incoming changes from %s\n' % other.svnurl) - for r in svn.revisions(start=meta.revmap.youngest): + svnrevisions = list(svn.revisions(start=meta.revmap.youngest)) + if opts.get('newest_first'): + svnrevisions.reverse() + # Returns 0 if there are incoming changes, 1 otherwise. + if len(svnrevisions) > 0: + ret = 0 + else: + ret = 1 + for r in svnrevisions: ui.status('\n') for label, attr in revmeta: l1 = label + ':' @@ -106,9 +116,11 @@ def incoming(orig, ui, repo, origsource= if not ui.verbose: val = val.split('\n')[0] ui.status('%s%s\n' % (l1.ljust(13), val)) + return ret -def findcommonoutgoing(repo, other, onlyheads=None, force=False, commoninc=None): +def findcommonoutgoing(repo, other, onlyheads=None, force=False, + commoninc=None, portable=False): assert other.capable('subversion') # split off #rev; TODO implement --revision/#rev support svn = other.svn @@ -179,6 +191,8 @@ def push(repo, dest, force, revs): checkpush(force, revs) ui = repo.ui old_encoding = util.swap_out_encoding() + + temporary_commits = [] try: # TODO: implement --rev/#rev support # TODO: do credentials specified in the URL still work? @@ -194,106 +208,129 @@ def push(repo, dest, force, revs): ui.status('searching for changes\n') hashes = meta.revmap.hashes() outgoing = util.outgoing_revisions(repo, hashes, workingrev.node()) - to_strip=[] if not (outgoing and len(outgoing)): ui.status('no changes found\n') return 1 # so we get a sane exit status, see hg's commands.push - while outgoing: - # 2. Commit oldest revision that needs to be pushed - oldest = outgoing.pop(-1) - old_ctx = repo[oldest] - old_pars = old_ctx.parents() - if len(old_pars) != 1: + tip_ctx = repo[outgoing[-1]].p1() + svnbranch = tip_ctx.branch() + modified_files = {} + for i in range(len(outgoing) - 1, -1, -1): + # 2. Pick the oldest changeset that needs to be pushed + current_ctx = repo[outgoing[i]] + original_ctx = current_ctx + + if len(current_ctx.parents()) != 1: ui.status('Found a branch merge, this needs discussion and ' 'implementation.\n') # results in nonzero exit status, see hg's commands.py return 0 - # We will commit to svn against this node's parent rev. Any - # file-level conflicts here will result in an error reported - # by svn. - base_ctx = old_pars[0] - base_revision = hashes[base_ctx.node()][0] - svnbranch = base_ctx.branch() - # Find most recent svn commit we have on this branch. This - # node will become the nearest known ancestor of the pushed - # rev. - oldtipctx = base_ctx - old_children = oldtipctx.descendants() - seen = set(c.node() for c in old_children) - samebranchchildren = [c for c in old_children - if c.branch() == svnbranch and c.node() in hashes] - if samebranchchildren: - # The following relies on descendants being sorted by rev. - oldtipctx = samebranchchildren[-1] - # All set, so commit now. + + # 3. Move the changeset to the tip of the branch if necessary + conflicts = False + for file in current_ctx.files(): + if file in modified_files: + conflicts = True + break + + if conflicts or current_ctx.branch() != svnbranch: + util.swap_out_encoding(old_encoding) + try: + def extrafn(ctx, extra): + extra['branch'] = ctx.branch() + + ui.status('rebasing %s onto %s \n' % (current_ctx, tip_ctx)) + hgrebase.rebase(ui, repo, + dest=node.hex(tip_ctx.node()), + rev=[node.hex(current_ctx.node())], + extrafn=extrafn, keep=True) + finally: + util.swap_out_encoding() + + # Don't trust the pre-rebase repo and context. + repo = getlocalpeer(ui, {}, meta.path) + tip_ctx = repo[tip_ctx.node()] + for c in tip_ctx.descendants(): + rebasesrc = c.extra().get('rebase_source') + if rebasesrc and node.bin(rebasesrc) == current_ctx.node(): + current_ctx = c + temporary_commits.append(c.node()) + break + + # 4. Push the changeset to subversion + tip_hash = hashes[tip_ctx.node()][0] try: - pushmod.commit(ui, repo, old_ctx, meta, base_revision, svn) + ui.status('committing %s\n' % current_ctx) + pushmod.commit(ui, repo, current_ctx, meta, tip_hash, svn) except pushmod.NoFilesException: ui.warn("Could not push revision %s because it had no changes " - "in svn.\n" % old_ctx) - return 1 + "in svn.\n" % current_ctx) + return - # 3. Fetch revisions from svn - # TODO: this probably should pass in the source explicitly - - # rev too? + # 5. Pull the latest changesets from subversion, which will + # include the one we just committed (and possibly others). r = repo.pull(dest, force=force) assert not r or r == 0 + meta = repo.svnmeta(svn.uuid, svn.subdir) + hashes = meta.revmap.hashes() - # 4. Find the new head of the target branch - # We expect to get our own new commit back, but we might - # also get other commits that happened since our last pull, - # or even right after our own commit (race). - for c in oldtipctx.descendants(): - if c.node() not in seen and c.branch() == svnbranch: - newtipctx = c - - # 5. Rebase all children of the currently-pushing rev to the - # new head - # - # there may be commits descended from the one we just - # pushed to svn that we aren't going to push to svn in - # this operation - oldhex = node.hex(old_ctx.node()) - needs_rebase_set = "%s:: and not(%s)" % (oldhex, oldhex) - def extrafn(ctx, extra): - extra['branch'] = ctx.branch() + # 6. Move our tip to the latest pulled tip + for c in tip_ctx.descendants(): + if c.node() in hashes and c.branch() == svnbranch: + tip_ctx = c + + # Remember what files have been modified since the + # whole push started. + for file in c.files(): + modified_files[file] = True + + # 7. Rebase any children of the commit we just pushed + # that are not in the outgoing set + for c in original_ctx.children(): + if not c.node() in hashes and not c.node() in outgoing: + util.swap_out_encoding(old_encoding) + try: + # Path changed as subdirectories were getting + # deleted during push. + saved_path = os.getcwd() + os.chdir(repo.root) + + def extrafn(ctx, extra): + extra['branch'] = ctx.branch() + + ui.status('rebasing non-outgoing %s onto %s\n' % (c, tip_ctx)) + needs_rebase_set = "%s::" % node.hex(c.node()) + hgrebase.rebase(ui, repo, + dest=node.hex(tip_ctx.node()), + rev=[needs_rebase_set], + extrafn=extrafn, keep=True) + finally: + os.chdir(saved_path) + util.swap_out_encoding() - util.swap_out_encoding(old_encoding) - try: - hgrebase.rebase(ui, repo, dest=node.hex(newtipctx.node()), - rev=[needs_rebase_set], - extrafn=extrafn, - # We actually want to strip one more rev than - # we're rebasing - keep=True) - finally: - util.swap_out_encoding() - - to_strip.append(old_ctx.node()) - # don't trust the pre-rebase repo. Do not reuse - # contexts across this. - newtip = newtipctx.node() - repo = getlocalpeer(ui, {}, meta.path) - newtipctx = repo[newtip] - - rebasemap = dict() - for child in newtipctx.descendants(): - rebasesrc = child.extra().get('rebase_source') - if rebasesrc: - rebasemap[node.bin(rebasesrc)] = child.node() - outgoing = [rebasemap.get(n) or n for n in outgoing] - meta = repo.svnmeta(svn.uuid, svn.subdir) - hashes = meta.revmap.hashes() util.swap_out_encoding(old_encoding) try: hg.update(repo, repo['tip'].node()) finally: util.swap_out_encoding() - repair.strip(ui, repo, to_strip, "all") + + # strip the original changesets since the push was successful + repair.strip(ui, repo, outgoing, "all") finally: - util.swap_out_encoding(old_encoding) + try: + # It's always safe to delete the temporary commits. + # The originals are not deleted unless the push + # completely succeeded. + if temporary_commits: + # If the repo is on a temporary commit, get off before + # the strip. + parent = repo[None].p1() + if parent.node() in temporary_commits: + hg.update(repo, parent.p1().node()) + repair.strip(ui, repo, temporary_commits, backup=None) + finally: + util.swap_out_encoding(old_encoding) return 1 # so we get a sane exit status, see hg's commands.push @@ -316,18 +353,11 @@ def pull(repo, source, heads=[], force=F stopat_rev = util.parse_revnum(svn, checkout) - layout = repo.ui.config('hgsubversion', 'layout', 'auto') + layout = layouts.detect.layout_from_config(repo.ui, allow_auto=True) if layout == 'auto': - try: - rootlist = svn.list_dir('', revision=(stopat_rev or None)) - except svnwrap.SubversionException, e: - err = "%s (subversion error: %d)" % (e.args[0], e.args[1]) - raise hgutil.Abort(err) - if sum(map(lambda x: x in rootlist, ('branches', 'tags', 'trunk'))): - layout = 'standard' - else: - layout = 'single' - repo.ui.setconfig('hgsubversion', 'layout', layout) + layout = layouts.detect.layout_from_subversion(svn, + (stopat_rev or None), + repo.ui) repo.ui.note('using %s layout\n' % layout) branch = repo.ui.config('hgsubversion', 'branch') @@ -375,12 +405,10 @@ def pull(repo, source, heads=[], force=F # start converting revisions firstrun = True for r in svn.revisions(start=start, stop=stopat_rev): - if r.revnum in skiprevs: - ui.status('[r%d SKIPPED]\n' % r.revnum) - continue - lastpulled = r.revnum - if (r.author is None and - r.message == 'This is an empty revision for padding.'): + if (r.revnum in skiprevs or + (r.author is None and + r.message == 'This is an empty revision for padding.')): + lastpulled = r.revnum continue tbdelta = meta.update_branch_tag_map_for_rev(r) # got a 502? Try more than once! @@ -429,6 +457,9 @@ def pull(repo, source, heads=[], force=F else: ui.traceback() raise hgutil.Abort(*e.args) + + lastpulled = r.revnum + except KeyboardInterrupt: ui.traceback() finally: diff --git a/setup.py b/setup.py --- a/setup.py +++ b/setup.py @@ -118,7 +118,8 @@ setup( long_description=open(os.path.join(os.path.dirname(__file__), 'README')).read(), keywords='mercurial', - packages=('hgsubversion', 'hgsubversion.hooks', 'hgsubversion.svnwrap'), + packages=('hgsubversion', 'hgsubversion.hooks', 'hgsubversion.layouts', + 'hgsubversion.svnwrap'), package_data={ 'hgsubversion': ['help/subversion.rst'] }, platforms='any', install_requires=requires, diff --git a/tests/run.py b/tests/run.py --- a/tests/run.py +++ b/tests/run.py @@ -19,6 +19,7 @@ def tests(): import test_fetch_symlinks import test_fetch_truncated import test_hooks + import test_svn_pre_commit_hooks import test_pull import test_pull_fallback import test_push_command diff --git a/tests/test_push_command.py b/tests/test_push_command.py --- a/tests/test_push_command.py +++ b/tests/test_push_command.py @@ -522,6 +522,109 @@ class PushTests(test_util.TestBase): self.pushrevisions() self.assertEqual(['alpha'], list(self.repo['tip'].manifest())) + def test_push_without_pushing_children(self): + ''' + Verify that a push of a nontip node, keeps the tip child + on top of the pushed commit. + ''' + + oldlen = len(self.repo) + oldtiphash = self.repo['default'].node() + + changes = [('gamma', 'gamma', 'sometext')] + newhash1 = self.commitchanges(changes) + + changes = [('delta', 'delta', 'sometext')] + newhash2 = self.commitchanges(changes) + + # push only the first commit + repo = self.repo + hg.update(repo, newhash1) + commands.push(repo.ui, repo) + self.assertEqual(len(self.repo), oldlen + 2) + + # verify that the first commit is pushed, and the second is not + commit2 = self.repo['tip'] + self.assertEqual(commit2.files(), ['delta', ]) + self.assertTrue(commit2.mutable()) + commit1 = commit2.parents()[0] + self.assertEqual(commit1.files(), ['gamma', ]) + self.assertFalse(commit1.mutable()) + + def test_push_two_that_modify_same_file(self): + ''' + Push performs a rebase if two commits touch the same file. + This test verifies that code path works. + ''' + + oldlen = len(self.repo) + oldtiphash = self.repo['default'].node() + + changes = [('gamma', 'gamma', 'sometext')] + newhash = self.commitchanges(changes) + changes = [('gamma', 'gamma', 'sometext\n moretext'), + ('delta', 'delta', 'sometext\n moretext'), + ] + newhash = self.commitchanges(changes) + + repo = self.repo + hg.update(repo, newhash) + commands.push(repo.ui, repo) + self.assertEqual(len(self.repo), oldlen + 2) + + # verify that both commits are pushed + commit1 = self.repo['tip'] + self.assertEqual(commit1.files(), ['delta', 'gamma']) + self.assertFalse(commit1.mutable()) + commit2 = commit1.parents()[0] + self.assertEqual(commit2.files(), ['gamma']) + self.assertFalse(commit2.mutable()) + + def test_push_in_subdir(self, commit=True): + repo = self.repo + old_tip = repo['tip'].node() + def file_callback(repo, memctx, path): + if path == 'adding_file' or path == 'newdir/new_file': + testData = 'fooFirstFile' + if path == 'newdir/new_file': + testData = 'fooNewFile' + return context.memfilectx(path=path, + data=testData, + islink=False, + isexec=False, + copied=False) + raise IOError(errno.EINVAL, 'Invalid operation: ' + path) + ctx = context.memctx(repo, + (repo['default'].node(), node.nullid), + 'automated test', + ['adding_file'], + file_callback, + 'an_author', + '2012-12-13 20:59:48 -0500', + {'branch': 'default', }) + new_hash = repo.commitctx(ctx) + p = os.path.join(repo.root, "newdir") + os.mkdir(p) + ctx = context.memctx(repo, + (repo['default'].node(), node.nullid), + 'automated test', + ['newdir/new_file'], + file_callback, + 'an_author', + '2012-12-13 20:59:48 -0500', + {'branch': 'default', }) + os.chdir(p) + new_hash = repo.commitctx(ctx) + hg.update(repo, repo['tip'].node()) + self.pushrevisions() + tip = self.repo['tip'] + self.assertNotEqual(tip.node(), old_tip) + self.assertEqual(p, os.getcwd()) + self.assertEqual(tip['adding_file'].data(), 'fooFirstFile') + self.assertEqual(tip['newdir/new_file'].data(), 'fooNewFile') + self.assertEqual(tip.branch(), 'default') + + def suite(): test_classes = [PushTests, ] all_tests = [] diff --git a/tests/test_svn_pre_commit_hooks.py b/tests/test_svn_pre_commit_hooks.py new file mode 100644 --- /dev/null +++ b/tests/test_svn_pre_commit_hooks.py @@ -0,0 +1,34 @@ +import os +import sys +import test_util +import unittest + +from mercurial import hg +from mercurial import commands +from mercurial import util + + +class TestSvnPreCommitHooks(test_util.TestBase): + def setUp(self): + super(TestSvnPreCommitHooks, self).setUp() + self.repo_path = self.load_and_fetch('single_rev.svndump')[1] + # creating pre-commit hook that doesn't allow any commit + hook_file_name = os.path.join( + self.repo_path, 'hooks', 'pre-commit' + ) + hook_file = open(hook_file_name, 'w') + hook_file.write( + '#!/bin/sh\n' + 'echo "Commits are not allowed" >&2; exit 1;\n' + ) + hook_file.close() + os.chmod(hook_file_name, 0755) + + def test_push_with_pre_commit_hooks(self): + changes = [('narf/a', 'narf/a', 'ohai',), + ] + self.commitchanges(changes) + self.assertRaises(util.Abort, self.pushrevisions) + +def suite(): + return unittest.findTestCases(sys.modules[__name__]) diff --git a/tests/test_util.py b/tests/test_util.py --- a/tests/test_util.py +++ b/tests/test_util.py @@ -22,6 +22,7 @@ from mercurial import dispatch as dispat from mercurial import hg from mercurial import i18n from mercurial import node +from mercurial import scmutil from mercurial import ui from mercurial import util from mercurial import extensions @@ -258,7 +259,9 @@ class TestBase(unittest.TestCase): 'svnwrap_test', dir=os.environ.get('HGSUBVERSION_TEST_TEMP', None)) self.hgrc = os.path.join(self.tmpdir, '.hgrc') os.environ['HGRCPATH'] = self.hgrc + scmutil._rcpath = None rc = open(self.hgrc, 'w') + rc.write('[ui]\nusername=test-user\n') for l in '[extensions]', 'hgsubversion=': print >> rc, l @@ -543,4 +546,3 @@ files: {files} def draw(self, repo): sys.stdout.write(self.getgraph(repo)) - diff --git a/tests/test_utility_commands.py b/tests/test_utility_commands.py --- a/tests/test_utility_commands.py +++ b/tests/test_utility_commands.py @@ -102,6 +102,9 @@ class UtilityTests(test_util.TestBase): def test_missing_metadata(self): self._load_fixture_and_fetch('two_heads.svndump') + os.remove(self.repo.join('svn/branch_info')) + svncommands.updatemeta(self.ui(), self.repo, []) + test_util.rmtree(self.repo.join('svn')) self.assertRaises(hgutil.Abort, self.repo.svnmeta) @@ -338,7 +341,7 @@ missing file: binary3 # rebuildmeta --unsafe-skip-uuid-check with unrelated repo svncommands.rebuildmeta(self.ui(), repo=self.repo, args=[otherurl], unsafe_skip_uuid_check=True) - + def suite(): all_tests = [unittest.TestLoader().loadTestsFromTestCase(UtilityTests), ]