blob: fa01243588905579f968beeda248b9ea45bf4082 [file] [log] [blame]
#!/usr/bin/perl -w
# Copyright (C) 2007 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::Spec;
use Getopt::Long;
use VCSUtils;
sub conflictFiles($);
sub fixChangeLogPatch($);
sub mergeChanges($$$);
sub resolveConflict($);
sub showStatus($;$);
my $SVN = "svn";
my $GIT = "git";
my $printWarnings = 1;
my $showHelp;
my $getOptionsResult = GetOptions(
'h|help' => \$showHelp,
'w|warnings!' => \$printWarnings,
);
sub findChangeLog {
return $_ if basename($_) eq "ChangeLog";
my $file = File::Spec->catfile($_, "ChangeLog");
return $file if -d $_ and -e $file;
return undef;
}
my @changeLogFiles = grep { defined $_ } map { findChangeLog($_) } @ARGV;
if (scalar(@changeLogFiles) != scalar(@ARGV)) {
print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n";
undef $getOptionsResult;
} elsif (scalar(@changeLogFiles) == 0) {
print STDERR "ERROR: No ChangeLog files listed on command-line.\n";
undef $getOptionsResult;
}
if (!$getOptionsResult || $showHelp) {
print STDERR <<__END__;
Usage: @{[ basename($0) ]} [options] path/to/ChangeLog [path/to/another/ChangeLog ...]
-h|--help show this help message
-w|--[no-]warnings show or suppress warnings (default: show warnings)
__END__
exit 1;
}
for my $file (@changeLogFiles) {
my ($fileMine, $fileOlder, $fileNewer) = conflictFiles($file);
if (!$fileMine || !$fileOlder || !$fileNewer) {
next;
}
if (mergeChanges($fileMine, $fileOlder, $fileNewer)) {
if ($file ne $fileNewer) {
unlink($file);
rename($fileNewer, $file) || 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();
}
}
exit 0;
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 || 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", $file || die;
while (my $line = <INFO>) {
$currentRevision = $1 if $line =~ m/^Revision: ([0-9]+)/;
}
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 || 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");
}
close GIT;
} 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 fixChangeLogPatch($)
{
my $patch = shift;
my $contextLineCount = 3;
return $patch if $patch !~ /\n@@ -1,(\d+) \+1,(\d+) @@\n( .*\n)+(\+.*\n)+( .*\n){$contextLineCount}$/m;
my ($oldLineCount, $newLineCount) = ($1, $2);
return $patch if $oldLineCount <= $contextLineCount;
# The diff(1) command is greedy when matching lines, so a new ChangeLog entry will
# have lines of context at the top of a patch when the existing entry has the same
# date and author as the new entry. This nifty loop alters a ChangeLog patch so
# that the added lines ("+") in the patch always start at the beginning of the
# patch and there are no initial lines of context.
my $newPatch;
my $lineCountInState = 0;
my $oldContentLineCountReduction = $oldLineCount - $contextLineCount;
my $newContentLineCountWithoutContext = $newLineCount - $oldLineCount - $oldContentLineCountReduction;
my ($stateHeader, $statePreContext, $stateNewChanges, $statePostContext) = (1..4);
my $state = $stateHeader;
foreach my $line (split(/\n/, $patch)) {
$lineCountInState++;
if ($state == $stateHeader && $line =~ /^@@ -1,$oldLineCount \+1,$newLineCount @\@$/) {
$line = "@@ -1,$contextLineCount +1," . ($newLineCount - $oldContentLineCountReduction) . " @@";
$lineCountInState = 0;
$state = $statePreContext;
} elsif ($state == $statePreContext && substr($line, 0, 1) eq " ") {
$line = "+" . substr($line, 1);
if ($lineCountInState == $oldContentLineCountReduction) {
$lineCountInState = 0;
$state = $stateNewChanges;
}
} elsif ($state == $stateNewChanges && substr($line, 0, 1) eq "+") {
# No changes to these lines
if ($lineCountInState == $newContentLineCountWithoutContext) {
$lineCountInState = 0;
$state = $statePostContext;
}
} elsif ($state == $statePostContext) {
if (substr($line, 0, 1) eq "+" && $lineCountInState <= $oldContentLineCountReduction) {
$line = " " . substr($line, 1);
} elsif ($lineCountInState > $contextLineCount && substr($line, 0, 1) eq " ") {
next; # Discard
}
}
$newPatch .= $line . "\n";
}
return $newPatch;
}
sub mergeChanges($$$)
{
my ($fileMine, $fileOlder, $fileNewer) = @_;
my $traditionalReject = $fileMine =~ /\.rej$/ ? 1 : 0;
local $/ = undef;
my $patch;
if ($traditionalReject) {
open(DIFF, "<", $fileMine);
$patch = <DIFF>;
close(DIFF);
rename($fileMine, "$fileMine.save");
rename($fileOlder, "$fileOlder.save");
} else {
open(DIFF, "-|", qw(diff -u), $fileOlder, $fileMine) || die;
$patch = <DIFF>;
close(DIFF);
}
unlink("${fileNewer}.orig");
unlink("${fileNewer}.rej");
open(PATCH, "| patch --fuzz=3 $fileNewer > /dev/null") || 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 resolveConflict($)
{
my ($file) = @_;
if (isSVN()) {
system($SVN, "resolved", $file);
} elsif (isGit()) {
system($GIT, "add", $file);
} 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";
}
}