#!/usr/bin/env python
"""svn2cvs - Create a cvs repository from a subversion repository"""

from __future__ import print_function

import errno
import optparse
import os
import random
import re
import shutil
import stat
import subprocess
import sys
import time
import xml.dom.minidom

OPTS = None
PROG = '?'

MAGIC = '+++cvs2svn+++'
MYTMPDIR = None
PROJDN = 'proj'

def cleanup():
    """Clean up temporary files"""
    global MYTMPDIR
    if MYTMPDIR != None:
	if OPTS.verbose > 0:
	    print('+ shutil.rmtree(%s)' % (MYTMPDIR))
        shutil.rmtree(MYTMPDIR)
        MYTMPDIR = None

def copysvn2cvs(revs, svnurl, cvsrepo, cvswork):
    """Copy svn revisions to the cvs repo, return status and list of files"""
    # svn export to cvswork and then cvs import

    seendirs = []                       # dirs already cvs add'ed
    seenfiles = []                      # files already cvs add'ed
    ignorefiles = getfiles(cvswork)     # cvs files to ignore

    # Loop through svn revs
    for rev in revs:
        n = rev['revision']
        msg = '%s|%s|%s\n' % (MAGIC, rev['date'], rev['author'])
        if 'msg' in rev:
            msg += rev['msg']

        # svn export current revision
        (ret, lines) = run(['svn', 'export', '--force', '-r%d' % (n),
            svnurl + '/trunk', '.'], cvswork)
        if lines != None:
            sys.stdout.write(lines)
        if ret != 0:
            return (ret, None)

        # Get current list of cvs working files; calculate new dirs and files
        cvsfiles = getfiles(cvswork, ignorefiles)
        newdirs = []
        newfiles = []
        for fn in cvsfiles:
            if fn not in seenfiles:
                seenfiles.append(fn)
                newfiles.append(fn)
                if fn != os.path.basename(fn):
                    # Add new directory
                    dn = fn
                    while True:
                        dn = os.path.split(dn)[0]
                        if dn == '':
                            break
                        if dn not in seendirs:
                            seendirs.append(dn)
                            if dn not in newdirs:
                                newdirs.append(dn)

            # Need to sort newdirs at least so the cvs add's play out correctly
            newdirs.sort()
            newfiles.sort()

        # cvs add any new directories and files
        if len(newfiles) > 0 or len(newdirs) > 0:
            cmd = ['cvs', '-d', cvsrepo, 'add']
            if len(newdirs) > 0:
                cmd.extend(newdirs)
            if len(newfiles) > 0:
                cmd.extend(newfiles)
            (ret, lines) = run(cmd, cvswork)
            if lines != None:
                sys.stdout.write(lines)
            if ret != 0:
                return (ret, None)

        # cvs commit changes
        (ret, lines) = run(['cvs', '-d', cvsrepo, 'commit', '-m', msg], cvswork)
        if lines != None:
            sys.stdout.write(lines)
        if ret != 0:
            return (ret, None)

    seenfiles.sort()
    return (0, seenfiles)

def createcvsrepo(cvsrepo, empty):
    """Create a cvs repo directory; deal with possible existing directory"""
    try:
        st = os.lstat(cvsrepo)
    except OSError, e:
        if e.errno != errno.ENOENT:
            raise e
        st = None

    if st != None:
        if not OPTS.force:
            print('%s: %s exists (use -f to remove and recreate)' % (PROG,
                cvsrepo), file=sys.stderr)
            return 1
        if OPTS.verbose > 0:
            print('+ shutil.rmtree(%s)' % (cvsrepo))
        shutil.rmtree(cvsrepo)

    # Initialize the repo
    (ret, lines) = run(['cvs', '-d', cvsrepo, 'init'])
    if ret != 0:
        sys.stdout.write(lines)
        return ret

    # Do the initial import (of no files)
    (ret, lines) = run(['cvs', '-d', cvsrepo, 'import', '-d', '-m', 'initial',
        PROJDN, 'root', 'initial'], empty)
    if ret != 0:
        sys.stdout.write(lines)
        return ret

    return 0

def createcvswork(cvsrepo, cwd, dn):
    """Create a cvs work directory"""
    (ret, lines) = run(['cvs', '-d', cvsrepo, 'checkout', dn], cwd)
    if lines != None:
        # Insure one trailing newline
        lines = lines.rstrip('\n') + '\n'
        sys.stdout.write(lines)
    return ret

