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

Instance Method Summary collapse

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.

Parameters:

  • ref (String) —

    git ref to diff against.

  • files (Array<String>) —

    source files to inspect.

  • project_root (String) —

    repository root for git -C.

  • runner (#call) (defaults to: method(:git_diff)) —

    injectable diff producer.

Returns:

  • (Hash<String, Set<Integer>>) —

    absolute file path to changed 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.expand_path(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.

Parameters:

  • ref (String) —

    git ref to diff against.

  • abs_file (String) —

    absolute path of the file being diffed.

  • project_root (String) —

    repository root for git -C.

Returns:

  • (String) —

    diff text, or "" on failure (after a stderr warning).



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.

Parameters:

  • abs_file (String) —

    absolute path of the file being diffed.

  • project_root (String) —

    repository root for git -C.

Returns:

  • (Boolean) —

    true when the index holds the file.



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.

Parameters:

  • abs_file (String) —

    absolute path of the file being diffed.

Returns:

  • (String) —

    a one-hunk diff header, or "".



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.

Parameters:

  • diff_text (String) —

    raw git diff --unified=0 output.

Returns:

  • (Set<Integer>) —

    changed line numbers on the new side.



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.

Parameters:

  • ref (String) —

    git ref to diff against.

  • files (Array<String>) —

    source files to inspect.

  • project_root (String) —

    repository root for git -C.

  • runner (#call) (defaults to: method(:git_diff)) —

    injectable diff producer.

Returns:

  • (Hash<String, Set<Integer>>) —

    absolute file path to changed 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.expand_path(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.

Parameters:

  • ref (String) —

    git ref to diff against.

  • abs_file (String) —

    absolute path of the file being diffed.

  • project_root (String) —

    repository root for git -C.

Returns:

  • (String) —

    diff text, or "" on failure (after a stderr warning).



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.

Parameters:

  • abs_file (String) —

    absolute path of the file being diffed.

  • project_root (String) —

    repository root for git -C.

Returns:

  • (Boolean) —

    true when the index holds the file.



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.

Parameters:

  • abs_file (String) —

    absolute path of the file being diffed.

Returns:

  • (String) —

    a one-hunk diff header, or "".



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.

Parameters:

  • diff_text (String) —

    raw git diff --unified=0 output.

Returns:

  • (Set<Integer>) —

    changed line numbers on the new side.



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