File Coverage

blib/lib/App/GitHooks/StagedChanges.pm
Criterion Covered Total %
statement 30 172 17.4
branch 0 52 0.0
condition 0 27 0.0
subroutine 10 23 43.4
pod 10 10 100.0
total 50 284 17.6


line stmt bran cond sub pod time code
1             package App::GitHooks::StagedChanges;
2              
3 23     23   95 use strict;
  23         37  
  23         816  
4 23     23   140 use warnings;
  23         25  
  23         559  
5              
6             # External dependencies.
7 23     23   83 use Carp qw( croak );
  23         26  
  23         945  
8 23     23   99 use Data::Dumper;
  23         25  
  23         1073  
9 23     23   103 use Data::Validate::Type;
  23         26  
  23         918  
10 23     23   101 use File::Basename qw();
  23         27  
  23         277  
11 23     23   14182 use Parallel::ForkManager qw();
  23         310886  
  23         545  
12 23     23   15401 use Path::Tiny qw();
  23         222006  
  23         596  
13 23     23   153 use Try::Tiny;
  23         32  
  23         1521  
14              
15             # Internal dependencies.
16 23     23   110 use App::GitHooks::Constants qw( :PLUGIN_RETURN_CODES );
  23         33  
  23         43458  
17              
18              
19             =head1 NAME
20              
21             App::GitHooks::StagedChanged - Staged changes in git.
22              
23              
24             =head1 VERSION
25              
26             Version 1.7.3
27              
28             =cut
29              
30             our $VERSION = '1.7.3';
31              
32              
33             =head1 METHODS
34              
35             =head2 new()
36              
37             Instantiate a new C 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 instance.
50              
51             =back
52              
53             =cut
54              
55             sub new
56             {
57 0     0 1   my ( $class, %args ) = @_;
58 0           my $app = delete( $args{'app'} );
59              
60             # Check arguments.
61 0 0         croak 'An "app" argument is mandatory'
62             if !Data::Validate::Type::is_instance( $app, class => 'App::GitHooks' );
63              
64 0           return bless(
65             {
66             app => $app,
67             },
68             $class,
69             );
70             }
71              
72              
73             =head2 get_app()
74              
75             Return the parent C object.
76              
77             my $app = $staged_changes->get_app();
78              
79             =cut
80              
81             sub get_app
82             {
83 0     0 1   my ( $self ) = @_;
84              
85 0           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 0     0 1   my ( $self, %args ) = @_;
110 0 0         croak 'Invalid argument(s): ' . join( ', ', keys %args )
111             if scalar( keys %args ) != 0;
112              
113 0           $self->analyze_changes();
114              
115             # Check the changed files.
116 0           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 0     0 1   my ( $self ) = @_;
141 0           my $app = $self->get_app();
142 0           my $repository = $app->get_repository();
143              
144             # Get a list of changes from Git.
145 0           my @changes = $repository->run( 'diff', '--cached', '--name-status', '--', '.' );
146              
147             # Parse changes.
148 0           my $files = {};
149 0           foreach my $change ( @changes )
150             {
151 0           my ( $git_action, $file ) = ( $change =~ /^(\w+)\s+(.*)$/x );
152 0 0         if ( !defined( $file ) )
153             {
154 0           print $app->wrap( "Could not parse git diff output:\n$change\n" );
155 0           return 0;
156             }
157              
158 0           $files->{ $file } = $git_action;
159             }
160              
161             # Check each file.
162 0           my $allow_commit = 1;
163 0           my $has_warnings = 0;
164 0           my $total = scalar( keys %$files );
165 0           my $count = 1;
166 0           foreach my $file ( sort keys %$files )
167             {
168 0           my ( $file_passes, $file_warnings ) = $self->check_file(
169             file => $file,
170             git_action => $files->{ $file },
171             total => $total,
172             count => $count,
173             );
174 0   0       $allow_commit &&= $file_passes;
175 0   0       $has_warnings ||= $file_warnings;
176 0           $count++;
177             }
178              
179 0           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 0     0 1   my ( $self, %args ) = @_;
231 0           my $file = delete( $args{'file'} );
232 0           my $git_action = delete( $args{'git_action'} );
233 0           my $total = delete( $args{'total'} );
234 0           my $count = delete( $args{'count'} );
235 0           my $app = $self->get_app();
236 0           my $repository = $app->get_repository();
237              
238 0           print $app->wrap( $app->color( 'blue', "($count/$total) $file" ) . "\n" );
239              
240             # Skip symlinks.
241 0 0         if ( -l $repository->work_tree . '/' . $file )
242             {
243 0           print $app->wrap(
244             $app->color( 'bright_black', "- Skipping symlink." ) . "\n",
245             ' ',
246             );
247 0           return ( 1, 0 );
248             }
249              
250             # Skip directories if needed.
251 0           my $config = $app->get_config();
252 0           my $skip_directories = $config->get_regex( '_', 'skip_directories' );
253 0 0 0       if ( defined( $skip_directories ) && ( $file =~ /$skip_directories/ ) )
254             {
255 0           print $app->wrap(
256             $app->color( 'bright_black', "- Skipping excluded directory." ) . "\n",
257             ' ',
258             );
259 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 0           my $match_file = $file;
265 0 0         if ( $git_action ne 'D' )
266             {
267 0           my ( undef, undef, $extension ) = File::Basename::fileparse( $file, qr/(?<=\.)[^\.]*$/ );
268 0 0 0       if ( !defined( $extension ) || $extension eq '' )
269             {
270 0 0         open( my $fh, '<', $file ) || croak "Can't open file $file: $!";
271 0           my $first_line = <$fh>;
272 0           close( $fh );
273             # TODO: generalize to other file types.
274 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 0           my $tests = [];
281              
282 0           my $plugins = $app->get_hook_plugins( 'pre-commit-file' );
283 0           foreach my $plugin ( @$plugins )
284             {
285 0           my $pattern = $plugin->get_file_pattern( app => $app );
286 0 0         next if $match_file !~ $pattern;
287 0           push(
288             @$tests,
289             $plugin,
290             );
291             }
292              
293 0 0         return ( 1, 0 )
294             if scalar( @$tests ) == 0;
295              
296             # Run the checks in parallel.
297 0           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 0 0 0       if ( ( $git_action eq 'D' )
  0            
306             && ( scalar( grep { $_->{'return_value'} != $PLUGIN_RETURN_SKIPPED } @$ordered_output ) == 0 )
307             )
308             {
309 0           print $app->wrap(
310             $app->color( 'bright_black', "- Skipping deleted file." ) . "\n",
311             ' ',
312             );
313 0           return ( 1, 0 );
314             }
315             # Otherwise, display all the information.
316             else
317             {
318 0           foreach my $output ( @$ordered_output )
319             {
320 0           print $self->format_check_output( $output );
321             }
322             }
323              
324             # Determine if the file passed all the checks or not.
325 0           my $file_passes = 1;
326 0           my $file_warnings = 0;
327 0           foreach my $output ( @$ordered_output )
328             {
329 0           my $return_value = $output->{'return_value'};
330              
331 0 0 0       next if $return_value == $PLUGIN_RETURN_PASSED
332             || $return_value == $PLUGIN_RETURN_SKIPPED;
333              
334 0 0         if ( $return_value == $PLUGIN_RETURN_WARNED )
    0          
335             {
336 0           $file_warnings = 1;
337             }
338             elsif ( $return_value == $PLUGIN_RETURN_FAILED )
339             {
340 0           $file_passes = 0;
341 0           last;
342             }
343             else
344             {
345 0           croak "Unrecognized return value: >$return_value<";
346             }
347             }
348              
349 0           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 0     0 1   my ( $self, %args ) = @_;
386 0           my $tests = delete( $args{'tests'} );
387 0           my $file = delete( $args{'file'} );
388 0           my $git_action = delete( $args{'git_action'} );
389 0           my $app = $self->get_app();
390              
391             # Configure the fork manager.
392 0           my $fork_manager = Parallel::ForkManager->new(4);
393              
394             # Add a hook to determine whether the file passed all the checks.
395 0           my $ordered_output = [];
396             $fork_manager->run_on_finish(
397             sub
398             {
399 0     0     my ( $pid, $exit_code, $ident, $exit_signal, $core_dump, $data_structure_reference ) = @_;
400 0 0         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 0           my $counter = delete( $data_structure_reference->{'counter'} );
409 0           $ordered_output->[ $counter ] = $data_structure_reference;
410             }
411 0           );
412              
413 0           my $method = 'run_' . $app->get_hook_name() . '_file';
414 0           $method =~ s/-/_/g;
415              
416             # Run the checks.
417 0           my $counter = -1;
418 0           foreach my $test ( @$tests )
419             {
420 0           my $name = $test->get_file_check_description();
421 0           $counter++;
422              
423             # Start a parallel process to execute this iteration of the loop.
424 0   0       my $pid = $fork_manager->start() && next;
425              
426             # Execute the check.
427             my ( $return_value, $error_message ) = try
428             {
429             return (
430 0     0     $test->$method(
431             file => $file,
432             git_action => $git_action,
433             app => $app,
434             ),
435             undef,
436             );
437             }
438             catch
439             {
440 0     0     chomp( $_ );
441 0           return ( $PLUGIN_RETURN_FAILED, $_ );
442 0           };
443              
444             # Terminate the parallel process and report back to the parent.
445 0   0       $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 0           $fork_manager->wait_all_children();
459              
460 0           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 0     0 1   my ( $self, $data ) = @_;
483 0           my $app = $self->get_app();
484              
485 0           my $name = $data->{'name'};
486 0           my $return_value = $data->{'return_value'};
487 0           my $error_message = $data->{'error_message'};
488              
489 0           my $failure_character = $app->get_failure_character();
490              
491             # Format the output.
492 0           my $output = '';
493 0 0         if ( $return_value == $PLUGIN_RETURN_FAILED )
    0          
    0          
    0          
494             {
495             # The check failed.
496 0           $output .= $app->wrap(
497             $app->color( 'red', $failure_character ) . $app->color( 'bright_black', " $name" ) . "\n",
498             " ",
499             );
500 0 0         $return_value .= "\n" if $return_value !~ /\n\Z/;
501 0   0       $error_message //= '(no error message specified)';
502 0           chomp( $error_message );
503 0           $output .= $app->wrap( $error_message, " " ) . "\n";
504             }
505             elsif ( $return_value == $PLUGIN_RETURN_PASSED )
506             {
507             # The check passed.
508 0           my $success_character = $app->get_success_character();
509 0           $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 0           $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 0           my $warning_character = $app->get_warning_character();
526 0           $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           $output .= $app->wrap(
535             $app->color( 'red', $failure_character ) . $app->color( 'bright_black', " $name" ) . "\n",
536             " ",
537             );
538 0           $output .= $app->wrap( "Invalid return value >$return_value<, contact the maintainer.", " " );
539             }
540              
541 0           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 0     0 1   my ( $self ) = @_;
557 0           my $app = $self->get_app();
558 0           my $repository = $app->get_repository();
559              
560             # Detect merges.
561 0 0         $self->{'is_merge'} = -e ( $repository->work_tree() . '/.git/MERGE_MSG' ) ? 1 : 0;
562              
563             # Detect reverts.
564 0           $self->{'is_revert'} = 0;
565 0 0         if ( $self->{'is_merge'} )
566             {
567 0           my $merge_message = Path::Tiny::path( $repository->work_tree(), '.git', 'MERGE_MSG' )->slurp();
568 0 0         $self->{'is_revert'} = 1
569             if $merge_message =~ /^Revert\s/;
570             }
571              
572 0           return;
573             }
574              
575              
576             =head2 is_revert()
577              
578             Return true if the changes correspond to a C 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 0 0         $self->analyze_changes()
590             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 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 0 0         $self->analyze_changes()
610             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.
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
638              
639             =item * AnnoCPAN: Annotated CPAN documentation
640              
641             L
642              
643             =item * CPAN Ratings
644              
645             L
646              
647             =item * MetaCPAN
648              
649             L
650              
651             =back
652              
653              
654             =head1 AUTHOR
655              
656             L,
657             C<< >>.
658              
659              
660             =head1 COPYRIGHT & LICENSE
661              
662             Copyright 2013-2015 Guillaume Aubert.
663              
664             This program is free software: you can redistribute it and/or modify it under
665             the terms of the GNU General Public License version 3 as published by the Free
666             Software Foundation.
667              
668             This program is distributed in the hope that it will be useful, but WITHOUT ANY
669             WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
670             PARTICULAR PURPOSE. See the GNU General Public License for more details.
671              
672             You should have received a copy of the GNU General Public License along with
673             this program. If not, see http://www.gnu.org/licenses/
674              
675             =cut
676              
677             1;