Line # Revision Author
1 1 dpavlin #!/usr/bin/perl -w
2
3 # This script will transfer changes from Subversion repository
4 # to CVS repository (e.g. SourceForge) while preserving commit
5 # logs.
6 #
7 # Based on original shell version by Tollef Fog Heen available at
8 # http://raw.no/personal/blog
9 #
10 # 2004-03-09 Dobrica Pavlinusic <dpavlin@rot13.org>
11 12 dpavlin #
12 # documentation is after __END__
13 1 dpavlin
14 use strict;
15 use File::Temp qw/ tempdir /;
16 21 dpavlin use File::Path;
17 1 dpavlin use Data::Dumper;
18 use XML::Simple;
19
20 27 dpavlin # do we want to sync just part of repository?
21 my $partial_import = 1;
22
23 36 dpavlin # do we want to add svk-like prefix with original revision, author and date?
24 my $decorate_commit_message = 1;
25
26 41 dpavlin if ( @ARGV < 2 ) {
27 8 dpavlin print "usage: $0 SVN_URL CVSROOT CVSREPOSITORY\n";
28 exit 1;
29 }
30 1 dpavlin
31 41 dpavlin my ( $SVNROOT, $CVSROOT, $CVSREP ) = @ARGV;
32 1 dpavlin
33 41 dpavlin if ( $SVNROOT !~ m,^[\w+]+:///*\w+, ) {
34 8 dpavlin print "ERROR: invalid svn root $SVNROOT\n";
35 exit 1;
36 }
37 7 dpavlin
38 21 dpavlin # Ensure File::Temp::END can clean up:
39 $SIG{__DIE__} = sub { chdir("/tmp"); die @_ };
40
41 41 dpavlin my $TMPDIR = tempdir( "/tmp/checkoutXXXXX", CLEANUP => 1 );
42 1 dpavlin
43 22 dpavlin sub cd_tmp {
44 chdir($TMPDIR) || die "can't cd to $TMPDIR: $!";
45 }
46 1 dpavlin
47 22 dpavlin sub cd_rep {
48 chdir("$TMPDIR/$CVSREP") || die "can't cd to $TMPDIR/$CVSREP: $!";
49 }
50
51 print "## using TMPDIR $TMPDIR\n";
52
53 1 dpavlin # cvs command with root
54 41 dpavlin my $cvs = "cvs -f -d $CVSROOT";
55 1 dpavlin
56 22 dpavlin # current revision in CVS
57 my $rev;
58
59 1 dpavlin #
60 # sub to do logging and system calls
61 #
62 sub log_system($$) {
63 41 dpavlin my ( $cmd, $errmsg ) = @_;
64 1 dpavlin print STDERR "## $cmd\n";
65 8 dpavlin system($cmd) == 0 || die "$errmsg: $!";
66 1 dpavlin }
67
68 3 dpavlin #
69 # sub to commit .svn rev file later
70 #
71 sub commit_svnrev {
72 41 dpavlin my $rev = shift @_;
73 3 dpavlin my $add_new = shift @_;
74 1 dpavlin
75 41 dpavlin die "commit_svnrev needs revision" if ( !defined($rev) );
76 3 dpavlin
77 41 dpavlin open( SVNREV, "> .svnrev" )
78 || die "can't open $TMPDIR/$CVSREP/.svnrev: $!";
79 3 dpavlin print SVNREV $rev;
80 close(SVNREV);
81
82 41 dpavlin my $path = ".svnrev";
83 3 dpavlin
84 if ($add_new) {
85 28 dpavlin system "$cvs add '$path'" || die "cvs add of $path failed: $!";
86 42 dpavlin } else {
87 41 dpavlin my $msg = "subversion revision $rev commited to CVS";
88 4 dpavlin print "$msg\n";
89 41 dpavlin system "$cvs commit -m '$msg' '$path'"
90 || die "cvs commit of $path failed: $!";
91 3 dpavlin }
92 }
93
94 22 dpavlin sub add_dir($$) {
95 41 dpavlin my ( $path, $msg ) = @_;
96 22 dpavlin print "# add_dir($path)\n";
97 41 dpavlin die "add_dir($path) is not directory" unless ( -d $path );
98 18 dpavlin
99 22 dpavlin my $curr_dir;
100
101 41 dpavlin foreach my $d ( split( m#/#, $path ) ) {
102 $curr_dir .= ( $curr_dir ? '/' : '' ) . $d;
103 22 dpavlin
104 next if in_entries($curr_dir);
105 41 dpavlin next if ( -e "$curr_dir/CVS" );
106 22 dpavlin
107 47 dpavlin log_system( "touch '$curr_dir/.keepme'", "creation of .keepme file (to keep $curr_dir alive in CVS) failed" );
108 log_system( "$cvs add '$curr_dir' '$curr_dir/.keepme'", "cvs add of $curr_dir failed" );
109 22 dpavlin }
110 }
111
112 1 dpavlin # ok, now do the checkout
113 18 dpavlin eval {
114 22 dpavlin cd_tmp;
115 41 dpavlin log_system( "$cvs -q checkout $CVSREP", "cvs checkout failed" );
116 18 dpavlin };
117 1 dpavlin
118 18 dpavlin if ($@) {
119 print <<_NEW_REP_;
120 1 dpavlin
121 18 dpavlin There is no CVS repository '$CVSREP' in your CVS. I will assume that
122 this is import of new module in your CVS and start from revision 0.
123 7 dpavlin
124 18 dpavlin Press enter to continue importing new CVS repository or CTRL+C to abort.
125 7 dpavlin
126 18 dpavlin _NEW_REP_
127 3 dpavlin
128 18 dpavlin print "start import of new module [yes]: ";
129 my $in = <STDIN>;
130 22 dpavlin cd_tmp;
131 18 dpavlin mkdir($CVSREP) || die "can't create $CVSREP: $!";
132 22 dpavlin cd_rep;
133 1 dpavlin
134 41 dpavlin open( SVNREV, "> .svnrev" ) || die "can't open $CVSREP/.svnrev: $!";
135 18 dpavlin print SVNREV "0";
136 close(SVNREV);
137
138 $rev = 0;
139
140 # create new module
141 22 dpavlin cd_rep;
142 41 dpavlin log_system( "$cvs import -d -m 'new CVS module' $CVSREP svn r$rev",
143 "import of new repository" );
144 22 dpavlin cd_tmp;
145 rmtree($CVSREP) || die "can't remove $CVSREP";
146 41 dpavlin log_system( "$cvs -q checkout $CVSREP", "cvs checkout failed" );
147 22 dpavlin cd_rep;
148 18 dpavlin
149 42 dpavlin } else {
150 41 dpavlin
151 18 dpavlin # import into existing module directory in CVS
152
153 22 dpavlin cd_rep;
154 41 dpavlin
155 18 dpavlin # check if svnrev exists
156 41 dpavlin if ( !-e ".svnrev" ) {
157 18 dpavlin print <<_USAGE_;
158
159 3 dpavlin Your CVS repository doesn't have .svnrev file!
160 1 dpavlin
161 12 dpavlin This file is used to keep CVS repository and Subversion in sync, so
162 3 dpavlin that only newer changes will be commited.
163 1 dpavlin
164 3 dpavlin It's quote possible that this is first svn2cvs run for this repository.
165 If so, you will have to identify correct svn revision which
166 corresponds to current version of CVS repository that has just
167 been checkouted.
168 1 dpavlin
169 3 dpavlin If you migrated your cvs repository to svn using cvs2svn, this will be
170 12 dpavlin last Subversion revision. If this is initial run of conversion of
171 Subversion repository to CVS, correct revision is 0.
172 1 dpavlin
173 _USAGE_
174 3 dpavlin
175 18 dpavlin print "svn revision corresponding to CVS [abort]: ";
176 my $in = <STDIN>;
177 chomp($in);
178 41 dpavlin if ( $in !~ /^\d+$/ ) {
179 18 dpavlin print "Aborting: revision not a number\n";
180 exit 1;
181 42 dpavlin } else {
182 18 dpavlin $rev = $in;
183 41 dpavlin commit_svnrev( $rev, 1 ); # create new
184 18 dpavlin }
185 42 dpavlin } else {
186 41 dpavlin open( SVNREV, ".svnrev" )
187 || die "can't open $TMPDIR/$CVSREP/.svnrev: $!";
188 18 dpavlin $rev = <SVNREV>;
189 chomp($rev);
190 close(SVNREV);
191 3 dpavlin }
192 18 dpavlin
193 print "Starting after revision $rev\n";
194 $rev++;
195 1 dpavlin }
196
197 #
198 # FIXME!! HEAD should really be next verison and loop because this way we
199 # loose multiple edits of same file and corresponding messages. On the
200 # other hand, if you want to compress your traffic to CVS server and don't
201 # case much about accuracy and completnes of logs there, this might
202 # be good. YMMV
203 #
204 41 dpavlin open( LOG, "svn log -r $rev:HEAD -v --xml $SVNROOT |" )
205 || die "svn log for repository $SVNROOT failed: $!";
206 1 dpavlin my $log;
207 41 dpavlin while (<LOG>) {
208 1 dpavlin $log .= $_;
209 }
210 close(LOG);
211
212 21 dpavlin my $xml;
213 41 dpavlin eval { $xml = XMLin( $log, ForceArray => [ 'logentry', 'path' ] ); };
214 1 dpavlin
215 12 dpavlin #=begin log_example
216 #
217 #------------------------------------------------------------------------
218 #r256 | dpavlin | 2004-03-09 13:18:17 +0100 (Tue, 09 Mar 2004) | 2 lines
219 #
220 #ported r254 from hidra branch
221 #
222 #=cut
223 1 dpavlin
224 my $fmt = "\n" . "-" x 79 . "\nr%5s| %8s | %s\n\n%s\n";
225
226 41 dpavlin if ( !$xml->{'logentry'} ) {
227 12 dpavlin print "no newer log entries in Subversion repostory. CVS is current\n";
228 2 dpavlin exit 0;
229 }
230
231 35 dpavlin # return all files in CVS/Entries
232 sub entries($) {
233 my $dir = shift;
234 die "entries expects directory argument!" unless -d $dir;
235 my @entries;
236 41 dpavlin open( my $fh, "./$dir/CVS/Entries" ) || return 0;
237 while (<$fh>) {
238 35 dpavlin if ( m{^D/([^/]+)}, ) {
239 my $sub_dir = $1;
240 warn "#### entries recurse into: $dir/$sub_dir";
241 41 dpavlin push @entries, map {"$sub_dir/$_"} entries("$dir/$sub_dir");
242 35 dpavlin push @entries, $sub_dir;
243 42 dpavlin } elsif (m{^/([^/]+)/}) {
244 35 dpavlin push @entries, $1;
245 42 dpavlin } elsif ( !m{^D$} ) {
246 35 dpavlin die "can't decode entries line: $_";
247 14 dpavlin }
248 }
249 35 dpavlin close($fh);
250 41 dpavlin warn "#### entries($dir) => ", join( "|", @entries );
251 35 dpavlin return @entries;
252 14 dpavlin }
253
254 35 dpavlin # check if file exists in CVS/Entries
255 sub in_entries($) {
256 my $path = shift;
257 41 dpavlin if ( $path =~ m,^(.*?/*)([^/]+)$, ) {
258 my ( $dir, $file ) = ( $1, $2 );
259 if ( $dir !~ m,/$, && $dir ne "" ) {
260 37 dpavlin $dir .= "/";
261 }
262
263 41 dpavlin open( my $fh, "./$dir/CVS/Entries" )
264 || return 0; #die "no entries file: $dir/CVS/Entries";
265 while (<$fh>) {
266 47 dpavlin return 1 if (m{^D?/$file/});
267 37 dpavlin }
268 close($fh);
269 return 0;
270 42 dpavlin } else {
271 37 dpavlin die "can't split '$path' to dir and file!";
272 35 dpavlin }
273 }
274
275 22 dpavlin cd_tmp;
276 cd_rep;
277 21 dpavlin
278 41 dpavlin foreach my $e ( @{ $xml->{'logentry'} } ) {
279 die "BUG: revision from .svnrev ($rev) greater than from subversion ("
280 . $e->{'revision'} . ")"
281 if ( $rev > $e->{'revision'} );
282 1 dpavlin $rev = $e->{'revision'};
283 41 dpavlin log_system( "svn export --force -q -r $rev $SVNROOT $TMPDIR/$CVSREP",
284 "svn export of revision $rev failed" );
285 1 dpavlin
286 8 dpavlin # deduce name of svn directory
287 41 dpavlin my $SVNREP = "";
288 my $tmpsvn = $SVNROOT || die "BUG: SVNROOT empty!";
289 my $tmppath = $e->{'paths'}->{'path'}->[0]->{'content'}
290 || die "BUG: tmppath empty!";
291 8 dpavlin do {
292 41 dpavlin if ( $tmpsvn =~ s#(/[^/]+)/*$## ) { # vim fix
293 20 dpavlin $SVNREP = $1 . $SVNREP;
294 42 dpavlin } elsif ( $e->{'paths'}->{'path'}->[0]->{'copyfrom-path'} ) {
295 41 dpavlin print
296 "NOTICE: copyfrom outside synced repository ignored - skipping\n";
297 26 dpavlin next;
298 42 dpavlin } else {
299 15 dpavlin print "NOTICE: can't deduce svn dir from $SVNROOT - skipping\n";
300 next;
301 8 dpavlin }
302 41 dpavlin } until ( $tmppath =~ m/^$SVNREP/ );
303 8 dpavlin
304 print "NOTICE: using $SVNREP as directory for svn\n";
305
306 41 dpavlin printf( $fmt,
307 $e->{'revision'}, $e->{'author'}, $e->{'date'}, $e->{'msg'} );
308 18 dpavlin my @commit;
309
310 35 dpavlin my $msg = $e->{'msg'};
311 41 dpavlin $msg =~ s/'/'\\''/g; # quote "
312 35 dpavlin
313 41 dpavlin $msg = 'r' . $rev . ' ' . $e->{author} . ' | ' . $e->{date} . "\n" . $msg
314 if $decorate_commit_message;
315 36 dpavlin
316 35 dpavlin sub cvs_commit {
317 my $msg = shift || die "no msg?";
318 41 dpavlin if ( !@_ ) {
319 35 dpavlin warn "commit ignored, no files\n";
320 return;
321 }
322 41 dpavlin log_system(
323 "$cvs commit -m '$msg' '" . join( "' '", @_ ) . "'",
324 "cvs commit of " . join( ",", @_ ) . " failed"
325 );
326 35 dpavlin }
327
328 41 dpavlin foreach my $p ( @{ $e->{'paths'}->{'path'} } ) {
329 my ( $action, $path ) = ( $p->{'action'}, $p->{'content'} );
330 1 dpavlin
331 41 dpavlin next if ( $path =~ m#/\.svnrev$# );
332 23 dpavlin
333 1 dpavlin print "svn2cvs: $action $path\n";
334
335 # prepare path and message
336 my $file = $path;
337 27 dpavlin if ( $path !~ s#^\Q$SVNREP\E/*## ) {
338 41 dpavlin print
339 "NOTICE: skipping '$path' which isn't under repository root '$SVNREP'\n";
340 27 dpavlin die unless $partial_import;
341 next;
342 }
343 7 dpavlin
344 41 dpavlin if ( !$path ) {
345 7 dpavlin print "NOTICE: skipped this operation. Probably trunk creation\n";
346 next;
347 }
348
349 1 dpavlin my $msg = $e->{'msg'};
350 41 dpavlin $msg =~ s/'/'\\''/g; # quote "
351 1 dpavlin
352 31 dpavlin sub add_path {
353 my $path = shift || die "no path?";
354 41 dpavlin
355 if ( -d $path ) {
356 add_dir( $path, $msg );
357 42 dpavlin } elsif ( $path =~ m,^(.+)/[^/]+$, && !-e "$1/CVS/Root" ) {
358 14 dpavlin my $dir = $1;
359 41 dpavlin in_entries($dir) || add_dir( $dir, $msg );
360 in_entries($path) || log_system( "$cvs add '$path'",
361 "cvs add of $path failed" );
362 42 dpavlin } else {
363 41 dpavlin in_entries($path) || log_system( "$cvs add '$path'",
364 "cvs add of $path failed" );
365 }
366 31 dpavlin }
367
368 41 dpavlin if ( $action =~ /M/ ) {
369 if ( in_entries($path) ) {
370 31 dpavlin print "svn2cvs: modify $path -- nop\n";
371 42 dpavlin } else {
372 31 dpavlin print "WARNING: modify $path which isn't in CVS, adding...\n";
373 add_path($path);
374 }
375 42 dpavlin } elsif ( $action =~ /A/ ) {
376 31 dpavlin add_path($path);
377 42 dpavlin } elsif ( $action =~ /D/ ) {
378 41 dpavlin if ( -e $path ) {
379 45 dpavlin if ( ! in_entries( $path ) ) {
380 print "WARNING: $path is not present in CVS, skipping...\n";
381 undef $path;
382 } elsif ( -d $path ) {
383 35 dpavlin warn "#### remove directory: $path";
384 foreach my $f ( entries($path) ) {
385 $f = "$path/$f";
386 41 dpavlin if ( -f $f ) {
387 35 dpavlin unlink($f) || die "can't delete file $f: $!";
388 41 dpavlin
389 # } else {
390 # rmtree($f) || die "can't delete dir $f: $!";
391 35 dpavlin }
392 41 dpavlin log_system( "$cvs delete '$f'",
393 "cvs delete of file $f failed" );
394 45 dpavlin cvs_commit( $msg, $f );
395 35 dpavlin }
396 41 dpavlin log_system( "$cvs delete '$path'",
397 "cvs delete of file $path failed" );
398 45 dpavlin cvs_commit( $msg, $path );
399 log_system( "$cvs update -dP .",
400 "cvs update -dP . failed" );
401 40 dpavlin undef $path;
402 42 dpavlin } else {
403 35 dpavlin warn "#### remove file: $path";
404 unlink($path) || die "can't delete $path: $!";
405 41 dpavlin log_system( "$cvs delete '$path'",
406 "cvs delete of dir $path failed" );
407 45 dpavlin cvs_commit( $msg, $path );
408 undef $path;
409 35 dpavlin }
410 42 dpavlin } else {
411 22 dpavlin print "WARNING: $path is not present, skipping...\n";
412 undef $path;
413 }
414 42 dpavlin } else {
415 41 dpavlin print
416 "WARNING: action $action not implemented on $path. Bug or missing feature of $0\n";
417 }
418 1 dpavlin
419 18 dpavlin # save commits for later
420 22 dpavlin push @commit, $path if ($path);
421 1 dpavlin
422 }
423
424 18 dpavlin # now commit changes
425 41 dpavlin cvs_commit( $msg, @commit );
426 18 dpavlin
427 1 dpavlin commit_svnrev($rev);
428 }
429 12 dpavlin
430 21 dpavlin # cd out of $CVSREP before File::Temp::END is called
431 chdir("/tmp") || die "can't cd to /tmp: $!";
432
433 12 dpavlin __END__
434
435 =pod
436
437 =head1 NAME
438
439 svn2cvs - save subversion commits to (read-only) cvs repository
440
441 =head1 SYNOPSIS
442
443 ./svn2cvs.pl SVN_URL CVSROOT CVSREPOSITORY
444
445 Usage example (used to self-host this script):
446
447 ./svn2cvs.pl file:///home/dpavlin/private/svn/svn2cvs/trunk/ \
448 :pserver:dpavlin@cvs.tigris.org:/cvs svn2cvs/src
449
450 =head1 DESCRIPTION
451
452 18 dpavlin This script will allows you to commit changes made to Subversion repository to
453 (read-only) CVS repository manually or from Subversion's C<post-commit> hook.
454 12 dpavlin
455 It's using F<.svnrev> file (which will be created on first run) in
456 B<CVSROOT/CVSREPOSITORY> to store last Subversion revision which was
457 committed into CVS.
458
459 One run will do following things:
460
461 =over 4
462
463 =item *
464 checkout B<CVSREPOSITORY> from B<CVSROOT> to temporary directory
465
466 =item *
467 check if F<.svnrev> file exists and create it if it doesn't
468
469 =item *
470 loop through all revisions from current in B<CVSROOT/CVSREPOSITORY> (using
471 F<.svnrev>) up to B<HEAD> (current one)
472
473 =over 5
474
475 =item *
476 checkout next Subversion revision from B<SVN_URL> over CVS checkout
477 temporary directory
478
479 =item *
480 make modification (add and/or delete) done in that revision
481
482 =item *
483 commit modification (added, deleted or modified files/dirs) while
484 preserving original message from CVS
485
486 =item *
487 update F<.svnrev> to match current revision
488
489 =back
490
491 =item *
492 cleanup temporary directory
493
494 =back
495
496 If checkout fails for some reason (e.g. flaky ssh connection), you will
497 still have valid CVS repository, so all you have to do is run B<svn2cvs.pl>
498 again.
499
500 =head1 WARNINGS
501
502 "Cheap" copy operations in Subversion are not at all cheap in CVS. They will
503 create multiple copies of files in CVS repository!
504
505 18 dpavlin This script assume that you want to sync your C<trunk> (or any other
506 directory for that matter) directory with CVS, not root of your subversion.
507 This might be considered bug, but since common practise is to have
508 directories C<trunk> and C<branches> in svn and source code in them, it's
509 not serious limitation.
510
511 12 dpavlin =head1 RELATED PROJECTS
512
513 B<Subversion> L<http://subversion.tigris.org/> version control system that is a
514 compelling replacement for CVS in the open source community.
515
516 B<cvs2svn> L<http://cvs2svn.tigris.org/> converts a CVS repository to a
517 Subversion repository. It is designed for one-time conversions, not for
518 repeated synchronizations between CVS and Subversion.
519
520 16 dpavlin =head1 CHANGES
521
522 Versions of this utility are actually Subversion repository revisions,
523 so they might not be in sequence.
524
525 =over 3
526
527 =item r10
528
529 First release available to public
530
531 =item r15
532
533 Addition of comprehensive documentation, fixes for quoting in commit
534 messages, and support for skipping changes which are not under current
535 Subversion checkout root (e.g. branches).
536