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