Trunk-to-branch script

Jon Foster <[email protected]> Fri, 26 Mar 2010 18:10:06 -0000
Newsgroups gmane.comp.version-control.subversion.cvs2svn.devel
Message-ID <[email protected]>
Hi,

In CVS, we have directories called "config_for_project_FOO".  These
contain a script that will check out all the other modules, typically
from PROJECT-FOO-BR branch.  The config directory is on trunk.

In SVN, we'd like to have these config directories on the same branch
as all the other code.  (Partly because it's slightly trickier to do
multi-branch checkouts in Subversion.  We'd have to use svn:externals
or "svn switch", and both those options add complexity.  I'd rather
our first-time Subversion users didn't have to learn about those
features).

So we plan to use the attached script to rewrite the history of the
config directories, so cvs2svn puts them on the right branch.

Originally, I had modified cvs2svn to do this, but having a separate
script is simpler.

One side effect of this script is that all pre-existing branches are
thrown away.  This is just to make the script slightly simpler to
write and easier to test - I know in my usecase there aren't any
other interesting branches.

Would you be interested in putting this in contrib/?

Kind regards,

Jon
--


**********************************************************************
This email and its attachments may be confidential and are intended solely for the use of the individual to whom it is addressed. Any views or opinions expressed are solely those of the author and do not necessarily represent those of Cabot Communications Ltd.

If you are not the intended recipient of this email and its attachments, you must take no action based upon them, nor must you copy or show them to anyone.

Cabot Communications Limited
Verona House, Filwood Road, Bristol BS16 3RY, UK
+44 (0) 1179584232

Co. Registered in England number 02817269

Please contact the sender if you believe you have received this email in error.

**********************************************************************


______________________________________________________________________
This email has been scanned by the MessageLabs Email Security System.
For more information please visit http://www.messagelabs.com/email 
______________________________________________________________________

------------------------------------------------------
http://cvs2svn.tigris.org/ds/viewMessage.do?dsForumId=1667&dsMessageId=2465601

To unsubscribe from this discussion, e-mail: [[email protected]].
trunk_to_branch.txt (text/plain, 17.4 KB)
#! /usr/bin/python

# (Be in -*- python -*- mode.)
#
# ====================================================================
# Copyright (C) 2010 Cabot Communications Ltd.  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.
#
# This software consists of voluntary contributions made by many
# individuals.  For exact contribution history, see the revision
# history and logs, available at http://cvs2svn.tigris.org/.
# ====================================================================

"""Usage: trunk_to_branch.py BRANCHNAME PATH...

Modify RCS files in PATH to:

1) Remove all existing branches, including tags that are on them.
  - Tags that are on trunk are kept
  - Vendor branches with a single "cvs import" are supported.
    The commit message and any tags will be moved from r1.1.1.1
    to r1.1.  (The contents of r1.1 and r1.1.1.1 is always the
    same anyway)
  - Vendor branches with more than one "cvs import" are not
    supported and will cause a fatal error.

2) Rewrite history to change the old trunk to a branch.  The new
   history will show that the file was never committed to trunk,
   only to the branch.  BRANCHNAME is the name of the new branch.
   I.e. if the old history of "whatever.c" is:

     1.1 log: "first commit", file contents: "foo"
     1.2 log: "change to bar", file contents: "bar"
     1.3 log: "got it right this time", file contents: "baz"

   then the new history will be:

   1.1 (file deleted), log: "file whatever.c was initially added
                             on branch BRANCHNAME".
     --- Branch 1.1.2 BRANCHNAME ---
     1.1.2.1 log: "first commit", file contents: "foo"
     1.1.2.2 log: "change to bar", file contents: "bar"
     1.1.2.3 log: "got it right this time", file contents: "baz"

Rationale:

  In CVS, we have directories called "config_for_project_FOO".  These
  contain a script that will check out all the other modules, typically
  from PROJECT-FOO-BR branch.  The config directory is on trunk.

  In SVN, we'd like to have these config directories on the same branch
  as all the other code.  (Partly because it's slightly trickier to do
  multi-branch checkouts in Subversion.  We'd have to use svn:externals
  or "svn switch", and both those options add complexity).

   So we're using this script to rewrite the history of the config
   directories, so cvs2svn puts them on the right branch.
"""

from __future__ import with_statement

