Support a suffix on ChangeLog filenames based on a configuration file
[WebKit-https.git] / Tools / 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::Copy;
38 use File::Path;
39 use File::Spec;
40 use Getopt::Long;
41 use POSIX;
42 use VCSUtils;
43
44 sub canonicalRelativePath($);
45 sub conflictFiles($);
46 sub findChangeLog($);
47 sub findUnmergedChangeLogs();
48 sub fixMergedChangeLogs($;@);
49 sub fixOneMergedChangeLog($);
50 sub hasGitUnmergedFiles();
51 sub isInGitFilterBranch();
52 sub parseFixMerged($$;$);
53 sub removeChangeLogArguments($);
54 sub resolveChangeLog($);
55 sub resolveConflict($);
56 sub showStatus($;$);
57 sub usageAndExit();
58
59 my $isGit = isGit();
60 my $isSVN = isSVN();
61
62 my $SVN = "svn";
63 my $GIT = "git";
64
65 my $fixMerged;
66 my $gitRebaseContinue = 0;
67 my $mergeDriver = 0;
68 my $printWarnings = 1;
69 my $showHelp;
70
71 sub usageAndExit()
72 {
73     print STDERR <<__END__;
74 Usage: @{[ basename($0) ]} [options] [path/to/ChangeLog] [path/to/another/ChangeLog ...]
75   -c|--[no-]continue               run "git rebase --continue" after fixing ChangeLog
76                                    entries (default: --no-continue)
77   -f|--fix-merged [revision-range] fix git-merged ChangeLog entries; if a revision-range
78                                    is specified, run git filter-branch on the range
79   -m|--merge-driver %O %A %B       act as a git merge-driver on files %O %A %B
80   -h|--help                        show this help message
81   -w|--[no-]warnings               show or suppress warnings (default: show warnings)
82 __END__
83     exit 1;
84 }
85
86 my $getOptionsResult = GetOptions(
87     'c|continue!'     => \$gitRebaseContinue,
88     'f|fix-merged:s'  => \&parseFixMerged,
89     'm|merge-driver!' => \$mergeDriver,
90     'h|help'          => \$showHelp,
91     'w|warnings!'     => \$printWarnings,
92 );
93
94 if (!$getOptionsResult || $showHelp) {
95     usageAndExit();
96 }
97
98 my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot());
99
100 my @changeLogFiles = removeChangeLogArguments($relativePath);
101
102 if (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) {
103     @changeLogFiles = findUnmergedChangeLogs();
104 }
105
106 if (!$mergeDriver && scalar(@ARGV) > 0) {
107     print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n";
108     undef $getOptionsResult;
109 } elsif (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) {
110     print STDERR "ERROR: No ChangeLog files listed on command-line or found unmerged.\n";
111     undef $getOptionsResult;
112 } elsif ($gitRebaseContinue && !$isGit) {
113     print STDERR "ERROR: --continue may only be used with a git repository\n";
114     undef $getOptionsResult;
115 } elsif (defined $fixMerged && !$isGit) {
116     print STDERR "ERROR: --fix-merged may only be used with a git repository\n";
117     undef $getOptionsResult;
118 } elsif ($mergeDriver && !$isGit) {
119     print STDERR "ERROR: --merge-driver may only be used with a git repository\n";
120     undef $getOptionsResult;
121 } elsif ($mergeDriver && scalar(@ARGV) < 3) {
122     print STDERR "ERROR: --merge-driver expects %O %A %B as arguments\n";
123     undef $getOptionsResult;
124 }
125
126 if (!$getOptionsResult) {
127     usageAndExit();
128 }
129
130 if (defined $fixMerged && length($fixMerged) > 0) {
131     my $commitRange = $fixMerged;
132     $commitRange = $commitRange . "..HEAD" if index($commitRange, "..") < 0;
133     fixMergedChangeLogs($commitRange, @changeLogFiles);
134 } elsif ($mergeDriver) {
135     my ($base, $theirs, $ours) = @ARGV;
136     if (mergeChangeLogs($ours, $base, $theirs)) {
137         unlink($ours);
138         copy($theirs, $ours) or die $!;
139     } else {
140         exec qw(git merge-file -L THEIRS -L BASE -L OURS), $theirs, $base, $ours;
141     }
142 } elsif (@changeLogFiles) {
143     for my $file (@changeLogFiles) {
144         if (defined $fixMerged) {
145             fixOneMergedChangeLog($file);
146         } else {
147             resolveChangeLog($file);
148         }
149     }
150 } else {
151     print STDERR "ERROR: Unknown combination of switches and arguments.\n";
152     usageAndExit();
153 }
154
155 if ($gitRebaseContinue) {
156     if (hasGitUnmergedFiles()) {
157         print "Unmerged files; skipping '$GIT rebase --continue'.\n";
158     } else {
159         print "Running '$GIT rebase --continue'...\n";
160         print `$GIT rebase --continue`;
161     }
162 }
163
164 exit 0;
165
166 sub canonicalRelativePath($)
167 {
168     my ($originalPath) = @_;
169     my $absolutePath = Cwd::abs_path($originalPath);
170     return File::Spec->abs2rel($absolutePath, Cwd::getcwd());
171 }
172
173 sub conflictFiles($)
174 {
175     my ($file) = @_;
176     my $fileMine;
177     my $fileOlder;
178     my $fileNewer;
179
180     if (-e $file && -e "$file.orig" && -e "$file.rej") {
181         return ("$file.rej", "$file.orig", $file);
182     }
183
184     if ($isSVN) {
185         my $escapedFile = escapeSubversionPath($file);
186         open STAT, "-|", $SVN, "status", $escapedFile or die $!;
187         my $status = <STAT>;
188         close STAT;
189         if (!$status || $status !~ m/^C\s+/) {
190             print STDERR "WARNING: ${file} is not in a conflicted state.\n" if $printWarnings;
191             return ();
192         }
193
194         $fileMine = "${file}.mine" if -e "${file}.mine";
195
196         my $currentRevision;
197         open INFO, "-|", $SVN, "info", $escapedFile or die $!;
198         while (my $line = <INFO>) {
199             if ($line =~ m/^Revision: ([0-9]+)/) {
200                 $currentRevision = $1;
201                 { local $/ = undef; <INFO>; }  # Consume rest of input.
202             }
203         }
204         close INFO;
205         $fileNewer = "${file}.r${currentRevision}" if -e "${file}.r${currentRevision}";
206
207         my @matchingFiles = grep { $_ ne $fileNewer } glob("${file}.r[0-9][0-9]*");
208         if (scalar(@matchingFiles) > 1) {
209             print STDERR "WARNING: Too many conflict files exist for ${file}!\n" if $printWarnings;
210         } else {
211             $fileOlder = shift @matchingFiles;
212         }
213     } elsif ($isGit) {
214         my $gitPrefix = `$GIT rev-parse --show-prefix`;
215         chomp $gitPrefix;
216         open GIT, "-|", $GIT, "ls-files", "--unmerged", $file or die $!;
217         while (my $line = <GIT>) {
218             my ($mode, $hash, $stage, $fileName) = split(' ', $line);
219             my $outputFile;
220             if ($stage == 1) {
221                 $fileOlder = "${file}.BASE.$$";
222                 $outputFile = $fileOlder;
223             } elsif ($stage == 2) {
224                 $fileNewer = "${file}.LOCAL.$$";
225                 $outputFile = $fileNewer;
226             } elsif ($stage == 3) {
227                 $fileMine = "${file}.REMOTE.$$";
228                 $outputFile = $fileMine;
229             } else {
230                 die "Unknown file stage: $stage";
231             }
232             system("$GIT cat-file blob :${stage}:${gitPrefix}${file} > $outputFile");
233             die $! if WEXITSTATUS($?);
234         }
235         close GIT or die $!;
236     } else {
237         die "Unknown version control system";
238     }
239
240     if (!$fileMine && !$fileOlder && !$fileNewer) {
241         print STDERR "WARNING: ${file} does not need merging.\n" if $printWarnings;
242     } elsif (!$fileMine || !$fileOlder || !$fileNewer) {
243         print STDERR "WARNING: ${file} is missing some conflict files.\n" if $printWarnings;
244     }
245
246     return ($fileMine, $fileOlder, $fileNewer);
247 }
248
249 sub findChangeLog($)
250 {
251     my $changeLogFileName = changeLogFileName();
252     return $_[0] if basename($_[0]) eq $changeLogFileName;
253
254     my $file = File::Spec->catfile($_[0], $changeLogFileName);
255     return $file if -d $_[0] and -e $file;
256
257     return undef;
258 }
259
260 sub findUnmergedChangeLogs()
261 {
262     my $statCommand = "";
263
264     if ($isSVN) {
265         $statCommand = "$SVN stat | grep '^C'";
266     } elsif ($isGit) {
267         $statCommand = "$GIT diff -r --name-status --diff-filter=U -C -C -M";
268     } else {
269         return ();
270     }
271
272     my @results = ();
273     open STAT, "-|", $statCommand or die "The status failed: $!.\n";
274     while (<STAT>) {
275         if ($isSVN) {
276             my $matches;
277             my $file;
278             if (isSVNVersion16OrNewer()) {
279                 $matches = /^([C]).{6} (.+?)[\r\n]*$/;
280                 $file = $2;
281             } else {
282                 $matches = /^([C]).{5} (.+?)[\r\n]*$/;
283                 $file = $2;
284             }
285             if ($matches) {
286                 $file = findChangeLog(normalizePath($file));
287                 push @results, $file if $file;
288             } else {
289                 print;  # error output from svn stat
290             }
291         } elsif ($isGit) {
292             if (/^([U])\t(.+)$/) {
293                 my $file = findChangeLog(normalizePath($2));
294                 push @results, $file if $file;
295             } else {
296                 print;  # error output from git diff
297             }
298         }
299     }
300     close STAT;
301
302     return @results;
303 }
304
305 sub fixMergedChangeLogs($;@)
306 {
307     my $revisionRange = shift;
308     my @changedFiles = @_;
309
310     if (scalar(@changedFiles) < 1) {
311         # Read in list of files changed in $revisionRange
312         open GIT, "-|", $GIT, "diff", "--name-only", $revisionRange or die $!;
313         push @changedFiles, <GIT>;
314         close GIT or die $!;
315         die "No changed files in $revisionRange" if scalar(@changedFiles) < 1;
316         chomp @changedFiles;
317     }
318
319     my @changeLogs = grep { defined $_ } map { findChangeLog($_) } @changedFiles;
320     die "No changed ChangeLog files in $revisionRange" if scalar(@changeLogs) < 1;
321
322     system("$GIT filter-branch --tree-filter 'PREVIOUS_COMMIT=\`$GIT rev-parse \$GIT_COMMIT^\` && MAPPED_PREVIOUS_COMMIT=\`map \$PREVIOUS_COMMIT\` \"$0\" -f \"" . join('" "', @changeLogs) . "\"' $revisionRange");
323
324     # On success, remove the backup refs directory
325     if (WEXITSTATUS($?) == 0) {
326         rmtree(qw(.git/refs/original));
327     }
328 }
329
330 sub fixOneMergedChangeLog($)
331 {
332     my $file = shift;
333     my $patch;
334
335     # Read in patch for incorrectly merged ChangeLog entry
336     {
337         local $/ = undef;
338         open GIT, "-|", $GIT, "diff", ($ENV{GIT_COMMIT} || "HEAD") . "^", $file or die $!;
339         $patch = <GIT>;
340         close GIT or die $!;
341     }
342
343     # Always checkout the previous commit's copy of the ChangeLog
344     system($GIT, "checkout", $ENV{MAPPED_PREVIOUS_COMMIT} || "HEAD^", $file);
345     die $! if WEXITSTATUS($?);
346
347     # The patch must have 0 or more lines of context, then 1 or more lines
348     # of additions, and then 1 or more lines of context.  If not, we skip it.
349     if ($patch =~ /\n@@ -(\d+),(\d+) \+(\d+),(\d+) @@\n( .*\n)*((\+.*\n)+)( .*\n)+$/m) {
350         # Copy the header from the original patch.
351         my $newPatch = substr($patch, 0, index($patch, "@@ -${1},${2} +${3},${4} @@"));
352
353         # Generate a new set of line numbers and patch lengths.  Our new
354         # patch will start with the lines for the fixed ChangeLog entry,
355         # then have 3 lines of context from the top of the current file to
356         # make the patch apply cleanly.
357         $newPatch .= "@@ -1,3 +1," . ($4 - $2 + 3) . " @@\n";
358
359         # We assume that top few lines of the ChangeLog entry are actually
360         # at the bottom of the list of added lines (due to the way the patch
361         # algorithm works), so we simply search through the lines until we
362         # find the date line, then move the rest of the lines to the top.
363         my @patchLines = map { $_ . "\n" } split(/\n/, $6);
364         foreach my $i (0 .. $#patchLines) {
365             if ($patchLines[$i] =~ /^\+\d{4}-\d{2}-\d{2}  /) {
366                 unshift(@patchLines, splice(@patchLines, $i, scalar(@patchLines) - $i));
367                 last;
368             }
369         }
370
371         $newPatch .= join("", @patchLines);
372
373         # Add 3 lines of context to the end
374         open FILE, "<", $file or die $!;
375         for (my $i = 0; $i < 3; $i++) {
376             $newPatch .= " " . <FILE>;
377         }
378         close FILE;
379
380         # Apply the new patch
381         open(PATCH, "| patch -p1 $file > " . File::Spec->devnull()) or die $!;
382         print PATCH $newPatch;
383         close(PATCH) or die $!;
384
385         # Run "git add" on the fixed ChangeLog file
386         system($GIT, "add", $file);
387         die $! if WEXITSTATUS($?);
388
389         showStatus($file, 1);
390     } elsif ($patch) {
391         # Restore the current copy of the ChangeLog file since we can't repatch it
392         system($GIT, "checkout", $ENV{GIT_COMMIT} || "HEAD", $file);
393         die $! if WEXITSTATUS($?);
394         print STDERR "WARNING: Last change to ${file} could not be fixed and re-merged.\n" if $printWarnings;
395     }
396 }
397
398 sub hasGitUnmergedFiles()
399 {
400     my $output = `$GIT ls-files --unmerged`;
401     return $output ne "";
402 }
403
404 sub isInGitFilterBranch()
405 {
406     return exists $ENV{MAPPED_PREVIOUS_COMMIT} && $ENV{MAPPED_PREVIOUS_COMMIT};
407 }
408
409 sub parseFixMerged($$;$)
410 {
411     my ($switchName, $key, $value) = @_;
412     if (defined $key) {
413         if (defined findChangeLog($key)) {
414             unshift(@ARGV, $key);
415             $fixMerged = "";
416         } else {
417             $fixMerged = $key;
418         }
419     } else {
420         $fixMerged = "";
421     }
422 }
423
424 sub removeChangeLogArguments($)
425 {
426     my ($baseDir) = @_;
427     my @results = ();
428
429     for (my $i = 0; $i < scalar(@ARGV); ) {
430         my $file = findChangeLog(canonicalRelativePath(File::Spec->catfile($baseDir, $ARGV[$i])));
431         if (defined $file) {
432             splice(@ARGV, $i, 1);
433             push @results, $file;
434         } else {
435             $i++;
436         }
437     }
438
439     return @results;
440 }
441
442 sub resolveChangeLog($)
443 {
444     my ($file) = @_;
445
446     my ($fileMine, $fileOlder, $fileNewer) = conflictFiles($file);
447
448     return unless $fileMine && $fileOlder && $fileNewer;
449
450     if (mergeChangeLogs($fileMine, $fileOlder, $fileNewer)) {
451         if ($file ne $fileNewer) {
452             unlink($file);
453             rename($fileNewer, $file) or die $!;
454         }
455         unlink($fileMine, $fileOlder);
456         resolveConflict($file);
457         showStatus($file, 1);
458     } else {
459         showStatus($file);
460         print STDERR "WARNING: ${file} could not be merged using fuzz level 3.\n" if $printWarnings;
461         unlink($fileMine, $fileOlder, $fileNewer) if $isGit;
462     }
463 }
464
465 sub resolveConflict($)
466 {
467     my ($file) = @_;
468
469     if ($isSVN) {
470         my $escapedFile = escapeSubversionPath($file);
471         system($SVN, "resolved", $escapedFile);
472         die $! if WEXITSTATUS($?);
473     } elsif ($isGit) {
474         system($GIT, "add", $file);
475         die $! if WEXITSTATUS($?);
476     } else {
477         die "Unknown version control system";
478     }
479 }
480
481 sub showStatus($;$)
482 {
483     my ($file, $isConflictResolved) = @_;
484
485     if ($isSVN) {
486         my $escapedFile = escapeSubversionPath($file);
487         system($SVN, "status", $escapedFile);
488     } elsif ($isGit) {
489         my @args = qw(--name-status);
490         unshift @args, qw(--cached) if $isConflictResolved;
491         system($GIT, "diff", @args, $file);
492     } else {
493         die "Unknown version control system";
494     }
495 }
496