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