#  -*- Python -*- -*- coding: iso-8859-1 -*-
# :Progetto: Bice -- Sync CVS->SVN
# :Sorgente: $HeadURL: http://svn.bice.dyndns.org/progetti/wip/tools/cvsync.py $
# :Creato:   mer 24 mar 2004 17:30:23 CET
# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
# :Modifica: $LastChangedDate: 2004-03-29 12:27:56 +0200 (Mon, 29 Mar 2004) $
# :Fatta da: $LastChangedBy: lele $
# 

"""Automatize the process of tracking CVS-based software within a SVN wc.

This script makes it easier to keep some third-party CVS-based software
up-to-date.  It is able to perform a ``cvs update``, collecting the
changed entries with the respective log, informing SVN about addition and
deletion and performing a commit.
"""

__docformat__ = 'reStructuredText'

class SystemCommand(object):
    """Wrap a single command to be executed by the shell."""

    COMMAND = None
    """The default command for this class.  Must be redefined by subclasses."""
    
    def __init__(self, command=None, working_dir=None):
        """Initialize a SystemCommand instance, specifying the command
           to be executed and eventually the working directory."""
        
        self.command = command or self.COMMAND
        """The command to be executed."""
        
        self.working_dir = working_dir
        """The working directory, go there before execution."""
        
        self.exit_status = None
        """Once the command has been executed, this is its exit status."""
        
    def __call__(self, output=None, dry_run=False, **kwargs):
        """Execute the command."""
        
        from os import system, popen, chdir
        from shutil import copyfileobj
        from StringIO import StringIO
        
        command = self.command % kwargs
        print command
        if self.working_dir:
            chdir(self.working_dir)

        if output:
            if output is True:
                output = StringIO()
                
            out = popen(command)
            copyfileobj(out, output)
            output.seek(0)
            
            self.exit_status = out.close()

            return output
        else:
            self.exit_status = system(command)


class CvsUpdate(SystemCommand):
    COMMAND = 'cvs %(dry)s up -I .svn -dP 2>&1'
    
    def __call__(self, output=None, dry_run=False, **kwargs):
        if dry_run:
            dry = '-n'
        else:
            dry = ''

        return SystemCommand.__call__(self, output=output,
                                      dry_run=dry_run, dry=dry)


class CvsLog(SystemCommand):
    COMMAND = 'cvs log -N %(rev)s %(entry)s'
       
    def __call__(self, output=None, dry_run=False, **kwargs):
        rev = kwargs['rev']
        if rev:
            kwargs['rev'] = '-r%s:' % rev
        
        return SystemCommand.__call__(self, output=output,
                                      dry_run=dry_run, **kwargs)


class ChangeSetCollector(object):
    """Collector of the applied change sets."""
    
    def __init__(self):
        """Initialize a ChangeSetCollector instance."""
        
        self.changesets = {}
        """The dictionary mapping (date, author, log) to each entry."""

    def __str__(self):
        """Concatenate the collected change logs in a string suitable for the
           commit log."""
        
        s = []
        keys = self.changesets.keys()
        keys.sort()
        for k in keys:
            e = self.changesets[k]
            d = 'Date: %s  Author: %s' % (k[0], k[1])
            s.append(d)
            s.append('-' * len(d))
            s.append('** %s' % ', '.join(e))
            s.append('')
            s.append(k[2])
            s.append('')

        return '\n'.join(s)
    
    def collect(self, date, author, log, entry):
        """Register a change set about an entry."""
        
        key = (date, author, log)
        if self.changesets.has_key(key):
            if entry not in self.changesets[key]:
                self.changesets[key].append(entry)
        else:
            self.changesets[key] = [entry]

    def parseRevision(self, entry, log):
        """Parse a single revision log, extracting the needed information
           and register it.

           Return False where there are no more logs to be parsed."""
        
        revision = log.readline()
        if not revision:
            return False
        info = log.readline().split(';')
        date = info[0][6:]
        author = info[1].strip()[8:]
        mesg = []
        l = log.readline()
        while not (l.startswith('----------------------------') or
                   l.startswith('============================')):
            mesg.append(l[:-1])
            l = log.readline()
        self.collect(date, author, '\n'.join(mesg), entry)
        return True
    
    def parseCvsLog(self, entry, log):
        """Parse a complete CVS log of an entry."""
        
        l = log.readline()
        while not l.startswith('----------------------------'):
            l = log.readline()
        while self.parseRevision(entry, log):
            pass


