File Coverage

lib/Git/Repository/Plugin/GitHooks.pm
Criterion Covered Total %
statement 120 663 18.1
branch 25 358 6.9
condition 9 56 16.0
subroutine 18 67 26.8
pod 40 40 100.0
total 212 1184 17.9


line stmt bran cond sub pod time code
1 15     15   12063 use warnings;
  15         36  
  15         930  
2              
3             package Git::Repository::Plugin::GitHooks;
4             # ABSTRACT: A Git::Repository plugin with some goodies for hook developers
5             $Git::Repository::Plugin::GitHooks::VERSION = '3.4.0';
6 15     15   6795 use parent qw/Git::Repository::Plugin/;
  15         4628  
  15         93  
7              
8 15     15   15831 use v5.16.0;
  15         57  
9 15     15   87 use utf8;
  15         38  
  15         230  
10 15     15   338 use Carp;
  15         39  
  15         759  
11 15     15   102 use Path::Tiny;
  15         31  
  15         626  
12 15     15   7179 use IO::Interactive 'is_interactive';
  15         14938  
  15         103  
13 15     15   7685 use Log::Any '$log';
  15         128911  
  15         78  
14              
15             sub _keywords { ## no critic (ProhibitUnusedPrivateSubroutines)
16              
17             return
18 17     17   1470 qw/
19             prepare_hook load_plugins invoke_external_hooks
20              
21             post_hook post_hooks
22              
23             cache
24              
25             get_config get_config_boolean get_config_integer
26              
27             check_timeout
28              
29             fault get_faults fail_on_faults
30              
31             undef_commit empty_tree get_commit get_commits
32              
33             read_commit_msg_file write_commit_msg_file
34              
35             get_affected_refs get_affected_ref_range get_affected_ref_commits
36              
37             filter_name_status_in_index filter_name_status_in_range filter_name_status_in_commit
38              
39             filter_files_in_index filter_files_in_range filter_files_in_commit
40              
41             authenticated_user repository_name
42              
43             get_current_branch get_sha1 get_head_or_empty_tree
44              
45             blob file_size file_mode
46              
47             is_reference_enabled match_user im_admin grok_acls
48             /;
49             }
50              
51             # This package variable tells get_config which character encoding is used in
52             # the output of the git-config command. Usually none, and decoding isn't
53             # necessary. But sometimes it is...
54             our $CONFIG_ENCODING = undef;
55              
56             ##############
57             # The following routines prepare the arguments for some hooks to make
58             # it easier to deal with them later on.
59              
60             # Some hooks get information from STDIN as text lines with
61             # space-separated fields. This routine reads up all of STDIN and tucks
62             # that information in the Git::Repository object.
63              
64             sub _push_input_data {
65 0     0   0 my ($git, $data) = @_;
66 0         0 push @{$git->{_plugin_githooks}{input_data}}, $data;
  0         0  
67 0         0 return;
68             }
69              
70             sub _get_input_data {
71 0     0   0 my ($git) = @_;
72 0   0     0 return $git->{_plugin_githooks}{input_data} || [];
73             }
74              
75             sub _prepare_input_data {
76 0     0   0 my ($git) = @_;
77 0         0 while () { ## no critic (InputOutput::ProhibitExplicitStdin)
78 0         0 chomp;
79 0         0 _push_input_data($git, [split]);
80             }
81 0         0 my $input_data = _get_input_data($git);
82 0         0 $log->info(_prepare_input_data => {input_data => $input_data});
83 0         0 return $input_data;
84             }
85              
86             # The pre-receive and post-receive hooks get the list of affected
87             # commits via STDIN. This routine gets them all and set all affected
88             # refs in the Git object.
89              
90             sub _prepare_receive {
91 0     0   0 my ($git) = @_;
92 0         0 foreach (@{_prepare_input_data($git)}) {
  0         0  
93 0         0 my ($old_commit, $new_commit, $ref) = @$_;
94 0         0 _set_affected_ref($git, $ref, $old_commit, $new_commit);
95             }
96 0         0 return;
97             }
98              
99             # The update hook get three arguments telling which reference is being
100             # updated, from which commit, to which commit. Here we use these
101             # arguments to set the affected ref in the Git object.
102              
103             sub _prepare_update {
104 0     0   0 my ($git, $args) = @_;
105 0         0 _set_affected_ref($git, @$args);
106 0         0 $log->debug(_prepare_update => {affected_refs => _get_affected_refs_hash($git)});
107 0         0 return;
108             }
109              
110             # Gerrit hooks get a list of option/value pairs. Here we convert the list into a
111             # hash and change the original argument list into a single hash-ref. We also
112             # record information about the user performing the push. Based on:
113             # https://gerrit.googlesource.com/plugins/hooks/+/refs/heads/master/src/main/resources/Documentation/hooks.md
114              
115             sub _prepare_gerrit_args {
116 0     0   0 my ($git, $args) = @_;
117              
118 0         0 my %opt = @$args;
119              
120             # Each Gerrit hook receive the full name and email of the user
121             # performing the hooked operation via a specific option in the
122             # format "User Name (email@example.net)". Here we grok it.
123             my $user =
124             $opt{'--uploader'} ||
125             $opt{'--author'} ||
126             $opt{'--submitter'} ||
127             $opt{'--abandoner'} ||
128             $opt{'--restorer'} ||
129 0   0     0 $opt{'--reviewer'} ||
130             undef;
131              
132             # Here we make the name and email available in two environment variables
133             # (GERRIT_USER_NAME and GERRIT_USER_EMAIL) so that
134             # Git::Repository::Plugin::GitHooks::authenticated_user can more easily
135             # grok the userid from them later.
136 0 0 0     0 if ($user && $user =~ /([^\(]+)\s+\(([^\)]+)\)/) {
137 0         0 $ENV{GERRIT_USER_NAME} = $1; ## no critic (Variables::RequireLocalizedPunctuationVars)
138 0         0 $ENV{GERRIT_USER_EMAIL} = $2; ## no critic (Variables::RequireLocalizedPunctuationVars)
139             }
140              
141 0         0 $log->debug(_prepare_gerrit_args => {opt => \%opt});
142              
143             # Now we create a Gerrit::REST object connected to the Gerrit
144             # server and tack it to the hook arguments so that Gerrit plugins
145             # can interact with it.
146              
147             # We 'require' the module instead of 'use' it because it's only
148             # used if one sets up Gerrit hooks, which may not be the most
149             # common usage of Git::Hooks.
150 0 0       0 eval {require Gerrit::REST}
  0         0  
151             or croak __PACKAGE__, ": Please, install the Gerrit::REST module to use Gerrit hooks.\n";
152              
153 0         0 $opt{gerrit} = do {
154 0         0 my %info;
155 0         0 foreach my $arg (qw/url username password/) {
156 0 0       0 $info{$arg} = $git->get_config('githooks.gerrit' => $arg)
157             or croak __PACKAGE__, ": Missing githooks.gerrit.$arg configuration variable.\n";
158             }
159              
160 0         0 Gerrit::REST->new(@info{qw/url username password/});
161             };
162              
163 0         0 @$args = (\%opt);
164              
165 0         0 $git->{_plugin_githooks}{gerrit_args} = \%opt;
166              
167 0         0 return;
168             }
169              
170             # The ref-update and the commit-received Gerrit hooks are invoked synchronously
171             # when a user pushes commits to a branch. So, they act much like Git's standard
172             # 'update' hook. This routine prepares the options as usual and sets the
173             # affected ref accordingly. The documented arguments for the hook are these:
174              
175             # ref-update --project --refname --uploader
176             # --uploader-username --oldrev --newrev
177              
178             # commit-received --project --refname --uploader
179             # --uploader-username --oldrev --newrev
180             # --cmdref
181              
182             sub _prepare_gerrit_ref_update {
183 0     0   0 my ($git, $args) = @_;
184              
185 0         0 _prepare_gerrit_args($git, $args);
186              
187             # The --refname argument contains the branch short-name if it's in the
188             # refs/heads/ namespace. But we need to always use the branch long-name,
189             # so we change it here.
190 0         0 my $refname = $args->[0]{'--refname'};
191 0 0       0 $refname = "refs/heads/$refname"
192             unless $refname =~ m:^refs/:;
193              
194 0         0 _set_affected_ref($git, $refname, @{$args->[0]}{qw/--oldrev --newrev/});
  0         0  
195 0         0 $log->debug(_prepare_gerrit_ref_update => {affected_refs => _get_affected_refs_hash($git)});
196 0         0 return;
197             }
198              
199             # The submit Gerrit hook is invoked synchronously when a user tries to submit a
200             # change. So, it acts much like Git's standard 'update' hook. This routine
201             # prepares the options as usual and sets the affected ref accordingly. The
202             # documented arguments for the hook are these:
203              
204             # submit --project --branch --submitter
205             # --patchset --commit
206              
207             sub _prepare_gerrit_submit {
208 0     0   0 my ($git, $args) = @_;
209              
210 0         0 _prepare_gerrit_args($git, $args);
211              
212             # The --branch argument contains the branch short-name if it's in the
213             # refs/heads/ namespace. But we need to always use the branch long-name,
214             # so we change it here.
215 0         0 my $refname = $args->[0]{'--branch'};
216 0 0       0 $refname = "refs/heads/$refname"
217             unless $refname =~ m:^refs/:;
218              
219 0         0 my $parent = $git->get_sha1("$refname^");
220              
221 0         0 _set_affected_ref($git, $refname, $parent, $args->[0]{'--commit'});
222 0         0 $log->debug(_prepare_gerrit_submit => {affected_refs => _get_affected_refs_hash($git)});
223 0         0 return;
224             }
225              
226             # The following routine is the post_hook used by the Gerrit hooks
227             # patchset-created and draft-published. It basically casts a vote on the
228             # patchset based on the errors found during the hook processing.
229              
230             sub _gerrit_patchset_post_hook {
231 0     0   0 my ($hook_name, $git, $args) = @_;
232              
233 0         0 for my $arg (qw/project branch change patchset/) {
234 0 0       0 exists $args->{"--$arg"}
235             or croak __PACKAGE__, ": Missing --$arg argument to Gerrit's $hook_name hook.\n";
236             }
237              
238             # We have to use the most complete form of Gerrit change ids because
239             # it's the only unanbiguous one. Vide:
240             # https://gerrit.cpqd.com.br/Documentation/rest-api-changes.html#change-id.
241              
242             # Up to Gerrit 2.12 the argument --change passed the change's Change-Id
243             # code. So, we had to build the complete change id using the information
244             # passed on the arguments --project and --branch. From Gerrit 2.13 on
245             # the --change argument already contains the complete change id. So we
246             # have to figure out if we need to build it or not.
247              
248             # Also, for the old Gerrit we have to url-escape the change-id because
249             # the project name may contain slashes (and perhaps other reserved
250             # characters). This is possibly not a complete solution. Vide:
251             # http://mark.stosberg.com/blog/2010/12/percent-encoding-uris-in-perl.html.
252              
253 0         0 require URI::Escape;
254             my $id = $args->{'--change'} =~ /~/
255             ? $args->{'--change'}
256 0 0       0 : URI::Escape::uri_escape(join('~', @{$args}{qw/--project --branch --change/}));
  0         0  
257              
258 0         0 my $patchset = $args->{'--patchset'};
259              
260             # Grok all configuration options at once to make it easier to deal with them below.
261 0         0 my %cfg = (
262             'votes-to-approve' => $git->get_config('githooks.gerrit' => 'votes-to-approve'),
263             'votes-to-reject' => $git->get_config('githooks.gerrit' => 'votes-to-reject'),
264             'comment-ok' => $git->get_config('githooks.gerrit' => 'comment-ok'),
265             'auto-submit' => $git->get_config_boolean('githooks.gerrit' => 'auto-submit'),
266             );
267              
268             # https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#set-review
269 0         0 my %review_input = (tag => 'autogenerated:git-hooks');
270 0         0 my $auto_submit = 0;
271              
272 0 0       0 if (my $faults = $git->get_faults()) {
273 0   0     0 $review_input{labels} = $cfg{'votes-to-reject'} || 'Code-Review-1';
274              
275             # We have to truncate $faults down to a little less than 64kB because up
276             # to at least Gerrit 2.14.4 messages are saved in a MySQL column of type
277             # 'text', which has this limit.
278 0 0       0 if (length $faults > 65000) {
279 0         0 $faults = substr($faults, 0, 65000) . "...\n\n";
280             }
281 0         0 $review_input{message} = $faults;
282             } else {
283 0   0     0 $review_input{labels} = $cfg{'votes-to-approve'} || 'Code-Review+1';
284             $review_input{message} = "[Git::Hooks] $cfg{'comment-ok'}"
285 0 0       0 if $cfg{'comment-ok'};
286 0 0       0 $auto_submit = 1 if $cfg{'auto-submit'};
287             }
288              
289             # Convert, e.g., 'LabelA-1,LabelB+2' into { LabelA => '-1', LabelB => '+2' }
290 0         0 $review_input{labels} = { map {/^([-\w]+)([-+]\d+)$/i} split(',', $review_input{labels}) };
  0         0  
291              
292 0 0       0 if (my $notify = $git->get_config('githooks.gerrit' => 'notify')) {
293 0         0 $review_input{notify} = $notify;
294             }
295              
296 0         0 $log->debug(_gerrit_patchset_post_hook => {
297             review_input => \%review_input,
298             auto_submit => $auto_submit,
299             });
300              
301             # Cast review
302 0 0       0 eval { $args->{gerrit}->POST("/changes/$id/revisions/$patchset/review", \%review_input) }
  0         0  
303             or croak __PACKAGE__ . ": error in Gerrit::REST::POST(/changes/$id/revisions/$patchset/review): $@\n";
304              
305             # Auto submit if requested and passed verification
306 0 0       0 if ($auto_submit) {
307 0 0       0 eval { $args->{gerrit}->POST("/changes/$id/submit", {wait_for_merge => 'true'}) }
  0         0  
308             or croak __PACKAGE__ . ": I couldn't submit the change. Perhaps you have to rebase it manually to resolve a conflict. Please go to its web page to check it out. The error message follows: $@\n";
309             }
310              
311 0         0 return;
312             }
313              
314             # Gerrit's patchset-created hook is invoked when a commit is pushed to a
315             # refs/for/* branch for revision. It's invoked asynchronously, i.e., it
316             # can't stop the push to happen. Instead, if it detects any problem, we must
317             # reject the commit via Gerrit's own revision process. So, we prepare a post
318             # hook action in which we see if there were errors that should be signaled
319             # via a code review action. Note, however, that draft changes can only be
320             # accessed by their respective owners and usually can't be voted on by the
321             # hook. So, draft changes aren't voted on and we exit the hook prematurely.
322             # The arguments for the hook are these:
323              
324             # patchset-created --change --is-draft \
325             # --kind --change-url \
326             # --change-owner --project \
327             # --branch --topic --uploader
328             # --commit --patchset
329              
330             # Gerrit's draft-published hook is invoked when a draft change is
331             # published. In this state they're are visible by the hook and can be voted
332             # on. The arguments for the hook are these:
333              
334             # draft-published --change --change-url \
335             # --change-owner --project \
336             # --branch --topic --uploader \
337             # --commit --patchset
338              
339             sub _prepare_gerrit_patchset {
340 0     0   0 my ($git, $args) = @_;
341              
342 0         0 _prepare_gerrit_args($git, $args);
343              
344 0 0 0     0 exit(0) if exists $args->[0]{'--is-draft'} and $args->[0]{'--is-draft'} eq 'true';
345              
346 0         0 $git->post_hook(\&_gerrit_patchset_post_hook);
347              
348 0         0 return;
349             }
350              
351             # The %prepare_hook hash maps hook names to the routine that must be
352             # invoked in order to "prepare" their arguments.
353              
354             my %prepare_hook = (
355             'update' => \&_prepare_update,
356             'pre-push' => \&_prepare_input_data,
357             'post-rewrite' => \&_prepare_input_data,
358             'pre-receive' => \&_prepare_receive,
359             'post-receive' => \&_prepare_receive,
360             'ref-update' => \&_prepare_gerrit_ref_update,
361             'commit-received' => \&_prepare_gerrit_ref_update,
362             'submit' => \&_prepare_gerrit_submit,
363             'patchset-created' => \&_prepare_gerrit_patchset,
364             'draft-published' => \&_prepare_gerrit_patchset,
365             );
366              
367             sub prepare_hook {
368 0     0 1 0 my ($git, $hook_name, $args) = @_;
369              
370 0         0 $git->{_plugin_githooks}{arguments} = $args;
371 0         0 my $basename = path($hook_name)->basename;
372 0         0 $git->{_plugin_githooks}{hookname} = $basename;
373              
374             # Some hooks need some argument munging before we invoke them
375 0 0       0 if (my $prepare = $prepare_hook{$basename}) {
376 0         0 $prepare->($git, $args);
377             }
378              
379 0         0 return $basename;
380             }
381              
382             sub load_plugins {
383 0     0 1 0 my ($git) = @_;
384              
385 0         0 my %plugins;
386              
387 0         0 foreach my $plugin (map {split} $git->get_config(githooks => 'plugin')) {
  0         0  
388 0         0 my ($negation, $prefix, $basename) = ($plugin =~ /^(\!?)((?:.+::)?)(.+)/);
389              
390 0 0 0     0 if (exists $ENV{$basename} && ! $ENV{$basename}) {
    0          
391 0         0 delete @plugins{$basename, "$prefix$basename"};
392             } elsif ($negation) {
393 0         0 delete $plugins{"$prefix$basename"};
394             } else {
395 0         0 $plugins{"$prefix$basename"} = [$prefix, $basename];
396             }
397             }
398              
399 0 0       0 return unless %plugins; # no one configured
400              
401             # Remove disabled plugins from the list of plugins
402 0         0 my %disabled_plugins = map {($_ => undef)} map {split} $git->get_config(githooks => 'disable');
  0         0  
  0         0  
403 0         0 delete @plugins{grep {exists $disabled_plugins{$_}} keys %plugins};
  0         0  
404              
405             # Define the list of directories where we'll look for the hook
406             # plugins. First the local directory 'githooks' under the
407             # repository path, then the optional list of directories
408             # specified by the githooks.plugins config option, and,
409             # finally, the Git::Hooks standard hooks directory.
410 0         0 my @plugin_dirs = grep {-d} (
411             'githooks',
412             $git->get_config(githooks => 'plugins'),
413 0         0 path($INC{'Git/Hooks.pm'})->parent->child('Hooks'),
414             );
415              
416 0         0 $log->debug(load_plugins => {plugins => \%plugins, plugin_dirs => \@plugin_dirs});
417              
418             # Load remaining enabled plugins
419 0         0 while (my ($key, $plugin) = each %plugins) {
420 0         0 my ($prefix, $basename) = @$plugin;
421 0         0 my $exit = do {
422 0 0       0 if ($prefix) {
423             # It must be a module name
424             ## no critic (ProhibitStringyEval, RequireCheckingReturnValueOfEval)
425 0         0 eval "require $prefix$basename";
426             ## use critic
427             } else {
428             # Otherwise, it's a basename we must look for in @plugin_dirs
429 0 0       0 $basename .= '.pm' unless $basename =~ /\.p[lm]$/i;
430 0         0 my @scripts = grep {!-d} map {path($_)->child($basename)} @plugin_dirs;
  0         0  
  0         0  
431 0 0       0 $basename = shift @scripts
432             or croak __PACKAGE__, ": can't find enabled hook $basename.\n";
433 0         0 do $basename;
434             }
435             };
436 0 0       0 unless ($exit) {
437 0 0       0 croak __PACKAGE__, ": couldn't parse $basename: $@\n" if $@;
438 0 0       0 croak __PACKAGE__, ": couldn't do $basename: $!\n" unless defined $exit;
439 0         0 croak __PACKAGE__, ": couldn't run $basename\n";
440             }
441             }
442              
443 0         0 return;
444             }
445              
446             sub _invoke_external_hook { ## no critic (ProhibitExcessComplexity)
447 0     0   0 my ($git, $file, $hook, @args) = @_;
448              
449 0         0 my $prefix = '[' . __PACKAGE__ . '(' . path($file)->basename . ')]';
450              
451 0         0 my $tempfile = Path::Tiny->tempfile(UNLINK => 1);
452              
453             ## no critic (RequireBriefOpen, RequireCarping)
454 0 0       0 open(my $oldout, '>&', \*STDOUT) or croak "Can't dup STDOUT: $!";
455 0 0       0 open(STDOUT , '>' , $tempfile) or croak "Can't redirect STDOUT to \$tempfile: $!";
456 0 0       0 open(my $olderr, '>&', \*STDERR) or croak "Can't dup STDERR: $!";
457 0 0       0 open(STDERR , '>&', \*STDOUT) or croak "Can't dup STDOUT for STDERR: $!";
458             ## use critic
459              
460 0 0       0 if ($hook =~ /^(?:pre-receive|post-receive|pre-push|post-rewrite)$/) {
461              
462             # These hooks receive information via STDIN that we read once
463             # before invoking any hook. Now, we must regenerate the same
464             # information and output it to the external hooks we invoke.
465              
466 0         0 my $pid = open my $pipe, '|-'; ## no critic (InputOutput::RequireBriefOpen)
467              
468 0 0       0 if (! defined $pid) {
    0          
469 0         0 $git->fault("I can't fork: $!", {prefix => $prefix});
470             } elsif ($pid) {
471             # parent
472 0         0 $pipe->print(join("\n", map {join(' ', @$_)} @{_get_input_data($git)}) . "\n");
  0         0  
  0         0  
473 0         0 my $exit = $pipe->close;
474              
475             ## no critic (RequireBriefOpen, RequireCarping)
476 0 0       0 open(STDOUT, '>&', $oldout) or croak "Can't dup \$oldout: $!";
477 0 0       0 open(STDERR, '>&', $olderr) or croak "Can't dup \$olderr: $!";
478             ## use critic
479              
480 0         0 my $output = $tempfile->slurp;
481 0 0       0 if ($exit) {
    0          
482 0 0       0 say STDERR $output if length $output;
483 0         0 return 1;
484             } elsif ($!) {
485 0         0 $git->fault("Error closing pipe to external hook: $!", {
486             prefix => $prefix,
487             details => $output,
488             });
489             } else {
490 0         0 $git->fault("External hook exited with code $?", {
491             prefix => $prefix,
492             details => $output,
493             });
494             }
495             } else {
496             # child
497 0         0 { exec {$file} ($hook, @args) }
  0         0  
  0         0  
498              
499             ## no critic (RequireBriefOpen, RequireCarping)
500 0 0       0 open(STDOUT, '>&', $oldout) or croak "Can't dup \$oldout: $!";
501 0 0       0 open(STDERR, '>&', $olderr) or croak "Can't dup \$olderr: $!";
502             ## use critic
503              
504 0         0 croak "$prefix: can't exec: $!\n";
505             }
506              
507             } else {
508              
509 0 0 0     0 if (@args && ref $args[0]) {
510             # This is a Gerrit hook and we need to expand its arguments
511 0         0 @args = %{$args[0]};
  0         0  
512             }
513              
514 0         0 my $exit = system {$file} ($hook, @args);
  0         0  
515              
516             ## no critic (RequireBriefOpen, RequireCarping)
517 0 0       0 open(STDOUT, '>&', $oldout) or croak "Can't dup \$oldout: $!";
518 0 0       0 open(STDERR, '>&', $olderr) or croak "Can't dup \$olderr: $!";
519             ## use critic
520              
521 0         0 my $output = $tempfile->slurp;
522              
523 0 0       0 if ($exit == 0) {
524 0 0       0 say STDERR $output if length $output;
525 0         0 return 1;
526             } else {
527 0         0 my $message = do {
528 0 0       0 if ($exit == -1) {
    0          
529 0         0 "failed to execute external hook: $!";
530             } elsif ($exit & 127) {
531 0 0       0 sprintf("external hook died with signal %d, %s coredump",
532             ($exit & 127), ($exit & 128) ? 'with' : 'without');
533             } else {
534 0         0 sprintf("'$file' exited abnormally with value %d", $exit >> 8);
535             }
536             };
537 0         0 $git->fault($message, {prefix => $prefix, details => $output});
538             }
539             }
540              
541 0         0 return 0;
542             }
543              
544             sub invoke_external_hooks {
545 0     0 1 0 my ($git, @args) = @_;
546              
547 0 0 0     0 return if $^O eq 'MSWin32' || ! $git->get_config_boolean(githooks => 'externals');
548              
549 0         0 my $hookname = $git->{_plugin_githooks}{hookname};
550              
551 0         0 foreach my $dir (
552 0         0 grep {-e}
553 0         0 map {path($_)->child($hookname)}
554             ($git->get_config(githooks => 'hooks'), path($git->git_dir())->child('hooks.d'))
555             ) {
556 0 0 0     0 opendir my $dh, $dir
557             or $git->fault("I cannot opendir '$dir'", {details => $!})
558             and next;
559 0 0       0 foreach my $file (grep {!-d && -x} map {path($dir)->child($_)} readdir $dh) {
  0         0  
  0         0  
560 0 0       0 _invoke_external_hook($git, $file, $hookname, @args)
561             or $git->fault(": error in external hook '$file'");
562             }
563             } continue {
564 0         0 $git->check_timeout();
565             }
566              
567 0         0 return;
568             }
569              
570             ##############
571             # The following routines are invoked after all hooks have been
572             # processed. Some hooks may need to take a global action depending on
573             # the overall result of all hooks.
574              
575             sub post_hook {
576 0     0 1 0 my ($git, $sub) = @_;
577 0         0 push @{$git->{_plugin_githooks}{post_hooks}}, $sub;
  0         0  
578 0         0 return;
579             }
580              
581             sub post_hooks {
582 0     0 1 0 my ($git) = @_;
583 0 0       0 if ($git->{_plugin_githooks}{post_hooks}) {
584 0         0 return @{$git->{_plugin_githooks}{post_hooks}}
  0         0  
585             } else {
586 0         0 return;
587             }
588             }
589              
590             sub cache {
591 12     12 1 56 my ($git, $section) = @_;
592              
593 12 100       115 unless (exists $git->{_plugin_githooks}{cache}{$section}) {
594 2         15 $git->{_plugin_githooks}{cache}{$section} = {};
595             }
596              
597 12         60 return $git->{_plugin_githooks}{cache}{$section};
598             }
599              
600             sub get_config {
601 21     21 1 380 my ($git, $section, $var) = @_;
602              
603 21 100       213 unless (exists $git->{_plugin_githooks}{config}) {
604 3         22 my %config;
605              
606 3         16 my $config = do {
607 3         58 local $/ = "\c@";
608 3         49 $git->run(qw/config --null --list/);
609             };
610              
611 3 50       61398 if (defined $CONFIG_ENCODING) {
612 0         0 require Encode;
613 0         0 $config = Encode::decode($CONFIG_ENCODING, $config);
614             }
615              
616 3 50       75 if (defined $config) {
617             # The --null option to git-log makes it output a null character
618             # after each option/value. The option and value are separated by a
619             # newline, unless there is no value, in which case, there is no
620             # newline.
621 3         105 while ($config =~ /([^\cJ]+)(\cJ[^\c@]*|)\c@/sg) {
622 29         161 my ($option, $value) = ($1, $2);
623 29 50       144 if ($option =~ /(.+)\.(.+)/) {
624 29         108 my ($osection, $okey) = (lc $1, lc $2);
625 29 50       134 if ($value =~ s/^\cJ//) {
626 29         51 push @{$config{$osection}{$okey}}, $value;
  29         356  
627             } else {
628             # An option without a value is considered a boolean
629             # true. We mark it explicitly so instead of leaving it
630             # undefined because Perl would consider it false.
631 0         0 push @{$config{$osection}{$okey}}, 'true';
  0         0  
632             }
633             } else {
634 0         0 croak __PACKAGE__, ": Cannot grok config variable name '$option'.\n";
635             }
636             }
637             }
638              
639             # Set default values for undefined ones.
640 3   50     95 $config{githooks}{externals} //= ['true'];
641 3   50     75 $config{githooks}{gerrit}{enabled} //= ['true'];
642 3   50     59 $config{githooks}{'abort-commit'} //= ['true'];
643              
644 3         37 $git->{_plugin_githooks}{config} = \%config;
645             }
646              
647 21         157 my $config = $git->{_plugin_githooks}{config};
648              
649 21 50       165 $section = lc $section if defined $section;
650 21 50       249 $var = lc $var if defined $var;
651              
652 21 50       164 if (! defined $section) {
    50          
    50          
653 0         0 return $config;
654             } elsif (! defined $var) {
655 0 0       0 $config->{$section} = {} unless exists $config->{$section};
656 0         0 return $config->{$section};
657             } elsif (exists $config->{$section}{$var}) {
658 0 0       0 if (wantarray) {
659             $log->trace(get_config => {
660             wantarray => 1,
661             section => $section,
662             var => $var,
663 0         0 result => $config->{$section}{$var},
664             });
665 0         0 return @{$config->{$section}{$var}};
  0         0  
666             } else {
667             $log->trace(get_config => {
668             wantarray => 0,
669             section => $section,
670             var => $var,
671 0         0 result => $config->{$section}{$var}[-1],
672             });
673 0         0 return $config->{$section}{$var}[-1];
674             }
675             } else {
676 21         930 $log->trace(get_config => {
677             wantarray => wantarray,
678             section => $section,
679             var => $var,
680             result => [],
681             });
682 21         522 return;
683             }
684             }
685              
686             sub get_config_boolean {
687 0     0 1 0 my ($git, $section, $var) = @_;
688              
689 0         0 my $bool = $git->get_config($section, $var);
690              
691 0 0       0 if (! defined $bool) {
    0          
    0          
    0          
692 0         0 return;
693             } elsif (ref $bool) {
694 0         0 croak __PACKAGE__, ": get_bool_config method requires two arguments\n";
695             } elsif ($bool =~ /^(?:yes|on|true|1)$/i) {
696 0         0 return 1;
697             } elsif ($bool =~ /^(?:no|off|false|0|)$/i) {
698 0         0 return 0;
699             } else {
700 0         0 croak __PACKAGE__, ": get_config_boolean($section, $var) not a valid boolean: '$bool'\n";
701             }
702             }
703              
704             sub get_config_integer {
705 0     0 1 0 my ($git, $section, $var) = @_;
706              
707 0         0 my $int = $git->get_config($section, $var);
708              
709 0 0       0 if (! defined $int) {
    0          
    0          
710 0         0 return;
711             } elsif (ref $int) {
712 0         0 croak __PACKAGE__, ": get_config_integer() requires two arguments\n";
713             } elsif ($int =~ /^([+-]?)([0-9]+)([kmg]?)$/i) {
714 0         0 my ($signal, $num, $unit) = ($1, $2, lc $3);
715 0 0       0 if ($unit) {
716 0 0       0 if ($unit eq 'k') {
    0          
    0          
717 0         0 $num *= 1024;
718             } elsif ($unit eq 'm') {
719 0         0 $num *= 1024*1024;
720             } elsif ($unit eq 'g') {
721 0         0 $num *= 1024*1024*1024;
722             }
723             }
724 0 0       0 if ($signal eq '-') {
725 0         0 $num *= -1;
726             }
727 0         0 return $num;
728             } else {
729 0         0 croak __PACKAGE__, ": get_config_integer($section, $var) not a valid integer: '$int'\n";
730             }
731             }
732              
733             sub _githooks_colors {
734 1     1   3 my ($git) = @_;
735              
736 1         12 my $cache = $git->cache('colors');
737              
738 1 50       7 unless (exists $cache->{reset}) {
739             # Check if we want to colorize the output, and if so, return a hash
740             # containing the default colors. Otherwise, return a hash containing no
741             # color codes at all.
742              
743             # NOTE: We have to pass the TERM environment variable explicitly because
744             # Git::Repository's constructor deletes it by default. (Se discussion in
745             # https://rt.cpan.org/Ticket/Display.html?id=124711.)
746              
747 1 50       16 my $stdout_is_tty = is_interactive() ? 'true' : 'false';
748             my $githooks_color = $git->run(qw/config --get-colorbool githooks.color/, $stdout_is_tty,
749 1         35 {env => {TERM => $ENV{TERM}}});
750 1 50       17541 if ($githooks_color eq 'true') {
751 0         0 $cache->{header} = $git->run(qw/config --get-color githooks.color.header/, 'green');
752 0         0 $cache->{footer} = $git->run(qw/config --get-color githooks.color.footer/, 'green');
753 0         0 $cache->{context} = $git->run(qw/config --get-color githooks.color.context/, 'red bold');
754 0         0 $cache->{message} = $git->run(qw/config --get-color githooks.color.message/, 'yellow');
755 0         0 $cache->{details} = '';
756 0         0 $cache->{reset} = $git->run(qw/config --get-color/, '', 'reset');
757             } else {
758 1         13 $cache->{header} = '';
759 1         12 $cache->{footer} = '';
760 1         10 $cache->{context} = '';
761 1         8 $cache->{message} = '';
762 1         7 $cache->{details} = '';
763 1         12 $cache->{reset} = '';
764             }
765             }
766              
767 1         18 return $cache;
768             }
769              
770             sub check_timeout {
771 0     0 1 0 my ($git) = @_;
772              
773 0         0 my $cache = $git->cache('timeout');
774              
775 0         0 $cache->{timeout} = $git->get_config_integer(githooks => 'timeout');
776              
777 0 0       0 return unless $cache->{timeout};
778              
779 0         0 $cache->{start_time} = time;
780              
781 0         0 my $now = time;
782              
783 0 0       0 if (($now - $cache->{start_time}) >= $cache->{timeout}) {
784 0         0 $git->fault("Hook timeout");
785 0         0 $git->fail_on_faults();
786             }
787              
788 0         0 return;
789             }
790              
791             sub fault {
792 1     1 1 6 my ($git, $message, $info) = @_;
793 1   50     4 $info //= {};
794              
795 1         4 my $colors = _githooks_colors($git);
796              
797 1         3 my $msg;
798              
799             {
800 1   33     2 my $prefix = $info->{prefix} || caller;
  1         20  
801 1         4 my @context;
802 1 50       10 if (my $commit = $info->{commit}) {
803 0 0       0 $commit = $commit->commit
804             if ref $commit; # It's a Git::Repository::Log object
805 0 0       0 $commit = $git->run('rev-parse', '--short', $commit)
806             if $commit =~ /^[0-9a-f]{40}$/; # It can be '' or ':0' sometimes
807 0         0 push @context, "commit $commit";
808             }
809 1 50       12 if (my $ref = $info->{ref}) {
810 0         0 push @context, "on ref $ref";
811             }
812 1 50       11 if (my $option = $info->{option}) {
813 1         7 push @context, "violates option '$option'";
814             }
815 1         5 $msg = "$colors->{context}\[$prefix";
816 1 50       11 $msg .= ': ' . join(' ', @context) if @context;
817 1         10 $msg .= "]$colors->{reset}\n";
818             }
819              
820 1         13 chomp $message; # strip trailing newlines
821 1         7 $msg .= "\n$colors->{message}$message$colors->{reset}\n";
822              
823 1 50       5 if (my $details = $info->{details}) {
824 1         54 $details =~ s/\n*$//s; # strip trailing newlines
825 1         16 $details =~ s/^/ /gm; # prefix each line with two spaces
826 1         7 $msg .= "\n$colors->{details}$details$colors->{reset}\n\n";
827             }
828              
829 1         3 push @{$git->{_plugin_githooks}{faults}}, $msg;
  1         6  
830              
831             # Return true to allow for the idiom: or $git->fault(...) and ;
832 1         12 return 1;
833             }
834              
835             sub get_faults {
836 0     0 1 0 my ($git) = @_;
837              
838 0 0       0 return unless exists $git->{_plugin_githooks}{faults};
839              
840 0         0 my $colors = _githooks_colors($git);
841              
842 0         0 my $faults = '';
843              
844 0 0       0 if (my $header = $git->get_config(githooks => 'error-header')) {
845 0         0 $faults .= $colors->{header} . qx{$header} . "$colors->{reset}\n"; ## no critic (ProhibitBacktickOperators)
846             }
847              
848 0         0 $faults .= join("\n\n", @{$git->{_plugin_githooks}{faults}});
  0         0  
849              
850 0 0 0     0 if ($git->{_plugin_githooks}{hookname} =~ /^commit-msg|pre-commit$/
851             && ! $git->get_config_boolean(githooks => 'abort-commit')) {
852 0         0 $faults .= <<'EOS';
853              
854             ATTENTION: To fix the problems in this commit, please consider amending it:
855              
856             git commit --amend
857             EOS
858             }
859              
860 0 0       0 if (my $footer = $git->get_config(githooks => 'error-footer')) {
861 0         0 $faults .= "\n$colors->{footer}" . qx{$footer} . "$colors->{reset}\n"; ## no critic (ProhibitBacktickOperators)
862             }
863              
864 0 0       0 if (my $prefix = $git->get_config(githooks => 'error-prefix')) {
865 0         0 $faults =~ s/^/$prefix/gm;
866             }
867              
868 0 0       0 if (my $limit = $git->get_config_integer(githooks => 'error-length-limit')) {
869 0 0 0     0 if ($limit > 0 && $limit < length($faults)) {
870 0         0 my $mark = "\n\n[MESSAGE TRUNCATED at githooks.error-length-limit]\n";
871 0         0 substr($faults, $limit - length($mark) - 1, length($faults), $mark);
872             }
873             }
874              
875 0         0 return $faults;
876             }
877              
878             sub fail_on_faults {
879 0     0 1 0 my ($git, $warn_only) = @_;
880              
881 0 0       0 if (my $faults = $git->get_faults()) {
882 0         0 $log->debug(Environment => {ENV => \%ENV});
883 0 0       0 $faults .= "\n" unless $faults =~ /\n$/;
884 0 0       0 if ($warn_only) {
885 0         0 $log->warning(Warning => {faults => $faults});
886 0         0 carp $faults;
887             } else {
888 0         0 $log->error(Error => {faults => $faults});
889 0         0 croak $faults;
890             }
891             }
892              
893 0         0 return;
894             }
895              
896             sub undef_commit {
897 0     0 1 0 return '0000000000000000000000000000000000000000';
898             }
899              
900             sub empty_tree {
901 0     0 1 0 return '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
902             }
903              
904             sub get_commit {
905 11     11 1 565 my ($git, $commit) = @_;
906              
907 11         208 my $cache = $git->cache('commits');
908              
909             # $commit may be a symbolic reference, but we only want to cache commits
910             # by their SHA1 ids, since the symbolic references may change.
911 11 50 33     74 unless ($commit =~ /^[0-9A-F]{40}$/ && exists $cache->{$commit}) {
912 11         247 my @commits = $git->log('-1', $commit);
913 11         391681 $commit = $commits[0]->{commit};
914 11         109 $cache->{$commit} = $commits[0];
915             }
916              
917 11         188 return $cache->{$commit};
918             }
919              
920             sub get_commits {
921 0     0 1 0 my ($git, $old_commit, $new_commit, $options, $paths) = @_;
922              
923 0         0 my $cache = $git->cache('ranges');
924              
925 0 0       0 my $range = join(
    0          
926             ':',
927             $old_commit,
928             $new_commit,
929             defined $options ? join('', @$options) : '',
930             defined $paths ? join('', @$paths) : '',
931             );
932              
933 0 0       0 unless (exists $cache->{$range}) {
934             # We're interested in all commits reachable from $new_commit but
935             # neither reachable from $old_commit nor from any other existing
936             # reference.
937              
938             # We're going to use the "git rev-list" command for that. As you can
939             # read on its documentation, the syntax to specify this set of
940             # commits is this:
941             # "--not --branches --tags --not $new_commit ^$old_commit".
942              
943             # However, there are some special cases...
944              
945             # When an old branch is deleted $new_commit is null (i.e.,
946             # '0'x40). In this case previous commits are being forgotten and the
947             # hooks usually don't need to check them. So, in this situation we
948             # simply return an empty list of commits.
949              
950 0 0       0 return if $new_commit eq $git->undef_commit;
951              
952             # The @excludes list will contain the arguments to git-log necessary to
953             # exclude from $new_commit history all commits already reachable by any
954             # other reference.
955 0         0 my @excludes;
956              
957 0 0       0 if ($git->{_plugin_githooks}{hookname} !~ /^post-/) {
958             # In pre-* hooks (e.g., pre-receive, update) we can use the '--not
959             # --branches --tags' arguments.
960 0         0 @excludes = qw/--not --branches --tags --not/;
961             } else {
962             # When we're called in a post-receive or post-update hook, the
963             # pushed references already point to $new_commit. So, in these cases
964             # the "--not --branches --tags" options would exclude from the
965             # results all commits reachable from $new_commit, which is exactly
966             # what we don't want... In order to avoid that we can't use these
967             # options directly with git-log. Instead, we use the git-rev-parse
968             # command to get a list of all commits directly reachable by
969             # existing references. Then we'll see if we have to remove any
970             # commit from that list.
971              
972 0         0 @excludes = $git->run(qw/rev-parse --not --branches --tags/);
973              
974             # But we can't simply remove $new_commit from @excludes because it
975             # can be reachable by other references. This can happen, for
976             # instance, when one creates a new branch and pushes it before
977             # making any commits to it or when one pushes a branch after a
978             # fast-forward merge. So, we only remove it if it's reachable by a
979             # single reference, which must be the reference being pushed.
980              
981 0 0       0 if ($git->version_ge('2.7.0')) {
982             # The --points-at option was implemented in this version of Git
983 0         0 my @new_commit_refs = $git->run(
984             qw/for-each-ref --format %(refname) --count 2 --points-at/, $new_commit,
985             );
986 0 0       0 if (@new_commit_refs == 1) {
987 0         0 @excludes = grep {$_ ne "^$new_commit"} @excludes;
  0         0  
988             }
989             } else {
990             # KLUDGE: I couldn't find a direct way to see how many refs
991             # point to $new_commit in older Gits. So, I use the porcelain
992             # git-log command with a format that shows the decoration for a
993             # single commit, which returns something like: (HEAD -> next,
994             # tag: v2.2.0, origin/next)
995 0         0 my $decoration = $git->run(qw/log -n1 --format=%d/, $new_commit);
996 0         0 $decoration =~ s/HEAD,\s*//;
997              
998             # If there are commas in $decoration it means that there are
999             # more than one reference.
1000 0 0       0 if ($decoration !~ /,/) {
1001 0         0 @excludes = grep {$_ ne "^$new_commit"} @excludes;
  0         0  
1002             }
1003             }
1004              
1005             # And we have to make sure $old_commit is on the list, as --not
1006             # --branches --tags wouldn't bring it when we're being called in a
1007             # post-receive or post-update hook.
1008              
1009 0 0       0 push @excludes, "^$old_commit" unless $old_commit eq $git->undef_commit;
1010             }
1011              
1012 0         0 my @arguments;
1013              
1014 0 0       0 push @arguments, @$options if defined $options;
1015 0         0 push @arguments, @excludes, $new_commit;
1016 0 0       0 push @arguments, '--', @$paths if defined $paths;
1017              
1018 0         0 $cache->{$range} = [$git->log(@arguments)];
1019             }
1020              
1021 0         0 return @{$cache->{$range}};
  0         0  
1022             }
1023              
1024             sub read_commit_msg_file {
1025 17     17 1 81 my ($git, $msgfile) = @_;
1026              
1027 17   50     315 my $encoding = $git->get_config(i18n => 'commitEncoding') || 'utf-8';
1028              
1029 17         210 my $msg = path($msgfile)->slurp({binmode => ":encoding($encoding)"});
1030              
1031             # Truncate the message just before the diff, if any.
1032 17         27757 $msg =~ s:\ndiff --git .*::s;
1033              
1034             # The comments in the following lines were taken from the "git
1035             # help stripspace" documentation to guide the
1036             # implementation. Previously we invoked the "git stripspace -s"
1037             # external command via Git::command_bidi_pipe to do the cleaning
1038             # but it seems that it doesn't work on FreeBSD. So, we reimplement
1039             # its functionality here.
1040              
1041 17         79 for ($msg) {
1042             # Skip and remove all lines starting with comment character
1043             # (default #).
1044 17         77 s/^#.*//gm;
1045              
1046             # remove trailing whitespace from all lines
1047 17         254 s/[ \t\f]+$//gm;
1048              
1049             # collapse multiple consecutive empty lines into one empty line
1050 17         164 s/\n{3,}/\n\n/gs;
1051              
1052             # remove empty lines from the beginning and end of the input
1053             # add a missing \n to the last line if necessary.
1054 17         69 s/^\n+//s;
1055 17         438 s/\n*$/\n/s;
1056              
1057             # In the case where the input consists entirely of whitespace
1058             # characters, no output will be produced.
1059 17         105 s/^\s+$//s;
1060             }
1061              
1062 17         101 return $msg;
1063             }
1064              
1065             sub write_commit_msg_file {
1066 2     2 1 30 my ($git, $msgfile, @msg) = @_;
1067              
1068 2   50     36 my $encoding = $git->get_config(i18n => 'commitEncoding') || 'utf-8';
1069              
1070 2         41 path($msgfile)->spew({binmode => ":encoding($encoding)"}, @msg);
1071              
1072 2         2710 return;
1073             }
1074              
1075             # Internal funtion to set the affected references in an update or
1076             # pre-receive hook.
1077              
1078             sub _set_affected_ref {
1079 0     0   0 my ($git, $ref, $old_commit, $new_commit) = @_;
1080 0         0 $git->{_plugin_githooks}{affected_refs}{$ref}{range} = [$old_commit, $new_commit];
1081 0         0 return;
1082             }
1083              
1084             # internal method
1085             sub _get_affected_refs_hash {
1086 0     0   0 my ($git) = @_;
1087              
1088 0   0     0 return $git->{_plugin_githooks}{affected_refs} || {};
1089             }
1090              
1091             sub get_affected_refs {
1092 0     0 1 0 my ($git) = @_;
1093              
1094 0         0 return keys %{_get_affected_refs_hash($git)};
  0         0  
1095             }
1096              
1097             sub get_affected_ref_range {
1098 0     0 1 0 my ($git, $ref) = @_;
1099              
1100 0         0 my $affected = _get_affected_refs_hash($git);
1101              
1102             exists $affected->{$ref}{range}
1103 0 0       0 or croak __PACKAGE__, ": get_affected_ref_range($ref): no such affected ref\n";
1104              
1105 0         0 return @{$affected->{$ref}{range}};
  0         0  
1106             }
1107              
1108             sub get_affected_ref_commits {
1109 0     0 1 0 my ($git, $ref, $options, $paths) = @_;
1110              
1111 0         0 return $git->get_commits($git->get_affected_ref_range($ref), $options, $paths);
1112             }
1113              
1114             sub filter_name_status_in_index {
1115 0     0 1 0 my ($git, $filter) = @_;
1116              
1117 0         0 my %actions;
1118              
1119 0         0 my $output = $git->run(
1120             qw/diff-index --name-status --ignore-submodules --no-commit-id --cached -r -z/,
1121             "--diff-filter=$filter",
1122             $git->get_head_or_empty_tree(),
1123             );
1124              
1125 0         0 my @output = split /\0/, $output;
1126 0         0 while (@output >= 2) {
1127 0         0 my ($action, $file) = splice @output, 0, 2;
1128 0         0 $actions{$file} = $action;
1129             }
1130              
1131 0         0 return \%actions;
1132             }
1133              
1134             sub filter_name_status_in_range {
1135 0     0 1 0 my ($git, $filter, $from, $to, $options, $paths) = @_;
1136              
1137             # If $to is the undefined commit this means that a branch or tag is being
1138             # removed. In this situation we return the empty list, bacause no file
1139             # has been affected.
1140 0 0       0 return {} if $to eq $git->undef_commit;
1141              
1142 0 0       0 if ($from eq $git->undef_commit) {
1143             # If $from is the undefined commit we get the list of commits
1144             # reachable from $to and not reachable from $from and all other
1145             # references. This list is in chronological order. We want to grok
1146             # the files changed from the list's first commit's PARENT commit to
1147             # the list's last commit.
1148              
1149 0 0       0 if (my @commits = $git->get_commits($from, $to, $options, $paths)) {
1150 0 0       0 if (my @parents = $commits[0]->parent()) {
1151 0         0 $from = $parents[0];
1152             } else {
1153             # If the list's first commit has no parent (i.e., it's a root
1154             # commit) then we return the empty hash because git-diff-tree
1155             # cannot compare the undefined commit with a commit.
1156 0         0 return {};
1157             }
1158             } else {
1159             # If @commits is empty we return an empty hash because no new commit
1160             # was pushed.
1161 0         0 return {};
1162             }
1163             }
1164              
1165 0         0 my %actions;
1166              
1167 0         0 my $output = $git->run(
1168             qw/diff-tree --name-status --ignore-submodules --no-commit-id -r -z/,
1169             "--diff-filter=$filter",
1170             $from, $to, '--',
1171             );
1172              
1173 0         0 my @output = split /\0/, $output;
1174 0         0 while (@output >= 2) {
1175 0         0 my ($action, $file) = splice @output, 0, 2;
1176 0         0 $actions{$file} = $action;
1177             }
1178              
1179 0         0 return \%actions;
1180             }
1181              
1182             sub filter_name_status_in_commit {
1183 0     0 1 0 my ($git, $filter, $commit) = @_;
1184              
1185 0         0 my $output = $git->run(
1186             qw/diff-tree --name-status --ignore-submodules -m -r -z/,
1187             "--diff-filter=$filter",
1188             $commit,
1189             );
1190              
1191 0         0 my @output = split /\0/, $output;
1192              
1193             # @output is a sequence of commits, actions, and files, with the following
1194             # general pattern: { COMMIT { ACTION FILE }* }+,
1195              
1196             # COMMIT is the parent commit of $commit. There can be more than one if
1197             # $commit is a merge commit.
1198              
1199             # Below we parse the sequence, tucking all the information in %actions.
1200              
1201 0         0 my %actions;
1202              
1203             my $sha1;
1204 0         0 my $action;
1205 0         0 my $parents = 0;
1206 0         0 my $expect = 'sha1';
1207              
1208             # PARSE @output
1209 0         0 while (@output) {
1210 0 0       0 if ($expect eq 'sha1') {
    0          
    0          
1211 0 0       0 if ($output[0] =~ /^[0-9a-f]{40}$/) {
1212 0         0 $sha1 = shift @output;
1213 0         0 ++$parents;
1214 0         0 $expect = 'sha1 or action';
1215             } else {
1216 0         0 croak;
1217             }
1218             } elsif ($expect eq 'sha1 or action') {
1219 0 0       0 if ($output[0] =~ /^[0-9a-f]{40}$/) {
    0          
1220 0         0 $sha1 = shift @output;
1221 0         0 ++$parents;
1222             } elsif ($output[0] =~ /^[A-Z]$/) {
1223 0         0 $action = shift @output;
1224 0         0 $expect = 'file';
1225             } else {
1226 0         0 croak;
1227             }
1228             } elsif ($expect eq 'file') {
1229 0         0 $actions{shift @output}{$sha1} = $action;
1230 0         0 $expect = 'sha1 or action';
1231             } else {
1232 0         0 croak;
1233             }
1234             }
1235              
1236             # %actions is a multi-level hash: $actions{$file}{$sha1} = $action. Next
1237             # we remove the $commit level, joining all $actions together under $file.
1238              
1239 0         0 foreach my $file (keys %actions) {
1240 0 0       0 if (keys(%{$actions{$file}}) == $parents) {
  0         0  
1241             # For merge commits we're interested only in files that were
1242             # affected in all parent commits. For files affected in all parents
1243             # we join their actions together. Non-merge commits ($parents == 1)
1244             # reduce to the general case of merge commits.
1245 0         0 $actions{$file} = join('', values %{$actions{$file}});
  0         0  
1246             } else {
1247             # Files not affected in all parents we don't care about.
1248 0         0 delete $actions{$file};
1249             }
1250             }
1251              
1252 0         0 return \%actions;
1253             }
1254              
1255             sub filter_files_in_index {
1256 0     0 1 0 my ($git, $filter) = @_;
1257 0         0 my @files = sort keys %{$git->filter_name_status_in_index($filter)};
  0         0  
1258 0         0 return @files;
1259             }
1260              
1261             sub filter_files_in_range {
1262 0     0 1 0 my ($git, @args) = @_;
1263 0         0 my @files = sort keys %{$git->filter_name_status_in_range(@args)};
  0         0  
1264 0         0 return @files;
1265             }
1266              
1267             sub filter_files_in_commit {
1268 0     0 1 0 my ($git, $commit) = @_;
1269 0         0 my @files = sort keys %{$git->filter_name_status_in_commit($commit)};
  0         0  
1270 0         0 return @files;
1271             }
1272              
1273             sub authenticated_user {
1274 1     1 1 21478 my ($git) = @_;
1275              
1276 1 50       54 unless (exists $git->{_plugin_githooks}{authenticated_user}) {
1277 1 50       32 if (my $userenv = $git->get_config(githooks => 'userenv')) {
1278 0 0       0 if ($userenv =~ /^eval:(.*)/) {
    0          
1279 0         0 $git->{_plugin_githooks}{authenticated_user} = eval $1; ## no critic (BuiltinFunctions::ProhibitStringyEval)
1280 0 0       0 croak __PACKAGE__, ": error evaluating userenv value ($userenv): $@\n"
1281             if $@;
1282             } elsif (exists $ENV{$userenv}) {
1283 0         0 $git->{_plugin_githooks}{authenticated_user} = $ENV{$userenv};
1284             } else {
1285 0         0 croak __PACKAGE__, ": option userenv environment variable ($userenv) is not defined.\n";
1286             }
1287             } else {
1288 1   50     59 $git->{_plugin_githooks}{authenticated_user} = $ENV{GERRIT_USER_EMAIL} || $ENV{BB_USER_NAME} || $ENV{GL_USERNAME} || $ENV{USER} || undef;
1289             }
1290             }
1291              
1292 1         54 return $git->{_plugin_githooks}{authenticated_user};
1293             }
1294              
1295             sub repository_name {
1296 0     0 1 0 my ($git) = @_;
1297              
1298 0 0       0 unless (exists $git->{_plugin_githooks}{repository_name}) {
1299 0 0       0 if (my $gerrit_args = $git->{_plugin_githooks}{gerrit_args}) {
    0          
    0          
1300             # Gerrit
1301 0         0 $git->{_plugin_githooks}{repository_name} = $gerrit_args->{'--project'};
1302             } elsif (exists $ENV{BB_REPO_SLUG}) {
1303             # Bitbucket Server environment variables available for hooks:
1304             # https://developer.atlassian.com/server/bitbucket/how-tos/write-hook-scripts/
1305 0         0 $git->{_plugin_githooks}{repository_name} = "$ENV{BB_PROJECT_KEY}/$ENV{BB_REPO_SLUG}";
1306             } elsif (exists $ENV{GL_PROJECT_PATH}) {
1307             # GitLab environment variables available for hooks:
1308             # https://docs.gitlab.com/ee/administration/server_hooks.html
1309 0         0 $git->{_plugin_githooks}{repository_name} = "$ENV{GL_PROJECT_PATH}";
1310             } else {
1311             # As a last resort, return GIT_DIR's basename
1312 0         0 my $gitdir = path($git->git_dir());
1313 0         0 my $basename = $gitdir->basename;
1314 0 0       0 if ($basename eq '.git') {
1315 0         0 $basename = $gitdir->parent->basename;
1316             }
1317 0         0 $git->{_plugin_githooks}{repository_name} = $basename;
1318             }
1319             }
1320              
1321 0         0 return $git->{_plugin_githooks}{repository_name};
1322             }
1323              
1324             sub get_current_branch {
1325 3     3 1 1885609 my ($git) = @_;
1326 3         72 my $branch = $git->run({fatal => [-129, -128], quiet => 1}, qw/symbolic-ref HEAD/);
1327              
1328             # Return undef if we're in detached head state
1329 3 50       54208 return $? == 0 ? $branch : undef;
1330             }
1331              
1332             sub get_sha1 {
1333 0     0 1   my ($git, $rev) = @_;
1334              
1335 0           return $git->run(qw/rev-parse --verify/, $rev);
1336             }
1337              
1338             sub get_head_or_empty_tree {
1339 0     0 1   my ($git) = @_;
1340              
1341 0           my $head = $git->run({fatal => [-129, -128], quiet => 1}, qw/rev-parse --verify HEAD/);
1342              
1343             # Return the empty tree object if in the initial commit
1344 0 0         return $? == 0 ? $head : $git->empty_tree;
1345             }
1346              
1347             sub blob {
1348 0     0 1   my ($git, $rev, $file, @args) = @_;
1349              
1350 0           my $cache = $git->cache('blob');
1351              
1352 0           my $blob = "$rev:$file";
1353              
1354 0 0         unless (exists $cache->{$blob}) {
1355 0   0       $cache->{tmpdir} //= Path::Tiny->tempdir(@args);
1356              
1357 0           my $path = path($file);
1358              
1359             # Calculate temporary file path
1360 0           my $revdir = $rev =~ s/^://r; # remove ':' from ':0' because Windows don't like ':' in filenames
1361 0           my $filepath = $cache->{tmpdir}->child($revdir, $path);
1362              
1363             # Create directory path for the temporary file.
1364 0           $filepath->parent->mkpath;
1365              
1366             # Create temporary file and copy contents to it
1367 0 0         open my $tmp, '>:', $filepath ## no critic (RequireBriefOpen)
1368             or croak "Internal error: can't create file '$filepath': $!";
1369              
1370 0           my $cmd = $git->command(qw/cat-file blob/, $blob);
1371 0           my $stdout = $cmd->stdout;
1372 0           my $read;
1373 0           while ($read = sysread $stdout, my $buffer, 64 * 1024) {
1374 0           my $length = length $buffer;
1375 0           my $offset = 0;
1376 0           while ($length) {
1377 0           my $written = syswrite $tmp, $buffer, $length, $offset;
1378 0 0         defined $written
1379             or croak "Internal error: can't write to '$filepath': $!";
1380 0           $length -= $written;
1381 0           $offset += $written;
1382             }
1383             }
1384 0 0         defined $read
1385             or croak "Internal error: can't read from git cat-file pipe: $!";
1386 0           $cmd->close;
1387              
1388 0           $tmp->close;
1389              
1390 0 0         if (my $exit = $cmd->exit) {
1391 0           croak "Command 'git cat-file blob $blob' exited with code $exit\n";
1392             }
1393              
1394 0           $cache->{$blob} = $filepath;
1395             }
1396              
1397 0           return $cache->{$blob}->stringify;
1398             }
1399              
1400             sub file_size {
1401 0     0 1   my ($git, $rev, $file) = @_;
1402              
1403 0           return $git->run(qw/cat-file -s/, "$rev:$file");
1404             }
1405              
1406             sub file_mode {
1407 0     0 1   my ($git, $rev, $file) = @_;
1408              
1409 0 0         if ($rev eq ':0') {
1410 0           my @diff_index = $git->run(qw/diff-index --cached --raw --no-color HEAD/, $file);
1411              
1412 0 0         if (@diff_index == 1) {
1413 0 0         if (my ($src_mode, $dst_mode, $rest) = $diff_index[0] =~ /^:(\d+) (\d+) (.*)/) {
1414 0           return oct $dst_mode;
1415             } else {
1416 0           croak "Internal error: cannot parse output of git-diff-idex:\n\n $diff_index[0]";
1417             }
1418             } else {
1419 0           croak "Internal error: git-diff-index should return a single line";
1420             }
1421             } else {
1422 0           my @ls_tree = $git->run('ls-tree', "$rev:", $file );
1423              
1424 0 0         if (@ls_tree == 1) {
1425 0 0         if (my ($mode, $type, $object, $filename) =
1426             $ls_tree[0] =~ /^(\d+) ([a-z]+) ([a-z0-9]{40})\t(.+)/) {
1427 0           return oct $mode;
1428             } else {
1429 0           croak "Internal error: cannot parse output of git-ls-tree:\n\n $ls_tree[0]";
1430             }
1431             } else {
1432 0           croak "Internal error: $rev:$file should be a blob";
1433             }
1434             }
1435              
1436 0           croak "Can't happen!";
1437             }
1438              
1439             sub is_reference_enabled {
1440 0     0 1   my ($git, $reference) = @_;
1441              
1442 0 0         return 1 unless defined $reference;
1443              
1444 0           my $cache = $git->cache('is_reference_enabled');
1445              
1446 0 0         unless (exists $cache->{$reference}) {
1447             my $check_reference = sub {
1448 0     0     foreach ($git->get_config(githooks => 'ref')) {
1449 0 0         if (/^\^/) {
1450 0 0         return 1 if $reference =~ qr/$_/;
1451             } else {
1452 0 0         return 1 if $reference eq $_;
1453             }
1454             }
1455              
1456 0           foreach ($git->get_config(githooks => 'noref')) {
1457 0 0         if (/^\^/) {
1458 0 0         return 0 if $reference =~ qr/$_/;
1459             } else {
1460 0 0         return 0 if $reference eq $_;
1461             }
1462             }
1463              
1464 0           return 1;
1465 0           };
1466              
1467 0           $cache->{$reference} = $check_reference->();
1468             }
1469              
1470 0           return $cache->{$reference};
1471             }
1472              
1473             sub _grok_groups_spec {
1474 0     0     my ($groups, $specs, $source) = @_;
1475 0           foreach (@$specs) {
1476 0           s/\#.*//; # strip comments
1477 0 0         next unless /\S/; # skip blank lines
1478 0 0         /^\s*([\w-]+)\s*=\s*(.+?)\s*$/
1479             or croak __PACKAGE__, ": invalid line in '$source': $_\n";
1480 0           my ($groupname, $members) = ($1, $2);
1481 0 0         exists $groups->{"\@$groupname"}
1482             and croak __PACKAGE__, ": redefinition of group ($groupname) in '$source': $_\n";
1483 0           foreach my $member (split ' ', $members) {
1484 0 0         if ($member =~ /^\@/) {
1485             # group member
1486 0 0         $groups->{"\@$groupname"}{$member} = $groups->{$member}
1487             or croak __PACKAGE__, ": unknown group ($member) cited in '$source': $_\n";
1488             } else {
1489             # user member
1490 0           $groups->{"\@$groupname"}{$member} = undef;
1491             }
1492             }
1493             }
1494 0           return;
1495             }
1496              
1497             sub _grok_groups {
1498 0     0     my ($git) = @_;
1499              
1500 0           my $cache = $git->cache('githooks');
1501              
1502 0 0         unless (exists $cache->{groups}) {
1503 0 0         my @groups = $git->get_config(githooks => 'groups')
1504             or croak __PACKAGE__, ": you have to define the githooks.groups option to use groups.\n";
1505              
1506 0           my $groups = {};
1507 0           foreach my $spec (@groups) {
1508 0 0         if (my ($groupfile) = ($spec =~ /^file:(.*)/)) {
1509 0           my @groupspecs = path($groupfile)->lines;
1510 0 0         defined $groupspecs[0]
1511             or croak __PACKAGE__, ": can't open groups file ($groupfile): $!\n";
1512 0           _grok_groups_spec($groups, \@groupspecs, $groupfile);
1513             } else {
1514 0           my @groupspecs = split /\n/, $spec;
1515 0           _grok_groups_spec($groups, \@groupspecs, "githooks.groups");
1516             }
1517             }
1518 0           $cache->{groups} = $groups;
1519             }
1520              
1521 0           return $cache->{groups};
1522             }
1523              
1524             sub _im_memberof {
1525 0     0     my ($git, $myself, $groupname) = @_;
1526              
1527 0           my $groups = _grok_groups($git);
1528              
1529 0 0         exists $groups->{$groupname}
1530             or croak __PACKAGE__, ": group $groupname is not defined.\n";
1531              
1532 0           my $group = $groups->{$groupname};
1533 0 0         return 1 if exists $group->{$myself};
1534 0           while (my ($member, $subgroup) = each %$group) {
1535 0 0         next unless defined $subgroup;
1536 0 0         return 1 if _im_memberof($git, $myself, $member);
1537             }
1538 0           return 0;
1539             }
1540              
1541             sub match_user {
1542 0     0 1   my ($git, $spec) = @_;
1543              
1544 0 0         if (my $myself = $git->authenticated_user()) {
1545 0 0         if ($spec =~ /^\^/) {
    0          
1546 0 0         return 1 if $myself =~ $spec;
1547             } elsif ($spec =~ /^@/) {
1548 0 0         return 1 if _im_memberof($git, $myself, $spec);
1549             } else {
1550 0 0         return 1 if $myself eq $spec;
1551             }
1552             }
1553              
1554 0           return 0;
1555             }
1556              
1557             sub im_admin {
1558 0     0 1   my ($git) = @_;
1559 0           foreach my $spec ($git->get_config(githooks => 'admin')) {
1560 0 0         return 1 if match_user($git, $spec);
1561             }
1562 0           return 0;
1563             }
1564              
1565             sub grok_acls {
1566 0     0 1   my ($git, $cfg, $actions) = @_;
1567              
1568 0           my @acls;
1569              
1570             ACL:
1571 0           foreach ($git->get_config($cfg => 'acl')) {
1572 0           my %acl;
1573 0 0         if (/^\s*(allow|deny)\s+([$actions]+)\s+(\S+)/) {
1574 0           $acl{acl} = $_;
1575 0           $acl{allow} = $1 eq 'allow';
1576 0           $acl{action} = $2;
1577 0           my $spec = $3;
1578              
1579             # Interpolate environment variables embedded as "{VAR}".
1580 0           $spec =~ s/{(\w+)}/$ENV{$1}/ige;
  0            
1581             # Pre-compile regex
1582 0 0         $acl{spec} = substr($spec, 0, 1) eq '^' ? qr/$spec/ : $spec;
1583             } else {
1584 0           croak "invalid acl syntax for actions '$actions': $_\n";
1585             }
1586              
1587 0 0         if (substr($_, $+[0]) =~ /^\s*by\s+(\S+)\s*$/) {
    0          
1588 0           $acl{who} = $1;
1589             # Discard this ACL if it doesn't match the user
1590 0 0         next ACL unless $git->match_user($acl{who});
1591             } elsif (substr($_, $+[0]) !~ /^\s*$/) {
1592 0           croak "invalid acl syntax for actions '$actions: $_\n";
1593             }
1594              
1595 0           unshift @acls, \%acl;
1596             }
1597              
1598 0           return @acls;
1599             }
1600              
1601            
1602             1; # End of Git::Repository::Plugin::GitHooks
1603              
1604             __END__