Home | History | Annotate | Download | only in Scripts
      1 #!/usr/bin/perl -w
      2 
      3 # Copyright (C) 2005, 2006, 2007 Apple Inc.  All rights reserved.
      4 # Copyright (C) 2009 Cameron McCormack <cam (at] mcc.id.au>
      5 # Copyright (C) 2010 Chris Jerdonek (chris.jerdonek (at] 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 my @patches;
    127 my %versions;
    128 
    129 my $copiedFromPath;
    130 my $filter;
    131 my $indexPath;
    132 my $patch;
    133 while (<>) {
    134     s/([\n\r]+)$//mg;
    135     my $eol = $1;
    136     if (!defined($indexPath) && m#^diff --git \w/#) {
    137         $filter = \&gitdiff2svndiff;
    138     }
    139     $_ = &$filter($_) if $filter;
    140     if (/^Index: (.+)/) {
    141         $indexPath = $1;
    142         if ($patch) {
    143             if (!$copiedFromPath) {
    144                 push @patches, $patch;
    145             }
    146             $copiedFromPath = "";
    147             $patch = "";
    148         }
    149     }
    150     if ($indexPath) {
    151         # Fix paths on diff, ---, and +++ lines to match preceding Index: line.
    152         s/\S+$/$indexPath/ if /^diff/;
    153         s/^--- \S+/--- $indexPath/;
    154         if (/^--- .+\(from (\S+):(\d+)\)$/) {
    155             $copiedFromPath = $1;
    156             $copiedFiles{$indexPath} = $copiedFromPath;
    157             $versions{$copiedFromPath} = $2 if ($2 != 0);
    158         }
    159         elsif (/^--- .+\(revision (\d+)\)$/) {
    160             $versions{$indexPath} = $1 if ($1 != 0);
    161         }
    162         if (s/^\+\+\+ \S+/+++ $indexPath/) {
    163             $indexPath = "";
    164         }
    165     }
    166     $patch .= $_;
    167     $patch .= $eol;
    168 }
    169 
    170 if ($patch && !$copiedFromPath) {
    171     push @patches, $patch;
    172 }
    173 
    174 if ($merge) {
    175     die "--merge is currently only supported for SVN" unless isSVN();
    176     # How do we handle Git patches applied to an SVN checkout here?
    177     for my $file (sort keys %versions) {
    178         my $version = $versions{$file};
    179         print "Getting version $version of $file\n";
    180         system("svn", "update", "-r", $version, $file) == 0 or die "Failed to run svn update -r $version $file.";
    181     }
    182 }
    183 
    184 # Handle copied and moved files first since moved files may have their source deleted before the move.
    185 for my $file (keys %copiedFiles) {
    186     addDirectoriesIfNeeded(dirname($file));
    187     scmCopy($copiedFiles{$file}, $file);
    188 }
    189 
    190 for $patch (@patches) {
    191     patch($patch);
    192 }
    193 
    194 removeDirectoriesIfNeeded();
    195 
    196 exit $globalExitStatus;
    197 
    198 sub addDirectoriesIfNeeded($)
    199 {
    200     my ($path) = @_;
    201     my @dirs = File::Spec->splitdir($path);
    202     my $dir = ".";
    203     while (scalar @dirs) {
    204         $dir = File::Spec->catdir($dir, shift @dirs);
    205         next if exists $checkedDirectories{$dir};
    206         if (! -e $dir) {
    207             mkdir $dir or die "Failed to create required directory '$dir' for path '$path'\n";
    208             scmAdd($dir);
    209             $checkedDirectories{$dir} = 1;
    210         }
    211         elsif (-d $dir) {
    212             # SVN prints "svn: warning: 'directory' is already under version control"
    213             # if you try and add a directory which is already in the repository.
    214             # Git will ignore the add, but re-adding large directories can be sloooow.
    215             # So we check first to see if the directory is under version control first.
    216             if (!scmKnowsOfFile($dir)) {
    217                 scmAdd($dir);
    218             }
    219             $checkedDirectories{$dir} = 1;
    220         }
    221         else {
    222             die "'$dir' exists, but is not a directory";
    223         }
    224     }
    225 }
    226 
    227 # Args:
    228 #   $patch: a patch string.
    229 #   $pathRelativeToRoot: the path of the file to be patched, relative to the
    230 #                        repository root. This should normally be the path
    231 #                        found in the patch's "Index:" line.
    232 #   $options: a reference to an array of options to pass to the patch command.
    233 sub applyPatch($$;$)
    234 {
    235     my ($patch, $pathRelativeToRoot, $options) = @_;
    236 
    237     my $optionalArgs = {options => $options, ensureForce => $force};
    238 
    239     my $exitStatus = runPatchCommand($patch, $repositoryRootPath, $pathRelativeToRoot, $optionalArgs);
    240 
    241     if ($exitStatus) {
    242         $globalExitStatus = $exitStatus;
    243     }
    244 }
    245 
    246 sub checksum($)
    247 {
    248     my $file = shift;
    249     open(FILE, $file) or die "Can't open '$file': $!";
    250     binmode(FILE);
    251     my $checksum = Digest::MD5->new->addfile(*FILE)->hexdigest();
    252     close(FILE);
    253     return $checksum;
    254 }
    255 
    256 sub handleBinaryChange($$)
    257 {
    258     my ($fullPath, $contents) = @_;
    259     # [A-Za-z0-9+/] is the class of allowed base64 characters.
    260     # One or more lines, at most 76 characters in length.
    261     # The last line is allowed to have up to two '=' characters at the end (to signify padding).
    262     if ($contents =~ m#((\n[A-Za-z0-9+/]{76})*\n[A-Za-z0-9+/]{2,74}?[A-Za-z0-9+/=]{2}\n)#) {
    263         # Addition or Modification
    264         open FILE, ">", $fullPath or die "Failed to open $fullPath.";
    265         print FILE decode_base64($1);
    266         close FILE;
    267         if (!scmKnowsOfFile($fullPath)) {
    268             # Addition
    269             scmAdd($fullPath);
    270         }
    271     } else {
    272         # Deletion
    273         scmRemove($fullPath);
    274     }
    275 }
    276 
    277 sub handleGitBinaryChange($$)
    278 {
    279     my ($fullPath, $contents) = @_;
    280 
    281     my ($binaryChunkType, $binaryChunk, $reverseBinaryChunkType, $reverseBinaryChunk) = decodeGitBinaryPatch($contents, $fullPath);
    282     # FIXME: support "delta" type.
    283     die "only literal type is supported now" if ($binaryChunkType ne "literal" || $reverseBinaryChunkType ne "literal");
    284 
    285     my $isFileAddition = $contents =~ /\nnew file mode \d+\n/;
    286     my $isFileDeletion = $contents =~ /\ndeleted file mode \d+\n/;
    287 
    288     my $originalContents = "";
    289     if (open FILE, $fullPath) {
    290         die "$fullPath already exists" if $isFileAddition;
    291 
    292         $originalContents = join("", <FILE>);
    293         close FILE;
    294     }
    295     die "Original content of $fullPath mismatches" if $originalContents ne $reverseBinaryChunk;
    296 
    297     if ($isFileDeletion) {
    298         scmRemove($fullPath);
    299     } else {
    300         # Addition or Modification
    301         open FILE, ">", $fullPath or die "Failed to open $fullPath.";
    302         print FILE $binaryChunk;
    303         close FILE;
    304         if ($isFileAddition) {
    305             scmAdd($fullPath);
    306         }
    307     }
    308 }
    309 
    310 sub isDirectoryEmptyForRemoval($)
    311 {
    312     my ($dir) = @_;
    313     my $directoryIsEmpty = 1;
    314     opendir DIR, $dir or die "Could not open '$dir' to list files: $?";
    315     for (my $item = readdir DIR; $item && $directoryIsEmpty; $item = readdir DIR) {
    316         next if exists $removeDirectoryIgnoreList{$item};
    317         if (! -d File::Spec->catdir($dir, $item)) {
    318             $directoryIsEmpty = 0;
    319         } else {
    320             next if (scmWillDeleteFile(File::Spec->catdir($dir, $item)));
    321             $directoryIsEmpty = 0;
    322         }
    323     }
    324     closedir DIR;
    325     return $directoryIsEmpty;
    326 }
    327 
    328 sub patch($)
    329 {
    330     my ($patch) = @_;
    331     return if !$patch;
    332 
    333     unless ($patch =~ m|^Index: ([^\r\n]+)|) {
    334         my $separator = '-' x 67;
    335         warn "Failed to find 'Index:' in:\n$separator\n$patch\n$separator\n";
    336         die unless $force;
    337         return;
    338     }
    339     my $fullPath = $1;
    340 
    341     my $deletion = 0;
    342     my $addition = 0;
    343     my $isBinary = 0;
    344     my $isGitBinary = 0;
    345 
    346     $addition = 1 if ($patch =~ /\n--- .+\(revision 0\)\r?\n/ || $patch =~ /\n@@ -0,0 .* @@/) && !exists($copiedFiles{$fullPath});
    347     $deletion = 1 if $patch =~ /\n@@ .* \+0,0 @@/;
    348     $isBinary = 1 if $patch =~ /\nCannot display: file marked as a binary type\./;
    349     $isGitBinary = 1 if $patch =~ /\nGIT binary patch\n/;
    350 
    351     if (!$addition && !$deletion && !$isBinary && !$isGitBinary) {
    352         # Standard patch, patch tool can handle this.
    353         if (basename($fullPath) eq "ChangeLog") {
    354             my $changeLogDotOrigExisted = -f "${fullPath}.orig";
    355             applyPatch(setChangeLogDateAndReviewer(fixChangeLogPatch($patch), $reviewer), $fullPath, ["--fuzz=3"]);
    356             unlink("${fullPath}.orig") if (! $changeLogDotOrigExisted);
    357         } else {
    358             applyPatch($patch, $fullPath);
    359         }
    360     } else {
    361         # Either a deletion, an addition or a binary change.
    362 
    363         addDirectoriesIfNeeded(dirname($fullPath));
    364 
    365         if ($isBinary) {
    366             # Binary change
    367             handleBinaryChange($fullPath, $patch);
    368         } elsif ($isGitBinary) {
    369             # Git binary change
    370             handleGitBinaryChange($fullPath, $patch);
    371         } elsif ($deletion) {
    372             # Deletion
    373             applyPatch($patch, $fullPath, ["--force"]);
    374             scmRemove($fullPath);
    375         } else {
    376             # Addition
    377             rename($fullPath, "$fullPath.orig") if -e $fullPath;
    378             applyPatch($patch, $fullPath);
    379             unlink("$fullPath.orig") if -e "$fullPath.orig" && checksum($fullPath) eq checksum("$fullPath.orig");
    380             scmAdd($fullPath);
    381             # What is this for?
    382             system("svn", "stat", "$fullPath.orig") if isSVN() && -e "$fullPath.orig";
    383         }
    384     }
    385 }
    386 
    387 sub removeDirectoriesIfNeeded()
    388 {
    389     foreach my $dir (reverse sort keys %checkedDirectories) {
    390         if (isDirectoryEmptyForRemoval($dir)) {
    391             scmRemove($dir);
    392         }
    393     }
    394 }
    395 
    396 sub setChangeLogDateAndReviewer($$)
    397 {
    398     my $patch = shift;
    399     my $reviewer = shift;
    400     my $savedTimeZone = $ENV{'TZ'};
    401     # Set TZ temporarily so that localtime() is in that time zone
    402     $ENV{'TZ'} = $changeLogTimeZone;
    403     my $newDate = strftime("%Y-%m-%d", localtime());
    404     if (defined $savedTimeZone) {
    405          $ENV{'TZ'} = $savedTimeZone;
    406     } else {
    407          delete $ENV{'TZ'};
    408     }
    409     $patch =~ s/(\n\+)\d{4}-[^-]{2}-[^-]{2}(  )/$1$newDate$2/;
    410     if (defined($reviewer)) {
    411         $patch =~ s/NOBODY \(OOPS!\)/$reviewer/;
    412     }
    413     return $patch;
    414 }
    415 
    416 # This could be made into a more general "status" call, except svn and git
    417 # have different ideas about "moving" files which might get confusing.
    418 sub scmWillDeleteFile($)
    419 {
    420     my ($path) = @_;
    421     if (isSVN()) {
    422         my $svnOutput = svnStatus($path);
    423         return 1 if $svnOutput && substr($svnOutput, 0, 1) eq "D";
    424     } elsif (isGit()) {
    425         my $gitOutput = `git diff-index --name-status HEAD -- $path`;
    426         return 1 if $gitOutput && substr($gitOutput, 0, 1) eq "D";
    427     }
    428     return 0;
    429 }
    430 
    431 sub scmKnowsOfFile($)
    432 {
    433     my ($path) = @_;
    434     if (isSVN()) {
    435         my $svnOutput = svnStatus($path);
    436         # This will match more than intended.  ? might not be the first field in the status
    437         if ($svnOutput && $svnOutput =~ m#\?\s+$path\n#) {
    438             return 0;
    439         }
    440         # This does not handle errors well.
    441         return 1;
    442     } elsif (isGit()) {
    443         `git ls-files --error-unmatch -- $path`;
    444         my $exitCode = $? >> 8;
    445         return $exitCode == 0;
    446     }
    447 }
    448 
    449 sub scmCopy($$)
    450 {
    451     my ($source, $destination) = @_;
    452     if (isSVN()) {
    453         system("svn", "copy", $source, $destination) == 0 or die "Failed to svn copy $source $destination.";
    454     } elsif (isGit()) {
    455         system("cp", $source, $destination) == 0 or die "Failed to copy $source $destination.";
    456         system("git", "add", $destination) == 0 or die "Failed to git add $destination.";
    457     }
    458 }
    459 
    460 sub scmAdd($)
    461 {
    462     my ($path) = @_;
    463     if (isSVN()) {
    464         system("svn", "add", $path) == 0 or die "Failed to svn add $path.";
    465     } elsif (isGit()) {
    466         system("git", "add", $path) == 0 or die "Failed to git add $path.";
    467     }
    468 }
    469 
    470 sub scmRemove($)
    471 {
    472     my ($path) = @_;
    473     if (isSVN()) {
    474         # SVN is very verbose when removing directories.  Squelch all output except the last line.
    475         my $svnOutput;
    476         open SVN, "svn rm --force '$path' |" or die "svn rm --force '$path' failed!";
    477         # Only print the last line.  Subversion outputs all changed statuses below $dir
    478         while (<SVN>) {
    479             $svnOutput = $_;
    480         }
    481         close SVN;
    482         print $svnOutput if $svnOutput;
    483     } elsif (isGit()) {
    484         system("git", "rm", "--force", $path) == 0 or die  "Failed to git rm --force $path.";
    485     }
    486 }
    487