File Coverage

blib/lib/App/GitGerrit.pm
Criterion Covered Total %
statement 38 285 13.3
branch 1 154 0.6
condition 0 15 0.0
subroutine 13 43 30.2
pod 0 29 0.0
total 52 526 9.8


line stmt bran cond sub pod time code
1 1     1   39478 use utf8;
  1         11  
  1         8  
2 1     1   60 use 5.010;
  1         4  
  1         44  
3 1     1   7 use strict;
  1         7  
  1         39  
4 1     1   6 use warnings;
  1         2  
  1         42  
5 1     1   2094 use locale ':not_characters';
  1         267  
  1         6  
6              
7             # The following incantation is here to avoid this bug:
8             # https://rt.perl.org/rt3/Public/Bug/Display.html?id=63402
9             my $encoding;
10             BEGIN {
11 1 50   1   140 if ($^O eq 'MSWin32') {
12 0         0 require Win32;
13 0         0 my $cp = Win32::GetConsoleCP();
14 0         0 $encoding = ":encoding(cp$cp)";
15             } else {
16 1         23 $encoding = ':locale';
17             }
18             }
19 1     1   4806 use open ':std', $encoding;
  1         8557  
  1         15  
20              
21             package App::GitGerrit;
22             {
23             $App::GitGerrit::VERSION = '0.022';
24             }
25             # ABSTRACT: A container for functions for the git-gerrit program
26              
27 1     1   51326 use Pod::Usage;
  1         121511  
  1         169  
28 1     1   2048 use Getopt::Long qw(:config auto_version auto_help);
  1         19427  
  1         7  
29 1     1   1391 use URI;
  1         6231  
  1         38  
30 1     1   11 use URI::Escape;
  1         2  
  1         84  
31              
32             # App::GitGerrit was converted from a script into a module following this:
33             # http://elliotlovesperl.com/2009/11/23/how-to-structure-perl-programs/
34 1     1   5 use Exporter 'import';
  1         2  
  1         4687  
