13b215b0db8196104de21e697a6bcdaff6b98e81
[WebKit-https.git] / WebKitTools / Scripts / svn-apply
1 #!/usr/bin/perl -w
2
3 # Copyright (C) 2005, 2006, 2007 Apple Inc.  All rights reserved.
4 # Copyright (C) 2009 Cameron McCormack <cam@mcc.id.au>
5 # Copyright (C) 2010 Chris Jerdonek (chris.jerdonek@gmail.com)
6 #
7 # Redistribution and use in source and binary forms, with or without
8 # modification, are permitted provided that the following conditions
9 # are met:
10 #
11 # 1.  Redistributions of source code must retain the above copyright
12 #     notice, this list of conditions and the following disclaimer.
13 # 2.  Redistributions in binary form must reproduce the above copyright
14 #     notice, this list of conditions and the following disclaimer in the
15 #     documentation and/or other materials provided with the distribution. 
16 # 3.  Neither the name of Apple Computer, Inc. ("Apple") nor the names of
17 #     its contributors may be used to endorse or promote products derived
18 #     from this software without specific prior written permission.
19 #
20 # THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
21 # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 # DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
24 # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
25 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
26 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
27 # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
29 # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30
31 # "patch" script for WebKit Open Source Project, used to apply patches.
32
33 # Differences from invoking "patch -p0":
34 #
35 #   Handles added files (does a svn add with logic to handle local changes).
36 #   Handles added directories (does a svn add).
37 #   Handles removed files (does a svn rm with logic to handle local changes).
38 #   Handles removed directories--those with no more files or directories left in them
39 #       (does a svn rm).
40 #   Has mode where it will roll back to svn version numbers in the patch file so svn
41 #       can do a 3-way merge.
42 #   Paths from Index: lines are used rather than the paths on the patch lines, which
43 #       makes patches generated by "cvs diff" work (increasingly unimportant since we
44 #       use Subversion now).
45 #   ChangeLog patches use --fuzz=3 to prevent rejects, and the entry date is set in
46 #       the patch to today's date using $changeLogTimeZone.
47 #   Handles binary files (requires patches made by svn-create-patch).
48 #   Handles copied and moved files (requires patches made by svn-create-patch).
49 #   Handles git-diff patches (without binary changes) created at the top-level directory
50 #
51 # Missing features:
52 #
53 #   Handle property changes.
54 #   Handle copied and moved directories (would require patches made by svn-create-patch).
55 #   When doing a removal, check that old file matches what's being removed.
56 #   Notice a patch that's being applied at the "wrong level" and make it work anyway.
57 #   Do a dry run on the whole patch and don't do anything if part of the patch is
58 #       going to fail (probably too strict unless we exclude ChangeLog).
59 #   Handle git-diff patches with binary delta
60
61 use strict;
62 use warnings;
63
64 use Digest::MD5;
65 use File::Basename;
66 use File::Spec;
67 use Getopt::Long;
68 use MIME::Base64;
69 use POSIX qw(strftime);
70
71 use FindBin;
72 use lib $FindBin::Bin;
73 use VCSUtils;
74
75 sub addDirectoriesIfNeeded($);
76 sub applyPatch($$;$);
77 sub checksum($);
78 sub handleBinaryChange($$);
79 sub handleGitBinaryChange($$);
80 sub isDirectoryEmptyForRemoval($);
81 sub patch($);
82 sub removeDirectoriesIfNeeded();
83 sub setChangeLogDateAndReviewer($$);
84
85 # These should be replaced by an scm class/module:
86 sub scmKnowsOfFile($);
87 sub scmCopy($$);
88 sub scmAdd($);
89 sub scmRemove($);
90
91
92 # Project time zone for Cupertino, CA, US
93 my $changeLogTimeZone = "PST8PDT";
94
95 my $merge = 0;
96 my $showHelp = 0;
97 my $reviewer;
98 my $force = 0;
99
100 my $optionParseSuccess = GetOptions(
101     "merge!" => \$merge,
102     "help!" => \$showHelp,
103     "reviewer=s" => \$reviewer,
104     "force!" => \$force
105 );
106
107 if (!$optionParseSuccess || $showHelp) {
108     print STDERR basename($0) . " [-h|--help] [--force] [-m|--merge] [-r|--reviewer name] patch1 [patch2 ...]\n";
109     exit 1;
110 }
111
112 my %removeDirectoryIgnoreList = (
113     '.' => 1,
114     '..' => 1,
115     '.git' => 1,
116     '.svn' => 1,
117     '_svn' => 1,
118 );
119
120 my $globalExitStatus = 0;
121
122 my $repositoryRootPath = determineVCSRoot();
123
124 my %checkedDirectories;
125 my %copiedFiles;
126
127 # Need to use a typeglob to pass the file handle as a parameter,
128 # otherwise get a bareword error.
129 my @diffHashRefs = parsePatch(*ARGV);
130
131 print "Parsed " . @diffHashRefs . " diffs from patch file(s).\n";
132
133 my $preparedPatchHash = prepareParsedPatch($force, @diffHashRefs);
134
135 my @copyDiffHashRefs = @{$preparedPatchHash->{copyDiffHashRefs}};
136 my @nonCopyDiffHashRefs = @{$preparedPatchHash->{nonCopyDiffHashRefs}};
137 my %sourceRevisions = %{$preparedPatchHash->{sourceRevisionHash}};
138
139 if ($merge) {
140     die "--merge is currently only supported for SVN" unless isSVN();
141     # How do we handle Git patches applied to an SVN checkout here?
142     for my $file (sort keys %sourceRevisions) {
143         my $version = $sourceRevisions{$file};
144         print "Getting version $version of $file\n";
145         system("svn", "update", "-r", $version, $file) == 0 or die "Failed to run svn update -r $version $file.";
146     }
147 }
148
149 # Handle copied and moved files first since moved files may have their
150 # source deleted before the move.
151 for my $copyDiffHashRef (@copyDiffHashRefs) {
152     my $indexPath = $copyDiffHashRef->{indexPath};
153     my $copiedFromPath = $copyDiffHashRef->{copiedFromPath};
154
155     addDirectoriesIfNeeded(dirname($indexPath));
156     scmCopy($copiedFromPath, $indexPath);
157
158     $copiedFiles{$indexPath} = $copiedFromPath;
159 }
160
161 for my $diffHashRef (@nonCopyDiffHashRefs) {
162     patch($diffHashRef);
163 }
164
165 removeDirectoriesIfNeeded();
166
167 exit $globalExitStatus;
168
169 sub addDirectoriesIfNeeded($)
170 {
171     my ($path) = @_;
172     my @dirs = File::Spec->splitdir($path);
173     my $dir = ".";
174     while (scalar @dirs) {
175         $dir = File::Spec->catdir($dir, shift @dirs);
176         next if exists $checkedDirectories{$dir};
177         if (! -e $dir) {
178             mkdir $dir or die "Failed to create required directory '$dir' for path '$path'\n";
179             scmAdd($dir);
180             $checkedDirectories{$dir} = 1;
181         }
182         elsif (-d $dir) {
183             # SVN prints "svn: warning: 'directory' is already under version control"
184             # if you try and add a directory which is already in the repository.
185             # Git will ignore the add, but re-adding large directories can be sloooow.
186             # So we check first to see if the directory is under version control first.
187             if (!scmKnowsOfFile($dir)) {
188                 scmAdd($dir);
189             }
190             $checkedDirectories{$dir} = 1;
191         }
192         else {
193             die "'$dir' exists, but is not a directory";
194         }
195     }
196 }
197
198 # Args:
199 #   $patch: a patch string.
200 #   $pathRelativeToRoot: the path of the file to be patched, relative to the
201 #                        repository root. This should normally be the path
202 #                        found in the patch's "Index:" line.
203 #   $options: a reference to an array of options to pass to the patch command.
204 sub applyPatch($$;$)
205 {
206     my ($patch, $pathRelativeToRoot, $options) = @_;
207
208     my $optionalArgs = {options => $options, ensureForce => $force};
209
210     my $exitStatus = runPatchCommand($patch, $repositoryRootPath, $pathRelativeToRoot, $optionalArgs);
211
212     if ($exitStatus) {
213         $globalExitStatus = $exitStatus;
214     }
215 }
216
217 sub checksum($)
218 {
219     my $file = shift;
220     open(FILE, $file) or die "Can't open '$file': $!";
221     binmode(FILE);
222     my $checksum = Digest::MD5->new->addfile(*FILE)->hexdigest();
223     close(FILE);
224     return $checksum;
225 }
226
227 sub handleBinaryChange($$)
228 {
229     my ($fullPath, $contents) = @_;
230     # [A-Za-z0-9+/] is the class of allowed base64 characters.
231     # One or more lines, at most 76 characters in length.
232     # The last line is allowed to have up to two '=' characters at the end (to signify padding).
233     if ($contents =~ m#((\n[A-Za-z0-9+/]{76})*\n[A-Za-z0-9+/]{2,74}?[A-Za-z0-9+/=]{2}\n)#) {
234         # Addition or Modification
235         open FILE, ">", $fullPath or die "Failed to open $fullPath.";
236         print FILE decode_base64($1);
237         close FILE;
238         if (!scmKnowsOfFile($fullPath)) {
239             # Addition
240             scmAdd($fullPath);
241         }
242     } else {
243         # Deletion
244         scmRemove($fullPath);
245     }
246 }
247
248 sub handleGitBinaryChange($$)
249 {
250     my ($fullPath, $contents) = @_;
251
252     my ($binaryChunkType, $binaryChunk, $reverseBinaryChunkType, $reverseBinaryChunk) = decodeGitBinaryPatch($contents, $fullPath);
253     # FIXME: support "delta" type.
254     die "only literal type is supported now" if ($binaryChunkType ne "literal" || $reverseBinaryChunkType ne "literal");
255
256     my $isFileAddition = $contents =~ /\nnew file mode \d+\n/;
257     my $isFileDeletion = $contents =~ /\ndeleted file mode \d+\n/;
258
259     my $originalContents = "";
260     if (open FILE, $fullPath) {
261         die "$fullPath already exists" if $isFileAddition;
262
263         $originalContents = join("", <FILE>);
264         close FILE;
265     }
266     die "Original content of $fullPath mismatches" if $originalContents ne $reverseBinaryChunk;
267
268     if ($isFileDeletion) {
269         scmRemove($fullPath);
270     } else {
271         # Addition or Modification
272         open FILE, ">", $fullPath or die "Failed to open $fullPath.";
273         print FILE $binaryChunk;
274         close FILE;
275         if ($isFileAddition) {
276             scmAdd($fullPath);
277         }
278     }
279 }
280
281 sub isDirectoryEmptyForRemoval($)
282 {
283     my ($dir) = @_;
284     return 1 unless -d $dir;
285     my $directoryIsEmpty = 1;
286     opendir DIR, $dir or die "Could not open '$dir' to list files: $?";
287     for (my $item = readdir DIR; $item && $directoryIsEmpty; $item = readdir DIR) {
288         next if exists $removeDirectoryIgnoreList{$item};
289         if (! -d File::Spec->catdir($dir, $item)) {
290             $directoryIsEmpty = 0;
291         } else {
292             next if (scmWillDeleteFile(File::Spec->catdir($dir, $item)));
293             $directoryIsEmpty = 0;
294         }
295     }
296     closedir DIR;
297     return $directoryIsEmpty;
298 }
299
300 # Args:
301 #   $diffHashRef: a diff hash reference of the type returned by parsePatch().
302 sub patch($)
303 {
304     my ($diffHashRef) = @_;
305
306     my $patch = $diffHashRef->{svnConvertedText};
307
308     unless ($patch =~ m|^Index: ([^\r\n]+)|) {
309         my $separator = '-' x 67;
310         warn "Failed to find 'Index:' in:\n$separator\n$patch\n$separator\n";
311         die unless $force;
312         return;
313     }
314     my $fullPath = $1;
315
316     my $deletion = 0;
317     my $addition = 0;
318     my $isBinary = 0;
319     my $isGitBinary = 0;
320
321     # FIXME: This information should be extracted from the diff file as
322     #        part of the parsing stage, i.e. the call to parsePatch().
323     $addition = 1 if ($patch =~ /\n--- .+\(revision 0\)\r?\n/ || $patch =~ /\n@@ -0,0 .* @@/) && !exists($copiedFiles{$fullPath});
324     $deletion = 1 if $patch =~ /\n@@ .* \+0,0 @@/;
325     $isBinary = 1 if $patch =~ /\nCannot display: file marked as a binary type\./;
326     $isGitBinary = 1 if $patch =~ /\nGIT binary patch\n/;
327
328     if (!$addition && !$deletion && !$isBinary && !$isGitBinary) {
329         # Standard patch, patch tool can handle this.
330         if (basename($fullPath) eq "ChangeLog") {
331             my $changeLogDotOrigExisted = -f "${fullPath}.orig";
332             applyPatch(setChangeLogDateAndReviewer(fixChangeLogPatch($patch), $reviewer), $fullPath, ["--fuzz=3"]);
333             unlink("${fullPath}.orig") if (! $changeLogDotOrigExisted);
334         } else {
335             applyPatch($patch, $fullPath);
336         }
337     } else {
338         # Either a deletion, an addition or a binary change.
339
340         addDirectoriesIfNeeded(dirname($fullPath));
341
342         if ($isBinary) {
343             # Binary change
344             handleBinaryChange($fullPath, $patch);
345         } elsif ($isGitBinary) {
346             # Git binary change
347             handleGitBinaryChange($fullPath, $patch);
348         } elsif ($deletion) {
349             # Deletion
350             applyPatch($patch, $fullPath, ["--force"]);
351             scmRemove($fullPath);
352         } else {
353             # Addition
354             rename($fullPath, "$fullPath.orig") if -e $fullPath;
355             applyPatch($patch, $fullPath);
356             unlink("$fullPath.orig") if -e "$fullPath.orig" && checksum($fullPath) eq checksum("$fullPath.orig");
357             scmAdd($fullPath);
358             # What is this for?
359             system("svn", "stat", "$fullPath.orig") if isSVN() && -e "$fullPath.orig";
360         }
361     }
362 }
363
364 sub removeDirectoriesIfNeeded()
365 {
366     foreach my $dir (reverse sort keys %checkedDirectories) {
367         if (isDirectoryEmptyForRemoval($dir)) {
368             scmRemove($dir);
369         }
370     }
371 }
372
373 sub setChangeLogDateAndReviewer($$)
374 {
375     my $patch = shift;
376     my $reviewer = shift;
377     my $savedTimeZone = $ENV{'TZ'};
378     # Set TZ temporarily so that localtime() is in that time zone
379     $ENV{'TZ'} = $changeLogTimeZone;
380     my $newDate = strftime("%Y-%m-%d", localtime());
381     if (defined $savedTimeZone) {
382          $ENV{'TZ'} = $savedTimeZone;
383     } else {
384          delete $ENV{'TZ'};
385     }
386     $patch =~ s/(\n\+)\d{4}-[^-]{2}-[^-]{2}(  )/$1$newDate$2/;
387     if (defined($reviewer)) {
388         $patch =~ s/NOBODY \(OOPS!\)/$reviewer/;
389     }
390     return $patch;
391 }
392
393 # This could be made into a more general "status" call, except svn and git
394 # have different ideas about "moving" files which might get confusing.
395 sub scmWillDeleteFile($)
396 {
397     my ($path) = @_;
398     if (isSVN()) {
399         my $svnOutput = svnStatus($path);
400         return 1 if $svnOutput && substr($svnOutput, 0, 1) eq "D";
401     } elsif (isGit()) {
402         my $gitOutput = `git diff-index --name-status HEAD -- $path`;
403         return 1 if $gitOutput && substr($gitOutput, 0, 1) eq "D";
404     }
405     return 0;
406 }
407
408 sub scmKnowsOfFile($)
409 {
410     my ($path) = @_;
411     if (isSVN()) {
412         my $svnOutput = svnStatus($path);
413         # This will match more than intended.  ? might not be the first field in the status
414         if ($svnOutput && $svnOutput =~ m#\?\s+$path\n#) {
415             return 0;
416         }
417         # This does not handle errors well.
418         return 1;
419     } elsif (isGit()) {
420         `git ls-files --error-unmatch -- $path`;
421         my $exitCode = $? >> 8;
422         return $exitCode == 0;
423     }
424 }
425
426 sub scmCopy($$)
427 {
428     my ($source, $destination) = @_;
429     if (isSVN()) {
430         system("svn", "copy", $source, $destination) == 0 or die "Failed to svn copy $source $destination.";
431     } elsif (isGit()) {
432         system("cp", $source, $destination) == 0 or die "Failed to copy $source $destination.";
433         system("git", "add", $destination) == 0 or die "Failed to git add $destination.";
434     }
435 }
436
437 sub scmAdd($)
438 {
439     my ($path) = @_;
440     if (isSVN()) {
441         system("svn", "add", $path) == 0 or die "Failed to svn add $path.";
442     } elsif (isGit()) {
443         system("git", "add", $path) == 0 or die "Failed to git add $path.";
444     }
445 }
446
447 sub scmRemove($)
448 {
449     my ($path) = @_;
450     if (isSVN()) {
451         # SVN is very verbose when removing directories.  Squelch all output except the last line.
452         my $svnOutput;
453         open SVN, "svn rm --force '$path' |" or die "svn rm --force '$path' failed!";
454         # Only print the last line.  Subversion outputs all changed statuses below $dir
455         while (<SVN>) {
456             $svnOutput = $_;
457         }
458         close SVN;
459         print $svnOutput if $svnOutput;
460     } elsif (isGit()) {
461         # Git removes a directory if it becomes empty when the last file it contains is
462         # removed by `git rm`. In svn-apply this can happen when a directory is being
463         # removed in a patch, and all of the files inside of the directory are removed
464         # before attemping to remove the directory itself. In this case, Git will have 
465         # already deleted the directory and `git rm` would exit with an error claiming
466         # there was no file. The --ignore-unmatch switch gracefully handles this case.
467         system("git", "rm", "--force", "--ignore-unmatch", $path) == 0 or die "Failed to git rm --force --ignore-unmatch $path.";
468     }
469 }