def date2ts(sdate):
    """Convert svn date string to unix ts"""
    # example: '2009-12-15T19:21:57.783232Z'
    tup = sdate.partition('.')
    thedate = time.strptime(tup[0], '%Y-%m-%dT%H:%M:%S')

    # There doesn't appear to be a "gmt" version of mktime()
    if 'TZ' in os.environ:
        otz = os.environ['TZ']
    else:
        otz = None
    os.environ['TZ'] = 'UTC'

    # Do the conversion
    ts = int(time.mktime(thedate))

    # Restore environment
    if otz != None:
        os.environ['TZ'] = otz
    else:
        del os.environ['TZ']
    return ts

def fixupcvs(fn):
    """Rewrite a ,v file to reflect the correct dates and authors"""
    ret = writable(fn)
    if ret != 0:
        return ret

    re_rev1 = re.compile(r'\d[\d.]*\d')
    magic = '@' + MAGIC + '|'

    tmp = fn + '-'

    # Pass one: extract and remove info
    revs = {}
    with open(fn) as fin:
        with open(tmp, 'w') as fout:
            # Copy first part
            while True:
                line = fin.readline()
                if line == '':
                    break
                fout.write(line)
                if line == '@@\n':
                    break

            # Collect and revision info
            n = None
            sawlog = False
            for line in fin.readlines():
                if len(line) > 0 and line[0].isdigit() and re_rev1.match(line):
                    # Save revision
                    n = line.rstrip()
                elif line == 'log\n':
                    sawlog = True
                elif sawlog:
                    sawlog = False
                    if line.startswith(magic):
                        tup = line.rstrip().split('|')
                        if len(tup) == 3 and tup[1].isdigit():
                            # Save ts and author
                            revs[n] = [int(tup[1]), tup[2]]
                            # Output just an '@' with no newline
                            line = '@'
                fout.write(line)

    re_rev2 = re.compile(r'(date\s+)\d{4}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{2}(;\s+author\s)[^;]+(;.*)')

    # Pass two: update rev date and authors
    with open(tmp) as fin:
        with open(fn, 'w') as fout:
            while True:
                line = fin.readline()
                if line == '':
                    break
                if len(line) > 0 and line[0].isdigit() and re_rev1.match(line):
                    # Save revision
                    n = line.rstrip()
                elif line.startswith('date') and n in revs:
                    m = re_rev2.match(line)
                    if m != None:
                        line = m.group(1)
                        thedate = time.gmtime(revs[n][0])
                        line += time.strftime('%Y.%m.%d.%H.%M.%S', thedate)
                        line += m.group(2)
                        line += revs[n][1]
                        line += m.group(3) + '\n'
                fout.write(line)
                if line == '@@\n':
                    break

            # Copy rest of file
            for line in fin.readlines():
                fout.write(line)

    # Clean up
    os.remove(tmp)

    ret = writable(fn, False)
    if ret != 0:
        return ret
    return 0

def fixupcvsfiles(files, cvsrepo):
    """Loop through and update cvs files"""
    for fn in files:
        ret = fixupcvs(os.path.join(cvsrepo, fn + ',v'))
        if ret != 0:
            return ret
    return 0

def getfiles(cvswork, ignore=[], dn=None):
    """Return list of files in cvs working directory"""
    files = []
    for fn in os.listdir(cvswork):
        longname = os.path.join(cvswork, fn)
        st = os.lstat(longname)

        # File
        if stat.S_ISREG(st.st_mode):
            if dn == None:
                shortfn = fn
            else:
                shortfn = os.path.join(dn, fn)
            if shortfn not in ignore:
                files.append(shortfn)
            continue

        # Directory
        if stat.S_ISDIR(st.st_mode):
            files.extend(getfiles(longname, ignore, fn))

    files.sort();
    return files

def getsvnfiles(svnurl):
    """Return list of files in svn repo"""
    (ret, lines) = run(['svn', 'ls', '-R', os.path.join(svnurl, 'trunk')])
    if ret != 0:
        if lines != None:
            sys.stdout.write(lines)
        return (ret, None)
    if lines == '':
        return (ret, [])
    files = lines.split('\n')
    files.sort();
    return (ret, files)

def getsvnrevs(svnurl):
    """Return list of svn revisions and log entries"""
    (ret, lines) = run(['svn', 'log', '--xml', svnurl])
    if ret != 0:
        if lines != None:
            sys.stdout.write(lines)
        return (ret, None)
    xmldoc = xml.dom.minidom.parseString(lines)
    logs = xmldoc.getElementsByTagName('logentry')
    revs = []
    for e in logs:
        d = {}

        # Attributes (only expecting 'revision')
        for i in range(e.attributes.length):
            attr = e.attributes.item(i)
            if attr.nodeName == 'revision':
                d[attr.nodeName] = int(attr.nodeValue)
            else:
                d[attr.nodeName] = attr.nodeValue

        # Child node (expecting 'author', 'date', 'msg')
        for e2 in e.childNodes:
            for e3 in e2.childNodes:
                if e2.nodeName == 'date':
                    d[e2.nodeName] = date2ts(e3.nodeValue)
                else:
                    d[e2.nodeName] = e3.nodeValue

        # Make sure we have these
        for w in ('author', 'msg'):
            if w not in d:
                d[w] = ''
        revs.append(d)

    # Want low revisions first
    revs.reverse()

    return (ret, revs)

