changeset 0:f2636cfed115

Initial import of hgsubversion into a public repository.
author Augie Fackler <durin42@gmail.com>
date Tue, 30 Sep 2008 11:42:52 -0500
parents
children 50dee64aa677
files .hgignore README TODO __init__.py fetch_command.py hg_delta_editor.py push_cmd.py svncommand.py svnwrap/__init__.py svnwrap/svn_ctypes_wrapper.py svnwrap/svn_swig_wrapper.py test_fetch_command.py test_svnwrap.py util.py utility_commands.py
diffstat 15 files changed, 2235 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
new file mode 100644
--- /dev/null
+++ b/.hgignore
@@ -0,0 +1,3 @@
+syntax:glob
+*.pyc
+.DS_Store
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.
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.
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'),
+}
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
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.
+    """
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
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
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 *
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.
+    """
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.
+    """
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']
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))
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
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