Skip to content

Instantly share code, notes, and snippets.

@andynu
Last active August 17, 2022 06:36
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save andynu/69b052ed2f7680ba728b84fc34936578 to your computer and use it in GitHub Desktop.
Save andynu/69b052ed2f7680ba728b84fc34936578 to your computer and use it in GitHub Desktop.
Find the last version that passes the tests by doing a binary search through a range of versions
#!/usr/bin/env ruby
#
# Find the last version that passes the tests by doing
# a binary search through a range of versions
#
# Usage: gem-bisect <gem_name> [<from_version> [<to_version>]]
#
# Example
# ❯ gem-bisect rack-attack 6.3 6.6.1
# Checking versions 6.6.1, 6.5.0, 6.4.0, 6.3.1
# --------------------------------------------------------------------------------
# 2022-08-15 13:09:20 -0400
# Trying rack-attack 6.4.0
# ... omit test output ...
# --------------------------------------------------------------------------------
# 2022-08-15 13:10:22 -0400
# Trying rack-attack 6.5.0
# ... omit test output ...
# ... etc ...
# ================================================================================
# last good: 6.6.1
# first bad: Hurray! No failing versions.
#
# Or if there was a failure, you'd see that version string.
#
require 'rubygems'
gem_name, from, to = ARGV
gem_name ||= 'rails'
# @return {Array} of version {String}s
def load_version_list(gem)
results = `gem search #{gem} --all --exact`
results.lines.last.gsub("#{gem} (", '').gsub(')', '').split(',').map(&:strip)
end
def to_version_objects(gem_version_strings)
gem_version_strings.map do |version_string|
Gem::Version.new(version_string)
end
end
def filter_major_minor(versions)
max_per_major_minor = {}
versions.each do |version|
major, minor, = version.canonical_segments
last_version = max_per_major_minor[[major, minor]]
max_per_major_minor[[major, minor]] = version if last_version.nil? || version > last_version
end
max_per_major_minor.values
end
def filter_range(versions, from, to)
ver_from = Gem::Version.new(from)
ver_to = Gem::Version.new(to)
versions.select do |ver|
ver_from <= ver && ver <= ver_to
end
end
def git_dirty?
system('git diff --quiet')
!$?.success?
end
def gsub_file(path, rex, replace)
File.write(path, File.readlines(path).map{|line| line.gsub(rex, replace) }.join)
end
def change_version(gem_name, version)
gsub_file 'Gemfile', /\s*gem ['"]#{gem_name}['"].*$/, "gem '#{gem_name}', '= #{version}'"
#system("git diff Gemfile") # Show the diff
`bundle update #{gem_name}`
bundle_ok = $?.success?
warn "Could not bundle #{gem_name} #{version}" unless bundle_ok
bundle_ok
end
def reset_version
`git checkout Gemfile Gemfile.lock`
end
def test
system('rails test')
$?.success?
end
if $0 == __FILE__
if git_dirty?
warn 'Do not run gem-bisect on a dirty repository. It needs to be able to change the Gemfile and reset the Gemfile'
return
end
versions = filter_major_minor(to_version_objects(load_version_list(gem_name)))
from ||= versions.first.to_s
to ||= versions.last.to_s
versions = filter_range(versions, from, to)
puts "Checking versions #{versions.map(&:to_s).join(', ')}"
good_version = versions.bsearch do |version|
test_result = nil
begin
puts
puts '-' * 80
puts Time.now # rubocop:disable Rails/TimeZone (This is not a rails script)
puts "Trying #{gem_name} #{version}"
test_result = change_version(gem_name, version) && test
ensure
reset_version
end
test_result
end
good_idx = versions.index(good_version)
bad_idx = good_idx - 1
bad_version = versions[bad_idx]&.to_s unless bad_idx < 0
puts
puts '=' * 80
puts
puts <<~STR
last good: #{good_version&.to_s}
first bad: #{bad_version || 'Hurray! No failing versions.'}
STR
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment