blob: c5fd431acea7beb49a360221108dfe76d0e00cc9 [file] [log] [blame]
#!/usr/bin/env python
#
# run_tests.py: test suite for cvs2svn
#
# Subversion is a tool for revision control.
# See http://subversion.tigris.org for more information.
#
# ====================================================================
# Copyright (c) 2000-2003 CollabNet. All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at http://subversion.tigris.org/license-1.html.
# If newer versions of this license are posted there, you may use a
# newer version instead, at your option.
#
######################################################################
# General modules
import sys
import shutil
import stat
import string
import re
import os
import time
import os.path
# This script needs to run in tools/cvs2svn/. Make sure we're there.
if not (os.path.exists('cvs2svn.py') and os.path.exists('test-data')):
sys.stderr.write("error: I need to be run in 'tools/cvs2svn/' "
"in the Subversion tree.\n")
sys.exit(1)
# Find the Subversion test framework.
sys.path += [os.path.abspath('../../subversion/tests/clients/cmdline')]
import svntest
# Abbreviations
Skip = svntest.testcase.Skip
XFail = svntest.testcase.XFail
Item = svntest.wc.StateItem
cvs2svn = os.path.abspath('cvs2svn.py')
# We use the installed svn and svnlook binaries, instead of using
# svntest.main.run_svn() and svntest.main.run_svnlook(), because the
# behavior -- or even existence -- of local builds shouldn't affect
# the cvs2svn test suite.
svn = 'svn'
svnlook = 'svnlook'
test_data_dir = 'test-data'
tmp_dir = 'tmp'
#----------------------------------------------------------------------
# Helpers.
#----------------------------------------------------------------------
def run_program(program, *varargs):
"""Run PROGRAM with VARARGS, return stdout as a list of lines.
If there is any stderr, print it and then exit with error."""
out, err = svntest.main.run_command(program, 1, 0, *varargs)
if err:
print '\n%s said:\n' % program
for line in err: print ' ' + line,
print
sys.exit(1)
return out
def run_cvs2svn(*varargs):
"""Run cvs2svn with VARARGS, return stdout as a list of lines.
If there is any stderr, print it and then exit with error."""
return run_program(cvs2svn, *varargs)
def run_svn(*varargs):
"""Run svn with VARARGS; return stdout as a list of lines.
If stderr, print stderr lines and exit with error."""
return run_program(svn, *varargs)
def repos_to_url(path_to_svn_repos):
"""This does what you think it does."""
return 'file://%s' % os.path.abspath(path_to_svn_repos)
class Log:
def __init__(self, revision, author, date):
self.revision = revision
self.author = author
# Internally, we represent the date as seconds since epoch (UTC).
# Since standard subversion log output shows dates in localtime
#
# "1993-06-18 00:46:07 -0500 (Fri, 18 Jun 1993)"
#
# and time.mktime() converts from localtime, it all works out very
# happily.
self.date = time.mktime(time.strptime(date[0:19], "%Y-%m-%d %H:%M:%S"))
# The changed paths will be accumulated later, as log data is read.
# Keys here are paths such as '/trunk/foo/bar', values are letter
# codes such as 'M', 'A', and 'D'.
self.changed_paths = { }
# The msg will be accumulated later, as log data is read.
self.msg = ''
def parse_log(svn_repos):
"""Return a dictionary of Logs, keyed on revision number, for SVN_REPOS."""
class LineFeeder:
'Make a list of lines behave like an open file handle.'
def __init__(self, lines):
self.lines = lines
def readline(self):
if len(self.lines) > 0:
return self.lines.pop(0)
else:
return None
def absorb_changed_paths(out, log):
'Read changed paths from OUT into Log item LOG, until no more.'
while 1:
line = out.readline()
if len(line) == 1: return
line = line[:-1]
log.changed_paths[line[5:]] = line[3:4]
def absorb_message_body(out, num_lines, log):
'Read NUM_LINES of log message body from OUT into Log item LOG.'
i = 0
while i < num_lines:
line = out.readline()
log.msg += line
i += 1
log_start_re = re.compile('^rev (?P<rev>[0-9]+): '
'(?P<author>[^\|]+) \| '
'(?P<date>[^\|]+) '
'\| (?P<lines>[0-9]+) (line|lines)$')
log_separator = '-' * 72
logs = { }
out = LineFeeder(run_svn('log', '-v', repos_to_url(svn_repos)))
while 1:
this_log = None
line = out.readline()
if not line: break
line = line[:-1]
if line.find(log_separator) == 0:
line = out.readline()
if not line: break
line = line[:-1]
m = log_start_re.match(line)
if m:
this_log = Log(int(m.group('rev')), m.group('author'), m.group('date'))
line = out.readline()
if not line.find('Changed paths:') == 0:
print 'unexpected log output (missing changed paths)'
print "Line: '%s'" % line
sys.exit(1)
absorb_changed_paths(out, this_log)
absorb_message_body(out, int(m.group('lines')), this_log)
logs[this_log.revision] = this_log
elif len(line) == 0:
break # We've reached the end of the log output.
else:
print 'unexpected log output (missing revision line)'
print "Line: '%s'" % line
sys.exit(1)
else:
print 'unexpected log output (missing log separator)'
print "Line: '%s'" % line
sys.exit(1)
return logs
def erase(path):
"""Unconditionally remove PATH and its subtree, if any. PATH may be
non-existent, a file or symlink, or a directory."""
if os.path.isdir(path):
shutil.rmtree(path)
elif os.path.exists(path):
os.remove(path)
# List of already converted names; see the NAME argument to ensure_conversion.
#
# Keys are names, values are tuples: (svn_repos, svn_wc, log_dictionary).
# The log_dictionary comes from parse_log(svn_repos).
already_converted = { }
def ensure_conversion(name, no_prune=None):
"""Convert CVS repository NAME to Subversion, but only if it has not
been converted before by this invocation of this script. If it has
been converted before, do nothing.
If NO_PRUNE is set, then pass the --no-prune option to cvs2svn.py
when converting.
NAME is just one word. For example, 'main' would mean to convert
'./test-data/main-cvsrepos', and after the conversion, the resulting
Subversion repository would be in './tmp/main-svnrepos', and a
checked out head working copy in './tmp/main-wc'.
Return the Subversion repository path and wc path. """
cvsrepos = os.path.abspath(os.path.join(test_data_dir, '%s-cvsrepos' % name))
if not already_converted.has_key(name):
if not os.path.isdir(tmp_dir):
os.mkdir(tmp_dir)
saved_wd = os.getcwd()
try:
os.chdir(tmp_dir)
svnrepos = '%s-svnrepos' % name
wc = '%s-wc' % name
# Clean up from any previous invocations of this script.
erase(svnrepos)
erase(wc)
if no_prune:
run_cvs2svn('--no-prune', '--create', '-s', svnrepos, cvsrepos)
else:
run_cvs2svn('--create', '-s', svnrepos, cvsrepos)
run_svn('co', repos_to_url(svnrepos), wc)
log_dict = parse_log(svnrepos)
finally:
os.chdir(saved_wd)
# This name is done for the rest of this session.
already_converted[name] = (os.path.join('tmp', svnrepos),
os.path.join('tmp', wc),
log_dict)
return already_converted[name]
#----------------------------------------------------------------------
# Tests.
#----------------------------------------------------------------------
def show_usage():
"cvs2svn with no arguments shows usage"
out = run_cvs2svn()
if out[0].find('USAGE') < 0:
print 'Basic cvs2svn invocation failed.'
raise svntest.Failure
def attr_exec():
"detection of the executable flag"
repos, wc, logs = ensure_conversion('main')
st = os.stat(os.path.join(wc, 'trunk', 'single-files', 'attr-exec'))
if not st[0] & stat.S_IXUSR:
raise svntest.Failure
def space_fname():
"conversion of filename with a space"
repos, wc, logs = ensure_conversion('main')
if not os.path.exists(os.path.join(wc, 'trunk', 'single-files',
'space fname')):
raise svntest.Failure
def two_quick():
"two commits in quick succession"
repos, wc, logs = ensure_conversion('main')
out = run_svn('log', os.path.join(wc, 'trunk', 'single-files', 'twoquick'))
num_revisions = 0
for line in out:
if line.find("rev ") == 0:
num_revisions = num_revisions + 1
if num_revisions != 2:
raise svntest.Failure
def prune_with_care():
"prune, but never too much"
# Robert Pluim encountered this lovely one while converting the
# directory src/gnu/usr.bin/cvs/contrib/pcl-cvs/ in FreeBSD's CVS
# repository (see issue #1302). Step 4 is the doozy:
#
# revision 1: adds trunk/blah/, adds trunk/blah/cookie
# revision 2: adds trunk/blah/NEWS
# revision 3: deletes trunk/blah/cookie
# revision 4: deletes blah [re-deleting trunk/blah/cookie pruned blah!]
# revision 5: does nothing
#
# After fixing cvs2svn, the sequence (correctly) looks like this:
#
# revision 1: adds trunk/blah/, adds trunk/blah/cookie
# revision 2: adds trunk/blah/NEWS
# revision 3: deletes trunk/blah/cookie
# revision 4: does nothing [because trunk/blah/cookie already deleted]
# revision 5: deletes blah
#
# The difference is in 4 and 5. In revision 4, it's not correct to
# prune blah/, because NEWS is still in there, so revision 4 does
# nothing now. But when we delete NEWS in 5, that should bubble up
# and prune blah/ instead.
#
# ### Note that empty revisions like 4 are probably going to become
# ### at least optional, if not banished entirely from cvs2svn's
# ### output. Hmmm, or they may stick around, with an extra
# ### revision property explaining what happened. Need to think
# ### about that. In some sense, it's a bug in Subversion itself,
# ### that such revisions don't show up in 'svn log' output.
#
# In the test below, 'trunk/full-prune/first' represents
# cookie, and 'trunk/full-prune/second' represents NEWS.
repos, wc, logs = ensure_conversion('main')
# Confirm that revision 4 removes '/trunk/full-prune/first',
# and that revision 6 removes '/trunk/full-prune'.
#
# Also confirm similar things about '/full-prune-reappear/...',
# which is similar, except that later on it reappears, restored
# from pruneland, because a file gets added to it.
#
# And finally, a similar thing for '/partial-prune/...', except that
# in its case, a permanent file on the top level prevents the
# pruning from going farther than the subdirectory containing first
# and second.
for path in ('/trunk/full-prune/first',
'/trunk/full-prune-reappear/sub/first',
'/trunk/partial-prune/sub/first'):
if not (logs[4].changed_paths.get(path) == 'D'):
print "Revision 4 failed to remove '%s'." % path
raise svntest.Failure
for path in ('/trunk/full-prune',
'/trunk/full-prune-reappear',
'/trunk/partial-prune/sub'):
if not (logs[6].changed_paths.get(path) == 'D'):
print "Revision 6 failed to remove '%s'." % path
raise svntest.Failure
for path in ('/trunk/full-prune-reappear',
'/trunk/full-prune-reappear',
'/trunk/full-prune-reappear/appears-later'):
if not (logs[19].changed_paths.get(path) == 'A'):
print "Revision 19 failed to create path '%s'." % path
raise svntest.Failure
def double_delete():
"file deleted twice, in the root of the repository"
# This really tests several things: how we handle a file that's
# removed (state 'dead') in two successive revisions; how we
# handle a file in the root of the repository (there were some
# bugs in cvs2svn's svn path construction for top-level files); and
# the --no-prune option.
repos, wc, logs = ensure_conversion('double-delete', 1)
path = '/trunk/twice-removed'
if not (logs[1].changed_paths.get(path) == 'A'):
raise svntest.Failure
if logs[1].msg.find('Initial revision') != 0:
raise svntest.Failure
if not (logs[2].changed_paths.get(path) == 'D'):
raise svntest.Failure
if logs[2].msg.find('Remove this file for the first time.') != 0:
raise svntest.Failure
if logs[2].changed_paths.has_key('/trunk'):
raise svntest.Failure
def simple_commits():
"simple trunk commits"
# See test-data/main-cvsrepos/proj/README.
repos, wc, logs = ensure_conversion('main')
# The initial import.
for path in ('/trunk/proj', '/trunk/proj/default', '/trunk/proj/sub1',
'/trunk/proj/sub1/default', '/trunk/proj/sub1/subsubA',
'/trunk/proj/sub1/subsubA/default', '/trunk/proj/sub1/subsubB',
'/trunk/proj/sub1/subsubB/default', '/trunk/proj/sub2',
'/trunk/proj/sub2/default', '/trunk/proj/sub2/subsubA',
'/trunk/proj/sub2/subsubA/default', '/trunk/proj/sub3',
'/trunk/proj/sub3/default'):
if not (logs[11].changed_paths.get(path) == 'A'):
raise svntest.Failure
if logs[11].msg.find('Initial revision') != 0:
raise svntest.Failure
# The first commit.
for path in ('/trunk/proj/sub1/subsubA/default', '/trunk/proj/sub3/default'):
if not (logs[12].changed_paths.get(path) == 'M'):
raise svntest.Failure
if logs[12].msg.find('First commit to proj, affecting two files.') != 0:
raise svntest.Failure
# The second commit.
for path in ('/trunk/proj/default', '/trunk/proj/sub1/default',
'/trunk/proj/sub1/subsubA/default',
'/trunk/proj/sub1/subsubB/default',
'/trunk/proj/sub2/default',
'/trunk/proj/sub2/subsubA/default',
'/trunk/proj/sub3/default'):
if not (logs[13].changed_paths.get(path) == 'M'):
raise svntest.Failure
if logs[13].msg.find('Second commit to proj, affecting all 7 files.') != 0:
raise svntest.Failure
def interleaved_commits():
"two interleaved trunk commits, with different log msgs"
# See test-data/main-cvsrepos/proj/README.
repos, wc, logs = ensure_conversion('main')
# The initial import.
for path in ('/trunk/interleaved',
'/trunk/interleaved/1',
'/trunk/interleaved/2',
'/trunk/interleaved/3',
'/trunk/interleaved/4',
'/trunk/interleaved/5',
'/trunk/interleaved/a',
'/trunk/interleaved/b',
'/trunk/interleaved/c',
'/trunk/interleaved/d',
'/trunk/interleaved/e',):
if not (logs[15].changed_paths.get(path) == 'A'):
raise svntest.Failure
if logs[15].msg.find('Initial revision') != 0:
raise svntest.Failure
# This PEP explains why we pass the 'logs' parameter to these two
# nested functions, instead of just inheriting it from the enclosing
# scope: http://www.python.org/peps/pep-0227.html
def check_letters(rev, logs):
'Return 1 if REV is the rev where only letters were committed, else None.'
for path in ('/trunk/interleaved/a',
'/trunk/interleaved/b',
'/trunk/interleaved/c',
'/trunk/interleaved/d',
'/trunk/interleaved/e',):
if not (logs[rev].changed_paths.get(path) == 'M'):
return None
if logs[rev].msg.find('Committing letters only.') != 0:
return None
return 1
def check_numbers(rev, logs):
'Return 1 if REV is the rev where only numbers were committed, else None.'
for path in ('/trunk/interleaved/1',
'/trunk/interleaved/2',
'/trunk/interleaved/3',
'/trunk/interleaved/4',
'/trunk/interleaved/5',):
if not (logs[rev].changed_paths.get(path) == 'M'):
return None
if logs[rev].msg.find('Committing numbers only.') != 0:
return None
return 1
# One of the commits was letters only, the other was numbers only.
# But they happened "simultaneously", so we don't assume anything
# about which commit appeared first, we just try both ways.
if not ((check_letters(16, logs) and check_numbers(17, logs))
or (check_numbers(16, logs) and check_letters(17, logs))):
raise svntest.Failure
def simple_tags():
"simple tags"
# See test-data/main-cvsrepos/proj/README.
repos, wc, logs = ensure_conversion('main')
### The actual revision number here (and in other tests) will have
### to change when tags and branches are recognized.
if not logs.has_key(14):
raise svntest.Failure
for path in ('/tags/T_ALL_INITIAL_FILES/proj',
'/tags/T_ALL_INITIAL_FILES/proj/default',
'/tags/T_ALL_INITIAL_FILES/proj/sub1',
'/tags/T_ALL_INITIAL_FILES/proj/sub1/default',
'/tags/T_ALL_INITIAL_FILES/proj/sub1/subsubA',
'/tags/T_ALL_INITIAL_FILES/proj/sub1/subsubA/default',
'/tags/T_ALL_INITIAL_FILES/proj/sub1/subsubB',
'/tags/T_ALL_INITIAL_FILES/proj/sub1/subsubB/default',
'/tags/T_ALL_INITIAL_FILES/proj/sub2',
'/tags/T_ALL_INITIAL_FILES/proj/sub2/default',
'/tags/T_ALL_INITIAL_FILES/proj/sub2/subsubA',
'/tags/T_ALL_INITIAL_FILES/proj/sub2/subsubA/default',
'/tags/T_ALL_INITIAL_FILES/proj/sub3',
'/tags/T_ALL_INITIAL_FILES/proj/sub3/default'):
if not (logs[14].changed_paths.get(path) == 'A'):
raise svntest.Failure
for path in ('/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/default',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub1',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub1/default',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub1/subsubA',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub1/subsubA/default',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub1/subsubB',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub2',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub2/default',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub2/subsubA',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub2/subsubA/default',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub3',
'/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub3/default'):
if not (logs[14].changed_paths.get(path) == 'A'):
raise svntest.Failure
# Make sure that other tag does *not* have the missing file:
but_one = '/tags/T_ALL_INITIAL_FILES_BUT_ONE/proj/sub1/subsubB/default'
if logs[14].has_key(but_one):
raise svntest.Failure
### TODO: we also need to check copy history.
### What will the log message be, hmm?
if logs[14].msg.find('') != 0:
raise svntest.Failure
def simple_branch_commits():
"simple branch commits"
# See test-data/main-cvsrepos/proj/README.
repos, wc, logs = ensure_conversion('main')
### The actual revision number here (and in other tests) will have
### to change when tags and branches are recognized.
if not logs.has_key(14):
raise svntest.Failure
for path in ('/branches/B_MIXED/proj/default',
'/branches/B_MIXED/proj/sub1/default',
'/branches/B_MIXED/proj/sub2/subsubA/default'):
if not (logs[14].changed_paths.get(path) == 'M'):
raise svntest.Failure
if logs[14].msg.find('Modify three files, on branch B_MIXED.') != 0:
raise svntest.Failure
def mixed_commit():
"a commit affecting both trunk and a branch"
# See test-data/main-cvsrepos/proj/README.
repos, wc, logs = ensure_conversion('main')
for path in ('/trunk/proj/sub2/default',
'/branches/B_MIXED/proj/sub2/branch_B_MIXED_only'):
if not (logs[13].changed_paths.get(path) == 'M'):
raise svntest.Failure
if logs[13].msg.find('A single commit affecting one file on branch B_MIXED '
'and one on trunk.') != 0:
raise svntest.Failure
def split_branch():
"a branch created at different times in different places"
# See test-data/main-cvsrepos/proj/README.
repos, wc, logs = ensure_conversion('main')
# Don't yet know the exact revision numbers, but basically we're
# testing these steps from test-data/main-cvsrepos/proj/README:
#
# 13. Do "cvs up -A" to get everyone back to trunk, then make a new
# branch B_SPLIT on everyone except sub1/subsubB/default,v.
#
# 14. Switch to branch B_SPLIT (see sub1/subsubB/default disappear)
# and commit a change that affects everyone except sub3/default.
#
# 15. An hour or so later, "cvs up -A" to get sub1/subsubB/default
# back, then commit a change on that file, on trunk. (It's
# important that this change happened after the previous commits
# on B_SPLIT.)
#
# 16. Branch sub1/subsubB/default to B_SPLIT, then "cvs up -r B_SPLIT"
# to switch the whole working copy to the branch.
#
# 17. Commit a change on B_SPLIT, to sub1/subsubB/default and
# sub3/default.
raise svntest.Failure
#----------------------------------------------------------------------
########################################################################
# Run the tests
# list all tests here, starting with None:
test_list = [ None,
show_usage,
attr_exec,
space_fname,
two_quick,
prune_with_care,
double_delete,
simple_commits,
interleaved_commits,
XFail(simple_tags),
XFail(simple_branch_commits),
XFail(mixed_commit),
XFail(split_branch),
]
if __name__ == '__main__':
svntest.main.run_tests(test_list)
# NOTREACHED
### End of file.