#!/usr/bin/perl -w

# This script will transfer changes from Subversion repository
# to CVS repository (e.g. SourceForge) while preserving commit
# logs.
#
# Based on original shell version by Tollef Fog Heen available at
# http://raw.no/personal/blog
#
# 2004-03-09 Dobrica Pavlinusic <dpavlin@rot13.org>
#
# documentation is after __END__

use strict;
use File::Temp qw/ tempdir /;
use File::Path;
use Data::Dumper;
use XML::Simple;
use Carp qw/confess/;

# do we want to sync just part of repository?
my $partial_import = 1;

# do we want to add svk-like prefix with original revision, author and date?
my $decorate_commit_message = 1;

if ( @ARGV < 2 ) {
	print "usage: $0 SVN_URL CVSROOT CVSREPOSITORY\n";
	exit 1;
}

my ( $SVNROOT, $CVSROOT, $CVSREP ) = @ARGV;

if ( $SVNROOT !~ m,^[\w+]+:///*\w+, ) {
	print "ERROR: invalid svn root $SVNROOT\n";
	exit 1;
}

# Ensure File::Temp::END can clean up:
$SIG{__DIE__} = sub { chdir("/tmp"); die @_ };

my $TMPDIR = tempdir( "/tmp/checkoutXXXXX", CLEANUP => 1 );

sub cd_tmp {
	chdir($TMPDIR) || die "can't cd to $TMPDIR: $!";
}

sub cd_rep {
	chdir("$TMPDIR/$CVSREP") || die "can't cd to $TMPDIR/$CVSREP: $!";
}

print "## using TMPDIR $TMPDIR\n";

# cvs command with root
my $cvs = "cvs -f -d $CVSROOT";

# current revision in CVS
my $rev;

#
# sub to do logging and system calls
#
sub log_system($$) {
	my ( $cmd, $errmsg ) = @_;
	print STDERR "## $cmd\n";
	system($cmd) == 0 || confess "$errmsg: $!";
}

#
# sub to commit .svn rev file later
#
sub commit_svnrev {
	my $rev     = shift @_;
	my $add_new = shift @_;

	die "commit_svnrev needs revision" if ( !defined($rev) );

	open( SVNREV, "> .svnrev" )
		|| die "can't open $TMPDIR/$CVSREP/.svnrev: $!";
	print SVNREV $rev;
	close(SVNREV);

	my $path = ".svnrev";

	if ($add_new) {
		system "$cvs add '$path'" || die "cvs add of $path failed: $!";
	} else {
		my $msg = "subversion revision $rev commited to CVS";
		print "$msg\n";
		system "$cvs commit -m '$msg' '$path'"
			|| die "cvs commit of $path failed: $!";
	}
}

