1#!/usr/bin/perl -w 2 3# Copyright (C) 2005, 2006, 2007 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# "patch" script for WebKit Open Source Project, used to apply patches. 30 31# Differences from invoking "patch -p0": 32# 33# Handles added files (does a svn add with logic to handle local changes). 34# Handles added directories (does a svn add). 35# Handles removed files (does a svn rm with logic to handle local changes). 36# Handles removed directories--those with no more files or directories left in them 37# (does a svn rm). 38# Has mode where it will roll back to svn version numbers in the patch file so svn 39# can do a 3-way merge. 40# Paths from Index: lines are used rather than the paths on the patch lines, which 41# makes patches generated by "cvs diff" work (increasingly unimportant since we 42# use Subversion now). 43# ChangeLog patches use --fuzz=3 to prevent rejects, and the entry date is set in 44# the patch to today's date using $changeLogTimeZone. 45# Handles binary files (requires patches made by svn-create-patch). 46# Handles copied and moved files (requires patches made by svn-create-patch). 47# Handles git-diff patches (without binary changes) created at the top-level directory 48# 49# Missing features: 50# 51# Handle property changes. 52# Handle copied and moved directories (would require patches made by svn-create-patch). 53# When doing a removal, check that old file matches what's being removed. 54# Notice a patch that's being applied at the "wrong level" and make it work anyway. 55# Do a dry run on the whole patch and don't do anything if part of the patch is 56# going to fail (probably too strict unless we exclude ChangeLog). 57# Handle git-diff patches with binary changes 58 59use strict; 60use warnings; 61 62use Digest::MD5; 63use File::Basename; 64use File::Spec; 65use Getopt::Long; 66use MIME::Base64; 67use POSIX qw(strftime); 68 69use FindBin; 70use lib $FindBin::Bin; 71use VCSUtils; 72 73sub addDirectoriesIfNeeded($); 74sub applyPatch($$;$); 75sub checksum($); 76sub fixChangeLogPatch($); 77sub gitdiff2svndiff($); 78sub handleBinaryChange($$); 79sub isDirectoryEmptyForRemoval($); 80sub patch($); 81sub removeDirectoriesIfNeeded(); 82sub setChangeLogDateAndReviewer($$); 83sub svnStatus($); 84 85# These should be replaced by an scm class/module: 86sub scmKnowsOfFile($); 87sub scmCopy($$); 88sub scmAdd($); 89sub scmRemove($); 90 91 92# Project time zone for Cupertino, CA, US 93my $changeLogTimeZone = "PST8PDT"; 94 95my $merge = 0; 96my $showHelp = 0; 97my $reviewer; 98my $force = 0; 99 100my $optionParseSuccess = GetOptions( 101 "merge!" => \$merge, 102 "help!" => \$showHelp, 103 "reviewer=s" => \$reviewer, 104 "force!" => \$force 105); 106 107if (!$optionParseSuccess || $showHelp) { 108 print STDERR basename($0) . " [-h|--help] [--force] [-m|--merge] [-r|--reviewer name] patch1 [patch2 ...]\n"; 109 exit 1; 110} 111 112my $isGit = isGitDirectory("."); 113my $isSVN = isSVNDirectory("."); 114$isSVN || $isGit || die "Couldn't determine your version control system."; 115 116my %removeDirectoryIgnoreList = ( 117 '.' => 1, 118 '..' => 1, 119 '.git' => 1, 120 '.svn' => 1, 121 '_svn' => 1, 122); 123 124my %checkedDirectories; 125my %copiedFiles; 126my @patches; 127my %versions; 128 129my $copiedFromPath; 130my $filter; 131my $indexPath; 132my $patch; 133while (<>) { 134 s/([\n\r]+)$//mg; 135 my $eol = $1; 136 if (!defined($indexPath) && m#^diff --git a/#) { 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 170if ($patch && !$copiedFromPath) { 171 push @patches, $patch; 172} 173 174if ($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 print "Getting version $versions{$file} of $file\n"; 179 system "svn", "update", "-r", $versions{$file}, $file; 180 } 181} 182 183# Handle copied and moved files first since moved files may have their source deleted before the move. 184for my $file (keys %copiedFiles) { 185 addDirectoriesIfNeeded(dirname($file)); 186 scmCopy($copiedFiles{$file}, $file); 187} 188 189for $patch (@patches) { 190 patch($patch); 191} 192 193removeDirectoriesIfNeeded(); 194 195exit 0; 196 197sub addDirectoriesIfNeeded($) 198{ 199 my ($path) = @_; 200 my @dirs = File::Spec->splitdir($path); 201 my $dir = "."; 202 while (scalar @dirs) { 203 $dir = File::Spec->catdir($dir, shift @dirs); 204 next if exists $checkedDirectories{$dir}; 205 if (! -e $dir) { 206 mkdir $dir or die "Failed to create required directory '$dir' for path '$path'\n"; 207 scmAdd($dir); 208 $checkedDirectories{$dir} = 1; 209 } 210 elsif (-d $dir) { 211 # SVN prints "svn: warning: 'directory' is already under version control" 212 # if you try and add a directory which is already in the repository. 213 # Git will ignore the add, but re-adding large directories can be sloooow. 214 # So we check first to see if the directory is under version control first. 215 if (!scmKnowsOfFile($dir)) { 216 scmAdd($dir); 217 } 218 $checkedDirectories{$dir} = 1; 219 } 220 else { 221 die "'$dir' exists, but is not a directory"; 222 } 223 } 224} 225 226sub applyPatch($$;$) 227{ 228 my ($patch, $fullPath, $options) = @_; 229 $options = [] if (! $options); 230 my $command = "patch " . join(" ", "-p0", @{$options}); 231 open PATCH, "| $command" or die "Failed to patch $fullPath\n"; 232 print PATCH $patch; 233 close PATCH; 234 235 my $exitCode = $? >> 8; 236 if ($exitCode != 0) { 237 print "patch -p0 \"$fullPath\" returned $exitCode. Pass --force to ignore patch failures.\n"; 238 exit($exitCode); 239 } 240} 241 242sub checksum($) 243{ 244 my $file = shift; 245 open(FILE, $file) or die "Can't open '$file': $!"; 246 binmode(FILE); 247 my $checksum = Digest::MD5->new->addfile(*FILE)->hexdigest(); 248 close(FILE); 249 return $checksum; 250} 251 252sub fixChangeLogPatch($) 253{ 254 my $patch = shift; 255 my $contextLineCount = 3; 256 257 return $patch if $patch !~ /\n@@ -1,(\d+) \+1,(\d+) @@\r?\n( .*\r?\n)+(\+.*\r?\n)+( .*\r?\n){$contextLineCount}$/m; 258 my ($oldLineCount, $newLineCount) = ($1, $2); 259 return $patch if $oldLineCount <= $contextLineCount; 260 261 # The diff(1) command is greedy when matching lines, so a new ChangeLog entry will 262 # have lines of context at the top of a patch when the existing entry has the same 263 # date and author as the new entry. This nifty loop alters a ChangeLog patch so 264 # that the added lines ("+") in the patch always start at the beginning of the 265 # patch and there are no initial lines of context. 266 my $newPatch; 267 my $lineCountInState = 0; 268 my $oldContentLineCountReduction = $oldLineCount - $contextLineCount; 269 my $newContentLineCountWithoutContext = $newLineCount - $oldLineCount - $oldContentLineCountReduction; 270 my ($stateHeader, $statePreContext, $stateNewChanges, $statePostContext) = (1..4); 271 my $state = $stateHeader; 272 foreach my $line (split(/\n/, $patch)) { 273 $lineCountInState++; 274 if ($state == $stateHeader && $line =~ /^@@ -1,$oldLineCount \+1,$newLineCount @\@$/) { 275 $line = "@@ -1,$contextLineCount +1," . ($newLineCount - $oldContentLineCountReduction) . " @@"; 276 $lineCountInState = 0; 277 $state = $statePreContext; 278 } elsif ($state == $statePreContext && substr($line, 0, 1) eq " ") { 279 $line = "+" . substr($line, 1); 280 if ($lineCountInState == $oldContentLineCountReduction) { 281 $lineCountInState = 0; 282 $state = $stateNewChanges; 283 } 284 } elsif ($state == $stateNewChanges && substr($line, 0, 1) eq "+") { 285 # No changes to these lines 286 if ($lineCountInState == $newContentLineCountWithoutContext) { 287 $lineCountInState = 0; 288 $state = $statePostContext; 289 } 290 } elsif ($state == $statePostContext) { 291 if (substr($line, 0, 1) eq "+" && $lineCountInState <= $oldContentLineCountReduction) { 292 $line = " " . substr($line, 1); 293 } elsif ($lineCountInState > $contextLineCount && substr($line, 0, 1) eq " ") { 294 next; # Discard 295 } 296 } 297 $newPatch .= $line . "\n"; 298 } 299 300 return $newPatch; 301} 302 303sub gitdiff2svndiff($) 304{ 305 $_ = shift @_; 306 if (m#^diff --git a/(.+) b/(.+)#) { 307 return "Index: $1"; 308 } elsif (m/^new file.*/) { 309 return ""; 310 } elsif (m#^index [0-9a-f]{7}\.\.[0-9a-f]{7} [0-9]{6}#) { 311 return "==================================================================="; 312 } elsif (m#^--- a/(.+)#) { 313 return "--- $1"; 314 } elsif (m#^\+\+\+ b/(.+)#) { 315 return "+++ $1"; 316 } 317 return $_; 318} 319 320sub handleBinaryChange($$) 321{ 322 my ($fullPath, $contents) = @_; 323 if ($contents =~ m#((\n[A-Za-z0-9+/]{76})+\n[A-Za-z0-9+/=]{4,76}\n)#) { 324 # Addition or Modification 325 open FILE, ">", $fullPath or die; 326 print FILE decode_base64($1); 327 close FILE; 328 if (!scmKnowsOfFile($fullPath)) { 329 # Addition 330 scmAdd($fullPath); 331 } 332 } else { 333 # Deletion 334 scmRemove($fullPath); 335 } 336} 337 338sub isDirectoryEmptyForRemoval($) 339{ 340 my ($dir) = @_; 341 my $directoryIsEmpty = 1; 342 opendir DIR, $dir or die "Could not open '$dir' to list files: $?"; 343 for (my $item = readdir DIR; $item && $directoryIsEmpty; $item = readdir DIR) { 344 next if exists $removeDirectoryIgnoreList{$item}; 345 if (! -d File::Spec->catdir($dir, $item)) { 346 $directoryIsEmpty = 0; 347 } else { 348 next if (scmWillDeleteFile(File::Spec->catdir($dir, $item))); 349 $directoryIsEmpty = 0; 350 } 351 } 352 closedir DIR; 353 return $directoryIsEmpty; 354} 355 356sub patch($) 357{ 358 my ($patch) = @_; 359 return if !$patch; 360 361 unless ($patch =~ m|^Index: ([^\r\n]+)|) { 362 my $separator = '-' x 67; 363 warn "Failed to find 'Index:' in:\n$separator\n$patch\n$separator\n"; 364 return; 365 } 366 my $fullPath = $1; 367 368 my $deletion = 0; 369 my $addition = 0; 370 my $isBinary = 0; 371 372 $addition = 1 if ($patch =~ /\n--- .+\(revision 0\)\r?\n/ || $patch =~ /\n@@ -0,0 .* @@/); 373 $deletion = 1 if $patch =~ /\n@@ .* \+0,0 @@/; 374 $isBinary = 1 if $patch =~ /\nCannot display: file marked as a binary type\./; 375 376 if (!$addition && !$deletion && !$isBinary) { 377 # Standard patch, patch tool can handle this. 378 if (basename($fullPath) eq "ChangeLog") { 379 my $changeLogDotOrigExisted = -f "${fullPath}.orig"; 380 applyPatch(setChangeLogDateAndReviewer(fixChangeLogPatch($patch), $reviewer), $fullPath, ["--fuzz=3"]); 381 unlink("${fullPath}.orig") if (! $changeLogDotOrigExisted); 382 } else { 383 applyPatch($patch, $fullPath); 384 } 385 } else { 386 # Either a deletion, an addition or a binary change. 387 388 addDirectoriesIfNeeded(dirname($fullPath)); 389 390 if ($isBinary) { 391 # Binary change 392 handleBinaryChange($fullPath, $patch); 393 } elsif ($deletion) { 394 # Deletion 395 applyPatch($patch, $fullPath, ["--force"]); 396 scmRemove($fullPath); 397 } else { 398 # Addition 399 rename($fullPath, "$fullPath.orig") if -e $fullPath; 400 applyPatch($patch, $fullPath); 401 unlink("$fullPath.orig") if -e "$fullPath.orig" && checksum($fullPath) eq checksum("$fullPath.orig"); 402 scmAdd($fullPath); 403 # What is this for? 404 system "svn", "stat", "$fullPath.orig" if $isSVN && -e "$fullPath.orig"; 405 } 406 } 407} 408 409sub removeDirectoriesIfNeeded() 410{ 411 foreach my $dir (reverse sort keys %checkedDirectories) { 412 if (isDirectoryEmptyForRemoval($dir)) { 413 scmRemove($dir); 414 } 415 } 416} 417 418sub setChangeLogDateAndReviewer($$) 419{ 420 my $patch = shift; 421 my $reviewer = shift; 422 my $savedTimeZone = $ENV{'TZ'}; 423 # Set TZ temporarily so that localtime() is in that time zone 424 $ENV{'TZ'} = $changeLogTimeZone; 425 my $newDate = strftime("%Y-%m-%d", localtime()); 426 if (defined $savedTimeZone) { 427 $ENV{'TZ'} = $savedTimeZone; 428 } else { 429 delete $ENV{'TZ'}; 430 } 431 $patch =~ s/(\n\+)\d{4}-[^-]{2}-[^-]{2}( )/$1$newDate$2/; 432 if (defined($reviewer)) { 433 $patch =~ s/NOBODY \(OOPS!\)/$reviewer/; 434 } 435 return $patch; 436} 437 438sub svnStatus($) 439{ 440 my ($fullPath) = @_; 441 my $svnStatus; 442 open SVN, "svn status --non-interactive --non-recursive '$fullPath' |" or die; 443 if (-d $fullPath) { 444 # When running "svn stat" on a directory, we can't assume that only one 445 # status will be returned (since any files with a status below the 446 # directory will be returned), and we can't assume that the directory will 447 # be first (since any files with unknown status will be listed first). 448 my $normalizedFullPath = File::Spec->catdir(File::Spec->splitdir($fullPath)); 449 while (<SVN>) { 450 # Input may use a different EOL sequence than $/, so avoid chomp. 451 $_ =~ s/[\r\n]+$//g; 452 my $normalizedStatPath = File::Spec->catdir(File::Spec->splitdir(substr($_, 7))); 453 if ($normalizedFullPath eq $normalizedStatPath) { 454 $svnStatus = $_; 455 last; 456 } 457 } 458 # Read the rest of the svn command output to avoid a broken pipe warning. 459 local $/ = undef; 460 <SVN>; 461 } 462 else { 463 # Files will have only one status returned. 464 $svnStatus = <SVN>; 465 } 466 close SVN; 467 return $svnStatus; 468} 469 470# This could be made into a more general "status" call, except svn and git 471# have different ideas about "moving" files which might get confusing. 472sub scmWillDeleteFile($) 473{ 474 my ($path) = @_; 475 if ($isSVN) { 476 my $svnOutput = svnStatus($path); 477 return 1 if $svnOutput && substr($svnOutput, 0, 1) eq "D"; 478 } elsif ($isGit) { 479 my $gitOutput = `git diff-index --name-status HEAD -- $path`; 480 return 1 if $gitOutput && substr($gitOutput, 0, 1) eq "D"; 481 } 482 return 0; 483} 484 485sub scmKnowsOfFile($) 486{ 487 my ($path) = @_; 488 if ($isSVN) { 489 my $svnOutput = svnStatus($path); 490 # This will match more than intended. ? might not be the first field in the status 491 if ($svnOutput && $svnOutput =~ m#\?\s+$path\n#) { 492 return 0; 493 } 494 # This does not handle errors well. 495 return 1; 496 } elsif ($isGit) { 497 `git ls-files --error-unmatch -- $path`; 498 my $exitCode = $? >> 8; 499 return $exitCode == 0; 500 } 501} 502 503sub scmCopy($$) 504{ 505 my ($source, $destination) = @_; 506 if ($isSVN) { 507 system "svn", "copy", $source, $destination; 508 } elsif ($isGit) { 509 system "cp", $source, $destination; 510 system "git", "add", $destination; 511 } 512} 513 514sub scmAdd($) 515{ 516 my ($path) = @_; 517 if ($isSVN) { 518 system "svn", "add", $path; 519 } elsif ($isGit) { 520 system "git", "add", $path; 521 } 522} 523 524sub scmRemove($) 525{ 526 my ($path) = @_; 527 if ($isSVN) { 528 # SVN is very verbose when removing directories. Squelch all output except the last line. 529 my $svnOutput; 530 open SVN, "svn rm --force '$path' |" or die "svn rm --force '$path' failed!"; 531 # Only print the last line. Subversion outputs all changed statuses below $dir 532 while (<SVN>) { 533 $svnOutput = $_; 534 } 535 close SVN; 536 print $svnOutput if $svnOutput; 537 } elsif ($isGit) { 538 system "git", "rm", "--force", $path; 539 } 540} 541