def mymakedirs(dn):
    """Create a directory"""
    if OPTS.verbose > 0:
        print('+ os.makedirs(%s)' % (dn))
    os.makedirs(dn)
    return 0

def mymktemp(w=6):
    """Return a string of random alphanumerics"""
    an = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
    return ''.join(map(lambda x: random.choice(an), range(w)))

def run(cmd, cwd=None):
    """Run a command, return the status and list of output lines"""
    if OPTS.verbose > 0:
        scmd = ' '.join(cmd)
        if cwd != None:
            scmd = '(cd %s && %s)' % (cwd, scmd)
        print('+', scmd)

    try:
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT, cwd=cwd)
    except OSError, e:
        if e.errno != errno.ENOENT:
            raise e
        return (1, '%s: %s: %s' % (PROG, cmd[0], e.strerror))
    lines = p.communicate()[0]
    return (p.returncode, lines)

def writable(fn, rw=True):
    """Make a file owner writable"""
    st = os.lstat(fn)
    mode = st.st_mode
    if rw:
        mode |= stat.S_IWUSR
    else:
        mode &= ~stat.S_IWUSR
    os.chmod(fn, mode)
    return 0

def main(argv=sys.argv):
    """Parse options, run program"""
    global OPTS
    global PROG
    global MYTMPDIR

    PROG = os.path.basename(argv[0])

    description = """\
Create a new cvs repository from an existing subversion repository.
SVNURL should be a file:// type; svn2cvs doesn't know how to deal
with svn authentication. The CVSREPO must not exist unless the -f
flag is used in which case it is clobbered before the conversion
starts. If something goes wrong, a temporary directory may be left
behind.
    """

    usage = 'usage: %prog [-dfvD] [-T TMPDIR] SVNURL CVSREPO'

    parser = optparse.OptionParser(usage=usage, description=description)
    parser.add_option('-d', None,
        action='count', dest='debug', default=0,
        help='increase debugging output')
    parser.add_option('-f', None,
        action='store_true', dest='force', default=False,
        help='clobber cvs repository')
    parser.add_option('-v', None,
        action='count', dest='verbose', default=0,
        help='increase verbosity')
    parser.add_option('', '--debugger',
        action='store_true', dest='Debug', default=False,
        help='interactive debugging')
    parser.add_option('-T', None,
        action='store', dest='tmpdir', default='/tmp',
        help='temporary directory (default: %default)')

    (OPTS, args) = parser.parse_args()

    if len(args) != 2:
        parser.print_help()
        return 1

    svnurl = args.pop(0)
    cvsrepo = args.pop(0)

    # Interactive debugging
    if OPTS.Debug:
        import pdb
        pdb.set_trace()

    # Create temporary directory
    MYTMPDIR = os.path.join(OPTS.tmpdir, PROG + '-' + mymktemp())
    ret = mymakedirs(MYTMPDIR)
    if ret != 0:
        cleanup()
        return ret

    # Create cvs repo
    ret = createcvsrepo(cvsrepo, MYTMPDIR)
    if ret != 0:
        cleanup()
        return ret

    # Create cvs working directory
    cvswork = os.path.join(MYTMPDIR, PROJDN)
    ret = createcvswork(cvsrepo, MYTMPDIR, PROJDN)
    if ret != 0:
        cleanup()
        return ret

    # Get list of svn revisions, dates and comments
    (ret, revs) = getsvnrevs(svnurl)
    if ret != 0:
        cleanup()
        return ret

    # Copy svn revisions to cvs repo
    (ret, files) = copysvn2cvs(revs, svnurl, cvsrepo, cvswork)
    if ret != 0:
        cleanup()
        return ret

    # Cleanup temporary files
    cleanup()

    # Fix up dates and authors in ,v files
    ret = fixupcvsfiles(files, os.path.join(cvsrepo, PROJDN))
    if ret != 0:
        cleanup()
        return ret

    print('%s: succesfully created %s' % (PROG, cvsrepo))
    return 0

if __name__ == "__main__":
    sys.exit(main())
