From 0102b5e508abb9df54a105b772165b2980e10d3d Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 6 May 2026 14:42:30 -0700 Subject: [PATCH 1/5] Extract Rugged code into a Changeset adapter Move all direct Rugged usage out of Changeset into a new RuggedAdapter. Changeset now delegates workdir lookup, file/line diff iteration, and emptiness checks to an adapter, keeping filtering and validation logic in Changeset itself. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/undercover/changeset.rb | 52 ++++++------------- lib/undercover/changeset/rugged_adapter.rb | 59 ++++++++++++++++++++++ 2 files changed, 75 insertions(+), 36 deletions(-) create mode 100644 lib/undercover/changeset/rugged_adapter.rb 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/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 From 3a8492b92bcce92e3202624c199e677be01da53d Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 6 May 2026 14:42:52 -0700 Subject: [PATCH 2/5] Add a GitAdapter backed by the git gem Implement a Changeset adapter on top of the pure-Ruby git gem so undercover can read changes without depending on libgit2. Parses unified diff hunks to recover added line numbers, mirroring the RuggedAdapter contract. Parameterize changeset_spec over both adapters to lock the contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/undercover/changeset/git_adapter.rb | 81 +++++++++++ spec/changeset_spec.rb | 179 +++++++++++++----------- undercover.gemspec | 1 + 3 files changed, 178 insertions(+), 83 deletions(-) create mode 100644 lib/undercover/changeset/git_adapter.rb 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/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..5faf450 100644 --- a/undercover.gemspec +++ b/undercover.gemspec @@ -30,6 +30,7 @@ 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' From 9accf03d6d95e5732378a71b9b30f7eec6bdb07e Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 6 May 2026 14:43:10 -0700 Subject: [PATCH 3/5] Drop rugged as a required dependency Remove rugged from undercover.gemspec so installing undercover no longer pulls in the libgit2 native extension. RuggedAdapter is still preferred at runtime when rugged happens to be loadable, so existing users see no change. Keep rugged in the dev Gemfile so both adapters get exercised by the test suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- Gemfile | 1 + undercover.gemspec | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) 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/undercover.gemspec b/undercover.gemspec index 5faf450..1ca4309 100644 --- a/undercover.gemspec +++ b/undercover.gemspec @@ -33,7 +33,6 @@ Gem::Specification.new do |spec| # rubocop:disable Metrics/BlockLength 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 From 724c3b1c5d604731bab294ce680369aa6f0f1582 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 6 May 2026 14:45:05 -0700 Subject: [PATCH 4/5] Document the git/rugged backend choice in README Explain that undercover ships with the pure-Ruby git gem by default and opts into rugged automatically when it is in the user's bundle. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 506eb66..d25f3e0 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,22 @@ 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. Used automatically. +- **`rugged`** — libgit2-based, faster on large repos. Optional. + +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. From 7a413996045b00f1984f5c0c9503a9621b32aa8a Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 6 May 2026 14:51:43 -0700 Subject: [PATCH 5/5] Note the system git CLI requirement of the default backend Call out in the Git backend section that the git gem shells out to a system git binary, which is universally present on dev and CI machines but may need to be added to minimal production images (distroless, slim Docker bases). Point users at rugged as the escape hatch when installing git is not an option. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d25f3e0..9408cc9 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,10 @@ Or install it yourself as: Undercover reads diffs through one of two interchangeable backends: -- **`git` gem** (default) — pure Ruby, no native extensions. Used automatically. -- **`rugged`** — libgit2-based, faster on large repos. Optional. +- **`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`: