9107fd2f8bc765fc30cb2af4d4fbedfff321a097
[WebKit-https.git] / WebKitTools / Scripts / resolve-ChangeLogs
1 #!/usr/bin/perl -w
2
3 # Copyright (C) 2007, 2008, 2009 Apple Inc.  All rights reserved.
4 #
5 # Redistribution and use in source and binary forms, with or without
6 # modification, are permitted provided that the following conditions
7 # are met:
8 #
9 # 1.  Redistributions of source code must retain the above copyright
10 #     notice, this list of conditions and the following disclaimer. 
11 # 2.  Redistributions in binary form must reproduce the above copyright
12 #     notice, this list of conditions and the following disclaimer in the
13 #     documentation and/or other materials provided with the distribution. 
14 # 3.  Neither the name of Apple Computer, Inc. ("Apple") nor the names of
15 #     its contributors may be used to endorse or promote products derived
16 #     from this software without specific prior written permission. 
17 #
18 # THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
19 # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 # DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
22 # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25 # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
27 # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
29 # Merge and resolve ChangeLog conflicts for svn and git repositories
30
31 use strict;
32
33 use FindBin;
34 use lib $FindBin::Bin;
35
36 use File::Basename;
37 use File::Path;
38 use File::Spec;
39 use Getopt::Long;
40 use POSIX;
41 use VCSUtils;
42
43 sub canonicalRelativePath($);
44 sub conflictFiles($);
45 sub findChangeLog($);
46 sub findUnmergedChangeLogs();
47 sub fixChangeLogPatch($);
48 sub fixMergedChangeLogs($;@);
49 sub fixOneMergedChangeLog($);
50 sub hasGitUnmergedFiles();
51 sub mergeChanges($$$);
52 sub parseFixMerged($$;$);
53 sub removeChangeLogArguments($);
54 sub resolveChangeLog($);
55 sub resolveConflict($);
56 sub showStatus($;$);
57 sub usageAndExit();
58 sub normalizePath($);
59
60 my $isGit = isGit();
61 my $isSVN = isSVN();
62
63 my $SVN = "svn";
64 my $GIT = "git";
65
66 my $svnVersion = `svn --version --quiet` if $isSVN;
67
68 my $fixMerged;
69 my $gitRebaseContinue = 0;
70 my $printWarnings = 1;
71 my $showHelp;
72
73 my $getOptionsResult = GetOptions(
74     'c|continue!'    => \$gitRebaseContinue,
75     'f|fix-merged:s' => \&parseFixMerged,
76     'h|help'         => \$showHelp,
77     'w|warnings!'    => \$printWarnings,
78 );
79
80 my $relativePath = chdirReturningRelativePath(determineVCSRoot());
81
82 my @changeLogFiles = removeChangeLogArguments($relativePath);
83
84 if (!defined $fixMerged && scalar(@changeLogFiles) == 0) {
85     @changeLogFiles = findUnmergedChangeLogs();
86 }
87
88 if (scalar(@ARGV) > 0) {
89     print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n";
90     undef $getOptionsResult;
91 } elsif (!defined $fixMerged && scalar(@changeLogFiles) == 0) {
92     print STDERR "ERROR: No ChangeLog files listed on command-line or found unmerged.\n";
93     undef $getOptionsResult;
94 } elsif ($gitRebaseContinue && !$isGit) {
95     print STDERR "ERROR: --continue may only be used with a git repository\n";
96     undef $getOptionsResult;
97 } elsif (defined $fixMerged && !$isGit) {
98     print STDERR "ERROR: --fix-merged may only be used with a git repository\n";
99     undef $getOptionsResult;
100 }
101
102 sub usageAndExit()
103 {
104     print STDERR <<__END__;
105 Usage: @{[ basename($0) ]} [options] [path/to/ChangeLog] [path/to/another/ChangeLog ...]
106   -c|--[no-]continue               run "git rebase --continue" after fixing ChangeLog
107                                    entries (default: --no-continue)
108   -f|--fix-merged [revision-range] fix git-merged ChangeLog entries; if a revision-range
109                                    is specified, run git filter-branch on the range
110   -h|--help                        show this help message
111   -w|--[no-]warnings               show or suppress warnings (default: show warnings)
112 __END__
113     exit 1;
114 }
115
116 if (!$getOptionsResult || $showHelp) {
117     usageAndExit();
118 }
119
120 if (defined $fixMerged && length($fixMerged) > 0) {
121     my $commitRange = $fixMerged;
122     $commitRange = $commitRange . "..HEAD" if index($commitRange, "..") < 0;
123     fixMergedChangeLogs($commitRange, @changeLogFiles);
124 } elsif (@changeLogFiles) {
125     for my $file (@changeLogFiles) {
126         if (defined $fixMerged) {
127             fixOneMergedChangeLog($file);
128         } else {
129             resolveChangeLog($file);
130         }
131     }
132 } else {
133     print STDERR "ERROR: Unknown combination of switches and arguments.\n";
134     usageAndExit();
135 }
136
137 if ($gitRebaseContinue) {
138     if (hasGitUnmergedFiles()) {
139         print "Unmerged files; skipping '$GIT rebase --continue'.\n";
140     } else {
141         print "Running '$GIT rebase --continue'...\n";
142         print `$GIT rebase --continue`;
143     }
144 }
145
146 exit 0;
147
148 sub canonicalRelativePath($)
149 {
150     my ($originalPath) = @_;
151     my $absolutePath = Cwd::abs_path($originalPath);
152     return File::Spec->abs2rel($absolutePath, Cwd::getcwd());
153 }
154
155 sub conflictFiles($)
156 {
157     my ($file) = @_;
158     my $fileMine;
159     my $fileOlder;
160     my $fileNewer;
161
162     if (-e $file && -e "$file.orig" && -e "$file.rej") {
163         return ("$file.rej", "$file.orig", $file);
164     }
165
166     if ($isSVN) {
167         open STAT, "-|", $SVN, "status", $file or die $!;
168         my $status = <STAT>;
169         close STAT;
170         if (!$status || $status !~ m/^C\s+/) {
171             print STDERR "WARNING: ${file} is not in a conflicted state.\n" if $printWarnings;
172             return ();
173         }
174
175         $fileMine = "${file}.mine" if -e "${file}.mine";
176
177         my $currentRevision;
178         open INFO, "-|", $SVN, "info", $file or die $!;
179         while (my $line = <INFO>) {
180             if ($line =~ m/^Revision: ([0-9]+)/) {
181                 $currentRevision = $1;
182                 { local $/ = undef; <INFO>; }  # Consume rest of input.
183             }
184         }
185         close INFO;
186         $fileNewer = "${file}.r${currentRevision}" if -e "${file}.r${currentRevision}";
187
188         my @matchingFiles = grep { $_ ne $fileNewer } glob("${file}.r[0-9][0-9]*");
189         if (scalar(@matchingFiles) > 1) {
190             print STDERR "WARNING: Too many conflict files exist for ${file}!\n" if $printWarnings;
191         } else {
192             $fileOlder = shift @matchingFiles;
193         }
194     } elsif ($isGit) {
195         my $gitPrefix = `$GIT rev-parse --show-prefix`;
196         chomp $gitPrefix;
197         open GIT, "-|", $GIT, "ls-files", "--unmerged", $file or die $!;
198         while (my $line = <GIT>) {
199             my ($mode, $hash, $stage, $fileName) = split(' ', $line);
200             my $outputFile;
201             if ($stage == 1) {
202                 $fileOlder = "${file}.BASE.$$";
203                 $outputFile = $fileOlder;
204             } elsif ($stage == 2) {
205                 $fileNewer = "${file}.LOCAL.$$";
206                 $outputFile = $fileNewer;
207             } elsif ($stage == 3) {
208                 $fileMine = "${file}.REMOTE.$$";
209                 $outputFile = $fileMine;
210             } else {
211                 die "Unknown file stage: $stage";
212             }
213             system("$GIT cat-file blob :${stage}:${gitPrefix}${file} > $outputFile");
214             die $! if WEXITSTATUS($?);
215         }
216         close GIT or die $!;
217     } else {
218         die "Unknown version control system";
219     }
220
221     if (!$fileMine && !$fileOlder && !$fileNewer) {
222         print STDERR "WARNING: ${file} does not need merging.\n" if $printWarnings;
223     } elsif (!$fileMine || !$fileOlder || !$fileNewer) {
224         print STDERR "WARNING: ${file} is missing some conflict files.\n" if $printWarnings;
225     }
226
227     return ($fileMine, $fileOlder, $fileNewer);
228 }
229
230 sub findChangeLog($)
231 {
232     return $_[0] if basename($_[0]) eq "ChangeLog";
233
234     my $file = File::Spec->catfile($_[0], "ChangeLog");
235     return $file if -d $_[0] and -e $file;
236
237     return undef;
238 }
239
240 sub findUnmergedChangeLogs()
241 {
242     my $statCommand = "";
243
244     if ($isSVN) {
245         $statCommand = "$SVN stat | grep '^C'";
246     } elsif ($isGit) {
247         $statCommand = "$GIT diff -r --name-status --diff-filter=U -C -C -M";
248     } else {
249         return ();
250     }
251
252     my @results = ();
253     open STAT, "-|", $statCommand or die "The status failed: $!.\n";
254     while (<STAT>) {
255         if ($isSVN) {
256             my $matches;
257             my $file;
258             if (eval "v$svnVersion" ge v1.6) {
259                 $matches = /^([C]).{6} (.*\S+)\s*$/;
260                 $file = $2;
261             } else {
262                 $matches = /^([C]).{5} (.*\S+)\s*$/;
263                 $file = $2;
264             }
265             if ($matches) {
266                 $file = findChangeLog(normalizePath($file));
267                 push @results, $file if $file;
268             } else {
269                 print;  # error output from svn stat
270             }
271         } elsif ($isGit) {
272             if (/^([U])\t(.+)$/) {
273                 my $file = findChangeLog(normalizePath($2));
274                 push @results, $file if $file;
275             } else {
276                 print;  # error output from git diff
277             }
278         }
279     }
280     close STAT;
281
282     return @results;
283 }
284
285 sub fixChangeLogPatch($)
286 {
287     my $patch = shift;
288     my $contextLineCount = 3;
289
290     return $patch if $patch !~ /\n@@ -1,(\d+) \+1,(\d+) @@\n( .*\n)+(\+.*\n)+( .*\n){$contextLineCount}$/m;
291     my ($oldLineCount, $newLineCount) = ($1, $2);
292     return $patch if $oldLineCount <= $contextLineCount;
293
294     # The diff(1) command is greedy when matching lines, so a new ChangeLog entry will
295     # have lines of context at the top of a patch when the existing entry has the same
296     # date and author as the new entry.  This nifty loop alters a ChangeLog patch so
297     # that the added lines ("+") in the patch always start at the beginning of the
298     # patch and there are no initial lines of context.
299     my $newPatch;
300     my $lineCountInState = 0;
301     my $oldContentLineCountReduction = $oldLineCount - $contextLineCount;
302     my $newContentLineCountWithoutContext = $newLineCount - $oldLineCount - $oldContentLineCountReduction;
303     my ($stateHeader, $statePreContext, $stateNewChanges, $statePostContext) = (1..4);
304     my $state = $stateHeader;
305     foreach my $line (split(/\n/, $patch)) {
306         $lineCountInState++;
307         if ($state == $stateHeader && $line =~ /^@@ -1,$oldLineCount \+1,$newLineCount @\@$/) {
308             $line = "@@ -1,$contextLineCount +1," . ($newLineCount - $oldContentLineCountReduction) . " @@";
309             $lineCountInState = 0;
310             $state = $statePreContext;
311         } elsif ($state == $statePreContext && substr($line, 0, 1) eq " ") {
312             $line = "+" . substr($line, 1);
313             if ($lineCountInState == $oldContentLineCountReduction) {
314                 $lineCountInState = 0;
315                 $state = $stateNewChanges;
316             }
317         } elsif ($state == $stateNewChanges && substr($line, 0, 1) eq "+") {
318             # No changes to these lines
319             if ($lineCountInState == $newContentLineCountWithoutContext) {
320                 $lineCountInState = 0;
321                 $state = $statePostContext;
322             }
323         } elsif ($state == $statePostContext) {
324             if (substr($line, 0, 1) eq "+" && $lineCountInState <= $oldContentLineCountReduction) {
325                 $line = " " . substr($line, 1);
326             } elsif ($lineCountInState > $contextLineCount && substr($line, 0, 1) eq " ") {
327                 next; # Discard
328             }
329         }
330         $newPatch .= $line . "\n";
331     }
332
333     return $newPatch;
334 }
335
336 sub fixMergedChangeLogs($;@)
337 {
338     my $revisionRange = shift;
339     my @changedFiles = @_;
340
341     if (scalar(@changedFiles) < 1) {
342         # Read in list of files changed in $revisionRange
343         open GIT, "-|", $GIT, "diff", "--name-only", $revisionRange or die $!;
344         push @changedFiles, <GIT>;
345         close GIT or die $!;
346         die "No changed files in $revisionRange" if scalar(@changedFiles) < 1;
347         chomp @changedFiles;
348     }
349
350     my @changeLogs = grep { defined $_ } map { findChangeLog($_) } @changedFiles;
351     die "No changed ChangeLog files in $revisionRange" if scalar(@changeLogs) < 1;
352
353     system("$GIT filter-branch --tree-filter 'PREVIOUS_COMMIT=\`$GIT rev-parse \$GIT_COMMIT^\` && MAPPED_PREVIOUS_COMMIT=\`map \$PREVIOUS_COMMIT\` \"$0\" -f \"" . join('" "', @changeLogs) . "\"' $revisionRange");
354
355     # On success, remove the backup refs directory
356     if (WEXITSTATUS($?) == 0) {
357         rmtree(qw(.git/refs/original));
358     }
359 }
360
361 sub fixOneMergedChangeLog($)
362 {
363     my $file = shift;
364     my $patch;
365
366     # Read in patch for incorrectly merged ChangeLog entry
367     {
368         local $/ = undef;
369         open GIT, "-|", $GIT, "diff", ($ENV{GIT_COMMIT} || "HEAD") . "^", $file or die $!;
370         $patch = <GIT>;
371         close GIT or die $!;
372     }
373
374     # Always checkout the previous commit's copy of the ChangeLog
375     system($GIT, "checkout", $ENV{MAPPED_PREVIOUS_COMMIT} || "HEAD^", $file);
376     die $! if WEXITSTATUS($?);
377
378     # The patch must have 0 or more lines of context, then 1 or more lines
379     # of additions, and then 1 or more lines of context.  If not, we skip it.
380     if ($patch =~ /\n@@ -(\d+),(\d+) \+(\d+),(\d+) @@\n( .*\n)*((\+.*\n)+)( .*\n)+$/m) {
381         # Copy the header from the original patch.
382         my $newPatch = substr($patch, 0, index($patch, "@@ -${1},${2} +${3},${4} @@"));
383
384         # Generate a new set of line numbers and patch lengths.  Our new
385         # patch will start with the lines for the fixed ChangeLog entry,
386         # then have 3 lines of context from the top of the current file to
387         # make the patch apply cleanly.
388         $newPatch .= "@@ -1,3 +1," . ($4 - $2 + 3) . " @@\n";
389
390         # We assume that top few lines of the ChangeLog entry are actually
391         # at the bottom of the list of added lines (due to the way the patch
392         # algorithm works), so we simply search through the lines until we
393         # find the date line, then move the rest of the lines to the top.
394         my @patchLines = map { $_ . "\n" } split(/\n/, $6);
395         foreach my $i (0 .. $#patchLines) {
396             if ($patchLines[$i] =~ /^\+\d{4}-\d{2}-\d{2}  /) {
397                 unshift(@patchLines, splice(@patchLines, $i, scalar(@patchLines) - $i));
398                 last;
399             }
400         }
401
402         $newPatch .= join("", @patchLines);
403
404         # Add 3 lines of context to the end
405         open FILE, "<", $file or die $!;
406         for (my $i = 0; $i < 3; $i++) {
407             $newPatch .= " " . <FILE>;
408         }
409         close FILE;
410
411         # Apply the new patch
412         open(PATCH, "| patch -p1 $file > /dev/null") or die $!;
413         print PATCH $newPatch;
414         close(PATCH) or die $!;
415
416         # Run "git add" on the fixed ChangeLog file
417         system($GIT, "add", $file);
418         die $! if WEXITSTATUS($?);
419
420         showStatus($file, 1);
421     } elsif ($patch) {
422         # Restore the current copy of the ChangeLog file since we can't repatch it
423         system($GIT, "checkout", $ENV{GIT_COMMIT} || "HEAD", $file);
424         die $! if WEXITSTATUS($?);
425         print STDERR "WARNING: Last change to ${file} could not be fixed and re-merged.\n" if $printWarnings;
426     }
427 }
428
429 sub hasGitUnmergedFiles()
430 {
431     my $output = `$GIT ls-files --unmerged`;
432     return $output ne "";
433 }
434
435 sub mergeChanges($$$)
436 {
437     my ($fileMine, $fileOlder, $fileNewer) = @_;
438
439     my $traditionalReject = $fileMine =~ /\.rej$/ ? 1 : 0;
440
441     local $/ = undef;
442
443     my $patch;
444     if ($traditionalReject) {
445         open(DIFF, "<", $fileMine) or die $!;
446         $patch = <DIFF>;
447         close(DIFF);
448         rename($fileMine, "$fileMine.save");
449         rename($fileOlder, "$fileOlder.save");
450     } else {
451         open(DIFF, "-|", qw(diff -u -a --binary), $fileOlder, $fileMine) or die $!;
452         $patch = <DIFF>;
453         close(DIFF);
454     }
455
456     unlink("${fileNewer}.orig");
457     unlink("${fileNewer}.rej");
458
459     open(PATCH, "| patch --fuzz=3 --binary $fileNewer > /dev/null") or die $!;
460     print PATCH fixChangeLogPatch($patch);
461     close(PATCH);
462
463     my $result;
464
465     # Refuse to merge the patch if it did not apply cleanly
466     if (-e "${fileNewer}.rej") {
467         unlink("${fileNewer}.rej");
468         unlink($fileNewer);
469         rename("${fileNewer}.orig", $fileNewer);
470         $result = 0;
471     } else {
472         unlink("${fileNewer}.orig");
473         $result = 1;
474     }
475
476     if ($traditionalReject) {
477         rename("$fileMine.save", $fileMine);
478         rename("$fileOlder.save", $fileOlder);
479     }
480
481     return $result;
482 }
483
484 sub parseFixMerged($$;$)
485 {
486     my ($switchName, $key, $value) = @_;
487     if (defined $key) {
488         if (defined findChangeLog($key)) {
489             unshift(@ARGV, $key);
490             $fixMerged = "";
491         } else {
492             $fixMerged = $key;
493         }
494     } else {
495         $fixMerged = "";
496     }
497 }
498
499 sub removeChangeLogArguments($)
500 {
501     my ($baseDir) = @_;
502     my @results = ();
503
504     for (my $i = 0; $i < scalar(@ARGV); ) {
505         my $file = findChangeLog(canonicalRelativePath(File::Spec->catfile($baseDir, $ARGV[$i])));
506         if (defined $file) {
507             splice(@ARGV, $i, 1);
508             push @results, $file;
509         } else {
510             $i++;
511         }
512     }
513
514     return @results;
515 }
516
517 sub resolveChangeLog($)
518 {
519     my ($file) = @_;
520
521     my ($fileMine, $fileOlder, $fileNewer) = conflictFiles($file);
522
523     return unless $fileMine && $fileOlder && $fileNewer;
524
525     if (mergeChanges($fileMine, $fileOlder, $fileNewer)) {
526         if ($file ne $fileNewer) {
527             unlink($file);
528             rename($fileNewer, $file) or die $!;
529         }
530         unlink($fileMine, $fileOlder);
531         resolveConflict($file);
532         showStatus($file, 1);
533     } else {
534         showStatus($file);
535         print STDERR "WARNING: ${file} could not be merged using fuzz level 3.\n" if $printWarnings;
536         unlink($fileMine, $fileOlder, $fileNewer) if $isGit;
537     }
538 }
539
540 sub resolveConflict($)
541 {
542     my ($file) = @_;
543
544     if ($isSVN) {
545         system($SVN, "resolved", $file);
546         die $! if WEXITSTATUS($?);
547     } elsif ($isGit) {
548         system($GIT, "add", $file);
549         die $! if WEXITSTATUS($?);
550     } else {
551         die "Unknown version control system";
552     }
553 }
554
555 sub showStatus($;$)
556 {
557     my ($file, $isConflictResolved) = @_;
558
559     if ($isSVN) {
560         system($SVN, "status", $file);
561     } elsif ($isGit) {
562         my @args = qw(--name-status);
563         unshift @args, qw(--cached) if $isConflictResolved;
564         system($GIT, "diff", @args, $file);
565     } else {
566         die "Unknown version control system";
567     }
568 }
569
570 sub normalizePath($)
571 {
572     my ($path) = @_;
573     $path =~ s/\\/\//g;
574     return $path;
575 }