1 #!/usr/bin/perl -w 2 3 # Copyright (C) 2007, 2008, 2009 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 # Merge and resolve ChangeLog conflicts for svn and git repositories 30 31 use strict; 32 33 use FindBin; 34 use lib $FindBin::Bin; 35 36 use File::Basename; 37 use File::Path; 38 use File::Spec; 39 use Getopt::Long; 40 use POSIX; 41 use VCSUtils; 42 43 sub canonicalRelativePath($); 44 sub conflictFiles($); 45 sub findChangeLog($); 46 sub findUnmergedChangeLogs(); 47 sub fixMergedChangeLogs($;@); 48 sub fixOneMergedChangeLog($); 49 sub hasGitUnmergedFiles(); 50 sub isInGitFilterBranch(); 51 sub mergeChanges($$$); 52 sub parseFixMerged($$;$); 53 sub removeChangeLogArguments($); 54 sub resolveChangeLog($); 55 sub resolveConflict($); 56 sub showStatus($;$); 57 sub usageAndExit(); 58 59 my $isGit = isGit(); 60 my $isSVN = isSVN(); 61 62 my $SVN = "svn"; 63 my $GIT = "git"; 64 65 my $fixMerged; 66 my $gitRebaseContinue = 0; 67 my $printWarnings = 1; 68 my $showHelp; 69 70 my $getOptionsResult = GetOptions( 71 'c|continue!' => \$gitRebaseContinue, 72 'f|fix-merged:s' => \&parseFixMerged, 73 'h|help' => \$showHelp, 74 'w|warnings!' => \$printWarnings, 75 ); 76 77 my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot()); 78 79 my @changeLogFiles = removeChangeLogArguments($relativePath); 80 81 if (!defined $fixMerged && scalar(@changeLogFiles) == 0) { 82 @changeLogFiles = findUnmergedChangeLogs(); 83 } 84 85 if (scalar(@ARGV) > 0) { 86 print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n"; 87 undef $getOptionsResult; 88 } elsif (!defined $fixMerged && scalar(@changeLogFiles) == 0) { 89 print STDERR "ERROR: No ChangeLog files listed on command-line or found unmerged.\n"; 90 undef $getOptionsResult; 91 } elsif ($gitRebaseContinue && !$isGit) { 92 print STDERR "ERROR: --continue may only be used with a git repository\n"; 93 undef $getOptionsResult; 94 } elsif (defined $fixMerged && !$isGit) { 95 print STDERR "ERROR: --fix-merged may only be used with a git repository\n"; 96 undef $getOptionsResult; 97 } 98 99 sub usageAndExit() 100 { 101 print STDERR <<__END__; 102 Usage: @{[ basename($0) ]} [options] [path/to/ChangeLog] [path/to/another/ChangeLog ...] 103 -c|--[no-]continue run "git rebase --continue" after fixing ChangeLog 104 entries (default: --no-continue) 105 -f|--fix-merged [revision-range] fix git-merged ChangeLog entries; if a revision-range 106 is specified, run git filter-branch on the range 107 -h|--help show this help message 108 -w|--[no-]warnings show or suppress warnings (default: show warnings) 109 __END__ 110 exit 1; 111 } 112 113 if (!$getOptionsResult || $showHelp) { 114 usageAndExit(); 115 } 116 117 if (defined $fixMerged && length($fixMerged) > 0) { 118 my $commitRange = $fixMerged; 119 $commitRange = $commitRange . "..HEAD" if index($commitRange, "..") < 0; 120 fixMergedChangeLogs($commitRange, @changeLogFiles); 121 } elsif (@changeLogFiles) { 122 for my $file (@changeLogFiles) { 123 if (defined $fixMerged) { 124 fixOneMergedChangeLog($file); 125 } else { 126 resolveChangeLog($file); 127 } 128 } 129 } else { 130 print STDERR "ERROR: Unknown combination of switches and arguments.\n"; 131 usageAndExit(); 132 } 133 134 if ($gitRebaseContinue) { 135 if (hasGitUnmergedFiles()) { 136 print "Unmerged files; skipping '$GIT rebase --continue'.\n"; 137 } else { 138 print "Running '$GIT rebase --continue'...\n"; 139 print `$GIT rebase --continue`; 140 } 141 } 142 143 exit 0; 144 145 sub canonicalRelativePath($) 146 { 147 my ($originalPath) = @_; 148 my $absolutePath = Cwd::abs_path($originalPath); 149 return File::Spec->abs2rel($absolutePath, Cwd::getcwd()); 150 } 151 152 sub conflictFiles($) 153 { 154 my ($file) = @_; 155 my $fileMine; 156 my $fileOlder; 157 my $fileNewer; 158 159 if (-e $file && -e "$file.orig" && -e "$file.rej") { 160 return ("$file.rej", "$file.orig", $file); 161 } 162 163 if ($isSVN) { 164 open STAT, "-|", $SVN, "status", $file or die $!; 165 my $status = <STAT>; 166 close STAT; 167 if (!$status || $status !~ m/^C\s+/) { 168 print STDERR "WARNING: ${file} is not in a conflicted state.\n" if $printWarnings; 169 return (); 170 } 171 172 $fileMine = "${file}.mine" if -e "${file}.mine"; 173 174 my $currentRevision; 175 open INFO, "-|", $SVN, "info", $file or die $!; 176 while (my $line = <INFO>) { 177 if ($line =~ m/^Revision: ([0-9]+)/) { 178 $currentRevision = $1; 179 { local $/ = undef; <INFO>; } # Consume rest of input. 180 } 181 } 182 close INFO; 183 $fileNewer = "${file}.r${currentRevision}" if -e "${file}.r${currentRevision}"; 184 185 my @matchingFiles = grep { $_ ne $fileNewer } glob("${file}.r[0-9][0-9]*"); 186 if (scalar(@matchingFiles) > 1) { 187 print STDERR "WARNING: Too many conflict files exist for ${file}!\n" if $printWarnings; 188 } else { 189 $fileOlder = shift @matchingFiles; 190 } 191 } elsif ($isGit) { 192 my $gitPrefix = `$GIT rev-parse --show-prefix`; 193 chomp $gitPrefix; 194 open GIT, "-|", $GIT, "ls-files", "--unmerged", $file or die $!; 195 while (my $line = <GIT>) { 196 my ($mode, $hash, $stage, $fileName) = split(' ', $line); 197 my $outputFile; 198 if ($stage == 1) { 199 $fileOlder = "${file}.BASE.$$"; 200 $outputFile = $fileOlder; 201 } elsif ($stage == 2) { 202 $fileNewer = "${file}.LOCAL.$$"; 203 $outputFile = $fileNewer; 204 } elsif ($stage == 3) { 205 $fileMine = "${file}.REMOTE.$$"; 206 $outputFile = $fileMine; 207 } else { 208 die "Unknown file stage: $stage"; 209 } 210 system("$GIT cat-file blob :${stage}:${gitPrefix}${file} > $outputFile"); 211 die $! if WEXITSTATUS($?); 212 } 213 close GIT or die $!; 214 } else { 215 die "Unknown version control system"; 216 } 217 218 if (!$fileMine && !$fileOlder && !$fileNewer) { 219 print STDERR "WARNING: ${file} does not need merging.\n" if $printWarnings; 220 } elsif (!$fileMine || !$fileOlder || !$fileNewer) { 221 print STDERR "WARNING: ${file} is missing some conflict files.\n" if $printWarnings; 222 } 223 224 return ($fileMine, $fileOlder, $fileNewer); 225 } 226 227 sub findChangeLog($) 228 { 229 return $_[0] if basename($_[0]) eq "ChangeLog"; 230 231 my $file = File::Spec->catfile($_[0], "ChangeLog"); 232 return $file if -d $_[0] and -e $file; 233 234 return undef; 235 } 236 237 sub findUnmergedChangeLogs() 238 { 239 my $statCommand = ""; 240 241 if ($isSVN) { 242 $statCommand = "$SVN stat | grep '^C'"; 243 } elsif ($isGit) { 244 $statCommand = "$GIT diff -r --name-status --diff-filter=U -C -C -M"; 245 } else { 246 return (); 247 } 248 249 my @results = (); 250 open STAT, "-|", $statCommand or die "The status failed: $!.\n"; 251 while (<STAT>) { 252 if ($isSVN) { 253 my $matches; 254 my $file; 255 if (isSVNVersion16OrNewer()) { 256 $matches = /^([C]).{6} (.+?)[\r\n]*$/; 257 $file = $2; 258 } else { 259 $matches = /^([C]).{5} (.+?)[\r\n]*$/; 260 $file = $2; 261 } 262 if ($matches) { 263 $file = findChangeLog(normalizePath($file)); 264 push @results, $file if $file; 265 } else { 266 print; # error output from svn stat 267 } 268 } elsif ($isGit) { 269 if (/^([U])\t(.+)$/) { 270 my $file = findChangeLog(normalizePath($2)); 271 push @results, $file if $file; 272 } else { 273 print; # error output from git diff 274 } 275 } 276 } 277 close STAT; 278 279 return @results; 280 } 281 282 sub fixMergedChangeLogs($;@) 283 { 284 my $revisionRange = shift; 285 my @changedFiles = @_; 286 287 if (scalar(@changedFiles) < 1) { 288 # Read in list of files changed in $revisionRange 289 open GIT, "-|", $GIT, "diff", "--name-only", $revisionRange or die $!; 290 push @changedFiles, <GIT>; 291 close GIT or die $!; 292 die "No changed files in $revisionRange" if scalar(@changedFiles) < 1; 293 chomp @changedFiles; 294 } 295 296 my @changeLogs = grep { defined $_ } map { findChangeLog($_) } @changedFiles; 297 die "No changed ChangeLog files in $revisionRange" if scalar(@changeLogs) < 1; 298 299 system("$GIT filter-branch --tree-filter 'PREVIOUS_COMMIT=\`$GIT rev-parse \$GIT_COMMIT^\` && MAPPED_PREVIOUS_COMMIT=\`map \$PREVIOUS_COMMIT\` \"$0\" -f \"" . join('" "', @changeLogs) . "\"' $revisionRange"); 300 301 # On success, remove the backup refs directory 302 if (WEXITSTATUS($?) == 0) { 303 rmtree(qw(.git/refs/original)); 304 } 305 } 306 307 sub fixOneMergedChangeLog($) 308 { 309 my $file = shift; 310 my $patch; 311 312 # Read in patch for incorrectly merged ChangeLog entry 313 { 314 local $/ = undef; 315 open GIT, "-|", $GIT, "diff", ($ENV{GIT_COMMIT} || "HEAD") . "^", $file or die $!; 316 $patch = <GIT>; 317 close GIT or die $!; 318 } 319 320 # Always checkout the previous commit's copy of the ChangeLog 321 system($GIT, "checkout", $ENV{MAPPED_PREVIOUS_COMMIT} || "HEAD^", $file); 322 die $! if WEXITSTATUS($?); 323 324 # The patch must have 0 or more lines of context, then 1 or more lines 325 # of additions, and then 1 or more lines of context. If not, we skip it. 326 if ($patch =~ /\n@@ -(\d+),(\d+) \+(\d+),(\d+) @@\n( .*\n)*((\+.*\n)+)( .*\n)+$/m) { 327 # Copy the header from the original patch. 328 my $newPatch = substr($patch, 0, index($patch, "@@ -${1},${2} +${3},${4} @@")); 329 330 # Generate a new set of line numbers and patch lengths. Our new 331 # patch will start with the lines for the fixed ChangeLog entry, 332 # then have 3 lines of context from the top of the current file to 333 # make the patch apply cleanly. 334 $newPatch .= "@@ -1,3 +1," . ($4 - $2 + 3) . " @@\n"; 335 336 # We assume that top few lines of the ChangeLog entry are actually 337 # at the bottom of the list of added lines (due to the way the patch 338 # algorithm works), so we simply search through the lines until we 339 # find the date line, then move the rest of the lines to the top. 340 my @patchLines = map { $_ . "\n" } split(/\n/, $6); 341 foreach my $i (0 .. $#patchLines) { 342 if ($patchLines[$i] =~ /^\+\d{4}-\d{2}-\d{2} /) { 343 unshift(@patchLines, splice(@patchLines, $i, scalar(@patchLines) - $i)); 344 last; 345 } 346 } 347 348 $newPatch .= join("", @patchLines); 349 350 # Add 3 lines of context to the end 351 open FILE, "<", $file or die $!; 352 for (my $i = 0; $i < 3; $i++) { 353 $newPatch .= " " . <FILE>; 354 } 355 close FILE; 356 357 # Apply the new patch 358 open(PATCH, "| patch -p1 $file > " . File::Spec->devnull()) or die $!; 359 print PATCH $newPatch; 360 close(PATCH) or die $!; 361 362 # Run "git add" on the fixed ChangeLog file 363 system($GIT, "add", $file); 364 die $! if WEXITSTATUS($?); 365 366 showStatus($file, 1); 367 } elsif ($patch) { 368 # Restore the current copy of the ChangeLog file since we can't repatch it 369 system($GIT, "checkout", $ENV{GIT_COMMIT} || "HEAD", $file); 370 die $! if WEXITSTATUS($?); 371 print STDERR "WARNING: Last change to ${file} could not be fixed and re-merged.\n" if $printWarnings; 372 } 373 } 374 375 sub hasGitUnmergedFiles() 376 { 377 my $output = `$GIT ls-files --unmerged`; 378 return $output ne ""; 379 } 380 381 sub isInGitFilterBranch() 382 { 383 return exists $ENV{MAPPED_PREVIOUS_COMMIT} && $ENV{MAPPED_PREVIOUS_COMMIT}; 384 } 385 386 sub mergeChanges($$$) 387 { 388 my ($fileMine, $fileOlder, $fileNewer) = @_; 389 390 my $traditionalReject = $fileMine =~ /\.rej$/ ? 1 : 0; 391 392 local $/ = undef; 393 394 my $patch; 395 if ($traditionalReject) { 396 open(DIFF, "<", $fileMine) or die $!; 397 $patch = <DIFF>; 398 close(DIFF); 399 rename($fileMine, "$fileMine.save"); 400 rename($fileOlder, "$fileOlder.save"); 401 } else { 402 open(DIFF, "-|", qw(diff -u -a --binary), $fileOlder, $fileMine) or die $!; 403 $patch = <DIFF>; 404 close(DIFF); 405 } 406 407 unlink("${fileNewer}.orig"); 408 unlink("${fileNewer}.rej"); 409 410 open(PATCH, "| patch --fuzz=3 --binary $fileNewer > " . File::Spec->devnull()) or die $!; 411 print PATCH fixChangeLogPatch($patch); 412 close(PATCH); 413 414 my $result; 415 416 # Refuse to merge the patch if it did not apply cleanly 417 if (-e "${fileNewer}.rej") { 418 unlink("${fileNewer}.rej"); 419 unlink($fileNewer); 420 rename("${fileNewer}.orig", $fileNewer); 421 $result = 0; 422 } else { 423 unlink("${fileNewer}.orig"); 424 $result = 1; 425 } 426 427 if ($traditionalReject) { 428 rename("$fileMine.save", $fileMine); 429 rename("$fileOlder.save", $fileOlder); 430 } 431 432 return $result; 433 } 434 435 sub parseFixMerged($$;$) 436 { 437 my ($switchName, $key, $value) = @_; 438 if (defined $key) { 439 if (defined findChangeLog($key)) { 440 unshift(@ARGV, $key); 441 $fixMerged = ""; 442 } else { 443 $fixMerged = $key; 444 } 445 } else { 446 $fixMerged = ""; 447 } 448 } 449 450 sub removeChangeLogArguments($) 451 { 452 my ($baseDir) = @_; 453 my @results = (); 454 455 for (my $i = 0; $i < scalar(@ARGV); ) { 456 my $file = findChangeLog(canonicalRelativePath(File::Spec->catfile($baseDir, $ARGV[$i]))); 457 if (defined $file) { 458 splice(@ARGV, $i, 1); 459 push @results, $file; 460 } else { 461 $i++; 462 } 463 } 464 465 return @results; 466 } 467 468 sub resolveChangeLog($) 469 { 470 my ($file) = @_; 471 472 my ($fileMine, $fileOlder, $fileNewer) = conflictFiles($file); 473 474 return unless $fileMine && $fileOlder && $fileNewer; 475 476 if (mergeChanges($fileMine, $fileOlder, $fileNewer)) { 477 if ($file ne $fileNewer) { 478 unlink($file); 479 rename($fileNewer, $file) or die $!; 480 } 481 unlink($fileMine, $fileOlder); 482 resolveConflict($file); 483 showStatus($file, 1); 484 } else { 485 showStatus($file); 486 print STDERR "WARNING: ${file} could not be merged using fuzz level 3.\n" if $printWarnings; 487 unlink($fileMine, $fileOlder, $fileNewer) if $isGit; 488 } 489 } 490 491 sub resolveConflict($) 492 { 493 my ($file) = @_; 494 495 if ($isSVN) { 496 system($SVN, "resolved", $file); 497 die $! if WEXITSTATUS($?); 498 } elsif ($isGit) { 499 system($GIT, "add", $file); 500 die $! if WEXITSTATUS($?); 501 } else { 502 die "Unknown version control system"; 503 } 504 } 505 506 sub showStatus($;$) 507 { 508 my ($file, $isConflictResolved) = @_; 509 510 if ($isSVN) { 511 system($SVN, "status", $file); 512 } elsif ($isGit) { 513 my @args = qw(--name-status); 514 unshift @args, qw(--cached) if $isConflictResolved; 515 system($GIT, "diff", @args, $file); 516 } else { 517 die "Unknown version control system"; 518 } 519 } 520 521