#!/usr/bin/perl -w # # 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 $programName = basename($0); my $usage = < \$showHelp, 'i|interactive' => \$interactive, ); 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 <filename . "\""; !($result >> 8) or die "Error spawning editor."; my @todo = (); open TEMPFILE, '<', $tempFile->filename or die "Error opening temp file."; foreach my $line () { 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, 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 () { 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 = ; foreach my $line () { 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>; }