Skip to content

Commit e904d60

Browse files
committed
Change incremental detection to support worktree
* This allows incremental to be triggered also from dirty worktrees. * In addition its more efficient as we do not shell out to git as often anymore. While still leaving open room for improvement. [Fix #461]
1 parent dc6e919 commit e904d60

File tree

5 files changed

+156
-117
lines changed

5 files changed

+156
-117
lines changed

lib/mutant/cli.rb

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22

33
module Mutant
44
# Commandline parser / runner
5-
#
6-
# rubocop:disable Metrics/ClassLength
75
class CLI
86
include Concord.new(:world, :config)
97

@@ -149,11 +147,7 @@ def add_filter_options(opts)
149147
add_matcher(
150148
:subject_filters,
151149
Repository::SubjectFilter.new(
152-
Repository::Diff.new(
153-
from: Repository::Diff::HEAD,
154-
to: revision,
155-
world: world
156-
)
150+
Repository::Diff.new(to: revision, world: world)
157151
)
158152
)
159153
end
@@ -213,5 +207,4 @@ def add_matcher(attribute, value)
213207
with(matcher: config.matcher.add(attribute, value))
214208
end
215209
end # CLI
216-
# rubocop:enable Metrics/ClassLength
217210
end # Mutant

lib/mutant/repository.rb

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22

33
module Mutant
44
module Repository
5-
# Error raised on repository interaction problems
6-
RepositoryError = Class.new(RuntimeError)
7-
85
# Subject filter based on repository diff
96
class SubjectFilter
107
include Adamantium, Concord.new(:diff)

lib/mutant/repository/diff.rb

Lines changed: 72 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
module Mutant
44
module Repository
5-
# Diff between two objects in repository
5+
# Diff index between HEAD and a tree reference
66
class Diff
7-
include Adamantium, Anima.new(:world, :from, :to)
7+
include Adamantium, Anima.new(:world, :to)
88

9-
HEAD = 'HEAD'
9+
FORMAT = /\A:\d{6} \d{6} [a-f\d]{40} [a-f\d]{40} [ACDMRTUX]\t(.*)\n\z/.freeze
10+
11+
private_constant(*constants(false))
12+
13+
class Error < RuntimeError; end
1014

1115
# Test if diff changes file at line range
1216
#
@@ -18,50 +22,83 @@ class Diff
1822
# @raise [RepositoryError]
1923
# when git command failed
2024
def touches?(path, line_range)
21-
return false unless within_working_directory?(path) && tracks?(path)
22-
23-
command = %W[
24-
git log
25-
#{from}...#{to}
26-
--ignore-all-space
27-
-L #{line_range.begin},#{line_range.end}:#{path}
28-
]
29-
30-
stdout, status = world.open3.capture2(*command, binmode: true)
31-
32-
fail RepositoryError, "Command #{command} failed!" unless status.success?
33-
34-
!stdout.empty?
25+
touched_paths
26+
.fetch(path) { return false }
27+
.touches?(line_range)
3528
end
3629

3730
private
3831

39-
# Test if path is tracked in repository
40-
#
41-
# FIXME: Cache results, to avoid spending time on producing redundant results.
32+
# Touched paths
4233
#
43-
# @param [Pathname] path
34+
# @return [Hash{Pathname => Path}]
4435
#
45-
# @return [Boolean]
46-
def tracks?(path)
47-
command = %W[git ls-files --error-unmatch -- #{path}]
48-
world.kernel.system(
49-
*command,
50-
out: File::NULL,
51-
err: File::NULL
52-
)
36+
# rubocop:disable Metrics/MethodLength
37+
def touched_paths
38+
pathname = world.pathname
39+
work_dir = pathname.pwd
40+
41+
world
42+
.capture_stdout(%W[git diff-index #{to}])
43+
.from_right
44+
.lines
45+
.map do |line|
46+
path = parse_line(work_dir, line)
47+
[path.path, path]
48+
end
49+
.to_h
5350
end
51+
memoize :touched_paths
5452

55-
# Test if the path is within the current working directory
53+
# Parse path
5654
#
57-
# @param [Pathname] path
55+
# @param [Pathname] work_dir
56+
# @param [String] line
5857
#
59-
# @return [TrueClass, nil]
60-
def within_working_directory?(path)
61-
working_directory = world.pathname.pwd
62-
path.ascend { |parent| return true if working_directory.eql?(parent) }
58+
# @return [Path]
59+
def parse_line(work_dir, line)
60+
match = FORMAT.match(line) or fail Error, "Invalid git diff-index line: #{line}"
61+
62+
Path.new(
63+
path: work_dir.join(match.captures.first),
64+
to: to,
65+
world: world
66+
)
6367
end
6468

69+
# Path touched by a diff
70+
class Path
71+
include Adamantium, Anima.new(:world, :to, :path)
72+
73+
DECIMAL = /(?:0|[1-9]\d*)/.freeze
74+
REGEXP = /\A@@ -(#{DECIMAL})(?:,(#{DECIMAL}))? \+(#{DECIMAL})(?:,(#{DECIMAL}))? @@/.freeze
75+
76+
private_constant(*constants(false))
77+
78+
# Test if diff path touches a line range
79+
#
80+
# @param [Range<Integer>] range
81+
#
82+
# @return [Boolean]
83+
def touches?(line_range)
84+
diff_ranges.any? do |range|
85+
Range.overlap?(range, line_range)
86+
end
87+
end
88+
89+
private
90+
91+
# Ranges of hunks in the diff
92+
#
93+
# @return [Array<Range<Integer>>]
94+
def diff_ranges
95+
world
96+
.capture_stdout(%W[git diff --unified=0 #{to} -- #{path}])
97+
.fmap(&Ranges.method(:parse))
98+
.from_right
99+
end
100+
memoize :diff_ranges
101+
end # Path
65102
end # Diff
66103
end # Repository
67104
end # Mutant

spec/unit/mutant/cli_spec.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,6 @@ def apply
250250
subject_filters: [
251251
Mutant::Repository::SubjectFilter.new(
252252
Mutant::Repository::Diff.new(
253-
from: 'HEAD',
254253
to: 'master',
255254
world: world
256255
)

spec/unit/mutant/repository/diff_spec.rb

Lines changed: 83 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,106 +2,119 @@
22

33
describe Mutant::Repository::Diff do
44
describe '#touches?' do
5-
let(:object) do
6-
described_class.new(
7-
world: world,
8-
from: 'from_rev',
9-
to: 'to_rev'
10-
)
5+
def apply
6+
subject.touches?(path, line_range)
117
end
128

9+
subject { described_class.new(world: world, to: 'to_rev') }
10+
11+
let(:pathname) { class_double(Pathname, pwd: pwd) }
12+
let(:open3) { class_double(Open3) }
13+
let(:kernel) { class_double(Kernel) }
14+
let(:pwd) { Pathname.new('/foo') }
15+
let(:path) { Pathname.new('/foo/bar.rb') }
16+
let(:line_range) { 4..5 }
17+
1318
let(:world) do
1419
instance_double(
1520
Mutant::World,
1621
kernel: kernel,
17-
open3: open3,
1822
pathname: pathname
1923
)
2024
end
2125

22-
let(:pathname) { class_double(Pathname, pwd: pwd) }
23-
let(:open3) { class_double(Open3) }
24-
let(:kernel) { class_double(Kernel) }
25-
let(:pwd) { Pathname.new('/foo') }
26-
let(:path) { Pathname.new('/foo/bar.rb') }
27-
let(:line_range) { 1..2 }
28-
29-
subject { object.touches?(path, line_range) }
30-
31-
shared_context 'test if git tracks the file' do
32-
before do
33-
# rubocop:disable Lint/UnneededSplatExpansion
34-
expect(world.kernel).to receive(:system)
35-
.ordered
36-
.with(
37-
*%W[git ls-files --error-unmatch -- #{path}],
38-
out: File::NULL,
39-
err: File::NULL
40-
).and_return(git_ls_success?)
41-
end
26+
let(:allowed_paths) do
27+
%w[bar.rb baz.rb].map do |path|
28+
[path, Pathname.new(path)]
29+
end.to_h
4230
end
4331

44-
context 'when file is in a different subdirectory' do
45-
let(:path) { Pathname.new('/baz/bar.rb') }
46-
47-
before do
48-
expect(world.kernel).to_not receive(:system)
49-
end
50-
51-
it { should be(false) }
32+
let(:file_diff_expectations) { [] }
33+
34+
let(:raw_expectations) do
35+
[
36+
{
37+
receiver: world,
38+
selector: :capture_stdout,
39+
arguments: [%w[git diff-index to_rev]],
40+
reaction: { return: Mutant::Either::Right.new(index_stdout) }
41+
},
42+
*file_diff_expectations
43+
]
5244
end
5345

54-
context 'when file is NOT tracked in repository' do
55-
let(:git_ls_success?) { false }
46+
before do
47+
allow(pathname).to receive(:new, &allowed_paths.method(:fetch))
48+
end
5649

57-
include_context 'test if git tracks the file'
50+
context 'when file is not touched in the diff' do
51+
let(:index_stdout) { '' }
5852

59-
it { should be(false) }
53+
it 'returns false' do
54+
verify_events { expect(apply).to be(false) }
55+
end
6056
end
6157

62-
context 'when file is tracked in repository' do
63-
let(:git_ls_success?) { true }
64-
let(:status) { instance_double(Process::Status, success?: success?) }
65-
let(:stdout) { instance_double(String, empty?: stdout_empty?) }
66-
let(:stdout_empty?) { false }
58+
context 'when a diff-index line is invalid' do
59+
let(:index_stdout) { 'invalid-line' }
6760

68-
include_context 'test if git tracks the file'
61+
it 'raises error' do
62+
expect { verify_events { apply } }
63+
.to raise_error(
64+
described_class::Error,
65+
'Invalid git diff-index line: invalid-line'
66+
)
67+
end
68+
end
6969

70-
before do
71-
expect(world.open3).to receive(:capture2)
72-
.ordered
73-
.with(*expected_git_log_command, binmode: true)
74-
.and_return([stdout, status])
70+
context 'when file is touched in the diff' do
71+
let(:index_stdout) do
72+
<<~STR
73+
:000000 000000 0000000000000000000000000000000000000000 0000000000000000000000000000000000000000 M\tbar.rb
74+
:000000 000000 0000000000000000000000000000000000000000 0000000000000000000000000000000000000000 M\tbaz.rb
75+
STR
7576
end
7677

77-
let(:expected_git_log_command) do
78-
%W[git log from_rev...to_rev --ignore-all-space -L 1,2:#{path}]
78+
let(:file_diff_expectations) do
79+
[
80+
{
81+
receiver: world,
82+
selector: :capture_stdout,
83+
arguments: [%w[git diff --unified=0 to_rev -- /foo/bar.rb]],
84+
reaction: { return: Mutant::Either::Right.new(diff_stdout) }
85+
}
86+
]
7987
end
8088

81-
context 'on failure of git log command' do
82-
let(:success?) { false }
89+
context 'and diff touches the line range' do
90+
let(:diff_stdout) do
91+
<<~'DIFF'
92+
--- bar.rb
93+
+++ bar.rb
94+
@@ -4 +4 @@ header
95+
-a
96+
+b
97+
DIFF
98+
end
8399

84-
it 'raises error' do
85-
expect { subject }.to raise_error(
86-
Mutant::Repository::RepositoryError,
87-
"Command #{expected_git_log_command} failed!"
88-
)
100+
it 'returns true' do
101+
verify_events { expect(apply).to be(true) }
89102
end
90103
end
91104

92-
context 'on suuccess of git command' do
93-
let(:success?) { true }
94-
95-
context 'on empty stdout' do
96-
let(:stdout_empty?) { true }
97-
98-
it { should be(false) }
105+
context 'and diff does not touch the line range' do
106+
let(:diff_stdout) do
107+
<<~'DIFF'
108+
--- bar.rb
109+
+++ bar.rb
110+
@@ -3 +3 @@ header
111+
-a
112+
+b
113+
DIFF
99114
end
100115

101-
context 'on non empty stdout' do
102-
let(:stdout_empty?) { false }
103-
104-
it { should be(true) }
116+
it 'returns false' do
117+
verify_events { expect(apply).to be(false) }
105118
end
106119
end
107120
end

0 commit comments

Comments
 (0)