File Coverage

lib/Git/Repository/Plugin/GitHooks.pm
Criterion Covered Total %
statement 121 665 18.2
branch 26 360 7.2
condition 9 56 16.0
subroutine 18 67 26.8
pod 40 40 100.0
total 214 1188 18.0


line stmt bran cond sub pod time code
1 15     15   11256 use warnings;
  15         36  
  15         898  
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.5.0';
6 15     15   6234 use parent qw/Git::Repository::Plugin/;
  15         4432  
  15         88  
7              
8 15     15   14990 use v5.16.0;
  15         49  
9 15     15   84 use utf8;
  15         29  
  15         157  
10 15     15   328 use Carp;
  15         43  
  15         722  
11 15     15   88 use Path::Tiny;
  15         31  
  15         617  
12 15     15   6602 use IO::Interactive 'is_interactive';
  15         13995  
  15         130  
13 15     15   7348 use Log::Any '$log';
  15         122861  
  15         72  
14              
15             sub _keywords { ## no critic (ProhibitUnusedPrivateSubroutines)
16              
17             return
18 17     17   1304 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 98 my ($git, $section) = @_;
592              
593 12 100       103 unless (exists $git->{_plugin_githooks}{cache}{$section}) {
594 2         17 $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 190 my ($git, $section, $var) = @_;
602              
603 21 100       174 unless (exists $git->{_plugin_githooks}{config}) {
604 3         15 my %config;
605              
606 3         23 my $config = do {
607 3         55 local $/ = "\c@";
608 3         50 $git->run(qw/config --null --list/);
609             };
610              
611 3 50       47365 if (defined $CONFIG_ENCODING) {
612 0         0 require Encode;
613 0         0 $config = Encode::decode($CONFIG_ENCODING, $config);
614             }
615              
616 3 50       71 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         74 while ($config =~ /([^\cJ]+)(\cJ[^\c@]*|)\c@/sg) {
622 29         141 my ($option, $value) = ($1, $2);
623 29 50       131 if ($option =~ /(.+)\.(.+)/) {
624 29         100 my ($osection, $okey) = (lc $1, lc $2);
625 29 50       132 if ($value =~ s/^\cJ//) {
626             ## no critic (ProhibitDeepNests)
627 29 50       67 if ($value eq 'undef') {
628             # The 'undef' string is a special mark telling us to
629             # disregard every previous value already set for
630             # this variable.
631 0         0 delete $config{$osection}{$okey};
632             } else {
633 29         35 push @{$config{$osection}{$okey}}, $value;
  29         314  
634             }
635             } else {
636             # An option without a value is considered a boolean
637             # true. We mark it explicitly so instead of leaving it
638             # undefined because Perl would consider it false.
639 0         0 push @{$config{$osection}{$okey}}, 'true';
  0         0  
640             }
641             } else {
642 0         0 croak __PACKAGE__, ": Cannot grok config variable name '$option'.\n";
643             }
644             }
645             }
646              
647             # Set default values for undefined ones.
648 3   50     73 $config{githooks}{externals} //= ['true'];
649 3   50     58 $config{githooks}{gerrit}{enabled} //= ['true'];
650 3   50     60 $config{githooks}{'abort-commit'} //= ['true'];
651              
652 3         26 $git->{_plugin_githooks}{config} = \%config;
653             }
654              
655 21         124 my $config = $git->{_plugin_githooks}{config};
656              
657 21 50       246 $section = lc $section if defined $section;
658 21 50       97 $var = lc $var if defined $var;
659              
660 21 50       134 if (! defined $section) {
    50          
    50          
661 0         0 return $config;
662             } elsif (! defined $var) {
663 0 0       0 $config->{$section} = {} unless exists $config->{$section};
664 0         0 return $config->{$section};
665             } elsif (exists $config->{$section}{$var}) {
666 0 0       0 if (wantarray) {
667             $log->trace(get_config => {
668             wantarray => 1,
669             section => $section,
670             var => $var,
671 0         0 result => $config->{$section}{$var},
672             });
673 0         0 return @{$config->{$section}{$var}};
  0         0  
674             } else {
675             $log->trace(get_config => {
676             wantarray => 0,
677             section => $section,
678             var => $var,
679 0         0 result => $config->{$section}{$var}[-1],
680             });
681 0         0 return $config->{$section}{$var}[-1];
682             }
683             } else {
684 21         676 $log->trace(get_config => {
685             wantarray => wantarray,
686             section => $section,
687             var => $var,
688             result => [],
689             });
690 21         374 return;
691             }
692             }
693              
694             sub get_config_boolean {
695 0     0 1 0 my ($git, $section, $var) = @_;
696              
697 0         0 my $bool = $git->get_config($section, $var);
698              
699 0 0       0 if (! defined $bool) {
    0          
    0          
    0          
700 0         0 return;
701             } elsif (ref $bool) {
702 0         0 croak __PACKAGE__, ": get_bool_config method requires two arguments\n";
703             } elsif ($bool =~ /^(?:yes|on|true|1)$/i) {
704 0         0 return 1;
705             } elsif ($bool =~ /^(?:no|off|false|0|)$/i) {
706 0         0 return 0;
707             } else {
708 0         0 croak __PACKAGE__, ": get_config_boolean($section, $var) not a valid boolean: '$bool'\n";
709             }
710             }
711              
712             sub get_config_integer {
713 0     0 1 0 my ($git, $section, $var) = @_;
714              
715 0         0 my $int = $git->get_config($section, $var);
716              
717 0 0       0 if (! defined $int) {
    0          
    0          
718 0         0 return;
719             } elsif (ref $int) {
720 0         0 croak __PACKAGE__, ": get_config_integer() requires two arguments\n";
721             } elsif ($int =~ /^([+-]?)([0-9]+)([kmg]?)$/i) {
722 0         0 my ($signal, $num, $unit) = ($1, $2, lc $3);
723 0 0       0 if ($unit) {
724 0 0       0 if ($unit eq 'k') {
    0          
    0          
725 0         0 $num *= 1024;
726             } elsif ($unit eq 'm') {
727 0         0 $num *= 1024*1024;
728             } elsif ($unit eq 'g') {
729 0         0 $num *= 1024*1024*1024;
730             }
731             }
732 0 0       0 if ($signal eq '-') {
733 0         0 $num *= -1;
734             }
735 0         0 return $num;
736             } else {
737 0         0 croak __PACKAGE__, ": get_config_integer($section, $var) not a valid integer: '$int'\n";
738             }
739             }
740              
741             sub _githooks_colors {
742 1     1   10 my ($git) = @_;
743              
744 1         15 my $cache = $git->cache('colors');
745              
746 1 50       4 unless (exists $cache->{reset}) {
747             # Check if we want to colorize the output, and if so, return a hash
748             # containing the default colors. Otherwise, return a hash containing no
749             # color codes at all.
750              
751             # NOTE: We have to pass the TERM environment variable explicitly because
752             # Git::Repository's constructor deletes it by default. (Se discussion in
753             # https://rt.cpan.org/Ticket/Display.html?id=124711.)
754              
755 1 50       25 my $stdout_is_tty = is_interactive() ? 'true' : 'false';
756             my $githooks_color = $git->run(qw/config --get-colorbool githooks.color/, $stdout_is_tty,
757 1         37 {env => {TERM => $ENV{TERM}}});
758 1 50       11641 if ($githooks_color eq 'true') {
759 0         0 $cache->{header} = $git->run(qw/config --get-color githooks.color.header/, 'green');
760 0         0 $cache->{footer} = $git->run(qw/config --get-color githooks.color.footer/, 'green');
761 0         0 $cache->{context} = $git->run(qw/config --get-color githooks.color.context/, 'red bold');
762 0         0 $cache->{message} = $git->run(qw/config --get-color githooks.color.message/, 'yellow');
763 0         0 $cache->{details} = '';
764 0         0 $cache->{reset} = $git->run(qw/config --get-color/, '', 'reset');
765             } else {
766 1         11 $cache->{header} = '';
767 1         11 $cache->{footer} = '';
768 1         11 $cache->{context} = '';
769 1         10 $cache->{message} = '';
770 1         7 $cache->{details} = '';
771 1         16 $cache->{reset} = '';
772             }
773             }
774              
775 1         18 return $cache;
776             }
777              
778             sub check_timeout {
779 0     0 1 0 my ($git) = @_;
780              
781 0         0 my $cache = $git->cache('timeout');
782              
783 0         0 $cache->{timeout} = $git->get_config_integer(githooks => 'timeout');
784              
785 0 0       0 return unless $cache->{timeout};
786              
787 0         0 $cache->{start_time} = time;
788              
789 0         0 my $now = time;
790              
791 0 0       0 if (($now - $cache->{start_time}) >= $cache->{timeout}) {
792 0         0 $git->fault("Hook timeout");
793 0         0 $git->fail_on_faults();
794             }
795              
796 0         0 return;
797             }
798              
799             sub fault {
800 1     1 1 8 my ($git, $message, $info) = @_;
801 1   50     4 $info //= {};
802              
803 1         11 my $colors = _githooks_colors($git);
804              
805 1         7 my $msg;
806              
807             {
808 1   33     10 my $prefix = $info->{prefix} || caller;
  1         36  
809 1         7 my @context;
810 1 50       6 if (my $commit = $info->{commit}) {
811 0 0       0 $commit = $commit->commit
812             if ref $commit; # It's a Git::Repository::Log object
813 0 0       0 $commit = $git->run('rev-parse', '--short', $commit)
814             if $commit =~ /^[0-9a-f]{40}$/; # It can be '' or ':0' sometimes
815 0         0 push @context, "commit $commit";
816             }
817 1 50       15 if (my $ref = $info->{ref}) {
818 0         0 push @context, "on ref $ref";
819             }
820 1 50       13 if (my $option = $info->{option}) {
821 1         6 push @context, "violates option '$option'";
822             }
823 1         3 $msg = "$colors->{context}\[$prefix";
824 1 50       11 $msg .= ': ' . join(' ', @context) if @context;
825 1         6 $msg .= "]$colors->{reset}\n";
826             }
827              
828 1         14 chomp $message; # strip trailing newlines
829 1         7 $msg .= "\n$colors->{message}$message$colors->{reset}\n";
830              
831 1 50       9 if (my $details = $info->{details}) {
832 1         50 $details =~ s/\n*$//s; # strip trailing newlines
833 1         14 $details =~ s/^/ /gm; # prefix each line with two spaces
834 1         6 $msg .= "\n$colors->{details}$details$colors->{reset}\n\n";
835             }
836              
837 1         2 push @{$git->{_plugin_githooks}{faults}}, $msg;
  1         11  
838              
839             # Return true to allow for the idiom: or $git->fault(...) and ;
840 1         16 return 1;
841             }
842              
843             sub get_faults {
844 0     0 1 0 my ($git) = @_;
845              
846 0 0       0 return unless exists $git->{_plugin_githooks}{faults};
847              
848 0         0 my $colors = _githooks_colors($git);
849              
850 0         0 my $faults = '';
851              
852 0 0       0 if (my $header = $git->get_config(githooks => 'error-header')) {
853 0         0 $faults .= $colors->{header} . qx{$header} . "$colors->{reset}\n"; ## no critic (ProhibitBacktickOperators)
854             }
855              
856 0         0 $faults .= join("\n\n", @{$git->{_plugin_githooks}{faults}});
  0         0  
857              
858 0 0 0     0 if ($git->{_plugin_githooks}{hookname} =~ /^commit-msg|pre-commit$/
859             && ! $git->get_config_boolean(githooks => 'abort-commit')) {
860 0         0 $faults .= <<'EOS';
861              
862             ATTENTION: To fix the problems in this commit, please consider amending it:
863              
864             git commit --amend
865             EOS
866             }
867              
868 0 0       0 if (my $footer = $git->get_config(githooks => 'error-footer')) {
869 0         0 $faults .= "\n$colors->{footer}" . qx{$footer} . "$colors->{reset}\n"; ## no critic (ProhibitBacktickOperators)
870             }
871              
872 0 0       0 if (my $prefix = $git->get_config(githooks => 'error-prefix')) {
873 0         0 $faults =~ s/^/$prefix/gm;
874             }
875              
876 0 0       0 if (my $limit = $git->get_config_integer(githooks => 'error-length-limit')) {
877 0 0 0     0 if ($limit > 0 && $limit < length($faults)) {
878 0         0 my $mark = "\n\n[MESSAGE TRUNCATED at githooks.error-length-limit]\n";
879 0         0 substr($faults, $limit - length($mark) - 1, length($faults), $mark);
880             }
881             }
882              
883 0         0 return $faults;
884             }
885              
886             sub fail_on_faults {
887 0     0 1 0 my ($git, $warn_only) = @_;
888              
889 0 0       0 if (my $faults = $git->get_faults()) {
890 0         0 $log->debug(Environment => {ENV => \%ENV});
891 0 0       0 $faults .= "\n" unless $faults =~ /\n$/;
892 0 0       0 if ($warn_only) {
893 0         0 $log->warning(Warning => {faults => $faults});
894 0         0 carp $faults;
895             } else {
896 0         0 $log->error(Error => {faults => $faults});
897 0         0 croak $faults;
898             }
899             }
900              
901 0         0 return;
902             }
903              
904             sub undef_commit {
905 0     0 1 0 return '0000000000000000000000000000000000000000';
906             }
907              
908             sub empty_tree {
909 0     0 1 0 return '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
910             }
911              
912             sub get_commit {
913 11     11 1 512 my ($git, $commit) = @_;
914              
915 11         151 my $cache = $git->cache('commits');
916              
917             # $commit may be a symbolic reference, but we only want to cache commits
918             # by their SHA1 ids, since the symbolic references may change.
919 11 50 33     56 unless ($commit =~ /^[0-9A-F]{40}$/ && exists $cache->{$commit}) {
920 11         224 my @commits = $git->log('-1', $commit);
921 11         303219 $commit = $commits[0]->{commit};
922 11         109 $cache->{$commit} = $commits[0];
923             }
924              
925 11         168 return $cache->{$commit};
926             }
927              
928             sub get_commits {
929 0     0 1 0 my ($git, $old_commit, $new_commit, $options, $paths) = @_;
930              
931 0         0 my $cache = $git->cache('ranges');
932              
933 0 0       0 my $range = join(
    0          
934             ':',
935             $old_commit,
936             $new_commit,
937             defined $options ? join('', @$options) : '',
938             defined $paths ? join('', @$paths) : '',
939             );
940              
941 0 0       0 unless (exists $cache->{$range}) {
942             # We're interested in all commits reachable from $new_commit but
943             # neither reachable from $old_commit nor from any other existing
944             # reference.
945              
946             # We're going to use the "git rev-list" command for that. As you can
947             # read on its documentation, the syntax to specify this set of
948             # commits is this:
949             # "--not --branches --tags --not $new_commit ^$old_commit".
950              
951             # However, there are some special cases...
952              
953             # When an old branch is deleted $new_commit is null (i.e.,
954             # '0'x40). In this case previous commits are being forgotten and the
955             # hooks usually don't need to check them. So, in this situation we
956             # simply return an empty list of commits.
957              
958 0 0       0 return if $new_commit eq $git->undef_commit;
959              
960             # The @excludes list will contain the arguments to git-log necessary to
961             # exclude from $new_commit history all commits already reachable by any
962             # other reference.
963 0         0 my @excludes;
964              
965 0 0       0 if ($git->{_plugin_githooks}{hookname} !~ /^post-/) {
966             # In pre-* hooks (e.g., pre-receive, update) we can use the '--not
967             # --branches --tags' arguments.
968 0         0 @excludes = qw/--not --branches --tags --not/;
969             } else {
970             # When we're called in a post-receive or post-update hook, the
971             # pushed references already point to $new_commit. So, in these cases
972             # the "--not --branches --tags" options would exclude from the
973             # results all commits reachable from $new_commit, which is exactly
974             # what we don't want... In order to avoid that we can't use these
975             # options directly with git-log. Instead, we use the git-rev-parse
976             # command to get a list of all commits directly reachable by
977             # existing references. Then we'll see if we have to remove any
978             # commit from that list.
979              
980 0         0 @excludes = $git->run(qw/rev-parse --not --branches --tags/);
981              
982             # But we can't simply remove $new_commit from @excludes because it
983             # can be reachable by other references. This can happen, for
984             # instance, when one creates a new branch and pushes it before
985             # making any commits to it or when one pushes a branch after a
986             # fast-forward merge. So, we only remove it if it's reachable by a
987             # single reference, which must be the reference being pushed.
988              
989 0 0       0 if ($git->version_ge('2.7.0')) {
990             # The --points-at option was implemented in this version of Git
991 0         0 my @new_commit_refs = $git->run(
992             qw/for-each-ref --format %(refname) --count 2 --points-at/, $new_commit,
993             );
994 0 0       0 if (@new_commit_refs == 1) {
995 0         0 @excludes = grep {$_ ne "^$new_commit"} @excludes;
  0         0  
996             }
997             } else {
998             # KLUDGE: I couldn't find a direct way to see how many refs
999             # point to $new_commit in older Gits. So, I use the porcelain
1000             # git-log command with a format that shows the decoration for a
1001             # single commit, which returns something like: (HEAD -> next,
1002             # tag: v2.2.0, origin/next)
1003 0         0 my $decoration = $git->run(qw/log -n1 --format=%d/, $new_commit);
1004 0         0 $decoration =~ s/HEAD,\s*//;
1005              
1006             # If there are commas in $decoration it means that there are
1007             # more than one reference.
1008 0 0       0 if ($decoration !~ /,/) {
1009 0         0 @excludes = grep {$_ ne "^$new_commit"} @excludes;
  0         0  
1010             }
1011             }
1012              
1013             # And we have to make sure $old_commit is on the list, as --not
1014             # --branches --tags wouldn't bring it when we're being called in a
1015             # post-receive or post-update hook.
1016              
1017 0 0       0 push @excludes, "^$old_commit" unless $old_commit eq $git->undef_commit;
1018             }
1019              
1020 0         0 my @arguments;
1021              
1022 0 0       0 push @arguments, @$options if defined $options;
1023 0         0 push @arguments, @excludes, $new_commit;
1024 0 0       0 push @arguments, '--', @$paths if defined $paths;
1025              
1026 0         0 $cache->{$range} = [$git->log(@arguments)];
1027             }
1028              
1029 0         0 return @{$cache->{$range}};
  0         0  
1030             }
1031              
1032             sub read_commit_msg_file {
1033 17     17 1 113 my ($git, $msgfile) = @_;
1034              
1035 17   50     226 my $encoding = $git->get_config(i18n => 'commitEncoding') || 'utf-8';
1036              
1037 17         155 my $msg = path($msgfile)->slurp({binmode => ":encoding($encoding)"});
1038              
1039             # Truncate the message just before the diff, if any.
1040 17         26019 $msg =~ s:\ndiff --git .*::s;
1041              
1042             # The comments in the following lines were taken from the "git
1043             # help stripspace" documentation to guide the
1044             # implementation. Previously we invoked the "git stripspace -s"
1045             # external command via Git::command_bidi_pipe to do the cleaning
1046             # but it seems that it doesn't work on FreeBSD. So, we reimplement
1047             # its functionality here.
1048              
1049 17         57 for ($msg) {
1050             # Skip and remove all lines starting with comment character
1051             # (default #).
1052 17         76 s/^#.*//gm;
1053              
1054             # remove trailing whitespace from all lines
1055 17         260 s/[ \t\f]+$//gm;
1056              
1057             # collapse multiple consecutive empty lines into one empty line
1058 17         125 s/\n{3,}/\n\n/gs;
1059              
1060             # remove empty lines from the beginning and end of the input
1061             # add a missing \n to the last line if necessary.
1062 17         59 s/^\n+//s;
1063 17         338 s/\n*$/\n/s;
1064              
1065             # In the case where the input consists entirely of whitespace
1066             # characters, no output will be produced.
1067 17         101 s/^\s+$//s;
1068             }
1069              
1070 17         82 return $msg;
1071             }
1072              
1073             sub write_commit_msg_file {
1074 2     2 1 22 my ($git, $msgfile, @msg) = @_;
1075              
1076 2   50     33 my $encoding = $git->get_config(i18n => 'commitEncoding') || 'utf-8';
1077              
1078 2         37 path($msgfile)->spew({binmode => ":encoding($encoding)"}, @msg);
1079              
1080 2         2407 return;
1081             }
1082              
1083             # Internal funtion to set the affected references in an update or
1084             # pre-receive hook.
1085              
1086             sub _set_affected_ref {
1087 0     0   0 my ($git, $ref, $old_commit, $new_commit) = @_;
1088 0         0 $git->{_plugin_githooks}{affected_refs}{$ref}{range} = [$old_commit, $new_commit];
1089 0         0 return;
1090             }
1091              
1092             # internal method
1093             sub _get_affected_refs_hash {
1094 0     0   0 my ($git) = @_;
1095              
1096 0   0     0 return $git->{_plugin_githooks}{affected_refs} || {};
1097             }
1098              
1099             sub get_affected_refs {
1100 0     0 1 0 my ($git) = @_;
1101              
1102 0         0 return keys %{_get_affected_refs_hash($git)};
  0         0  
1103             }
1104              
1105             sub get_affected_ref_range {
1106 0     0 1 0 my ($git, $ref) = @_;
1107              
1108 0         0 my $affected = _get_affected_refs_hash($git);
1109              
1110             exists $affected->{$ref}{range}
1111 0 0       0 or croak __PACKAGE__, ": get_affected_ref_range($ref): no such affected ref\n";
1112              
1113 0         0 return @{$affected->{$ref}{range}};
  0         0  
1114             }
1115              
1116             sub get_affected_ref_commits {
1117 0     0 1 0 my ($git, $ref, $options, $paths) = @_;
1118              
1119 0         0 return $git->get_commits($git->get_affected_ref_range($ref), $options, $paths);
1120             }
1121              
1122             sub filter_name_status_in_index {
1123 0     0 1 0 my ($git, $filter) = @_;
1124              
1125 0         0 my %actions;
1126              
1127 0         0 my $output = $git->run(
1128             qw/diff-index --name-status --ignore-submodules --no-commit-id --cached -r -z/,
1129             "--diff-filter=$filter",
1130             $git->get_head_or_empty_tree(),
1131             );
1132              
1133 0         0 my @output = split /\0/, $output;
1134 0         0 while (@output >= 2) {
1135 0         0 my ($action, $file) = splice @output, 0, 2;
1136 0         0 $actions{$file} = $action;
1137             }
1138              
1139 0         0 return \%actions;
1140             }
1141              
1142             sub filter_name_status_in_range {
1143 0     0 1 0 my ($git, $filter, $from, $to, $options, $paths) = @_;
1144              
1145             # If $to is the undefined commit this means that a branch or tag is being
1146             # removed. In this situation we return the empty list, bacause no file
1147             # has been affected.
1148 0 0       0 return {} if $to eq $git->undef_commit;
1149              
1150 0 0       0 if ($from eq $git->undef_commit) {
1151             # If $from is the undefined commit we get the list of commits
1152             # reachable from $to and not reachable from $from and all other
1153             # references. This list is in chronological order. We want to grok
1154             # the files changed from the list's first commit's PARENT commit to
1155             # the list's last commit.
1156              
1157 0 0       0 if (my @commits = $git->get_commits($from, $to, $options, $paths)) {
1158 0 0       0 if (my @parents = $commits[0]->parent()) {
1159 0         0 $from = $parents[0];
1160             } else {
1161             # If the list's first commit has no parent (i.e., it's a root
1162             # commit) then we return the empty hash because git-diff-tree
1163             # cannot compare the undefined commit with a commit.
1164 0         0 return {};
1165             }
1166             } else {
1167             # If @commits is empty we return an empty hash because no new commit
1168             # was pushed.
1169 0         0 return {};
1170             }
1171             }
1172              
1173 0         0 my %actions;
1174              
1175 0         0 my $output = $git->run(
1176             qw/diff-tree --name-status --ignore-submodules --no-commit-id -r -z/,
1177             "--diff-filter=$filter",
1178             $from, $to, '--',
1179             );
1180              
1181 0         0 my @output = split /\0/, $output;
1182 0         0 while (@output >= 2) {
1183 0         0 my ($action, $file) = splice @output, 0, 2;
1184 0         0 $actions{$file} = $action;
1185             }
1186              
1187 0         0 return \%actions;
1188             }
1189              
1190             sub filter_name_status_in_commit {
1191 0     0 1 0 my ($git, $filter, $commit) = @_;
1192              
1193 0         0 my $output = $git->run(
1194             qw/diff-tree --name-status --ignore-submodules -m -r -z/,
1195             "--diff-filter=$filter",
1196             $commit,
1197             );
1198              
1199 0         0 my @output = split /\0/, $output;
1200              
1201             # @output is a sequence of commits, actions, and files, with the following
1202             # general pattern: { COMMIT { ACTION FILE }* }+,
1203              
1204             # COMMIT is the parent commit of $commit. There can be more than one if
1205             # $commit is a merge commit.
1206              
1207             # Below we parse the sequence, tucking all the information in %actions.
1208              
1209 0         0 my %actions;
1210              
1211             my $sha1;
1212 0         0 my $action;
1213 0         0 my $parents = 0;
1214 0         0 my $expect = 'sha1';
1215              
1216             # PARSE @output
1217 0         0 while (@output) {
1218 0 0       0 if ($expect eq 'sha1') {
    0          
    0          
1219 0 0       0 if ($output[0] =~ /^[0-9a-f]{40}$/) {
1220 0         0 $sha1 = shift @output;
1221 0         0 ++$parents;
1222 0         0 $expect = 'sha1 or action';
1223             } else {
1224 0         0 croak;
1225             }
1226             } elsif ($expect eq 'sha1 or action') {
1227 0 0       0 if ($output[0] =~ /^[0-9a-f]{40}$/) {
    0          
1228 0         0 $sha1 = shift @output;
1229 0         0 ++$parents;
1230             } elsif ($output[0] =~ /^[A-Z]$/) {
1231 0         0 $action = shift @output;
1232 0         0 $expect = 'file';
1233             } else {
1234 0         0 croak;
1235             }
1236             } elsif ($expect eq 'file') {
1237 0         0 $actions{shift @output}{$sha1} = $action;
1238 0         0 $expect = 'sha1 or action';
1239             } else {
1240 0         0 croak;
1241             }
1242             }
1243              
1244             # %actions is a multi-level hash: $actions{$file}{$sha1} = $action. Next
1245             # we remove the $commit level, joining all $actions together under $file.
1246              
1247 0         0 foreach my $file (keys %actions) {
1248 0 0       0 if (keys(%{$actions{$file}}) == $parents) {
  0         0  
1249             # For merge commits we're interested only in files that were
1250             # affected in all parent commits. For files affected in all parents
1251             # we join their actions together. Non-merge commits ($parents == 1)
1252             # reduce to the general case of merge commits.
1253 0         0 $actions{$file} = join('', values %{$actions{$file}});
  0         0  
1254             } else {
1255             # Files not affected in all parents we don't care about.
1256 0         0 delete $actions{$file};
1257             }
1258             }
1259              
1260 0         0 return \%actions;
1261             }
1262              
1263             sub filter_files_in_index {
1264 0     0 1 0 my ($git, $filter) = @_;
1265 0         0 my @files = sort keys %{$git->filter_name_status_in_index($filter)};
  0         0  
1266 0         0 return @files;
1267             }
1268              
1269             sub filter_files_in_range {
1270 0     0 1 0 my ($git, @args) = @_;
1271 0         0 my @files = sort keys %{$git->filter_name_status_in_range(@args)};
  0         0  
1272 0         0 return @files;
1273             }
1274              
1275             sub filter_files_in_commit {
1276 0     0 1 0 my ($git, $commit) = @_;
1277 0         0 my @files = sort keys %{$git->filter_name_status_in_commit($commit)};
  0         0  
1278 0         0 return @files;
1279             }
1280              
1281             sub authenticated_user {
1282 1     1 1 13733 my ($git) = @_;
1283              
1284 1 50       25 unless (exists $git->{_plugin_githooks}{authenticated_user}) {
1285 1 50       22 if (my $userenv = $git->get_config(githooks => 'userenv')) {
1286 0 0       0 if ($userenv =~ /^eval:(.*)/) {
    0          
1287 0         0 $git->{_plugin_githooks}{authenticated_user} = eval $1; ## no critic (BuiltinFunctions::ProhibitStringyEval)
1288 0 0       0 croak __PACKAGE__, ": error evaluating userenv value ($userenv): $@\n"
1289             if $@;
1290             } elsif (exists $ENV{$userenv}) {
1291 0         0 $git->{_plugin_githooks}{authenticated_user} = $ENV{$userenv};
1292             } else {
1293 0         0 croak __PACKAGE__, ": option userenv environment variable ($userenv) is not defined.\n";
1294             }
1295             } else {
1296 1   50     61 $git->{_plugin_githooks}{authenticated_user} = $ENV{GERRIT_USER_EMAIL} || $ENV{BB_USER_NAME} || $ENV{GL_USERNAME} || $ENV{USER} || undef;
1297             }
1298             }
1299              
1300 1         16 return $git->{_plugin_githooks}{authenticated_user};
1301             }
1302              
1303             sub repository_name {
1304 0     0 1 0 my ($git) = @_;
1305              
1306 0 0       0 unless (exists $git->{_plugin_githooks}{repository_name}) {
1307 0 0       0 if (my $gerrit_args = $git->{_plugin_githooks}{gerrit_args}) {
    0          
    0          
1308             # Gerrit
1309 0         0 $git->{_plugin_githooks}{repository_name} = $gerrit_args->{'--project'};
1310             } elsif (exists $ENV{BB_REPO_SLUG}) {
1311             # Bitbucket Server environment variables available for hooks:
1312             # https://developer.atlassian.com/server/bitbucket/how-tos/write-hook-scripts/
1313 0         0 $git->{_plugin_githooks}{repository_name} = "$ENV{BB_PROJECT_KEY}/$ENV{BB_REPO_SLUG}";
1314             } elsif (exists $ENV{GL_PROJECT_PATH}) {
1315             # GitLab environment variables available for hooks:
1316             # https://docs.gitlab.com/ee/administration/server_hooks.html
1317 0         0 $git->{_plugin_githooks}{repository_name} = "$ENV{GL_PROJECT_PATH}";
1318             } else {
1319             # As a last resort, return GIT_DIR's basename
1320 0         0 my $gitdir = path($git->git_dir());
1321 0         0 my $basename = $gitdir->basename;
1322 0 0       0 if ($basename eq '.git') {
1323 0         0 $basename = $gitdir->parent->basename;
1324             }
1325 0         0 $git->{_plugin_githooks}{repository_name} = $basename;
1326             }
1327             }
1328              
1329 0         0 return $git->{_plugin_githooks}{repository_name};
1330             }
1331              
1332             sub get_current_branch {
1333 3     3 1 1630447 my ($git) = @_;
1334 3         49 my $branch = $git->run({fatal => [-129, -128], quiet => 1}, qw/symbolic-ref HEAD/);
1335              
1336             # Return undef if we're in detached head state
1337 3 50       37581 return $? == 0 ? $branch : undef;
1338             }
1339              
1340             sub get_sha1 {
1341 0     0 1   my ($git, $rev) = @_;
1342              
1343 0           return $git->run(qw/rev-parse --verify/, $rev);
1344             }
1345              
1346             sub get_head_or_empty_tree {
1347 0     0 1   my ($git) = @_;
1348              
1349 0           my $head = $git->run({fatal => [-129, -128], quiet => 1}, qw/rev-parse --verify HEAD/);
1350              
1351             # Return the empty tree object if in the initial commit
1352 0 0         return $? == 0 ? $head : $git->empty_tree;
1353             }
1354              
1355             sub blob {
1356 0     0 1   my ($git, $rev, $file, @args) = @_;
1357              
1358 0           my $cache = $git->cache('blob');
1359              
1360 0           my $blob = "$rev:$file";
1361              
1362 0 0         unless (exists $cache->{$blob}) {
1363 0   0       $cache->{tmpdir} //= Path::Tiny->tempdir(@args);
1364              
1365 0           my $path = path($file);
1366              
1367             # Calculate temporary file path
1368 0           my $revdir = $rev =~ s/^://r; # remove ':' from ':0' because Windows don't like ':' in filenames
1369 0           my $filepath = $cache->{tmpdir}->child($revdir, $path);
1370              
1371             # Create directory path for the temporary file.
1372 0           $filepath->parent->mkpath;
1373              
1374             # Create temporary file and copy contents to it
1375 0 0         open my $tmp, '>:', $filepath ## no critic (RequireBriefOpen)
1376             or croak "Internal error: can't create file '$filepath': $!";
1377              
1378 0           my $cmd = $git->command(qw/cat-file blob/, $blob);
1379 0           my $stdout = $cmd->stdout;
1380 0           my $read;
1381 0           while ($read = sysread $stdout, my $buffer, 64 * 1024) {
1382 0           my $length = length $buffer;
1383 0           my $offset = 0;
1384 0           while ($length) {
1385 0           my $written = syswrite $tmp, $buffer, $length, $offset;
1386 0 0         defined $written
1387             or croak "Internal error: can't write to '$filepath': $!";
1388 0           $length -= $written;
1389 0           $offset += $written;
1390             }
1391             }
1392 0 0         defined $read
1393             or croak "Internal error: can't read from git cat-file pipe: $!";
1394 0           $cmd->close;
1395              
1396 0           $tmp->close;
1397              
1398 0 0         if (my $exit = $cmd->exit) {
1399 0           croak "Command 'git cat-file blob $blob' exited with code $exit\n";
1400             }
1401              
1402 0           $cache->{$blob} = $filepath;
1403             }
1404              
1405 0           return $cache->{$blob}->stringify;
1406             }
1407              
1408             sub file_size {
1409 0     0 1   my ($git, $rev, $file) = @_;
1410              
1411 0           return $git->run(qw/cat-file -s/, "$rev:$file");
1412             }
1413              
1414             sub file_mode {
1415 0     0 1   my ($git, $rev, $file) = @_;
1416              
1417 0 0         if ($rev eq ':0') {
1418 0           my @diff_index = $git->run(qw/diff-index --cached --raw --no-color HEAD/, $file);
1419              
1420 0 0         if (@diff_index == 1) {
1421 0 0         if (my ($src_mode, $dst_mode, $rest) = $diff_index[0] =~ /^:(\d+) (\d+) (.*)/) {
1422 0           return oct $dst_mode;
1423             } else {
1424 0           croak "Internal error: cannot parse output of git-diff-idex:\n\n $diff_index[0]";
1425             }
1426             } else {
1427 0           croak "Internal error: git-diff-index should return a single line";
1428             }
1429             } else {
1430 0           my @ls_tree = $git->run('ls-tree', "$rev:", $file );
1431              
1432 0 0         if (@ls_tree == 1) {
1433 0 0         if (my ($mode, $type, $object, $filename) =
1434             $ls_tree[0] =~ /^(\d+) ([a-z]+) ([a-z0-9]{40})\t(.+)/) {
1435 0           return oct $mode;
1436             } else {
1437 0           croak "Internal error: cannot parse output of git-ls-tree:\n\n $ls_tree[0]";
1438             }
1439             } else {
1440 0           croak "Internal error: $rev:$file should be a blob";
1441             }
1442             }
1443              
1444 0           croak "Can't happen!";
1445             }
1446              
1447             sub is_reference_enabled {
1448 0     0 1   my ($git, $reference) = @_;
1449              
1450 0 0         return 1 unless defined $reference;
1451              
1452 0           my $cache = $git->cache('is_reference_enabled');
1453              
1454 0 0         unless (exists $cache->{$reference}) {
1455             my $check_reference = sub {
1456 0     0     foreach ($git->get_config(githooks => 'ref')) {
1457 0 0         if (/^\^/) {
1458 0 0         return 1 if $reference =~ qr/$_/;
1459             } else {
1460 0 0         return 1 if $reference eq $_;
1461             }
1462             }
1463              
1464 0           foreach ($git->get_config(githooks => 'noref')) {
1465 0 0         if (/^\^/) {
1466 0 0         return 0 if $reference =~ qr/$_/;
1467             } else {
1468 0 0         return 0 if $reference eq $_;
1469             }
1470             }
1471              
1472 0           return 1;
1473 0           };
1474              
1475 0           $cache->{$reference} = $check_reference->();
1476             }
1477              
1478 0           return $cache->{$reference};
1479             }
1480              
1481             sub _grok_groups_spec {
1482 0     0     my ($groups, $specs, $source) = @_;
1483 0           foreach (@$specs) {
1484 0           s/\#.*//; # strip comments
1485 0 0         next unless /\S/; # skip blank lines
1486 0 0         /^\s*([\w-]+)\s*=\s*(.+?)\s*$/
1487             or croak __PACKAGE__, ": invalid line in '$source': $_\n";
1488 0           my ($groupname, $members) = ($1, $2);
1489 0 0         exists $groups->{"\@$groupname"}
1490             and croak __PACKAGE__, ": redefinition of group ($groupname) in '$source': $_\n";
1491 0           foreach my $member (split ' ', $members) {
1492 0 0         if ($member =~ /^\@/) {
1493             # group member
1494 0 0         $groups->{"\@$groupname"}{$member} = $groups->{$member}
1495             or croak __PACKAGE__, ": unknown group ($member) cited in '$source': $_\n";
1496             } else {
1497             # user member
1498 0           $groups->{"\@$groupname"}{$member} = undef;
1499             }
1500             }
1501             }
1502 0           return;
1503             }
1504              
1505             sub _grok_groups {
1506 0     0     my ($git) = @_;
1507              
1508 0           my $cache = $git->cache('githooks');
1509              
1510 0 0         unless (exists $cache->{groups}) {
1511 0 0         my @groups = $git->get_config(githooks => 'groups')
1512             or croak __PACKAGE__, ": you have to define the githooks.groups option to use groups.\n";
1513              
1514 0           my $groups = {};
1515 0           foreach my $spec (@groups) {
1516 0 0         if (my ($groupfile) = ($spec =~ /^file:(.*)/)) {
1517 0           my @groupspecs = path($groupfile)->lines;
1518 0 0         defined $groupspecs[0]
1519             or croak __PACKAGE__, ": can't open groups file ($groupfile): $!\n";
1520 0           _grok_groups_spec($groups, \@groupspecs, $groupfile);
1521             } else {
1522 0           my @groupspecs = split /\n/, $spec;
1523 0           _grok_groups_spec($groups, \@groupspecs, "githooks.groups");
1524             }
1525             }
1526 0           $cache->{groups} = $groups;
1527             }
1528              
1529 0           return $cache->{groups};
1530             }
1531              
1532             sub _im_memberof {
1533 0     0     my ($git, $myself, $groupname) = @_;
1534              
1535 0           my $groups = _grok_groups($git);
1536              
1537 0 0         exists $groups->{$groupname}
1538             or croak __PACKAGE__, ": group $groupname is not defined.\n";
1539              
1540 0           my $group = $groups->{$groupname};
1541 0 0         return 1 if exists $group->{$myself};
1542 0           while (my ($member, $subgroup) = each %$group) {
1543 0 0         next unless defined $subgroup;
1544 0 0         return 1 if _im_memberof($git, $myself, $member);
1545             }
1546 0           return 0;
1547             }
1548              
1549             sub match_user {
1550 0     0 1   my ($git, $spec) = @_;
1551              
1552 0 0         if (my $myself = $git->authenticated_user()) {
1553 0 0         if ($spec =~ /^\^/) {
    0          
1554 0 0         return 1 if $myself =~ $spec;
1555             } elsif ($spec =~ /^@/) {
1556 0 0         return 1 if _im_memberof($git, $myself, $spec);
1557             } else {
1558 0 0         return 1 if $myself eq $spec;
1559             }
1560             }
1561              
1562 0           return 0;
1563             }
1564              
1565             sub im_admin {
1566 0     0 1   my ($git) = @_;
1567 0           foreach my $spec ($git->get_config(githooks => 'admin')) {
1568 0 0         return 1 if match_user($git, $spec);
1569             }
1570 0           return 0;
1571             }
1572              
1573             sub grok_acls {
1574 0     0 1   my ($git, $cfg, $actions) = @_;
1575              
1576 0           my @acls;
1577              
1578             ACL:
1579 0           foreach ($git->get_config($cfg => 'acl')) {
1580 0           my %acl;
1581 0 0         if (/^\s*(allow|deny)\s+([$actions]+)\s+(\S+)/) {
1582 0           $acl{acl} = $_;
1583 0           $acl{allow} = $1 eq 'allow';
1584 0           $acl{action} = $2;
1585 0           my $spec = $3;
1586              
1587             # Interpolate environment variables embedded as "{VAR}".
1588 0           $spec =~ s/{(\w+)}/$ENV{$1}/ige;
  0            
1589             # Pre-compile regex
1590 0 0         $acl{spec} = substr($spec, 0, 1) eq '^' ? qr/$spec/ : $spec;
1591             } else {
1592 0           croak "invalid acl syntax for actions '$actions': $_\n";
1593             }
1594              
1595 0 0         if (substr($_, $+[0]) =~ /^\s*by\s+(\S+)\s*$/) {
    0          
1596 0           $acl{who} = $1;
1597             # Discard this ACL if it doesn't match the user
1598 0 0         next ACL unless $git->match_user($acl{who});
1599             } elsif (substr($_, $+[0]) !~ /^\s*$/) {
1600 0           croak "invalid acl syntax for actions '$actions: $_\n";
1601             }
1602              
1603 0           unshift @acls, \%acl;
1604             }
1605              
1606 0           return @acls;
1607             }
1608              
1609            
1610             1; # End of Git::Repository::Plugin::GitHooks
1611              
1612             __END__