# HG changeset patch # User Augie Fackler # Date 1222792972 18000 # Node ID f2636cfed11500fdc47d1e3822d8e4a2bd636bf7 Initial import of hgsubversion into a public repository. diff --git a/.hgignore b/.hgignore new file mode 100644 --- /dev/null +++ b/.hgignore @@ -0,0 +1,3 @@ +syntax:glob +*.pyc +.DS_Store diff --git a/README b/README new file mode 100644 --- /dev/null +++ b/README @@ -0,0 +1,3 @@ +hgsubversion is an extension for Mercurial that allows using Mercurial as a Subversion client. + +Right now it is *not* ready for production use. You should only be using this if you're ready to hack on it, and go diving into the internals of Mercurial and/or Subversion. diff --git a/TODO b/TODO new file mode 100644 --- /dev/null +++ b/TODO @@ -0,0 +1,16 @@ +Handle directory adds. +Handle symlinks in commit. +Handle execute in commit. +Handle auto-props on file adds. +Handle directory deletes. +Test coverage is horrible. Figure out a strategy to have some real tests. +Be more explicit about deletes in diff-replay because then we can probably +handle a rename like ``mv Foo foo '' properly. +Use replay_range on svn 1.5, it will make replay-over-serf faster. +Add convertedrev to extra on hg-side commit. + +Known upstream issues: +Using serf as the http library causes no revisions to convert. (svn trunk and 1.5.x branch as of +r33158 both have this. No idea bout 1.4.x, since I always just use neon there). +This is fixed in Subversion trunk in revision 33173. I don't know if it has been backported to +the 1.5.x branch yet. diff --git a/__init__.py b/__init__.py new file mode 100644 --- /dev/null +++ b/__init__.py @@ -0,0 +1,31 @@ +from mercurial import commands +from mercurial import hg + +import svncommand +import fetch_command + +def svn(ui, repo, subcommand, *args, **opts): + return svncommand.svncmd(ui, repo, subcommand, *args, **opts) + +def svn_fetch(ui, svn_url, hg_repo_path=None, **opts): + if not hg_repo_path: + hg_repo_path = hg.defaultdest(svn_url) + "-hg" + ui.status("Assuming destination %s\n" % hg_repo_path) + return fetch_command.fetch_revisions(ui, svn_url, hg_repo_path, **opts) + +commands.norepo += " svnclone" +cmdtable = { + "svn": + (svn, + [('u', 'svn_url', '', 'Path to the Subversion server.'), + ('', 'stupid', False, 'Be stupid and use diffy replay.'), + ], + 'hg svn subcommand'), + "svnclone" :(svn_fetch, + [('S', 'skipto_rev', '0', 'Skip commits before this revision.'), + ('', 'stupid', False, 'Be stupid and use diffy replay.'), + ('T', 'tag_locations', 'tags', 'Relative path to where tags get ' + 'stored, as comma sep. values if there is more than one such path.') + ], + 'hg svn_fetch svn_url, dest'), +} diff --git a/fetch_command.py b/fetch_command.py new file mode 100644 --- /dev/null +++ b/fetch_command.py @@ -0,0 +1,447 @@ +import cStringIO +import re +import operator +import os +import shutil +import stat +import tempfile + +from mercurial import patch +from mercurial import node +from mercurial import context +from mercurial import revlog +from svn import core +from svn import delta + +import hg_delta_editor +import svnwrap +import util + + +def print_your_svn_is_old_message(ui): + ui.status("In light of that, I'll fall back and do diffs, but it won't do " + "as good a job. You should really upgrade your server.") + + +@util.register_subcommand('pull') +def fetch_revisions(ui, svn_url, hg_repo_path, skipto_rev=0, stupid=None, + tag_locations='tags', + **opts): + """Pull new revisions from Subversion. + """ + skipto_rev=int(skipto_rev) + have_replay = not stupid + if have_replay and not callable(delta.svn_txdelta_apply(None, None, + None)[0]): + ui.status('You are using old Subversion SWIG bindings. Replay will not' + ' work until you upgrade to 1.5.0 or newer. Falling back to' + ' a slower method that may be buggier. Please upgrade, or' + ' contribute a patch to use the ctypes bindings instead' + ' of SWIG.') + have_replay = False + initializing_repo = False + svn = svnwrap.SubversionRepo(svn_url) + author_host = "@%s" % svn.uuid + tag_locations = tag_locations.split(',') + hg_editor = hg_delta_editor.HgChangeReceiver(hg_repo_path, + ui_=ui, + subdir=svn.subdir, + author_host=author_host, + tag_locations=tag_locations) + if os.path.exists(hg_editor.uuid_file): + uuid = open(hg_editor.uuid_file).read() + assert uuid == svn.uuid + start = int(open(hg_editor.last_revision_handled_file, 'r').read()) + else: + open(hg_editor.uuid_file, 'w').write(svn.uuid) + open(hg_editor.svn_url_file, 'w').write(svn_url) + open(hg_editor.last_revision_handled_file, 'w').write(str(0)) + initializing_repo = True + start = skipto_rev + + # start converting revisions + for r in svn.revisions(start=start): + valid = False + hg_editor.update_branch_tag_map_for_rev(r) + for p in r.paths: + if hg_editor._is_path_valid(p): + valid = True + continue + if initializing_repo and start > 0: + assert False, 'This feature not ready yet.' + if valid: + # got a 502? Try more than once! + tries = 0 + converted = False + while not converted and tries < 3: + try: + ui.status('converting %s\n' % r) + if have_replay: + try: + replay_convert_rev(hg_editor, svn, r) + except svnwrap.SubversionRepoCanNotReplay, e: + ui.status('%s\n' % e.message) + print_your_svn_is_old_message(ui) + have_replay = False + stupid_svn_server_pull_rev(ui, svn, hg_editor, r) + else: + stupid_svn_server_pull_rev(ui, svn, hg_editor, r) + converted = True + open(hg_editor.last_revision_handled_file, + 'w').write(str(r.revnum)) + except core.SubversionException, e: + if hasattr(e, 'message') and ( + 'Server sent unexpected return value (502 Bad Gateway)' + ' in response to PROPFIND') in e.message: + tries += 1 + ui.status('Got a 502, retrying (%s)\n' % tries) + else: + raise + + +def replay_convert_rev(hg_editor, svn, r): + hg_editor.set_current_rev(r) + svn.get_replay(r.revnum, hg_editor) + if hg_editor.missing_plaintexts: + files_to_grab = set() + dirs_to_list = [] + props = {} + for p in hg_editor.missing_plaintexts: + p2 = p + if svn.subdir: + p2 = p2[len(svn.subdir)-1:] + # this *sometimes* raises on me, and I have + # no idea why. TODO(augie) figure out the why. + try: + pl = svn.proplist(p2, r.revnum, recurse=True) + except core.SubversionException, e: + pass + props.update(pl) + if p[-1] == '/': + dirs_to_list.append(p) + else: + files_to_grab.add(p) + while dirs_to_list: + p = dirs_to_list.pop(0) + l = svn.list_dir(p[:-1], r.revnum) + for f in l: + + if l[f].kind == core.svn_node_dir: + dirs_to_list.append(p+f+'/') + elif l[f].kind == core.svn_node_file: + files_to_grab.add(p+f) + for p in files_to_grab: + p2 = p + if svn.subdir: + p2 = p2[len(svn.subdir)-1:] + hg_editor.current_files[p] = svn.get_file(p2, r.revnum) + hg_editor.current_files_exec[p] = False + if p in props: + if 'svn:executable' in props[p]: + hg_editor.current_files_exec[p] = True + if 'svn:special' in props[p]: + hg_editor.current_files_symlink[p] = True + hg_editor.missing_plaintexts = set() + hg_editor.commit_current_delta() + + +binary_file_re = re.compile(r'''Index: ([^\n]*) +=* +Cannot display: file marked as a binary type.''') + +property_exec_set_re = re.compile(r'''Property changes on: ([^\n]*) +_* +Added: svn:executable + \+ \* +''') + +property_exec_removed_re = re.compile(r'''Property changes on: ([^\n]*) +_* +Deleted: svn:executable + - \* +''') + +empty_file_patch_wont_make_re = re.compile(r'''Index: ([^\n]*)\n=*\n(?=Index:)''') + +any_file_re = re.compile(r'''^Index: ([^\n]*)\n=*\n''', re.MULTILINE) + +property_special_set_re = re.compile(r'''Property changes on: ([^\n]*) +_* +Added: svn:special + \+ \* +''') + +property_special_removed_re = re.compile(r'''Property changes on: ([^\n]*) +_* +Added: svn:special + \- \* +''') + +def make_diff_path(b): + if b == None: + return 'trunk' + return 'branches/' + b + + +def stupid_svn_server_pull_rev(ui, svn, hg_editor, r): + used_diff = True + delete_all_files = False + # this server fails at replay + branches = hg_editor.branches_in_paths(r.paths) + temp_location = os.path.join(hg_editor.path, '.hg', 'svn', 'temp') + if not os.path.exists(temp_location): + os.makedirs(temp_location) + for b in branches: + our_tempdir = tempfile.mkdtemp('svn_fetch_temp', dir=temp_location) + diff_path = make_diff_path(b) + parent_rev, br_p = hg_editor.get_parent_svn_branch_and_rev(r.revnum, b) + parent_ha = hg_editor.get_parent_revision(r.revnum, b) + files_touched = set() + link_files = {} + exec_files = {} + try: + if br_p == b: + d = svn.get_unified_diff(diff_path, r.revnum, deleted=False, + # letting patch handle binaries sounded + # cool, but it breaks patch in sad ways + ignore_type=False) + else: + d = svn.get_unified_diff(diff_path, r.revnum, + other_path=make_diff_path(br_p), + other_rev=parent_rev, + deleted=True, ignore_type=True) + if d: + ui.status('Branch creation with mods, pulling full rev.\n') + raise BadPatchApply() + for m in binary_file_re.findall(d): + # we have to pull each binary file by hand as a fulltext, + # which sucks but we've got no choice + file_path = os.path.join(our_tempdir, m) + files_touched.add(m) + try: + try: + os.makedirs(os.path.dirname(file_path)) + except OSError, e: + pass + f = open(file_path, 'w') + f.write(svn.get_file(diff_path+'/'+m, r.revnum)) + f.close() + except core.SubversionException, e: + if (e.message.endswith("' path not found") + or e.message.startswith("File not found: revision")): + pass + else: + raise + d2 = empty_file_patch_wont_make_re.sub('', d) + d2 = property_exec_set_re.sub('', d2) + d2 = property_exec_removed_re.sub('', d2) + old_cwd = os.getcwd() + os.chdir(our_tempdir) + for f in any_file_re.findall(d): + files_touched.add(f) + # this check is here because modified binary files will get + # created before here. + if os.path.exists(f): + continue + dn = os.path.dirname(f) + if dn and not os.path.exists(dn): + os.makedirs(dn) + if f in hg_editor.repo[parent_ha].manifest(): + data = hg_editor.repo[parent_ha].filectx(f).data() + fi = open(f, 'w') + fi.write(data) + fi.close() + else: + open(f, 'w').close() + if f.startswith(our_tempdir): + f = f[len(our_tempdir)+1:] + os.chdir(old_cwd) + if d2.strip() and len(re.findall('\n[-+]', d2.strip())) > 0: + old_cwd = os.getcwd() + os.chdir(our_tempdir) + changed = {} + try: + patch_st = patch.applydiff(ui, cStringIO.StringIO(d2), + changed, strip=0) + except patch.PatchError: + # TODO: this happens if the svn server has the wrong mime + # type stored and doesn't know a file is binary. It would + # be better to do one file at a time and only do a + # full fetch on files that had problems. + os.chdir(old_cwd) + raise BadPatchApply() + for x in changed.iterkeys(): + ui.status('M %s\n' % x) + files_touched.add(x) + os.chdir(old_cwd) + # if this patch didn't apply right, fall back to exporting the + # entire rev. + if patch_st == -1: + parent_ctx = hg_editor.repo[parent_ha] + parent_manifest = parent_ctx.manifest() + for fn in files_touched: + if (fn in parent_manifest and + 'l' in parent_ctx.filectx(fn).flags()): + # I think this might be an underlying bug in svn - + # I get diffs of deleted symlinks even though I + # specifically said no deletes above. + ui.status('Pulling whole rev because of a deleted' + 'symlink') + raise BadPatchApply() + assert False, ('This should only happen on case-insensitive' + ' volumes.') + elif patch_st == 1: + # When converting Django, I saw fuzz on .po files that was + # causing revisions to end up failing verification. If that + # can be fixed, maybe this won't ever be reached. + ui.status('There was some fuzz, not using diff after all.') + raise BadPatchApply() + else: + ui.status('Not using patch for %s, diff had no hunks.\n' % + r.revnum) + + # we create the files if they don't exist here because we know + # that we'll never have diff info for a deleted file, so if the + # property is set, we should force the file to exist no matter what. + for m in property_exec_removed_re.findall(d): + f = os.path.join(our_tempdir, m) + if not os.path.exists(f): + d = os.path.dirname(f) + if not os.path.exists(d): + os.makedirs(d) + if not m in hg_editor.repo[parent_ha].manifest(): + open(f, 'w').close() + else: + data = hg_editor.repo[parent_ha].filectx(m).data() + fp = open(f, 'w') + fp.write(data) + fp.close() + exec_files[m] = False + files_touched.add(m) + for m in property_exec_set_re.findall(d): + f = os.path.join(our_tempdir, m) + if not os.path.exists(f): + d = os.path.dirname(f) + if not os.path.exists(d): + os.makedirs(d) + if m not in hg_editor.repo[parent_ha].manifest(): + open(f, 'w').close() + else: + data = hg_editor.repo[parent_ha].filectx(m).data() + fp = open(f, 'w') + fp.write(data) + fp.close() + exec_files[m] = True + files_touched.add(m) + for m in property_special_set_re.findall(d): + # TODO(augie) when a symlink is removed, patching will fail. + # We're seeing that above - there's gotta be a better + # workaround than just bailing like that. + path = os.path.join(our_tempdir, m) + assert os.path.exists(path) + link_path = open(path).read() + link_path = link_path[len('link '):] + os.remove(path) + link_files[m] = link_path + files_touched.add(m) + except core.SubversionException, e: + if (e.apr_err == 160013 or (hasattr(e, 'message') and + 'was not found in the repository at revision ' in e.message)): + # Either this revision or the previous one does not exist. + try: + ui.status("fetching entire rev previous rev does not exist.\n") + used_diff = False + svn.fetch_all_files_to_dir(diff_path, r.revnum, our_tempdir) + except core.SubversionException, e: + if e.apr_err == 170000 or (e.message.startswith("URL '") + and e.message.endswith("' doesn't exist")): + delete_all_files = True + else: + raise + + except BadPatchApply, e: + # previous rev didn't exist, so this is most likely the first + # revision. We'll have to pull all files by hand. + try: + ui.status("fetching entire rev because raised.\n") + used_diff = False + shutil.rmtree(our_tempdir) + os.makedirs(our_tempdir) + svn.fetch_all_files_to_dir(diff_path, r.revnum, our_tempdir) + except core.SubversionException, e: + if e.apr_err == 170000 or (e.message.startswith("URL '") + and e.message.endswith("' doesn't exist")): + delete_all_files = True + else: + raise + for p in r.paths: + if p.startswith(diff_path) and r.paths[p].action == 'D': + p2 = p[len(diff_path)+1:] + files_touched.add(p2) + p3 = os.path.join(our_tempdir, p2) + if os.path.exists(p3) and not os.path.isdir(p3): + os.unlink(p3) + if p2 and p2[0] == '/': + p2 = p2[1:] + # If this isn't in the parent ctx, it must've been a dir + if not p2 in hg_editor.repo[parent_ha]: + d_files = [f for f in hg_editor.repo[parent_ha].manifest().iterkeys() + if f.startswith(p2 + '/')] + for d in d_files: + files_touched.add(d) + if delete_all_files: + for p in hg_editor.repo[parent_ha].manifest().iterkeys(): + files_touched.add(p) + if not used_diff: + for p in reduce(operator.add, [[os.path.join(x[0], y) for y in x[2]] + for x in + list(os.walk(our_tempdir))]): + p_real = p[len(our_tempdir)+1:] + if os.path.islink(p): + link_files[p_real] = os.readlink(p) + exec_files[p_real] = (os.lstat(p).st_mode & 0100 != 0) + files_touched.add(p_real) + for p in hg_editor.repo[parent_ha].manifest().iterkeys(): + # TODO this might not be a required step. + files_touched.add(p) + date = r.date.replace('T', ' ').replace('Z', '').split('.')[0] + date += ' -0000' + def filectxfn(repo, memctx, path): + disk_path = os.path.join(our_tempdir, path) + if path in link_files: + return context.memfilectx(path=path, data=link_files[path], + islink=True, isexec=False, + copied=False) + fp = open(disk_path) + exe = exec_files.get(path, None) + if exe is None and path in hg_editor.repo[parent_ha]: + exe = 'x' in hg_editor.repo[parent_ha].filectx(path).flags() + return context.memfilectx(path=path, data=fp.read(), islink=False, + isexec=exe, copied=False) + extra = {} + if b: + extra['branch'] = b + if parent_ha != node.nullid or files_touched: + # TODO(augie) remove this debug code? Or maybe it's sane to have it. + for f in files_touched: + if f: + assert f[0] != '/' + current_ctx = context.memctx(hg_editor.repo, + [parent_ha, revlog.nullid], + r.message or '...', + files_touched, + filectxfn, + '%s%s' % (r.author, + hg_editor.author_host), + date, + extra) + ha = hg_editor.repo.commitctx(current_ctx) + hg_editor.revmap[r.revnum, b] = ha + hg_editor._save_metadata() + ui.status('committed as %s on branch %s\n' % + (node.hex(ha), b or 'default')) + shutil.rmtree(our_tempdir) + + +class BadPatchApply(Exception): + pass diff --git a/hg_delta_editor.py b/hg_delta_editor.py new file mode 100644 --- /dev/null +++ b/hg_delta_editor.py @@ -0,0 +1,614 @@ +import cStringIO +import cPickle as pickle +import os +import sys +import tempfile +import traceback + +from mercurial import context +from mercurial import hg +from mercurial import ui +from mercurial import revlog +from mercurial import node +from svn import delta +from svn import core + +def pickle_atomic(data, file_path, dir=None): + """pickle some data to a path atomically. + + This is present because I kept corrupting my revmap by managing to hit ^C + during the pickle of that file. + """ + try: + f, path = tempfile.mkstemp(prefix='pickling', dir=dir) + f = os.fdopen(f, 'w') + pickle.dump(data, f) + f.close() + except: + raise + else: + os.rename(path, file_path) + +def stash_exception_on_self(fn): + """Stash any exception raised in the method on self. + + This is required because the SWIG bindings just mutate any exception into + a generic Subversion exception with no way of telling what the original was. + This allows the editor object to notice when you try and commit and really + got an exception in the replay process. + """ + def fun(self, *args, **kwargs): + try: + return fn(self, *args, **kwargs) + except: + if not hasattr(self, '_exception_info'): + self._exception_info = sys.exc_info() + raise + return fun + + +class HgChangeReceiver(delta.Editor): + def __init__(self, path, ui_=None, subdir='', author_host='', + tag_locations=['tags']): + """path is the path to the target hg repo. + + subdir is the subdirectory of the edits *on the svn server*. + It is needed for stripping paths off in certain cases. + """ + if not ui_: + ui_ = ui.ui() + self.ui = ui_ + self.path = path + self.__setup_repo(path) + self.subdir = subdir + if self.subdir and self.subdir[0] == '/': + self.subdir = self.subdir[1:] + self.revmap = {} + if os.path.exists(self.revmap_file): + f = open(self.revmap_file) + self.revmap = pickle.load(f) + f.close() + self.branches = {} + if os.path.exists(self.branch_info_file): + f = open(self.branch_info_file) + self.branches = pickle.load(f) + f.close() + self.tags = {} + if os.path.exists(self.tag_info_file): + f = open(self.tag_info_file) + self.tags = pickle.load(f) + f.close() + if os.path.exists(self.tag_locations_file): + f = open(self.tag_locations_file) + self.tag_locations = pickle.load(f) + else: + self.tag_locations = tag_locations + pickle_atomic(self.tag_locations, self.tag_locations_file, + self.meta_data_dir) + + self.clear_current_info() + self.author_host = author_host + + def __setup_repo(self, repo_path): + '''Verify the repo is going to work out for us. + + This method will fail an assertion if the repo exists but doesn't have + the Subversion metadata. + ''' + if os.path.isdir(repo_path) and len(os.listdir(repo_path)): + self.repo = hg.repository(self.ui, repo_path) + assert os.path.isfile(self.revmap_file) + assert os.path.isfile(self.svn_url_file) + assert os.path.isfile(self.uuid_file) + assert os.path.isfile(self.last_revision_handled_file) + else: + self.repo = hg.repository(self.ui, repo_path, create=True) + os.makedirs(os.path.dirname(self.uuid_file)) + + def clear_current_info(self): + '''Clear the info relevant to a replayed revision so that the next + revision can be replayed. + ''' + self.current_files = {} + self.deleted_files = {} + self.current_rev = None + self.current_files_exec = {} + self.current_files_symlink = {} + self.missing_plaintexts = set() + self.commit_branches_empty = {} + self.base_revision = None + + def _save_metadata(self): + '''Save the Subversion metadata. This should really be called after + every revision is created. + ''' + pickle_atomic(self.revmap, self.revmap_file, self.meta_data_dir) + pickle_atomic(self.branches, self.branch_info_file, self.meta_data_dir) + pickle_atomic(self.tags, self.tag_info_file, self.meta_data_dir) + + def branches_in_paths(self, paths): + '''Given a list of paths, return the set of branches that are touched. + ''' + branches = set([]) + for p in paths: + if self._is_path_valid(p): + junk, branch = self._path_and_branch_for_path(p) + branches.add(branch) + return branches + + def _path_and_branch_for_path(self, path): + '''Figure out which branch inside our repo this path represents, and + also figure out which path inside that branch it is. + + Raises an exception if it can't perform its job. + ''' + path = self._normalize_path(path) + if path.startswith('trunk'): + p = path[len('trunk'):] + if p and p[0] == '/': + p = p[1:] + return p, None + elif path.startswith('branches/'): + p = path[len('branches/'):] + br = p.split('/')[0] + p = p[len(br)+1:] + if p and p[0] == '/': + p = p[1:] + return p, br + raise Exception,'Things went boom: ' + path + + def set_current_rev(self, rev): + '''Set the revision we're currently converting. + ''' + self.current_rev = rev + + def _normalize_path(self, path): + '''Normalize a path to strip of leading slashes and our subdir if we + have one. + ''' + if path and path[0] == '/': + path = path[1:] + if path and path.startswith(self.subdir): + path = path[len(self.subdir):] + if path and path[0] == '/': + path = path[1:] + return path + + def _is_path_valid(self, path): + path = self._normalize_path(path) + if path.startswith('trunk'): + return True + elif path.startswith('branches/'): + br = path.split('/')[1] + return len(br) > 0 + return False + + def _is_path_tag(self, path): + """If path represents the path to a tag, returns the tag name. + + Otherwise, returns False. + """ + path = self._normalize_path(path) + for tags_path in self.tag_locations: + if path and (path.startswith(tags_path) and + len(path) > len('%s/' % tags_path)): + return path[len(tags_path)+1:].split('/')[0] + return False + + def get_parent_svn_branch_and_rev(self, number, branch): + number -= 1 + if (number, branch) in self.revmap: + return number, branch + real_num = 0 + for num, br in self.revmap.iterkeys(): + if br != branch: + continue + if num <= number and num > real_num: + real_num = num + if real_num == 0: + if branch in self.branches: + parent_branch = self.branches[branch][0] + parent_branch_rev = self.branches[branch][1] + branch_created_rev = self.branches[branch][2] + if parent_branch == 'trunk': + parent_branch = None + if branch_created_rev <= number+1 and branch != parent_branch: + return self.get_parent_svn_branch_and_rev( + parent_branch_rev+1, + parent_branch) + if real_num != 0: + return real_num, branch + return None, None + + def get_parent_revision(self, number, branch): + '''Get the parent revision hash for a commit on a specific branch. + ''' + r, br = self.get_parent_svn_branch_and_rev(number, branch) + if r is not None: + return self.revmap[r, br] + return revlog.nullid + + def update_branch_tag_map_for_rev(self, revision): + paths = revision.paths + added_branches = {} + added_tags = {} + tags_to_delete = set() + for p in paths: + if self._is_path_valid(p): + fi, br = self._path_and_branch_for_path(p) + if fi == '' and br not in self.branches: + # TODO handle creating a branch from a tag + src_p = paths[p].copyfrom_path + src_rev = paths[p].copyfrom_rev + src_tag = self._is_path_tag(src_p) + + if not src_p or not (self._is_path_valid(src_p) or src_tag): + # we'll imply you're a branch off of trunk + # if you have no path, but if you do, it must be valid + # or else we assume trunk as well + src_branch = None + src_rev = revision.revnum + elif src_tag: + # this is a branch created from a tag. Note that this + # really does happen (see Django) + src_branch, src_rev = self.tags[src_tag] + added_branches[br] = (src_branch, src_rev, + revision.revnum) + else: + # Not from a tag, and from a valid repo path + (src_p, + src_branch) = self._path_and_branch_for_path(src_p) + added_branches[br] = src_branch, src_rev, revision.revnum + elif br in added_branches: + if paths[p].copyfrom_rev > added_branches[br][1]: + x,y,z = added_branches[br] + added_branches[br] = x, paths[p].copyfrom_rev, z + else: + t_name = self._is_path_tag(p) + if t_name == False: + continue + src_p, src_rev = paths[p].copyfrom_path, paths[p].copyfrom_rev + # if you commit to a tag, I'm calling you stupid and ignoring + # you. + if src_p is not None and src_rev is not None: + if self._is_path_valid(src_p): + file, branch = self._path_and_branch_for_path(src_p) + else: + # some crazy people make tags from other tags + file = '' + from_tag = self._is_path_tag(src_p) + if not from_tag: + continue + branch, src_rev = self.tags[from_tag] + if t_name not in added_tags: + added_tags[t_name] = branch, src_rev + elif file and src_rev > added_tags[t_name][1]: + added_tags[t_name] = branch, src_rev + elif (paths[p].action == 'D' and p.endswith(t_name) + and t_name in self.tags): + tags_to_delete.add(t_name) + for t in tags_to_delete: + del self.tags[t] + self.tags.update(added_tags) + self.branches.update(added_branches) + + def commit_current_delta(self): + if hasattr(self, '_exception_info'): + traceback.print_exception(*self._exception_info) + raise ReplayException() + if self.missing_plaintexts: + raise MissingPlainTextError() + files_to_commit = self.current_files.keys() + files_to_commit.extend(self.current_files_symlink.keys()) + files_to_commit.extend(self.current_files_exec.keys()) + files_to_commit = sorted(list(set(files_to_commit))) + branch_batches = {} + rev = self.current_rev + date = rev.date.replace('T', ' ').replace('Z', '').split('.')[0] + date += ' -0000' + + # build up the branches that have files on them + for f in files_to_commit: + if not self._is_path_valid(f): + continue + p, b = self._path_and_branch_for_path(f) + if b not in branch_batches: + branch_batches[b] = [] + branch_batches[b].append((p, f)) + + for branch, files in branch_batches.iteritems(): + if branch in self.commit_branches_empty and files: + del self.commit_branches_empty[branch] + extra = {} + files = dict(files) + + parents = (self.get_parent_revision(rev.revnum, branch), + revlog.nullid) + if branch is not None: + if branch not in self.branches: + continue + if parents == (revlog.nullid, revlog.nullid): + assert False, ('a non-trunk branch should probably have' + ' parents figured out by this point') + extra['branch'] = branch + parent_ctx = self.repo.changectx(parents[0]) + def filectxfn(repo, memctx, path): + is_link = False + is_exec = False + copied = None + current_file = files[path] + if current_file in self.deleted_files: + raise IOError() + # TODO(augie) tag copies from files + if path in parent_ctx: + is_exec = 'x' in parent_ctx.flags(path) + is_link = 'l' in parent_ctx.flags(path) + if current_file in self.current_files_exec: + is_exec = self.current_files_exec[current_file] + if current_file in self.current_files_symlink: + is_link = self.current_files_symlink[current_file] + if current_file in self.current_files: + data = self.current_files[current_file] + if is_link: + assert data.startswith('link ') + data = data[len('link '):] + else: + data = parent_ctx.filectx(path).data() + return context.memfilectx(path=path, + data=data, + islink=is_link, isexec=is_exec, + copied=copied) + current_ctx = context.memctx(self.repo, + parents, + rev.message or '...', + files.keys(), + filectxfn, + '%s%s' %(rev.author, self.author_host), + date, + extra) + new_hash = self.repo.commitctx(current_ctx) + self.ui.status('committed as %s on branch %s\n' % + (node.hex(new_hash), (branch or 'default'))) + if (rev.revnum, branch) not in self.revmap: + self.revmap[rev.revnum, branch] = new_hash + self._save_metadata() + # now we handle branches that need to be committed without any files + for branch in self.commit_branches_empty: + ha = self.get_parent_revision(rev.revnum, branch) + if ha == node.nullid: + continue + parent_ctx = self.repo.changectx(ha) + def del_all_files(*args): + raise IOError + extra = {} + if branch: + extra['branch'] = branch + # True here means nuke all files + files = [] + if self.commit_branches_empty[branch]: + files = parent_ctx.manifest().keys() + current_ctx = context.memctx(self.repo, + (ha, node.nullid), + rev.message or ' ', + files, + del_all_files, + '%s%s' % (rev.author, + self.author_host), + date, + extra) + new_hash = self.repo.commitctx(current_ctx) + self.ui.status('committed as %s on branch %s\n' % + (node.hex(new_hash), (branch or 'default'))) + if (rev.revnum, branch) not in self.revmap: + self.revmap[rev.revnum, branch] = new_hash + self._save_metadata() + self.clear_current_info() + + @property + def meta_data_dir(self): + return os.path.join(self.path, '.hg', 'svn') + + def meta_file_named(self, name): + return os.path.join(self.meta_data_dir, name) + + @property + def revmap_file(self): + return self.meta_file_named('rev_map') + + @property + def svn_url_file(self): + return self.meta_file_named('url') + + @property + def uuid_file(self): + return self.meta_file_named('uuid') + + @property + def last_revision_handled_file(self): + return self.meta_file_named('last_rev') + + @property + def branch_info_file(self): + return self.meta_file_named('branch_info') + + @property + def tag_info_file(self): + return self.meta_file_named('tag_info') + + @property + def tag_locations_file(self): + return self.meta_file_named('tag_locations') + + @property + def url(self): + return open(self.svn_url_file).read() + + @stash_exception_on_self + def delete_entry(self, path, revision_bogus, parent_baton, pool=None): + if self._is_path_valid(path): + br_path, branch = self._path_and_branch_for_path(path) + ha = self.get_parent_revision(self.current_rev.revnum, branch) + if ha == revlog.nullid: + return + ctx = self.repo.changectx(ha) + if br_path not in ctx: + br_path2 = '' + if br_path != '': + br_path2 = br_path + '/' + # assuming it is a directory + for f in ctx: + if f.startswith(br_path2): + f_p = '%s/%s' % (path, f[len(br_path2):]) + self.deleted_files[f_p] = True + self.current_files[f_p] = '' + self.ui.status('D %s\n' % f_p) + self.deleted_files[path] = True + self.current_files[path] = '' + self.ui.status('D %s\n' % path) + + @stash_exception_on_self + def open_file(self, path, parent_baton, base_revision, p=None): + self.current_file = 'foobaz' + if self._is_path_valid(path): + self.current_file = path + self.ui.status('M %s\n' % path) + if base_revision != -1: + self.base_revision = base_revision + else: + self.base_revision = None + self.should_edit_most_recent_plaintext = True + + @stash_exception_on_self + def add_file(self, path, parent_baton, copyfrom_path, + copyfrom_revision, file_pool=None): + self.current_file = 'foobaz' + self.base_revision = None + if path in self.deleted_files: + del self.deleted_files[path] + if self._is_path_valid(path): + self.current_file = path + self.should_edit_most_recent_plaintext = False + if copyfrom_path: + self.ui.status('A+ %s\n' % path) + # TODO(augie) handle this better, actually mark a copy + (from_file, + from_branch) = self._path_and_branch_for_path(copyfrom_path) + ha = self.get_parent_revision(copyfrom_revision + 1, + from_branch) + ctx = self.repo.changectx(ha) + if from_file in ctx: + fctx = ctx.filectx(from_file) + cur_file = self.current_file + self.current_files[cur_file] = fctx.data() + self.current_files_symlink[cur_file] = 'l' in fctx.flags() + self.current_files_exec[cur_file] = 'x' in fctx.flags() + else: + self.ui.status('A %s\n' % path) + + + @stash_exception_on_self + def add_directory(self, path, parent_baton, copyfrom_path, + copyfrom_revision, dir_pool=None): + if self._is_path_valid(path): + junk, branch = self._path_and_branch_for_path(path) + if not copyfrom_path and not junk: + self.commit_branches_empty[branch] = True + else: + self.commit_branches_empty[branch] = False + if not (self._is_path_valid(path) and copyfrom_path and + self._is_path_valid(copyfrom_path)): + return + + cp_f, br_from = self._path_and_branch_for_path(copyfrom_path) + new_hash = self.get_parent_revision(copyfrom_revision + 1, br_from) + if new_hash == node.nullid: + self.missing_plaintexts.add('%s/' % path) + return + cp_f_ctx = self.repo.changectx(new_hash) + if cp_f != '/' and cp_f != '': + cp_f = '%s/' % cp_f + else: + cp_f = '' + for f in cp_f_ctx: + if f.startswith(cp_f): + f2 = f[len(cp_f):] + fctx = cp_f_ctx.filectx(f) + fp_c = path + '/' + f2 + self.current_files[fp_c] = fctx.data() + self.current_files_exec[fp_c] = 'x' in fctx.flags() + self.current_files_symlink[fp_c] = 'l' in fctx.flags() + # TODO(augie) tag copies from files + + @stash_exception_on_self + def change_file_prop(self, file_baton, name, value, pool=None): + if name == 'svn:executable': + self.current_files_exec[self.current_file] = bool(value) + elif name == 'svn:special': + self.current_files_symlink[self.current_file] = bool(value) + + @stash_exception_on_self + def open_directory(self, path, parent_baton, base_revision, dir_pool=None): + if self._is_path_valid(path): + p_, branch = self._path_and_branch_for_path(path) + if p_ == '': + self.commit_branches_empty[branch] = False + + @stash_exception_on_self + def apply_textdelta(self, file_baton, base_checksum, pool=None): + base = '' + if not self._is_path_valid(self.current_file): + return lambda x: None + if (self.current_file in self.current_files + and not self.should_edit_most_recent_plaintext): + base = self.current_files[self.current_file] + elif (base_checksum is not None or + self.should_edit_most_recent_plaintext): + p_, br = self._path_and_branch_for_path(self.current_file) + par_rev = self.current_rev.revnum + if self.base_revision: + par_rev = self.base_revision + 1 + ha = self.get_parent_revision(par_rev, br) + if ha != revlog.nullid: + ctx = self.repo.changectx(ha) + if not p_ in ctx: + self.missing_plaintexts.add(self.current_file) + # short circuit exit since we can't do anything anyway + return lambda x: None + base = ctx.filectx(p_).data() + source = cStringIO.StringIO(base) + target = cStringIO.StringIO() + self.stream = target + + handler, baton = delta.svn_txdelta_apply(source, target, None) + if not callable(handler): + # TODO(augie) Raise a real exception, don't just fail an assertion. + assert False, 'handler not callable, bindings are broken' + def txdelt_window(window): + try: + if not self._is_path_valid(self.current_file): + return + handler(window, baton) + # window being None means commit this file + if not window: + self.current_files[self.current_file] = target.getvalue() + except core.SubversionException, e: + if e.message == 'Delta source ended unexpectedly': + self.missing_plaintexts.add(self.current_file) + else: + self._exception_info = sys.exc_info() + raise + except: + print len(base), self.current_file + self._exception_info = sys.exc_info() + raise + return txdelt_window + +class MissingPlainTextError(Exception): + """Exception raised when the repo lacks a source file required for replaying + a txdelta. + """ + +class ReplayException(Exception): + """Exception raised when you try and commit but the replay encountered an + exception. + """ diff --git a/push_cmd.py b/push_cmd.py new file mode 100644 --- /dev/null +++ b/push_cmd.py @@ -0,0 +1,114 @@ +from mercurial import util as merc_util +from mercurial import hg +from svn import core + +import util +import hg_delta_editor +import svnwrap +import fetch_command +import utility_commands + + +@util.register_subcommand('push') +@util.register_subcommand('dcommit') # for git expats +def push_revisions_to_subversion(ui, repo, hg_repo_path, svn_url, **opts): + """Push revisions starting at a specified head back to Subversion. + """ + #assert False # safety while the command is partially implemented. + hge = hg_delta_editor.HgChangeReceiver(hg_repo_path, + ui_=ui) + svn_commit_hashes = dict(zip(hge.revmap.itervalues(), + hge.revmap.iterkeys())) + # Strategy: + # 1. Find all outgoing commits from this head + outgoing = utility_commands.outgoing_revisions(ui, repo, hge, + svn_commit_hashes) + if not (outgoing and len(outgoing)): + ui.status('No revisions to push.') + return 0 + if len(repo.parents()) != 1: + ui.status('Cowardly refusing to push branch merge') + return 1 + while outgoing: + oldest = outgoing.pop(-1) + old_ctx = repo[oldest] + if len(old_ctx.parents()) != 1: + ui.status('Found a branch merge, this needs discussion and ' + 'implementation.') + return 1 + base_n = old_ctx.parents()[0].node() + old_children = repo[base_n].children() + # 2. Commit oldest revision that needs to be pushed + base_revision = svn_commit_hashes[old_ctx.parents()[0].node()][0] + commit_from_rev(ui, repo, old_ctx, hge, svn_url, base_revision) + # 3. Fetch revisions from svn + r = fetch_command.fetch_revisions(ui, svn_url, hg_repo_path) + assert not r or r == 0 + # 4. Find the new head of the target branch + repo = hg.repository(ui, hge.path) + base_c = repo[base_n] + replacement = [c for c in base_c.children() if c not in old_children + and c.branch() == old_ctx.branch()] + assert len(replacement) == 1 + replacement = replacement[0] + # 5. Rebase all children of the currently-pushing rev to the new branch + heads = repo.heads(old_ctx.node()) + for needs_transplant in heads: + hg.clean(repo, needs_transplant) + utility_commands.rebase_commits(ui, repo, hg_repo_path, **opts) + repo = hg.repository(ui, hge.path) + if needs_transplant in outgoing: + hg.clean(repo, repo['tip'].node()) + hge = hg_delta_editor.HgChangeReceiver(hg_repo_path, ui_=ui) + svn_commit_hashes = dict(zip(hge.revmap.itervalues(), + hge.revmap.iterkeys())) + outgoing = utility_commands.outgoing_revisions(ui, repo, hge, + svn_commit_hashes) + return 0 + + +def commit_from_rev(ui, repo, rev_ctx, hg_editor, svn_url, base_revision): + """Build and send a commit from Mercurial to Subversion. + """ + target_files = [] + file_data = {} + for file in rev_ctx.files(): + parent = rev_ctx.parents()[0] + new_data = base_data = '' + action = '' + if file in rev_ctx: + new_data = rev_ctx.filectx(file).data() + if file not in parent: + target_files.append(file) + action = 'add' + # TODO check for mime-type autoprops here + # TODO check for directory adds here + else: + target_files.append(file) + base_data = parent.filectx(file).data() + action = 'modify' + else: + target_files.append(file) + base_data = parent.filectx(file).data() + action = 'delete' + file_data[file] = base_data, new_data, action + + # TODO check for directory deletes here + svn = svnwrap.SubversionRepo(svn_url) + parent_branch = rev_ctx.parents()[0].branch() + branch_path = 'trunk' + if parent_branch and parent_branch != 'default': + branch_path = 'branches/%s' % parent_branch + new_target_files = ['%s/%s' % (branch_path, f) for f in target_files] + for tf, ntf in zip(target_files, new_target_files): + if tf in file_data: + file_data[ntf] = file_data[tf] + del file_data[tf] + try: + svn.commit(new_target_files, rev_ctx.description(), file_data, + base_revision, set([])) + except core.SubversionException, e: + if hasattr(e, 'apr_err') and e.apr_err == 160028: + raise merc_util.Abort('Base text was out of date, maybe rebase?') + else: + raise diff --git a/svncommand.py b/svncommand.py new file mode 100644 --- /dev/null +++ b/svncommand.py @@ -0,0 +1,155 @@ +import os +import pickle +import stat + +from mercurial import hg +from mercurial import node + +import svnwrap +import hg_delta_editor +import util +from util import register_subcommand, svn_subcommands +# dirty trick to force demandimport to run my decorator anyway. +from utility_commands import print_wc_url +from fetch_command import fetch_revisions +from push_cmd import commit_from_rev +# shut up, pyflakes, we must import those +__x = [print_wc_url, fetch_revisions, commit_from_rev, ] + +mode755 = (stat.S_IXUSR | stat.S_IXGRP| stat.S_IXOTH | stat.S_IRUSR | + stat.S_IRGRP| stat.S_IROTH | stat.S_IWUSR) +mode644 = (stat.S_IRUSR | stat.S_IRGRP| stat.S_IROTH | stat.S_IWUSR) + + +def svncmd(ui, repo, subcommand, *args, **opts): + if subcommand not in svn_subcommands: + candidates = [] + for c in svn_subcommands: + if c.startswith(subcommand): + candidates.append(c) + if len(candidates) == 1: + subcommand = candidates[0] + path = os.path.dirname(repo.path) + try: + opts['svn_url'] = open(os.path.join(repo.path, 'svn', 'url')).read() + return svn_subcommands[subcommand](ui, args=args, + hg_repo_path=path, + repo=repo, + **opts) + except TypeError, e: + print e + print 'Bad arguments for subcommand %s' % subcommand + except KeyError, e: + print 'Unknown subcommand %s' % subcommand + +@register_subcommand('help') +def help_command(ui, args=None, **opts): + """Get help on the subsubcommands. + """ + if args and args[0] in svn_subcommands: + print svn_subcommands[args[0]].__doc__.strip() + return + print 'Valid commands:', ' '.join(sorted(svn_subcommands.keys())) + +@register_subcommand('gentags') +def generate_hg_tags(ui, hg_repo_path, **opts): + """Save tags to .hg/localtags + """ + hg_editor = hg_delta_editor.HgChangeReceiver(hg_repo_path, ui_=ui) + f = open(hg_editor.tag_info_file) + tag_info = pickle.load(f) + f = open(os.path.join(hg_repo_path, '.hg', 'localtags'), 'w') + for tag, source in tag_info.iteritems(): + source_ha = hg_editor.get_parent_revision(source[1]+1, source[0]) + f.write('%s tag:%s\n' % (node.hex(source_ha), tag)) + +@register_subcommand('up') +def update(ui, args, repo, clean=False, **opts): + """Update to a specified Subversion revision number. + """ + assert len(args) == 1 + rev = int(args[0]) + path = os.path.join(repo.path, 'svn', 'rev_map') + answers = [] + for k,v in pickle.load(open(path)).iteritems(): + if k[0] == rev: + answers.append((v, k[1])) + if len(answers) == 1: + if clean: + return hg.clean(repo, answers[0][0]) + return hg.update(repo, answers[0][0]) + elif len(answers) == 0: + ui.status('Revision %s did not produce an hg revision.\n' % rev) + return 1 + else: + ui.status('Non-ambiguous revision!\n') + ui.status('\n'.join(['%s on %s' % (node.hex(a[0]), a[1]) for a in + answers]+[''])) + return 1 + + +@register_subcommand('verify_revision') +def verify_revision(ui, args, repo, force=False, **opts): + """Verify a single converted revision. + Note: This wipes your working copy and then exports the corresponding + Subversion into your working copy to verify. Use with caution. + """ + assert len(args) == 1 + if not force: + assert repo.status(ignored=True, + unknown=True) == ([], [], [], [], [], [], []) + rev = int(args[0]) + wc_path = os.path.dirname(repo.path) + svn_url = open(os.path.join(repo.path, 'svn', 'url')).read() + svn = svnwrap.SubversionRepo(svn_url) + util.wipe_all_files(wc_path) + if update(ui, args, repo, clean=True) == 0: + util.wipe_all_files(wc_path) + br = repo.dirstate.branch() + if br == 'default': + br = None + if br: + diff_path = 'branches/%s' % br + else: + diff_path = 'trunk' + svn.fetch_all_files_to_dir(diff_path, rev, wc_path) + stat = repo.status(unknown=True) + ignored = [s for s in stat[4] + if '/.svn/' not in s and not s.startswith('.svn/')] + stat = stat[0:4] + if stat != ([], [], [], [],) or ignored != []: + ui.status('Something is wrong with this revision.\n') + return 2 + else: + ui.status('OK.\n') + return 0 + return 1 + +@register_subcommand('verify_all_revisions') +def verify_all_revisions(ui, args, repo, **opts): + """Verify all the converted revisions, optionally starting at a revision. + + Note: This is *extremely* abusive of the Subversion server. It exports every + revision of the code one revision at a time. + """ + assert repo.status(ignored=True, + unknown=True) == ([], [], [], [], [], [], []) + start_rev = 0 + args = list(args) + if args: + start_rev = int(args.pop(0)) + revmap_f = open(os.path.join(repo.path, 'svn', 'rev_map')) + revmap = pickle.load(revmap_f) + revs = sorted(revmap.keys()) + for revnum, br in revs: + if revnum < start_rev: + continue + res = verify_revision(ui, [revnum], repo, force=True) + if res == 0: + print revnum, 'verfied' + elif res == 1: + print revnum, 'skipped' + else: + print revnum, 'failed' + return 1 + return 0 diff --git a/svnwrap/__init__.py b/svnwrap/__init__.py new file mode 100644 --- /dev/null +++ b/svnwrap/__init__.py @@ -0,0 +1,16 @@ +"""This is a special package because it contains (or will contain, as of now) +two parallel implementations of the same code. One implementation, the original, +uses the SWIG Python bindings. That's great, but those leak RAM and have a few +other quirks. There are new, up-and-coming ctypes bindings for Subversion which +look more promising, and are portible backwards to 1.4's libraries. The goal is +to have this file automatically contain the "best" available implementation +without the user having to configure what is actually present. +""" + +#try: +# # we do __import__ here so that the correct items get pulled in. Otherwise +# # demandimport can make life difficult. +# __import__('csvn') +# from svn_ctypes_wrapper import * +#except ImportError, e: +from svn_swig_wrapper import * diff --git a/svnwrap/svn_ctypes_wrapper.py b/svnwrap/svn_ctypes_wrapper.py new file mode 100644 --- /dev/null +++ b/svnwrap/svn_ctypes_wrapper.py @@ -0,0 +1,120 @@ +"""Right now this is a dummy module, but it should wrap the ctypes API and +allow running this more easily without the SWIG bindings. +""" +from csvn import repos + +class Revision(object): + """Wrapper for a Subversion revision. + """ + def __init__(self, revnum, author, message, date, paths, strip_path=''): + self.revnum, self.author, self.message = revnum, author, message + # TODO parse this into a datetime + self.date = date + self.paths = {} + for p in paths: + self.paths[p[len(strip_path):]] = paths[p] + + def __str__(self): + return 'r%d by %s' % (self.revnum, self.author) + + +class SubversionRepo(object): + """Wrapper for a Subversion repository. + + This uses the SWIG Python bindings, and will only work on svn >= 1.4. + It takes a required param, the URL. + """ + def __init__(self, url=''): + self.svn_url = url + + self.init_ra_and_client() + self.uuid = ra.get_uuid(self.ra, self.pool) + repo_root = ra.get_repos_root(self.ra, self.pool) + # *will* have a leading '/', would not if we used get_repos_root2 + self.subdir = url[len(repo_root):] + if not self.subdir or self.subdir[-1] != '/': + self.subdir += '/' + + def init_ra_and_client(self): + # TODO(augie) need to figure out a way to do auth + self.repo = repos.RemoteRepository(self.svn_url) + + @property + def HEAD(self): + raise NotImplementedError + + @property + def START(self): + return 0 + + @property + def branches(self): + """Get the branches defined in this repo assuming a standard layout. + """ + raise NotImplementedError + + @property + def tags(self): + """Get the current tags in this repo assuming a standard layout. + + This returns a dictionary of tag: (source path, source rev) + """ + raise NotImplementedError + + def _get_copy_source(self, path, cached_head=None): + """Get copy revision for the given path, assuming it was meant to be + a copy of the entire tree. + """ + raise NotImplementedError + + def list_dir(self, dir, revision=None): + """List the contents of a server-side directory. + + Returns a dict-like object with one dict key per directory entry. + + Args: + dir: the directory to list, no leading slash + rev: the revision at which to list the directory, defaults to HEAD + """ + raise NotImplementedError + + def revisions(self, start=None, chunk_size=1000): + """Load the history of this repo. + + This is LAZY. It returns a generator, and fetches a small number + of revisions at a time. + + The reason this is lazy is so that you can use the same repo object + to perform RA calls to get deltas. + """ + # NB: you'd think this would work, but you'd be wrong. I'm pretty + # convinced there must be some kind of svn bug here. + #return self.fetch_history_at_paths(['tags', 'trunk', 'branches'], + # start=start) + # this does the same thing, but at the repo root + filtering. It's + # kind of tough cookies, sadly. + raise NotImplementedError + + + def fetch_history_at_paths(self, paths, start=None, stop=None, + chunk_size=1000): + raise NotImplementedError + + def get_replay(self, revision, editor, oldest_rev_i_have=0): + raise NotImplementedError + + def get_unified_diff(self, path, revision, deleted=True, ignore_type=False): + raise NotImplementedError + + def get_file(self, path, revision): + raise NotImplementedError + + def proplist(self, path, revision, recurse=False): + raise NotImplementedError + + def fetch_all_files_to_dir(self, path, revision, checkout_path): + raise NotImplementedError + +class SubversionRepoCanNotReplay(Exception): + """Exception raised when the svn server is too old to have replay. + """ diff --git a/svnwrap/svn_swig_wrapper.py b/svnwrap/svn_swig_wrapper.py new file mode 100644 --- /dev/null +++ b/svnwrap/svn_swig_wrapper.py @@ -0,0 +1,381 @@ +import cStringIO +import getpass +import os +import pwd +import shutil +import sys +import tempfile + +from svn import client +from svn import core +from svn import delta +from svn import ra + +svn_config = core.svn_config_get_config(None) + + +def user_pass_prompt(realm, default_username, ms, pool): + creds = core.svn_auth_cred_simple_t() + creds.may_save = ms + if default_username: + sys.stderr.write('Auth realm: %s\n' % (realm,)) + creds.username = default_username + else: + sys.stderr.write('Auth realm: %s\n' % (realm,)) + sys.stderr.write('Username: ') + sys.stderr.flush() + creds.username = sys.stdin.readline().strip() + creds.password = getpass.getpass('Password for %s: ' % creds.username) + return creds + +def _create_auth_baton(pool): + """Create a Subversion authentication baton. """ + # Give the client context baton a suite of authentication + # providers.h + providers = [ + client.get_simple_provider(), + client.get_username_provider(), + client.get_ssl_client_cert_file_provider(), + client.get_ssl_client_cert_pw_file_provider(), + client.get_ssl_server_trust_file_provider(), + ] + # Platform-dependant authentication methods + if hasattr(client, 'get_windows_simple_provider'): + providers.append(client.get_windows_simple_provider()) + if hasattr(client, 'get_keychain_simple_provider'): + providers.append(client.get_keychain_simple_provider()) + providers.extend([client.get_simple_prompt_provider(user_pass_prompt, 2), + ]) + return core.svn_auth_open(providers, pool) + + +class Revision(object): + """Wrapper for a Subversion revision. + """ + def __init__(self, revnum, author, message, date, paths, strip_path=''): + self.revnum, self.author, self.message = revnum, author, message + # TODO parse this into a datetime + self.date = date + self.paths = {} + for p in paths: + self.paths[p[len(strip_path):]] = paths[p] + + def __str__(self): + return 'r%d by %s' % (self.revnum, self.author) + +class SubversionRepo(object): + """Wrapper for a Subversion repository. + + This uses the SWIG Python bindings, and will only work on svn >= 1.4. + It takes a required param, the URL. + """ + def __init__(self, url=''): + self.svn_url = url + self.auth_baton_pool = core.Pool() + self.auth_baton = _create_auth_baton(self.auth_baton_pool) + + self.init_ra_and_client() + self.uuid = ra.get_uuid(self.ra, self.pool) + repo_root = ra.get_repos_root(self.ra, self.pool) + # *will* have a leading '/', would not if we used get_repos_root2 + self.subdir = url[len(repo_root):] + if not self.subdir or self.subdir[-1] != '/': + self.subdir += '/' + + def init_ra_and_client(self): + """Initializes the RA and client layers, because sometimes getting + unified diffs runs the remote server out of open files. + """ + # while we're in here we'll recreate our pool + self.pool = core.Pool() + self.client_context = client.create_context() + self.uname = str(pwd.getpwuid(os.getuid())[0]) + core.svn_auth_set_parameter(self.auth_baton, + core.SVN_AUTH_PARAM_DEFAULT_USERNAME, + self.uname) + + self.client_context.auth_baton = self.auth_baton + self.client_context.config = svn_config + self.ra = client.open_ra_session(self.svn_url.encode('utf8'), + self.client_context) + + + @property + def HEAD(self): + return ra.get_latest_revnum(self.ra, self.pool) + + @property + def START(self): + return 0 + + @property + def branches(self): + """Get the branches defined in this repo assuming a standard layout. + """ + branches = self.list_dir('branches').keys() + branch_info = {} + head=self.HEAD + for b in branches: + b_path = 'branches/%s' %b + hist_gen = self.fetch_history_at_paths([b_path], stop=head) + hist = hist_gen.next() + source, source_rev = self._get_copy_source(b_path, cached_head=head) + # This if statement guards against projects that have non-ancestral + # branches by not listing them has branches + # Note that they probably are really ancestrally related, but there + # is just no way for us to know how. + if source is not None and source_rev is not None: + branch_info[b] = (source, source_rev, hist.revnum) + return branch_info + + @property + def tags(self): + """Get the current tags in this repo assuming a standard layout. + + This returns a dictionary of tag: (source path, source rev) + """ + tags = self.list_dir('tags').keys() + tag_info = {} + head = self.HEAD + for t in tags: + tag_info[t] = self._get_copy_source('tags/%s' % t, + cached_head=head) + return tag_info + + def _get_copy_source(self, path, cached_head=None): + """Get copy revision for the given path, assuming it was meant to be + a copy of the entire tree. + """ + if not cached_head: + cached_head = self.HEAD + hist_gen = self.fetch_history_at_paths([path], stop=cached_head) + hist = hist_gen.next() + if hist.paths[path].copyfrom_path is None: + return None, None + source = hist.paths[path].copyfrom_path + source_rev = 0 + for p in hist.paths: + if hist.paths[p].copyfrom_rev: + # We assume that the revision of the source tree as it was + # copied was actually the revision of the highest revision + # copied item. This could be wrong, but in practice it will + # *probably* be correct + if source_rev < hist.paths[p].copyfrom_rev: + source_rev = hist.paths[p].copyfrom_rev + source = source[len(self.subdir):] + return source, source_rev + + def list_dir(self, dir, revision=None): + """List the contents of a server-side directory. + + Returns a dict-like object with one dict key per directory entry. + + Args: + dir: the directory to list, no leading slash + rev: the revision at which to list the directory, defaults to HEAD + """ + if dir[-1] == '/': + dir = dir[:-1] + if revision is None: + revision = self.HEAD + r = ra.get_dir2(self.ra, dir, revision, core.SVN_DIRENT_KIND, self.pool) + folders, props, junk = r + return folders + + def revisions(self, start=None, chunk_size=1000): + """Load the history of this repo. + + This is LAZY. It returns a generator, and fetches a small number + of revisions at a time. + + The reason this is lazy is so that you can use the same repo object + to perform RA calls to get deltas. + """ + # NB: you'd think this would work, but you'd be wrong. I'm pretty + # convinced there must be some kind of svn bug here. + #return self.fetch_history_at_paths(['tags', 'trunk', 'branches'], + # start=start) + # this does the same thing, but at the repo root + filtering. It's + # kind of tough cookies, sadly. + for r in self.fetch_history_at_paths([''], start=start, + chunk_size=chunk_size): + should_yield = False + i = 0 + paths = list(r.paths.keys()) + while i < len(paths) and not should_yield: + p = paths[i] + if (p.startswith('trunk') or p.startswith('tags') + or p.startswith('branches')): + should_yield = True + i += 1 + if should_yield: + yield r + + + def fetch_history_at_paths(self, paths, start=None, stop=None, + chunk_size=1000): + revisions = [] + def callback(paths, revnum, author, date, message, pool): + r = Revision(revnum, author, message, date, paths, + strip_path=self.subdir) + revisions.append(r) + if not start: + start = self.START + if not stop: + stop = self.HEAD + while stop > start: + ra.get_log(self.ra, paths, + start+1, + stop, + chunk_size, #limit of how many log messages to load + True, # don't need to know changed paths + True, # stop on copies + callback, + self.pool) + if len(revisions) < chunk_size: + # this means there was no history for the path, so force the + # loop to exit + start = stop + else: + start = revisions[-1].revnum + while len(revisions) > 0: + yield revisions[0] + revisions.pop(0) + + def commit(self, paths, message, file_data, base_revision, dirs): + """Commits the appropriate targets from revision in editor's store. + """ + self.init_ra_and_client() + commit_info = [] + def commit_cb(_commit_info, pool): + commit_info.append(_commit_info) + editor, edit_baton = ra.get_commit_editor2(self.ra, + message, + commit_cb, + None, + False) + checksum = [] + def driver_cb(parent, path, pool): + if path in dirs: + return baton + base_text, new_text, action = file_data[path] + compute_delta = True + if action == 'modify': + baton = editor.open_file(path, parent, base_revision, pool) + elif action == 'add': + try: + baton = editor.add_file(path, parent, None, -1, pool) + except (core.SubversionException, TypeError), e: + print e.message + raise + elif action == 'delete': + baton = editor.delete_entry(path, base_revision, parent, pool) + compute_delta = False + + if compute_delta: + handler, wh_baton = editor.apply_textdelta(baton, None, + self.pool) + + txdelta_stream = delta.svn_txdelta( + cStringIO.StringIO(base_text), cStringIO.StringIO(new_text), + self.pool) + delta.svn_txdelta_send_txstream(txdelta_stream, handler, + wh_baton, pool) + + delta.path_driver(editor, edit_baton, base_revision, paths, driver_cb, + self.pool) + 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() + e_ptr, e_baton = delta.make_editor(editor) + try: + ra.replay(self.ra, revision, oldest_rev_i_have, True, e_ptr, + e_baton, self.pool) + except core.SubversionException, e: + # can I depend on this number being constant? + if (e.message == "Server doesn't support the replay command" + or e.apr_err == 170003): + raise SubversionRepoCanNotReplay, ('This Subversion server ' + 'is older than 1.4.0, and cannot satisfy replay requests.') + else: + raise + + def get_unified_diff(self, path, revision, other_path=None, other_rev=None, + deleted=True, ignore_type=False): + """Gets a unidiff of path at revision against revision-1. + """ + # works around an svn server keeping too many open files (observed + # in an svnserve from the 1.2 era) + self.init_ra_and_client() + + old_cwd = os.getcwd() + assert path[0] != '/' + url = self.svn_url + '/' + path + url2 = url + if other_path is not None: + url2 = self.svn_url + '/' + other_path + if other_rev is None: + other_rev = revision - 1 + tmpdir = tempfile.mkdtemp('svnwrap_temp') + # hot tip: the swig bridge doesn't like StringIO for these bad boys + out_path = os.path.join(tmpdir, 'diffout') + error_path = os.path.join(tmpdir, 'differr') + out = open(out_path, 'w') + err = open(error_path, 'w') + rev_old = core.svn_opt_revision_t() + rev_old.kind = core.svn_opt_revision_number + rev_old.value.number = other_rev + rev_new = core.svn_opt_revision_t() + rev_new.kind = core.svn_opt_revision_number + rev_new.value.number = revision + client.diff3([], url2, rev_old, url, rev_new, True, True, + deleted, ignore_type, 'UTF-8', out, err, + self.client_context, self.pool) + out.close() + err.close() + assert len(open(error_path).read()) == 0 + diff = open(out_path).read() + os.chdir(old_cwd) + shutil.rmtree(tmpdir) + return diff + + def get_file(self, path, revision): + out = cStringIO.StringIO() + tmpdir = tempfile.mkdtemp('svnwrap_temp') + # hot tip: the swig bridge doesn't like StringIO for these bad boys + out_path = os.path.join(tmpdir, 'diffout') + out = open(out_path, 'w') + ra.get_file(self.ra, path,revision, out , None) + out.close() + x = open(out_path).read() + shutil.rmtree(tmpdir) + return x + + def proplist(self, path, revision, recurse=False): + rev = core.svn_opt_revision_t() + rev.kind = core.svn_opt_revision_number + rev.value.number = revision + if path[-1] == '/': + path = path[:-1] + if path[0] == '/': + path = path[1:] + pl = dict(client.proplist2(self.svn_url+'/'+path, rev, rev, True, + self.client_context, self.pool)) + pl2 = {} + for key, value in pl.iteritems(): + pl2[key[len(self.svn_url)+1:]] = value + return pl2 + + def fetch_all_files_to_dir(self, path, revision, checkout_path): + rev = core.svn_opt_revision_t() + rev.kind = core.svn_opt_revision_number + rev.value.number = revision + client.export3(self.svn_url+'/'+path, checkout_path, rev, + rev, True, True, True, 'LF', # should be 'CRLF' on win32 + self.client_context, self.pool) + +class SubversionRepoCanNotReplay(Exception): + """Exception raised when the svn server is too old to have replay. + """ diff --git a/test_fetch_command.py b/test_fetch_command.py new file mode 100644 --- /dev/null +++ b/test_fetch_command.py @@ -0,0 +1,48 @@ +import fetch_command + +two_empties = """Index: __init__.py +=================================================================== +Index: bar/__init__.py +=================================================================== +Index: bar/test_muhaha.py +=================================================================== +--- bar/test_muhaha.py (revision 0) ++++ bar/test_muhaha.py (revision 1) +@@ -0,0 +1,2 @@ ++ ++blah blah blah, I'm a fake patch +\ No newline at end of file +""" + +def test_empty_file_re(): + matches = fetch_command.empty_file_patch_wont_make_re.findall(two_empties) + assert sorted(matches) == ['__init__.py', 'bar/__init__.py'] + +def test_any_matches_just_one(): + pat = '''Index: trunk/django/contrib/admin/urls/__init__.py +=================================================================== +''' + matches = fetch_command.any_file_re.findall(pat) + assert len(matches) == 1 + +def test_any_file_re(): + matches = fetch_command.any_file_re.findall(two_empties) + assert sorted(matches) == ['__init__.py', 'bar/__init__.py', + 'bar/test_muhaha.py'] +binary_delta = """Index: trunk/functional_tests/doc_tests/test_doctest_fixtures/doctest_fixtures_fixtures.pyc +=================================================================== +Cannot display: file marked as a binary type. +svn:mime-type = application/octet-stream + +Property changes on: trunk/functional_tests/doc_tests/test_doctest_fixtures/doctest_fixtures_fixtures.pyc +___________________________________________________________________ +Added: svn:mime-type + + application/octet-stream + +Index: trunk/functional_tests/doc_tests/test_doctest_fixtures/doctest_fixtures.rst +=================================================================== +""" +def test_binary_file_re(): + matches = fetch_command.binary_file_re.findall(binary_delta) + print matches + assert matches == ['trunk/functional_tests/doc_tests/test_doctest_fixtures/doctest_fixtures_fixtures.pyc'] diff --git a/test_svnwrap.py b/test_svnwrap.py new file mode 100644 --- /dev/null +++ b/test_svnwrap.py @@ -0,0 +1,146 @@ +import os +import shutil +import tempfile +import unittest + +from nose import tools + +import svnwrap + +class TestBasicRepoLayout(unittest.TestCase): + def setUp(self): + self.oldwd = os.getcwd() + self.tmpdir = tempfile.mkdtemp('svnwrap_test') + self.repo_path = '%s/testrepo' % self.tmpdir + wc_path = '%s/testrepo_wc' % self.tmpdir + os.spawnvp(os.P_WAIT, 'svnadmin', ['svnadmin', 'create', + self.repo_path,]) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'checkout', + 'file://%s' % self.repo_path, + wc_path,]) + os.chdir(wc_path) + for d in ['branches', 'tags', 'trunk']: + os.mkdir(os.path.join(wc_path, d)) + #r1 + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'add', 'branches', 'tags', 'trunk']) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Empty dirs.']) + #r2 + files = ['alpha', 'beta', 'delta'] + for f in files: + open(os.path.join(wc_path, 'trunk', f), 'w').write('This is %s.\n' % f) + os.chdir('trunk') + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'add']+files) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Initial Files.']) + os.chdir('..') + #r3 + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'cp', 'trunk', 'tags/rev1']) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Tag rev 1.']) + #r4 + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'cp', 'trunk', 'branches/crazy']) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Branch to crazy.']) + + #r5 + open(os.path.join(wc_path, 'trunk', 'gamma'), 'w').write('This is %s.\n' + % 'gamma') + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'add', 'trunk/gamma', ]) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Add gamma']) + + #r6 + open(os.path.join(wc_path, 'branches', 'crazy', 'omega'), + 'w').write('This is %s.\n' % 'omega') + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'add', 'branches/crazy/omega', ]) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Add omega']) + + #r7 + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'cp', 'trunk', 'branches/more_crazy']) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Branch to more_crazy.']) + + self.repo = svnwrap.SubversionRepo('file://%s' % self.repo_path) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + os.chdir(self.oldwd) + + + def test_num_revs(self): + revs = list(self.repo.revisions()) + tools.eq_(len(revs), 7) + r = revs[1] + tools.eq_(r.revnum, 2) + tools.eq_(sorted(r.paths.keys()), + ['trunk/alpha', 'trunk/beta', 'trunk/delta']) + for r in revs: + for p in r.paths: + # make sure these paths are always non-absolute for sanity + if p: + assert p[0] != '/' + revs = list(self.repo.revisions(start=3)) + tools.eq_(len(revs), 4) + + + def test_branches(self): + tools.eq_(self.repo.branches.keys(), ['crazy', 'more_crazy']) + tools.eq_(self.repo.branches['crazy'], ('trunk', 2, 4)) + tools.eq_(self.repo.branches['more_crazy'], ('trunk', 5, 7)) + + + def test_tags(self): + tags = self.repo.tags + tools.eq_(tags.keys(), ['rev1']) + tools.eq_(tags['rev1'], ('trunk', 2)) + +class TestRootAsSubdirOfRepo(TestBasicRepoLayout): + def setUp(self): + self.oldwd = os.getcwd() + self.tmpdir = tempfile.mkdtemp('svnwrap_test') + self.repo_path = '%s/testrepo' % self.tmpdir + wc_path = '%s/testrepo_wc' % self.tmpdir + os.spawnvp(os.P_WAIT, 'svnadmin', ['svnadmin', 'create', + self.repo_path,]) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'checkout', + 'file://%s' % self.repo_path, + wc_path,]) + self.repo_path += '/dummyproj' + os.chdir(wc_path) + os.mkdir('dummyproj') + os.chdir('dummyproj') + wc_path += '/dummyproj' + for d in ['branches', 'tags', 'trunk']: + os.mkdir(os.path.join(wc_path, d)) + #r1 + os.chdir('..') + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'add', 'dummyproj']) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Empty dirs.']) + os.chdir('dummyproj') + #r2 + files = ['alpha', 'beta', 'delta'] + for f in files: + open(os.path.join(wc_path, 'trunk', f), 'w').write('This is %s.\n' % f) + os.chdir('trunk') + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'add']+files) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Initial Files.']) + os.chdir('..') + #r3 + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'cp', 'trunk', 'tags/rev1']) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Tag rev 1.']) + #r4 + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'cp', 'trunk', 'branches/crazy']) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Branch to crazy.']) + + #r5 + open(os.path.join(wc_path, 'trunk', 'gamma'), 'w').write('This is %s.\n' + % 'gamma') + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'add', 'trunk/gamma', ]) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Add gamma']) + + #r6 + open(os.path.join(wc_path, 'branches', 'crazy', 'omega'), + 'w').write('This is %s.\n' % 'omega') + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'add', 'branches/crazy/omega', ]) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Add omega']) + + #r7 + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'cp', 'trunk', 'branches/more_crazy']) + os.spawnvp(os.P_WAIT, 'svn', ['svn', 'ci', '-m', 'Branch to more_crazy.']) + + self.repo = svnwrap.SubversionRepo('file://%s' % (self.repo_path)) diff --git a/util.py b/util.py new file mode 100644 --- /dev/null +++ b/util.py @@ -0,0 +1,35 @@ +import os +import shutil + +svn_subcommands = { } + +def register_subcommand(name): + def inner(fn): + svn_subcommands[name] = fn + return fn + return inner + + +def wipe_all_files(hg_wc_path): + files = [f for f in os.listdir(hg_wc_path) if f != '.hg'] + for f in files: + f = os.path.join(hg_wc_path, f) + if os.path.isdir(f): + shutil.rmtree(f) + else: + os.remove(f) + + +def remove_all_files_with_status(path, rev_paths, strip_path, status): + for p in rev_paths: + if rev_paths[p].action == status: + if p.startswith(strip_path): + fi = p[len(strip_path)+1:] + if len(fi) > 0: + fi = os.path.join(path, fi) + if os.path.isfile(fi): + os.remove(fi) + print 'D %s' % fi + elif os.path.isdir(fi): + shutil.rmtree(fi) + print 'D %s' % fi diff --git a/utility_commands.py b/utility_commands.py new file mode 100644 --- /dev/null +++ b/utility_commands.py @@ -0,0 +1,106 @@ +from mercurial import cmdutil +from mercurial import node +from hgext import rebase + +import util +import hg_delta_editor + +@util.register_subcommand('url') +def print_wc_url(ui, repo, hg_repo_path, **opts): + hge = hg_delta_editor.HgChangeReceiver(hg_repo_path, + ui_=ui) + ui.status(hge.url, '\n') + + +@util.register_subcommand('parent') +def print_parent_revision(ui, repo, hg_repo_path, **opts): + """Prints the hg hash and svn revision info for the nearest svn parent of + the current revision""" + hge = hg_delta_editor.HgChangeReceiver(hg_repo_path, + ui_=ui) + svn_commit_hashes = dict(zip(hge.revmap.itervalues(), + hge.revmap.iterkeys())) + ha = repo.parents()[0] + o_r = outgoing_revisions(ui, repo, hge, svn_commit_hashes) + if o_r: + ha = repo[o_r[-1]].parents()[0] + if ha.node() != node.nullid: + r, br = svn_commit_hashes[ha.node()] + ui.status('Working copy parent revision is %s: r%s on %s\n' % + (ha, r, br or 'trunk')) + else: + ui.status('Working copy seems to have no parent svn revision.\n') + return 0 + + +@util.register_subcommand('rebase') +def rebase_commits(ui, repo, hg_repo_path, **opts): + """Rebases the current uncommitted revisions onto the top of the branch. + """ + hge = hg_delta_editor.HgChangeReceiver(hg_repo_path, + ui_=ui) + svn_commit_hashes = dict(zip(hge.revmap.itervalues(), + hge.revmap.iterkeys())) + o_r = outgoing_revisions(ui, repo, hge, svn_commit_hashes) + if not o_r: + ui.status('Nothing to rebase!\n') + return 0 + if len(repo.parents()[0].children()): + ui.status('Refusing to rebase non-head commit like a coward\n') + return 0 + parent_rev = repo[o_r[-1]].parents()[0] + target_rev = parent_rev + p_n = parent_rev.node() + exhausted_choices = False + while target_rev.children() and not exhausted_choices: + for c in target_rev.children(): + exhausted_choices = True + n = c.node() + if (n in svn_commit_hashes and + svn_commit_hashes[n][1] == svn_commit_hashes[p_n][1]): + target_rev = c + exhausted_choices = False + break + if parent_rev == target_rev: + ui.status('Already up to date!\n') + return 0 + # TODO this is really hacky, there must be a more direct way + return rebase.rebase(ui, repo, dest=node.hex(target_rev.node()), + base=node.hex(repo.parents()[0].node())) + + +@util.register_subcommand('outgoing') +def show_outgoing_to_svn(ui, repo, hg_repo_path, **opts): + """Commit the current revision and any required parents back to svn. + """ + hge = hg_delta_editor.HgChangeReceiver(hg_repo_path, + ui_=ui) + svn_commit_hashes = dict(zip(hge.revmap.itervalues(), + hge.revmap.iterkeys())) + o_r = outgoing_revisions(ui, repo, hge, svn_commit_hashes) + if not (o_r and len(o_r)): + ui.status('No outgoing changes found.\n') + return 0 + displayer = cmdutil.show_changeset(ui, repo, opts, buffered=False) + for rev in reversed(o_r): + displayer.show(changenode=rev) + + +def outgoing_revisions(ui, repo, hg_editor, reverse_map): + """Given a repo and an hg_editor, determines outgoing revisions for the + current working copy state. + """ + outgoing_rev_hashes = [] + working_rev = repo.parents() + assert len(working_rev) == 1 + working_rev = working_rev[0] + if working_rev.node() in reverse_map: + return + while (not working_rev.node() in reverse_map + and working_rev.node() != node.nullid): + outgoing_rev_hashes.append(working_rev.node()) + working_rev = working_rev.parents() + assert len(working_rev) == 1 + working_rev = working_rev[0] + if working_rev.node() != node.nullid: + return outgoing_rev_hashes