498 lines
16 KiB
Perl
Executable File
498 lines
16 KiB
Perl
Executable File
#!/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";
|
|
}
|
|
}
|
|
|