Module: Mutineer::ChangedLines
- Defined in:
- lib/mutineer/changed_lines.rb
Overview
Maps each source file to the set of NEW-side line numbers changed since a git ref.
By parsing git diff --unified=0, this restricts mutations to only the
diff (issue #2): on a PR you care whether the changed code is tested, so
mutating just those lines is fast and actionable.
git is an external tool, not a gem dependency — shelling out is fine. The
parse carries the logic; git_diff is injectable so it stays testable
without invoking git.
Constant Summary collapse
- HUNK =
Matches unified-diff hunks and captures the new-file start/count.
/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
Class Method Summary collapse
-
.for(ref:, files:, project_root:, runner: method(:git_diff)) ⇒ Hash<String, Set<Integer>>
Builds a per-file map of changed new-side lines.
-
.git_diff(ref, abs_file, project_root) ⇒ String
Returns the stdout of
git -C <root> diff --unified=0 <ref> -- <file>. -
.indexed?(abs_file, project_root) ⇒ Boolean
Tells whether the index holds the file.
-
.new_file_diff(abs_file) ⇒ String
Returns a diff that marks a file as entirely new.
-
.parse(diff_text) ⇒ Set<Integer>
Parses unified diff text into the set of NEW-side line numbers.
Instance Method Summary collapse
-
#for(ref:, files:, project_root:, runner: method(:git_diff)) ⇒ Hash<String, Set<Integer>>
private
Builds a per-file map of changed new-side lines.
-
#git_diff(ref, abs_file, project_root) ⇒ String
private
Returns the stdout of
git -C <root> diff --unified=0 <ref> -- <file>. -
#indexed?(abs_file, project_root) ⇒ Boolean
private
Tells whether the index holds the file.
-
#new_file_diff(abs_file) ⇒ String
private
Returns a diff that marks a file as entirely new.
-
#parse(diff_text) ⇒ Set<Integer>
private
Parses unified diff text into the set of NEW-side line numbers.
Class Method Details
.for(ref:, files:, project_root:, runner: method(:git_diff)) ⇒ Hash<String, Set<Integer>>
Builds a per-file map of changed new-side lines.
51 52 53 54 55 56 |
# File 'lib/mutineer/changed_lines.rb', line 51 def for(ref:, files:, project_root:, runner: method(:git_diff)) files.each_with_object({}) do |file, acc| abs = File.(file, project_root) acc[abs] = parse(runner.call(ref, abs, project_root)) end end |
.git_diff(ref, abs_file, project_root) ⇒ String
Returns the stdout of git -C <root> diff --unified=0 <ref> -- <file>.
A file that is not in the index has no usable diff, so new_file_diff
supplies one that marks every line new. That includes a file the ref still
has, deleted and then written again: git prints only a deletion hunk for it.
Paths are literal, so a name such as file[1].rb is not read as a glob.
A failure is warned, never silent: an empty result means "no changed
lines", which under --since removes every mutant for the file — a green
gate must not be manufactured by a broken diff without a trace.
72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/mutineer/changed_lines.rb', line 72 def git_diff(ref, abs_file, project_root) out, _err, status = Open3.capture3( "git", "--literal-pathspecs", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file ) return(indexed?(abs_file, project_root) ? out : new_file_diff(abs_file)) if status.success? warn "[mutineer] git diff failed for #{abs_file}; its lines will not be mutated (--since)" "" rescue StandardError => e warn "[mutineer] git diff failed for #{abs_file} (#{e.class}); its lines will not be mutated (--since)" "" end |
.indexed?(abs_file, project_root) ⇒ Boolean
Tells whether the index holds the file. Only then is git's own diff the truth; the diff text cannot tell, because a mode-only diff must stay empty.
91 92 93 94 95 96 |
# File 'lib/mutineer/changed_lines.rb', line 91 def indexed?(abs_file, project_root) _out, _err, known = Open3.capture3( "git", "--literal-pathspecs", "-C", project_root, "ls-files", "--error-unmatch", "--", abs_file ) known.success? end |
.new_file_diff(abs_file) ⇒ String
Returns a diff that marks a file as entirely new. It returns "" when
the file is empty or cannot be read (after a warning). A file outside the
index has every line new; read as "unchanged", --since would score
nothing and a positive threshold would still exit 0.
105 106 107 108 109 110 111 |
# File 'lib/mutineer/changed_lines.rb', line 105 def new_file_diff(abs_file) count = File.foreach(abs_file).count count.zero? ? "" : "@@ -0,0 +1,#{count} @@\n" rescue SystemCallError => e warn "[mutineer] cannot read #{abs_file} (#{e.class}); its lines will not be mutated (--since)" "" end |
.parse(diff_text) ⇒ Set<Integer>
Parses unified diff text into the set of NEW-side line numbers.
With --unified=0 each hunk's +c,d block is exactly the changed lines:
c..c+d-1. d absent means 1 line; d == 0 is a pure deletion and
contributes nothing.
31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/mutineer/changed_lines.rb', line 31 def parse(diff_text) lines = Set.new diff_text.each_line do |row| m = HUNK.match(row) or next start = m[1].to_i count = m[2].nil? ? 1 : m[2].to_i next if count.zero? lines.merge(start...(start + count)) end lines end |
Instance Method Details
#for(ref:, files:, project_root:, runner: method(:git_diff)) ⇒ Hash<String, Set<Integer>> (private)
Builds a per-file map of changed new-side lines.
51 52 53 54 55 56 |
# File 'lib/mutineer/changed_lines.rb', line 51 def for(ref:, files:, project_root:, runner: method(:git_diff)) files.each_with_object({}) do |file, acc| abs = File.(file, project_root) acc[abs] = parse(runner.call(ref, abs, project_root)) end end |
#git_diff(ref, abs_file, project_root) ⇒ String (private)
Returns the stdout of git -C <root> diff --unified=0 <ref> -- <file>.
A file that is not in the index has no usable diff, so new_file_diff
supplies one that marks every line new. That includes a file the ref still
has, deleted and then written again: git prints only a deletion hunk for it.
Paths are literal, so a name such as file[1].rb is not read as a glob.
A failure is warned, never silent: an empty result means "no changed
lines", which under --since removes every mutant for the file — a green
gate must not be manufactured by a broken diff without a trace.
72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/mutineer/changed_lines.rb', line 72 def git_diff(ref, abs_file, project_root) out, _err, status = Open3.capture3( "git", "--literal-pathspecs", "-C", project_root, "diff", "--unified=0", ref, "--", abs_file ) return(indexed?(abs_file, project_root) ? out : new_file_diff(abs_file)) if status.success? warn "[mutineer] git diff failed for #{abs_file}; its lines will not be mutated (--since)" "" rescue StandardError => e warn "[mutineer] git diff failed for #{abs_file} (#{e.class}); its lines will not be mutated (--since)" "" end |
#indexed?(abs_file, project_root) ⇒ Boolean (private)
Tells whether the index holds the file. Only then is git's own diff the truth; the diff text cannot tell, because a mode-only diff must stay empty.
91 92 93 94 95 96 |
# File 'lib/mutineer/changed_lines.rb', line 91 def indexed?(abs_file, project_root) _out, _err, known = Open3.capture3( "git", "--literal-pathspecs", "-C", project_root, "ls-files", "--error-unmatch", "--", abs_file ) known.success? end |
#new_file_diff(abs_file) ⇒ String (private)
Returns a diff that marks a file as entirely new. It returns "" when
the file is empty or cannot be read (after a warning). A file outside the
index has every line new; read as "unchanged", --since would score
nothing and a positive threshold would still exit 0.
105 106 107 108 109 110 111 |
# File 'lib/mutineer/changed_lines.rb', line 105 def new_file_diff(abs_file) count = File.foreach(abs_file).count count.zero? ? "" : "@@ -0,0 +1,#{count} @@\n" rescue SystemCallError => e warn "[mutineer] cannot read #{abs_file} (#{e.class}); its lines will not be mutated (--since)" "" end |
#parse(diff_text) ⇒ Set<Integer> (private)
Parses unified diff text into the set of NEW-side line numbers.
With --unified=0 each hunk's +c,d block is exactly the changed lines:
c..c+d-1. d absent means 1 line; d == 0 is a pure deletion and
contributes nothing.
31 32 33 34 35 36 37 38 39 40 41 42 |
# File 'lib/mutineer/changed_lines.rb', line 31 def parse(diff_text) lines = Set.new diff_text.each_line do |row| m = HUNK.match(row) or next start = m[1].to_i count = m[2].nil? ? 1 : m[2].to_i next if count.zero? lines.merge(start...(start + count)) end lines end |