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.
     46 #   Handles binary files (requires patches made by svn-create-patch).
     47 #   Handles copied and moved files (requires patches made by svn-create-patch).
     48 #   Handles git-diff patches (without binary changes) created at the top-level directory
     49 #
     50 # Missing features:
     51 #
     52 #   Handle property changes.
     53 #   Handle copied and moved directories (would require patches made by svn-create-patch).
     54 #   When doing a removal, check that old file matches what's being removed.
     55 #   Notice a patch that's being applied at the "wrong level" and make it work anyway.
     56 #   Do a dry run on the whole patch and don't do anything if part of the patch is
     57 #       going to fail (probably too strict unless we exclude ChangeLog).
     58 #   Handle git-diff patches with binary delta
     59 
     60 use strict;
     61 use warnings;
     62 
     63 use Digest::MD5;
     64 use File::Basename;
     65 use File::Spec;
     66 use Getopt::Long;
     67 use MIME::Base64;
     68 use POSIX qw(strftime);
     69 
     70 use FindBin;
     71 use lib $FindBin::Bin;
     72 use VCSUtils;
     73 
     74 sub addDirectoriesIfNeeded($);
     75 sub applyPatch($$;$);
     76 sub checksum($);
     77 sub handleBinaryChange($$);
     78 sub handleGitBinaryChange($$);
     79 sub isDirectoryEmptyForRemoval($);
     80 sub patch($);
     81 sub removeDirectoriesIfNeeded();
     82 
     83 # These should be replaced by an scm class/module:
     84 sub scmKnowsOfFile($);
     85 sub scmCopy($$);
     86 sub scmAdd($);
     87 sub scmRemove($);
     88 
     89 my $merge = 0;
     90 my $showHelp = 0;
     91 my $reviewer;
     92 my $force = 0;
     93 
     94 my $optionParseSuccess = GetOptions(
     95     "merge!" => \$merge,
     96     "help!" => \$showHelp,
     97     "reviewer=s" => \$reviewer,
     98     "force!" => \$force
     99 );
    100 
    101 if (!$optionParseSuccess || $showHelp) {
    102     print STDERR basename($0) . " [-h|--help] [--force] [-m|--merge] [-r|--reviewer name] patch1 [patch2 ...]\n";
    103     exit 1;
    104 }
    105 
    106 my %removeDirectoryIgnoreList = (
    107     '.' => 1,
    108     '..' => 1,
    109     '.git' => 1,
    110     '.svn' => 1,
    111     '_svn' => 1,
    112 );
    113 
    114 my $epochTime = time(); # This is used to set the date in ChangeLog files.
    115 my $globalExitStatus = 0;
    116 
    117 my $repositoryRootPath = determineVCSRoot();
    118 
    119 my %checkedDirectories;
    120 
    121 # Need to use a typeglob to pass the file handle as a parameter,
    122 # otherwise get a bareword error.
    123 my @diffHashRefs = parsePatch(*ARGV);
    124 
    125 print "Parsed " . @diffHashRefs . " diffs from patch file(s).\n";
    126 
    127 my $preparedPatchHash = prepareParsedPatch($force, @diffHashRefs);
    128 
    129 my @copyDiffHashRefs = @{$preparedPatchHash->{copyDiffHashRefs}};
    130 my @nonCopyDiffHashRefs = @{$preparedPatchHash->{nonCopyDiffHashRefs}};
    131 my %sourceRevisions = %{$preparedPatchHash->{sourceRevisionHash}};
    132 
    133 if ($merge) {
    134     die "--merge is currently only supported for SVN" unless isSVN();
    135     # How do we handle Git patches applied to an SVN checkout here?
    136     for my $file (sort keys %sourceRevisions) {
    137         my $version = $sourceRevisions{$file};
    138         print "Getting version $version of $file\n";
    139         system("svn", "update", "-r", $version, $file) == 0 or die "Failed to run svn update -r $version $file.";
    140     }
    141 }
    142 
    143 # Handle copied and moved files first since moved files may have their
    144 # source deleted before the move.
    145 for my $copyDiffHashRef (@copyDiffHashRefs) {
    146     my $indexPath = $copyDiffHashRef->{indexPath};
    147     my $copiedFromPath = $copyDiffHashRef->{copiedFromPath};
    148 
    149     addDirectoriesIfNeeded(dirname($indexPath));
    150     scmCopy($copiedFromPath, $indexPath);
    151 }
    152 
    153 for my $diffHashRef (@nonCopyDiffHashRefs) {
    154     patch($diffHashRef);
    155 }
    156 
    157 removeDirectoriesIfNeeded();
    158 
    159 exit $globalExitStatus;
    160 
    161 sub addDirectoriesIfNeeded($)
    162 {
    163     my ($path) = @_;
    164     my @dirs = File::Spec->splitdir($path);
    165     my $dir = ".";
    166     while (scalar @dirs) {
    167         $dir = File::Spec->catdir($dir, shift @dirs);
    168         next if exists $checkedDirectories{$dir};
    169         if (! -e $dir) {
    170             mkdir $dir or die "Failed to create required directory '$dir' for path '$path'\n";
    171             scmAdd($dir);
    172             $checkedDirectories{$dir} = 1;
    173         }
    174         elsif (-d $dir) {
    175             # SVN prints "svn: warning: 'directory' is already under version control"
    176             # if you try and add a directory which is already in the repository.
    177             # Git will ignore the add, but re-adding large directories can be sloooow.
    178             # So we check first to see if the directory is under version control first.
    179             if (!scmKnowsOfFile($dir)) {
    180                 scmAdd($dir);
    181             }
    182             $checkedDirectories{$dir} = 1;
    183         }
    184         else {
    185             die "'$dir' exists, but is not a directory";
    186         }
    187     }
    188 }
    189 
    190 # Args:
    191 #   $patch: a patch string.
    192 #   $pathRelativeToRoot: the path of the file to be patched, relative to the
    193 #                        repository root. This should normally be the path
    194 #                        found in the patch's "Index:" line.
    195 #   $options: a reference to an array of options to pass to the patch command.
    196 sub applyPatch($$;$)
    197 {
    198     my ($patch, $pathRelativeToRoot, $options) = @_;
    199 
    200     my $optionalArgs = {options => $options, ensureForce => $force};
    201 
    202     my $exitStatus = runPatchCommand($patch, $repositoryRootPath, $pathRelativeToRoot, $optionalArgs);
    203 
    204     if ($exitStatus) {
    205         $globalExitStatus = $exitStatus;
    206     }
    207 }
    208 
    209 sub checksum($)
    210 {
    211     my $file = shift;
    212     open(FILE, $file) or die "Can't open '$file': $!";
    213     binmode(FILE);
    214     my $checksum = Digest::MD5->new->addfile(*FILE)->hexdigest();
    215     close(FILE);
    216     return $checksum;
    217 }
    218 
    219 sub handleBinaryChange($$)
    220 {
    221     my ($fullPath, $contents) = @_;
    222     # [A-Za-z0-9+/] is the class of allowed base64 characters.
    223     # One or more lines, at most 76 characters in length.
    224     # The last line is allowed to have up to two '=' characters at the end (to signify padding).
    225     if ($contents =~ m#((\n[A-Za-z0-9+/]{76})*\n[A-Za-z0-9+/]{2,74}?[A-Za-z0-9+/=]{2}\n)#) {
    226         # Addition or Modification
    227         open FILE, ">", $fullPath or die "Failed to open $fullPath.";
    228         print FILE decode_base64($1);
    229         close FILE;
    230         if (!scmKnowsOfFile($fullPath)) {
    231             # Addition
    232             scmAdd($fullPath);
    233         }
    234     } else {
    235         # Deletion
    236         scmRemove($fullPath);
    237     }
    238 }
    239 
    240 sub handleGitBinaryChange($$)
    241 {
    242     my ($fullPath, $diffHashRef) = @_;
    243 
    244     my $contents = $diffHashRef->{svnConvertedText};
    245 
    246     my ($binaryChunkType, $binaryChunk, $reverseBinaryChunkType, $reverseBinaryChunk) = decodeGitBinaryPatch($contents, $fullPath);
    247 
    248     my $isFileAddition = $diffHashRef->{isNew};
    249     my $isFileDeletion = $diffHashRef->{isDeletion};
    250 
    251     my $originalContents = "";
    252     if (open FILE, $fullPath) {
    253         die "$fullPath already exists" if $isFileAddition;
    254 
    255         $originalContents = join("", <FILE>);
    256         close FILE;
    257     }
    258 
    259     if ($reverseBinaryChunkType eq "literal") {
    260         die "Original content of $fullPath mismatches" if $originalContents ne $reverseBinaryChunk;
    261     }
    262 
    263     if ($isFileDeletion) {
    264         scmRemove($fullPath);
    265     } else {
    266         # Addition or Modification
    267         my $out = "";
    268         if ($binaryChunkType eq "delta") {
    269             $out = applyGitBinaryPatchDelta($binaryChunk, $originalContents);
    270         } else {
    271             $out = $binaryChunk;
    272         }
    273         if ($reverseBinaryChunkType eq "delta") {
    274             die "Original content of $fullPath mismatches" if $originalContents ne applyGitBinaryPatchDelta($reverseBinaryChunk, $out);
    275         }
    276         open FILE, ">", $fullPath or die "Failed to open $fullPath.";
    277         print FILE $out;
    278         close FILE;
    279         if ($isFileAddition) {
    280             scmAdd($fullPath);
    281         }
    282     }
    283 }
    284 
    285 sub isDirectoryEmptyForRemoval($)
    286 {
    287     my ($dir) = @_;
    288     return 1 unless -d $dir;
    289     my $directoryIsEmpty = 1;
    290     opendir DIR, $dir or die "Could not open '$dir' to list files: $?";
    291     for (my $item = readdir DIR; $item && $directoryIsEmpty; $item = readdir DIR) {
    292         next if exists $removeDirectoryIgnoreList{$item};
    293         if (-d File::Spec->catdir($dir, $item)) {
    294             $directoryIsEmpty = 0;
    295         } else {
    296             next if (scmWillDeleteFile(File::Spec->catdir($dir, $item)));
    297             $directoryIsEmpty = 0;
    298         }
    299     }
    300     closedir DIR;
    301     return $directoryIsEmpty;
    302 }
    303 
    304 # Args:
    305 #   $diffHashRef: a diff hash reference of the type returned by parsePatch().
    306 sub patch($)
    307 {
    308     my ($diffHashRef) = @_;
    309 
    310     # Make sure $patch is initialized to some value.  A deletion can have no
    311     # svnConvertedText property in the case of a deletion resulting from a
    312     # Git rename.
    313     my $patch = $diffHashRef->{svnConvertedText} || "";
    314 
    315     my $fullPath = $diffHashRef->{indexPath};
    316     my $isBinary = $diffHashRef->{isBinary};
    317     my $isGit = $diffHashRef->{isGit};
    318 
    319     my $deletion = 0;
    320     my $addition = 0;
    321 
    322     $addition = 1 if ($diffHashRef->{isNew} || $patch =~ /\n@@ -0,0 .* @@/);
    323     $deletion = 1 if ($diffHashRef->{isDeletion} || $patch =~ /\n@@ .* \+0,0 @@/);
    324 
    325     if (!$addition && !$deletion && !$isBinary) {
    326         # Standard patch, patch tool can handle this.
    327         if (basename($fullPath) eq "ChangeLog") {
    328             my $changeLogDotOrigExisted = -f "${fullPath}.orig";
    329             my $changeLogHash = fixChangeLogPatch($patch);
    330             my $newPatch = setChangeLogDateAndReviewer($changeLogHash->{patch}, $reviewer, $epochTime);
    331             applyPatch($newPatch, $fullPath, ["--fuzz=3"]);
    332             unlink("${fullPath}.orig") if (! $changeLogDotOrigExisted);
    333         } else {
    334             applyPatch($patch, $fullPath) if $patch;
    335         }
    336     } else {
    337         # Either a deletion, an addition or a binary change.
    338 
    339         addDirectoriesIfNeeded(dirname($fullPath));
    340 
    341         if ($isBinary) {
    342             if ($isGit) {
    343                 handleGitBinaryChange($fullPath, $diffHashRef);
    344             } else {
    345                 handleBinaryChange($fullPath, $patch) if $patch;
    346             }
    347         } elsif ($deletion) {
    348             applyPatch($patch, $fullPath, ["--force"]) if $patch;
    349             scmRemove($fullPath);
    350         } else {
    351             # Addition
    352             rename($fullPath, "$fullPath.orig") if -e $fullPath;
    353             applyPatch($patch, $fullPath) if $patch;
    354             unlink("$fullPath.orig") if -e "$fullPath.orig" && checksum($fullPath) eq checksum("$fullPath.orig");
    355             scmAdd($fullPath);
    356             # What is this for?
    357             system("svn", "stat", "$fullPath.orig") if isSVN() && -e "$fullPath.orig";
    358         }
    359     }
    360 
    361     scmToggleExecutableBit($fullPath, $diffHashRef->{executableBitDelta}) if defined($diffHashRef->{executableBitDelta});
    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 # This could be made into a more general "status" call, except svn and git
    374 # have different ideas about "moving" files which might get confusing.
    375 sub scmWillDeleteFile($)
    376 {
    377     my ($path) = @_;
    378     if (isSVN()) {
    379         my $svnOutput = svnStatus($path);
    380         return 1 if $svnOutput && substr($svnOutput, 0, 1) eq "D";
    381     } elsif (isGit()) {
    382         my $gitOutput = `git diff-index --name-status HEAD -- $path`;
    383         return 1 if $gitOutput && substr($gitOutput, 0, 1) eq "D";
    384     }
    385     return 0;
    386 }
    387 
    388 # Return whether the file at the given path is known to Git.
    389 #
    390 # This method outputs a message like the following to STDERR when
    391 # returning false:
    392 #
    393 # "error: pathspec 'test.png' did not match any file(s) known to git.
    394 #  Did you forget to 'git add'?"
    395 sub gitKnowsOfFile($)
    396 {
    397     my $path = shift;
    398 
    399     `git ls-files --error-unmatch -- $path`;
    400     my $exitStatus = exitStatus($?);
    401     return $exitStatus == 0;
    402 }
    403 
    404 sub scmKnowsOfFile($)
    405 {
    406     my ($path) = @_;
    407     if (isSVN()) {
    408         my $svnOutput = svnStatus($path);
    409         # This will match more than intended.  ? might not be the first field in the status
    410         if ($svnOutput && $svnOutput =~ m#\?\s+$path\n#) {
    411             return 0;
    412         }
    413         # This does not handle errors well.
    414         return 1;
    415     } elsif (isGit()) {
    416         my @result = callSilently(\&gitKnowsOfFile, $path);
    417         return $result[0];
    418     }
    419 }
    420 
    421 sub scmCopy($$)
    422 {
    423     my ($source, $destination) = @_;
    424     if (isSVN()) {
    425         system("svn", "copy", $source, $destination) == 0 or die "Failed to svn copy $source $destination.";
    426     } elsif (isGit()) {
    427         system("cp", $source, $destination) == 0 or die "Failed to copy $source $destination.";
    428         system("git", "add", $destination) == 0 or die "Failed to git add $destination.";
    429     }
    430 }
    431 
    432 sub scmAdd($)
    433 {
    434     my ($path) = @_;
    435     if (isSVN()) {
    436         system("svn", "add", $path) == 0 or die "Failed to svn add $path.";
    437     } elsif (isGit()) {
    438         system("git", "add", $path) == 0 or die "Failed to git add $path.";
    439     }
    440 }
    441 
    442 sub scmRemove($)
    443 {
    444     my ($path) = @_;
    445     if (isSVN()) {
    446         # SVN is very verbose when removing directories.  Squelch all output except the last line.
    447         my $svnOutput;
    448         open SVN, "svn rm --force '$path' |" or die "svn rm --force '$path' failed!";
    449         # Only print the last line.  Subversion outputs all changed statuses below $dir
    450         while (<SVN>) {
    451             $svnOutput = $_;
    452         }
    453         close SVN;
    454         print $svnOutput if $svnOutput;
    455     } elsif (isGit()) {
    456         # Git removes a directory if it becomes empty when the last file it contains is
    457         # removed by `git rm`. In svn-apply this can happen when a directory is being
    458         # removed in a patch, and all of the files inside of the directory are removed
    459         # before attemping to remove the directory itself. In this case, Git will have 
    460         # already deleted the directory and `git rm` would exit with an error claiming
    461         # there was no file. The --ignore-unmatch switch gracefully handles this case.
    462         system("git", "rm", "--force", "--ignore-unmatch", $path) == 0 or die "Failed to git rm --force --ignore-unmatch $path.";
    463     }
    464 }
    465