blob: 8353f2520a8671288733b19e72d297420c629287 [file] [log] [blame]
# Copyright (C) 2007, 2008, 2009 Apple Inc. All rights reserved.
# Copyright (C) 2009, 2010 Chris Jerdonek (chris.jerdonek@gmail.com)
# Copyright (C) 2010, 2011 Research In Motion Limited. 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.
# Module to share code to work with various version control systems.
package VCSUtils;
use strict;
use warnings;
use Cwd qw(); # "qw()" prevents warnings about redefining getcwd() with "use POSIX;"
use English; # for $POSTMATCH, etc.
use File::Basename;
use File::Spec;
use POSIX;
BEGIN {
use Exporter ();
our ($VERSION, @ISA, @EXPORT, @EXPORT_OK, %EXPORT_TAGS);
$VERSION = 1.00;
@ISA = qw(Exporter);
@EXPORT = qw(
&applyGitBinaryPatchDelta
&callSilently
&canonicalizePath
&changeLogEmailAddress
&changeLogName
&chdirReturningRelativePath
&decodeGitBinaryChunk
&decodeGitBinaryPatch
&determineSVNRoot
&determineVCSRoot
&exitStatus
&fixChangeLogPatch
&gitBranch
&gitdiff2svndiff
&isGit
&isGitSVN
&isGitBranchBuild
&isGitDirectory
&isSVN
&isSVNDirectory
&isSVNVersion16OrNewer
&makeFilePathRelative
&mergeChangeLogs
&normalizePath
&parseFirstEOL
&parsePatch
&pathRelativeToSVNRepositoryRootForPath
&prepareParsedPatch
&removeEOL
&runPatchCommand
&scmMoveOrRenameFile
&scmToggleExecutableBit
&setChangeLogDateAndReviewer
&svnRevisionForDirectory
&svnStatus
&toWindowsLineEndings
);
%EXPORT_TAGS = ( );
@EXPORT_OK = ();
}
our @EXPORT_OK;
my $gitBranch;
my $gitRoot;
my $isGit;
my $isGitSVN;
my $isGitBranchBuild;
my $isSVN;
my $svnVersion;
# Project time zone for Cupertino, CA, US
my $changeLogTimeZone = "PST8PDT";
my $chunkRangeRegEx = qr#^\@\@ -(\d+),(\d+) \+\d+,(\d+) \@\@$#; # e.g. @@ -2,6 +2,18 @@
my $gitDiffStartRegEx = qr#^diff --git (\w/)?(.+) (\w/)?([^\r\n]+)#;
my $svnDiffStartRegEx = qr#^Index: ([^\r\n]+)#;
my $svnPropertiesStartRegEx = qr#^Property changes on: ([^\r\n]+)#; # $1 is normally the same as the index path.
my $svnPropertyStartRegEx = qr#^(Modified|Name|Added|Deleted): ([^\r\n]+)#; # $2 is the name of the property.
my $svnPropertyValueStartRegEx = qr#^ (\+|-|Merged|Reverse-merged) ([^\r\n]+)#; # $2 is the start of the property's value (which may span multiple lines).
# This method is for portability. Return the system-appropriate exit
# status of a child process.
#
# Args: pass the child error status returned by the last pipe close,
# for example "$?".
sub exitStatus($)
{
my ($returnvalue) = @_;
if ($^O eq "MSWin32") {
return $returnvalue >> 8;
}
return WEXITSTATUS($returnvalue);
}
# Call a function while suppressing STDERR, and return the return values
# as an array.
sub callSilently($@) {
my ($func, @args) = @_;
# The following pattern was taken from here:
# http://www.sdsc.edu/~moreland/courses/IntroPerl/docs/manual/pod/perlfunc/open.html
#
# Also see this Perl documentation (search for "open OLDERR"):
# http://perldoc.perl.org/functions/open.html
open(OLDERR, ">&STDERR");
close(STDERR);
my @returnValue = &$func(@args);
open(STDERR, ">&OLDERR");
close(OLDERR);
return @returnValue;
}
sub toWindowsLineEndings
{
my ($text) = @_;
$text =~ s/\n/\r\n/g;
return $text;
}
# Note, this method will not error if the file corresponding to the $source path does not exist.
sub scmMoveOrRenameFile
{
my ($source, $destination) = @_;
return if ! -e $source;
if (isSVN()) {
system("svn", "move", $source, $destination);
} elsif (isGit()) {
system("git", "mv", $source, $destination);
}
}
# Note, this method will not error if the file corresponding to the path does not exist.
sub scmToggleExecutableBit
{
my ($path, $executableBitDelta) = @_;
return if ! -e $path;
if ($executableBitDelta == 1) {
scmAddExecutableBit($path);
} elsif ($executableBitDelta == -1) {
scmRemoveExecutableBit($path);
}
}
sub scmAddExecutableBit($)
{
my ($path) = @_;
if (isSVN()) {
system("svn", "propset", "svn:executable", "on", $path) == 0 or die "Failed to run 'svn propset svn:executable on $path'.";
} elsif (isGit()) {
chmod(0755, $path);
}
}
sub scmRemoveExecutableBit($)
{
my ($path) = @_;
if (isSVN()) {
system("svn", "propdel", "svn:executable", $path) == 0 or die "Failed to run 'svn propdel svn:executable $path'.";
} elsif (isGit()) {
chmod(0664, $path);
}
}
sub isGitDirectory($)
{
my ($dir) = @_;
return system("cd $dir && git rev-parse > " . File::Spec->devnull() . " 2>&1") == 0;
}
sub isGit()
{
return $isGit if defined $isGit;
$isGit = isGitDirectory(".");
return $isGit;
}
sub isGitSVN()
{
return $isGitSVN if defined $isGitSVN;
# There doesn't seem to be an officially documented way to determine
# if you're in a git-svn checkout. The best suggestions seen so far
# all use something like the following:
my $output = `git config --get svn-remote.svn.fetch 2>& 1`;
$isGitSVN = $output ne '';
return $isGitSVN;
}
sub gitBranch()
{
unless (defined $gitBranch) {
chomp($gitBranch = `git symbolic-ref -q HEAD`);
$gitBranch = "" if exitStatus($?);
$gitBranch =~ s#^refs/heads/##;
$gitBranch = "" if $gitBranch eq "master";
}
return $gitBranch;
}
sub isGitBranchBuild()
{
my $branch = gitBranch();
chomp(my $override = `git config --bool branch.$branch.webKitBranchBuild`);
return 1 if $override eq "true";
return 0 if $override eq "false";
unless (defined $isGitBranchBuild) {
chomp(my $gitBranchBuild = `git config --bool core.webKitBranchBuild`);
$isGitBranchBuild = $gitBranchBuild eq "true";
}
return $isGitBranchBuild;
}
sub isSVNDirectory($)
{
my ($dir) = @_;
return -d File::Spec->catdir($dir, ".svn");
}
sub isSVN()
{
return $isSVN if defined $isSVN;
$isSVN = isSVNDirectory(".");
return $isSVN;
}
sub svnVersion()
{
return $svnVersion if defined $svnVersion;
if (!isSVN()) {
$svnVersion = 0;
} else {
chomp($svnVersion = `svn --version --quiet`);
}
return $svnVersion;
}
sub isSVNVersion16OrNewer()
{
my $version = svnVersion();
return eval "v$version" ge v1.6;
}
sub chdirReturningRelativePath($)
{
my ($directory) = @_;
my $previousDirectory = Cwd::getcwd();
chdir $directory;
my $newDirectory = Cwd::getcwd();
return "." if $newDirectory eq $previousDirectory;
return File::Spec->abs2rel($previousDirectory, $newDirectory);
}
sub determineGitRoot()
{
chomp(my $gitDir = `git rev-parse --git-dir`);
return dirname($gitDir);
}
sub determineSVNRoot()
{
my $last = '';
my $path = '.';
my $parent = '..';
my $repositoryRoot;
my $repositoryUUID;
while (1) {
my $thisRoot;
my $thisUUID;
# Ignore error messages in case we've run past the root of the checkout.
open INFO, "svn info '$path' 2> " . File::Spec->devnull() . " |" or die;
while (<INFO>) {
if (/^Repository Root: (.+)/) {
$thisRoot = $1;
}
if (/^Repository UUID: (.+)/) {
$thisUUID = $1;
}
if ($thisRoot && $thisUUID) {
local $/ = undef;
<INFO>; # Consume the rest of the input.
}
}
close INFO;
# It's possible (e.g. for developers of some ports) to have a WebKit
# checkout in a subdirectory of another checkout. So abort if the
# repository root or the repository UUID suddenly changes.
last if !$thisUUID;
$repositoryUUID = $thisUUID if !$repositoryUUID;
last if $thisUUID ne $repositoryUUID;
last if !$thisRoot;
$repositoryRoot = $thisRoot if !$repositoryRoot;
last if $thisRoot ne $repositoryRoot;
$last = $path;
$path = File::Spec->catdir($parent, $path);
}
return File::Spec->rel2abs($last);
}
sub determineVCSRoot()
{
if (isGit()) {
return determineGitRoot();
}
if (!isSVN()) {
# Some users have a workflow where svn-create-patch, svn-apply and
# svn-unapply are used outside of multiple svn working directores,
# so warn the user and assume Subversion is being used in this case.
warn "Unable to determine VCS root; assuming Subversion";
$isSVN = 1;
}
return determineSVNRoot();
}
sub svnRevisionForDirectory($)
{
my ($dir) = @_;
my $revision;
if (isSVNDirectory($dir)) {
my $svnInfo = `LC_ALL=C svn info $dir | grep Revision:`;
($revision) = ($svnInfo =~ m/Revision: (\d+).*/g);
} elsif (isGitDirectory($dir)) {
my $gitLog = `cd $dir && LC_ALL=C git log --grep='git-svn-id: ' -n 1 | grep git-svn-id:`;
($revision) = ($gitLog =~ m/ +git-svn-id: .+@(\d+) /g);
}
die "Unable to determine current SVN revision in $dir" unless (defined $revision);
return $revision;
}
sub pathRelativeToSVNRepositoryRootForPath($)
{
my ($file) = @_;
my $relativePath = File::Spec->abs2rel($file);
my $svnInfo;
if (isSVN()) {
$svnInfo = `LC_ALL=C svn info $relativePath`;
} elsif (isGit()) {
$svnInfo = `LC_ALL=C git svn info $relativePath`;
}
$svnInfo =~ /.*^URL: (.*?)$/m;
my $svnURL = $1;
$svnInfo =~ /.*^Repository Root: (.*?)$/m;
my $repositoryRoot = $1;
$svnURL =~ s/$repositoryRoot\///;
return $svnURL;
}
sub makeFilePathRelative($)
{
my ($path) = @_;
return $path unless isGit();
unless (defined $gitRoot) {
chomp($gitRoot = `git rev-parse --show-cdup`);
}
return $gitRoot . $path;
}
sub normalizePath($)
{
my ($path) = @_;
$path =~ s/\\/\//g;
return $path;
}
sub adjustPathForRecentRenamings($)
{
my ($fullPath) = @_;
if ($fullPath =~ m|^WebCore/|
|| $fullPath =~ m|^JavaScriptCore/|
|| $fullPath =~ m|^WebKit/|
|| $fullPath =~ m|^WebKit2/|) {
return "Source/$fullPath";
}
return $fullPath;
}
sub canonicalizePath($)
{
my ($file) = @_;
# Remove extra slashes and '.' directories in path
$file = File::Spec->canonpath($file);
# Remove '..' directories in path
my @dirs = ();
foreach my $dir (File::Spec->splitdir($file)) {
if ($dir eq '..' && $#dirs >= 0 && $dirs[$#dirs] ne '..') {
pop(@dirs);
} else {
push(@dirs, $dir);
}
}
return ($#dirs >= 0) ? File::Spec->catdir(@dirs) : ".";
}
sub removeEOL($)
{
my ($line) = @_;
return "" unless $line;
$line =~ s/[\r\n]+$//g;
return $line;
}
sub parseFirstEOL($)
{
my ($fileHandle) = @_;
# Make input record separator the new-line character to simplify regex matching below.
my $savedInputRecordSeparator = $INPUT_RECORD_SEPARATOR;
$INPUT_RECORD_SEPARATOR = "\n";
my $firstLine = <$fileHandle>;
$INPUT_RECORD_SEPARATOR = $savedInputRecordSeparator;
return unless defined($firstLine);
my $eol;
if ($firstLine =~ /\r\n/) {
$eol = "\r\n";
} elsif ($firstLine =~ /\r/) {
$eol = "\r";
} elsif ($firstLine =~ /\n/) {
$eol = "\n";
}
return $eol;
}
sub firstEOLInFile($)
{
my ($file) = @_;
my $eol;
if (open(FILE, $file)) {
$eol = parseFirstEOL(*FILE);
close(FILE);
}
return $eol;
}
sub svnStatus($)
{
my ($fullPath) = @_;
my $svnStatus;
open SVN, "svn status --non-interactive --non-recursive '$fullPath' |" or die;
if (-d $fullPath) {
# When running "svn stat" on a directory, we can't assume that only one
# status will be returned (since any files with a status below the
# directory will be returned), and we can't assume that the directory will
# be first (since any files with unknown status will be listed first).
my $normalizedFullPath = File::Spec->catdir(File::Spec->splitdir($fullPath));
while (<SVN>) {
# Input may use a different EOL sequence than $/, so avoid chomp.
$_ = removeEOL($_);
my $normalizedStatPath = File::Spec->catdir(File::Spec->splitdir(substr($_, 7)));
if ($normalizedFullPath eq $normalizedStatPath) {
$svnStatus = "$_\n";
last;
}
}
# Read the rest of the svn command output to avoid a broken pipe warning.
local $/ = undef;
<SVN>;
}
else {
# Files will have only one status returned.
$svnStatus = removeEOL(<SVN>) . "\n";
}
close SVN;
return $svnStatus;
}
# Return whether the given file mode is executable in the source control
# sense. We make this determination based on whether the executable bit
# is set for "others" rather than the stronger condition that it be set
# for the user, group, and others. This is sufficient for distinguishing
# the default behavior in Git and SVN.
#
# Args:
# $fileMode: A number or string representing a file mode in octal notation.
sub isExecutable($)
{
my $fileMode = shift;
return $fileMode % 2;
}
# Parse the next Git diff header from the given file handle, and advance
# the handle so the last line read is the first line after the header.
#
# This subroutine dies if given leading junk.
#
# Args:
# $fileHandle: advanced so the last line read from the handle is the first
# line of the header to parse. This should be a line
# beginning with "diff --git".
# $line: the line last read from $fileHandle
#
# Returns ($headerHashRef, $lastReadLine):
# $headerHashRef: a hash reference representing a diff header, as follows--
# copiedFromPath: the path from which the file was copied or moved if
# the diff is a copy or move.
# executableBitDelta: the value 1 or -1 if the executable bit was added or
# removed, respectively. New and deleted files have
# this value only if the file is executable, in which
# case the value is 1 and -1, respectively.
# indexPath: the path of the target file.
# isBinary: the value 1 if the diff is for a binary file.
# isDeletion: the value 1 if the diff is a file deletion.
# isCopyWithChanges: the value 1 if the file was copied or moved and
# the target file was changed in some way after being
# copied or moved (e.g. if its contents or executable
# bit were changed).
# isNew: the value 1 if the diff is for a new file.
# shouldDeleteSource: the value 1 if the file was copied or moved and
# the source file was deleted -- i.e. if the copy
# was actually a move.
# svnConvertedText: the header text with some lines converted to SVN
# format. Git-specific lines are preserved.
# $lastReadLine: the line last read from $fileHandle.
sub parseGitDiffHeader($$)
{
my ($fileHandle, $line) = @_;
$_ = $line;
my $indexPath;
if (/$gitDiffStartRegEx/) {
# The first and second paths can differ in the case of copies
# and renames. We use the second file path because it is the
# destination path.
$indexPath = adjustPathForRecentRenamings($4);
# Use $POSTMATCH to preserve the end-of-line character.
$_ = "Index: $indexPath$POSTMATCH"; # Convert to SVN format.
} else {
die("Could not parse leading \"diff --git\" line: \"$line\".");
}
my $copiedFromPath;
my $foundHeaderEnding;
my $isBinary;
my $isDeletion;
my $isNew;
my $newExecutableBit = 0;
my $oldExecutableBit = 0;
my $shouldDeleteSource = 0;
my $similarityIndex = 0;
my $svnConvertedText;
while (1) {
# Temporarily strip off any end-of-line characters to simplify
# regex matching below.
s/([\n\r]+)$//;
my $eol = $1;
if (/^(deleted file|old) mode (\d+)/) {
$oldExecutableBit = (isExecutable($2) ? 1 : 0);
$isDeletion = 1 if $1 eq "deleted file";
} elsif (/^new( file)? mode (\d+)/) {
$newExecutableBit = (isExecutable($2) ? 1 : 0);
$isNew = 1 if $1;
} elsif (/^similarity index (\d+)%/) {
$similarityIndex = $1;
} elsif (/^copy from (\S+)/) {
$copiedFromPath = $1;
} elsif (/^rename from (\S+)/) {
# FIXME: Record this as a move rather than as a copy-and-delete.
# This will simplify adding rename support to svn-unapply.
# Otherwise, the hash for a deletion would have to know
# everything about the file being deleted in order to
# support undoing itself. Recording as a move will also
# permit us to use "svn move" and "git move".
$copiedFromPath = $1;
$shouldDeleteSource = 1;
} elsif (/^--- \S+/) {
$_ = "--- $indexPath"; # Convert to SVN format.
} elsif (/^\+\+\+ \S+/) {
$_ = "+++ $indexPath"; # Convert to SVN format.
$foundHeaderEnding = 1;
} elsif (/^GIT binary patch$/ ) {
$isBinary = 1;
$foundHeaderEnding = 1;
# The "git diff" command includes a line of the form "Binary files
# <path1> and <path2> differ" if the --binary flag is not used.
} elsif (/^Binary files / ) {
die("Error: the Git diff contains a binary file without the binary data in ".
"line: \"$_\". Be sure to use the --binary flag when invoking \"git diff\" ".
"with diffs containing binary files.");
}
$svnConvertedText .= "$_$eol"; # Also restore end-of-line characters.
$_ = <$fileHandle>; # Not defined if end-of-file reached.
last if (!defined($_) || /$gitDiffStartRegEx/ || $foundHeaderEnding);
}
my $executableBitDelta = $newExecutableBit - $oldExecutableBit;
my %header;
$header{copiedFromPath} = $copiedFromPath if $copiedFromPath;
$header{executableBitDelta} = $executableBitDelta if $executableBitDelta;
$header{indexPath} = $indexPath;
$header{isBinary} = $isBinary if $isBinary;
$header{isCopyWithChanges} = 1 if ($copiedFromPath && ($similarityIndex != 100 || $executableBitDelta));
$header{isDeletion} = $isDeletion if $isDeletion;
$header{isNew} = $isNew if $isNew;
$header{shouldDeleteSource} = $shouldDeleteSource if $shouldDeleteSource;
$header{svnConvertedText} = $svnConvertedText;
return (\%header, $_);
}
# Parse the next SVN diff header from the given file handle, and advance
# the handle so the last line read is the first line after the header.
#
# This subroutine dies if given leading junk or if it could not detect
# the end of the header block.
#
# Args:
# $fileHandle: advanced so the last line read from the handle is the first
# line of the header to parse. This should be a line
# beginning with "Index:".
# $line: the line last read from $fileHandle
#
# Returns ($headerHashRef, $lastReadLine):
# $headerHashRef: a hash reference representing a diff header, as follows--
# copiedFromPath: the path from which the file was copied if the diff
# is a copy.
# indexPath: the path of the target file, which is the path found in
# the "Index:" line.
# isBinary: the value 1 if the diff is for a binary file.
# isNew: the value 1 if the diff is for a new file.
# sourceRevision: the revision number of the source, if it exists. This
# is the same as the revision number the file was copied
# from, in the case of a file copy.
# svnConvertedText: the header text converted to a header with the paths
# in some lines corrected.
# $lastReadLine: the line last read from $fileHandle.
sub parseSvnDiffHeader($$)
{
my ($fileHandle, $line) = @_;
$_ = $line;
my $indexPath;
if (/$svnDiffStartRegEx/) {
$indexPath = adjustPathForRecentRenamings($1);
} else {
die("First line of SVN diff does not begin with \"Index \": \"$_\"");
}
my $copiedFromPath;
my $foundHeaderEnding;
my $isBinary;
my $isNew;
my $sourceRevision;
my $svnConvertedText;
while (1) {
# Temporarily strip off any end-of-line characters to simplify
# regex matching below.
s/([\n\r]+)$//;
my $eol = $1;
# Fix paths on ""---" and "+++" lines to match the leading
# index line.
if (s/^--- \S+/--- $indexPath/) {
# ---
if (/^--- .+\(revision (\d+)\)/) {
$sourceRevision = $1;
$isNew = 1 if !$sourceRevision; # if revision 0.
if (/\(from (\S+):(\d+)\)$/) {
# The "from" clause is created by svn-create-patch, in
# which case there is always also a "revision" clause.
$copiedFromPath = $1;
die("Revision number \"$2\" in \"from\" clause does not match " .
"source revision number \"$sourceRevision\".") if ($2 != $sourceRevision);
}
}
} elsif (s/^\+\+\+ \S+/+++ $indexPath/) {
$foundHeaderEnding = 1;
} elsif (/^Cannot display: file marked as a binary type.$/) {
$isBinary = 1;
$foundHeaderEnding = 1;
}
$svnConvertedText .= "$_$eol"; # Also restore end-of-line characters.
$_ = <$fileHandle>; # Not defined if end-of-file reached.
last if (!defined($_) || /$svnDiffStartRegEx/ || $foundHeaderEnding);
}
if (!$foundHeaderEnding) {
die("Did not find end of header block corresponding to index path \"$indexPath\".");
}
my %header;
$header{copiedFromPath} = $copiedFromPath if $copiedFromPath;
$header{indexPath} = $indexPath;
$header{isBinary} = $isBinary if $isBinary;
$header{isNew} = $isNew if $isNew;
$header{sourceRevision} = $sourceRevision if $sourceRevision;
$header{svnConvertedText} = $svnConvertedText;
return (\%header, $_);
}
# Parse the next diff header from the given file handle, and advance
# the handle so the last line read is the first line after the header.
#
# This subroutine dies if given leading junk or if it could not detect
# the end of the header block.
#
# Args:
# $fileHandle: advanced so the last line read from the handle is the first
# line of the header to parse. For SVN-formatted diffs, this
# is a line beginning with "Index:". For Git, this is a line
# beginning with "diff --git".
# $line: the line last read from $fileHandle
#
# Returns ($headerHashRef, $lastReadLine):
# $headerHashRef: a hash reference representing a diff header
# copiedFromPath: the path from which the file was copied if the diff
# is a copy.
# executableBitDelta: the value 1 or -1 if the executable bit was added or
# removed, respectively. New and deleted files have
# this value only if the file is executable, in which
# case the value is 1 and -1, respectively.
# indexPath: the path of the target file.
# isBinary: the value 1 if the diff is for a binary file.
# isGit: the value 1 if the diff is Git-formatted.
# isSvn: the value 1 if the diff is SVN-formatted.
# sourceRevision: the revision number of the source, if it exists. This
# is the same as the revision number the file was copied
# from, in the case of a file copy.
# svnConvertedText: the header text with some lines converted to SVN
# format. Git-specific lines are preserved.
# $lastReadLine: the line last read from $fileHandle.
sub parseDiffHeader($$)
{
my ($fileHandle, $line) = @_;
my $header; # This is a hash ref.
my $isGit;
my $isSvn;
my $lastReadLine;
if ($line =~ $svnDiffStartRegEx) {
$isSvn = 1;
($header, $lastReadLine) = parseSvnDiffHeader($fileHandle, $line);
} elsif ($line =~ $gitDiffStartRegEx) {
$isGit = 1;
($header, $lastReadLine) = parseGitDiffHeader($fileHandle, $line);
} else {
die("First line of diff does not begin with \"Index:\" or \"diff --git\": \"$line\"");
}
$header->{isGit} = $isGit if $isGit;
$header->{isSvn} = $isSvn if $isSvn;
return ($header, $lastReadLine);
}
# FIXME: The %diffHash "object" should not have an svnConvertedText property.
# Instead, the hash object should store its information in a
# structured way as properties. This should be done in a way so
# that, if necessary, the text of an SVN or Git patch can be
# reconstructed from the information in those hash properties.
#
# A %diffHash is a hash representing a source control diff of a single
# file operation (e.g. a file modification, copy, or delete).
#
# These hashes appear, for example, in the parseDiff(), parsePatch(),
# and prepareParsedPatch() subroutines of this package.
#
# The corresponding values are--
#
# copiedFromPath: the path from which the file was copied if the diff
# is a copy.
# executableBitDelta: the value 1 or -1 if the executable bit was added or
# removed from the target file, respectively.
# indexPath: the path of the target file. For SVN-formatted diffs,
# this is the same as the path in the "Index:" line.
# isBinary: the value 1 if the diff is for a binary file.
# isDeletion: the value 1 if the diff is known from the header to be a deletion.
# isGit: the value 1 if the diff is Git-formatted.
# isNew: the value 1 if the dif is known from the header to be a new file.
# isSvn: the value 1 if the diff is SVN-formatted.
# sourceRevision: the revision number of the source, if it exists. This
# is the same as the revision number the file was copied
# from, in the case of a file copy.
# svnConvertedText: the diff with some lines converted to SVN format.
# Git-specific lines are preserved.
# Parse one diff from a patch file created by svn-create-patch, and
# advance the file handle so the last line read is the first line
# of the next header block.
#
# This subroutine preserves any leading junk encountered before the header.
#
# Composition of an SVN diff
#
# There are three parts to an SVN diff: the header, the property change, and
# the binary contents, in that order. Either the header or the property change
# may be ommitted, but not both. If there are binary changes, then you always
# have all three.
#
# Args:
# $fileHandle: a file handle advanced to the first line of the next
# header block. Leading junk is okay.
# $line: the line last read from $fileHandle.
# $optionsHashRef: a hash reference representing optional options to use
# when processing a diff.
# shouldNotUseIndexPathEOL: whether to use the line endings in the diff instead
# instead of the line endings in the target file; the
# value of 1 if svnConvertedText should use the line
# endings in the diff.
#
# Returns ($diffHashRefs, $lastReadLine):
# $diffHashRefs: A reference to an array of references to %diffHash hashes.
# See the %diffHash documentation above.
# $lastReadLine: the line last read from $fileHandle
sub parseDiff($$;$)
{
# FIXME: Adjust this method so that it dies if the first line does not
# match the start of a diff. This will require a change to
# parsePatch() so that parsePatch() skips over leading junk.
my ($fileHandle, $line, $optionsHashRef) = @_;
my $headerStartRegEx = $svnDiffStartRegEx; # SVN-style header for the default
my $headerHashRef; # Last header found, as returned by parseDiffHeader().
my $svnPropertiesHashRef; # Last SVN properties diff found, as returned by parseSvnDiffProperties().
my $svnText;
my $indexPathEOL;
while (defined($line)) {
if (!$headerHashRef && ($line =~ $gitDiffStartRegEx)) {
# Then assume all diffs in the patch are Git-formatted. This
# block was made to be enterable at most once since we assume
# all diffs in the patch are formatted the same (SVN or Git).
$headerStartRegEx = $gitDiffStartRegEx;
}
if ($line =~ $svnPropertiesStartRegEx) {
my $propertyPath = $1;
if ($svnPropertiesHashRef || $headerHashRef && ($propertyPath ne $headerHashRef->{indexPath})) {
# This is the start of the second diff in the while loop, which happens to
# be a property diff. If $svnPropertiesHasRef is defined, then this is the
# second consecutive property diff, otherwise it's the start of a property
# diff for a file that only has property changes.
last;
}
($svnPropertiesHashRef, $line) = parseSvnDiffProperties($fileHandle, $line);
next;
}
if ($line !~ $headerStartRegEx) {
# Then we are in the body of the diff.
if ($indexPathEOL && $line !~ /$chunkRangeRegEx/) {
# The chunk range is part of the body of the diff, but its line endings should't be
# modified or patch(1) will complain. So, we only modify non-chunk range lines.
$line =~ s/\r\n|\r|\n/$indexPathEOL/g;
}
$svnText .= $line;
$line = <$fileHandle>;
next;
} # Otherwise, we found a diff header.
if ($svnPropertiesHashRef || $headerHashRef) {
# Then either we just processed an SVN property change or this
# is the start of the second diff header of this while loop.
last;
}
($headerHashRef, $line) = parseDiffHeader($fileHandle, $line);
if (!$optionsHashRef || !$optionsHashRef->{shouldNotUseIndexPathEOL}) {
$indexPathEOL = firstEOLInFile($headerHashRef->{indexPath}) if !$headerHashRef->{isNew} && !$headerHashRef->{isBinary};
}
$svnText .= $headerHashRef->{svnConvertedText};
}
my @diffHashRefs;
if ($headerHashRef->{shouldDeleteSource}) {
my %deletionHash;
$deletionHash{indexPath} = $headerHashRef->{copiedFromPath};
$deletionHash{isDeletion} = 1;
push @diffHashRefs, \%deletionHash;
}
if ($headerHashRef->{copiedFromPath}) {
my %copyHash;
$copyHash{copiedFromPath} = $headerHashRef->{copiedFromPath};
$copyHash{indexPath} = $headerHashRef->{indexPath};
$copyHash{sourceRevision} = $headerHashRef->{sourceRevision} if $headerHashRef->{sourceRevision};
if ($headerHashRef->{isSvn}) {
$copyHash{executableBitDelta} = $svnPropertiesHashRef->{executableBitDelta} if $svnPropertiesHashRef->{executableBitDelta};
}
push @diffHashRefs, \%copyHash;
}
# Note, the order of evaluation for the following if conditional has been explicitly chosen so that
# it evaluates to false when there is no headerHashRef (e.g. a property change diff for a file that
# only has property changes).
if ($headerHashRef->{isCopyWithChanges} || (%$headerHashRef && !$headerHashRef->{copiedFromPath})) {
# Then add the usual file modification.
my %diffHash;
# FIXME: We should expand this code to support other properties. In the future,
# parseSvnDiffProperties may return a hash whose keys are the properties.
if ($headerHashRef->{isSvn}) {
# SVN records the change to the executable bit in a separate property change diff
# that follows the contents of the diff, except for binary diffs. For binary
# diffs, the property change diff follows the diff header.
$diffHash{executableBitDelta} = $svnPropertiesHashRef->{executableBitDelta} if $svnPropertiesHashRef->{executableBitDelta};
} elsif ($headerHashRef->{isGit}) {
# Git records the change to the executable bit in the header of a diff.
$diffHash{executableBitDelta} = $headerHashRef->{executableBitDelta} if $headerHashRef->{executableBitDelta};
}
$diffHash{indexPath} = $headerHashRef->{indexPath};
$diffHash{isBinary} = $headerHashRef->{isBinary} if $headerHashRef->{isBinary};
$diffHash{isDeletion} = $headerHashRef->{isDeletion} if $headerHashRef->{isDeletion};
$diffHash{isGit} = $headerHashRef->{isGit} if $headerHashRef->{isGit};
$diffHash{isNew} = $headerHashRef->{isNew} if $headerHashRef->{isNew};
$diffHash{isSvn} = $headerHashRef->{isSvn} if $headerHashRef->{isSvn};
if (!$headerHashRef->{copiedFromPath}) {
# If the file was copied, then we have already incorporated the
# sourceRevision information into the change.
$diffHash{sourceRevision} = $headerHashRef->{sourceRevision} if $headerHashRef->{sourceRevision};
}
# FIXME: Remove the need for svnConvertedText. See the %diffHash
# code comments above for more information.
#
# Note, we may not always have SVN converted text since we intend
# to deprecate it in the future. For example, a property change
# diff for a file that only has property changes will not return
# any SVN converted text.
$diffHash{svnConvertedText} = $svnText if $svnText;
push @diffHashRefs, \%diffHash;
}
if (!%$headerHashRef && $svnPropertiesHashRef) {
# A property change diff for a file that only has property changes.
my %propertyChangeHash;
$propertyChangeHash{executableBitDelta} = $svnPropertiesHashRef->{executableBitDelta} if $svnPropertiesHashRef->{executableBitDelta};
$propertyChangeHash{indexPath} = $svnPropertiesHashRef->{propertyPath};
$propertyChangeHash{isSvn} = 1;
push @diffHashRefs, \%propertyChangeHash;
}
return (\@diffHashRefs, $line);
}
# Parse an SVN property change diff from the given file handle, and advance
# the handle so the last line read is the first line after this diff.
#
# For the case of an SVN binary diff, the binary contents will follow the
# the property changes.
#
# This subroutine dies if the first line does not begin with "Property changes on"
# or if the separator line that follows this line is missing.
#
# Args:
# $fileHandle: advanced so the last line read from the handle is the first
# line of the footer to parse. This line begins with
# "Property changes on".
# $line: the line last read from $fileHandle.
#
# Returns ($propertyHashRef, $lastReadLine):
# $propertyHashRef: a hash reference representing an SVN diff footer.
# propertyPath: the path of the target file.
# executableBitDelta: the value 1 or -1 if the executable bit was added or
# removed from the target file, respectively.
# $lastReadLine: the line last read from $fileHandle.
sub parseSvnDiffProperties($$)
{
my ($fileHandle, $line) = @_;
$_ = $line;
my %footer;
if (/$svnPropertiesStartRegEx/) {
$footer{propertyPath} = $1;
} else {
die("Failed to find start of SVN property change, \"Property changes on \": \"$_\"");
}
# We advance $fileHandle two lines so that the next line that
# we process is $svnPropertyStartRegEx in a well-formed footer.
# A well-formed footer has the form:
# Property changes on: FileA
# ___________________________________________________________________
# Added: svn:executable
# + *
$_ = <$fileHandle>; # Not defined if end-of-file reached.
my $separator = "_" x 67;
if (defined($_) && /^$separator[\r\n]+$/) {
$_ = <$fileHandle>;
} else {
die("Failed to find separator line: \"$_\".");
}
# FIXME: We should expand this to support other SVN properties
# (e.g. return a hash of property key-values that represents
# all properties).
#
# Notice, we keep processing until we hit end-of-file or some
# line that does not resemble $svnPropertyStartRegEx, such as
# the empty line that precedes the start of the binary contents
# of a patch, or the start of the next diff (e.g. "Index:").
my $propertyHashRef;
while (defined($_) && /$svnPropertyStartRegEx/) {
($propertyHashRef, $_) = parseSvnProperty($fileHandle, $_);
if ($propertyHashRef->{name} eq "svn:executable") {
# Notice, for SVN properties, propertyChangeDelta is always non-zero
# because a property can only be added or removed.
$footer{executableBitDelta} = $propertyHashRef->{propertyChangeDelta};
}
}
return(\%footer, $_);
}
# Parse the next SVN property from the given file handle, and advance the handle so the last
# line read is the first line after the property.
#
# This subroutine dies if the first line is not a valid start of an SVN property,
# or the property is missing a value, or the property change type (e.g. "Added")
# does not correspond to the property value type (e.g. "+").
#
# Args:
# $fileHandle: advanced so the last line read from the handle is the first
# line of the property to parse. This should be a line
# that matches $svnPropertyStartRegEx.
# $line: the line last read from $fileHandle.
#
# Returns ($propertyHashRef, $lastReadLine):
# $propertyHashRef: a hash reference representing a SVN property.
# name: the name of the property.
# value: the last property value. For instance, suppose the property is "Modified".
# Then it has both a '-' and '+' property value in that order. Therefore,
# the value of this key is the value of the '+' property by ordering (since
# it is the last value).
# propertyChangeDelta: the value 1 or -1 if the property was added or
# removed, respectively.
# $lastReadLine: the line last read from $fileHandle.
sub parseSvnProperty($$)
{
my ($fileHandle, $line) = @_;
$_ = $line;
my $propertyName;
my $propertyChangeType;
if (/$svnPropertyStartRegEx/) {
$propertyChangeType = $1;
$propertyName = $2;
} else {
die("Failed to find SVN property: \"$_\".");
}
$_ = <$fileHandle>; # Not defined if end-of-file reached.
# The "svn diff" command neither inserts newline characters between property values
# nor between successive properties.
#
# FIXME: We do not support property values that contain tailing newline characters
# as it is difficult to disambiguate these trailing newlines from the empty
# line that precedes the contents of a binary patch.
my $propertyValue;
my $propertyValueType;
while (defined($_) && /$svnPropertyValueStartRegEx/) {
# Note, a '-' property may be followed by a '+' property in the case of a "Modified"
# or "Name" property. We only care about the ending value (i.e. the '+' property)
# in such circumstances. So, we take the property value for the property to be its
# last parsed property value.
#
# FIXME: We may want to consider strictly enforcing a '-', '+' property ordering or
# add error checking to prevent '+', '+', ..., '+' and other invalid combinations.
$propertyValueType = $1;
($propertyValue, $_) = parseSvnPropertyValue($fileHandle, $_);
}
if (!$propertyValue) {
die("Failed to find the property value for the SVN property \"$propertyName\": \"$_\".");
}
my $propertyChangeDelta;
if ($propertyValueType eq "+" || $propertyValueType eq "Merged") {
$propertyChangeDelta = 1;
} elsif ($propertyValueType eq "-" || $propertyValueType eq "Reverse-merged") {
$propertyChangeDelta = -1;
} else {
die("Not reached.");
}
# We perform a simple validation that an "Added" or "Deleted" property
# change type corresponds with a "+" and "-" value type, respectively.
my $expectedChangeDelta;
if ($propertyChangeType eq "Added") {
$expectedChangeDelta = 1;
} elsif ($propertyChangeType eq "Deleted") {
$expectedChangeDelta = -1;
}
if ($expectedChangeDelta && $propertyChangeDelta != $expectedChangeDelta) {
die("The final property value type found \"$propertyValueType\" does not " .
"correspond to the property change type found \"$propertyChangeType\".");
}
my %propertyHash;
$propertyHash{name} = $propertyName;
$propertyHash{propertyChangeDelta} = $propertyChangeDelta;
$propertyHash{value} = $propertyValue;
return (\%propertyHash, $_);
}
# Parse the value of an SVN property from the given file handle, and advance
# the handle so the last line read is the first line after the property value.
#
# This subroutine dies if the first line is an invalid SVN property value line
# (i.e. a line that does not begin with " +" or " -").
#
# Args:
# $fileHandle: advanced so the last line read from the handle is the first
# line of the property value to parse. This should be a line
# beginning with " +" or " -".
# $line: the line last read from $fileHandle.
#
# Returns ($propertyValue, $lastReadLine):
# $propertyValue: the value of the property.
# $lastReadLine: the line last read from $fileHandle.
sub parseSvnPropertyValue($$)
{
my ($fileHandle, $line) = @_;
$_ = $line;
my $propertyValue;
my $eol;
if (/$svnPropertyValueStartRegEx/) {
$propertyValue = $2; # Does not include the end-of-line character(s).
$eol = $POSTMATCH;
} else {
die("Failed to find property value beginning with '+', '-', 'Merged', or 'Reverse-merged': \"$_\".");
}
while (<$fileHandle>) {
if (/^[\r\n]+$/ || /$svnPropertyValueStartRegEx/ || /$svnPropertyStartRegEx/) {
# Note, we may encounter an empty line before the contents of a binary patch.
# Also, we check for $svnPropertyValueStartRegEx because a '-' property may be
# followed by a '+' property in the case of a "Modified" or "Name" property.
# We check for $svnPropertyStartRegEx because it indicates the start of the
# next property to parse.
last;
}
# Temporarily strip off any end-of-line characters. We add the end-of-line characters
# from the previously processed line to the start of this line so that the last line
# of the property value does not end in end-of-line characters.
s/([\n\r]+)$//;
$propertyValue .= "$eol$_";
$eol = $1;
}
return ($propertyValue, $_);
}
# Parse a patch file created by svn-create-patch.
#
# Args:
# $fileHandle: A file handle to the patch file that has not yet been
# read from.
# $optionsHashRef: a hash reference representing optional options to use
# when processing a diff.
# shouldNotUseIndexPathEOL: whether to use the line endings in the diff instead
# instead of the line endings in the target file; the
# value of 1 if svnConvertedText should use the line
# endings in the diff.
#
# Returns:
# @diffHashRefs: an array of diff hash references.
# See the %diffHash documentation above.
sub parsePatch($;$)
{
my ($fileHandle, $optionsHashRef) = @_;
my $newDiffHashRefs;
my @diffHashRefs; # return value
my $line = <$fileHandle>;
while (defined($line)) { # Otherwise, at EOF.
($newDiffHashRefs, $line) = parseDiff($fileHandle, $line, $optionsHashRef);
push @diffHashRefs, @$newDiffHashRefs;
}
return @diffHashRefs;
}
# Prepare the results of parsePatch() for use in svn-apply and svn-unapply.
#
# Args:
# $shouldForce: Whether to continue processing if an unexpected
# state occurs.
# @diffHashRefs: An array of references to %diffHashes.
# See the %diffHash documentation above.
#
# Returns $preparedPatchHashRef:
# copyDiffHashRefs: A reference to an array of the $diffHashRefs in
# @diffHashRefs that represent file copies. The original
# ordering is preserved.
# nonCopyDiffHashRefs: A reference to an array of the $diffHashRefs in
# @diffHashRefs that do not represent file copies.
# The original ordering is preserved.
# sourceRevisionHash: A reference to a hash of source path to source
# revision number.
sub prepareParsedPatch($@)
{
my ($shouldForce, @diffHashRefs) = @_;
my %copiedFiles;
# Return values
my @copyDiffHashRefs = ();
my @nonCopyDiffHashRefs = ();
my %sourceRevisionHash = ();
for my $diffHashRef (@diffHashRefs) {
my $copiedFromPath = $diffHashRef->{copiedFromPath};
my $indexPath = $diffHashRef->{indexPath};
my $sourceRevision = $diffHashRef->{sourceRevision};
my $sourcePath;
if (defined($copiedFromPath)) {
# Then the diff is a copy operation.
$sourcePath = $copiedFromPath;
# FIXME: Consider printing a warning or exiting if
# exists($copiedFiles{$indexPath}) is true -- i.e. if
# $indexPath appears twice as a copy target.
$copiedFiles{$indexPath} = $sourcePath;
push @copyDiffHashRefs, $diffHashRef;
} else {
# Then the diff is not a copy operation.
$sourcePath = $indexPath;
push @nonCopyDiffHashRefs, $diffHashRef;
}
if (defined($sourceRevision)) {
if (exists($sourceRevisionHash{$sourcePath}) &&
($sourceRevisionHash{$sourcePath} != $sourceRevision)) {
if (!$shouldForce) {
die "Two revisions of the same file required as a source:\n".
" $sourcePath:$sourceRevisionHash{$sourcePath}\n".
" $sourcePath:$sourceRevision";
}
}
$sourceRevisionHash{$sourcePath} = $sourceRevision;
}
}
my %preparedPatchHash;
$preparedPatchHash{copyDiffHashRefs} = \@copyDiffHashRefs;
$preparedPatchHash{nonCopyDiffHashRefs} = \@nonCopyDiffHashRefs;
$preparedPatchHash{sourceRevisionHash} = \%sourceRevisionHash;
return \%preparedPatchHash;
}
# Return localtime() for the project's time zone, given an integer time as
# returned by Perl's time() function.
sub localTimeInProjectTimeZone($)
{
my $epochTime = shift;
# Change the time zone temporarily for the localtime() call.
my $savedTimeZone = $ENV{'TZ'};
$ENV{'TZ'} = $changeLogTimeZone;
my @localTime = localtime($epochTime);
if (defined $savedTimeZone) {
$ENV{'TZ'} = $savedTimeZone;
} else {
delete $ENV{'TZ'};
}
return @localTime;
}
# Set the reviewer and date in a ChangeLog patch, and return the new patch.
#
# Args:
# $patch: a ChangeLog patch as a string.
# $reviewer: the name of the reviewer, or undef if the reviewer should not be set.
# $epochTime: an integer time as returned by Perl's time() function.
sub setChangeLogDateAndReviewer($$$)
{
my ($patch, $reviewer, $epochTime) = @_;
my @localTime = localTimeInProjectTimeZone($epochTime);
my $newDate = strftime("%Y-%m-%d", @localTime);
my $firstChangeLogLineRegEx = qr#(\n\+)\d{4}-[^-]{2}-[^-]{2}( )#;
$patch =~ s/$firstChangeLogLineRegEx/$1$newDate$2/;
if (defined($reviewer)) {
# We include a leading plus ("+") in the regular expression to make
# the regular expression less likely to match text in the leading junk
# for the patch, if the patch has leading junk.
$patch =~ s/(\n\+.*)NOBODY \(OOPS!\)/$1$reviewer/;
}
return $patch;
}
# If possible, returns a ChangeLog patch equivalent to the given one,
# but with the newest ChangeLog entry inserted at the top of the
# file -- i.e. no leading context and all lines starting with "+".
#
# If given a patch string not representable as a patch with the above
# properties, it returns the input back unchanged.
#
# WARNING: This subroutine can return an inequivalent patch string if
# both the beginning of the new ChangeLog file matches the beginning
# of the source ChangeLog, and the source beginning was modified.
# Otherwise, it is guaranteed to return an equivalent patch string,
# if it returns.
#
# Applying this subroutine to ChangeLog patches allows svn-apply to
# insert new ChangeLog entries at the top of the ChangeLog file.
# svn-apply uses patch with --fuzz=3 to do this. We need to apply
# this subroutine because the diff(1) command is greedy when matching
# lines. A new ChangeLog entry with the same date and author as the
# previous will match and cause the diff to have lines of starting
# context.
#
# This subroutine has unit tests in VCSUtils_unittest.pl.
#
# Returns $changeLogHashRef:
# $changeLogHashRef: a hash reference representing a change log patch.
# patch: a ChangeLog patch equivalent to the given one, but with the
# newest ChangeLog entry inserted at the top of the file, if possible.
sub fixChangeLogPatch($)
{
my $patch = shift; # $patch will only contain patch fragments for ChangeLog.
$patch =~ /(\r?\n)/;
my $lineEnding = $1;
my @lines = split(/$lineEnding/, $patch);
my $i = 0; # We reuse the same index throughout.
# Skip to beginning of first chunk.
for (; $i < @lines; ++$i) {
if (substr($lines[$i], 0, 1) eq "@") {
last;
}
}
my $chunkStartIndex = ++$i;
my %changeLogHashRef;
# Optimization: do not process if new lines already begin the chunk.
if (substr($lines[$i], 0, 1) eq "+") {
$changeLogHashRef{patch} = $patch;
return \%changeLogHashRef;
}
# Skip to first line of newly added ChangeLog entry.
# For example, +2009-06-03 Eric Seidel <eric@webkit.org>
my $dateStartRegEx = '^\+(\d{4}-\d{2}-\d{2})' # leading "+" and date
. '\s+(.+)\s+' # name
. '<([^<>]+)>$'; # e-mail address
for (; $i < @lines; ++$i) {
my $line = $lines[$i];
my $firstChar = substr($line, 0, 1);
if ($line =~ /$dateStartRegEx/) {
last;
} elsif ($firstChar eq " " or $firstChar eq "+") {
next;
}
$changeLogHashRef{patch} = $patch; # Do not change if, for example, "-" or "@" found.
return \%changeLogHashRef;
}
if ($i >= @lines) {
$changeLogHashRef{patch} = $patch; # Do not change if date not found.
return \%changeLogHashRef;
}
my $dateStartIndex = $i;
# Rewrite overlapping lines to lead with " ".
my @overlappingLines = (); # These will include a leading "+".
for (; $i < @lines; ++$i) {
my $line = $lines[$i];
if (substr($line, 0, 1) ne "+") {
last;
}
push(@overlappingLines, $line);
$lines[$i] = " " . substr($line, 1);
}
# Remove excess ending context, if necessary.
my $shouldTrimContext = 1;
for (; $i < @lines; ++$i) {
my $firstChar = substr($lines[$i], 0, 1);
if ($firstChar eq " ") {
next;
} elsif ($firstChar eq "@") {
last;
}
$shouldTrimContext = 0; # For example, if "+" or "-" encountered.
last;
}
my $deletedLineCount = 0;
if ($shouldTrimContext) { # Also occurs if end of file reached.
splice(@lines, $i - @overlappingLines, @overlappingLines);
$deletedLineCount = @overlappingLines;
}
# Work backwards, shifting overlapping lines towards front
# while checking that patch stays equivalent.
for ($i = $dateStartIndex - 1; @overlappingLines && $i >= $chunkStartIndex; --$i) {
my $line = $lines[$i];
if (substr($line, 0, 1) ne " ") {
next;
}
my $text = substr($line, 1);
my $newLine = pop(@overlappingLines);
if ($text ne substr($newLine, 1)) {
$changeLogHashRef{patch} = $patch; # Unexpected difference.
return \%changeLogHashRef;
}
$lines[$i] = "+$text";
}
# If @overlappingLines > 0, this is where we make use of the
# assumption that the beginning of the source file was not modified.
splice(@lines, $chunkStartIndex, 0, @overlappingLines);
# Update the date start index as it may have changed after shifting
# the overlapping lines towards the front.
for ($i = $chunkStartIndex; $i < $dateStartIndex; ++$i) {
$dateStartIndex = $i if $lines[$i] =~ /$dateStartRegEx/;
}
splice(@lines, $chunkStartIndex, $dateStartIndex - $chunkStartIndex); # Remove context of later entry.
$deletedLineCount += $dateStartIndex - $chunkStartIndex;
# Update the initial chunk range.
if ($lines[$chunkStartIndex - 1] !~ /$chunkRangeRegEx/) {
# FIXME: Handle errors differently from ChangeLog files that
# are okay but should not be altered. That way we can find out
# if improvements to the script ever become necessary.
$changeLogHashRef{patch} = $patch; # Error: unexpected patch string format.
return \%changeLogHashRef;
}
my $oldSourceLineCount = $2;
my $oldTargetLineCount = $3;
my $sourceLineCount = $oldSourceLineCount + @overlappingLines - $deletedLineCount;
my $targetLineCount = $oldTargetLineCount + @overlappingLines - $deletedLineCount;
$lines[$chunkStartIndex - 1] = "@@ -1,$sourceLineCount +1,$targetLineCount @@";
$changeLogHashRef{patch} = join($lineEnding, @lines) . "\n"; # patch(1) expects an extra trailing newline.
return \%changeLogHashRef;
}
# This is a supporting method for runPatchCommand.
#
# Arg: the optional $args parameter passed to runPatchCommand (can be undefined).
#
# Returns ($patchCommand, $isForcing).
#
# This subroutine has unit tests in VCSUtils_unittest.pl.
sub generatePatchCommand($)
{
my ($passedArgsHashRef) = @_;
my $argsHashRef = { # Defaults
ensureForce => 0,
shouldReverse => 0,
options => []
};
# Merges hash references. It's okay here if passed hash reference is undefined.
@{$argsHashRef}{keys %{$passedArgsHashRef}} = values %{$passedArgsHashRef};
my $ensureForce = $argsHashRef->{ensureForce};
my $shouldReverse = $argsHashRef->{shouldReverse};
my $options = $argsHashRef->{options};
if (! $options) {
$options = [];
} else {
$options = [@{$options}]; # Copy to avoid side effects.
}
my $isForcing = 0;
if (grep /^--force$/, @{$options}) {
$isForcing = 1;
} elsif ($ensureForce) {
push @{$options}, "--force";
$isForcing = 1;
}
if ($shouldReverse) { # No check: --reverse should never be passed explicitly.
push @{$options}, "--reverse";
}
@{$options} = sort(@{$options}); # For easier testing.
my $patchCommand = join(" ", "patch -p0", @{$options});
return ($patchCommand, $isForcing);
}
# Apply the given patch using the patch(1) command.
#
# On success, return the resulting exit status. Otherwise, exit with the
# exit status. If "--force" is passed as an option, however, then never
# exit and always return the exit status.
#
# Args:
# $patch: a patch string.
# $repositoryRootPath: an absolute path to the repository root.
# $pathRelativeToRoot: the path of the file to be patched, relative to the
# repository root. This should normally be the path
# found in the patch's "Index:" line. It is passed
# explicitly rather than reparsed from the patch
# string for optimization purposes.
# This is used only for error reporting. The
# patch command gleans the actual file to patch
# from the patch string.
# $args: a reference to a hash of optional arguments. The possible
# keys are --
# ensureForce: whether to ensure --force is passed (defaults to 0).
# shouldReverse: whether to pass --reverse (defaults to 0).
# options: a reference to an array of options to pass to the
# patch command. The subroutine passes the -p0 option
# no matter what. This should not include --reverse.
#
# This subroutine has unit tests in VCSUtils_unittest.pl.
sub runPatchCommand($$$;$)
{
my ($patch, $repositoryRootPath, $pathRelativeToRoot, $args) = @_;
my ($patchCommand, $isForcing) = generatePatchCommand($args);
# Temporarily change the working directory since the path found
# in the patch's "Index:" line is relative to the repository root
# (i.e. the same as $pathRelativeToRoot).
my $cwd = Cwd::getcwd();
chdir $repositoryRootPath;
open PATCH, "| $patchCommand" or die "Could not call \"$patchCommand\" for file \"$pathRelativeToRoot\": $!";
print PATCH $patch;
close PATCH;
my $exitStatus = exitStatus($?);
chdir $cwd;
if ($exitStatus && !$isForcing) {
print "Calling \"$patchCommand\" for file \"$pathRelativeToRoot\" returned " .
"status $exitStatus. Pass --force to ignore patch failures.\n";
exit $exitStatus;
}
return $exitStatus;
}
# Merge ChangeLog patches using a three-file approach.
#
# This is used by resolve-ChangeLogs when it's operated as a merge driver
# and when it's used to merge conflicts after a patch is applied or after
# an svn update.
#
# It's also used for traditional rejected patches.
#
# Args:
# $fileMine: The merged version of the file. Also known in git as the
# other branch's version (%B) or "ours".
# For traditional patch rejects, this is the *.rej file.
# $fileOlder: The base version of the file. Also known in git as the
# ancestor version (%O) or "base".
# For traditional patch rejects, this is the *.orig file.
# $fileNewer: The current version of the file. Also known in git as the
# current version (%A) or "theirs".
# For traditional patch rejects, this is the original-named
# file.
#
# Returns 1 if merge was successful, else 0.
sub mergeChangeLogs($$$)
{
my ($fileMine, $fileOlder, $fileNewer) = @_;
my $traditionalReject = $fileMine =~ /\.rej$/ ? 1 : 0;
local $/ = undef;
my $patch;
if ($traditionalReject) {
open(DIFF, "<", $fileMine) or die $!;
$patch = <DIFF>;
close(DIFF);
rename($fileMine, "$fileMine.save");
rename($fileOlder, "$fileOlder.save");
} else {
open(DIFF, "-|", qw(diff -u -a --binary), $fileOlder, $fileMine) or die $!;
$patch = <DIFF>;
close(DIFF);
}
unlink("${fileNewer}.orig");
unlink("${fileNewer}.rej");
open(PATCH, "| patch --force --fuzz=3 --binary $fileNewer > " . File::Spec->devnull()) or die $!;
if ($traditionalReject) {
print PATCH $patch;
} else {
my $changeLogHash = fixChangeLogPatch($patch);
print PATCH $changeLogHash->{patch};
}
close(PATCH);
my $result = !exitStatus($?);
# Refuse to merge the patch if it did not apply cleanly
if (-e "${fileNewer}.rej") {
unlink("${fileNewer}.rej");
if (-f "${fileNewer}.orig") {
unlink($fileNewer);
rename("${fileNewer}.orig", $fileNewer);
}
} else {
unlink("${fileNewer}.orig");
}
if ($traditionalReject) {
rename("$fileMine.save", $fileMine);
rename("$fileOlder.save", $fileOlder);
}
return $result;
}
sub gitConfig($)
{
return unless $isGit;
my ($config) = @_;
my $result = `git config $config`;
if (($? >> 8)) {
$result = `git repo-config $config`;
}
chomp $result;
return $result;
}
sub changeLogNameError($)
{
my ($message) = @_;
print STDERR "$message\nEither:\n";
print STDERR " set CHANGE_LOG_NAME in your environment\n";
print STDERR " OR pass --name= on the command line\n";
print STDERR " OR set REAL_NAME in your environment";
print STDERR " OR git users can set 'git config user.name'\n";
exit(1);
}
sub changeLogName()
{
my $name = $ENV{CHANGE_LOG_NAME} || $ENV{REAL_NAME} || gitConfig("user.name") || (split /\s*,\s*/, (getpwuid $<)[6])[0];
changeLogNameError("Failed to determine ChangeLog name.") unless $name;
# getpwuid seems to always succeed on windows, returning the username instead of the full name. This check will catch that case.
changeLogNameError("'$name' does not contain a space! ChangeLogs should contain your full name.") unless ($name =~ /\w \w/);
return $name;
}
sub changeLogEmailAddressError($)
{
my ($message) = @_;
print STDERR "$message\nEither:\n";
print STDERR " set CHANGE_LOG_EMAIL_ADDRESS in your environment\n";
print STDERR " OR pass --email= on the command line\n";
print STDERR " OR set EMAIL_ADDRESS in your environment\n";
print STDERR " OR git users can set 'git config user.email'\n";
exit(1);
}
sub changeLogEmailAddress()
{
my $emailAddress = $ENV{CHANGE_LOG_EMAIL_ADDRESS} || $ENV{EMAIL_ADDRESS} || gitConfig("user.email");
changeLogEmailAddressError("Failed to determine email address for ChangeLog.") unless $emailAddress;
changeLogEmailAddressError("Email address '$emailAddress' does not contain '\@' and is likely invalid.") unless ($emailAddress =~ /\@/);
return $emailAddress;
}
# http://tools.ietf.org/html/rfc1924
sub decodeBase85($)
{
my ($encoded) = @_;
my %table;
my @characters = ('0'..'9', 'A'..'Z', 'a'..'z', '!', '#', '$', '%', '&', '(', ')', '*', '+', '-', ';', '<', '=', '>', '?', '@', '^', '_', '`', '{', '|', '}', '~');
for (my $i = 0; $i < 85; $i++) {
$table{$characters[$i]} = $i;
}
my $decoded = '';
my @encodedChars = $encoded =~ /./g;
for (my $encodedIter = 0; defined($encodedChars[$encodedIter]);) {
my $digit = 0;
for (my $i = 0; $i < 5; $i++) {
$digit *= 85;
my $char = $encodedChars[$encodedIter];
$digit += $table{$char};
$encodedIter++;
}
for (my $i = 0; $i < 4; $i++) {
$decoded .= chr(($digit >> (3 - $i) * 8) & 255);
}
}
return $decoded;
}
sub decodeGitBinaryChunk($$)
{
my ($contents, $fullPath) = @_;
# Load this module lazily in case the user don't have this module
# and won't handle git binary patches.
require Compress::Zlib;
my $encoded = "";
my $compressedSize = 0;
while ($contents =~ /^([A-Za-z])(.*)$/gm) {
my $line = $2;
next if $line eq "";
die "$fullPath: unexpected size of a line: $&" if length($2) % 5 != 0;
my $actualSize = length($2) / 5 * 4;
my $encodedExpectedSize = ord($1);
my $expectedSize = $encodedExpectedSize <= ord("Z") ? $encodedExpectedSize - ord("A") + 1 : $encodedExpectedSize - ord("a") + 27;
die "$fullPath: unexpected size of a line: $&" if int(($expectedSize + 3) / 4) * 4 != $actualSize;
$compressedSize += $expectedSize;
$encoded .= $line;
}
my $compressed = decodeBase85($encoded);
$compressed = substr($compressed, 0, $compressedSize);
return Compress::Zlib::uncompress($compressed);
}
sub decodeGitBinaryPatch($$)
{
my ($contents, $fullPath) = @_;
# Git binary patch has two chunks. One is for the normal patching
# and another is for the reverse patching.
#
# Each chunk a line which starts from either "literal" or "delta",
# followed by a number which specifies decoded size of the chunk.
#
# Then, content of the chunk comes. To decode the content, we
# need decode it with base85 first, and then zlib.
my $gitPatchRegExp = '(literal|delta) ([0-9]+)\n([A-Za-z0-9!#$%&()*+-;<=>?@^_`{|}~\\n]*?)\n\n';
if ($contents !~ m"\nGIT binary patch\n$gitPatchRegExp$gitPatchRegExp\Z") {
die "$fullPath: unknown git binary patch format"
}
my $binaryChunkType = $1;
my $binaryChunkExpectedSize = $2;
my $encodedChunk = $3;
my $reverseBinaryChunkType = $4;
my $reverseBinaryChunkExpectedSize = $5;
my $encodedReverseChunk = $6;
my $binaryChunk = decodeGitBinaryChunk($encodedChunk, $fullPath);
my $binaryChunkActualSize = length($binaryChunk);
my $reverseBinaryChunk = decodeGitBinaryChunk($encodedReverseChunk, $fullPath);
my $reverseBinaryChunkActualSize = length($reverseBinaryChunk);
die "$fullPath: unexpected size of the first chunk (expected $binaryChunkExpectedSize but was $binaryChunkActualSize" if ($binaryChunkType eq "literal" and $binaryChunkExpectedSize != $binaryChunkActualSize);
die "$fullPath: unexpected size of the second chunk (expected $reverseBinaryChunkExpectedSize but was $reverseBinaryChunkActualSize" if ($reverseBinaryChunkType eq "literal" and $reverseBinaryChunkExpectedSize != $reverseBinaryChunkActualSize);
return ($binaryChunkType, $binaryChunk, $reverseBinaryChunkType, $reverseBinaryChunk);
}
sub readByte($$)
{
my ($data, $location) = @_;
# Return the byte at $location in $data as a numeric value.
return ord(substr($data, $location, 1));
}
# The git binary delta format is undocumented, except in code:
# - https://github.com/git/git/blob/master/delta.h:get_delta_hdr_size is the source
# of the algorithm in decodeGitBinaryPatchDeltaSize.
# - https://github.com/git/git/blob/master/patch-delta.c:patch_delta is the source
# of the algorithm in applyGitBinaryPatchDelta.
sub decodeGitBinaryPatchDeltaSize($)
{
my ($binaryChunk) = @_;
# Source and destination buffer sizes are stored in 7-bit chunks at the
# start of the binary delta patch data. The highest bit in each byte
# except the last is set; the remaining 7 bits provide the next
# chunk of the size. The chunks are stored in ascending significance
# order.
my $cmd;
my $size = 0;
my $shift = 0;
for (my $i = 0; $i < length($binaryChunk);) {
$cmd = readByte($binaryChunk, $i++);
$size |= ($cmd & 0x7f) << $shift;
$shift += 7;
if (!($cmd & 0x80)) {
return ($size, $i);
}
}
}
sub applyGitBinaryPatchDelta($$)
{
my ($binaryChunk, $originalContents) = @_;
# Git delta format consists of two headers indicating source buffer size
# and result size, then a series of commands. Each command is either
# a copy-from-old-version (the 0x80 bit is set) or a copy-from-delta
# command. Commands are applied sequentially to generate the result.
#
# A copy-from-old-version command encodes an offset and size to copy
# from in subsequent bits, while a copy-from-delta command consists only
# of the number of bytes to copy from the delta.
# We don't use these values, but we need to know how big they are so that
# we can skip to the diff data.
my ($size, $bytesUsed) = decodeGitBinaryPatchDeltaSize($binaryChunk);
$binaryChunk = substr($binaryChunk, $bytesUsed);
($size, $bytesUsed) = decodeGitBinaryPatchDeltaSize($binaryChunk);
$binaryChunk = substr($binaryChunk, $bytesUsed);
my $out = "";
for (my $i = 0; $i < length($binaryChunk); ) {
my $cmd = ord(substr($binaryChunk, $i++, 1));
if ($cmd & 0x80) {
# Extract an offset and size from the delta data, then copy
# $size bytes from $offset in the original data into the output.
my $offset = 0;
my $size = 0;
if ($cmd & 0x01) { $offset = readByte($binaryChunk, $i++); }
if ($cmd & 0x02) { $offset |= readByte($binaryChunk, $i++) << 8; }
if ($cmd & 0x04) { $offset |= readByte($binaryChunk, $i++) << 16; }
if ($cmd & 0x08) { $offset |= readByte($binaryChunk, $i++) << 24; }
if ($cmd & 0x10) { $size = readByte($binaryChunk, $i++); }
if ($cmd & 0x20) { $size |= readByte($binaryChunk, $i++) << 8; }
if ($cmd & 0x40) { $size |= readByte($binaryChunk, $i++) << 16; }
if ($size == 0) { $size = 0x10000; }
$out .= substr($originalContents, $offset, $size);
} elsif ($cmd) {
# Copy $cmd bytes from the delta data into the output.
$out .= substr($binaryChunk, $i, $cmd);
$i += $cmd;
} else {
die "unexpected delta opcode 0";
}
}
return $out;
}
1;