File Coverage

blib/lib/App/GitHooks/StagedChanges.pm
Criterion Covered Total %
statement 146 172 84.8
branch 26 52 50.0
condition 14 27 51.8
subroutine 20 23 86.9
pod 10 10 100.0
total 216 284 76.0


line stmt bran cond sub pod time code
1             package App::GitHooks::StagedChanges;
2              
3 32     32   125 use strict;
  32         45  
  32         906  
4 32     32   136 use warnings;
  32         39  
  32         847  
5              
6             # External dependencies.
7 32     32   132 use Carp qw( croak );
  32         41  
  32         1338  
8 32     32   149 use Data::Dumper;
  32         109  
  32         1468  
9 32     32   141 use Data::Validate::Type;
  32         36  
  32         1442  
10 32     32   138 use File::Basename qw();
  32         48  
  32         536  
11 32     32   17584 use Parallel::ForkManager qw();
  32         500510  
  32         822  
12 32     32   22639 use Path::Tiny qw();
  32         284005  
  32         905  
13 32     32   314 use Try::Tiny;
  32         59  
  32         2440  
14              
15             # Internal dependencies.
16 32     32   162 use App::GitHooks::Constants qw( :PLUGIN_RETURN_CODES );
  32         54  
  32         66789  
17              
18              
19             =head1 NAME
20              
21             App::GitHooks::StagedChanged - Staged changes in git.
22              
23              
24             =head1 VERSION
25              
26             Version 1.9.0
27              
28             =cut
29              
30             our $VERSION = '1.9.0';
31              
32              
33             =head1 METHODS
34              
35             =head2 new()
36              
37             Instantiate a new C<App::GitHooks::StagedChanges> object.
38              
39             my $staged_changes = App::GitHooks::StagedChanges->new(
40             app => $app,
41             );
42              
43             Arguments:
44              
45             =over 4
46              
47             =item * app I<(mandatory)>
48              
49             An C<App::GitHook> instance.
50              
51             =back
52              
53             =cut
54              
55             sub new
56             {
57 14     14 1 40 my ( $class, %args ) = @_;
58 14         76 my $app = delete( $args{'app'} );
59              
60             # Check arguments.
61 14 50       72 croak 'An "app" argument is mandatory'
62             if !Data::Validate::Type::is_instance( $app, class => 'App::GitHooks' );
63              
64 14         306 return bless(
65             {
66             app => $app,
67             },
68             $class,
69             );
70             }
71              
72              
73             =head2 get_app()
74              
75             Return the parent C<App::GitHooks> object.
76              
77             my $app = $staged_changes->get_app();
78              
79             =cut
80              
81             sub get_app
82             {
83 63     63 1 165 my ( $self ) = @_;
84              
85 63         219 return $self->{'app'};
86             }
87              
88              
89             =head2 verify()
90              
91             Verify the changes that are being committed.
92              
93             This method returns an array composed of:
94              
95             =over 4
96              
97             =item * A boolean to indicate whether the checks passed or failed.
98              
99             =item * A boolean to indicate whether any warnings were displayed.
100              
101             =back
102              
103             ( $allow_commit, $has_warnings ) = $staged_changes->verify();
104              
105             =cut
106              
107             sub verify
108             {
109 14     14 1 28 my ( $self, %args ) = @_;
110 14 50       52 croak 'Invalid argument(s): ' . join( ', ', keys %args )
111             if scalar( keys %args ) != 0;
112              
113 14         60 $self->analyze_changes();
114              
115             # Check the changed files.
116 14         140 return $self->check_changed_files();
117             }
118              
119              
120             =head2 check_changed_files()
121              
122             Verify that the files changed pass various rules.
123              
124             This method returns an array composed of:
125              
126             =over 4
127              
128             =item * A boolean to indicate whether the files passed the checks.
129              
130             =item * A boolean to indicate whether any warnings were displayed.
131              
132             =back
133              
134             my ( $all_files_pass, $has_warnings ) = check_changed_files();
135              
136             =cut
137              
138             sub check_changed_files
139             {
140 14     14 1 48 my ( $self ) = @_;
141 14         124 my $app = $self->get_app();
142 14         240 my $repository = $app->get_repository();
143              
144             # Get a list of changes from Git.
145 14         122 my @changes = $repository->run( 'diff', '--cached', '--name-status', '--', '.' );
146              
147             # Parse changes.
148 14         200814 my $files = {};
149 14         80 foreach my $change ( @changes )
150             {
151 14         174 my ( $git_action, $file ) = ( $change =~ /^(\w+)\s+(.*)$/x );
152 14 50       212 if ( !defined( $file ) )
153             {
154 0         0 print $app->wrap( "Could not parse git diff output:\n$change\n" );
155 0         0 return 0;
156             }
157              
158 14         98 $files->{ $file } = $git_action;
159             }
160              
161             # Check each file.
162 14         60 my $allow_commit = 1;
163 14         130 my $has_warnings = 0;
164 14         74 my $total = scalar( keys %$files );
165 14         40 my $count = 1;
166 14         84 foreach my $file ( sort keys %$files )
167             {
168             my ( $file_passes, $file_warnings ) = $self->check_file(
169             file => $file,
170 14         158 git_action => $files->{ $file },
171             total => $total,
172             count => $count,
173             );
174 7   66     59 $allow_commit &&= $file_passes;
175 7   66     41 $has_warnings ||= $file_warnings;
176 7         17 $count++;
177             }
178              
179 7         81 return ( $allow_commit, $has_warnings );
180             }
181              
182              
183             =head2 check_file()
184              
185             Verify that that a given file passes all the verification rules.
186              
187             This method returns an array composed of:
188              
189             =over 4
190              
191             =item * A boolean to indicate whether the file passed all the checks.
192              
193             =item * A boolean to indicate whether any warnings were displayed.
194              
195             =back
196              
197             my ( $file_passes, $has_warnings ) = check_file(
198             count => $count,
199             file => $file,
200             git_action => $git_action,
201             total => $total,
202             );
203              
204             Arguments:
205              
206             =over 4
207              
208             =item * count I<(mandatory)>
209              
210             The number of the file in the list of files to check.
211              
212             =item * file I<(mandatory)>
213              
214             The path of the file to check.
215              
216             =item * git_action I<(mandatory)>
217              
218             The action performed by git on the file (add, delete, etc).
219              
220             =item * total I<(mandatory)>
221              
222             The total number of files to check as part of this git hooks instance.
223              
224             =back
225              
226             =cut
227              
228             sub check_file ## no critic (Subroutines::ProhibitExcessComplexity)
229             {
230 14     14 1 268 my ( $self, %args ) = @_;
231 14         58 my $file = delete( $args{'file'} );
232 14         60 my $git_action = delete( $args{'git_action'} );
233 14         40 my $total = delete( $args{'total'} );
234 14         44 my $count = delete( $args{'count'} );
235 14         116 my $app = $self->get_app();
236 14         312 my $repository = $app->get_repository();
237              
238 14         272 print $app->wrap( $app->color( 'blue', "($count/$total) $file" ) . "\n" );
239              
240             # Skip symlinks.
241 14 50       240 if ( -l $repository->work_tree . '/' . $file )
242             {
243 0         0 print $app->wrap(
244             $app->color( 'bright_black', "- Skipping symlink." ) . "\n",
245             ' ',
246             );
247 0         0 return ( 1, 0 );
248             }
249              
250             # Skip directories if needed.
251 14         1016 my $config = $app->get_config();
252 14         176 my $skip_directories = $config->get_regex( '_', 'skip_directories' );
253 14 50 33     80 if ( defined( $skip_directories ) && ( $file =~ /$skip_directories/ ) )
254             {
255 0         0 print $app->wrap(
256             $app->color( 'bright_black', "- Skipping excluded directory." ) . "\n",
257             ' ',
258             );
259 0         0 return ( 1, 0 );
260             }
261              
262             # If the file has no extension, try to determine it based on the first line
263             # (except for deleted files).
264 14         42 my $match_file = $file;
265 14 50       66 if ( $git_action ne 'D' )
266             {
267 14         2962 my ( undef, undef, $extension ) = File::Basename::fileparse( $file, qr/(?<=\.)[^\.]*$/ );
268 14 50 33     200 if ( !defined( $extension ) || $extension eq '' )
269             {
270 0 0       0 open( my $fh, '<', $file ) || croak "Can't open file $file: $!";
271 0         0 my $first_line = <$fh>;
272 0         0 close( $fh );
273             # TODO: generalize to other file types.
274 0 0 0     0 $match_file .= '.pl' if defined( $first_line ) && ( $first_line =~ /^#!.*perl/ );
275             }
276             }
277              
278             # Find all the tests we will need to run on the file.
279             # Use an arrayref here instead of a hashref, to preserve test order.
280 14         48 my $tests = [];
281              
282 14         166 my $plugins = $app->get_hook_plugins( 'pre-commit-file' );
283 14         76 foreach my $plugin ( @$plugins )
284             {
285 14         272 my $pattern = $plugin->get_file_pattern( app => $app );
286 14 50       208 next if $match_file !~ $pattern;
287 14         68 push(
288             @$tests,
289             $plugin,
290             );
291             }
292              
293 14 50       72 return ( 1, 0 )
294             if scalar( @$tests ) == 0;
295              
296             # Run the checks in parallel.
297 14         820 my $ordered_output = $self->run_parallelized_checks(
298             tests => $tests,
299             file => $file,
300             git_action => $git_action,
301             );
302              
303             # If the file has been deleted and all the checks were skipped, print a
304             # short message instead.
305 7 50 33     3545 if ( ( $git_action eq 'D' )
306 0         0 && ( scalar( grep { $_->{'return_value'} != $PLUGIN_RETURN_SKIPPED } @$ordered_output ) == 0 )
307             )
308             {
309 0         0 print $app->wrap(
310             $app->color( 'bright_black', "- Skipping deleted file." ) . "\n",
311             ' ',
312             );
313 0         0 return ( 1, 0 );
314             }
315             # Otherwise, display all the information.
316             else
317             {
318 7         28 foreach my $output ( @$ordered_output )
319             {
320 7         57 print $self->format_check_output( $output );
321             }
322             }
323              
324             # Determine if the file passed all the checks or not.
325 7         18 my $file_passes = 1;
326 7         15 my $file_warnings = 0;
327 7         18 foreach my $output ( @$ordered_output )
328             {
329 7         18 my $return_value = $output->{'return_value'};
330              
331 7 100 100     70 next if $return_value == $PLUGIN_RETURN_PASSED
332             || $return_value == $PLUGIN_RETURN_SKIPPED;
333              
334 3 100       14 if ( $return_value == $PLUGIN_RETURN_WARNED )
    50          
335             {
336 1         2 $file_warnings = 1;
337             }
338             elsif ( $return_value == $PLUGIN_RETURN_FAILED )
339             {
340 2         2 $file_passes = 0;
341 2         2 last;
342             }
343             else
344             {
345 0         0 croak "Unrecognized return value: >$return_value<";
346             }
347             }
348              
349 7         184 return ( $file_passes, $file_warnings );
350             }
351              
352              
353             =head2 run_parallelized_checks()
354              
355             Run in parallel the checks for a given file.
356              
357             run_parallelized_checks(
358             tests => $tests,
359             file => $file,
360             git_action => $git_action,
361             );
362              
363             Arguments:
364              
365             =over 4
366              
367             =item * tests
368              
369             An arrayref of tests to run.
370              
371             =item * file
372              
373             The path of the file being checked.
374              
375             =item * git_action
376              
377             The type of action recorded by git on the file (deletion, addition, etc).
378              
379             =back
380              
381             =cut
382              
383             sub run_parallelized_checks
384             {
385 14     14 1 106 my ( $self, %args ) = @_;
386 14         42 my $tests = delete( $args{'tests'} );
387 14         48 my $file = delete( $args{'file'} );
388 14         1468 my $git_action = delete( $args{'git_action'} );
389 14         68 my $app = $self->get_app();
390              
391             # Configure the fork manager.
392 14         210 my $fork_manager = Parallel::ForkManager->new(4);
393              
394             # Add a hook to determine whether the file passed all the checks.
395 14         60412 my $ordered_output = [];
396             $fork_manager->run_on_finish(
397             sub
398             {
399 7     7   8061054 my ( $pid, $exit_code, $ident, $exit_signal, $core_dump, $data_structure_reference ) = @_;
400 7 50       57 croak 'Invalid check return: ' . Dumper( $data_structure_reference )
401             if !defined( $data_structure_reference );
402              
403             # Store the output. There is no guaranteed order in which the
404             # sub-processes will complete, but we want to keep their final
405             # output in the order they were listed in the patterns. To achieve
406             # that, we store them in an array that we'll display once all the
407             # sub-processes have completed.
408 7         32 my $counter = delete( $data_structure_reference->{'counter'} );
409 7         67 $ordered_output->[ $counter ] = $data_structure_reference;
410             }
411 14         266 );
412              
413 14         378 my $method = 'run_' . $app->get_hook_name() . '_file';
414 14         94 $method =~ s/-/_/g;
415              
416             # Run the checks.
417 14         30 my $counter = -1;
418 14         58 foreach my $test ( @$tests )
419             {
420 14         92 my $name = $test->get_file_check_description();
421 14         28 $counter++;
422              
423             # Start a parallel process to execute this iteration of the loop.
424 14   100     70 my $pid = $fork_manager->start() && next;
425              
426             # Execute the check.
427             my ( $return_value, $error_message ) = try
428             {
429             return (
430 7     7   2687 $test->$method(
431             file => $file,
432             git_action => $git_action,
433             app => $app,
434             ),
435             undef,
436             );
437             }
438             catch
439             {
440 0     0   0 chomp( $_ );
441 0         0 return ( $PLUGIN_RETURN_FAILED, $_ );
442 7         10570 };
443              
444             # Terminate the parallel process and report back to the parent.
445 7   50     546 $fork_manager->finish(
446             0, # Exit code, not used.
447             {
448             name => $name,
449             return_value => $return_value // '',
450             error_message => $error_message,
451             counter => $counter,
452             }
453             );
454             }
455              
456             # Make sure all the checks have been completed, before we move to the next
457             # file.
458 7         13804 $fork_manager->wait_all_children();
459              
460 7         126 return $ordered_output;
461             }
462              
463              
464             =head2 format_check_output()
465              
466             Format the output of a check against a file into a printable string.
467              
468             format_check_output(
469             app => $app,
470             data =>
471             {
472             name => $name,
473             return_value => $return_value,
474             error_message => $error_message,
475             }
476             );
477              
478             =cut
479              
480             sub format_check_output
481             {
482 7     7 1 27 my ( $self, $data ) = @_;
483 7         62 my $app = $self->get_app();
484              
485 7         24 my $name = $data->{'name'};
486 7         23 my $return_value = $data->{'return_value'};
487 7         15 my $error_message = $data->{'error_message'};
488              
489 7         77 my $failure_character = $app->get_failure_character();
490              
491             # Format the output.
492 7         25 my $output = '';
493 7 100       73 if ( $return_value == $PLUGIN_RETURN_FAILED )
    100          
    100          
    50          
494             {
495             # The check failed.
496 2         11 $output .= $app->wrap(
497             $app->color( 'red', $failure_character ) . $app->color( 'bright_black', " $name" ) . "\n",
498             " ",
499             );
500 2 50       14 $return_value .= "\n" if $return_value !~ /\n\Z/;
501 2   50     12 $error_message //= '(no error message specified)';
502 2         12 chomp( $error_message );
503 2         6 $output .= $app->wrap( $error_message, " " ) . "\n";
504             }
505             elsif ( $return_value == $PLUGIN_RETURN_PASSED )
506             {
507             # The check passed.
508 3         16 my $success_character = $app->get_success_character();
509 3         20 $output .= $app->wrap(
510             $app->color( 'green', $success_character ) . $app->color( 'bright_black', " $name" ) . "\n",
511             " ",
512             );
513             }
514             elsif ( $return_value == $PLUGIN_RETURN_SKIPPED )
515             {
516             # The check was skipped.
517 1         8 $output .= $app->wrap(
518             $app->color( 'bright_black', "- $name" ) . "\n",
519             " ",
520             );
521             }
522             elsif ( $return_value == $PLUGIN_RETURN_WARNED )
523             {
524             # The check returned warnings.
525 1         32 my $warning_character = $app->get_warning_character();
526 1         7 $output .= $app->wrap(
527             $app->color( 'orange', $warning_character ) . $app->color( 'bright_black', " $name" ) . "\n",
528             " ",
529             );
530             }
531             else
532             {
533             # The check sent an invalid return value.
534 0         0 $output .= $app->wrap(
535             $app->color( 'red', $failure_character ) . $app->color( 'bright_black', " $name" ) . "\n",
536             " ",
537             );
538 0         0 $output .= $app->wrap( "Invalid return value >$return_value<, contact the maintainer.", " " );
539             }
540              
541 7         50 return $output;
542             }
543              
544              
545             =head2 analyze_changes()
546              
547             Analyze the state of the repository to detect if the changes correspond to a
548             merge or revert operation.
549              
550             $staged_changes->analyze_changes();
551              
552             =cut
553              
554             sub analyze_changes
555             {
556 14     14 1 22 my ( $self ) = @_;
557 14         64 my $app = $self->get_app();
558 14         62 my $repository = $app->get_repository();
559              
560             # Detect merges.
561 14 50       174 $self->{'is_merge'} = -e ( $repository->work_tree() . '/.git/MERGE_MSG' ) ? 1 : 0;
562              
563             # Detect reverts.
564 14         858 $self->{'is_revert'} = 0;
565 14 50       770 if ( $self->{'is_merge'} )
566             {
567 0         0 my $merge_message = Path::Tiny::path( $repository->work_tree(), '.git', 'MERGE_MSG' )->slurp();
568 0 0       0 $self->{'is_revert'} = 1
569             if $merge_message =~ /^Revert\s/;
570             }
571              
572 14         86 return;
573             }
574              
575              
576             =head2 is_revert()
577              
578             Return true if the changes correspond to a C<git revert> operation, false
579             otherwise.
580              
581             my $is_revert = $staged_changes->is_revert();
582              
583             =cut
584              
585             sub is_revert
586             {
587 0     0 1   my ( $self ) = @_;
588              
589             $self->analyze_changes()
590 0 0         if !defined( $self->{'is_revert'} );
591              
592 0           return $self->{'is_revert'};
593             }
594              
595              
596             =head2 is_merge()
597              
598             Return true if the changes correspond to a C<git revert> operation, false
599             otherwise.
600              
601             my $is_merge = $staged_changes->is_merge();
602              
603             =cut
604              
605             sub is_merge
606             {
607 0     0 1   my ( $self ) = @_;
608              
609             $self->analyze_changes()
610 0 0         if !defined( $self->{'is_merge'} );
611              
612 0           return $self->{'is_merge'};
613             }
614              
615              
616             =head1 BUGS
617              
618             Please report any bugs or feature requests through the web interface at
619             L<https://github.com/guillaumeaubert/App-GitHooks/issues/new>.
620             I will be notified, and then you'll automatically be notified of progress on
621             your bug as I make changes.
622              
623              
624             =head1 SUPPORT
625              
626             You can find documentation for this module with the perldoc command.
627              
628             perldoc App::GitHooks::StagedChanges
629              
630              
631             You can also look for information at:
632              
633             =over
634              
635             =item * GitHub's request tracker
636              
637             L<https://github.com/guillaumeaubert/App-GitHooks/issues>
638              
639             =item * AnnoCPAN: Annotated CPAN documentation
640              
641             L<http://annocpan.org/dist/app-githooks>
642              
643             =item * CPAN Ratings
644              
645             L<http://cpanratings.perl.org/d/app-githooks>
646              
647             =item * MetaCPAN
648              
649             L<https://metacpan.org/release/App-GitHooks>
650              
651             =back
652              
653              
654             =head1 AUTHOR
655              
656             L<Guillaume Aubert|https://metacpan.org/author/AUBERTG>,
657             C<< <aubertg at cpan.org> >>.
658              
659              
660             =head1 COPYRIGHT & LICENSE
661              
662             Copyright 2013-2017 Guillaume Aubert.
663              
664             This code is free software; you can redistribute it and/or modify it under the
665             same terms as Perl 5 itself.
666              
667             This program is distributed in the hope that it will be useful, but WITHOUT ANY
668             WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
669             PARTICULAR PURPOSE. See the LICENSE file for more details.
670              
671             =cut
672              
673             1;