diff --git a/bin/dirty-rubocop b/bin/dirty-rubocop new file mode 100755 index 0000000000..ab2d781420 --- /dev/null +++ b/bin/dirty-rubocop @@ -0,0 +1,202 @@ +#!/usr/bin/env ruby + +# A sneaky wrapper around Rubocop that allows you to run it only against +# the recent changes, as opposed to the whole project. It lets you +# enforce the style guide for new/modified code only, as opposed to +# having to restyle everything or adding cops incrementally. It relies +# on git to figure out which files to check. +# +# Here are some options you can pass in addition to the ones in rubocop: +# +# --local Check only the changes you are about to push +# to the remote repository. +# +# --staged Check only changes that are currently staged. +# +# --uncommitted Check only changes in files that have not been +# --index committed (i.e. either in working directory or +# staged). +# +# --against REFSPEC Check changes since REFSPEC. This can be +# anything that git will recognize. +# +# --branch Check only changes in the current branch. +# +# Caveat emptor: +# +# * Monkey patching ahead. This script relies on Rubocop internals and +# has been tested against 1.30.1. Newer (or older) versions might +# break it. +# +# * While it does try to check modified lines only, there might be some +# quirks. It might not show offenses in modified code if they are +# reported at unmodified lines. It might also show offenses in +# unmodified code if they are reported in modified lines. + +# Inspired and adapted from +# https://gist.github.com/skanev/9d4bec97d5a6825eaaf6 +# https://gist.github.com/MaxLap/ea4b6d1df81de3024562798b5501b9c8 + +require 'rubocop' + +module DirtyCop + extend self # In your face, style guide! + + def bury_evidence?(file, line) + !report_offense_at?(file, line) + end + + def bury_evidences(file, offenses) + offenses.reject { |o| bury_evidence?(file, o.line) } + end + + def staged_changes_only? + !!@staged_changes_only + end + + def uncovered_targets + @files + end + + def cover_up_unmodified(ref) + @files = files_modified_since(ref) + @line_filter = build_line_filter(@files, ref) + end + + def process_bribe + eat_a_donut if ARGV.empty? + + ref = nil + + loop do + arg = ARGV.shift + case arg + when '--local' + ref = `git rev-parse --abbrev-ref --symbolic-full-name @{u}` + exit 1 unless $?.success? + when '--staged' + ref = '--cached' + @staged_changes_only = true + ARGV << "--cache=false" + when '--against' + ref = ARGV.shift + when '--uncommitted', '--index' + ref = 'HEAD' + when '--branch' + ref = `git merge-base HEAD dev` + else + ARGV.unshift arg + break + end + end + + return unless ref + + cover_up_unmodified ref.chomp + end + + private + + def report_offense_at?(file, line) + @line_filter && @line_filter.fetch(file)[line] + end + + def files_modified_since(ref) + `git diff --diff-filter=AM --name-only #{ref}` + .lines + .map(&:chomp) + .map { |file| File.absolute_path(file) } + end + + def build_line_filter(suspects, ref) + result = {} + + suspects.each do |file| + result[file] = lines_modified_since(file, ref) + end + + result + end + + def lines_modified_since(file, ref) + ranges = + `git diff -p -U0 #{ref} #{file}` + .lines + .grep(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/) { $1.to_i...($1.to_i + ($2 || 1).to_i) } + .reverse + + return [] if ranges.empty? + + mask = Array.new(ranges.first.end) + + ranges.each do |range| + range.each do |line| + mask[line] = true + end + end + + mask + end + + def eat_a_donut + puts "#$PROGRAM_NAME: The dirty cop Alex Murphy could have been" + puts + puts File.read(__FILE__)[/(?:^#(?:[^!].*)?\n)+/s].gsub(/^#/, ' ') + exit + end +end + +module RuboCop + class TargetFinder + alias find_unpatched find + + def find(...) + every_files = find_unpatched(...) + modified_files = DirtyCop.uncovered_targets + + if modified_files + every_files & modified_files + else + every_files + end + end + end + + class Runner + alias inspect_file_unpatched inspect_file + + def inspect_file(file, *args) + offenses, updated = inspect_file_unpatched(file, *args) + offenses = offenses.reject { |o| DirtyCop.bury_evidence?(file.path, o.line) } + [offenses, updated] + end + + alias add_redundant_disables_unpatched add_redundant_disables + def add_redundant_disables(file, offenses, source) + offenses = add_redundant_disables_unpatched(file, offenses, source) + DirtyCop.bury_evidences(file, offenses) + end + end + + class ProcessedSource + class << self + alias from_file_unpatched from_file + end + + def self.from_file(path, ruby_version) + if DirtyCop.staged_changes_only? + pathname = Pathname.new(path) + git_root = Pathname.new(`git rev-parse --show-toplevel`.strip) + git_relative_path = pathname.relative_path_from(git_root).to_s + source = `git show :#{git_relative_path}` + new(source, ruby_version, path) + else + from_file_unpatched(path, ruby_version) + end + end + end +end + +DirtyCop.process_bribe + +exit RuboCop::CLI.new.run diff --git a/lefthook.yml b/lefthook.yml index b316fa6aab..b03d2bbdd3 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -16,4 +16,4 @@ pre-commit: rubocop: files: git diff --name-only --staged glob: "*.rb" - run: bundle exec rubocop --force-exclusion {files} + run: bin/dirty-rubocop --uncommitted --force-exclusion {files}