35             our @EXPORT_OK = qw/run/;
36              
37             # The $Command variable holds the name of the git-gerrit sub-command
38             # that's been invoked. It's defined in the 'run' routine below.
39              
40             our $Command;
41              
42             # The %Options hash is used to hold the command line options passed to
43             # all git-gerrit subcommands. The --debug option is common to all of
44             # them. Each subcommand supports a specific set of options which are
45             # grokked by the get_options routine below.
46              
47             my %Options = ( debug => 0, help => 0 );
48              
49             sub debug {
50 0     0 0   my ($msg) = @_;
51 0 0         warn 'git-gerrit[DEBUG]: ', $msg, "\n" if $Options{debug};
52             }
53              
54             sub info {
55 0     0 0   my ($msg) = @_;
56 0           warn 'git-gerrit[INFO]: ', $msg, "\n";
57             }
58              
59             sub error {
60 0     0 0   my ($msg) = @_;
61 0           die 'git-gerrit[ERROR]: ', $msg, "\n";
62             }
63              
64             sub syntax_error {
65 0     0 0   my ($msg) = @_;
66 0           pod2usage "git-gerrit[SYNTAX]: $msg\n";
67             }
68              
69             sub get_options {
70 0     0 0   my (@opt_specs) = @_;
71              
72             # Get defaults from configuration
73 0           foreach my $cmd ($Command, 'all') {
74 0 0         if (my $options = config("options.$cmd")) {
75 0           debug "$cmd: unshift default options: $options";
76 0           unshift @ARGV, split(' ', $options);
77             }
78             }
79              
80 0 0         GetOptions(\%Options, 'debug', 'help', @opt_specs) or pod2usage(2);
81 0 0         pod2usage({-exitval => 1, -verbose => 2}) if $Options{help};
82             }
83              
84             # The cmd routine is used to invoke shell commands, usually git. It
85             # prints out the command before invoking it in debug operation.
86              
87             sub cmd {
88 0     0 0   my ($cmd) = @_;
89 0           debug $cmd;
90 0           return system($cmd) == 0;
91             }
92              
93             # The grok_config routine returns a hash-ref mapping every Git
94             # configuration variable under the 'git-gerrit' section to its list of
95             # values.
96              
97             sub grok_config {
98 0     0 0   state $config;
99              
100 0 0         unless ($config) {
101 0           debug "git config --list";
102             {
103 0           open my $pipe, '-|', 'git config --list';
  0            
104 0           while (<$pipe>) {
105 0 0         if (/^(.+?)\.(\S+)=(.*)/) {
106 0           push @{$config->{$1}{$2}}, $3;
  0            
107             } else {
108 0           info "Strange git-config output: $_";
109             }
110             }
111             }
112              
113             # Now we must assume some configuration by default
114              
115 0   0       $config->{'git-gerrit'}{remote} //= ['origin'];
116              
117             my $remote_url = sub {
118 0     0     state $url;
119 0 0         unless ($url) {
120 0           my $remote = $config->{'git-gerrit'}{remote}[-1];
121 0 0         $url = $config->{remote}{"$remote.url"}[-1]
122             or error "The remote '$remote' isn't configured because there's no remote.$remote.url configuration";
123 0           $url = URI->new($url);
124             }
125 0           return $url;
126 0           };
127              
128 0 0         unless ($config->{'git-gerrit'}{baseurl}) {
129 0           my $url = $remote_url->();
130 0           $config->{'git-gerrit'}{baseurl} = [$url->scheme . '://' . $url->authority];
131             }
132 0           $config->{'git-gerrit'}{baseurl}[-1] =~ s:/+$::; # strip trailing slashes
133              
134 0 0         unless ($config->{'git-gerrit'}{project}) {
135 0           my $prefix = URI->new($config->{'git-gerrit'}{baseurl}[-1])->path;
136 0           my $path = $remote_url->()->path;
137 0 0         if (length $prefix) {
138 0 0         $prefix eq substr($path, 0, length($prefix))
139             or error <
140             I can't grok git-gerrit.project because git-gerrit.baseurl's path
141             doesn't match git-gerrit.remote's path:
142              
143             * baseurl:
144             EOF
145 0           $config->{'git-gerrit'}{project} = [substr($path, length($prefix))];
146             } else {
147 0           $config->{'git-gerrit'}{project} = [$path];
148             }
149             }
150 0           $config->{'git-gerrit'}{project}[-1] =~ s:^/+::; # strip leading slashes
151             }
152              
153 0           return $config;
154             }
155              
156             # The config routine returns the last value associated with Git's
157             # git-gerrit.$var configuration variable, as output by the 'git config
158             # -l' command, or undef if the variable isn't defined.
159              
160             sub config {
161 0     0 0   my ($var) = @_;
162 0           state $config = grok_config;
163 0 0         return exists $config->{'git-gerrit'}{$var} ? $config->{'git-gerrit'}{$var}[-1] : undef;
164             }
165              
166             # The configs routine returns all values associated with Git's
167             # git-gerrit.$var configuration variable or the empty list if the
168             # variable isn't defined.
169              
170             sub configs {
171 0     0 0   my ($var) = @_;
172 0           state $config = grok_config;
173 0 0         return exists $config->{'git-gerrit'}{$var} ? @{$config->{'git-gerrit'}{$var}} : ();
  0            
174             }
175              
176             # The install_commit_msg_hook routine is invoked by a few of
177             # git-gerrit subcommands. It checks if the current repository already
178             # has a commit-msg hook installed. If not, it tries to download and
179             # install Gerrit's default commit-msg hook, which inserts Change-Ids
180             # in commits messages.
181              
182             sub install_commit_msg_hook {
183 0     0 0   require File::Spec;
184              
185 0           chomp(my $git_dir = qx/git rev-parse --git-dir/);
186              
187             # Do nothing if it already exists
188 0           my $commit_msg = File::Spec->catfile($git_dir, 'hooks', 'commit-msg');
189 0 0         return if -e $commit_msg;
190              
191             # Otherwise, check if we need to mkdir the hooks directory
192 0           my $hooks_dir = File::Spec->catdir($git_dir, 'hooks');
193 0 0         mkdir $hooks_dir unless -e $hooks_dir;
194              
195             # Try to download and install the hook.
196 0           eval { require LWP::Simple };
  0            
197 0 0         if ($@) {
198 0           info "Cannot install $commit_msg hook because you don't have LWP::Simple installed";
199             } else {
200 0           info "Installing $commit_msg hook";
201 0 0         if (LWP::Simple::is_success(LWP::Simple::getstore(config('baseurl') . "/tools/hooks/commit-msg", $commit_msg))) {
202 0           chmod 0755, $commit_msg;
203             }
204             }
205             }
206              
207             # The credential_* routines below use the git-credential command to
208             # get and set credentials for git commands and also for Gerrit REST
209             # interactions.
210              
211             sub url_userinfo {
212 0     0 0   my ($url) = @_;
213 0 0         if (my $userinfo = $url->userinfo) {
214 0           return split /:/, $userinfo, 2;
215             } else {
216 0           return (undef, undef);
217             }
218             }
219              
220             sub credential_description_file {
221 0     0 0   my ($baseurl, $password) = @_;
222              
223 0           my %credential = (
224             protocol => $baseurl->scheme,
225             host => $baseurl->host,
226             path => $baseurl->path,
227             password => $password,
228             );
229              
230             # Try to get the username from the baseurl
231 0           my ($username) = url_userinfo($baseurl);
232 0 0         $credential{username} = $username if $username;
233              
234 0           require File::Temp;
235 0           my $fh = File::Temp->new();
236              
237 0           while (my ($key, $value) = each %credential) {
238 0 0         $fh->print("$key=$value\n") if $value;
239             }
240              
241 0           $fh->print("\n\n");
242 0           $fh->close();
243              
244 0           return ($fh, $fh->filename);
245             }
246              
247             my $git_credential_supported = 1;
248             sub get_credentials {
249 0     0 0   my $baseurl = URI->new(config('baseurl'));
250 0           my ($fh, $credfile) = credential_description_file($baseurl);
251              
252 0           my %credentials;
253 0           debug "Get credentials from git-credential";
254 0 0         open my $pipe, '-|', "git credential fill <$credfile"
255             or error "Can't open pipe to git-credential: $!";
256 0           while (<$pipe>) {
257 0           chomp;
258 0 0         $credentials{$1} = $2 if /^([^=]+)=(.*)/;
259             }
260 0 0         unless (close $pipe) {
261 0 0         error "Can't close pipe to git-credential: $!" if $!;
262              
263             # If we get here it is because the shell invoked by open
264             # above couldn't exec git-credential, which most probably
265             # means that we're using a pre-1.8 Git, which doesn't
266             # support git-credential yet.
267 0           $git_credential_supported = 0;
268             }
269              
270 0           my ($username, $password) = @credentials{qw/username password/};
271              
272 0 0 0       unless (defined $username && defined $password) {
273 0           debug "Get credentials from git-gerrit.baseurl";
274 0           ($username, $password) = url_userinfo(config('baseurl'));
275             }
276              
277 0 0 0       unless (defined $username && defined $password) {
278 0           debug "Get credentials from a .netrc file";
279 0 0         if (eval {require Net::Netrc}) {
  0            
280 0 0         if (my $mach = Net::Netrc->lookup(URI->new(config('baseurl'))->host, $username)) {
281 0           ($username, $password) = ($mach->login, $mach->password);
282             }
283             } else {
284 0           debug "Failed to require Net::Netrc";
285             }
286             }
287              
288 0 0 0       unless (defined $username && defined $password) {
289 0           debug "Prompt the user for the credentials";
290 0 0         if (eval {require Term::Prompt}) {
  0            
291 0           $username = Term::Prompt::prompt('x', 'Gerrit username: ', '', $ENV{USER});
292 0           $password = Term::Prompt::prompt('p', 'Gerrit password: ', '');
293 0           print "\n";
294             } else {
295 0           debug "Failed to require Term::Prompt";
296             }
297             }
298              
299 0 0         defined $username or error "Couldn't get credential's username";
300 0 0         defined $password or error "Couldn't get credential's password";
301              
302 0           return ($username, $password);
303             }
304              
305             sub set_credentials {
306 0     0 0   my ($username, $password, $what) = @_;
307              
308 0 0         return 1 unless $git_credential_supported;
309              
310 0 0         $what =~ /^(?:approve|reject)$/
311             or error "set_credentials \$what argument ($what) must be either 'approve' or 'reject'";
312              
313 0           my $baseurl = URI->new(config('baseurl'));
314 0           my ($fh, $credfile) = credential_description_file($baseurl, $password);
315              
316 0           return system("git credential $what <$credfile") == 0;
317             }
318              
319             # The get_message routine returns the message argument to the
320             # --message option. If the option is not present it invokes the git
321             # editor to let the user compose a message and returns it.
322              
323             sub get_message {
324 0 0   0 0   return $Options{message} if exists $Options{message};
325              
326 0           chomp(my $editor = qx/git var GIT_EDITOR/);
327              
328 0 0         error "Please, read 'git help var' to know how to set up an editor for git messages."
329             unless $editor;
330              
331 0           require File::Temp;
332 0           my $tmp = File::Temp->new();
333 0           my $filename = $tmp->filename;
334              
335             {
336 0 0         open my $fh, '>', $filename
  0            
337             or error "Can't open file for writing ($filename): $!\n";
338 0           print $fh <<'EOF';
339              
340             # Please enter the review message for this change. Lines starting
341             # with '#' will be ignored, and an empty message aborts the review.
342             EOF
343 0           close $fh;
344             }
345              
346 0 0         cmd "$editor $filename"
347             or error "Aborting because I couldn't invoke '$editor $filename'.";
348              
349 0           my $message;
350             {
351 0 0         open my $fh, '<', $filename
  0            
352             or error "Can't open file for reading ($filename): $!\n";
353 0           local $/ = undef; # slurp mode
354 0           $message = <$fh>;
355 0           close $fh;
356             }
357 0           $message =~ s/(?<=\n)#.*?\n//gs; # remove all lines starting with '#'
358 0           return $message;
359             }
360              
361             # The gerrit routine keeps a cached Gerrit::REST object to which it
362             # relays REST calls.
363              
364             sub gerrit {
365 0     0 0   my $method = shift;
366              
367 0           state $gerrit;
368 0 0         unless ($gerrit) {
369 0           my ($username, $password) = get_credentials;
370 0           require Gerrit::REST;
371 0           $gerrit = Gerrit::REST->new(config('baseurl'), $username, $password);
372 0           eval { $gerrit->GET("/projects/" . uri_escape_utf8(config('project'))) };
  0            
373 0 0         if (my $error = $@) {
374 0 0         set_credentials($username, $password, 'reject') if $error->{code} == 401;
375 0           die $error;
376             } else {
377 0           set_credentials($username, $password, 'approve');
378             }
379             }
380              
381 0 0         if ($Options{debug}) {
382 0           my ($endpoint, @args) = @_;
383 0           debug "GERRIT->$method($endpoint)";
384 0 0         if (@args) {
385 0           require Data::Dumper;
386 0           warn Data::Dumper::Dumper(@args);
387             }
388             }
389              
390 0           return $gerrit->$method(@_);
391             }
392              
393             # The gerrit_or_die routine relays its arguments to the gerrit routine
394             # but catches any exception and dies with a formatted message. It
395             # should be called instead of gerrit whenever the caller doesn't want
396             # to treat exceptions.
397              
398             sub gerrit_or_die {
399 0     0 0   my $result = eval { gerrit(@_) };
  0            
400 0 0         die $@->as_text if $@;
401 0           return $result;
402             }
403              
404             # The normalize_date routine removes the trailing zeroes from a $date.
405              
406             sub normalize_date {
407 0     0 0   my ($date) = @_;
408 0           $date =~ s/\.0+$//;
409 0           return $date;
410             }
411              
412             # The query_changes routine receives a list of strings to query the
413             # Gerrit server. It returns an array-ref containing a list of
414             # array-refs, each containing a list of change descriptions.
415              
416             sub query_changes {
417 0     0 0   my @queries = @_;
418              
419 0 0         return [] unless @queries;
420              
421             # If we're inside a git repository, restrict the query to the
422             # current project's reviews.
423 0 0         if (my $project = config('project')) {
424 0           $project = uri_escape_utf8($project);
425 0           @queries = map "q=project:$project+$_", @queries;
426             }
427              
428 0 0         push @queries, "n=$Options{limit}" if $Options{limit};
429              
430 0           push @queries, "o=LABELS";
431              
432 0           my $changes = gerrit_or_die(GET => "/changes/?" . join('&', @queries));
433 0 0         $changes = [$changes] if ref $changes->[0] eq 'HASH';
434              
435 0           return $changes;
436             }
437              
438             # The get_change routine returns the description of a change
439             # identified by $id. An optional boolean second argument ($allrevs)
440             # tells if the change description should contain a description of all
441             # patchsets or just the current one.
442              
443             sub get_change {
444 0     0 0   my ($id, $allrevs) = @_;
445              
446 0 0         my $revs = $allrevs ? 'ALL_REVISIONS' : 'CURRENT_REVISION';
447 0           return (gerrit_or_die(GET => "/changes/?q=change:$id&o=$revs"))[0][0];
448             }
449              
450             # The current_branch routine returns the name of the current branch or
451             # 'HEAD' in a dettached head state.
452              
453             sub current_branch {
454 0     0 0   chomp(my $branch = qx/git rev-parse --abbrev-ref HEAD/);
455 0           return $branch;
456             }
457              
458             # The update_branch routine receives a local $branch name and updates
459             # it with the homonym branch in the Gerrit remote.
460              
461             sub update_branch {
462 0     0 0   my ($branch) = @_;
463              
464 0           my $remote = config('remote');
465 0           cmd "git fetch $remote $branch:$branch";
466             }
467              
468             # The change_branch_info routine receives the name of a branch. If
469             # it's a change-branch, it returns a two-element list containing it's
470             # upstream name and its id. Otherwise, it returns the empty list.
471              
472             sub change_branch_info {
473 0     0 0   my ($branch) = @_;
474 0 0         if ($branch =~ m:^change/(?.*)/(?[^/]+):) {
475 1     1   2315 return ($+{upstream}, $+{id});
  1         5775  
  1         9812  
  0            
476             }
477 0           return;
478             }
479              
480             # The current_change_id routine returns the id of the change branch
481             # we're currently in. If we're not in a change branch, it returns
482             # undef.
483              
484             sub current_change_id {
485 0     0 0   my ($branch, $id) = change_branch_info(current_branch);
486              
487 0           return $id;
488             }
489              
490             # This routine receives the hash-ref mapped to the 'Code-Review' label
491             # in a change's 'labels' key when it's fetched with the option
492             # LABELS. For more information, please read:
493             # https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
494              
495             sub code_review {
496 0     0 0   my ($cr) = @_;
497 0 0         if (! defined $cr) {
    0          
    0          
    0          
    0          
498 0           return '';
499             } elsif (exists $cr->{rejected}) {
500 0           return '-2';
501             } elsif (exists $cr->{disliked}) {
502 0           return '-1';
503             } elsif (exists $cr->{approved}) {
504 0           return '+2';
505             } elsif (exists $cr->{recommended}) {
506 0           return '+1';
507             } else {
508 0           return '';
509             }
510             }
511              
512             # This routine receives a branch name (normally the upstream of a
513             # change-branch) and returns a list of users matching the
514             # git-gerrit.reviewers specifications. The list returned is guaranteed
515             # to have no duplicates.
516              
517             sub auto_reviewers {
518 0     0 0   my ($upstream) = @_;
519 0           my $paths;
520              
521             my @reviewers;
522              
523             REVIEWERS:
524 0           foreach my $spec (configs('reviewers')) {
525 0 0         if (my ($users, @conditions) = split ' ', $spec) {
526 0 0         if (@conditions) {
527             CONDITION:
528 0           foreach my $condition (@conditions) {
529 0 0         if (my ($what, $op, $match) = ($condition =~ /^(branch|path)([=~])(.+)$/i)) {
530 0 0         if ($what eq 'branch') {
531 0 0         if ($op eq '=') {
532 0 0         next CONDITION if $upstream eq $match;
533             } else {
534 0           my $regex = eval { qr/$match/ };
  0            
535 0 0 0       defined $regex
536             or info "Warning: skipping git-gerrit.reviewers spec with invalid REGEXP ($match)."
537             and next REVIEWERS;
538 0 0         next CONDITION if $upstream =~ $match;
539             }
540             } else {
541 0 0         unless ($paths) {
542 0           $paths = [qx/git diff --name-only ${upstream}..HEAD/];
543 0           chomp @$paths;
544             }
545 0 0         if ($op eq '=') {
546 0           foreach my $path (@$paths) {
547 0 0         next CONDITION if $path eq $match;
548             }
549             } else {
550 0           my $regex = eval { qr/$match/ };
  0            
551 0 0 0       defined $regex
552             or info "Warning: skipping git-gerrit.reviewers spec with invalid REGEXP ($match)."
553             and next REVIEWERS;
554 0           foreach my $path (@$paths) {
555 0 0         next CONDITION if $path =~ $regex;
556             }
557             }
558             }
559             } else {
560 0           info "Warning: skipping git-gerrit.reviewers spec with invalid condition ($condition).";
561             }
562 0           next REVIEWERS;
563             }
564             }
565 0           push @reviewers, split(/,/, $users);
566             }
567             }
568              
569             # Use a hash to remove duplicates
570 0           my %reviewers = map {$_ => undef} @reviewers;
  0            
571 0           return keys %reviewers;
572             }
573              
574             # This routine is used by all sub-commands that accept zero or more
575             # change ids. If @ARGV is empty it pushes into it the id of the change
576             # associated with the current change-branch, if any. It returns a
577             # boolean telling if the push has been made.
578              
579             sub grok_unspecified_change {
580 0 0   0 0   if (@ARGV) {
581 0           return 0;
582             } else {
583 0 0         my $id = current_change_id()
584             or syntax_error "$Command: You have to be in a change-branch or specify at least one CHANGE.";
585 0 0         $id =~ /^\d+$/
586             or error "$Command: The change-branch you're in haven't been pushed yet.";
587 0           @ARGV = ($id);
588 0           return 1;
589             }
590             }
591              
592             # This routine is used by the sub-commands that, when applied
593             # successfully to the current change-branch, want to checkout its
594             # upstream and remove the change-branch.
595              
596             sub checkout_upstream_and_delete_branch {
597 0     0 0   my $branch = current_branch;
598 0           my ($upstream) = change_branch_info($branch);
599 0 0         cmd "git checkout $upstream" and cmd "git branch -D $branch";
600             }
601              
602             ############################################################
603             # MAIN
604              
605             # Each git-gerrit subcommand is implemented by an anonymous routine
606             # associated with one or more names in the %Commands hash.
607              
608             my %Commands;
609              
610             $Commands{new} = sub {
611             get_options('update');
612              
613             my $topic = shift @ARGV
614             or syntax_error "$Command: Missing TOPIC.";
615              
616             $topic !~ m:/:
617             or error "$Command: the topic name ($topic) should not contain slashes.";
618              
619             $topic =~ m:\D:
620             or error "$Command: the topic name ($topic) should contain at least one non-digit character.";
621              
622             my $branch = shift @ARGV || current_branch;
623              
624             if (my ($upstream, $id) = change_branch_info($branch)) {
625             # If we're on a change-branch the new change-branch is based on the same upstream
626             $branch = $upstream;
627             }
628              
629             my $status = qx/git status --porcelain --untracked-files=no/;
630              
631             info "Warning: git-status tells me that your working area is dirty:\n$status\n"
632             if length $status;
633              
634             if ($Options{update}) {
635             update_branch($branch)
636             or error "$Command: Non-fast-forward pull. Please, merge or rebase your branch first.";
637             }
638              
639             cmd "git checkout -b change/$branch/$topic $branch";
640              
641             install_commit_msg_hook;
642              
643             return;
644             };
645              
646             $Commands{push} = sub {
647             $Options{rebase} = ''; # false by default
648             get_options(
649             'keep',
650             'force+',
651             'rebase!',
652             'draft',
653             'topic=s',
654             'submit',
655             'base=s',
656             'reviewer=s@',
657             'cc=s@'
658             );
659              
660             my $branch = current_branch;
661              
662             my ($upstream, $id) = change_branch_info($branch)
663             or error "$Command: You aren't in a change branch. I cannot push it.";
664              
665             my $is_clean = qx/git status --porcelain --untracked-files=no/ eq '';
666              
667             $is_clean or $Options{force}--
668             or error <
669             push: Can't push change because git-status is dirty.
670             If this is really what you want to do, please try again with --force.
671             EOF
672              
673             my @commits = qx/git log --decorate=no --first-parent --oneline ${upstream}..HEAD/;
674             if (@commits == 0) {
675             error "$Command: no changes between $upstream and $branch. Pushing would be pointless.";
676             } elsif (@commits > 1) {
677             error <
678             push: you have more than one commit that you are about to push.
679             The outstanding commits are:
680              
681             @commits
682             If this is really what you want to do, please try again with --force.
683             EOF
684             }
685              
686             # Grok the list of parent commits to see if it's a merge commit.
687             my @parents = split / /, qx/git log --pretty='format:%p' -1/;
688              
689             # A --noverbose option sets $Options{rebase} to '0'.
690             if ($is_clean && (@parents < 2) && ($Options{rebase} || $Options{rebase} eq '' && $id =~ /\D/)) {
691             update_branch($upstream)
692             or error "$Command: Non-fast-forward pull. Please, merge or rebase your branch first.";
693             cmd "git rebase $upstream"
694             or error "$Command: please resolve this 'git rebase $upstream' and try again.";
695             }
696              
697             my $refspec = 'HEAD:refs/' . ($Options{draft} ? 'draft' : 'for') . "/$upstream";
698              
699             my @tags;
700             if (my $topic = $Options{topic}) {
701             push @tags, "topic=$topic";
702             } elsif ($id =~ /\D/) {
703             push @tags, "topic=$id";
704             }
705              
706             my @reviewers = auto_reviewers($upstream);
707             if (my $reviewers = $Options{reviewer}) {
708             push @reviewers, split(/,/, join(',', @$reviewers));
709             }
710             if (@reviewers) {
711             push @tags, map("r=$_", @reviewers);
712             }
713              
714             if (my $ccs = $Options{cc}) {
715             push @tags, map("cc=$_", split(/,/, join(',', @$ccs)));
716             }
717             if ($Options{submit}) {
718             push @tags, 'submit';
719             }
720             if (my $base = $Options{base}) {
721             push @tags, "base=$base";
722             }
723             if (@tags) {
724             $refspec .= '%';
725             $refspec .= join(',', @tags);
726             }
727              
728             my $remote = config('remote');
729             cmd "git push $remote $refspec"
730             or error "$Command: Error pushing change.";
731              
732             if ($is_clean && ! $Options{keep}) {
733             cmd "git checkout $upstream" and cmd "git branch -D $branch";
734             }
735              
736             install_commit_msg_hook;
737              
738             return;
739             };
740              
741             $Commands{query} = sub {
742             get_options(
743             'verbose',
744             'limit=i',
745             );
746              
747             my (@names, @queries);
748             foreach my $arg (@ARGV) {
749             if ($arg =~ /(?.*?)=(?.*)/) {
750             push @names, $+{name};
751             push @queries, $+{query};
752             } else {
753             push @names, "QUERY";
754             push @queries, $arg;
755             }
756             }
757              
758             my $changes = query_changes(@queries);
759              
760             for (my $i=0; $i < @$changes; ++$i) {
761             print "[$names[$i]=$queries[$i]]\n";
762             next unless @{$changes->[$i]};
763              
764             require Text::Table;
765             my $table = Text::Table->new("ID\n&num", qw/STATUS CR UPDATED PROJECT BRANCH OWNER SUBJECT/);
766              
767             foreach my $change (sort {$b->{updated} cmp $a->{updated}} @{$changes->[$i]}) {
768             if ($Options{verbose}) {
769             if (my $topic = gerrit_or_die(GET => "/changes/$change->{id}/topic")) {
770             $change->{branch} .= " ($topic)";
771             }
772             }
773             $table->add(
774             $change->{_number},
775             $change->{status},
776             code_review($change->{labels}{'Code-Review'}),
777             normalize_date($change->{updated}),
778             $change->{project},
779             $change->{branch},
780             $change->{owner}{name},
781             $change->{subject},
782             );
783             }
784             print $table->table(), "\n";
785             }
786              
787             return;
788             };
789              
790             my %StandardQueries = (
791             changes => [
792             'Outgoing reviews=is:open+owner:self',
793             'Incoming reviews=is:open+reviewer:self+-owner:self',
794             'Recently closed=is:closed+owner:self+-age:1mon',
795             ],
796             drafts => ['Drafts=is:draft'],
797             watched => ['Watched changes=is:watched+status:open'],
798             starred => ['Starred changes=is:starred'],
799             );
800             $Commands{my} = sub {
801             if (@ARGV) {
802             if (exists $StandardQueries{$ARGV[-1]}) {
803             splice @ARGV, -1, 1, @{$StandardQueries{$ARGV[-1]}};
804             } elsif ($ARGV[-1] =~ /^-/) {
805             # By default we show 'My Changes'
806             push @ARGV, @{$StandardQueries{changes}};
807             } else {
808             syntax_error "$Command: Invalid change specification: '$ARGV[-1]'";
809             }
810             } else {
811             # By default we show 'My Changes'
812             push @ARGV, @{$StandardQueries{changes}};
813             }
814              
815             $Commands{query}();
816              
817             return;
818             };
819              
820             $Commands{show} = sub {
821             get_options();
822              
823             grok_unspecified_change;
824              
825             foreach my $id (@ARGV) {
826             my $change = gerrit_or_die(GET => "/changes/$id/detail");
827              
828             print <
829             Change-Num: $change->{_number}
830             Change-Id: $change->{change_id}
831             Subject: $change->{subject}
832             Owner: $change->{owner}{name}
833             EOF
834              
835             foreach my $date (qw/created updated/) {
836             $change->{$date} = normalize_date($change->{$date})
837             if exists $change->{$date};
838             }
839              
840             foreach my $key (qw/project branch topic created updated status reviewed mergeable/) {
841             printf "%12s %s\n", "\u$key:", $change->{$key}
842             if exists $change->{$key};
843             }
844              
845             print "\n";
846             # We want to produce a table in which the first column lists the
847             # reviewer names and the other columns have their votes for each
848             # label. However, the change object has this information
849             # inverted. So, we have to first collect all votes.
850             my @labels = sort keys %{$change->{labels}};
851             my %reviewers;
852             while (my ($label, $info) = each %{$change->{labels}}) {
853             foreach my $vote (@{$info->{all}}) {
854             $reviewers{$vote->{name}}{$label} = $vote->{value};
855             }
856             }
857              
858             # And now we can output the vote table
859             require Text::Table;
860             my $table = Text::Table->new('REVIEWER', map {"$_\n&num"} @labels);
861              
862             foreach my $name (sort keys %reviewers) {
863             my @votes = map {$_ > 0 ? "+$_" : $_} map {defined $_ ? $_ : '0'} @{$reviewers{$name}}{@labels};
864             $table->add($name, @votes);
865             }
866             print $table->table(), '-' x 60, "\n";
867             }
868              
869             return;
870             };
871              
872             $Commands{fetch} = sub {
873             get_options();
874              
875             grok_unspecified_change;
876              
877             my $branch;
878             my $project = config('project');
879             my @change_branches;
880             foreach my $id (@ARGV) {
881             my $change = get_change($id);
882              
883             $change->{project} eq $project
884             or error "$Command: Change $id belongs to a different project ($change->{project}), not $project";
885              
886             my ($revision) = values %{$change->{revisions}};
887              
888             my ($url, $ref) = @{$revision->{fetch}{http}}{qw/url ref/};
889              
890             $branch = "change/$change->{branch}/$change->{_number}";
891              
892             cmd "git fetch $url $ref:$branch"
893             or error "$Command: Can't fetch $url";
894              
895             push @change_branches, $branch;
896             }
897              
898             return @change_branches;
899             };
900              
901             $Commands{checkout} = $Commands{co} = sub {
902             my $last_change_branch = do {
903             local $Command = 'fetch';
904             my @change_branches = $Commands{fetch}->();
905             $change_branches[-1];
906             };
907              
908             cmd "git checkout $last_change_branch";
909              
910             return;
911             };
912              
913             $Commands{upstream} = $Commands{up} = sub {
914             get_options(
915             'keep',
916             'delete',
917             );
918              
919             my $branch = current_branch;
920              
921             if (my ($upstream, $id) = change_branch_info($branch)) {
922             if (cmd "git checkout $upstream") {
923             if ($Options{keep} || ! $Options{delete} && $id =~ /\D/) {
924             info "Keeping $branch";
925             } else {
926             cmd "git branch -D $branch";
927             }
928             }
929             } else {
930             error "$Command: You aren't in a change branch. There is no upstream to go to.";
931             }
932              
933             return;
934             };
935              
936             $Commands{'cherry-pick'} = $Commands{cp} = sub {
937             get_options(
938             'edit',
939             'no-commit',
940             );
941              
942             my @args;
943             push @args, '--edit' if $Options{edit};
944             push @args, '--no-commit' if $Options{'no-commit'};
945              
946             @ARGV or syntax_error "$Command: Missing CHANGE.";
947              
948             my @change_branches = do {
949             local $Command = 'fetch';
950             $Commands{fetch}->();
951             };
952              
953             cmd join(' ', 'git cherry-pick', @args, @change_branches);
954              
955             return;
956             };
957              
958             $Commands{rebase} = sub {
959             get_options();
960              
961             my ($upstream, $id) = change_branch_info(current_branch)
962             or error "$Command: You must be in a change branch to invoke rebase.";
963              
964             cmd "git rebase $upstream"
965             or error "$Command: please resolve this 'git rebase $upstream' and try again.";
966             };
967              
968             $Commands{reviewer} = sub {
969             get_options(
970             'add=s@',
971             'confirm',
972             'delete=s@',
973             );
974              
975             grok_unspecified_change;
976              
977             foreach my $id (@ARGV) {
978             # First try to make all deletions
979             if (my $users = $Options{delete}) {
980             foreach my $user (split(/,/, join(',', @$users))) {
981             $user = uri_escape_utf8($user);
982             gerrit_or_die(DELETE => "/changes/$id/reviewers/$user");
983             }
984             }
985              
986             # Second try to make all additions
987             if (my $users = $Options{add}) {
988             my $confirm = $Options{confirm} ? 'true' : 'false';
989             foreach my $user (split(/,/, join(',', @$users))) {
990             gerrit_or_die(POST => "/changes/$id/reviewers", { reviewer => $user, confirm => $confirm});
991             }
992             }
993              
994             # Finally, list current reviewers
995             my $reviewers = gerrit_or_die(GET => "/changes/$id/reviewers");
996              
997             print "[$id]\n";
998             require Text::Table;
999             my %labels = map {$_ => undef} map {keys %{$_->{approvals}}} @$reviewers;
1000             my @labels = sort keys %labels;
1001             my $table = Text::Table->new('REVIEWER', map {"$_\n&num"} @labels);
1002             $table->add($_->{name}, @{$_->{approvals}}{@labels})
1003             foreach sort {$a->{name} cmp $b->{name}} @$reviewers;
1004             print $table->table(), '-' x 60, "\n";
1005             }
1006              
1007             return;
1008             };
1009              
1010             $Commands{review} = sub {
1011             get_options(
1012             'message=s',
1013             'keep',
1014             );
1015              
1016             my %review;
1017              
1018             if (my $message = get_message) {
1019             $review{message} = $message;
1020             }
1021              
1022             # Set all votes
1023             while (@ARGV && $ARGV[0] =~ /(?
1024             shift @ARGV;
1025             $review{labels}{$+{label} || 'Code-Review'} = $+{vote};
1026             $+{vote} =~ /^[+-]?\d$/
1027             or syntax_error "$Command: Invalid vote ($+{vote}). It must be a single digit optionally prefixed by a [-+] sign.";
1028             }
1029              
1030             error "$Command: Invalid vote $ARGV[0]." if @ARGV > 1;
1031              
1032             error "$Command: You must specify a message or a vote to review."
1033             unless keys %review;
1034              
1035             my $local_change = grok_unspecified_change;
1036              
1037             foreach my $id (@ARGV) {
1038             gerrit_or_die(POST => "/changes/$id/revisions/current/review", \%review);
1039             }
1040              
1041             checkout_upstream_and_delete_branch
1042             if $local_change && ! $Options{keep};
1043              
1044             return;
1045             };
1046              
1047             $Commands{abandon} = sub {
1048             get_options(
1049             'message=s',
1050             'keep',
1051             );
1052              
1053             my @args;
1054              
1055             if (my $message = get_message) {
1056             push @args, { message => $message };
1057             }
1058              
1059             my $local_change = grok_unspecified_change;
1060              
1061             foreach my $id (@ARGV) {
1062             gerrit_or_die(POST => "/changes/$id/abandon", @args);
1063             }
1064              
1065             checkout_upstream_and_delete_branch
1066             if $local_change && ! $Options{keep};
1067              
1068             return;
1069             };
1070              
1071             $Commands{restore} = sub {
1072             get_options('message=s');
1073              
1074             my @args;
1075              
1076             if (my $message = get_message) {
1077             push @args, { message => $message };
1078             }
1079              
1080             grok_unspecified_change;
1081              
1082             foreach my $id (@ARGV) {
1083             gerrit_or_die(POST => "/changes/$id/restore", @args);
1084             }
1085              
1086             return;
1087             };
1088              
1089             $Commands{revert} = sub {
1090             get_options('message=s');
1091              
1092             my @args;
1093              
1094             if (my $message = get_message) {
1095             push @args, { message => $message };
1096             }
1097              
1098             grok_unspecified_change;
1099              
1100             foreach my $id (@ARGV) {
1101             gerrit_or_die(POST => "/changes/$id/revert", @args);
1102             }
1103              
1104             return;
1105             };
1106              
1107             $Commands{submit} = sub {
1108             get_options(
1109             'no-wait-for-merge',
1110             'keep',
1111             );
1112              
1113             my @args;
1114             push @args, { wait_for_merge => 'true' } unless $Options{'no-wait-for-merge'};
1115              
1116             my $local_change = grok_unspecified_change;
1117              
1118             foreach my $id (@ARGV) {
1119             gerrit_or_die(POST => "/changes/$id/submit", @args);
1120             }
1121              
1122             checkout_upstream_and_delete_branch
1123             if $local_change && ! $Options{keep};
1124              
1125             return;
1126             };
1127              
1128             $Commands{web} = sub {
1129             # The 'gerrit web' sub-command passes all of its options,
1130             # but --debug, to 'git web--browse'.
1131             Getopt::Long::Configure('pass_through');
1132             get_options();
1133              
1134             # If the user is passing any option we require that it mark where
1135             # they end with a '--' so that we know where the CHANGEs arguments
1136             # start.
1137             my @options;
1138             for (my $i = 0; $i < @ARGV; ++$i) {
1139             if ($ARGV[$i] eq '--') {
1140             # We found a mark. Let's move all the options from @ARGV
1141             # to @options and get rid of the mark.
1142             @options = splice @ARGV, 0, $i;
1143             shift @ARGV;
1144             last;
1145             }
1146             }
1147              
1148             grok_unspecified_change;
1149              
1150             # Grok the URLs of each change
1151             my @urls;
1152             my $baseurl = config('baseurl');
1153             foreach my $id (@ARGV) {
1154             my $change = get_change($id);
1155             push @urls, "$baseurl/#/c/$change->{_number}";
1156             }
1157              
1158             cmd join(' ', qw/git web--browse/, @options, @urls);
1159             };
1160              
1161             $Commands{config} = sub {
1162             my $config = grok_config;
1163             my $git_gerrit = $config->{'git-gerrit'}
1164             or return;
1165             require Text::Table;
1166             my $table = Text::Table->new();
1167             foreach my $var (sort keys %$git_gerrit) {
1168             foreach my $value (@{$git_gerrit->{$var}}) {
1169             $table->add("git-gerrit.$var", $value);
1170             }
1171             }
1172             print $table->table(), "\n";
1173              
1174             return;
1175             };
1176              
1177             $Commands{version} = sub {
1178             print "Perl version $^V\n";
1179             print "git-gerrit version $App::GitGerrit::VERSION\n";
1180             cmd "git version";
1181             my $baseurl = config('baseurl'); # die unless configured
1182             my $version = eval { gerrit(GET => '/config/server/version') };
1183             $version //= "pre-2.7 (Because it doesn't support the 'Get Version' REST Endpoint.)";
1184             print "Gerrit version $version\n";
1185             return;
1186             };
1187              
1188             # MAIN
1189              
1190             sub run {
1191 0 0   0 0   $Command = shift @ARGV
1192             or syntax_error "Missing command name.";
1193              
1194 0 0         exists $Commands{$Command}
1195             or syntax_error "Invalid command: $Command.";
1196              
1197 0           $Commands{$Command}->();
1198              
1199 0           return 0;
1200             }
1201              
1202             1;
1203              
1204             __END__