svn-apply 17 KB

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