sub add_dir($$) {
	my ( $path, $msg ) = @_;
	print "# add_dir($path)\n";
	die "add_dir($path) is not directory" unless ( -d $path );

	my $curr_dir;

	foreach my $d ( split( m#/#, $path ) ) {
		$curr_dir .= ( $curr_dir ? '/' : '' ) . $d;

		next if in_entries($curr_dir);
		next if ( -e "$curr_dir/CVS" );

		log_system( "$cvs add '$curr_dir'", "cvs add of $curr_dir failed" );
	}
}

# ok, now do the checkout
eval {
	cd_tmp;
	log_system( "$cvs -q checkout $CVSREP", "cvs checkout failed" );
};

if ($@) {
	print <<_NEW_REP_;

There is no CVS repository '$CVSREP' in your CVS. I will assume that
this is import of new module in your CVS and start from revision 0.

Press enter to continue importing new CVS repository or CTRL+C to abort.

_NEW_REP_

	print "start import of new module [yes]: ";
	my $in = <STDIN>;
	cd_tmp;
	mkdir($CVSREP) || die "can't create $CVSREP: $!";
	cd_rep;

	open( SVNREV, "> .svnrev" ) || die "can't open $CVSREP/.svnrev: $!";
	print SVNREV "0";
	close(SVNREV);

	$rev = 0;

	# create new module
	cd_rep;
	log_system( "$cvs import -d -m 'new CVS module' $CVSREP svn r$rev",
		"import of new repository" );
	cd_tmp;
	rmtree($CVSREP) || die "can't remove $CVSREP";
	log_system( "$cvs -q checkout $CVSREP", "cvs checkout failed" );
	cd_rep;

} else {

	# import into existing module directory in CVS

	cd_rep;

	# check if svnrev exists
	if ( !-e ".svnrev" ) {
		print <<_USAGE_;

Your CVS repository doesn't have .svnrev file!

This file is used to keep CVS repository and Subversion in sync, so
that only newer changes will be commited.

It's quote possible that this is first svn2cvs run for this repository.
If so, you will have to identify correct svn revision which
corresponds to current version of CVS repository that has just
been checkouted.

If you migrated your cvs repository to svn using cvs2svn, this will be
last Subversion revision. If this is initial run of conversion of
Subversion repository to CVS, correct revision is 0.

_USAGE_

		print "svn revision corresponding to CVS [abort]: ";
		my $in = <STDIN>;
		chomp($in);
		if ( $in !~ /^\d+$/ ) {
			print "Aborting: revision not a number\n";
			exit 1;
		} else {
			$rev = $in;
			commit_svnrev( $rev, 1 );    # create new
		}
	} else {
		open( SVNREV, ".svnrev" )
			|| die "can't open $TMPDIR/$CVSREP/.svnrev: $!";
		$rev = <SVNREV>;
		chomp($rev);
		close(SVNREV);
	}

	print "Starting after revision $rev\n";
	$rev++;
}

#
# FIXME!! HEAD should really be next verison and loop because this way we
# loose multiple edits of same file and corresponding messages. On the
# other hand, if you want to compress your traffic to CVS server and don't
# case much about accuracy and completnes of logs there, this might
# be good. YMMV
#
open( LOG, "svn log -r $rev:HEAD -v --xml $SVNROOT |" )
	|| die "svn log for repository $SVNROOT failed: $!";
my $log;
while (<LOG>) {
	$log .= $_;
}
close(LOG);

my $xml;
eval { $xml = XMLin( $log, ForceArray => [ 'logentry', 'path' ] ); };

#=begin log_example
#
#------------------------------------------------------------------------
#r256 | dpavlin | 2004-03-09 13:18:17 +0100 (Tue, 09 Mar 2004) | 2 lines
#
#ported r254 from hidra branch
#
#=cut

my $fmt = "\n" . "-" x 79 . "\nr%5s| %8s | %s\n\n%s\n";

if ( !$xml->{'logentry'} ) {
	print "no newer log entries in Subversion repostory. CVS is current\n";
	exit 0;
}

# return all files in CVS/Entries
sub entries($) {
	my $dir = shift;
	die "entries expects directory argument!" unless -d $dir;
	my @entries;
	open( my $fh, "./$dir/CVS/Entries" ) || return 0;
	while (<$fh>) {
		if ( m{^D/([^/]+)}, ) {
			my $sub_dir = $1;
			warn "#### entries recurse into: $dir/$sub_dir";
			push @entries, map {"$sub_dir/$_"} entries("$dir/$sub_dir");
			push @entries, $sub_dir;
		} elsif (m{^/([^/]+)/}) {
			push @entries, $1;
		} elsif ( !m{^D$} ) {
			die "can't decode entries line: $_";
		}
	}
	close($fh);
	warn "#### entries($dir) => ", join( "|", @entries );
	return @entries;
}

# check if file exists in CVS/Entries
sub in_entries($) {
	my $path = shift;
	if ( $path =~ m,^(.*?/*)([^/]+)$, ) {
		my ( $dir, $file ) = ( $1, $2 );
		if ( $dir !~ m,/$, && $dir ne "" ) {
			$dir .= "/";
		}

		open( my $fh, "./$dir/CVS/Entries" )
			|| return 0;    #die "no entries file: $dir/CVS/Entries";
		while (<$fh>) {
			return 1 if (m{^/$file/});
		}
		close($fh);
		return 0;
	} else {
		die "can't split '$path' to dir and file!";
	}
}

cd_tmp;
cd_rep;

foreach my $e ( @{ $xml->{'logentry'} } ) {
	die "BUG: revision from .svnrev ($rev) greater than from subversion ("
		. $e->{'revision'} . ")"
		if ( $rev > $e->{'revision'} );
	$rev = $e->{'revision'};
	log_system( "svn export --force -q -r $rev $SVNROOT $TMPDIR/$CVSREP",
		"svn export of revision $rev failed" );

	# deduce name of svn directory
	my $SVNREP  = "";
	my $tmpsvn  = $SVNROOT || die "BUG: SVNROOT empty!";
	my $tmppath = $e->{'paths'}->{'path'}->[0]->{'content'}
		|| die "BUG: tmppath empty!";
	do {
		if ( $tmpsvn =~ s#(/[^/]+)/*$## ) {    # vim fix
			$SVNREP = $1 . $SVNREP;
		} elsif ( $e->{'paths'}->{'path'}->[0]->{'copyfrom-path'} ) {
			print
				"NOTICE: copyfrom outside synced repository ignored - skipping\n";
			next;
		} else {
			print "NOTICE: can't deduce svn dir from $SVNROOT - skipping\n";
			next;
		}
	} until ( $tmppath =~ m/^$SVNREP/ );

	print "NOTICE: using $SVNREP as directory for svn\n";

	printf( $fmt,
		$e->{'revision'}, $e->{'author'}, $e->{'date'}, $e->{'msg'} );
	my @commit;

	my $msg = $e->{'msg'};
	$msg =~ s/'/'\\''/g;    # quote "

	$msg = 'r' . $rev . ' ' . $e->{author} . ' | ' . $e->{date} . "\n" . $msg
		if $decorate_commit_message;

	sub cvs_commit {
		my $msg = shift || die "no msg?";
		if ( !@_ ) {
			warn "commit ignored, no files\n";
			return;
		}
		warn "## cvs commit ", join( ",", @_ );
		log_system(
			"$cvs commit -m '$msg' '" . join( "' '", @_ ) . "'",
			"cvs commit of " . join( ",",            @_ ) . " failed"
		);
	}

	foreach my $p ( @{ $e->{'paths'}->{'path'} } ) {
		my ( $action, $path ) = ( $p->{'action'}, $p->{'content'} );

		next if ( $path =~ m#/\.svnrev$# );

		print "svn2cvs: $action $path\n";

		# prepare path and message
		my $file = $path;
		if ( $path !~ s#^\Q$SVNREP\E/*## ) {
			print
				"NOTICE: skipping '$path' which isn't under repository root '$SVNREP'\n";
			die unless $partial_import;
			next;
		}

		if ( !$path ) {
			print "NOTICE: skipped this operation. Probably trunk creation\n";
			next;
		}

		my $msg = $e->{'msg'};
		$msg =~ s/'/'\\''/g;    # quote "

		sub add_path {
			my $path = shift || die "no path?";

			if ( -d $path ) {
				add_dir( $path, $msg );
			} elsif ( $path =~ m,^(.+)/[^/]+$, && !-e "$1/CVS/Root" ) {
				my $dir = $1;
				in_entries($dir) || add_dir( $dir, $msg );
				in_entries($path) || log_system( "$cvs add '$path'",
					"cvs add of $path failed" );
			} else {
				in_entries($path) || log_system( "$cvs add '$path'",
					"cvs add of $path failed" );
			}
		}

		if ( $action =~ /M/ ) {
			if ( in_entries($path) ) {
				print "svn2cvs: modify $path -- nop\n";
			} else {
				print "WARNING: modify $path which isn't in CVS, adding...\n";
				add_path($path);
			}
		} elsif ( $action =~ /A/ ) {
			add_path($path);
		} elsif ( $action =~ /D/ ) {
			if ( -e $path ) {
				if ( -d $path ) {
					warn "#### remove directory: $path";
					foreach my $f ( entries($path) ) {
						$f = "$path/$f";
						if ( -f $f ) {
							unlink($f) || die "can't delete file $f: $!";
						} else {

						  #							rmtree($f) || die "can't delete dir $f: $!";
						}
						log_system( "$cvs delete '$f'",
							"cvs delete of file $f failed" );
						cvs_commit( $msg, $f );
						log_system(
							"$cvs update -dP '$f'",
							"cvs update -dP $f failed"
						);
					}
					log_system( "$cvs delete '$path'",
						"cvs delete of file $path failed" );
					cvs_commit( $msg, $path );
					log_system(
						"$cvs update -dP '$path'",
						"cvs update -dP $path failed"
					);
					undef $path;
				} else {
					warn "#### remove file: $path";
					unlink($path) || die "can't delete $path: $!";
					log_system( "$cvs delete '$path'",
						"cvs delete of dir $path failed" );
					cvs_commit( $msg, $path );
					undef $path;
				}
			} else {
				print "WARNING: $path is not present, skipping...\n";
				undef $path;
			}
		} else {
			print
				"WARNING: action $action not implemented on $path. Bug or missing feature of $0\n";
		}

		# save commits for later
		push @commit, $path if ($path);

	}

	# now commit changes
	cvs_commit( $msg, @commit );

	commit_svnrev($rev);
}

# cd out of $CVSREP before File::Temp::END is called
chdir("/tmp") || die "can't cd to /tmp: $!";

__END__

=pod

=head1 NAME

svn2cvs - save subversion commits to (read-only) cvs repository

=head1 SYNOPSIS

  ./svn2cvs.pl SVN_URL CVSROOT CVSREPOSITORY

Usage example (used to self-host this script):

  ./svn2cvs.pl file:///home/dpavlin/private/svn/svn2cvs/trunk/ \
               :pserver:dpavlin@cvs.tigris.org:/cvs svn2cvs/src

=head1 DESCRIPTION

This script will allows you to commit changes made to Subversion repository to
(read-only) CVS repository manually or from Subversion's C<post-commit> hook.

It's using F<.svnrev> file (which will be created on first run) in 
B<CVSROOT/CVSREPOSITORY> to store last Subversion revision which was
committed into CVS.

One run will do following things:

=over 4

=item *
checkout B<CVSREPOSITORY> from B<CVSROOT> to temporary directory

=item *
check if F<.svnrev> file exists and create it if it doesn't

=item *
loop through all revisions from current in B<CVSROOT/CVSREPOSITORY> (using
F<.svnrev>) up to B<HEAD> (current one)

=over 5

=item *
checkout next Subversion revision from B<SVN_URL> over CVS checkout
temporary directory

=item *
make modification (add and/or delete) done in that revision

=item *
commit modification (added, deleted or modified files/dirs) while
preserving original message from CVS

=item *
update F<.svnrev> to match current revision

=back

=item *
cleanup temporary directory

=back

If checkout fails for some reason (e.g. flaky ssh connection), you will
still have valid CVS repository, so all you have to do is run B<svn2cvs.pl>
again.

=head1 WARNINGS

"Cheap" copy operations in Subversion are not at all cheap in CVS. They will
create multiple copies of files in CVS repository!

This script assume that you want to sync your C<trunk> (or any other
directory for that matter) directory with CVS, not root of your subversion.
This might be considered bug, but since common practise is to have
directories C<trunk> and C<branches> in svn and source code in them, it's
not serious limitation.

=head1 RELATED PROJECTS

B<Subversion> L<http://subversion.tigris.org/> version control system that is a
compelling replacement for CVS in the open source community.

B<cvs2svn> L<http://cvs2svn.tigris.org/> converts a CVS repository to a
Subversion repository. It is designed for one-time conversions, not for
repeated synchronizations between CVS and Subversion.

=head1 CHANGES

Versions of this utility are actually Subversion repository revisions,
so they might not be in sequence.

=over 3

=item r10

First release available to public

=item r15

Addition of comprehensive documentation, fixes for quoting in commit
messages, and support for skipping changes which are not under current
Subversion checkout root (e.g. branches).

=item r18

Support for importing your svn into empty CVS repository (it will first
create module and than dump all revisions).
Group commit operations to save round-trips to CVS server.
Documentation improvements and other small fixes.

=item r20

Fixed path deduction (overlap between Subversion reporistory and CVS checkout).

=item r21

Use C<update -d> instead of checkout after import.
Added fixes by Paul Egan <paulegan@mail.com> for XMLin and fixing working
directory.

=item r22

Rewritten import from revision 0 to empty repository, better importing
of deep directory structures, initial support for recovery from partial
commit.

=back

=head1 AUTHOR

Dobrica Pavlinusic <dpavlin@rot13.org>

L<http://www.rot13.org/~dpavlin/>

=head1 LICENSE

This product is licensed under GNU Public License (GPL) v2 or later.

=cut

