blob: 099be846c9f37a4f23a53960d78a7727390e6a81 [file] [log] [blame]
#!/usr/bin/env perl
#
# Copyright (C) 2011 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.
#
# THIS SOFTWARE IS PROVIDED BY APPLE INC. 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 INC. 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.
use strict;
use warnings;
use File::Basename;
use File::Temp ();
use Getopt::Long;
use POSIX;
use IPC::Open2;
use FindBin;
use lib $FindBin::Bin;
use webkitdirs;
use VCSUtils;
my $defaultReviewer = "NOBODY";
sub addReviewer(\%);
sub addReviewerToChangeLog($$$);
sub addReviewerToCommitMessage($$$);
sub changeLogsForCommit($);
sub checkout($);
sub cherryPick(\%);
sub commit(;$);
sub getConfigValue($);
sub fail(;$);
sub head();
sub interactive();
sub isAncestor($$);
sub nonInteractive();
sub rebaseOntoHead($$);
sub requireCleanWorkTree();
sub resetToCommit($);
sub toCommit($);
sub usage();
sub writeCommitMessageToFile($);
my $interactive = 0;
my $showHelp = 0;
my $rubberStamp = 0;
my $programName = basename($0);
my $usage = <<EOF;
Usage: $programName -i|--interactive upstream
$programName commit-ish reviewer
Adds a reviewer to a git commit in a repository with WebKit-style commit logs
and ChangeLogs.
When run in interactive mode, `upstream` specifies the commit after which
reviewers should be added.
When run in non-interactive mode, `commit-ish` specifies the commit to which
the `reviewer` will be added.
Options:
-h|--help Display this message
-i|--interactive Interactive mode
-s|--rubber-stamp Change `Reviewed by` to `Rubber-stamped by`
EOF
my $getOptionsResult = GetOptions(
'h|help' => \$showHelp,
'i|interactive' => \$interactive,
's|rubber-stamp' => \$rubberStamp,
);
my $gitDirectory = gitDirectory();
usage() if !$getOptionsResult || $showHelp;
requireCleanWorkTree();
$interactive ? interactive() : nonInteractive();
exit;
sub interactive()
{
@ARGV == 1 or usage();
my $upstream = toCommit($ARGV[0]);
my $head = head();
isAncestor($upstream, $head) or die "$ARGV[0] is not an ancestor of HEAD.";
my @revlist = runCommandWithOutput('git', 'rev-list', '--reverse', '--pretty=oneline', "$upstream..");
@revlist or die "Couldn't determine revisions";
my $tempFile = new File::Temp(UNLINK => 1);
foreach my $line (@revlist) {
print $tempFile "$defaultReviewer : $line";
}
print $tempFile <<EOF;
# Change 'NOBODY' to the reviewer for each commit
#
# If any line starts with "rs" followed by one or more spaces, then the phrase
# "Reviewed by" is changed to "Rubber-stamped by" in the ChangeLog(s)/commit
# message for that commit.
#
# Commits may be reordered
# Omitted commits will be lost
EOF
close $tempFile;
my $editor = $ENV{GIT_EDITOR} || getConfigValue("core.editor") || $ENV{VISUAL} || $ENV{EDITOR} || "vi";
my $result = system "$editor \"" . $tempFile->filename . "\"";
!($result >> 8) or die "Error spawning editor.";
my @todo = ();
open TEMPFILE, '<', $tempFile->filename or die "Error opening temp file.";
foreach my $line (<TEMPFILE>) {
next if $line =~ /^#/;
$line =~ /^(rs\s+)?(.*)\s+:\s+([0-9a-fA-F]+)/ or next;
push @todo, {rubberstamp => defined $1 && length $1, reviewer => $2, commit => $3};
}
close TEMPFILE;
@todo or die "No revisions specified.";
foreach my $item (@todo) {
$item->{changeLogs} = changeLogsForCommit($item->{commit});
}
$result = system "git", "checkout", $upstream;
!($result >> 8) or die "Error checking out $ARGV[0].";
my $success = 1;
foreach my $item (@todo) {
$success = cherryPick(%{$item});
$success or last;
$success = addReviewer(%{$item});
$success or last;
$success = commit();
$success or last;
}
unless ($success) {
resetToCommit($head);
exit 1;
}
$result = system "git", "branch", "-f", $head;
!($result >> 8) or die "Error updating $head.";
$result = system "git", "checkout", $head;
exit WEXITSTATUS($result >> 8);
}
sub nonInteractive()
{
@ARGV == 2 or usage();
my $commit = toCommit($ARGV[0]);
my $reviewer = $ARGV[1];
my $head = head();
my $headCommit = toCommit($head);
isAncestor($commit, $head) or die "$ARGV[0] is not an ancestor of HEAD.";
chomp($reviewer);
my %item = (
reviewer => $reviewer,
rubberstamp => $rubberStamp,
commit => $commit,
);
$item{changeLogs} = changeLogsForCommit($commit);
$item{changeLogs} or die;
unless ((($commit eq $headCommit) or checkout($commit))
&& writeCommitMessageToFile("$gitDirectory/MERGE_MSG")
&& addReviewer(%item)
&& commit(1)
&& (($commit eq $headCommit) or rebaseOntoHead($commit, $head))) {
resetToCommit($head);
exit 1;
}
}
sub usage()
{
print STDERR $usage;
exit 1;
}
sub requireCleanWorkTree()
{
my $result = system("git rev-parse --verify HEAD > /dev/null") >> 8;
$result ||= system(qw(git update-index --refresh)) >> 8;
$result ||= system(qw(git diff-files --quiet)) >> 8;
$result ||= system(qw(git diff-index --cached --quiet HEAD --)) >> 8;
!$result or die "Working tree is dirty"
}
sub fail(;$)
{
my ($message) = @_;
print STDERR $message, "\n" if defined $message;
return 0;
}
sub cherryPick(\%)
{
my ($item) = @_;
my $result = system "git cherry-pick -n $item->{commit} > /dev/null";
!($result >> 8) or return fail("Failed to cherry-pick $item->{commit}");
return 1;
}
sub addReviewer(\%)
{
my ($item) = @_;
return 1 if $item->{reviewer} eq $defaultReviewer;
foreach my $log (@{$item->{changeLogs}}) {
addReviewerToChangeLog($item->{reviewer}, $item->{rubberstamp}, $log) or return fail();
}
addReviewerToCommitMessage($item->{reviewer}, $item->{rubberstamp}, "$gitDirectory/MERGE_MSG") or return fail();
return 1;
}
sub commit(;$)
{
my ($amend) = @_;
my @command = qw(git commit -F);
push @command, "$gitDirectory/MERGE_MSG";
push @command, "--amend" if $amend;
my $result = system @command;
!($result >> 8) or return fail("Failed to commit revision");
return 1;
}
sub addReviewerToChangeLog($$$)
{
my ($reviewer, $rubberstamp, $log) = @_;
return addReviewerToFile($reviewer, $rubberstamp, $log, 0);
}
sub addReviewerToCommitMessage($$$)
{
my ($reviewer, $rubberstamp, $log) = @_;
return addReviewerToFile($reviewer, $rubberstamp, $log, 1);
}
sub addReviewerToFile
{
my ($reviewer, $rubberstamp, $log, $isCommitMessage) = @_;
my $tempFile = new File::Temp(UNLINK => 1);
open LOG, "<", $log or return fail("Couldn't open $log.");
my $finished = 0;
foreach my $line (<LOG>) {
if (!$finished && $line =~ /NOBODY \(OOPS!\)/) {
$line =~ s/NOBODY \(OOPS!\)/$reviewer/;
$line =~ s/Reviewed/Rubber-stamped/ if $rubberstamp;
$finished = 1 unless $isCommitMessage;
}
print $tempFile $line;
}
close $tempFile;
close LOG or return fail("Couldn't close $log");
my $result = system "mv", $tempFile->filename, $log;
!($result >> 8) or return fail("Failed to rename $tempFile to $log");
unless ($isCommitMessage) {
my $result = system "git", "add", $log;
!($result >> 8) or return fail("Failed to git add");
}
return 1;
}
sub head()
{
my $head = runCommandWithOutput('git', 'symbolic-ref', 'HEAD');
$head =~ /^refs\/heads\/(.*)$/ or die "Couldn't determine current branch.";
$head = $1;
return $head;
}
sub isAncestor($$)
{
my ($ancestor, $descendant) = @_;
chomp(my $mergeBase = runCommandWithOutput('git', 'merge-base', $ancestor, $descendant));
return $mergeBase eq $ancestor;
}
sub toCommit($)
{
my ($arg) = @_;
chomp(my $commit = runCommandWithOutput('git', 'rev-parse', $arg));
return $commit;
}
sub changeLogsForCommit($)
{
my ($commit) = @_;
my @files = runCommandWithOutput('git', 'diff', '-r', '--name-status', "$commit^", "$commit");
@files or return fail("Couldn't determine changed files for $commit.");
my @changeLogs = map { /^[ACMR]\s*(.*)/; makeFilePathRelative($1) } grep { /^[ACMR].*[^-]ChangeLog/ } @files;
return \@changeLogs;
}
sub resetToCommit($)
{
my ($commit) = @_;
my $result = system "git", "checkout", "-f", $commit;
!($result >> 8) or return fail("Error checking out $commit.");
return 1;
}
sub writeCommitMessageToFile($)
{
my ($file) = @_;
open FILE, ">", $file or return fail("Couldn't open $file.");
open MESSAGE, "-|", qw(git rev-list --max-count=1 --pretty=format:%B HEAD) or return fail("Error running git rev-list.");
my $commitLine = <MESSAGE>;
foreach my $line (<MESSAGE>) {
print FILE $line;
}
close MESSAGE;
close FILE or return fail("Couldn't close $file.");
return 1;
}
sub rebaseOntoHead($$)
{
my ($upstream, $branch) = @_;
my $result = system qw(git rebase --onto HEAD), $upstream, $branch;
!$result or return fail("Couldn't rebase.");
return 1;
}
sub checkout($)
{
my ($commit) = @_;
my $result = system "git", "checkout", $commit;
!$result or return fail("Error checking out $commit.");
return 1;
}
sub getConfigValue($)
{
my ($variable) = @_;
chomp(my $value = runCommandWithOutput('git', 'config', '--get', $variable));
return $value;
}
sub runCommandWithOutput {
my ($output, $input);
my $pid = open2($output, $input, @_);
waitpid($pid, 0);
return <$output>;
}