import sys
import os

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import cvs2svn_rcsparse
from rcs_file_filter import WriteRCSFileSink
from cvs2svn_lib.common import is_trunk_revision
from cvs2svn_lib.rcs_stream import RCSStream

class RcsCorruptionError(Exception):
    pass

class RevData:
    """Data about a CVS revision.

    Member variables:

    REVISION is a string revision number, e.g. "1.1".
    TIMESTAMP is the timestamp in UNIX format
    AUTHOR is a string containing the author name.
    STATE is the state as a string, typically "Exp" or "dead".
    NEXT is the string revision number of the next revision along this
         branch (for branch revisions), or the previous revision on
         trunk (for trunk revisions). If there is no such revision, NEXT is
         None.
    BRANCHES is a list containing the string revision numbers of the first
             commit on each branch which branches from here.
    LOG is the log message, as a string.  None if it hasn't yet been read.
    TEXT is the contents of this revision as a string.  This is fulltext for
        the current head revision, and an RCS diff for any other revision.
        None if it hasn't yet been read.
    """
    def __init__(self, revision, timestamp, author, state, next):
        self.revision = revision
        self.timestamp = timestamp
        self.author = author
        self.state = state
        self.next = next
        self.log = None
        self.text = None
        self.branches = []

def fulltext_to_rcsdiff_vs_empty(new_full_text):
    """Does a RCS diff from an empty "old" version and a specified "new"
    version.  Returns the RCS diff."""
    if new_full_text == '':
        # Old and new are identical, so diff is empty
        return ''

    # Count number of lines, remembering that the last line might not end
    # with a newline.
    num_lines = new_full_text.count('\n')
    if not new_full_text.endswith('\n'):
        num_lines = num_lines + 1

    # RCS diff header saying "insert at the start of the file, the
    # following NUM_LINES lines".
    header = 'a0 %d\n' % (num_lines,)

    return header + new_full_text

