#!/usr/bin/env python
# -*-python-*-
#
# Copyright (C) 2004 James Henstridge
#
# By using this file, you agree to the terms and conditions set forth in
# the LICENSE.html file which can be found at the top level of the ViewVC
# distribution or at http://viewvc.org/license-1.html.
#
# For more information, visit http://viewvc.org/
#
# -----------------------------------------------------------------------
#
# administrative program for loading Subversion revision information
# into the checkin database.  It can be used to add a single revision
# to the database, or rebuild/update all revisions.
#
# To add all the checkins from a Subversion repository to the checkin
# database, run the following:
#    /path/to/svndbadmin rebuild /path/to/repo
#
# This script can also be called from the Subversion post-commit hook,
# something like this:
#    REPOS="$1"
#    REV="$2"
#    /path/to/svndbadmin update "$REPOS" "$REV"
#
# If you allow changes to revision properties in your repository, you
# might also want to set up something similar in the
# post-revprop-change hook using "rebuild" instead of "update" to keep
# the checkin database consistent with the repository.
#
# -----------------------------------------------------------------------
#

#########################################################################
#
# INSTALL-TIME CONFIGURATION
#
# These values will be set during the installation process. During
# development, they will remain None.
#

LIBRARY_DIR = None
CONF_PATHNAME = None

# Adjust sys.path to include our library directory
import sys
import os

if LIBRARY_DIR:
  sys.path.insert(0, LIBRARY_DIR)
else:
  sys.path.insert(0, os.path.abspath(os.path.join(sys.argv[0], "../../lib")))

#########################################################################

import os
import string
import re

import svn.core
import svn.repos
import svn.fs
import svn.delta

import cvsdb
import viewvc

class SvnRepo:
    """Class used to manage a connection to a SVN repository."""
    def __init__(self, path, pool):
        self.scratch_pool = svn.core.svn_pool_create(pool)
        self.path = path
        self.repo = svn.repos.svn_repos_open(path, pool)
        self.fs = svn.repos.svn_repos_fs(self.repo)
        # youngest revision of base of file system is highest revision number
        self.rev_max = svn.fs.youngest_rev(self.fs, pool)
    def __getitem__(self, rev):
        if rev is None:
            rev = self.rev_max
        elif rev < 0:
            rev = rev + self.rev_max + 1
        assert 0 <= rev <= self.rev_max
        rev = SvnRev(self, rev, self.scratch_pool)
        svn.core.svn_pool_clear(self.scratch_pool)
        return rev

_re_diff_change_command = re.compile('(\d+)(?:,(\d+))?([acd])(\d+)(?:,(\d+))?')

def _get_diff_counts(diff_fp):
    """Calculate the plus/minus counts by parsing the output of a
    normal diff.  The reasons for choosing Normal diff format are:
      - the output is short, so should be quicker to parse.
      - only the change commands need be parsed to calculate the counts.
      - All file data is prefixed, so won't be mistaken for a change
        command.
    This code is based on the description of the format found in the
    GNU diff manual."""

    plus, minus = 0, 0
    line = diff_fp.readline()
    while line:
        match = re.match(_re_diff_change_command, line)
        if match:
            # size of first range
            if match.group(2):
                count1 = int(match.group(2)) - int(match.group(1)) + 1
            else:
                count1 = 1
            cmd = match.group(3)
            # size of second range
            if match.group(5):
                count2 = int(match.group(5)) - int(match.group(4)) + 1
            else:
                count2 = 1

            if cmd == 'a':
                # LaR - insert after line L of file1 range R of file2
                plus = plus + count2
            elif cmd == 'c':
                # FcT - replace range F of file1 with range T of file2
                minus = minus + count1
                plus = plus + count2
            elif cmd == 'd':
                # RdL - remove range R of file1, which would have been
                #       at line L of file2
                minus = minus + count1
        line = diff_fp.readline()
    return plus, minus


