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