# When doing this trunk-to-branch process, we don't try to do incremental
# processing.  Instead, we record all the data we need as the file is
# parsed, throwing away branches and handling trivial vendor branches
# as we go.  We then have a second step where we do the trunk-to-branch
# conversion, and a third step to write out the results.
#
# General design philosophy: check everything.  If there's anything
# unexpected in the RCS file, throw an RcsCorruptionError and die.  (This
# reduces the risk of silently doing the wrong thing and corrupting data).
class CaptureRcsData(cvs2svn_rcsparse.Sink):
    def __init__(self):
        self.head = None
        self.principal_branch = None
        self.symbols = []
        self.revs = {}
        self.revs_list = []
        self.expansion = None

        # RCS junk that just gets copied through
        self.comment = None
        self.description = None

    #########################################################################
    # Step 1: Sink methods, used to populate this class
    #########################################################################

    def set_head_revision(self, revision):
        self.head = revision

    def set_principal_branch(self, branch_name):
        # There's a live vendor branch.  We currently only support the normal
        # vendor branch number, which is 1.1.1.
        if branch_name != '1.1.1':
            raise RcsCorruptionError(
                'Nonstandard vendor branch %r' % (branch_name,)
                )
        self.principal_branch = branch_name

    def set_access(self, accessors):
        # This field doesn't need to be preserved.
        pass

    def define_tag(self, name, revision):
        # Tag or branch.  Branches, and any tags on a branch, will be thrown
        # away.  Tags on the 1.1.1.1 vendor branch revision will be moved to
        # the corresponding trunk revision, rev 1.1.
        if revision == '1.1.1.1':
            revision = '1.1'
        if is_trunk_revision(revision):
            self.symbols.append((name, revision))

    def set_locker(self, revision, locker):
        # This field doesn't need to be preserved.
        pass

    def set_locking(self, mode):
        # This field doesn't need to be preserved.
        pass

    def set_comment(self, comment):
        self.comment = comment

    def set_expansion(self, mode):
        self.expansion = mode

    def admin_completed(self):
        if self.head is None:
            raise RcsCorruptionError('No head revision')

    def define_revision(
            self, revision, timestamp, author, state, branches, next
            ):
        if revision == '1.1.1.1':
            # Initial commit on vendor branch, created by "cvs import".
            # Just check it looks normal.
            if next is not None:
                raise RcsCorruptionError(
                    'More than one "cvs import" is not supported'
                    )
            if '1.1' not in self.revs:
                raise RcsCorruptionError(
                    'Misordered revisions: 1.1.1.1 should be after 1.1'
                    )
            if (self.revs['1.1'].author != author
                 or self.revs['1.1'].timestamp != timestamp
                 or self.revs['1.1'].state != state):
                raise RcsCorruptionError(
                    'Revision 1.1.1.1 doesn\'t look like a normal import'
                    )
        elif is_trunk_revision(revision):
            # Trunk revision we need to keep.
            # Check they're appearing in reverse chronological order, and
            # just add it to the list.
            if revision in self.revs:
                raise RcsCorruptionError(
                    'Duplicate revision %r' % (revision,)
                    )
            if len(self.revs_list) == 0:
                expect_rev = self.head
            else:
                expect_rev = self.revs_list[-1].next
            if revision != expect_rev:
                raise RcsCorruptionError(
                    'Misordered revisions: got %r, expected %r'
                        % (revision, expect_rev)
                    )

            new_rev = RevData(revision, timestamp, author, state, next)
            self.revs[revision] = new_rev
            self.revs_list.append(new_rev)

    def tree_completed(self):
        # We should now have initial entries for each revision.

        if not self.revs_list:
            raise RcsCorruptionError('No revisions')

        if self.revs_list[-1].next is not None:
            raise RcsCorruptionError(
                'Revision %r refers to nonexistant next rev %r'
                    % (self.revs_list[-1].revision, self.revs_list[-1].next)
                )

        if len(self.revs_list) > 1 and self.principal_branch is not None:
            raise RcsCorruptionError(
                'Vendor branch still active, dispite later trunk commits'
                )
        self.principal_branch = None # We're removing the vendor branch.

    def set_description(self, description):
        self.description = description

    def set_revision_info(self, revision, log, text):
        if revision == '1.1.1.1':
            # Initial commit on vendor branch, created by "cvs import".
            if '1.1' not in self.revs:
                raise RcsCorruptionError('Missing revision 1.1')
            if self.revs['1.1'].log is None:
                raise RcsCorruptionError(
                    'Misordered revisions: 1.1.1.1 should be after 1.1'
                    )
            if self.revs['1.1'].log != 'Initial revision\n' or text != '':
                raise RcsCorruptionError('Doesn\'t look like a normal import')
            self.revs['1.1'].log = log
        elif is_trunk_revision(revision):
            # Trunk revision we need to keep.  Just record LOG and TEXT.
            if revision not in self.revs:
                raise RcsCorruptionError('Missing revision %r' % (revision,))
            this_rev = self.revs[revision]
            if this_rev.log is not None:
                raise RcsCorruptionError(
                    'Duplicate data for revision %r' % (revision,)
                    )
            this_rev.log = log
            this_rev.text = text

    def parse_completed(self):
        for rev in self.revs.itervalues():
            if rev.log is None:
                raise RcsCorruptionError(
                    'Missing data for revision %r' % (rev.revision,)
                    )
        del self.revs # Finished with this dict.  Will use REVS_LIST later.


    #########################################################################
    # Step 2: Processing
    #########################################################################

    def trunk_to_branch(self, new_branch_name, filename):
        """Convert the trunk to a branch.

        NEW_BRANCH_NAME is the name of the new branch.
        FILENAME is the filename of this RCS file, which is only needed for
                 a log message.
        """
        
        # self.revs_list now contains revs in REVERSE chronological order,
        # starting with a fulltext and then with diffs for remaining ones.
        # Reverse it.
        stream = RCSStream(self.revs_list[0].text)
        for index in range(1, len(self.revs_list)):
            self.revs_list[index - 1].text = stream.invert_diff(
                self.revs_list[index].text
                )
        self.revs_list[-1].text = stream.get_text()
        self.revs_list.reverse()
        # self.revs_list now contains revs in NORMAL chronological order,
        # starting with a fulltext and then with diffs for remaining ones.

        # Renumber revs onto 1.1.2 branch.  As we go, we regenerate the
        # 'next' pointers and generate a mapping from old to new revision
        # numbers.  The mapping will be used later to update tags.
        revs_map = {}
        for idx, rev in enumerate(self.revs_list):
            new_revision = '1.1.2.%d' % (idx + 1)
            revs_map[rev.revision] = new_revision
            rev.revision = new_revision
            if idx != len(self.revs_list) - 1:
                # not last entry
                rev.next = '1.1.2.%d' % (idx + 2)
            else:
                rev.next = None

        # Tweak rev 1.1.2.1 to contain a diff rather than a fulltext.
        # We're going to make rev 1.1 empty, i.e. the old file we're going
        # to apply this diff to is zero bytes long.
        self.revs_list[0].text = fulltext_to_rcsdiff_vs_empty(
            self.revs_list[0].text
            )

        # Insert a new dead revision 1.1 at the start of self.revs.
        rev = RevData('1.1', self.revs_list[0].timestamp,
                      self.revs_list[0].author, 'dead', None)
        rev.text = ''
        rev.branches = ['1.1.2.1']
        rev.log = 'file %s was initially added on branch %s.\n' % (
                    filename, new_branch_name)
        self.revs_list.insert(0, rev)
        self.head = '1.1'

        # Map revision numbers in self.symbols.  As we go, double-check that
        # the new branch name isn't already used.
        new_symbols = []
        for (name, revision) in self.symbols:
            if name == new_branch_name:
                raise RcsCorruptionError(
                    'Symbol %r already exists' % (new_branch_name,)
                    )
            new_symbols.append((name, revs_map[revision]))
        self.symbols = new_symbols

        # Name the new branch.
        self.symbols.append((new_branch_name, '1.1.0.2'))

    #########################################################################
    # Step 3: Output
    #########################################################################

    def write(self, dest):
        """Write out the data to the Sink DEST."""
        dest.set_head_revision(self.head)
        for (name, revision) in self.symbols:
            dest.define_tag(name, revision)
        dest.set_locking("strict")
        dest.set_comment(self.comment)
        dest.set_expansion(self.expansion)
        dest.admin_completed()
        for rev in self.revs_list:
            dest.define_revision(rev.revision, rev.timestamp, rev.author,
                                 rev.state, rev.branches, rev.next)
        dest.tree_completed()
        dest.set_description(self.description)
        for rev in self.revs_list:
            dest.set_revision_info(rev.revision, rev.log, rev.text)
        dest.parse_completed()


