It's Almost 2014 and We Are Still Comitting Broken Code

Dispite testcases, syntax errors still find their way into our commits.

  • Maybe it was a change in that bash script that wasn't covered by tests. Too bad our deploys relied on it.
  • Maybe it was just a textual change and we didn't think it was necessary to run the associated code before pushing this upstream. Too bad we missed that quote.

Whatever the reason, it's almost 2014 and we are still comitting broken code. This needs to change because in the

  • Best case: Travis or Jenkins prevent those errors from hitting production and it's frustrating to go back and revert/redo that stuff. A waste of your time and state of mind, as you already moved onto other things.
  • Worst case: your error goes unnoticed and hits production.

Git offers commit hooks to prevent bad code from entering the repository, but you have to install them on a local per-project basis.

Chances are you have been too busy/lazy and never took the time/effort to whip up a commit hook that could deal with all your projects and programming languages.

That holds true for me, however I recently had some free time and decided to invest it in cooking up ochtra. One Commit Hook To Rule All.

Features

I first set out to find existing hooks, but I found all of them had caveats I wanted to avoid. For example this hook:

  • Works on many languages (Ruby, JavaScript, Python, Bash, Go, and PHP)
  • Works on filenames with spaces
  • Works on initial commits
  • Will skip files that are staged to be deleted
  • Will not run when we're not currently on a branch
  • Checks files as staged in Git, not how they're currently happen to be saved in your working dir
  • Deals with discrepancies between linters sometimes printing errors on STDOUT vs STDERR

The code

Feel free to review and suggest improvements

#!/usr/bin/env bash
#
# A Git pre-commit hook that checks for syntax errors
# for: Ruby, JavaScript, Python, Bash, Go, and (Cake)PHP
# based on the extensions of staged files in Git.
# Can be 'installed globally' as of Git 1.7.1 using init.templatedir
#
# Copyright 2013, kvz (http://twitter.com/kvz)
#
# Necessary check for initial commit
against="4b825dc642cb6eb9a060e54bf8d69288fbee4904"
git rev-parse --verify HEAD >/dev/null 2>&1 && against="HEAD"

# Only run when we're on a branch (to avoid rebase hell)
# http://git-blame.blogspot.nl/2013/06/checking-current-branch-programatically.html
if branch=$(git symbolic-ref --short -q HEAD); then
  echo on branch $branch
else
  echo not on any branch
  exit 0
fi

# Takes a command as arguments and paints both it's STDOUT & STDERR in
# colors specified in first and second arguments. Use 'purge' to skip printing
# at all
function paint() (
  set -o pipefail;

  green=$'s,.*,\x1B[32m&\x1B[m,'
  red=$'s,.*,\x1B[31m&\x1B[m,'
  gray=$'s,.*,\x1B[37m&\x1B[m,'
  purge="/.*/d"

  stdout="${!1}"
  stderr="${!2}"

  ("${@:3}" 2>&1>&3 |sed ${stderr} >&2) 3>&1 \
                    |sed ${stdout}
)

