#!/usr/bin/perl -w # Copyright (C) 2007, 2008, 2009 Apple Inc. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of # its contributors may be used to endorse or promote products derived # from this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE # DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # Merge and resolve ChangeLog conflicts for svn and git repositories use strict; use FindBin; use lib $FindBin::Bin; use File::Basename; use File::Path; use File::Spec; use Getopt::Long; use POSIX; use VCSUtils; sub canonicalRelativePath($); sub conflictFiles($); sub findChangeLog($); sub findUnmergedChangeLogs(); sub fixMergedChangeLogs($;@); sub fixOneMergedChangeLog($); sub hasGitUnmergedFiles(); sub isInGitFilterBranch(); sub mergeChanges($$$); sub parseFixMerged($$;$); sub removeChangeLogArguments($); sub resolveChangeLog($); sub resolveConflict($); sub showStatus($;$); sub usageAndExit(); my $isGit = isGit(); my $isSVN = isSVN(); my $SVN = "svn"; my $GIT = "git"; my $fixMerged; my $gitRebaseContinue = 0; my $printWarnings = 1; my $showHelp; my $getOptionsResult = GetOptions( 'c|continue!' => \$gitRebaseContinue, 'f|fix-merged:s' => \&parseFixMerged, 'h|help' => \$showHelp, 'w|warnings!' => \$printWarnings, ); my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot()); my @changeLogFiles = removeChangeLogArguments($relativePath); if (!defined $fixMerged && scalar(@changeLogFiles) == 0) { @changeLogFiles = findUnmergedChangeLogs(); } if (scalar(@ARGV) > 0) { print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n"; undef $getOptionsResult; } elsif (!defined $fixMerged && scalar(@changeLogFiles) == 0) { print STDERR "ERROR: No ChangeLog files listed on command-line or found unmerged.\n"; undef $getOptionsResult; } elsif ($gitRebaseContinue && !$isGit) { print STDERR "ERROR: --continue may only be used with a git repository\n"; undef $getOptionsResult; } elsif (defined $fixMerged && !$isGit) { print STDERR "ERROR: --fix-merged may only be used with a git repository\n"; undef $getOptionsResult; } sub usageAndExit() { print STDERR <<__END__; Usage: @{[ basename($0) ]} [options] [path/to/ChangeLog] [path/to/another/ChangeLog ...] -c|--[no-]continue run "git rebase --continue" after fixing ChangeLog entries (default: --no-continue) -f|--fix-merged [revision-range] fix git-merged ChangeLog entries; if a revision-range is specified, run git filter-branch on the range -h|--help show this help message -w|--[no-]warnings show or suppress warnings (default: show warnings) __END__ exit 1; } if (!$getOptionsResult || $showHelp) { usageAndExit(); } if (defined $fixMerged && length($fixMerged) > 0) { my $commitRange = $fixMerged; $commitRange = $commitRange . "..HEAD" if index($commitRange, "..") < 0; fixMergedChangeLogs($commitRange, @changeLogFiles); } elsif (@changeLogFiles) { for my $file (@changeLogFiles) { if (defined $fixMerged) { fixOneMergedChangeLog($file); } else { resolveChangeLog($file); } } } else { print STDERR "ERROR: Unknown combination of switches and arguments.\n"; usageAndExit(); } if ($gitRebaseContinue) { if (hasGitUnmergedFiles()) { print "Unmerged files; skipping '$GIT rebase --continue'.\n"; } else { print "Running '$GIT rebase --continue'...\n"; print `$GIT rebase --continue`; } } exit 0; sub canonicalRelativePath($) { my ($originalPath) = @_; my $absolutePath = Cwd::abs_path($originalPath); return File::Spec->abs2rel($absolutePath, Cwd::getcwd()); } sub conflictFiles($) { my ($file) = @_; my $fileMine; my $fileOlder; my $fileNewer; if (-e $file && -e "$file.orig" && -e "$file.rej") { return ("$file.rej", "$file.orig", $file); } if ($isSVN) { open STAT, "-|", $SVN, "status", $file or die $!; my $status = ; close STAT; if (!$status || $status !~ m/^C\s+/) { print STDERR "WARNING: ${file} is not in a conflicted state.\n" if $printWarnings; return (); } $fileMine = "${file}.mine" if -e "${file}.mine"; my $currentRevision; open INFO, "-|", $SVN, "info", $file or die $!; while (my $line = ) { if ($line =~ m/^Revision: ([0-9]+)/) { $currentRevision = $1; { local $/ = undef; ; } # Consume rest of input. } } close INFO; $fileNewer = "${file}.r${currentRevision}" if -e "${file}.r${currentRevision}"; my @matchingFiles = grep { $_ ne $fileNewer } glob("${file}.r[0-9][0-9]*"); if (scalar(@matchingFiles) > 1) { print STDERR "WARNING: Too many conflict files exist for ${file}!\n" if $printWarnings; } else { $fileOlder = shift @matchingFiles; } } elsif ($isGit) { my $gitPrefix = `$GIT rev-parse --show-prefix`; chomp $gitPrefix; open GIT, "-|", $GIT, "ls-files", "--unmerged", $file or die $!; while (my $line = ) { my ($mode, $hash, $stage, $fileName) = split(' ', $line); my $outputFile; if ($stage == 1) { $fileOlder = "${file}.BASE.$$"; $outputFile = $fileOlder; } elsif ($stage == 2) { $fileNewer = "${file}.LOCAL.$$"; $outputFile = $fileNewer; } elsif ($stage == 3) { $fileMine = "${file}.REMOTE.$$"; $outputFile = $fileMine; } else { die "Unknown file stage: $stage"; } system("$GIT cat-file blob :${stage}:${gitPrefix}${file} > $outputFile"); die $! if WEXITSTATUS($?); } close GIT or die $!; } else { die "Unknown version control system"; } if (!$fileMine && !$fileOlder && !$fileNewer) { print STDERR "WARNING: ${file} does not need merging.\n" if $printWarnings; } elsif (!$fileMine || !$fileOlder || !$fileNewer) { print STDERR "WARNING: ${file} is missing some conflict files.\n" if $printWarnings; } return ($fileMine, $fileOlder, $fileNewer); } sub findChangeLog($) { return $_[0] if basename($_[0]) eq "ChangeLog"; my $file = File::Spec->catfile($_[0], "ChangeLog"); return $file if -d $_[0] and -e $file; return undef; } sub findUnmergedChangeLogs() { my $statCommand = ""; if ($isSVN) { $statCommand = "$SVN stat | grep '^C'"; } elsif ($isGit) { $statCommand = "$GIT diff -r --name-status --diff-filter=U -C -C -M"; } else { return (); } my @results = (); open STAT, "-|", $statCommand or die "The status failed: $!.\n"; while () { if ($isSVN) { my $matches; my $file; if (isSVNVersion16OrNewer()) { $matches = /^([C]).{6} (.+?)[\r\n]*$/; $file = $2; } else { $matches = /^([C]).{5} (.+?)[\r\n]*$/; $file = $2; } if ($matches) { $file = findChangeLog(normalizePath($file)); push @results, $file if $file; } else { print; # error output from svn stat } } elsif ($isGit) { if (/^([U])\t(.+)$/) { my $file = findChangeLog(normalizePath($2)); push @results, $file if $file; } else { print; # error output from git diff } } } close STAT; return @results; } sub fixMergedChangeLogs($;@) { my $revisionRange = shift; my @changedFiles = @_; if (scalar(@changedFiles) < 1) { # Read in list of files changed in $revisionRange open GIT, "-|", $GIT, "diff", "--name-only", $revisionRange or die $!; push @changedFiles, ; close GIT or die $!; die "No changed files in $revisionRange" if scalar(@changedFiles) < 1; chomp @changedFiles; } my @changeLogs = grep { defined $_ } map { findChangeLog($_) } @changedFiles; die "No changed ChangeLog files in $revisionRange" if scalar(@changeLogs) < 1; system("$GIT filter-branch --tree-filter 'PREVIOUS_COMMIT=\`$GIT rev-parse \$GIT_COMMIT^\` && MAPPED_PREVIOUS_COMMIT=\`map \$PREVIOUS_COMMIT\` \"$0\" -f \"" . join('" "', @changeLogs) . "\"' $revisionRange"); # On success, remove the backup refs directory if (WEXITSTATUS($?) == 0) { rmtree(qw(.git/refs/original)); } } sub fixOneMergedChangeLog($) { my $file = shift; my $patch; # Read in patch for incorrectly merged ChangeLog entry { local $/ = undef; open GIT, "-|", $GIT, "diff", ($ENV{GIT_COMMIT} || "HEAD") . "^", $file or die $!; $patch = ; close GIT or die $!; } # Always checkout the previous commit's copy of the ChangeLog system($GIT, "checkout", $ENV{MAPPED_PREVIOUS_COMMIT} || "HEAD^", $file); die $! if WEXITSTATUS($?); # The patch must have 0 or more lines of context, then 1 or more lines # of additions, and then 1 or more lines of context. If not, we skip it. if ($patch =~ /\n@@ -(\d+),(\d+) \+(\d+),(\d+) @@\n( .*\n)*((\+.*\n)+)( .*\n)+$/m) { # Copy the header from the original patch. my $newPatch = substr($patch, 0, index($patch, "@@ -${1},${2} +${3},${4} @@")); # Generate a new set of line numbers and patch lengths. Our new # patch will start with the lines for the fixed ChangeLog entry, # then have 3 lines of context from the top of the current file to # make the patch apply cleanly. $newPatch .= "@@ -1,3 +1," . ($4 - $2 + 3) . " @@\n"; # We assume that top few lines of the ChangeLog entry are actually # at the bottom of the list of added lines (due to the way the patch # algorithm works), so we simply search through the lines until we # find the date line, then move the rest of the lines to the top. my @patchLines = map { $_ . "\n" } split(/\n/, $6); foreach my $i (0 .. $#patchLines) { if ($patchLines[$i] =~ /^\+\d{4}-\d{2}-\d{2} /) { unshift(@patchLines, splice(@patchLines, $i, scalar(@patchLines) - $i)); last; } } $newPatch .= join("", @patchLines); # Add 3 lines of context to the end open FILE, "<", $file or die $!; for (my $i = 0; $i < 3; $i++) { $newPatch .= " " . ; } close FILE; # Apply the new patch open(PATCH, "| patch -p1 $file > " . File::Spec->devnull()) or die $!; print PATCH $newPatch; close(PATCH) or die $!; # Run "git add" on the fixed ChangeLog file system($GIT, "add", $file); die $! if WEXITSTATUS($?); showStatus($file, 1); } elsif ($patch) { # Restore the current copy of the ChangeLog file since we can't repatch it system($GIT, "checkout", $ENV{GIT_COMMIT} || "HEAD", $file); die $! if WEXITSTATUS($?); print STDERR "WARNING: Last change to ${file} could not be fixed and re-merged.\n" if $printWarnings; } } sub hasGitUnmergedFiles() { my $output = `$GIT ls-files --unmerged`; return $output ne ""; } sub isInGitFilterBranch() { return exists $ENV{MAPPED_PREVIOUS_COMMIT} && $ENV{MAPPED_PREVIOUS_COMMIT}; } sub mergeChanges($$$) { my ($fileMine, $fileOlder, $fileNewer) = @_; my $traditionalReject = $fileMine =~ /\.rej$/ ? 1 : 0; local $/ = undef; my $patch; if ($traditionalReject) { open(DIFF, "<", $fileMine) or die $!; $patch = ; close(DIFF); rename($fileMine, "$fileMine.save"); rename($fileOlder, "$fileOlder.save"); } else { open(DIFF, "-|", qw(diff -u -a --binary), $fileOlder, $fileMine) or die $!; $patch = ; close(DIFF); } unlink("${fileNewer}.orig"); unlink("${fileNewer}.rej"); open(PATCH, "| patch --fuzz=3 --binary $fileNewer > " . File::Spec->devnull()) or die $!; print PATCH fixChangeLogPatch($patch); close(PATCH); my $result; # Refuse to merge the patch if it did not apply cleanly if (-e "${fileNewer}.rej") { unlink("${fileNewer}.rej"); unlink($fileNewer); rename("${fileNewer}.orig", $fileNewer); $result = 0; } else { unlink("${fileNewer}.orig"); $result = 1; } if ($traditionalReject) { rename("$fileMine.save", $fileMine); rename("$fileOlder.save", $fileOlder); } return $result; } sub parseFixMerged($$;$) { my ($switchName, $key, $value) = @_; if (defined $key) { if (defined findChangeLog($key)) { unshift(@ARGV, $key); $fixMerged = ""; } else { $fixMerged = $key; } } else { $fixMerged = ""; } } sub removeChangeLogArguments($) { my ($baseDir) = @_; my @results = (); for (my $i = 0; $i < scalar(@ARGV); ) { my $file = findChangeLog(canonicalRelativePath(File::Spec->catfile($baseDir, $ARGV[$i]))); if (defined $file) { splice(@ARGV, $i, 1); push @results, $file; } else { $i++; } } return @results; } sub resolveChangeLog($) { my ($file) = @_; my ($fileMine, $fileOlder, $fileNewer) = conflictFiles($file); return unless $fileMine && $fileOlder && $fileNewer; if (mergeChanges($fileMine, $fileOlder, $fileNewer)) { if ($file ne $fileNewer) { unlink($file); rename($fileNewer, $file) or die $!; } unlink($fileMine, $fileOlder); resolveConflict($file); showStatus($file, 1); } else { showStatus($file); print STDERR "WARNING: ${file} could not be merged using fuzz level 3.\n" if $printWarnings; unlink($fileMine, $fileOlder, $fileNewer) if $isGit; } } sub resolveConflict($) { my ($file) = @_; if ($isSVN) { system($SVN, "resolved", $file); die $! if WEXITSTATUS($?); } elsif ($isGit) { system($GIT, "add", $file); die $! if WEXITSTATUS($?); } else { die "Unknown version control system"; } } sub showStatus($;$) { my ($file, $isConflictResolved) = @_; if ($isSVN) { system($SVN, "status", $file); } elsif ($isGit) { my @args = qw(--name-status); unshift @args, qw(--cached) if $isConflictResolved; system($GIT, "diff", @args, $file); } else { die "Unknown version control system"; } }