class CvsEntry(object):
    """Collect the info about a file in a CVS working dir."""
    
    __slots__ = ('filename', 'cvs_actual_version', 'cvs_actual_tag')

    def __init__(self, entry):
        """Initialize a CvsEntry."""
        
        dummy, fn, rev, date, dummy, tag = entry.split('/')
        self.filename = fn
        self.cvs_actual_version = rev
        self.cvs_actual_tag = tag

    def __str__(self):
        return "CvsEntry('%s', '%s', '%s')" % (self.filename,
                                               self.cvs_actual_version,
                                               self.cvs_actual_tag)


class CvsWorkingDir(object):
    """Represent a CVS working directory."""

    __slots__ = ('root', 'files', 'directories',
                 'added', 'removed', 'modified', 'conflicts')

    def __init__(self, root):
        """Initialize a CvsWorkingDir instance.

           Walk down the working directory, collecting info from each
           CVS/Entries found."""
        
        from os import walk, chdir
        from os.path import join, exists
        
        self.root = root
        """The directory in question."""
        
        self.files = {}
        """List of the files, under revision control, it contains."""
        
        self.directories = {}
        """List of subdirectories, under revision control, it contains."""
        
        self.added = []
        """List of added entries, after update."""
        
        self.modified = []
        """List of modified entries, after update."""
        
        self.removed = []
        """List of removed entries, after update."""
        
        self.conflicts = []
        """List of conflicting entries, after update."""

        chdir(root)
        
        entries = join(root, 'CVS/Entries')
        if exists(entries):
            for entry in open(entries).readlines():
                entry = entry[:-1]
                if entry.startswith('/'):
                    e = CvsEntry(entry)
                    self.files[e.filename] = e

            for root, dirs, files in walk(root):
                if '.svn' in dirs:
                    dirs.remove('.svn')
                if 'CVS' in dirs:
                    dirs.remove('CVS')
                for d in dirs:
                    if not exists(join(root, d, 'CVS/Entries')):
                        dirs.remove(d)
                    else:
                        subdir = CvsWorkingDir(join(root, d))
                        self.directories[d] = subdir

                # Do not descend further, this is done by the subdir objects!
                dirs[:] = []
                        
    def __str__(self):
        return "CvsWorkingDir('%s', %d files, %d subdirectories)" % (
            self.root, len(self.files), len(self.directories))

    def getFileInfo(self, fpath):
        """Fetch the info about a path, if known.  Otherwise return None."""

        try:
            if '/' in fpath:
                dir,rest = fpath.split('/', 1)
                return self.directories[dir].getFileInfo(rest)
            else:
                return self.files[fpath]
        except KeyError:
            return None

    def update(self, dry_run=False):
        """Execute a ``cvs update`` on the directory, collecting applied
           changes in a ChangeSetCollector."""
        
        cvsup = CvsUpdate(working_dir=self.root)
        output = cvsup(output=True, dry_run=dry_run)

        for line in output:
            if line[0] in 'UP':
                fname = line[2:-1]
                if self.getFileInfo(fname):
                    self.modified.append(fname)
                else:
                    self.added.append(fname)                
            elif line[0] == 'A':
                self.added.append(line[2:-1])
            elif line[0] == 'C':
                self.conflicts.append(line[2:-1])
            elif 'is no longer in the repository' in line:
                fname = line[13:].split("'")[0]
                self.removed.append(fname)
            elif 'is modified but no longer in the repository' in line:
                fname = line[23:].split("'")[0]
                self.removed.append(fname)
            elif 'is not (any longer) pertinent' in line:
                if 'warning:' in line:
                    fname = line[line.index('warning:')+9:
                                 line.index(' is not (any longer) pertinent')]
                else:
                    fname = line[22:].split("'")[0]
                self.removed.append(fname)
            ##else:
            ##    print "????", line

        touched = self.added + self.modified + self.removed

        changesets = ChangeSetCollector()
        cvslog = CvsLog(working_dir=self.root)
        done = {}
        for entry in touched:
            if not done.has_key(entry):
                actualrev = self.getFileInfo(entry)
                if actualrev:
                    rev = actualrev.cvs_actual_version
                else:
                    rev = ''
                log = cvslog(output=True, entry=entry, rev=rev)
                changesets.parseCvsLog(entry, log)
                done[entry] = True
                
        return changesets