# (A)dded (C)opied or (M)odified
git diff-index --cached --full-index --diff-filter=ACM $against |while read -r line; do
  sha="$(echo ${line} |cut -d' ' -f4)"
  sts="$(echo ${line} |cut -d' ' -f5)"
  pth="$(echo ${line} |cut -d' ' -f6-)"
  ext="${pth##*.}"

  she="$(git cat-file -p ${sha} |head -n1 |awk -F/ '/^#\!/ {print $NF}' |sed 's/^env //g')"
  out="purge"
  err="red"
  cmd=""
  tmp=""

  # Select linting tool based on extension or shebang
  if [ "${ext}" = "rb" ] || [ "${ext}" = "erb" ] || [ "${she}" = "ruby" ]; then
    cmd="ruby -c -"
  elif [ "${ext}" = "js" ] || [ "${she}" = "node" ]; then
    # jshint unfortunately uses STDOUT for errors, so paint all red
    cmd="jshint -"
    out="red"
  elif [ "${ext}" = "coffee" ] || [ "${she}" = "coffee" ]; then
    # coffeelint unfortunately uses STDOUT for errors, so paint all red
    cmd="coffeelint --stdin"
    out="red"
  elif [ "${ext}" = "py" ] || [ "${she}" = "python" ]; then
    tmp="${TMPDIR:-/tmp}/${$}.${ext}"
    cmd="pylint --errors-only ${tmp}"
    out="red"
  elif [ "${ext}" = "go" ]; then
    cmd="gofmt -e"
  elif [ "${she}" = "bash" ]; then
    cmd="bash -n"
  elif [ "${she}" = "sh" ]; then
    cmd="sh -n"
  elif [ "${ext}" = "php" ] || [ "${ext}" = "ctp" ] || [ "${she}" = "php" ]; then
    cmd="php -n -l -ddisplay_errors=1 -derror_reporting=E_ALL -dlog_errrors=0"
  elif [ "${ext}" = "pl" ] || [ "${she}" = "perl" ]; then
    cmd="perl -wc -"
  fi

  if [ -n "${cmd}" ]; then
    tool="$(echo "${cmd}" |cut -d' ' -f1)"
    paint "gray" "red" echo "--> ${tool} syntax checking for ${pth}"
  else
    paint "gray" "red" echo "--> No syntax checking for ${pth}"
    continue
  fi

  # require linting tool
  if ! which "${tool}" >/dev/null 2>&1; then
    paint "red" "red" echo "Please install ${tool} for pre-commit syntax checking. "
    exit 1
  fi

  # execute on staged buffer
  [ -n "${tmp}" ] && git cat-file -p ${sha} > "${tmp}"

  if ! git cat-file -p ${sha} |paint ${out} ${err} ${cmd}; then
    paint "red" "red" echo "Please fix ${tool} syntax errors and type 'git add ${pth}'"
    [ -n "${tmp}" ] && rm -f "${tmp}"
    exit 1
  fi

  [ -n "${tmp}" ] && rm -f "${tmp}"

  paint "green" "red" echo "No ${tool} syntax errors detected in '${pth}'"
done

This file is being maintained on Github and could be more up to date there.

Test

Without installing anything, you can try ochtra on a local repository:

$ mkdir my-project && cd $_
$ git init
$ echo ";-)" > syntax-error.go
$ git add syntax-error.go
$ curl -s https://raw.github.com/kvz/ochtra/master/pre-commit |bash

This will showcase ochtra on a staged Go file with syntax errors without having to install or commit anything.

If you want to test ochtra against all languages, you can run the test suite:

git clone https://github.com/kvz/ochtra.git
cd ochtra
make test

Install

As of Git 1.7.1, you can use the init.templatedir to store hooks that you want present in all your repositories. These files will be copied from e.g. ~/.gittemplate/ into your current repo's .git dir upon every git init.

This also works for existing repos, and it will not overwrite files already present.

To install the pre-commit template type

$ mkdir -p ~/.gittemplate/hooks
$ curl https://raw.github.com/kvz/ochtra/master/pre-commit -o ~/.gittemplate/hooks/pre-commit \
 && chmod u+x $_
$ git config --global init.templatedir '~/.gittemplate'

The template is just sitting there. To install the hook into new (or existing!) repos, type

$ git init

From now on, any file you are about to commit will first be checked for syntax errors.

If you ever update your template you can type

$ rm .git/hooks/pre-commit && git init

Tips

  • If you ever want to commit code and disable the pre-commit one time, type
$ git commit -n

This can be useful if you import big chunks of code that don't pass jshint yet.

Feedback

It's a work in progress and I would like your feedback on this to make it harder, better, faster, stronger and have it support more languages. Our work is never over : )

Leave a comment here or let's collaborate on Github

Thanks to

These pages have been a great source of inspiration when building ochtra: