Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
52 changes: 16 additions & 36 deletions lib/undercover/changeset.rb
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
# frozen_string_literal: true

require 'rugged'
require 'time'

module Undercover
# Base class for different kinds of input
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)
Expand All @@ -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
Expand All @@ -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
81 changes: 81 additions & 0 deletions lib/undercover/changeset/git_adapter.rb
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions lib/undercover/changeset/rugged_adapter.rb
Original file line number Diff line number Diff line change
@@ -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
Loading