#! /usr/bin/perl -w
#
# $Id: svn-merge-repos.pl 446 2006-09-23 11:39:41Z coelho $
#

use strict;
use Pod::Usage;

=head1 NAME

svn-merge-repos - Merge SVN repositories into one, in date order

=head1 SYNOPSIS

svn-merge-repos --target repos_path[/subdir] repos_path[/ssubdir]:tsubdir ...

=head1 OPTIONS

=over 4

=item B<--help>

This help.

=item B<--man>

More help.

=item B<--verbose>

Be verbose.

=item B<--svn=/path/to/svn>

Use this 'svn' command.

=item B<--svnadmin=/path/to/svnadmin>

Use this 'svnadmin' command.

=item B<--svndumpfilter=/path/to/svndumpfilter>

Use this 'svndumpfilter' command.

=item B<--target=repos_path/subdir>

Path to destination repository, possibly within the specified
sub directory.

=item B<--source>

Add a 'merge:source' revision property to tell the revision source.
Revision property changes must be allowed on the target repos in order
to do so.

=back

=head1 ARGUMENTS

Arguments are of the form repos_path/ssubdir:tsubdir.
The source sub-directory 'ssubdir' of the source repository is merged into 
the specified target sub directory 'subdir/tsubdir' in the target repository.

=head1 DESIGN NOTE

Revisions are exported and imported thanks to the 'svnadmin' command.
This means that an actual filesystem path to the repository is necessary. 
An url-based subversion repository path will not work.

It is a bad idea to use the repository while the merge is in progress.

=head1 ADVICES

=over 4

=item *

Do not merge big and pretty independent repositories.
Consider 'svn:external' prior to merging repositories.

=item *

To unmerge a repository, 'svnadmin' + 'svndumpfilter include'
are your friends.

=back

=head1 BUGS

I'm unsure about what happens with svn copies. 
Maybe just merge your trunk?

Dates are in some timezone. 
What happens around daylight time changes?

The sorting breaks around one million revisions.

=head1 AUTHOR

(c) Fabien COELHO <fabien at coelho dot net> 2006

=head1 COPYRIGHT

This is free software: do whatever you want with it. 
However use it at your own risk, and do not blame me.
Do not trust hardware. Do not trust software either.
Always backup your data. 

=cut

# svn revision number
my $rev = '$Rev: 446 $';
$rev =~ tr/0-9//cd;

####################################################################### OPTIONS

my $svn = 'svn';
my $svnadmin = 'svnadmin';
my $svndumpfilter = 'svndumpfilter';
my $target = undef;
my $verb = 0;
my $source = 0;

use Getopt::Long;
GetOptions("target|t=s" => \$target,
	   "svndumpfilter|dumpfilter|d=s" => \$svndumpfilter,
	   "svnadmin|admin|a=s" => \$svnadmin,
	   "svn|s=s" => \$svn,
	   "verbose|v+" => \$verb,
	   "source" => \$source,
	   "help|h" => sub { pod2usage(-verbose => 1); },
           "man|m" => sub { pod2usage(-verbose => 2); },
           "version" => sub { print "$0 version $rev\n"; })
    or die "invalid option ($!)";

die 'expecting target option' unless $target;

##################################################################### FUNCTIONS

# safe execute system
sub sys($)
{
    my ($cmd) = @_;
    print STDERR "sys($cmd)\n" if $verb;
    system($cmd) and die "cmd=$cmd\n$!";
}

# simple quote string for shell
sub quote($)
{
    my ($str) = @_;
    $str =~ s/([\'\\])/\\$1/;
    return "'$str'";
}

# is argument an svn repository?
sub is_repository($)
{
    my ($path) = @_;
    return -d $path and -f "$path/README.txt" and -f "$path/format"
	and -d "$path/db" and -d "$path/hooks" and -d "$path/locks" 
	and -d "$path/conf" and -d "$path/dav";
}

# return separated repository path and sub directory from path
sub repository_path($)
{
    my ($path) = @_;
    my ($dir, $subdir) = ($path, '');
    while ($dir and not is_repository($dir) and $dir =~ m,(.*)/([^/]*)$,)
    {
	($dir, $subdir) = ($1, $subdir? "$2/$subdir": $2);
    }
    die "path '$path' not a repository" unless is_repository($dir);
    return ($dir, $subdir);
}

################################################################## DO SOMETHING

my ($target_repos, $target_subdir) = repository_path($target);
die "no such repository: $target_repos" unless -d $target_repos;

# full source path -> subdir map in target repository
my %sources = ();
# subdir map in target repository -> full source path
my %dirs = ();
# full source path -> source repository path
my %srcrepos = ();
# full source path -> include source directory
my %srcinclude = ();

# process sources specifications repos-path:sub/dir
for my $src (@ARGV)
{
    die "invalid source specification: $src" unless $src =~ /(.*):(.*)/;
    my ($path,$dir) = ($1,$2);
    $dir = "$target_subdir/$dir" if $target_subdir;
    my ($path_repos, $path_include) = repository_path($path);
    die "repository $path is already taken" if exists $sources{$path};
    # prefixes should also be forbidden
    die "directory $dir is already taken" if exists $dirs{$dir};
    die "must used relative path for $2" if $dir =~ /^\//;
    $sources{$path} = $dir;
    $dirs{$dir} = $path;
    $srcrepos{$path} = $path_repos;
    $srcinclude{$path} = $path_include? "/$path_include": '';
}

# get all log history
my @all = ();
my %dates = ();
for my $path (sort keys %sources)
{
    my $dir = $sources{$path};
    open LOG, "$svn log --quiet " . quote("file://$path") . " |" or die $!;
    while (<LOG>)
    {
	chomp;
	next if /^-+$/;
	# hmmm... the date is in some timezone.
	my ($rev, $date) = (split ' \| ')[0,2];
	$rev =~ s/^r//;
	$date =~ tr/0-9//cd;
	$date =~ s/^(\d{14}).*/$1/;
	push @all, "$dir:$rev";
	# it must have less than 1,000,000 commits
	$dates{"$dir:$rev"} = sprintf("$date%06d", $rev);
    }
    close LOG or die $!;
}

# create directories within target repository
for my $dirpath (sort keys %dirs)
{
    my $path = '';
    for my $dir (split '/', $dirpath)
    {
	$path .= "/$dir";
	sys("$svn ls " . quote("file://$target_repos$path") . " || " .
	    "$svn mkdir --message " . quote("mkdir $path") . 
	    " " . quote("file://$target_repos$path"));
    }
}

# get them sorted by date
# the sorting will break around year 10000.
for (sort { $dates{$a} cmp $dates{$b} } @all)
{
    my ($dir,$rev) = split /:/;
    print STDERR "considering dir=$dir rev=$rev\n" if $verb;
    my $path = $dirs{$dir};
    my $repospath = $srcrepos{$path};

    sys(# dump wanted revision from source repository
	"$svnadmin dump --revision $rev --incremental " . quote($repospath) .
	# pass through optionnal filter
	($srcinclude{$path}? 
	 " | $svndumpfilter include " . quote($srcinclude{$path}): '') .
	# load into target repository
	" | $svnadmin load --parent-dir " . quote($dir) . 
	" " . quote($target_repos));

    # store source as a rev prop
    sys("$svn pset merge:source " . 
	quote("file://$repospath$srcinclude{$path}\@$rev") .
	" --revprop --revision HEAD " . quote("file://$target_repos"))
	if $source;
}
