| #!/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>; |
| } |