Unreviewed. Add Silvia Pfeiffer to contributor list.
[WebKit-https.git] / Tools / Scripts / git-add-reviewer
1 #!/usr/bin/perl -w
2 #
3 # Copyright (C) 2011 Apple Inc. All rights reserved.
4 #
5 # Redistribution and use in source and binary forms, with or without
6 # modification, are permitted provided that the following conditions
7 # are met:
8 # 1.  Redistributions of source code must retain the above copyright
9 #     notice, this list of conditions and the following disclaimer.
10 # 2.  Redistributions in binary form must reproduce the above copyright
11 #     notice, this list of conditions and the following disclaimer in the
12 #     documentation and/or other materials provided with the distribution.
13 #
14 # THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND ANY
15 # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17 # DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR ANY
18 # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
19 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
20 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
21 # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
22 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
23 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24
25 use strict;
26 use warnings;
27
28 use File::Basename;
29 use File::Temp ();
30 use Getopt::Long;
31 use POSIX;
32
33 my $defaultReviewer = "NOBODY";
34
35 sub addReviewer(\%);
36 sub addReviewerToChangeLog($$$);
37 sub addReviewerToCommitMessage($$$);
38 sub changeLogsForCommit($);
39 sub checkout($);
40 sub cherryPick(\%);
41 sub commit(;$);
42 sub getConfigValue($);
43 sub fail(;$);
44 sub head();
45 sub interactive();
46 sub isAncestor($$);
47 sub nonInteractive();
48 sub rebaseOntoHead($$);
49 sub requireCleanWorkTree();
50 sub resetToCommit($);
51 sub toCommit($);
52 sub usage();
53 sub writeCommitMessageToFile($);
54
55
56 my $interactive = 0;
57 my $showHelp = 0;
58
59 my $programName = basename($0);
60 my $usage = <<EOF;
61 Usage: $programName -i|--interactive upstream
62        $programName commit-ish reviewer
63
64 Adds a reviewer to a git commit in a repository with WebKit-style commit logs
65 and ChangeLogs.
66
67 When run in interactive mode, `upstream` specifies the commit after which
68 reviewers should be added.
69
70 When run in non-interactive mode, `commit-ish` specifies the commit to which
71 the `reviewer` will be added.
72
73 Options:
74   -h|--help          Display this message
75   -i|--interactive   Interactive mode
76 EOF
77
78 my $getOptionsResult = GetOptions(
79     'h|help' => \$showHelp,
80     'i|interactive' => \$interactive,
81 );
82
83 usage() if !$getOptionsResult || $showHelp;
84
85 requireCleanWorkTree();
86 $interactive ? interactive() : nonInteractive();
87 exit;
88
89 sub interactive()
90 {
91     @ARGV == 1 or usage();
92
93     my $upstream = toCommit($ARGV[0]);
94     my $head = head();
95
96     isAncestor($upstream, $head) or die "$ARGV[0] is not an ancestor of HEAD.";
97
98     my @revlist = `git rev-list --reverse --pretty=oneline $upstream..`;
99     @revlist or die "Couldn't determine revisions";
100
101     my $tempFile = new File::Temp(UNLINK => 1);
102     foreach my $line (@revlist) {
103         print $tempFile "$defaultReviewer : $line";
104     }
105
106     print $tempFile <<EOF;
107
108 # Change 'NOBODY' to the reviewer for each commit
109 #
110 # If any line starts with "rs" followed by one or more spaces, then the phrase
111 # "Reviewed by" is changed to "Rubber-stamped by" in the ChangeLog(s)/commit
112 # message for that commit.
113 #
114 # Commits may be reordered
115 # Omitted commits will be lost
116 EOF
117
118     close $tempFile;
119
120     my $editor = $ENV{GIT_EDITOR} || getConfigValue("core.editor") || $ENV{VISUAL} || $ENV{EDITOR} || "vi";
121     my $result = system "$editor \"" . $tempFile->filename . "\"";
122     !$result or die "Error spawning editor.";
123
124     my @todo = ();
125     open TEMPFILE, '<', $tempFile->filename or die "Error opening temp file.";
126     foreach my $line (<TEMPFILE>) {
127         next if $line =~ /^#/;
128         $line =~ /^(rs\s+)?(.*)\s+:\s+([0-9a-fA-F]+)/ or next;
129         push @todo, {rubberstamp => defined $1 && length $1, reviewer => $2, commit => $3};
130     }
131     close TEMPFILE;
132     @todo or die "No revisions specified.";
133
134     foreach my $item (@todo) {
135         $item->{changeLogs} = changeLogsForCommit($item->{commit});
136     }
137
138     $result = system "git", "checkout", $upstream;
139     !$result or die "Error checking out $ARGV[0].";
140
141     my $success = 1;
142     foreach my $item (@todo) {
143         $success = cherryPick(%{$item});
144         $success or last;
145         $success = addReviewer(%{$item});
146         $success or last;
147         $success = commit();
148         $success or last;
149     }
150
151     unless ($success) {
152         resetToCommit($head);
153         exit 1;
154     }
155
156     $result = system "git", "branch", "-f", $head;
157     !$result or die "Error updating $head.";
158     $result = system "git", "checkout", $head;
159     exit WEXITSTATUS($result);
160 }
161
162 sub nonInteractive()
163 {
164     @ARGV == 2 or usage();
165
166     my $commit = toCommit($ARGV[0]);
167     my $reviewer = $ARGV[1];
168     my $head = head();
169     my $headCommit = toCommit($head);
170
171     isAncestor($commit, $head) or die "$ARGV[1] is not an ancestor of HEAD.";
172
173     my %item = (
174         reviewer => $reviewer,
175         commit => $commit,
176     );
177
178     $item{changeLogs} = changeLogsForCommit($commit);
179     $item{changeLogs} or die;
180
181     unless ((($commit eq $headCommit) or checkout($commit))
182             # FIXME: We need to use $ENV{GIT_DIR}/.git/MERGE_MSG
183             && writeCommitMessageToFile(".git/MERGE_MSG")
184             && addReviewer(%item)
185             && commit(1)
186             && (($commit eq $headCommit) or rebaseOntoHead($commit, $head))) {
187         resetToCommit($head);
188         exit 1;
189     }
190 }
191
192 sub usage()
193 {
194     print STDERR $usage;
195     exit 1;
196 }
197
198 sub requireCleanWorkTree()
199 {
200     my $result = system "git rev-parse --verify HEAD > /dev/null";
201     $result ||= system qw(git update-index --refresh);
202     $result ||= system qw(git diff-files --quiet);
203     $result ||= system qw(git diff-index --cached --quiet HEAD --);
204     !$result or die "Working tree is dirty"
205 }
206
207 sub fail(;$)
208 {
209     my ($message) = @_;
210     print STDERR $message, "\n" if defined $message;
211     return 0;
212 }
213
214 sub cherryPick(\%)
215 {
216     my ($item) = @_;
217
218     my $result = system "git cherry-pick -n $item->{commit} > /dev/null";
219     !$result or return fail("Failed to cherry-pick $item->{commit}");
220
221     return 1;
222 }
223
224 sub addReviewer(\%)
225 {
226     my ($item) = @_;
227
228     return 1 if $item->{reviewer} eq $defaultReviewer;
229
230     foreach my $log (@{$item->{changeLogs}}) {
231         addReviewerToChangeLog($item->{reviewer}, $item->{rubberstamp}, $log) or return fail();
232     }
233
234     addReviewerToCommitMessage($item->{reviewer}, $item->{rubberstamp}, ".git/MERGE_MSG") or return fail();
235
236     return 1;
237 }
238
239 sub commit(;$)
240 {
241     my ($amend) = @_;
242
243     my @command = qw(git commit -F .git/MERGE_MSG);
244     push @command, "--amend" if $amend;
245     my $result = system @command;
246     !$result or return fail("Failed to commit revision");
247
248     return 1;
249 }
250
251 sub addReviewerToChangeLog($$$)
252 {
253     my ($reviewer, $rubberstamp, $log) = @_;
254
255     return addReviewerToFile($reviewer, $rubberstamp, $log, 0);
256 }
257
258 sub addReviewerToCommitMessage($$$)
259 {
260     my ($reviewer, $rubberstamp, $log) = @_;
261
262     return addReviewerToFile($reviewer, $rubberstamp, $log, 1);
263 }
264
265 sub addReviewerToFile
266 {
267     my ($reviewer, $rubberstamp, $log, $isCommitMessage) = @_;
268
269     my $tempFile = new File::Temp(UNLINK => 1);
270
271     open LOG, "<", $log or return fail("Couldn't open $log.");
272
273     my $finished = 0;
274     foreach my $line (<LOG>) {
275         if (!$finished && $line =~ /NOBODY \(OOPS!\)/) {
276             $line =~ s/NOBODY \(OOPS!\)/$reviewer/;
277             $line =~ s/Reviewed/Rubber-stamped/ if $rubberstamp;
278             $finished = 1 unless $isCommitMessage;
279         }
280
281         print $tempFile $line;
282     }
283
284     close $tempFile;
285     close LOG or return fail("Couldn't close $log");
286
287     my $result = system "mv", $tempFile->filename, $log;
288     !$result or return fail("Failed to rename $tempFile to $log");
289
290     unless ($isCommitMessage) {
291         my $result = system "git", "add", $log;
292         !$result or return fail("Failed to git add");
293     }
294
295     return 1;
296 }
297
298 sub head()
299 {
300     my $head = `git symbolic-ref HEAD`;
301     $head =~ /^refs\/heads\/(.*)$/ or die "Couldn't determine current branch.";
302     $head = $1;
303
304     return $head;
305 }
306
307 sub isAncestor($$)
308 {
309     my ($ancestor, $descendant) = @_;
310
311     chomp(my $mergeBase = `git merge-base $ancestor $descendant`);
312     return $mergeBase eq $ancestor;
313 }
314
315 sub toCommit($)
316 {
317     my ($arg) = @_;
318
319     chomp(my $commit = `git rev-parse $arg`);
320     return $commit;
321 }
322
323 sub changeLogsForCommit($)
324 {
325     my ($commit) = @_;
326
327     my @files = `git diff -r --name-status $commit^ $commit`;
328     @files or return fail("Couldn't determine changed files for $commit.");
329
330     my @changeLogs = map { /^[ACMR]\s*(.*)/; $1 } grep { /^[ACMR].*[^-]ChangeLog/ } @files;
331     return \@changeLogs;
332 }
333
334 sub resetToCommit($)
335 {
336     my ($commit) = @_;
337
338     my $result = system "git", "checkout", "-f", $commit;
339     !$result or return fail("Error checking out $commit.");
340
341     return 1;
342 }
343
344 sub writeCommitMessageToFile($)
345 {
346     my ($file) = @_;
347
348     open FILE, ">", $file or return fail("Couldn't open $file.");
349     open MESSAGE, "-|", qw(git rev-list --max-count=1 --pretty=format:%B HEAD) or return fail("Error running git rev-list.");
350     my $commitLine = <MESSAGE>;
351     foreach my $line (<MESSAGE>) {
352         print FILE $line;
353     }
354     close MESSAGE;
355     close FILE or return fail("Couldn't close $file.");
356
357     return 1;
358 }
359
360 sub rebaseOntoHead($$)
361 {
362     my ($upstream, $branch) = @_;
363
364     my $result = system qw(git rebase --onto HEAD), $upstream, $branch;
365     !$result or return fail("Couldn't rebase.");
366
367     return 1;
368 }
369
370 sub checkout($)
371 {
372     my ($commit) = @_;
373
374     my $result = system "git", "checkout", $commit;
375     !$result or return fail("Error checking out $commit.");
376
377     return 1;
378 }
379
380 sub getConfigValue($)
381 {
382     my ($variable) = @_;
383
384     chomp(my $value = `git config --get "$variable"`);
385
386     return $value;
387 }