Gittest - love child of Git and Autotest

March 10th, 2010

Edit: this script has now evolved into a gem. See https://github.com/thickpaddy/gittest.

Continuous testing with autotest is great, especially when working on an existing application. Autotest runs in the background, and when you save a file, runs any matching tests and tells you if you've broken something, fantastic! Thing is though, there are lots of reasons why you don't want tests to run every, single time you make some little, tiny change to your code.

For me, the most important feature of autotest is its ability to find and run relevant tests for the modified code files, rather than run the entire test suite. This is crucial when you have an enormous application with a horrendously large test suite. It means you can have a fairly good idea whether you've broken the build or not, without having to run the entire test suite, which saves a lot of time.

But what if you forget to run autotest? Then you realise that you want to run the relevant tests and you can't, unless you re-save each file you have open. Or what if you've made some minor changes that you know are going to break the tests, but you want to save the file because you need to make a cup of tea or grab a sandwich? Wouldn't it be nice if you could just run the relevant tests when you're ready, rather than every single time you make a change?

Gittest is a scrappy little script that abuses autotest. You run it, it looks for new or modified files in git, then it uses the autotest mappings to determine which tests (or specs) need to run and tells autotest to run them (err, sort of). It's really, really ugly because I found it hard to figure out what the hell autotest was doing under the hood, but it works. I'll clean it up when I get a chance, but here's a working version of the script...

#!/usr/bin/env ruby

require 'rubygems'
require 'autotest'
require 'optparse'

# exit if we're not in a git repository
exit $?.exitstatus unless system('git diff --name-only > /dev/null')

# make sure we're in the root path for the repository
loop do
  begin
    Dir.entries('.git')
    break
  rescue SystemCallError
    Dir.chdir('..')
    next   
  end
end

options = {:fast => false, :diff => 'HEAD', :trace => false}
OptionParser.new do |opts|
  opts.banner = "Usage: ./script/gittest [options]"
  opts.on("-f", "--fast", "Fast mode - skips preparation of test db") do |o|
    options[:fast] = o
  end
  opts.on("-d MANDATORY", "--diff MANDATORY", "Commit argument for git diff command used to check for new or modified files (defaults to HEAD)") do |o|
    options[:diff] = o
  end
  opts.on("-t", "--trace", "Enable trace option when calling rake tasks to prepare test db") do |o|
    options[:trace] = o
  end  
end.parse!

# prepare db if fast start not switched on
unless options[:fast]

  puts "Preparing test database..."
  puts "(You can use the -f switch to skip this in future)"
  rake_options = options[:trace] ? '--trace' : ''
  system "rake db:migrate RAILS_ENV=test #{$rake_options} > /dev/null"
  system "rake db:test:prepare #{$rake_options} > /dev/null"
 
end
      
# autotest options
$f = true # never run the entire test/spec suite on startup
$v = false
$h = false
$q = false
$DEBUG = false
$help = false

# use ansi colors to highlight test/spec passes, failures and errors
COLORS = { :red => 31, :green => 32, :yellow => 33 }

# get a list of new or modified files according to git (using terminal commands is faster than using a ruby git library)
new_or_modified_files = `git diff --name-only #{options[:diff]}`.split("\n").uniq

if new_or_modified_files.size == 0
  puts "No modified files, exiting"
  exit
end
  
msg = "#{new_or_modified_files.size} new or modified file" 
msg << "s" unless new_or_modified_files.size == 1
puts msg + ":"

new_or_modified_files.each {|f| puts "\t#{f}"}

at = Autotest.new
# Note: the initialize hook is normally called within Autotest#run
at.hook :initialize
at.reset
at.find_files # must populate the known files for autotest, otherwise Autotest#files_matching will always return nil

# this isn't pretty, but it will probably be reliable enough (can't see any good reason for renaming that particular instance variable, IMO it should be accessible anyway)
test_mappings = at.instance_eval { @test_mappings }

# find files to test
files_to_test = at.new_hash_of_arrays
new_or_modified_files.each do |f|
  next if f =~ at.exceptions # skip exceptions
  result = test_mappings.find { |file_re, ignored| f =~ file_re }
  unless result.nil?
    [result.last.call(f, $~)].flatten.each {|match| files_to_test[match] if File.exist?(match)}
  end
end

# exit if no files to test
puts "No matching files to test, exiting" and exit if files_to_test.empty?
  
msg = "#{files_to_test.size} file" 
msg << "s" unless files_to_test.size == 1
msg << " to test"
puts msg + ":"
puts "\t" + files_to_test.map{|k,v| k}.sort.join("\n\t")

puts "Press ENTER to continue, or CTRL+C to quit"
begin
  $stdin.gets # note: Kernel#gets assumes that ARGV contains a list of files from which to read next line
rescue Interrupt
  exit 1
end
puts "Running tests and specs, please wait..." 

cmd = at.make_test_cmd(files_to_test)

at.hook :run_command

# copied from Autotest#run_tests and updated to use ansi colours in TURN enabled test output and specs run with the format option set to specdoc
old_sync = $stdout.sync
$stdout.sync = true
results = []
line = []
begin
  open("| #{cmd}", "r") do |f|
    until f.eof? do
      c = f.getc or break
      # putc c
      line << c
      if c == ?\n then
        str = if RUBY_VERSION >= "1.9" then
                          line.join
                        else
                          line.pack "c*"
                        end
        results << str
        line.clear
        if str.match(/(PASS|FAIL|ERROR)$/)
          # test output
          case $1
            when 'PASS' ; color = :green
            when 'FAIL' ; color = :red
            when 'ERROR' ; color = :yellow
          end
          print "\e[#{COLORS[color]}m" + str + "\e[0m"
        elsif str.match(/^\- /)
          # spec output 
          if str.match(/^\- .*(ERROR|FAILED) \- [0-9]+/)
            color = $1 == 'FAILED' ? :red : :yellow 
            print "\e[#{COLORS[color]}m" + str + "\e[0m"
          else
            print "\e[#{COLORS[:green]}m" + str + "\e[0m"
          end
        else
          print str
        end
      end
    end
  end
ensure
  $stdout.sync = old_sync
end

at.handle_results(results.join)

Sorry, comments are closed for this article.