test && commit || revert

I’ve been playing with Kent Beck’s TCR idea for the past couple of weeks, and it’s grown into this fun little shell script I want to share:

#!/bin/bash

# This script watches for file changes (via fswatch) in your
# git repository, runs your configured test command(s), and
# commits if they pass, reverts if they fail.
#
# This is Kent Beck's TCR idea:
# - test && commit || revert - Kent Beck - Medium
#   https://medium.com/@kentbeck_7670/test-commit-revert-870bbd756864
# - TCR test && commit || revert -- Rope in Python
#   https://www.youtube.com/playlist?list=PLlmVY7qtgT_nhLyIbeAaUlFOWbWT5y53t
#
# I'm interested in TCR as a forcing function for:
# 1. Keeping me focused
# 2. Keeping me oriented toward small changes
# 3. Keeping the tests really really fast

set -euo pipefail

# Let's clear the screen before we get started.
tput reset

# Now, TCR makes a lot (a LOT!) of small commits with
# meaningless messages.
#
# Maybe we don't worry about them! See Limbo:
# https://medium.com/@kentbeck_7670/limbo-on-the-cheap-e4cfae840330
#
# But for me, for now, on my projects, I'd like to squash a
# session's work into one commit.
#
# There's git tooling to help:
# - `git commit --fixup`
# - `git rebase --interactive --autosquash`
#
# So we start by making an empty base commit (with a fun
# message!) here before we enter the TCR loop.
#
# (Perhaps we should just run `git commit --amend` instead,
#  avoiding the rebase altogether. But something about
#  retaining short-term access to the commit history feels
#  comforting to me.)
emoji=("🍻" "πŸ¦–" "🌯" "πŸ•" "🎸" "πŸš€" "🦏" "🍩" "🍦")
message="${emoji[$RANDOM % ${#emoji[@]} ]} $(date '+%A, %B %e')"
git commit --message "$message" --allow-empty
base=$(git rev-parse HEAD)

test_and_commit_or_revert() {
  local base=${1}

  # Clear the screen at the beginning of each run.
  tput reset

  # A cyan heading and a newline help me scan the output.
  run() {
    echo "$(tput setaf 6)$*$(tput sgr0)"
    eval $*
    echo
  }

  # When the code gets reverted, I want to see what I did:
  run git --no-pager diff

  # Now, let's run the tests.
  # As soon as any test fails, this subshell exits with
  # failure. We don't want that to kill the whole script, so
  # we jankily hop in & out of `set -e`.
  set +e; ( set -e

    # (And how to emit `run`'s lovely balancing newline on
    #  failure? Like this. So cool!)
    set -E; trap echo ERR

    # Where to store the project-specific test commands?
    # Scripts or config files are messy -- and we're already
    # using git!
    git config --get-all tcr.command \
      | while read -r command; do run $command; done
  )

  result=$?; set -e

  if [ $result -eq 0 ]; then
    run git commit --all --fixup $base --allow-empty
  else
    run git reset --hard
  fi
}

# Why not just use fswatch's own looping?
# Because the revert would otherwise trigger another burst of
# fswatch events, and we want to leave the bad diff and the
# test failures on the screen.
running=1; trap "running=0" INT

while [ $running -eq 1 ]; do
  fswatch --one-event --latency 0.1 $(git ls-files) \
    | read && test_and_commit_or_revert $base
done

# You can get fswatch via `brew install fswatch`
# http://emcrisostomo.github.io/fswatch/getting.html

See it in action!