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