diff --git a/Gemfile b/Gemfile index ba24392..c66eed7 100644 --- a/Gemfile +++ b/Gemfile @@ -11,6 +11,7 @@ gem 'rake', '~> 13.0' gem 'rspec', '~> 3.0' gem 'rubocop', '~> 1.84.0' gem 'ruby-lsp-rspec', require: false +gem 'rugged', '>= 0.27', '< 1.10' gem 'simplecov' gem 'simplecov-html' gem 'simplecov_json_formatter' diff --git a/README.md b/README.md index 506eb66..9408cc9 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,24 @@ Or install it yourself as: $ gem install undercover +### Git backend + +Undercover reads diffs through one of two interchangeable backends: + +- **`git` gem** (default) — pure Ruby, no native extensions. Shells out to the system `git` binary, which must be on `PATH`. +- **`rugged`** — libgit2-based, faster on large repos. Optional, no system `git` required at runtime. + +The `git` gem requirement on a system `git` CLI is satisfied automatically in most environments (developer machines, CI runners, standard container images), but minimal production images such as distroless or `slim` Docker bases may need `git` added explicitly. If your deployment cannot install `git`, opt into `rugged` instead. + +To opt into `rugged`, add it to your Gemfile alongside `undercover`: + +```ruby +gem 'undercover' +gem 'rugged' +``` + +When `rugged` is loadable it is picked automatically; otherwise the `git` gem is used. No configuration is needed to switch — install or remove `rugged` from your bundle. + ## Setting up coverage reporting To make your specs or tests compatible with `undercover`, please add `undercover` to your gemfile to use the undercover formatter the test helper. diff --git a/lib/undercover/changeset.rb b/lib/undercover/changeset.rb index 9159da7..a420e73 100644 --- a/lib/undercover/changeset.rb +++ b/lib/undercover/changeset.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require 'rugged' require 'time' module Undercover @@ -8,17 +7,22 @@ module Undercover class Changeset T_ZERO = Time.strptime('0', '%s').freeze - def initialize(dir, compare_base = nil, filter_set = nil) - @dir = dir - @repo = Rugged::Repository.new(dir) - @repo.workdir = Pathname.new(dir).dirname.to_s # TODO: can replace? - @compare_base = compare_base + def self.default_adapter_class + require 'undercover/changeset/rugged_adapter' + RuggedAdapter + rescue LoadError + require 'undercover/changeset/git_adapter' + GitAdapter + end + + def initialize(dir, compare_base = nil, filter_set = nil, adapter: nil) + @adapter = adapter || self.class.default_adapter_class.new(dir, compare_base) @filter_set = filter_set end def last_modified mod = file_paths.map do |f| - path = File.join(repo.workdir, f) + path = File.join(@adapter.workdir, f) next T_ZERO unless File.exist?(path) File.mtime(path) @@ -27,24 +31,19 @@ def last_modified end def file_paths - full_diff.deltas.map { |d| d.new_file[:path] }.sort + @adapter.changed_files end def each_changed_line - full_diff.each_patch do |patch| - filepath = patch.delta.new_file[:path] + @adapter.each_added_line do |filepath, lineno| next if filter_set && !filter_set.include?(filepath) - patch.each_hunk do |hunk| - hunk.lines.select(&:addition?).each do |line| - yield filepath, line.new_lineno - end - end + yield filepath, lineno end end def validate(lcov_report_path) - return :no_changes if full_diff.deltas.empty? + return :no_changes if @adapter.empty? :stale_coverage if last_modified > File.mtime(lcov_report_path) end @@ -55,25 +54,6 @@ def filter_with(filter_set) private - # Diffs `head` or `head` + `compare_base` (if exists), - # as it makes sense to run Undercover with the most recent file versions - def full_diff - base = compare_base_obj || head - @full_diff ||= base.diff(repo.index).merge!(repo.diff_workdir(head)) - end - - def compare_base_obj - return nil unless compare_base - - merge_base = repo.merge_base(compare_base.to_s, head) - # merge_base may be nil with --depth 1, compare two refs directly - merge_base ? repo.lookup(merge_base) : repo.rev_parse(compare_base) - end - - def head - repo.head.target - end - - attr_reader :repo, :compare_base, :filter_set + attr_reader :filter_set end end diff --git a/lib/undercover/changeset/git_adapter.rb b/lib/undercover/changeset/git_adapter.rb new file mode 100644 index 0000000..b07cd78 --- /dev/null +++ b/lib/undercover/changeset/git_adapter.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'git' +require 'pathname' + +module Undercover + class Changeset + class GitAdapter + HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/ + + def initialize(dir, compare_base = nil) + git_dir = File.expand_path(dir) + @workdir = Pathname.new(git_dir).dirname.to_s + @repo = Git.open(@workdir, repository: git_dir) + @compare_base = compare_base + end + + attr_reader :workdir + + def changed_files + diff_files.map(&:path).sort + end + + def each_added_line + diff_files.each do |fd| + patch = fd.patch + next if patch.nil? || patch.empty? + + parse_added_lines(patch) do |lineno| + yield fd.path, lineno + end + end + end + + def empty? + diff_files.none? + end + + private + + attr_reader :repo, :compare_base + + def diff_files + @diff_files ||= repo.diff(diff_base).to_a + end + + def diff_base + return 'HEAD' unless compare_base + + merge_base_sha || compare_base.to_s + end + + def merge_base_sha + commits = repo.merge_base(compare_base.to_s, 'HEAD') + commits.first&.sha + rescue Git::FailedError, ArgumentError + nil + end + + def parse_added_lines(patch) + new_lineno = nil + patch.each_line do |line| + if (m = line.match(HUNK_HEADER)) + new_lineno = m[1].to_i + elsif new_lineno + yield new_lineno if added_line?(line) + new_lineno += 1 if added_line?(line) || context_line?(line) + end + end + end + + def added_line?(line) + line.start_with?('+') && !line.start_with?('+++') + end + + def context_line?(line) + line.start_with?(' ') + end + end + end +end diff --git a/lib/undercover/changeset/rugged_adapter.rb b/lib/undercover/changeset/rugged_adapter.rb new file mode 100644 index 0000000..43fabdb --- /dev/null +++ b/lib/undercover/changeset/rugged_adapter.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'rugged' +require 'pathname' + +module Undercover + class Changeset + class RuggedAdapter + def initialize(dir, compare_base = nil) + @repo = Rugged::Repository.new(dir) + @repo.workdir = Pathname.new(dir).dirname.to_s + @compare_base = compare_base + end + + def workdir + @repo.workdir + end + + def changed_files + full_diff.deltas.map { |d| d.new_file[:path] }.sort + end + + def each_added_line + full_diff.each_patch do |patch| + filepath = patch.delta.new_file[:path] + patch.each_hunk do |hunk| + hunk.lines.select(&:addition?).each do |line| + yield filepath, line.new_lineno + end + end + end + end + + def empty? + full_diff.deltas.empty? + end + + private + + attr_reader :repo, :compare_base + + def full_diff + base = compare_base_obj || head + @full_diff ||= base.diff(repo.index).merge!(repo.diff_workdir(head)) + end + + def compare_base_obj + return nil unless compare_base + + merge_base = repo.merge_base(compare_base.to_s, head) + merge_base ? repo.lookup(merge_base) : repo.rev_parse(compare_base) + end + + def head + repo.head.target + end + end + end +end diff --git a/spec/changeset_spec.rb b/spec/changeset_spec.rb index 01adf92..5586345 100644 --- a/spec/changeset_spec.rb +++ b/spec/changeset_spec.rb @@ -1,118 +1,131 @@ # frozen_string_literal: true require 'spec_helper' +require 'undercover/changeset/rugged_adapter' +require 'undercover/changeset/git_adapter' # These tests use the the fixture repo test.git # use `git --git-dir=test.git ` to inspect # or modify it. describe Undercover::Changeset do - it 'diffs index and staging area against HEAD' do - changeset = Undercover::Changeset.new( - 'spec/fixtures/test.git' - ) - - expect(changeset.file_paths).to match_array( - %w[file_one file_two staged_file class.rb module.rb sinatra.rb] - ) - - file_two_lines = [] - changeset.each_changed_line do |filepath, line_no| - file_two_lines << line_no if filepath == 'file_two' + shared_examples 'a changeset adapter' do |adapter_class| + def build_changeset(dir, compare_base = nil, filter_set = nil) + Undercover::Changeset.new( + dir, compare_base, filter_set, + adapter: described_adapter.new(dir, compare_base) + ) end - expect(file_two_lines).to eq([7, 10, 11]) - end - it 'has all the changes agains base with compare_base arg' do - changeset = Undercover::Changeset.new( - 'spec/fixtures/test.git', - 'master' - ) - - expect(changeset.file_paths).to match_array( - %w[file_one file_three file_two staged_file class.rb module.rb sinatra.rb] - ) - - file_two_lines = [] - file_three_lines = [] - changeset.each_changed_line do |filepath, line_no| - file_two_lines << line_no if filepath == 'file_two' - file_three_lines << line_no if filepath == 'file_three' - end - expect(file_two_lines).to eq([7, 10, 11]) - expect(file_three_lines).to eq([1, 2, 3, 4, 5, 6]) - end + let(:described_adapter) { adapter_class } + + it 'diffs index and staging area against HEAD' do + changeset = build_changeset('spec/fixtures/test.git') - it 'has a last_modified when changes are present' do - changeset = Undercover::Changeset.new( - 'spec/fixtures/test.git', - 'master' - ) + expect(changeset.file_paths).to match_array( + %w[file_one file_two staged_file class.rb module.rb sinatra.rb] + ) - Timecop.freeze do - file_paths = changeset.file_paths.map { |p| "spec/fixtures/#{p}" } - FileUtils.touch(file_paths, mtime: Time.now) - expect(changeset.last_modified.to_i).to eq(Time.now.to_i) + file_two_lines = [] + changeset.each_changed_line do |filepath, line_no| + file_two_lines << line_no if filepath == 'file_two' + end + expect(file_two_lines).to eq([7, 10, 11]) end - end - it 'has a default last_modified with no changes' do - changeset = Undercover::Changeset.new('spec/fixtures/empty.git', 'master') - expect(changeset.last_modified).to eq(Undercover::Changeset::T_ZERO) - end + it 'has all the changes agains base with compare_base arg' do + changeset = build_changeset('spec/fixtures/test.git', 'master') - describe 'filtering' do - it 'filters files using FilterSet in each_changed_line' do - filter_set = Undercover::FilterSet.new(['*.rb'], ['*_spec.rb'], []) - changeset = Undercover::Changeset.new('spec/fixtures/test.git', 'master', filter_set) + expect(changeset.file_paths).to match_array( + %w[file_one file_three file_two staged_file class.rb module.rb sinatra.rb] + ) - yielded_files = [] - changeset.each_changed_line do |filepath, _line_no| - yielded_files << filepath + file_two_lines = [] + file_three_lines = [] + changeset.each_changed_line do |filepath, line_no| + file_two_lines << line_no if filepath == 'file_two' + file_three_lines << line_no if filepath == 'file_three' end - - expect(yielded_files.uniq).to match_array(['class.rb', 'module.rb', 'sinatra.rb']) - expect(yielded_files.uniq).not_to include('file_one', 'file_two', 'file_three', 'staged_file') + expect(file_two_lines).to eq([7, 10, 11]) + expect(file_three_lines).to eq([1, 2, 3, 4, 5, 6]) end - it 'filters files using FilterSet with brace expansion' do - filter_set = Undercover::FilterSet.new(['*.{rb,js}'], ['*_spec.rb'], []) - changeset = Undercover::Changeset.new('spec/fixtures/test.git', 'master', filter_set) + it 'has a last_modified when changes are present' do + changeset = build_changeset('spec/fixtures/test.git', 'master') - yielded_files = [] - changeset.each_changed_line do |filepath, _line_no| - yielded_files << filepath + Timecop.freeze do + file_paths = changeset.file_paths.map { |p| "spec/fixtures/#{p}" } + FileUtils.touch(file_paths, mtime: Time.now) + expect(changeset.last_modified.to_i).to eq(Time.now.to_i) end + end - expect(yielded_files.uniq).to match_array(['class.rb', 'module.rb', 'sinatra.rb']) - expect(yielded_files.uniq).not_to include('file_one', 'file_two', 'file_three', 'staged_file') + it 'has a default last_modified with no changes' do + changeset = build_changeset('spec/fixtures/empty.git', 'master') + expect(changeset.last_modified).to eq(Undercover::Changeset::T_ZERO) end - end - describe 'validate' do - let(:report_path) { 'spec/fixtures/sample.lcov' } + describe 'filtering' do + it 'filters files using FilterSet in each_changed_line' do + filter_set = Undercover::FilterSet.new(['*.rb'], ['*_spec.rb'], []) + changeset = build_changeset('spec/fixtures/test.git', 'master', filter_set) + + yielded_files = [] + changeset.each_changed_line do |filepath, _line_no| + yielded_files << filepath + end + + expect(yielded_files.uniq).to match_array(['class.rb', 'module.rb', 'sinatra.rb']) + expect(yielded_files.uniq).not_to include('file_one', 'file_two', 'file_three', 'staged_file') + end - it 'returns :no_changes with empty files' do - changeset = Undercover::Changeset.new('spec/fixtures/empty.git', 'master') - expect(changeset.validate(report_path)).to eq(:no_changes) + it 'filters files using FilterSet with brace expansion' do + filter_set = Undercover::FilterSet.new(['*.{rb,js}'], ['*_spec.rb'], []) + changeset = build_changeset('spec/fixtures/test.git', 'master', filter_set) + + yielded_files = [] + changeset.each_changed_line do |filepath, _line_no| + yielded_files << filepath + end + + expect(yielded_files.uniq).to match_array(['class.rb', 'module.rb', 'sinatra.rb']) + expect(yielded_files.uniq).not_to include('file_one', 'file_two', 'file_three', 'staged_file') + end end - it 'returns :stale_coverage if coverage report is older than last file change' do - changeset = Undercover::Changeset.new('spec/fixtures/test.git', 'master') + describe 'validate' do + let(:report_path) { 'spec/fixtures/sample.lcov' } - Timecop.freeze do - file_paths = changeset.file_paths.map { |p| "spec/fixtures/#{p}" } - FileUtils.touch(file_paths, mtime: Time.now) - FileUtils.touch(report_path, mtime: Time.now - 60) + it 'returns :no_changes with empty files' do + changeset = build_changeset('spec/fixtures/empty.git', 'master') + expect(changeset.validate(report_path)).to eq(:no_changes) end - expect(changeset.validate(report_path)).to eq(:stale_coverage) - end + it 'returns :stale_coverage if coverage report is older than last file change' do + changeset = build_changeset('spec/fixtures/test.git', 'master') - it 'returns nil with no validation errors' do - changeset = Undercover::Changeset.new('spec/fixtures/test.git', 'master') - FileUtils.touch(report_path, mtime: Time.now) + Timecop.freeze do + file_paths = changeset.file_paths.map { |p| "spec/fixtures/#{p}" } + FileUtils.touch(file_paths, mtime: Time.now) + FileUtils.touch(report_path, mtime: Time.now - 60) + end - expect(changeset.validate(report_path)).to be_nil + expect(changeset.validate(report_path)).to eq(:stale_coverage) + end + + it 'returns nil with no validation errors' do + changeset = build_changeset('spec/fixtures/test.git', 'master') + FileUtils.touch(report_path, mtime: Time.now) + + expect(changeset.validate(report_path)).to be_nil + end end end + + context 'with RuggedAdapter' do + include_examples 'a changeset adapter', Undercover::Changeset::RuggedAdapter + end + + context 'with GitAdapter' do + include_examples 'a changeset adapter', Undercover::Changeset::GitAdapter + end end diff --git a/undercover.gemspec b/undercover.gemspec index 62e1f97..1ca4309 100644 --- a/undercover.gemspec +++ b/undercover.gemspec @@ -30,9 +30,9 @@ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength spec.add_dependency 'base64' spec.add_dependency 'benchmark' spec.add_dependency 'bigdecimal' + spec.add_dependency 'git', '~> 4.0' spec.add_dependency 'imagen', '>= 0.2.0' spec.add_dependency 'rainbow', '>= 2.1', '< 4.0' - spec.add_dependency 'rugged', '>= 0.27', '< 1.10' spec.add_dependency 'simplecov' spec.add_dependency 'simplecov_json_formatter' end