test-ly-files: Display symlink creation, add `-v` to `ln`
[sunny256-utils.git] / mergesvn
blobab3c7e76d3833e5a8fca5c9702d22d0bf0f56b5b
1 #!/usr/bin/env perl
3 #=======================================================================
4 # mergesvn
5 # File ID: 7a1ba190-f743-11dd-a70f-000475e441b9
6 # Merges new changes into a file version controlled by Subversion.
8 # Character set: UTF-8
9 # ©opyleft 2006– Øyvind A. Holm <sunny@sunbase.org>
10 # License: GNU General Public License version 2 or later, see end of
11 # file for legal stuff.
12 # This file is part of the svnutils project — http://svnutils.tigris.org
13 #=======================================================================
15 use strict;
16 use warnings;
17 use Getopt::Long;
19 $| = 1;
21 our $Debug = 0;
23 our %Opt = (
25 'alias' => "",
26 'conflict' => 0,
27 'debug' => 0,
28 'diff' => 0,
29 'dry-run' => 0,
30 'help' => 0,
31 'log' => 0,
32 'set' => "",
33 'to' => "HEAD",
34 'verbose' => 0,
35 'version' => 0,
39 our $progname = $0;
40 $progname =~ s/^.*\/(.*?)$/$1/;
41 our $VERSION = "0.00";
43 Getopt::Long::Configure("bundling");
44 GetOptions(
46 "alias|a=s" => \$Opt{'alias'},
47 "conflict|C" => \$Opt{'conflict'},
48 "debug" => \$Opt{'debug'},
49 "diff|d" => \$Opt{'diff'},
50 "dry-run" => \$Opt{'dry-run'},
51 "help|h" => \$Opt{'help'},
52 "log|l" => \$Opt{'log'},
53 "set|s=s" => \$Opt{'set'},
54 "to|t=s" => \$Opt{'to'},
55 "verbose|v+" => \$Opt{'verbose'},
56 "version" => \$Opt{'version'},
58 ) || die("$progname: Option error. Use -h for help.\n");
60 my $CMD_SVN = "svn";
62 my $PROP_NAME = "mergesvn";
64 $Opt{'debug'} && ($Debug = 1);
65 $Opt{'help'} && usage(0);
66 if ($Opt{'version'}) {
67 print_version();
68 exit(0);
71 my @Files = @ARGV;
73 if (!scalar(@Files)) {
74 while (<STDIN>) {
75 chomp;
76 push(@Files, $_);
80 LOOP:
81 for (@Files) {
82 # {{{
83 my $File = $_;
84 if (length($Opt{'set'})) {
85 set_mergesvn_prop($File, $Opt{'set'});
86 next LOOP;
88 my $prop_val = `$CMD_SVN propget $PROP_NAME $File`;
89 if (!length($prop_val)) {
90 warn("$progname: $File: \"$PROP_NAME\" property not found, " .
91 "skipping file\n");
92 next LOOP;
94 my @new_prop = ();
95 my @Props = split(/\n/, $prop_val);
96 D("Props = (\"" . join("\", \"", @Props) . "\")");
97 for my $Curr (@Props) {
98 if ($Curr =~ /^(\d+) (.+)$/) {
99 my ($last_merge, $orig_master) =
100 ( $1, $2);
101 $orig_master =~ s/[\r\n]+$//;
102 my $master_file = length($Opt{'alias'})
103 ? $Opt{'alias'}
104 : $orig_master;
105 my $curr_rev = highest_revision($master_file, $Opt{'to'});
106 if (!legal_revision($curr_rev)) {
107 warn("$progname: $master_file: " .
108 "Unable to get newest revision\n");
109 next LOOP;
111 if ($Opt{'conflict'}) {
112 find_conflict($master_file, $File, $last_merge, $curr_rev);
113 next LOOP;
115 if ($Opt{'diff'}) {
116 my $Repos = repos_url($File);
117 mysyst(
118 $CMD_SVN, "diff",
119 repos_url($master_file) . "\@$last_merge",
120 $Repos
122 next LOOP;
124 if ($Opt{'log'}) {
125 if ($last_merge == $curr_rev) {
126 print(STDERR "No revisions found\n");
127 } else {
128 log_range($master_file, $last_merge + 1, $curr_rev);
130 next LOOP;
132 if ($Opt{'dry-run'}) {
133 mysyst(
134 $CMD_SVN, "merge", "-r$last_merge:$curr_rev",
135 "--dry-run",
136 $master_file, $File
138 } else {
139 if ($last_merge == $curr_rev) {
140 print(STDERR "$progname: $File: No new revisions available (r$last_merge)\n");
141 next LOOP;
143 mysyst(
144 $CMD_SVN, "merge", "-r$last_merge:$curr_rev",
145 $master_file, $File
148 push(@new_prop, "$curr_rev $orig_master");
149 print(STDERR
150 "$progname: $File: Merged r$last_merge:$curr_rev (" .
151 join(", ", revisions($master_file, $last_merge, $curr_rev)) .
152 ")\n"
154 } else {
155 warn("$File: \"$Curr\": Invalid property line\n");
156 next LOOP;
159 $Opt{'dry-run'} ||
160 mysyst($CMD_SVN, "propset", $PROP_NAME, join("\n", @new_prop), $File);
161 # }}}
164 sub legal_revision {
165 # Check that a string is a legal revision number {{{
166 my $Rev = shift;
167 my $Retval = 1;
169 if ($Rev =~ /[^\d]/ || !length($Rev)) {
170 $Retval = 0;
172 return($Retval);
173 # }}}
174 } # legal_revision()
176 sub set_mergesvn_prop {
177 # Define the $PROP_NAME property for an element. Finds the highest
178 # revision which had a change.
179 # {{{
180 my ($File, $Str) = @_;
181 my ($Source, $source_rev) = ("", "");
183 if ($Str =~ /^(\S+)\@(\d*)$/) {
184 $Source = $1;
185 $source_rev = $2;
186 } elsif ($Str =~ /^(\S+)$/) {
187 $Source = $1;
188 $source_rev = "HEAD";
189 } else {
190 warn("$progname: $Str: Invalid source URL\n");
191 return(undef);
193 if ($source_rev =~ /[^\d]/ && $source_rev !~ /^HEAD$/i) {
194 die("$progname: $source_rev: Invalid source revision\n");
196 D("set_mergesvn_prop(): Source = '$Source', source_rev = '$source_rev'");
197 my $work_source = length($Opt{'alias'}) ? $Opt{'alias'} : $Source;
198 my $highest_rev = highest_revision($work_source, $source_rev);
199 D("set_mergesvn_prop(): highest_rev = '$highest_rev'");
200 if (!length($highest_rev)) {
201 die("$progname: $work_source: Unable to locate source revision\n");
203 if ($highest_rev ne $source_rev) {
204 warn("$progname: $File: Using revision $highest_rev instead of $source_rev\n");
206 mysyst($CMD_SVN, "propset", $PROP_NAME, "$highest_rev $Source", $File);
207 # }}}
208 } # set_mergesvn_prop()
210 sub find_conflict {
211 # Scan a specific revision range for the first merge conflict and
212 # return the revision number
213 # {{{
214 my ($Src, $Dest, $Start, $End) = @_;
216 D("find_conflict('$Src', '$Dest', '$Start', '$End')");
217 print(STDERR "$progname: $Dest: Scanning revision range r$Start:$End " .
218 "for conflicts\n");
219 my @Array = revisions($Src, $Start, $End);
220 if (!scalar(@Array)) {
221 print(STDERR "No revisions found.\n");
222 return undef;
225 my $rev_count = scalar(@Array);
226 printf(STDERR "$rev_count revision%s to check\n", $rev_count == 1 ? "" : "s");
227 print(STDERR "(" . join(", ", @Array) . ")\n");
229 my $min_block = 0;
230 my ($min_pos, $max_pos) = (0, $rev_count);
232 my $last_mid = 0;
233 my $first_conflict = 0;
234 my $last_good = 0;
235 my $has_checked = 0;
237 while (1) {
238 my $mid_pos = int(($min_pos + $max_pos) / 2);
239 last if ($has_checked && ($mid_pos == $last_mid));
240 my $Rev = $Array[$mid_pos];
241 printf(STDERR "Checking revision %lu (%lu/%lu)...",
242 $Rev, $mid_pos + 1, $rev_count);
243 if (!has_conflict($Src, $Dest, $Start, $Rev)) {
244 print(STDERR "No conflict\n");
245 $min_pos = $mid_pos;
246 D("min_pos set to '$mid_pos'");
247 if (!$last_good || ($Rev > $last_good)) {
248 $last_good = $Rev;
250 } else {
251 print(STDERR "Conflict\n");
252 $max_pos = $mid_pos;
253 D("max_pos set to '$mid_pos'");
254 if (!$first_conflict || ($Rev < $first_conflict)) {
255 $first_conflict = $Rev;
258 $has_checked = 1;
259 $last_mid = $mid_pos;
261 print(STDERR $first_conflict
262 ? "First conflict at r$first_conflict. "
263 : "No conflicts found. "
265 print(STDERR $last_good
266 ? "Last revision without conflict at r$last_good.\n"
267 : "No revisions without conflicts found.\n"
270 # }}}
271 } # find_conflict()
273 sub has_conflict {
274 # {{{
275 my ($Src, $Dest, $Start, $End) = @_;
276 my ( $safe_src, $safe_dest) =
277 (escape_filename($Src), escape_filename($Dest));
278 my $Retval = 0;
279 D("has_conflict('$Src', '$Dest', '$Start', '$End')");
280 if (open(ConflFP, "$CMD_SVN merge --dry-run " .
281 "-r$Start:$End $safe_src $safe_dest |")) {
282 while (<ConflFP>) {
283 my $Stat = substr($_, 0, 2);
284 ($Stat =~ /C/) && ($Retval = 1);
286 close(ConflFP);
288 return($Retval);
289 # }}}
290 } # has_conflict()
292 sub revisions {
293 # Return an array of revision numbers from a specific revision range
294 # for a version controlled element
295 # {{{
296 my ($File, $Start, $End) = @_;
297 D("revisions('$File', '$Start', '$End')");
298 my $safe_file = escape_filename($File);
299 my $Data = "";
300 my @Revs = ();
302 if (open(PipeFP, "$CMD_SVN log --xml -r$Start:$End $safe_file |")) {
303 $Data = join("", <PipeFP>);
304 close(PipeFP);
305 $Data =~ s/<logentry\b.*?\brevision="(\d+)".*?>/push(@Revs, "$1")/egs;
307 if ($Revs[0] eq $Start) {
308 splice(@Revs, 0, 1);
310 return(@Revs);
311 # }}}
312 } # revisions()
314 sub highest_revision {
315 # Return the newest revision of a versioned element inside a
316 # specified revision range
317 # {{{
318 my ($Path, $max_rev) = @_;
319 my $safe_path = escape_filename($Path);
320 my $highest_rev = `$CMD_SVN log -r$max_rev:1 --limit 1 --xml $safe_path`; # FIXME
321 $highest_rev =~ s/^.*?<logentry.+?revision="(\d+)".*?>.*/$1/s;
322 legal_revision($highest_rev) || ($highest_rev = "");
323 return($highest_rev);
324 # }}}
325 } # highest_revision()
327 sub repos_url {
328 # Return the repository address of an element {{{
329 my $File = shift;
330 my $safe_file = escape_filename($File);
331 my $Retval = `$CMD_SVN info --xml $safe_file`;
332 $Retval =~ s/^.*<url>(.*?)<\/url>.*$/$1/s; # FIXME: Add XML parsing
333 return($Retval);
334 # }}}
335 } # repos_url()
337 sub mysyst {
338 # Customised system() {{{
339 my @Args = @_;
340 my $system_txt = sprintf("system(\"%s\");", join("\", \"", @Args));
341 D("$system_txt");
342 deb_wait();
343 msg(1, "Executing '@_'");
344 system(@_);
345 # }}}
346 } # mysyst()
348 sub escape_filename {
349 # Kludge for handling file names with spaces and characters that
350 # trigger shell functions
351 # {{{
352 my $Name = shift;
353 # $Name =~ s/\\/\\\\/g;
354 # $Name =~ s/([ \t;\|!&"'`#\$\(\)<>\*\?])/\\$1/g;
355 $Name =~ s/'/\\'/g;
356 $Name = "'$Name'";
357 return($Name);
358 # }}}
359 } # escape_filename()
361 sub log_range {
362 # Show a revision log of the specified range {{{
363 my ($File, $Start, $End) = @_;
365 if ($Opt{'verbose'}) {
366 mysyst($CMD_SVN, "log", "-r$Start:$End", "-v", $File);
367 } else {
368 mysyst($CMD_SVN, "log", "-r$Start:$End", $File);
370 # }}}
371 } # log_range()
373 sub deb_wait {
374 # Wait until Enter is pressed if --debug {{{
375 $Debug || return;
376 print(STDERR "debug: Press ENTER...");
377 <STDIN>;
378 # }}}
379 } # deb_wait()
381 sub print_version {
382 # Print program version {{{
383 print("$progname v$VERSION\n");
384 # }}}
385 } # print_version()
387 sub usage {
388 # Send the help message to stdout {{{
389 my $Retval = shift;
391 if ($Opt{'verbose'}) {
392 print("\n");
393 print_version();
395 print(<<END);
397 Merge changes between Subversion controlled files or directories.
398 Elements without the "$PROP_NAME" property will be ignored. If no
399 filenames are specified on the command line, it reads filenames from
400 stdin.
402 Usage: $progname [options] [file [files [...]]]
404 Options:
406 -a x, --alias x
407 Use x as alias for the master URL. The old value will still be
408 written to the $PROP_NAME property.
409 -C, --conflict
410 Do not merge, but search for the first revision a conflict will
411 occur when a merge is done. After the search is finished, the first
412 revision number of a troublesome patch is printed, and you can
413 choose by using the -t option if you want to include the conflicting
414 revision in the merge.
415 -d, --diff
416 Instead of merging, show a repository diff between the master URL
417 and the versioned element.
418 --dry-run
419 Try operation without making any changes. Can be used to see if the
420 merge will result in conflicts.
421 -h, --help
422 Show this help.
423 -l, --log
424 Show a revision log of the remaining merges.
425 -s x[\@y], --set x[\@y]
426 Set merge source for all filenames on the command line. If \@y is
427 specified, revision y will be used as the merge source, otherwise
428 HEAD is used. The revision number actually used is the newest
429 revision the source changed.
430 -t x, --to x
431 Merge to revision x instead of HEAD.
432 -v, --verbose
433 Use the -v option together with svn commands that accepts it.
434 --version
435 Print version information.
436 --debug
437 Print debugging messages.
440 exit($Retval);
441 # }}}
442 } # usage()
444 sub msg {
445 # Print a status message to stderr based on verbosity level {{{
446 my ($verbose_level, $Txt) = @_;
448 if ($Opt{'verbose'} >= $verbose_level) {
449 print(STDERR "$progname: $Txt\n");
451 # }}}
452 } # msg()
454 sub D {
455 # Print a debugging message if --debug {{{
456 $Debug || return;
457 my @call_info = caller;
458 chomp(my $Txt = shift);
459 my $File = $call_info[1];
460 $File =~ s#\\#/#g;
461 $File =~ s#^.*/(.*?)$#$1#;
462 print(STDERR "$File:$call_info[2] $$ $Txt\n");
463 return("");
464 # }}}
465 } # D()
467 __END__
469 # Plain Old Documentation (POD) {{{
471 =pod
473 =head1 NAME
475 mergesvn
477 =head1 SYNOPSIS
479 mergesvn [options] [file [files [...]]]
481 =head1 DESCRIPTION
483 Merge changes between Subversion controlled files or directories.
484 Elements without the "mergesvn" property will be ignored.
485 If no filenames are specified on the command line, it reads filenames
486 from stdin.
488 Files or directories to be controlled by mergesvn must have the
489 following property set:
491 =over 4
493 =item B<mergesvn>
495 =back
497 Contains one line for every place to merge from.
498 The line consists of two elements, the revision in the master file the
499 last merge was done, and path or URL to the master file.
500 These two fields are separated by exactly one space (U+0020).
502 =head1 OPTIONS
504 =over 4
506 =item B<-a>, B<--alias> I<x>
508 Use I<x> as alias for the master URL. The old value will still be
509 written to the mergesvn property.
511 =item B<-C>, B<--conflict>
513 Do not merge, but search for the first revision a conflict will occur
514 when a merge is done. After the search is finished, the first revision
515 number of a troublesome patch is printed, and you can choose by using
516 the -t option if you want to include the conflicting revision in the
517 merge.
519 =item B<-d>, B<--diff>
521 Instead of merging, show a repository diff between the master URL and
522 the versioned element.
524 =item B<--dry-run>
526 Try operation without making any changes.
527 Can be used to see if the merge will result in conflicts.
529 =item B<-h>, B<--help>
531 Print a brief help summary.
533 =item B<-l>, B<--log>
535 Show a revision log of the remaining merges.
537 =item B<-s>, B<--set> I<x>[@I<y>]
539 Set merge source for all filenames on the command line.
540 If I<@y> is specified, revision y will be used as the merge source,
541 otherwise HEAD is used.
542 The revision number actually used is the newest revision the source
543 changed.
545 =item B<-t>, B<--to> I<x>
547 Merge to revision I<x> instead of HEAD.
549 =item B<-v>, B<--verbose>
551 Increase level of verbosity. Can be repeated.
553 =item B<--version>
555 Print version information.
557 =item B<-v>, B<--verbose>
559 Use the -v option together with svn commands that accepts it.
561 =item B<--debug>
563 Print debugging messages.
565 =back
567 =head1 BUGS
569 =over 4
571 =item The svn(1) client does not support diffs between different
572 repositories (yet), so the B<--diff> option will only work with elements
573 that has the master in the same repository.
575 =back
577 =head1 AUTHOR
579 Made by Øyvind A. Holm S<E<lt>sunny@sunbase.orgE<gt>>.
581 =head1 COPYRIGHT
583 Copyleft © Øyvind A. Holm E<lt>sunny@sunbase.orgE<gt>
584 This is free software; see the file F<COPYING> for legalese stuff.
586 =head1 LICENCE
588 This program is free software: you can redistribute it and/or modify it
589 under the terms of the GNU General Public License as published by the
590 Free Software Foundation, either version 2 of the License, or (at your
591 option) any later version.
593 This program is distributed in the hope that it will be useful, but
594 WITHOUT ANY WARRANTY; without even the implied warranty of
595 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
596 See the GNU General Public License for more details.
598 You should have received a copy of the GNU General Public License along
599 with this program.
600 If not, see L<http://www.gnu.org/licenses/>.
602 =head1 SEE ALSO
604 svn(1)
606 =cut
608 # }}}
610 # vim: set fenc=UTF-8 ft=perl fdm=marker ts=4 sw=4 sts=4 et fo+=w :