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