class SvnWorkingDir(object):
    """Represent a SVN working directory."""

    __slots__ = ('root', 'pool', 'context')

    def __init__(self, root, pool):
        """Initialize a SvnWorkingDir instance."""
        
        from svn import core, client

        self.root = root
        """The directory in question."""
        
        self.pool = pool
        """The svn memory pool."""

        self.context = client.svn_client_create_context(self.pool)
        """The svn client context."""

        self.context.config = core.svn_config_get_config(None, pool)
        
    def update(self):
        """Bring this directory up to its HEAD revision in the repository,
           returning the revision number."""

        from svn import core, client

        pool = core.svn_pool_create(self.pool)
        revision = core.svn_opt_revision_t()
        revision.kind = core.svn_opt_revision_head
        recurse = True

        assert(self.root)
        
        try:
            revnum = client.svn_client_update(self.root, revision,
                                              recurse,
                                              self.context, pool)
        finally:
            core.svn_pool_destroy(pool)

        return revnum
    
    def commit(self, msg):
        """Commit the changes."""

        from svn import client

        nonrecurse = False

        ## XXX: find the way to attach the log msg to the context,
        ## in ``log_msg_func``.
        
        client.svn_client_commit([self.root], nonrecurse,
                                 self.context, self.pool)

    def add(self, entry):
        """Add an entry."""

        from svn import client
        from os.path import join
        
        recurse = False

        ## XXX: find a way to specify --no-auto-props
        
        client.svn_client_add(join(self.root, entry), recurse,
                              self.context, self.pool)

    def remove(self, entry):
        """Remove an entry."""

        from svn import client
        from os.path import join

        force = False
        
        client.svn_client_delete([join(self.root, entry)], force,
                                 self.context, self.pool)


class Syncronizer(object):
    """Perform the needed steps to syncronize CVS world with SVN."""
    
    def __init__(self, root, pool):
        """Initialize a Syncronizer."""
        
        self.root = root
        """The directory under both CVS and SVN version control."""
        
        self.pool = pool
        """The svn memory pool."""
        
        self.cvs_wc = CvsWorkingDir(self.root)
        """The CVS point-of-view."""
        
        self.svn_wc = SvnWorkingDir(self.root)
        """The SVN point-of-view."""

    def __call__(self, options):
        """Do the mentioned steps :)"""

        # Bring the svnwc up-to-date
        self.svn_wc.update()

        # Do a cvs update and collect the changes
        changes = self.cvs_wc.update(dry_run=options.dry_run)

        if self.cvs_wc.conflicts:
            print "CAUTION: you should look at the following conflicts first:"
            print ' '.join(cvs.conflicts)

            if cvs.added:
                print "You should then execute: svn add %s" % ' '.join(cvs.added)
            if cvs.removed:
                print "Followed by: svn remove %s" % ' '.join(cvs.removed)
        else:
            if not options.dry_run:
                for a in cvs.added:
                    svn.add(a)

                for r in cvs.removed:
                    svn.remove(r)

                svn.commit(str(changes))
            else:
                if cvs.added:
                    print "You should then execute: svn add %s" % ' '.join(cvs.added)
                if cvs.removed:
                    print "Followed by: svn remove %s" % ' '.join(cvs.removed)

                print changes


def main(pool, options, args):
    for wc in args:
        sync = Syncronizer(wc, pool)
        sync(options)
    

if __name__ == '__main__':
    from optparse import OptionParser
    
    try:
        from svn.core import run_app
    except ImportError:
        import sys

        sys.path.append('/usr/local/lib/svn-python/')
        from svn.core import run_app
        
    parser = OptionParser(usage='%prog [options] working_dir [wc2 ...]'
)
    parser.add_option("-d", "--dry-run", dest="dry_run",
                      action="store_true", default=False,
                      help="Do not perform anything harmful, just show what could happen." )
                          
    options, args = parser.parse_args()
    if len(args) == 0:
        parser.error('No working directory specified, one is mandatory.')

    run_app(main, options, args)

