• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
61use strict;
62use warnings;
63
64use Digest::MD5;
65use File::Basename;
66use File::Spec;
67use Getopt::Long;
68use MIME::Base64;
69use POSIX qw(strftime);
70
71use FindBin;
72use lib $FindBin::Bin;
73use VCSUtils;
74
75sub addDirectoriesIfNeeded($);
76sub applyPatch($$;$);
77sub checksum($);
78sub handleBinaryChange($$);
79sub handleGitBinaryChange($$);
80sub isDirectoryEmptyForRemoval($);
81sub patch($);
82sub removeDirectoriesIfNeeded();
83sub setChangeLogDateAndReviewer($$);
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 %removeDirectoryIgnoreList = (
113    '.' => 1,
114    '..' => 1,
115    '.git' => 1,
116    '.svn' => 1,
117    '_svn' => 1,
118);
119
120my $globalExitStatus = 0;
121
122my $repositoryRootPath = determineVCSRoot();
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 \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
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        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.
185for my $file (keys %copiedFiles) {
186    addDirectoriesIfNeeded(dirname($file));
187    scmCopy($copiedFiles{$file}, $file);
188}
189
190for $patch (@patches) {
191    patch($patch);
192}
193
194removeDirectoriesIfNeeded();
195
196exit $globalExitStatus;
197
198sub 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.
233sub 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
246sub 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
256sub 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
277sub 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
310sub 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
328sub 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
387sub removeDirectoriesIfNeeded()
388{
389    foreach my $dir (reverse sort keys %checkedDirectories) {
390        if (isDirectoryEmptyForRemoval($dir)) {
391            scmRemove($dir);
392        }
393    }
394}
395
396sub 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.
418sub 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
431sub 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
449sub 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
460sub 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
470sub 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