resolve-ChangeLogs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. #!/usr/bin/perl -w
  2. # Copyright (C) 2007, 2008, 2009 Apple Inc. All rights reserved.
  3. #
  4. # Redistribution and use in source and binary forms, with or without
  5. # modification, are permitted provided that the following conditions
  6. # are met:
  7. #
  8. # 1. Redistributions of source code must retain the above copyright
  9. # notice, this list of conditions and the following disclaimer.
  10. # 2. Redistributions in binary form must reproduce the above copyright
  11. # notice, this list of conditions and the following disclaimer in the
  12. # documentation and/or other materials provided with the distribution.
  13. # 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of
  14. # its contributors may be used to endorse or promote products derived
  15. # from this software without specific prior written permission.
  16. #
  17. # THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY
  18. # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  19. # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  20. # DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY
  21. # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
  22. # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
  23. # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
  24. # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  25. # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
  26. # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  27. # Merge and resolve ChangeLog conflicts for svn and git repositories
  28. use strict;
  29. use FindBin;
  30. use lib $FindBin::Bin;
  31. use File::Basename;
  32. use File::Copy;
  33. use File::Path;
  34. use File::Spec;
  35. use Getopt::Long;
  36. use POSIX;
  37. use VCSUtils;
  38. sub canonicalRelativePath($);
  39. sub conflictFiles($);
  40. sub findChangeLog($);
  41. sub findUnmergedChangeLogs();
  42. sub fixMergedChangeLogs($;@);
  43. sub fixOneMergedChangeLog($);
  44. sub hasGitUnmergedFiles();
  45. sub isInGitFilterBranch();
  46. sub parseFixMerged($$;$);
  47. sub removeChangeLogArguments($);
  48. sub resolveChangeLog($);
  49. sub resolveConflict($);
  50. sub showStatus($;$);
  51. sub usageAndExit();
  52. my $isGit = isGit();
  53. my $isSVN = isSVN();
  54. my $SVN = "svn";
  55. my $GIT = "git";
  56. my $fixMerged;
  57. my $gitRebaseContinue = 0;
  58. my $mergeDriver = 0;
  59. my $printWarnings = 1;
  60. my $showHelp;
  61. sub usageAndExit()
  62. {
  63. print STDERR <<__END__;
  64. Usage: @{[ basename($0) ]} [options] [path/to/ChangeLog] [path/to/another/ChangeLog ...]
  65. -c|--[no-]continue run "git rebase --continue" after fixing ChangeLog
  66. entries (default: --no-continue)
  67. -f|--fix-merged [revision-range] fix git-merged ChangeLog entries; if a revision-range
  68. is specified, run git filter-branch on the range
  69. -m|--merge-driver %O %A %B act as a git merge-driver on files %O %A %B
  70. -h|--help show this help message
  71. -w|--[no-]warnings show or suppress warnings (default: show warnings)
  72. __END__
  73. exit 1;
  74. }
  75. my $getOptionsResult = GetOptions(
  76. 'c|continue!' => \$gitRebaseContinue,
  77. 'f|fix-merged:s' => \&parseFixMerged,
  78. 'm|merge-driver!' => \$mergeDriver,
  79. 'h|help' => \$showHelp,
  80. 'w|warnings!' => \$printWarnings,
  81. );
  82. if (!$getOptionsResult || $showHelp) {
  83. usageAndExit();
  84. }
  85. my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot());
  86. my @changeLogFiles = removeChangeLogArguments($relativePath);
  87. if (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) {
  88. @changeLogFiles = findUnmergedChangeLogs();
  89. }
  90. if (!$mergeDriver && scalar(@ARGV) > 0) {
  91. print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n";
  92. undef $getOptionsResult;
  93. } elsif (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) {
  94. print STDERR "ERROR: No ChangeLog files listed on command-line or found unmerged.\n";
  95. undef $getOptionsResult;
  96. } elsif ($gitRebaseContinue && !$isGit) {
  97. print STDERR "ERROR: --continue may only be used with a git repository\n";
  98. undef $getOptionsResult;
  99. } elsif (defined $fixMerged && !$isGit) {
  100. print STDERR "ERROR: --fix-merged may only be used with a git repository\n";
  101. undef $getOptionsResult;
  102. } elsif ($mergeDriver && !$isGit) {
  103. print STDERR "ERROR: --merge-driver may only be used with a git repository\n";
  104. undef $getOptionsResult;
  105. } elsif ($mergeDriver && scalar(@ARGV) < 3) {
  106. print STDERR "ERROR: --merge-driver expects %O %A %B as arguments\n";
  107. undef $getOptionsResult;
  108. }
  109. if (!$getOptionsResult) {
  110. usageAndExit();
  111. }
  112. if (defined $fixMerged && length($fixMerged) > 0) {
  113. my $commitRange = $fixMerged;
  114. $commitRange = $commitRange . "..HEAD" if index($commitRange, "..") < 0;
  115. fixMergedChangeLogs($commitRange, @changeLogFiles);
  116. } elsif ($mergeDriver) {
  117. my ($base, $theirs, $ours) = @ARGV;
  118. if (mergeChangeLogs($ours, $base, $theirs)) {
  119. unlink($ours);
  120. copy($theirs, $ours) or die $!;
  121. } else {
  122. exec qw(git merge-file -L THEIRS -L BASE -L OURS), $theirs, $base, $ours;
  123. }
  124. } elsif (@changeLogFiles) {
  125. for my $file (@changeLogFiles) {
  126. if (defined $fixMerged) {
  127. fixOneMergedChangeLog($file);
  128. } else {
  129. resolveChangeLog($file);
  130. }
  131. }
  132. } else {
  133. print STDERR "ERROR: Unknown combination of switches and arguments.\n";
  134. usageAndExit();
  135. }
  136. if ($gitRebaseContinue) {
  137. if (hasGitUnmergedFiles()) {
  138. print "Unmerged files; skipping '$GIT rebase --continue'.\n";
  139. } else {
  140. print "Running '$GIT rebase --continue'...\n";
  141. print `$GIT rebase --continue`;
  142. }
  143. }
  144. exit 0;
  145. sub canonicalRelativePath($)
  146. {
  147. my ($originalPath) = @_;
  148. my $absolutePath = Cwd::abs_path($originalPath);
  149. return File::Spec->abs2rel($absolutePath, Cwd::getcwd());
  150. }
  151. sub conflictFiles($)
  152. {
  153. my ($file) = @_;
  154. my $fileMine;
  155. my $fileOlder;
  156. my $fileNewer;
  157. if (-e $file && -e "$file.orig" && -e "$file.rej") {
  158. return ("$file.rej", "$file.orig", $file);
  159. }
  160. if ($isSVN) {
  161. my $escapedFile = escapeSubversionPath($file);
  162. open STAT, "-|", $SVN, "status", $escapedFile or die $!;
  163. my $status = <STAT>;
  164. close STAT;
  165. if (!$status || $status !~ m/^C\s+/) {
  166. print STDERR "WARNING: ${file} is not in a conflicted state.\n" if $printWarnings;
  167. return ();
  168. }
  169. $fileMine = "${file}.mine" if -e "${file}.mine";
  170. my $currentRevision;
  171. open INFO, "-|", $SVN, "info", $escapedFile or die $!;
  172. while (my $line = <INFO>) {
  173. if ($line =~ m/^Revision: ([0-9]+)/) {
  174. $currentRevision = $1;
  175. { local $/ = undef; <INFO>; } # Consume rest of input.
  176. }
  177. }
  178. close INFO;
  179. $fileNewer = "${file}.r${currentRevision}" if -e "${file}.r${currentRevision}";
  180. my @matchingFiles = grep { $_ ne $fileNewer } glob("${file}.r[0-9][0-9]*");
  181. if (scalar(@matchingFiles) > 1) {
  182. print STDERR "WARNING: Too many conflict files exist for ${file}!\n" if $printWarnings;
  183. } else {
  184. $fileOlder = shift @matchingFiles;
  185. }
  186. } elsif ($isGit) {
  187. my $gitPrefix = `$GIT rev-parse --show-prefix`;
  188. chomp $gitPrefix;
  189. open GIT, "-|", $GIT, "ls-files", "--unmerged", $file or die $!;
  190. while (my $line = <GIT>) {
  191. my ($mode, $hash, $stage, $fileName) = split(' ', $line);
  192. my $outputFile;
  193. if ($stage == 1) {
  194. $fileOlder = "${file}.BASE.$$";
  195. $outputFile = $fileOlder;
  196. } elsif ($stage == 2) {
  197. $fileNewer = "${file}.LOCAL.$$";
  198. $outputFile = $fileNewer;
  199. } elsif ($stage == 3) {
  200. $fileMine = "${file}.REMOTE.$$";
  201. $outputFile = $fileMine;
  202. } else {
  203. die "Unknown file stage: $stage";
  204. }
  205. system("$GIT cat-file blob :${stage}:${gitPrefix}${file} > $outputFile");
  206. die $! if WEXITSTATUS($?);
  207. }
  208. close GIT or die $!;
  209. } else {
  210. die "Unknown version control system";
  211. }
  212. if (!$fileMine && !$fileOlder && !$fileNewer) {
  213. print STDERR "WARNING: ${file} does not need merging.\n" if $printWarnings;
  214. } elsif (!$fileMine || !$fileOlder || !$fileNewer) {
  215. print STDERR "WARNING: ${file} is missing some conflict files.\n" if $printWarnings;
  216. }
  217. return ($fileMine, $fileOlder, $fileNewer);
  218. }
  219. sub findChangeLog($)
  220. {
  221. my $changeLogFileName = changeLogFileName();
  222. return $_[0] if basename($_[0]) eq $changeLogFileName;
  223. my $file = File::Spec->catfile($_[0], $changeLogFileName);
  224. return $file if -d $_[0] and -e $file;
  225. return undef;
  226. }
  227. sub findUnmergedChangeLogs()
  228. {
  229. my $statCommand = "";
  230. if ($isSVN) {
  231. $statCommand = "$SVN stat | grep '^C'";
  232. } elsif ($isGit) {
  233. $statCommand = "$GIT diff -r --name-status --diff-filter=U -C -C -M";
  234. } else {
  235. return ();
  236. }
  237. my @results = ();
  238. open STAT, "-|", $statCommand or die "The status failed: $!.\n";
  239. while (<STAT>) {
  240. if ($isSVN) {
  241. my $matches;
  242. my $file;
  243. if (isSVNVersion16OrNewer()) {
  244. $matches = /^([C]).{6} (.+?)[\r\n]*$/;
  245. $file = $2;
  246. } else {
  247. $matches = /^([C]).{5} (.+?)[\r\n]*$/;
  248. $file = $2;
  249. }
  250. if ($matches) {
  251. $file = findChangeLog(normalizePath($file));
  252. push @results, $file if $file;
  253. } else {
  254. print; # error output from svn stat
  255. }
  256. } elsif ($isGit) {
  257. if (/^([U])\t(.+)$/) {
  258. my $file = findChangeLog(normalizePath($2));
  259. push @results, $file if $file;
  260. } else {
  261. print; # error output from git diff
  262. }
  263. }
  264. }
  265. close STAT;
  266. return @results;
  267. }
  268. sub fixMergedChangeLogs($;@)
  269. {
  270. my $revisionRange = shift;
  271. my @changedFiles = @_;
  272. if (scalar(@changedFiles) < 1) {
  273. # Read in list of files changed in $revisionRange
  274. open GIT, "-|", $GIT, "diff", "--name-only", $revisionRange or die $!;
  275. push @changedFiles, <GIT>;
  276. close GIT or die $!;
  277. die "No changed files in $revisionRange" if scalar(@changedFiles) < 1;
  278. chomp @changedFiles;
  279. }
  280. my @changeLogs = grep { defined $_ } map { findChangeLog($_) } @changedFiles;
  281. die "No changed ChangeLog files in $revisionRange" if scalar(@changeLogs) < 1;
  282. system("$GIT filter-branch --tree-filter 'PREVIOUS_COMMIT=\`$GIT rev-parse \$GIT_COMMIT^\` && MAPPED_PREVIOUS_COMMIT=\`map \$PREVIOUS_COMMIT\` \"$0\" -f \"" . join('" "', @changeLogs) . "\"' $revisionRange");
  283. # On success, remove the backup refs directory
  284. if (WEXITSTATUS($?) == 0) {
  285. rmtree(qw(.git/refs/original));
  286. }
  287. }
  288. sub fixOneMergedChangeLog($)
  289. {
  290. my $file = shift;
  291. my $patch;
  292. # Read in patch for incorrectly merged ChangeLog entry
  293. {
  294. local $/ = undef;
  295. open GIT, "-|", $GIT, "diff", ($ENV{GIT_COMMIT} || "HEAD") . "^", $file or die $!;
  296. $patch = <GIT>;
  297. close GIT or die $!;
  298. }
  299. # Always checkout the previous commit's copy of the ChangeLog
  300. system($GIT, "checkout", $ENV{MAPPED_PREVIOUS_COMMIT} || "HEAD^", $file);
  301. die $! if WEXITSTATUS($?);
  302. # The patch must have 0 or more lines of context, then 1 or more lines
  303. # of additions, and then 1 or more lines of context. If not, we skip it.
  304. if ($patch =~ /\n@@ -(\d+),(\d+) \+(\d+),(\d+) @@\n( .*\n)*((\+.*\n)+)( .*\n)+$/m) {
  305. # Copy the header from the original patch.
  306. my $newPatch = substr($patch, 0, index($patch, "@@ -${1},${2} +${3},${4} @@"));
  307. # Generate a new set of line numbers and patch lengths. Our new
  308. # patch will start with the lines for the fixed ChangeLog entry,
  309. # then have 3 lines of context from the top of the current file to
  310. # make the patch apply cleanly.
  311. $newPatch .= "@@ -1,3 +1," . ($4 - $2 + 3) . " @@\n";
  312. # We assume that top few lines of the ChangeLog entry are actually
  313. # at the bottom of the list of added lines (due to the way the patch
  314. # algorithm works), so we simply search through the lines until we
  315. # find the date line, then move the rest of the lines to the top.
  316. my @patchLines = map { $_ . "\n" } split(/\n/, $6);
  317. foreach my $i (0 .. $#patchLines) {
  318. if ($patchLines[$i] =~ /^\+\d{4}-\d{2}-\d{2} /) {
  319. unshift(@patchLines, splice(@patchLines, $i, scalar(@patchLines) - $i));
  320. last;
  321. }
  322. }
  323. $newPatch .= join("", @patchLines);
  324. # Add 3 lines of context to the end
  325. open FILE, "<", $file or die $!;
  326. for (my $i = 0; $i < 3; $i++) {
  327. $newPatch .= " " . <FILE>;
  328. }
  329. close FILE;
  330. # Apply the new patch
  331. open(PATCH, "| patch -p1 $file > " . File::Spec->devnull()) or die $!;
  332. print PATCH $newPatch;
  333. close(PATCH) or die $!;
  334. # Run "git add" on the fixed ChangeLog file
  335. system($GIT, "add", $file);
  336. die $! if WEXITSTATUS($?);
  337. showStatus($file, 1);
  338. } elsif ($patch) {
  339. # Restore the current copy of the ChangeLog file since we can't repatch it
  340. system($GIT, "checkout", $ENV{GIT_COMMIT} || "HEAD", $file);
  341. die $! if WEXITSTATUS($?);
  342. print STDERR "WARNING: Last change to ${file} could not be fixed and re-merged.\n" if $printWarnings;
  343. }
  344. }
  345. sub hasGitUnmergedFiles()
  346. {
  347. my $output = `$GIT ls-files --unmerged`;
  348. return $output ne "";
  349. }
  350. sub isInGitFilterBranch()
  351. {
  352. return exists $ENV{MAPPED_PREVIOUS_COMMIT} && $ENV{MAPPED_PREVIOUS_COMMIT};
  353. }
  354. sub parseFixMerged($$;$)
  355. {
  356. my ($switchName, $key, $value) = @_;
  357. if (defined $key) {
  358. if (defined findChangeLog($key)) {
  359. unshift(@ARGV, $key);
  360. $fixMerged = "";
  361. } else {
  362. $fixMerged = $key;
  363. }
  364. } else {
  365. $fixMerged = "";
  366. }
  367. }
  368. sub removeChangeLogArguments($)
  369. {
  370. my ($baseDir) = @_;
  371. my @results = ();
  372. for (my $i = 0; $i < scalar(@ARGV); ) {
  373. my $file = findChangeLog(canonicalRelativePath(File::Spec->catfile($baseDir, $ARGV[$i])));
  374. if (defined $file) {
  375. splice(@ARGV, $i, 1);
  376. push @results, $file;
  377. } else {
  378. $i++;
  379. }
  380. }
  381. return @results;
  382. }
  383. sub resolveChangeLog($)
  384. {
  385. my ($file) = @_;
  386. my ($fileMine, $fileOlder, $fileNewer) = conflictFiles($file);
  387. return unless $fileMine && $fileOlder && $fileNewer;
  388. if (mergeChangeLogs($fileMine, $fileOlder, $fileNewer)) {
  389. if ($file ne $fileNewer) {
  390. unlink($file);
  391. rename($fileNewer, $file) or die $!;
  392. }
  393. unlink($fileMine, $fileOlder);
  394. resolveConflict($file);
  395. showStatus($file, 1);
  396. } else {
  397. showStatus($file);
  398. print STDERR "WARNING: ${file} could not be merged using fuzz level 3.\n" if $printWarnings;
  399. unlink($fileMine, $fileOlder, $fileNewer) if $isGit;
  400. }
  401. }
  402. sub resolveConflict($)
  403. {
  404. my ($file) = @_;
  405. if ($isSVN) {
  406. my $escapedFile = escapeSubversionPath($file);
  407. system($SVN, "resolved", $escapedFile);
  408. die $! if WEXITSTATUS($?);
  409. } elsif ($isGit) {
  410. system($GIT, "add", $file);
  411. die $! if WEXITSTATUS($?);
  412. } else {
  413. die "Unknown version control system";
  414. }
  415. }
  416. sub showStatus($;$)
  417. {
  418. my ($file, $isConflictResolved) = @_;
  419. if ($isSVN) {
  420. my $escapedFile = escapeSubversionPath($file);
  421. system($SVN, "status", $escapedFile);
  422. } elsif ($isGit) {
  423. my @args = qw(--name-status);
  424. unshift @args, qw(--cached) if $isConflictResolved;
  425. system($GIT, "diff", @args, $file);
  426. } else {
  427. die "Unknown version control system";
  428. }
  429. }