#!/bin/sh # Tidy staged Perl, judge it against .perlcriticrc, then run the tests, before # any of it lands. # # There is a .perltidyrc, and a tree stays tidy to it only if something makes it. # Left to people, it does not: tidying a file you are changing three lines of # buries the change in a hundred lines of reformatting, so the reasonable thing # is to skip it, and the tree drifts until a mass tidy is the only way back. # One commit's worth at a time is small enough that nobody has to weigh it up. # # perlcritic is here for the other half of that. Nothing else in tCMS runs the # profile, so this is where an objection is about the diff in front of you. # # There is one profile, .perlcriticrc, and which one that is was decided when # the floor was chosen: the packaging-perl skill asks which perl you # are targeting and copies the matching profile to that name. So the hook needs # no opinion about versions -- the file it reads is already the right one, and # changing your floor means replacing that file rather than editing this. # # Install it: cp git-hooks/pre-commit .git/hooks/ # # A file qualifies three ways: # - a .pm, .pl or .t extension # - a .PL extension, which is how a Makefile.PL gets covered # - executable with perl on its shebang line, which is how the extensionless # scripts in bin/ get covered # # .t is in there because test files are often not executable, and the shebang # rule alone would then tidy whichever ones happen to be and let the rest drift. # # Both passes read the working tree rather than the index, so a file staged in # part is judged whole. That is deliberate: perltidy rewrites the file on disk # and the hook stages what it wrote, so the working copy is what lands anyway, # and two passes disagreeing about which bytes they looked at would be worse to # own than this. # # Both run from the top of the working tree, which is where git runs a hook and # the only place they work at all: the profile names .preferred_modules.ini and # .pod_stopwords by relative path. # Redirect output to stderr. exec 1>&2 # ACMR so that a file deleted in this commit is not handed to perltidy, which # would fail on a path that is no longer there. staged=$(git diff --cached --name-only --diff-filter=ACMR) lib_files='' test_files='' for file in $staged do test -f "$file" || continue is_perl='' case "$file" in *.pm|*.pl|*.PL|*.t) is_perl=1 ;; esac if [ -z "$is_perl" ] && test -x "$file" && head -n 1 "$file" | grep -Eq '^#!.*perl' then is_perl=1 fi test -n "$is_perl" || continue # Whether a file is perl at all, and then which list it goes in. One case # statement doing both would file t/00-load.t under whichever arm it reached # first, and the .t rule is above the path rule. case "$file" in t/*) test_files="$test_files $file" ;; *) lib_files="$lib_files $file" ;; esac done # For the tidy pass, which does not care which list a file came from. The # emptiness tests below concatenate the two without a space instead: with one, # "no perl staged at all" would be a string holding a space, which is not empty. all_files="$lib_files $test_files" # Quoted here, because a bare [ $all_files ] is a syntax error the moment there # is more than one file -- which would stop the hook working in exactly the # commits that need it most. Unquoted below, because there the word splitting is # what turns one string into an argument list. if [ -z "$lib_files$test_files" ] then : elif command -v perltidy >/dev/null 2>&1 then echo "Auto-tidying perl changes..." # -bext='/' makes perltidy delete the backup it would otherwise leave # sitting next to the file as .bak. perltidy -b -bext='/' $all_files || { echo "pre-commit: perltidy failed, leaving the commit alone." exit 1 } git update-index --add $all_files echo "Done." else echo "pre-commit: perltidy is not on PATH, skipping the tidy pass." fi # After the tidy pass and never before. The tidy pass rewrites these files and # stages what it wrote, so what critic has to judge is the bytes that will land # rather than the ones you saved -- and if you ever turn [CodeLayout:: # RequireTidyCode] back on, running it first would fail the commit over the one # thing the hook had already set out to fix by itself. # # Three branches rather than two conditions and one else. Staging nothing the # profile judges -- a commit of documentation, say -- is not the same thing as # having no perlcritic to judge it with, and one else says the second whenever # the first is true. if [ -z "$lib_files$test_files" ] then echo "Nothing staged that the profile judges." elif command -v perlcritic >/dev/null 2>&1 then echo "Running perlcritic..." # Both passes run even when the first has already failed, so one commit # shows every objection rather than one list's worth at a time. critic_failed='' if [ -n "$lib_files" ] then perlcritic --profile .perlcriticrc $lib_files || critic_failed=1 fi # The same profile for t/, less three policies. A test is not a module: # nobody runs perldoc on it, so requiring POD of it asks for a page no # reader will ever open. And a test sets %ENV and %INC, and writes its # fixtures, in a BEGIN block before the SUT loads, for the whole file: a # local would end with the block, and no compiled binary is made of a test. # Everything else in the profile still judges it, which is more than a # release does -- all_critic_ok() reads lib/ and not t/, so this hook is the # only thing that looks at a test file at all. if [ -n "$test_files" ] then perlcritic --profile .perlcriticrc --exclude Documentation::RequirePod --exclude Variables::RequireLocalizedPunctuationVars --exclude CompileTime $test_files || critic_failed=1 fi if [ -n "$critic_failed" ] then echo echo "pre-commit: perlcritic refused the above. Either fix them, or say" echo "why with a '## no critic (Policy::Name)' and the reason beside it." echo "To commit without these checks at all: git commit --no-verify" exit 1 fi echo "Done." else # Skipped rather than fatal, for the same reason the tidy pass is: a machine # without perlcritic can still commit. echo "pre-commit: perlcritic is not on PATH, skipping the critic pass." fi # Last, because the tests read the files that the tidy pass wrote. Whatever is # staged, and not only Perl: a change to a share/ file or a fixture breaks a # test as surely as a change to a module does. -l for lib/. # # Without the GIT_ variables that git sets for this hook. It sets # GIT_INDEX_FILE, and GIT_DIR too in a worktree, and git obeys them over -C. So # a test that runs git in a repository of its own would add, commit and write # config in this one. In a subshell, because the git commands of this hook need # them: under git commit -a, GIT_INDEX_FILE names the index being committed. run_tests() { ( unset $(env | sed -n 's/^\(GIT_[A-Za-z0-9_]*\)=.*/\1/p') prove -lm -j8 "$@" ) || { echo "pre-commit: the tests above failed. To see why, run each failing file" echo "on its own: prove -lv t/.t" echo "To commit without these checks at all: git commit --no-verify" exit 1 } } test -d t || exit 0 # Without tests-covering, every test, because nothing here knows which tests a # change can reach. if ! command -v tests-covering >/dev/null 2>&1 then echo "Running every test, because tests-covering is not on PATH; install Perl::Tests::Covering..." run_tests t/ exit 0 fi # tests-covering chooses the tests that ran a line the diff changes, from the # records that git-hooks/post-commit keeps up to date. It asks # .tests-covering-map.pl about each file that no test loads, and the map names # the files that no test reads, such as dist.ini. --unexplained all runs every # test for a file that neither can place, such as a fixture. --full-index, # because it matches each file to its records by the blob id on the index line # of the diff, and an abbreviated id matches nothing. tests=$(git diff --cached --full-index | tests-covering --diff --unexplained all) || exit 1 if [ -z "$tests" ] then echo "No test ran a line that this commit changes." exit 0 fi echo "Running the tests that ran what this commit changes..." run_tests $tests