blob: 87517322a7f9c7077a93fd1754af9dd767efd6ce [file] [log] [blame]
#!/usr/bin/env perl
# 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 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 warnings;
use FindBin;
use lib $FindBin::Bin;
use File::Basename;
use File::Copy;
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 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 $mergeDriver = 0;
my $printWarnings = 1;
my $showHelp;
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
-m|--merge-driver %O %A %B act as a git merge-driver on files %O %A %B
-h|--help show this help message
-w|--[no-]warnings show or suppress warnings (default: show warnings)
__END__
exit 1;
}
my $getOptionsResult = GetOptions(
'c|continue!' => \$gitRebaseContinue,
'f|fix-merged:s' => \&parseFixMerged,
'm|merge-driver!' => \$mergeDriver,
'h|help' => \$showHelp,
'w|warnings!' => \$printWarnings,
);
if (!$getOptionsResult || $showHelp) {
usageAndExit();
}
my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot());
my @changeLogFiles = removeChangeLogArguments($relativePath);
if (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) {
@changeLogFiles = findUnmergedChangeLogs();
}
if (!$mergeDriver && scalar(@ARGV) > 0) {
print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n";
undef $getOptionsResult;
} elsif (!defined $fixMerged && !$mergeDriver && 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;
} elsif ($mergeDriver && !$isGit) {
print STDERR "ERROR: --merge-driver may only be used with a git repository\n";
undef $getOptionsResult;
} elsif ($mergeDriver && scalar(@ARGV) < 3) {
print STDERR "ERROR: --merge-driver expects %O %A %B as arguments\n";
undef $getOptionsResult;
}
if (!$getOptionsResult) {
usageAndExit();
}
if (defined $fixMerged && length($fixMerged) > 0) {
my $commitRange = $fixMerged;
$commitRange = $commitRange . "..HEAD" if index($commitRange, "..") < 0;
fixMergedChangeLogs($commitRange, @changeLogFiles);
} elsif ($mergeDriver) {
my ($base, $theirs, $ours) = @ARGV;
if (mergeChangeLogs($ours, $base, $theirs)) {
unlink($ours);
copy($theirs, $ours) or die $!;
} else {
exec qw(git merge-file -L THEIRS -L BASE -L OURS), $theirs, $base, $ours;
}
} 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) {
my $escapedFile = escapeSubversionPath($file);
open STAT, "-|", $SVN, "status", $escapedFile or die $!;
my $status = <STAT>;
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", $escapedFile or die $!;
while (my $line = <INFO>) {
if ($line =~ m/^Revision: ([0-9]+)/) {
$currentRevision = $1;
{ local $/ = undef; <INFO>; } # 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 = <GIT>) {
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 (<STAT>) {
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, <GIT>;
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 = <GIT>;
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 .= " " . <FILE>;
}
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 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 (mergeChangeLogs($fileMine, $fileOlder, $fileNewer)) {
if ($file ne $fileNewer) {
chmod 0644, $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) {
my $escapedFile = escapeSubversionPath($file);
system($SVN, "resolved", $escapedFile);
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) {
my $escapedFile = escapeSubversionPath($file);
system($SVN, "status", $escapedFile);
} elsif ($isGit) {
my @args = qw(--name-status);
unshift @args, qw(--cached) if $isConflictResolved;
system($GIT, "--no-pager", "diff", @args, $file);
} else {
die "Unknown version control system";
}
}