diff --git a/README.md b/README.md new file mode 100644 index 0000000..2185ead --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# pre-commit + +This program is a git pre-commit hook that runs an arbitrary set of +syntax, style, and complexity checks against files about to be checked +in. + +pre-commit is a git-hook; you install it into your project's .git +directory in .git/hooks and make it executable. It will run every time +you attempt to commit a collection of files, running the configured list +of linters against those files, and will terminate the check-in if any +of the files fails. + +pre-commit uses the git-stash to temporarily store any changes you may +have made between your "git-add" and your "git-commit"; it therefore +checks against your *staged* files, not your *workspace* files. Most +hooks do the wrong thing and assume your stage and workspace are the +same. This is not necessarily so. + +pre-commit is written in Hy, a Lisp-like dialect of Python. I find Hy's +support for "cond", complex anonymous functions, and complex return +values highly appealing. The UTF-8 handling in this script means it is +compatible only with Hy running atop Python3. + +pre-commit is based on the pre-commit recommendations in Steve Pulec's +"Why you need a git-hook and why most are wrong" available at: +http://css.dzone.com/articles/why-your-need-git-pre-commit The changes +I've made reflect a different set of needs, different possible ways of +receiving error conditions, and a slightly nicer output. + +If, while installing this, you encounter a problem, you must return your +git repository to its original pre-stash state. Learn to use the +following commands correctly: + +git stash list +git stash pop + diff --git a/pre-commit b/pre-commit new file mode 100755 index 0000000..c31396b --- /dev/null +++ b/pre-commit @@ -0,0 +1,180 @@ +#!/usr/bin/env hy + +(def *version* "0.0.2") +(import os re subprocess sys) + +; pccs (pre-commit configs) should be a directory under your .git +; where you store the RCs for your various linters. If you want to +; use a global one, you'll have to edit the configuration entries +; below. + +(def *config-path* (os.path.join (.get os.environ "GIT_DIR" "./.git") "pccs")) +(def *modified* (.compile re "^[MA]\s+(?P.*)$")) + +(def *checks* + [ +; { +; "output" "Checking for debugger commands in Javascript..." +; "command" "grep -n debugger {filename}" +; "match_files" [".*\.js$"] +; "print_filename" True +; "error_condition" "output" +; } + { + "output" "Running Jshint..." + "command" "jshint -c {config_path}/jshint.rc {filename}" + "match_files" [".*\.js$"] + "print_filename" False + "error_condition" "error" + } + { + "output" "Running Coffeelint..." + "command" "coffeelint {filename}" + "match_files" [".*\.coffee$"] + "print_filename" False + "error_condition" "error" + } + { + "output" "Running JSCS..." + "command" "jscs -c {config_path}/jscs.rc {filename}" + "match_files" [".*\.js$"] + "print_filename" False + "error_condition" "error" + } + { + "output" "Running pep8..." + "command" "pep8 -r --ignore=E501,W293,W391 {filename}" + "match_files" [".*\.py$"] + "print_filename" False + "error_condition" "error" + } + { + "output" "Running xmllint..." + "command" "xmllint {filename}" + "match_files" [".*\.xml"] + "print_filename" False + "error_condition" "error" + } + ] + ) + +(defn get-git [cmd] + (let [[fullcmd (+ ["git"] cmd)] + [process (subprocess.Popen fullcmd + :stdout subprocess.PIPE + :stderr subprocess.PIPE)] + [(, out err) (.communicate process)]] + (, out err process.returncode))) + +(defn call-git [cmd] + (let [[fullcmd (+ ["git"] cmd)]] + (subprocess.call fullcmd + :stdout subprocess.PIPE + :stderr subprocess.PIPE))) + +(defn get-cmd [cmd] + (let [[process (subprocess.Popen cmd + :stdout subprocess.PIPE + :stderr subprocess.PIPE + :shell True)] + [(, out err) (.communicate process)]] + (, out err process.returncode))) + +(defn max-code [code-pairs] + (reduce (fn [m i] (if + (> (abs (get i 0)) (abs m)) + (get i 0) + m)) + code-pairs 0)) + +(defn message-bodies [code-pairs] + (lmap (fn [i] (get i 1)) code-pairs)) + +(defn matches-file [filename match-files] + (any (map (fn [match-file] (-> (.compile re match-file) + (.match filename))) + match-files))) + +; Hy is overeager to return an iterators which is consumed during +; later traversal. lmap returns a concrete list instead. + +(defn lmap (pred iter) (list (map pred iter))) + +(defn run-external-checker [filename check] + (let [[cmd (-> (get check "command") (.format + :filename filename + :config_path *config-path*))] + [(, out err returncode) (get-cmd cmd)]] + (if (or (and out (= (.get check "error_condition" "error") "output")) + err + (not (= returncode 0))) + (let [[prefix (if (get check "print_filename") + (.format "\t{}:" filename) + "\t")] + [output (+ + (lmap (fn [line] (.format "{}{}" prefix (.decode line "utf-8"))) + (.splitlines out)) + (if err [err] []))]] + [(or returncode 1) output]) + [0 []]))) + +(defn check-file [filename check] + (cond [(and (in "match_files" check) + (not (matches-file filename (get check "match_files")))) [0 []] ] + [(and (in "ignore_files" check) + (matches-file filename (get check "ignore_files"))) [0 []] ] + [true (run-external-checker filename check)])) + +(defn check-files [filenames check] + (let [[scan-results (lmap + (fn [filename] (check-file filename check)) filenames)] + [messages (+ [(get check "output")] (message-bodies scan-results))]] + [(max-code scan-results) messages])) + +(defn get-all-files [] + (let [[build-filenames + (fn [filenames] + (map + (fn [f] (os.path.join (get filenames 0) f)) (get filenames 2)))]] + (flatten (list-comp (build-filenames o) [o (.walk os ".")])))) + +; I removed the originally recommended "-u" command from stash; it was +; "cleaning up" far too zealously, deleting my node_modules directory + +(defn get-some-files [against] + (let [[(, out err returncode) + (get-git ["diff-index" "--name-status" against])] + [lines (.splitlines out)] + [matcher (fn [line] (.match *modified* (.decode line "utf-8")))]] + (filter + (fn [x] (not (= x ""))) + (list-comp (.group match "name") [match (map matcher lines)] match)))) + +(defn scan [all-files against] + (do + (call-git ["stash" "--keep-index"]) + (let [[toscan + (list (if all-files (get-all-files) (get-some-files against)))] + [check-results + (lmap (fn [check] (check-files toscan check)) *checks*)] + [exit-code (max-code check-results)] + [messages (flatten (message-bodies check-results))]] + (do + (for [line messages] (print line)) + (call-git ["reset" "--hard"]) + (call-git ["stash" "pop" "--quiet" "--index"]) + exit-code)))) + +; That magic number below is what git requires when the repository is +; completely empty. + +(defn get-head-tag [] + (let [[(, out err returncode) (get-git ["rev-parse" "--verify HEAD"])]] + (if err "4b825dc642cb6eb9a060e54bf8d69288fbee4904" "HEAD"))) + +(defmain [&rest args] + (sys.exit + (scan + (and (> (len args) 1) + (= (get args 2) "--all-files")) + (get-head-tag)))) diff --git a/pre-commit.py b/pre-commit.py new file mode 100644 index 0000000..4e20b60 --- /dev/null +++ b/pre-commit.py @@ -0,0 +1,153 @@ +from hy.core.language import filter, flatten, is_integer, map, reduce +VERSION = '0.0.2' +import os +import re +import subprocess +import sys +CONFIG_PATH = os.path.join(os.environ.get('GIT_DIR', './.git'), 'pccs') +MODIFIED = re.compile('^[MA]\\s+(?P.*)$') +CHECKS = [{'output': 'Running Jshint...', 'command': 'jshint -c {config_path}/jshint.rc {filename}', 'match_files': ['.*\\.js$'], 'print_filename': False, 'error_condition': 'error', }, {'output': 'Running Coffeelint...', 'command': 'coffeelint {filename}', 'match_files': ['.*\\.coffee$'], 'print_filename': False, 'error_condition': 'error', }, {'output': 'Running JSCS...', 'command': 'jscs -c {config_path}/jscs.rc {filename}', 'match_files': ['.*\\.js$'], 'print_filename': False, 'error_condition': 'error', }, {'output': 'Running pep8...', 'command': 'pep8 -r --ignore=E501,W293,W391 {filename}', 'match_files': ['.*\\.py$'], 'print_filename': False, 'error_condition': 'error', }, {'output': 'Running xmllint...', 'command': 'xmllint {filename}', 'match_files': ['.*\\.xml'], 'print_filename': False, 'error_condition': 'error', }] + +def get_git(cmd): + + def _hy_anon_fn_1(): + fullcmd = (['git'] + cmd) + process = subprocess.Popen(fullcmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + (out, err) = process.communicate() + (out, err) + return (out, err, process.returncode) + return _hy_anon_fn_1() + +def call_git(cmd): + + def _hy_anon_fn_3(): + fullcmd = (['git'] + cmd) + return subprocess.call(fullcmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return _hy_anon_fn_3() + +def get_cmd(cmd): + + def _hy_anon_fn_5(): + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + (out, err) = process.communicate() + (out, err) + return (out, err, process.returncode) + return _hy_anon_fn_5() + +def max_code(code_pairs): + + def _hy_anon_fn_7(m, i): + return (i[0] if (abs(i[0]) > abs(m)) else m) + return reduce(_hy_anon_fn_7, code_pairs, 0) + +def message_bodies(code_pairs): + + def _hy_anon_fn_9(i): + return i[1] + return lmap(_hy_anon_fn_9, code_pairs) + +def matches_file(filename, match_files): + + def _hy_anon_fn_11(match_file): + return re.compile(match_file).match(filename) + return any(map(_hy_anon_fn_11, match_files)) + +def lmap(pred, iter): + return list(map(pred, iter)) + +def run_external_checker(filename, check): + + def _hy_anon_fn_16(): + cmd = check['command'].format(filename=filename, config_path=CONFIG_PATH) + (out, err, returncode) = get_cmd(cmd) + (out, err, returncode) + if ((out and (check.get('error_condition', 'error') == 'output')) or err or (not (returncode == 0))): + + def _hy_anon_fn_15(): + prefix = ('\t{}:'.format(filename) if check['print_filename'] else '\t') + + def _hy_anon_fn_14(line): + return '{}{}'.format(prefix, line.decode('utf-8')) + output = (lmap(_hy_anon_fn_14, out.splitlines()) + ([err] if err else [])) + return [(returncode or 1), output] + _hy_anon_var_1 = _hy_anon_fn_15() + else: + _hy_anon_var_1 = [0, []] + return _hy_anon_var_1 + return _hy_anon_fn_16() + +def check_file(filename, check): + return ([0, []] if (('match_files' in check) and (not matches_file(filename, check['match_files']))) else ([0, []] if (('ignore_files' in check) and matches_file(filename, check['ignore_files'])) else (run_external_checker(filename, check) if True else None))) + +def check_files(filenames, check): + + def _hy_anon_fn_20(): + + def _hy_anon_fn_19(filename): + return check_file(filename, check) + scan_results = lmap(_hy_anon_fn_19, filenames) + messages = ([check['output']] + message_bodies(scan_results)) + return [max_code(scan_results), messages] + return _hy_anon_fn_20() + +def get_all_files(): + + def _hy_anon_fn_24(): + + def build_filenames(filenames): + + def _hy_anon_fn_22(f): + return os.path.join(filenames[0], f) + return map(_hy_anon_fn_22, filenames[2]) + return flatten([build_filenames(o) for o in os.walk('.')]) + return _hy_anon_fn_24() + +def get_some_files(against): + + def _hy_anon_fn_28(): + (out, err, returncode) = get_git(['diff-index', '--name-status', against]) + (out, err, returncode) + lines = out.splitlines() + + def matcher(line): + return MODIFIED.match(line.decode('utf-8')) + + def _hy_anon_fn_27(x): + return (not (x == '')) + return filter(_hy_anon_fn_27, [match.group('name') for match in map(matcher, lines) if match]) + return _hy_anon_fn_28() + +def scan(all_files, against): + call_git(['stash', '--keep-index']) + + def _hy_anon_fn_31(): + toscan = list((get_all_files() if all_files else get_some_files(against))) + + def _hy_anon_fn_30(check): + return check_files(toscan, check) + check_results = lmap(_hy_anon_fn_30, CHECKS) + exit_code = max_code(check_results) + messages = flatten(message_bodies(check_results)) + for line in messages: + print(line) + call_git(['reset', '--hard']) + call_git(['stash', 'pop', '--quiet', '--index']) + return exit_code + return _hy_anon_fn_31() + +def get_head_tag(): + + def _hy_anon_fn_33(): + (out, err, returncode) = get_git(['rev-parse', '--verify HEAD']) + (out, err, returncode) + return ('4b825dc642cb6eb9a060e54bf8d69288fbee4904' if err else 'HEAD') + return _hy_anon_fn_33() + +def main(*args): + return sys.exit(scan(((len(args) > 1) and (args[2] == '--all-files')), get_head_tag())) +if (__name__ == '__main__'): + import sys + :G_1235 = main(*sys.argv) + _hy_anon_var_2 = (sys.exit(:G_1235) if is_integer(:G_1235) else None) +else: + _hy_anon_var_2 = None