class SvnRev:
    """Class used to hold information about a particular revision of
    the repository."""
    def __init__(self, repo, rev, pool):
        self.repo = repo
        self.rev = rev
        self.pool = pool
        self.rev_roots = {} # cache of revision roots

        subpool = svn.core.svn_pool_create(pool)

        # revision properties ...
        properties = svn.fs.revision_proplist(repo.fs, rev, pool)
        self.author = str(properties.get(svn.core.SVN_PROP_REVISION_AUTHOR,''))
        self.date = str(properties.get(svn.core.SVN_PROP_REVISION_DATE, ''))
        self.log = str(properties.get(svn.core.SVN_PROP_REVISION_LOG, ''))

        # convert the date string to seconds since epoch ...
        self.date = svn.core.secs_from_timestr(self.date, pool)

        # get a root for the current revisions
        fsroot = self._get_root_for_rev(rev)
        
        # find changes in the revision
        editor = svn.repos.RevisionChangeCollector(repo.fs, rev, pool)
        e_ptr, e_baton = svn.delta.make_editor(editor, pool)
        svn.repos.svn_repos_replay(fsroot, e_ptr, e_baton, pool)

        self.changes = []
        for path, change in editor.changes.items():

            # clear the iteration subpool
            svn.core.svn_pool_clear(subpool)

            # skip non-file changes
            if change.item_kind != svn.core.svn_node_file: continue

            # deal with the change types we handle
            base_root = None
            if change.base_path:
                base_root = self._get_root_for_rev(change.base_rev)
                
            if not change.path:
                action = 'remove'
            elif change.added:
                action = 'add'
            else:
                action = 'change'

            diffobj = svn.fs.FileDiff(base_root and base_root or None,
                                      base_root and change.base_path or None,
                                      change.path and fsroot or None,
                                      change.path and change.path or None,
                                      subpool, [])
            diff_fp = diffobj.get_pipe()
            plus, minus = _get_diff_counts(diff_fp)
            self.changes.append((path, action, plus, minus))

    def _get_root_for_rev(self, rev):
        """Fetch a revision root from a cache of such, or a fresh root
        (which is then cached for later use."""
        if not self.rev_roots.has_key(rev):
            self.rev_roots[rev] = svn.fs.revision_root(self.repo.fs, rev,
                                                       self.pool)
        return self.rev_roots[rev]


def handle_revision(db, command, repo, rev, verbose):
    """Adds a particular revision of the repository to the checkin database."""
    revision = repo[rev]
    committed = 0

    if verbose: print "Building commit info for revision %d..." % (rev),

    if not revision.changes:
        if verbose: print "skipped (no changes)."
        return

    for (path, action, plus, minus) in revision.changes:
        directory, file = os.path.split(path)
        commit = cvsdb.CreateCommit()
        commit.SetRepository(repo.path)
        commit.SetDirectory(directory)
        commit.SetFile(file)
        commit.SetRevision(str(rev))
        commit.SetAuthor(revision.author)
        commit.SetDescription(revision.log)
        commit.SetTime(revision.date)
        commit.SetPlusCount(plus)
        commit.SetMinusCount(minus)
        commit.SetBranch(None)

        if action == 'add':
            commit.SetTypeAdd()
        elif action == 'remove':
            commit.SetTypeRemove()
        elif action == 'change':
            commit.SetTypeChange()

        if command == 'update':
            result = db.CheckCommit(commit)
            if result:
                continue # already recorded

        # commit to database
        db.AddCommit(commit)
        committed = 1

    if verbose:
        if committed:
            print "done."
        else:
            print "skipped (already recorded)."

def main(pool, command, repository, rev=None, verbose=0):
    cfg = viewvc.load_config(CONF_PATHNAME)
    db = cvsdb.ConnectDatabase(cfg)

    repo = SvnRepo(repository, pool)
    if rev:
        handle_revision(db, command, repo, rev, verbose)
    else:
        for rev in range(repo.rev_max+1):
            handle_revision(db, command, repo, rev, verbose)

def usage():
    cmd = os.path.basename(sys.argv[0])
    sys.stderr.write("""
Usage: 1. %s [-v] rebuild REPOSITORY [REVISION]
       2. %s [-v] update REPOSITORY [REVISION]

1.  Rebuild the commit database information for REPOSITORY across all revisions
    or, optionally, only for the specified REVISION.

2.  Update the commit database information for REPOSITORY across all revisions
    or, optionally, only for the specified REVISION.  This is just like
    rebuilding, except that no commit information will be stored for
    commits already present in the database.

Use the -v flag to cause this script to give progress information as it works.
""" % (cmd, cmd))
    sys.exit(1)

if __name__ == '__main__':
    verbose = 0
    
    args = sys.argv
    try:
        index = args.index('-v')
        verbose = 1
        del args[index]
    except ValueError:
        pass
        
    if len(args) < 3:
        usage()

    command = string.lower(args[1])
    if command not in ('rebuild', 'update'):
        sys.stderr.write('ERROR: unknown command %s\n' % command)
        usage()

    repository = args[2]
    if not os.path.exists(repository):
        sys.stderr.write('ERROR: could not find repository %s\n' % repository)
        usage()

    if len(sys.argv) > 3:
        rev = sys.argv[3]
        try:
            rev = int(rev)
        except ValueError:
            sys.stderr.write('ERROR: revision "%s" is not numeric\n' % rev)
            usage()
    else:
        rev = None

    repository = cvsdb.CleanRepository(os.path.abspath(repository))
    svn.core.run_app(main, command, repository, rev, verbose)
