The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/env perl

package Git::Hooks::CheckFile;
{
  $Git::Hooks::CheckFile::VERSION = '1.0.1';
}
# ABSTRACT: Git::Hooks plugin for checking files

use 5.010;
use utf8;
use strict;
use warnings;
use Git::Hooks qw/:DEFAULT :utils/;
use Data::Util qw(:check);
use File::Basename;
use File::Slurp;
use Text::Glob qw/glob_to_regex/;
use Error qw(:try);

my $PKG = __PACKAGE__;
(my $CFG = __PACKAGE__) =~ s/.*::/githooks./;

sub check_new_files {
    my ($git, $commit, @files) = @_;

    return 1 unless @files;     # No new file to check

    # First we construct a list of checks from the
    # githooks.checkfile.basename configuration. Each check in the list is a
    # pair containing a regex and a command specification.
    my @checks;
    foreach my $check ($git->get_config($CFG => 'name')) {
        my ($pattern, $command) = split / /, $check, 2;
        if ($pattern =~ m/^qr(.)(.*)\g{1}/) {
            $pattern = qr/$2/;
        } else {
            $pattern = glob_to_regex($pattern);
        }
        $command .= ' {}' unless $command =~ /\{\}/;
        push @checks, [$pattern => $command];
    }

    # Now we iterate through every new file and apply to them the matching
    # commands.
    my $errors = 0;

    foreach my $file (@files) {
        my $basename = basename($file);
        foreach my $command (map {$_->[1]} grep {$basename =~ $_->[0]} @checks) {
            my $tmp = file_temp($git, $commit, $file)
                or ++$errors
                    and next;

            # interpolate filename in $command
            (my $cmd = $command) =~ s/\{\}/"'" . $tmp->filename() . "'"/eg;

            # execute command and update $errors
            my $saved_output = redirect_output();
            my $exit = system $cmd;
            my $output = restore_output($saved_output);
            if ($exit != 0) {
                $command =~ s/\{\}/\'$file\'/g;
                my $message = do {
                    if ($exit == -1) {
                        "'$command' failed to execute: $!";
                    } elsif ($exit & 127) {
                        sprintf("'%s' died with signal %d, %s coredump",
                                $command, ($exit & 127), ($exit & 128) ? 'with' : 'without');
                    } else {
                        sprintf("'%s' failed (exit = %d)", $command, $exit >> 8);
                    }
                };
                $git->error($PKG, $message, $output);
                ++$errors;
            } else {
                # FIXME: What we should do with eventual output from a
                # successful command?
            }
        }
    }

    return $errors == 0;
}

# This routine can act both as an update or a pre-receive hook.
sub check_affected_refs {
    my ($git) = @_;

    return 1 if im_admin($git);

    my $errors = 0;

    foreach my $ref ($git->get_affected_refs()) {
        my ($old_commit, $new_commit) = $git->get_affected_ref_range($ref);
        check_new_files($git, $new_commit, $git->filter_files_in_range('AM', $old_commit, $new_commit))
            or $errors++;
    }

    return $errors == 0;
}

sub check_commit {
    my ($git) = @_;

    return check_new_files($git, ':0', $git->filter_files_in_index('AM'));
}

sub check_patchset {
    my ($git, $opts) = @_;

    return 1 if im_admin($git);

    return check_new_files($git, ':0', $git->filter_files_in_commit('AM', $opts->{'--commit'}));
}

# Install hooks
PRE_COMMIT       \&check_commit;
UPDATE           \&check_affected_refs;
PRE_RECEIVE      \&check_affected_refs;
REF_UPDATE       \&check_affected_refs;
PATCHSET_CREATED \&check_patchset;
DRAFT_PUBLISHED  \&check_patchset;

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

Git::Hooks::CheckFile - Git::Hooks plugin for checking files

=head1 VERSION

version 1.0.1

=head1 DESCRIPTION

This Git::Hooks plugin hooks itself to the hooks below to check if the
contents of files added to or modified in the repository meet specified
constraints. If they don't, the commit/push is aborted.

=over

=item * B<pre-commit>

=item * B<update>

=item * B<pre-receive>

=item * B<ref-update>

=item * B<patchset-created>

=item * B<draft-published>

=back

To enable it you should add it to the githooks.plugin configuration
option:

    git config --add githooks.plugin CheckFile

=for Pod::Coverage check_new_files check_affected_refs check_commit check_patchset

=head1 NAME

CheckFile - Git::Hooks plugin for checking files

=head1 CONFIGURATION

The plugin is configured by the following git options.

=head2 githooks.checkfile.name PATTERN COMMAND

This directive tells which COMMAND should be used to check files matching
PATTERN.

Only the file's L<basename|https://metacpan.org/pod/File::Basename> is
matched against PATTERN.

PATTERN is usually expressed with
L<globbing|https://metacpan.org/pod/File::Glob> to match files based on
their extensions, for example:

    git config githooks.checkfile.name *.pl perlcritic --stern

If you need more power than globs can provide you can match using L<regular
expressions|http://perldoc.perl.org/perlre.html>, using the C<qr//>
operator, for example:

    git config githooks.checkfile.name qr/xpto-\\d+.pl/ perlcritic --stern

COMMAND is everything that comes after the PATTERN. It is invoked once for
each file matching PATTERN with the name of a temporary file containing the
contents of the matching file passed to it as a last argument.  If the
command exits with any code different than 0 it is considered a violation
and the hook complains, rejecting the commit or the push.

If the filename can't be the last argument to COMMAND you must tell where in
the command-line it should go using the placeholder C<{}> (like the argument
to the C<find> command's C<-exec> option). For example:

    git config githooks.checkfile.name *.xpto cmd1 --file {} | cmd2

COMMAND is invoked as a single string passed to C<system>, which means it
can use shell operators such as pipes and redirections.

Some real examples:

    git config --add githooks.checkfile.name *.p[lm] perlcritic --stern --verbose 5
    git config --add githooks.checkfile.name *.pp    puppet parser validate --verbose --debug
    git config --add githooks.checkfile.name *.pp    puppet-lint --no-variable_scope-check
    git config --add githooks.checkfile.name *.sh    bash -n
    git config --add githooks.checkfile.name *.sh    shellcheck --exclude=SC2046,SC2053,SC2086
    git config --add githooks.checkfile.name *.erb   erb -P -x -T - {} | ruby -c | sed '/Syntax OK/d'

=head1 AUTHOR

Gustavo L. de M. Chaves <gnustavo@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2014 by CPqD <www.cpqd.com.br>.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut