# HG changeset patch # User Bryan O'Sullivan # Date 2007-11-08 02:26:59 # Node ID f0c58fd4b798ba008a121cc1dfe6c4dabf1a9cc8 # Parent 8cd26ccc68f83a0e833932087dc883228a56a475 convert: add support for Subversion as a sink diff --git a/hgext/convert/__init__.py b/hgext/convert/__init__.py --- a/hgext/convert/__init__.py +++ b/hgext/convert/__init__.py @@ -10,7 +10,7 @@ from cvs import convert_cvs from darcs import darcs_source from git import convert_git from hg import mercurial_source, mercurial_sink -from subversion import svn_source, debugsvnlog +from subversion import debugsvnlog, svn_source, svn_sink import filemap import os, shutil @@ -29,6 +29,7 @@ source_converters = [ sink_converters = [ ('hg', mercurial_sink), + ('svn', svn_sink), ] def convertsource(ui, path, type, rev): @@ -283,6 +284,7 @@ def convert(ui, src, dest=None, revmapfi Accepted destination formats: - Mercurial + - Subversion (history on branches is not preserved) If no revision is given, all revisions will be converted. Otherwise, convert will only import up to the named revision (given in a format diff --git a/hgext/convert/common.py b/hgext/convert/common.py --- a/hgext/convert/common.py +++ b/hgext/convert/common.py @@ -2,6 +2,7 @@ import base64, errno import cPickle as pickle from mercurial import util +from mercurial.i18n import _ def encodeargs(args): def encodearg(s): diff --git a/hgext/convert/subversion.py b/hgext/convert/subversion.py --- a/hgext/convert/subversion.py +++ b/hgext/convert/subversion.py @@ -17,9 +17,13 @@ import locale import os +import re import sys import cPickle as pickle -from mercurial import util +import tempfile + +from mercurial import strutil, util +from mercurial.i18n import _ # Subversion stuff. Works best with very recent Python SVN bindings # e.g. SVN 1.5 or backports. Thanks to the bzr folks for enhancing @@ -28,6 +32,7 @@ from mercurial import util from cStringIO import StringIO from common import NoRepo, commit, converter_source, encodeargs, decodeargs +from common import commandline, converter_sink, mapfile try: from svn.core import SubversionException, Pool @@ -664,3 +669,198 @@ class svn_source(converter_source): pool = Pool() rpath = '/'.join([self.base, path]).strip('/') return ['%s/%s' % (path, x) for x in svn.client.ls(rpath, optrev(revnum), True, self.ctx, pool).keys()] + +pre_revprop_change = '''#!/bin/sh + +REPOS="$1" +REV="$2" +USER="$3" +PROPNAME="$4" +ACTION="$5" + +if [ "$ACTION" = "M" -a "$PROPNAME" = "svn:log" ]; then exit 0; fi +if [ "$ACTION" = "A" -a "$PROPNAME" = "hg:convert-branch" ]; then exit 0; fi +if [ "$ACTION" = "A" -a "$PROPNAME" = "hg:convert-rev" ]; then exit 0; fi + +echo "Changing prohibited revision property" >&2 +exit 1 +''' + +class svn_sink(converter_sink, commandline): + commit_re = re.compile(r'Committed revision (\d+).', re.M) + + def prerun(self): + if self.wc: + os.chdir(self.wc) + + def postrun(self): + if self.wc: + os.chdir(self.cwd) + + def join(self, name): + return os.path.join(self.wc, '.svn', name) + + def revmapfile(self): + return self.join('hg-shamap') + + def authorfile(self): + return self.join('hg-authormap') + + def __init__(self, ui, path): + converter_sink.__init__(self, ui, path) + commandline.__init__(self, ui, 'svn') + self.delete = [] + self.wc = None + self.cwd = os.getcwd() + + path = os.path.realpath(path) + + created = False + if os.path.isfile(os.path.join(path, '.svn', 'entries')): + self.wc = path + self.run0('update') + else: + if os.path.isdir(os.path.dirname(path)): + if not os.path.exists(os.path.join(path, 'db', 'fs-type')): + ui.status(_('initializing svn repo %r\n') % + os.path.basename(path)) + commandline(ui, 'svnadmin').run0('create', path) + created = path + path = 'file://' + path + wcpath = os.path.join(os.getcwd(), os.path.basename(path) + '-wc') + ui.status(_('initializing svn wc %r\n') % os.path.basename(wcpath)) + self.run0('checkout', path, wcpath) + + self.wc = wcpath + self.opener = util.opener(self.wc) + self.wopener = util.opener(self.wc) + self.childmap = mapfile(ui, self.join('hg-childmap')) + + if created: + hook = os.path.join(created, 'hooks', 'pre-revprop-change') + fp = open(hook, 'w') + fp.write(pre_revprop_change) + fp.close() + util.set_exec(hook, True) + + def wjoin(self, *names): + return os.path.join(self.wc, *names) + + def putfile(self, filename, flags, data): + if 'l' in flags: + self.wopener.symlink(data, filename) + else: + try: + if os.path.islink(self.wjoin(filename)): + os.unlink(filename) + except OSError: + pass + self.wopener(filename, 'w').write(data) + was_exec = util.is_exec(self.wjoin(filename)) + util.set_exec(self.wjoin(filename), 'x' in flags) + if was_exec: + if 'x' not in flags: + self.run0('propdel', 'svn:executable', filename) + else: + if 'x' in flags: + self.run0('propset', 'svn:executable', '*', filename) + + def delfile(self, name): + self.delete.append(name) + + def copyfile(self, source, dest): + # SVN's copy command pukes if the destination file exists, but + # our copyfile method expects to record a copy that has + # already occurred. Cross the semantic gap. + wdest = self.wjoin(dest) + exists = os.path.exists(wdest) + if exists: + fd, tempname = tempfile.mkstemp( + prefix='hg-copy-', dir=os.path.dirname(wdest)) + os.close(fd) + os.unlink(tempname) + os.rename(wdest, tempname) + try: + self.run0('copy', source, dest) + finally: + if exists: + try: + os.unlink(wdest) + except OSError: + pass + os.rename(tempname, wdest) + + def dirs_of(self, files): + dirs = set() + for f in files: + if os.path.isdir(self.wjoin(f)): + dirs.add(f) + for i in strutil.rfindall(f, '/'): + dirs.add(f[:i]) + return dirs + + def add_files(self, files): + add_dirs = [d for d in self.dirs_of(files) + if not os.path.exists(self.wjoin(d, '.svn', 'entries'))] + if add_dirs: + self.run('add', non_recursive=True, quiet=True, *add) + if files: + self.run('add', quiet=True, *files) + return files.union(add_dirs) + + def tidy_dirs(self, names): + dirs = list(self.dirs_of(names)) + dirs.sort(reverse=True) + deleted = [] + for d in dirs: + wd = self.wjoin(d) + if os.listdir(wd) == '.svn': + self.run0('delete', d) + deleted.append(d) + return deleted + + def addchild(self, parent, child): + self.childmap[parent] = child + + def putcommit(self, files, parents, commit): + for parent in parents: + try: + return self.childmap[parent] + except KeyError: + pass + entries = set(self.delete) + if self.delete: + self.run0('delete', *self.delete) + self.delete = [] + files = util.frozenset(files) + entries.update(self.add_files(files.difference(entries))) + entries.update(self.tidy_dirs(entries)) + fd, messagefile = tempfile.mkstemp(prefix='hg-convert-') + fp = os.fdopen(fd, 'w') + fp.write(commit.desc) + fp.close() + try: + output = self.run0('commit', + username=util.shortuser(commit.author), + file=messagefile, + *list(entries)) + try: + rev = self.commit_re.search(output).group(1) + except AttributeError: + self.ui.warn(_('unexpected svn output:\n')) + self.ui.warn(output) + raise util.Abort(_('unable to cope with svn output')) + if commit.rev: + self.run('propset', 'hg:convert-rev', commit.rev, + revprop=True, revision=rev) + if commit.branch and commit.branch != 'default': + self.run('propset', 'hg:convert-branch', commit.branch, + revprop=True, revision=rev) + for parent in parents: + self.addchild(parent, rev) + return rev + finally: + os.unlink(messagefile) + + def puttags(self, tags): + self.ui.warn(_('XXX TAGS NOT IMPLEMENTED YET\n')) diff --git a/tests/test-convert-svn-sink b/tests/test-convert-svn-sink new file mode 100755 --- /dev/null +++ b/tests/test-convert-svn-sink @@ -0,0 +1,91 @@ +#!/bin/sh + +"$TESTDIR/hghave" svn svn-bindings || exit 80 + +echo "[extensions]" >> $HGRCPATH +echo "convert = " >> $HGRCPATH + +hg init a + +echo a > a/a +echo % add +hg --cwd a ci -d '0 0' -A -m 'add a file' + +echo a >> a/a +echo % modify +hg --cwd a ci -d '1 0' -m 'modify a file' +hg --cwd a tip -q + +hg convert -d svn a +(cd a-hg-wc; svn up; svn st -v; svn log --xml -v --limit=2 | sed 's,.*,,') +ls a a-hg-wc +cmp a/a a-hg-wc/a && echo same || echo different + +hg --cwd a mv a b +echo % rename +hg --cwd a ci -d '2 0' -m 'rename a file' +hg --cwd a tip -q + +hg convert -d svn a +(cd a-hg-wc; svn up; svn st -v; svn log --xml -v --limit=1 | sed 's,.*,,') +ls a a-hg-wc + +hg --cwd a cp b c +echo % copy +hg --cwd a ci -d '3 0' -m 'copy a file' +hg --cwd a tip -q + +hg convert -d svn a +(cd a-hg-wc; svn up; svn st -v; svn log --xml -v --limit=1 | sed 's,.*,,') +ls a a-hg-wc + +hg --cwd a rm b +echo % remove +hg --cwd a ci -d '4 0' -m 'remove a file' +hg --cwd a tip -q + +hg convert -d svn a +(cd a-hg-wc; svn up; svn st -v; svn log --xml -v --limit=1 | sed 's,.*,,') +ls a a-hg-wc + +chmod +x a/c +echo % executable +hg --cwd a ci -d '5 0' -m 'make a file executable' +hg --cwd a tip -q + +hg convert -d svn a +(cd a-hg-wc; svn up; svn st -v; svn log --xml -v --limit=1 | sed 's,.*,,') +test -x a-hg-wc/c && echo executable || echo not executable + +echo % branchy history + +hg init b +echo base > b/b +hg --cwd b ci -d '0 0' -Ambase + +echo left-1 >> b/b +echo left-1 > b/left-1 +hg --cwd b ci -d '1 0' -Amleft-1 + +echo left-2 >> b/b +echo left-2 > b/left-2 +hg --cwd b ci -d '2 0' -Amleft-2 + +hg --cwd b up 0 + +echo right-1 >> b/b +echo right-1 > b/right-1 +hg --cwd b ci -d '3 0' -Amright-1 + +echo right-2 >> b/b +echo right-2 > b/right-2 +hg --cwd b ci -d '4 0' -Amright-2 + +hg --cwd b up -C 2 +hg --cwd b merge +hg --cwd b revert -r 2 b +hg --cwd b ci -d '5 0' -m 'merge' + +hg convert -d svn b +echo % expect 4 changes +(cd b-hg-wc; svn up; svn st -v; svn log --xml -v | sed 's,.*,,') diff --git a/tests/test-convert-svn-sink.out b/tests/test-convert-svn-sink.out new file mode 100644 --- /dev/null +++ b/tests/test-convert-svn-sink.out @@ -0,0 +1,251 @@ +% add +adding a +% modify +1:10307c220ed9 +assuming destination a-hg +initializing svn repo 'a-hg' +initializing svn wc 'a-hg-wc' +scanning source... +sorting... +converting... +1 add a file +0 modify a file +At revision 2. + 2 2 test . + 2 2 test a + + + +test + + +/a + +modify a file + + +test + + +/a + +add a file + + +a: +a + +a-hg-wc: +a +same +% rename +2:6e45a219686e +assuming destination a-hg +initializing svn wc 'a-hg-wc' +scanning source... +sorting... +converting... +0 rename a file +At revision 3. + 3 3 test . + 3 3 test b + + + +test + + +/a +/b + +rename a file + + +a: +b + +a-hg-wc: +b +% copy +3:d811dc81efbb +assuming destination a-hg +initializing svn wc 'a-hg-wc' +scanning source... +sorting... +converting... +0 copy a file +At revision 4. + 4 4 test . + 4 3 test b + 4 4 test c + + + +test + + +/c + +copy a file + + +a: +b +c + +a-hg-wc: +b +c +% remove +4:045e93063aca +assuming destination a-hg +initializing svn wc 'a-hg-wc' +scanning source... +sorting... +converting... +0 remove a file +At revision 5. + 5 5 test . + 5 4 test c + + + +test + + +/b + +remove a file + + +a: +c + +a-hg-wc: +c +% executable +5:7eda3f4b5331 +svn: Path 'b' does not exist +assuming destination a-hg +initializing svn wc 'a-hg-wc' +scanning source... +sorting... +converting... +0 make a file executable +abort: svn exited with status 1 +At revision 5. + 5 5 test . + M 5 4 test c + + + +test + + +/b + +remove a file + + +executable +% branchy history +adding b +adding left-1 +adding left-2 +1 files updated, 0 files merged, 2 files removed, 0 files unresolved +adding right-1 +adding right-2 +3 files updated, 0 files merged, 2 files removed, 0 files unresolved +warning: conflicts during merge. +merging b +merging b failed! +2 files updated, 0 files merged, 0 files removed, 1 files unresolved +There are unresolved merges, you can redo the full merge using: + hg update -C 2 + hg merge 4 +assuming destination b-hg +initializing svn repo 'b-hg' +initializing svn wc 'b-hg-wc' +scanning source... +sorting... +converting... +5 base +4 left-1 +3 left-2 +2 right-1 +1 right-2 +0 merge +% expect 4 changes +At revision 4. + 4 4 test . + 4 3 test b + 4 2 test left-1 + 4 3 test left-2 + 4 4 test right-1 + 4 4 test right-2 + + + +test + + +/right-1 +/right-2 + +merge + + +test + + +/b +/left-2 + +left-2 + + +test + + +/b +/left-1 + +left-1 + + +test + + +/b + +base + + diff --git a/tests/test-convert-svn b/tests/test-convert-svn-source rename from tests/test-convert-svn rename to tests/test-convert-svn-source diff --git a/tests/test-convert-svn.out b/tests/test-convert-svn-source.out rename from tests/test-convert-svn.out rename to tests/test-convert-svn-source.out diff --git a/tests/test-convert.out b/tests/test-convert.out --- a/tests/test-convert.out +++ b/tests/test-convert.out @@ -11,6 +11,7 @@ Convert a foreign SCM repository to a Me Accepted destination formats: - Mercurial + - Subversion (history on branches is not preserved) If no revision is given, all revisions will be converted. Otherwise, convert will only import up to the named revision (given in a format