def process_file(filename, branchname):
    """Process a single file.

    Removes pre-existing branches and converts the trunk to a branch called
    BRANCHNAME.  The RCS file on disk is replaced with the edited version.
    FILENAME is the full path to the RCS file on disk, which is read and then
    replaced with the edited version."""

    # Step 1: Read file
    with open(filename, 'rb') as infp:
        data = CaptureRcsData()
        cvs2svn_rcsparse.parse(infp, data)

    # Step 2: Processing
    basename = os.path.basename(filename)
    if basename.endswith(',v'):
        basename = basename[:-2]
    data.trunk_to_branch(branchname, basename)

    # Step 3: Write out
    tmp_filename = filename + '.tmp'
    with open(tmp_filename, 'wb') as outfp:
        data.write(WriteRCSFileSink(outfp))
    os.rename(tmp_filename, filename)

def iter_files_in_dir(top_path):
    """Iterator that gives the full path to every file in a specified
    directory tree.  This recurses into subdirectories."""
    for (dirpath, dirnames, filenames) in os.walk(top_path):
        for name in filenames:
            yield os.path.join(dirpath, name)

def iter_rcs_files(list_of_starting_paths, verbose=False):
    """Iterator that gives the full path to every RCS file in a specified list
    of starting paths.  If one of the starting paths is a directory, then it
    will list all RCS files in that directory, including subdirectories.

    Optionally, if VERBOSE is True, will print out messages about ignored
    files."""
    for base_path in list_of_starting_paths:
        if os.path.isfile(base_path) and base_path.endswith(',v'):
            yield base_path
        elif os.path.isdir(base_path):
            for file_path in iter_files_in_dir(base_path):
                if file_path.endswith(',v'):
                    yield file_path
                elif verbose:
                    sys.stdout.write('File %s is being ignored.\n' % file_path)
        elif verbose:
            sys.stdout.write('PATH %s is being ignored.\n' % base_path)

def main():
    if len(sys.argv) < 3:
        sys.stderr.write('Usage: %s BRANCHNAME PATH...\n' % (sys.argv[0],))
        sys.exit(1)

    branchname = sys.argv[1]
    for path in iter_rcs_files(sys.argv[2:], verbose=True):
        sys.stdout.write('Processing %s...' % path)
        process_file(path, branchname)
        sys.stdout.write('done.\n')

if __name__ == '__main__':
    main()