test_impact 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +541 -0
- data/exe/test-impact +6 -0
- data/lib/test_impact/cli.rb +156 -0
- data/lib/test_impact/collector/coverage_backend.rb +47 -0
- data/lib/test_impact/collector/ddcov_backend.rb +103 -0
- data/lib/test_impact/collector/null_backend.rb +22 -0
- data/lib/test_impact/config.rb +62 -0
- data/lib/test_impact/frameworks/rspec.rb +28 -0
- data/lib/test_impact/git.rb +125 -0
- data/lib/test_impact/map.rb +124 -0
- data/lib/test_impact/map_serializer.rb +88 -0
- data/lib/test_impact/path_matcher.rb +35 -0
- data/lib/test_impact/paths.rb +39 -0
- data/lib/test_impact/plan_result.rb +36 -0
- data/lib/test_impact/planner.rb +224 -0
- data/lib/test_impact/recorder.rb +112 -0
- data/lib/test_impact/version.rb +5 -0
- data/lib/test_impact.rb +28 -0
- data/skills/impacted-specs/SKILL.md +80 -0
- data/test_impact.gemspec +34 -0
- metadata +98 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'zlib'
|
|
7
|
+
|
|
8
|
+
module TestImpact
|
|
9
|
+
# `test-impact` command line entry point: merges per-process coverage parts
|
|
10
|
+
# into a single map, and plans which specs a diff requires.
|
|
11
|
+
# Thor subcommands must live in one class to share its DSL and options, so
|
|
12
|
+
# splitting merge/info/plan out would break the command definitions.
|
|
13
|
+
class CLI < Thor # rubocop:disable Metrics/ClassLength
|
|
14
|
+
# Any unreadable map (schema mismatch, malformed payload, truncated gzip,
|
|
15
|
+
# corrupt JSON) must degrade to "no map" so plan falls back to a full run.
|
|
16
|
+
MAP_LOAD_ERRORS = [
|
|
17
|
+
TestImpact::SchemaVersionError, TestImpact::MapFormatError, Zlib::Error, JSON::ParserError
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def self.exit_on_failure?
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc 'merge', 'Merge part-*.json.gz coverage maps into a single map'
|
|
25
|
+
method_option :input, type: :string, default: 'tmp/test_impact', desc: 'Directory containing part-*.json.gz files'
|
|
26
|
+
method_option :output, type: :string, default: '.test_impact/map.json.gz', desc: 'Output path for the merged map'
|
|
27
|
+
def merge
|
|
28
|
+
input_dir = options[:input]
|
|
29
|
+
output_path = options[:output]
|
|
30
|
+
|
|
31
|
+
part_paths = Dir.glob(File.join(input_dir, 'part-*.json.gz'))
|
|
32
|
+
die("no part-*.json.gz files found in #{input_dir}") if part_paths.empty?
|
|
33
|
+
|
|
34
|
+
merged = merge_parts(load_parts(part_paths), part_paths)
|
|
35
|
+
|
|
36
|
+
FileUtils.mkdir_p(File.dirname(output_path))
|
|
37
|
+
MapSerializer.dump(merged, output_path)
|
|
38
|
+
|
|
39
|
+
warn_merge_summary(merged, part_paths.size, output_path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
desc 'info', 'Show summary information about a test impact map'
|
|
43
|
+
method_option :map, type: :string, default: '.test_impact/map.json.gz', desc: 'Path to the map file'
|
|
44
|
+
def info
|
|
45
|
+
map_path = options[:map]
|
|
46
|
+
|
|
47
|
+
die("map file not found: #{map_path}") unless File.exist?(map_path)
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
map = MapSerializer.load(map_path)
|
|
51
|
+
rescue *MAP_LOAD_ERRORS => e
|
|
52
|
+
die("could not read map #{map_path}: #{e.message}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
puts "schema_version: #{map.schema_version}"
|
|
56
|
+
puts "commit_sha: #{map.commit_sha}"
|
|
57
|
+
puts "branch: #{map.branch}"
|
|
58
|
+
puts "generated_at: #{map.generated_at}"
|
|
59
|
+
puts "backend: #{map.collector.fetch('backend', 'unknown')}"
|
|
60
|
+
puts "source_files: #{map.index.keys.size}"
|
|
61
|
+
puts "spec_files: #{map.spec_count}"
|
|
62
|
+
puts "known_spec_files: #{map.known_spec_files.size}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
desc 'plan', 'Print the spec files impacted by the current diff'
|
|
66
|
+
method_option :map, type: :string, default: '.test_impact/map.json.gz', desc: 'Path to the map file'
|
|
67
|
+
method_option :base, type: :string, desc: 'Base ref to diff against (overrides config and GITHUB_BASE_REF)'
|
|
68
|
+
method_option :format, type: :string, default: 'lines', enum: %w[lines json], desc: 'Output format'
|
|
69
|
+
method_option :fallback_to_all_exit_code,
|
|
70
|
+
type: :numeric,
|
|
71
|
+
default: 10,
|
|
72
|
+
desc: 'Exit code used for lines format when mode is all'
|
|
73
|
+
method_option :include_uncommitted,
|
|
74
|
+
type: :boolean,
|
|
75
|
+
default: false,
|
|
76
|
+
desc: 'Also consider staged, unstaged and untracked working tree changes'
|
|
77
|
+
def plan
|
|
78
|
+
map_path = options[:map]
|
|
79
|
+
config = Config.load
|
|
80
|
+
map = load_map_or_nil(map_path)
|
|
81
|
+
|
|
82
|
+
base = options.fetch(:base, normalized_github_base_ref) || config.base
|
|
83
|
+
result = Planner.new(map:, config:).plan(base:, include_uncommitted: options[:include_uncommitted])
|
|
84
|
+
|
|
85
|
+
warn "mode: #{result.mode}"
|
|
86
|
+
warn "reason: #{result.reason}" if result.reason
|
|
87
|
+
warn "spec_files: #{result.spec_files.size}"
|
|
88
|
+
|
|
89
|
+
if options[:format] == 'json'
|
|
90
|
+
print_plan_json(result)
|
|
91
|
+
else
|
|
92
|
+
print_plan_lines(result)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def load_parts(part_paths)
|
|
99
|
+
part_paths.map do |path|
|
|
100
|
+
MapSerializer.load(path)
|
|
101
|
+
rescue *MAP_LOAD_ERRORS => e
|
|
102
|
+
die("could not read part #{path}: #{e.message}")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def merge_parts(maps, part_paths)
|
|
107
|
+
base_commit_sha = maps.first.commit_sha
|
|
108
|
+
|
|
109
|
+
maps[1..].each_with_index.reduce(maps[0]) do |merged, (map, idx)|
|
|
110
|
+
if map.commit_sha != base_commit_sha
|
|
111
|
+
warn "warning: commit_sha mismatch in #{part_paths[idx + 1]} (#{map.commit_sha} != #{base_commit_sha})"
|
|
112
|
+
end
|
|
113
|
+
merged.merge(map)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def warn_merge_summary(merged, part_count, output_path)
|
|
118
|
+
warn "merged #{part_count} part(s) into #{output_path}"
|
|
119
|
+
warn "source files: #{merged.index.keys.size}, specs: #{merged.spec_count}, " \
|
|
120
|
+
"known_spec_files: #{merged.known_spec_files.size}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def print_plan_json(result)
|
|
124
|
+
puts JSON.generate({ 'mode' => result.mode.to_s, 'spec_files' => result.spec_files, 'reason' => result.reason })
|
|
125
|
+
exit(0)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def print_plan_lines(result)
|
|
129
|
+
exit(options[:fallback_to_all_exit_code]) if result.mode == :all
|
|
130
|
+
|
|
131
|
+
puts result.spec_files.join("\n") unless result.spec_files.empty?
|
|
132
|
+
exit(0)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def die(message)
|
|
136
|
+
warn "error: #{message}"
|
|
137
|
+
exit(1)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def load_map_or_nil(map_path)
|
|
141
|
+
return nil unless File.exist?(map_path)
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
MapSerializer.load(map_path)
|
|
145
|
+
rescue *MAP_LOAD_ERRORS => e
|
|
146
|
+
warn "warning: could not read map #{map_path}: #{e.message}"
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def normalized_github_base_ref
|
|
152
|
+
ref = ENV.fetch('GITHUB_BASE_REF', nil)
|
|
153
|
+
ref.nil? || ref.empty? ? nil : "origin/#{ref}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_impact/collector/ddcov_backend'
|
|
4
|
+
require 'test_impact/collector/null_backend'
|
|
5
|
+
|
|
6
|
+
module TestImpact
|
|
7
|
+
module Collector
|
|
8
|
+
# Chooses the coverage backend for this process: the native ddcov backend
|
|
9
|
+
# when it loads, otherwise a hard failure unless the caller opted out.
|
|
10
|
+
module CoverageBackend
|
|
11
|
+
UNAVAILABLE_MESSAGE = 'test_impact: coverage backend unavailable, ' \
|
|
12
|
+
'falling back to null backend because TEST_IMPACT_REQUIRE_COVERAGE ' \
|
|
13
|
+
'opts out (no coverage will be collected)'
|
|
14
|
+
|
|
15
|
+
# Spelled generously on purpose: unlike an opt-in flag, a misspelled
|
|
16
|
+
# opt-out fails the collection job, and the typo only surfaces on the
|
|
17
|
+
# day the backend actually breaks.
|
|
18
|
+
OPT_OUT_VALUES = %w[0 false no off].freeze
|
|
19
|
+
|
|
20
|
+
def self.build(config)
|
|
21
|
+
# Bind once: both lookups memoize per parameter, so they must be
|
|
22
|
+
# asked about the very same one.
|
|
23
|
+
allocation_tracing = DdcovBackend.allocation_tracing?(config)
|
|
24
|
+
reason = DdcovBackend.unavailable_reason(use_allocation_tracing: allocation_tracing)
|
|
25
|
+
return DdcovBackend.new(config) if reason.nil?
|
|
26
|
+
|
|
27
|
+
# Collecting coverage is the only reason this process runs, so an
|
|
28
|
+
# unusable backend is a hard failure unless explicitly opted out of.
|
|
29
|
+
raise CoverageUnavailableError, unavailable_error_message(reason) unless opted_out?
|
|
30
|
+
|
|
31
|
+
warn UNAVAILABLE_MESSAGE
|
|
32
|
+
NullBackend.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.opted_out?
|
|
36
|
+
OPT_OUT_VALUES.include?(ENV['TEST_IMPACT_REQUIRE_COVERAGE'].to_s.strip.downcase)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Spelled out for operators who only ever see the CI log.
|
|
40
|
+
def self.unavailable_error_message(reason)
|
|
41
|
+
"test_impact: coverage backend unavailable (#{reason.class}: #{reason.message}). " \
|
|
42
|
+
'Set TEST_IMPACT_REQUIRE_COVERAGE=0 to fall back to the null backend instead ' \
|
|
43
|
+
'(the map is then tagged as invalid and every spec runs).'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_impact/paths'
|
|
4
|
+
|
|
5
|
+
module TestImpact
|
|
6
|
+
module Collector
|
|
7
|
+
# Coverage backend built on datadog-ci's native DDCov extension. Records
|
|
8
|
+
# the source files touched between #start and #stop.
|
|
9
|
+
class DdcovBackend
|
|
10
|
+
NATIVE_REQUIRE_PATH = "datadog_ci_native.#{RUBY_VERSION}_#{RUBY_PLATFORM}".freeze
|
|
11
|
+
DDCOV_CONST = 'Datadog::CI::TestImpactAnalysis::Coverage::DDCov'
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Probes with the same parameters the real instance will use
|
|
15
|
+
# (ignored_path and allocation tracing included), so a code path that
|
|
16
|
+
# only breaks under the real configuration still fails safe here.
|
|
17
|
+
def available?(use_allocation_tracing: true)
|
|
18
|
+
probe(use_allocation_tracing).nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The exception that made the probe fail, or nil when the backend is
|
|
22
|
+
# available. Callers that turn unavailability into a hard failure need
|
|
23
|
+
# it to tell the user *why* ddcov could not load.
|
|
24
|
+
def unavailable_reason(use_allocation_tracing: true)
|
|
25
|
+
probe(use_allocation_tracing)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def allocation_tracing?(config)
|
|
29
|
+
config.collector['allocation_tracing'] ? true : false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Must be the same value the instance passes, so the availability
|
|
33
|
+
# check exercises the real parameter shape (nil without Bundler).
|
|
34
|
+
def default_ignored_path
|
|
35
|
+
return nil unless defined?(Bundler)
|
|
36
|
+
|
|
37
|
+
Bundler.bundle_path.to_s
|
|
38
|
+
rescue StandardError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def load_ddcov_class!
|
|
43
|
+
require NATIVE_REQUIRE_PATH
|
|
44
|
+
Object.const_get(DDCOV_CONST)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_instance(root:, ignored_path:, use_allocation_tracing:)
|
|
48
|
+
load_ddcov_class!.new(
|
|
49
|
+
root:,
|
|
50
|
+
ignored_path:,
|
|
51
|
+
threading_mode: :multi,
|
|
52
|
+
use_allocation_tracing:
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def reset_memoization!
|
|
57
|
+
@probe = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Memoizes one probe result per parameter: nil when ddcov works, the
|
|
63
|
+
# exception that broke it otherwise. Keeping "did it work" and "why
|
|
64
|
+
# not" in a single entry means the two can never disagree.
|
|
65
|
+
def probe(use_allocation_tracing)
|
|
66
|
+
@probe ||= {}
|
|
67
|
+
return @probe[use_allocation_tracing] if @probe.key?(use_allocation_tracing)
|
|
68
|
+
|
|
69
|
+
@probe[use_allocation_tracing] =
|
|
70
|
+
begin
|
|
71
|
+
build_instance(root: Paths.repo_root,
|
|
72
|
+
ignored_path: default_ignored_path,
|
|
73
|
+
use_allocation_tracing:)
|
|
74
|
+
.tap(&:start).stop
|
|
75
|
+
nil
|
|
76
|
+
rescue LoadError, StandardError => e
|
|
77
|
+
e
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def initialize(config)
|
|
83
|
+
@ddcov = self.class.build_instance(
|
|
84
|
+
root: Paths.repo_root,
|
|
85
|
+
ignored_path: self.class.default_ignored_path,
|
|
86
|
+
use_allocation_tracing: self.class.allocation_tracing?(config)
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def start
|
|
91
|
+
@ddcov.start
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def stop
|
|
95
|
+
@ddcov.stop
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def name
|
|
99
|
+
'ddcov'
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestImpact
|
|
4
|
+
module Collector
|
|
5
|
+
# Fallback backend that records nothing. Used when coverage is unavailable
|
|
6
|
+
# and the caller opted out of failing; the resulting map stays invalid so
|
|
7
|
+
# planning degrades to a full run.
|
|
8
|
+
class NullBackend
|
|
9
|
+
def start
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def stop
|
|
14
|
+
{}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def name
|
|
18
|
+
'null'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module TestImpact
|
|
6
|
+
# Settings loaded from .test_impact.yml, with defaults for the base ref,
|
|
7
|
+
# map staleness, always-run specs, global files, ignored files, and collector
|
|
8
|
+
# options.
|
|
9
|
+
class Config
|
|
10
|
+
DEFAULT_GLOBAL_FILES = [
|
|
11
|
+
'Gemfile',
|
|
12
|
+
'Gemfile.lock',
|
|
13
|
+
'*.gemspec',
|
|
14
|
+
'.ruby-version',
|
|
15
|
+
'Dockerfile',
|
|
16
|
+
'config/**/*',
|
|
17
|
+
'db/schema.rb',
|
|
18
|
+
'db/structure.sql',
|
|
19
|
+
'spec/spec_helper.rb',
|
|
20
|
+
'spec/rails_helper.rb',
|
|
21
|
+
'spec/factories/**/*',
|
|
22
|
+
'spec/fixtures/**/*',
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
DEFAULT_COLLECTOR = {
|
|
26
|
+
'allocation_tracing' => true,
|
|
27
|
+
'ignored_paths' => ['vendor/', 'tmp/'].freeze,
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :base, :max_age_days, :always_run, :global_files, :ignore, :collector
|
|
31
|
+
|
|
32
|
+
def self.load(path = nil)
|
|
33
|
+
path ||= File.join(Paths.repo_root, '.test_impact.yml')
|
|
34
|
+
raw = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
35
|
+
new(raw)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(raw = {})
|
|
39
|
+
raw = raw.transform_keys(&:to_s)
|
|
40
|
+
|
|
41
|
+
@base = value_or_default(raw, 'base', 'origin/main')
|
|
42
|
+
@max_age_days = value_or_default(raw, 'max_age_days', 7)
|
|
43
|
+
@always_run = value_or_default(raw, 'always_run', [])
|
|
44
|
+
@global_files = value_or_default(raw, 'global_files', DEFAULT_GLOBAL_FILES.dup)
|
|
45
|
+
@ignore = value_or_default(raw, 'ignore', [])
|
|
46
|
+
@collector = value_or_default(raw, 'collector', DEFAULT_COLLECTOR.dup)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Unlike Hash#fetch, treats an explicitly nil value (e.g. a bare
|
|
52
|
+
# "collector:" line in YAML) as absent so defaults still apply. A Hash
|
|
53
|
+
# default is merged key-by-key with the same rule applied one level down,
|
|
54
|
+
# so a bare "ignored_paths:" nested under it falls back to the default too.
|
|
55
|
+
def value_or_default(raw, key, default)
|
|
56
|
+
value = raw[key]
|
|
57
|
+
return default if value.nil?
|
|
58
|
+
|
|
59
|
+
default.is_a?(Hash) ? default.merge(value.compact) : value
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless ENV['TEST_IMPACT_COLLECT'] == '1'
|
|
4
|
+
|
|
5
|
+
require 'test_impact'
|
|
6
|
+
require 'test_impact/collector/coverage_backend'
|
|
7
|
+
require 'test_impact/recorder'
|
|
8
|
+
|
|
9
|
+
test_impact_config = TestImpact::Config.load
|
|
10
|
+
TestImpact.recorder = TestImpact::Recorder.new(
|
|
11
|
+
backend: TestImpact::Collector::CoverageBackend.build(test_impact_config),
|
|
12
|
+
config: test_impact_config
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
RSpec.configure do |config|
|
|
16
|
+
config.prepend_before(:each) { TestImpact.recorder.start_example }
|
|
17
|
+
|
|
18
|
+
config.append_after(:each) do |example|
|
|
19
|
+
TestImpact.recorder.finish_example(example.metadata[:absolute_file_path])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
config.after(:suite) do
|
|
23
|
+
TestImpact.recorder.record_known_spec_files(RSpec.configuration.files_to_run)
|
|
24
|
+
TestImpact.recorder.write_part
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
at_exit { TestImpact.recorder&.write_part }
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module TestImpact
|
|
7
|
+
# Thin wrapper over the git commands planning needs: merge-base lookup,
|
|
8
|
+
# name-status diffs (rename/copy aware), and reachability checks.
|
|
9
|
+
class Git
|
|
10
|
+
def initialize(repo_root: Paths.repo_root)
|
|
11
|
+
@repo_root = repo_root
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def merge_base(base_ref)
|
|
15
|
+
stdout, status = run('merge-base', base_ref, 'HEAD')
|
|
16
|
+
status.success? ? stdout.strip : nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns nil when the diff itself fails, so callers can distinguish
|
|
20
|
+
# "no changes" ([]) from "could not compute the diff". Omitting the
|
|
21
|
+
# trailing HEAD diffs merge_base_sha against the working tree
|
|
22
|
+
# (staged + unstaged combined) instead of against the last commit.
|
|
23
|
+
def changed_files(merge_base_sha, include_uncommitted: false)
|
|
24
|
+
diff_args = ['diff', '--name-status', '-M', '-C', merge_base_sha]
|
|
25
|
+
diff_args << 'HEAD' unless include_uncommitted
|
|
26
|
+
|
|
27
|
+
stdout, status = run(*diff_args)
|
|
28
|
+
return nil unless status.success?
|
|
29
|
+
|
|
30
|
+
diffed = stdout.each_line.filter_map { |line| parse_diff_line(line) }
|
|
31
|
+
return diffed unless include_uncommitted
|
|
32
|
+
|
|
33
|
+
merge_untracked(diffed)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# True when sha is reachable from ref. A mere `cat-file -e` object check
|
|
37
|
+
# would also accept dangling objects left behind by a force-push.
|
|
38
|
+
def in_history?(sha, ref)
|
|
39
|
+
_, status = run('merge-base', '--is-ancestor', sha, ref)
|
|
40
|
+
status.success?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# On pull_request events actions/checkout checks out the synthetic
|
|
44
|
+
# refs/pull/N/merge commit, which exists in no branch history — a map
|
|
45
|
+
# recorded under it would always look stale to Planner#in_history?.
|
|
46
|
+
# The event payload carries the real head commit, so prefer it there.
|
|
47
|
+
def head_sha
|
|
48
|
+
pull_request_head_sha || rev_parse('HEAD')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Detached HEAD (the default actions/checkout state) yields the literal
|
|
52
|
+
# string "HEAD"; only then is CI's branch name a valid substitute.
|
|
53
|
+
# GITHUB_HEAD_REF holds the source branch on pull_request events (where
|
|
54
|
+
# GITHUB_REF_NAME would be the synthetic "N/merge" ref). An empty result
|
|
55
|
+
# means git itself failed — keep it visible as "".
|
|
56
|
+
def head_branch
|
|
57
|
+
name = rev_parse('--abbrev-ref', 'HEAD')
|
|
58
|
+
return name unless name == 'HEAD'
|
|
59
|
+
|
|
60
|
+
ci_branch = [ENV.fetch('GITHUB_HEAD_REF', nil), ENV.fetch('GITHUB_REF_NAME', nil)].find { |v| v && !v.empty? }
|
|
61
|
+
ci_branch || name
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def rev_parse(*)
|
|
67
|
+
stdout, status = run('rev-parse', *)
|
|
68
|
+
status.success? ? stdout.strip : ''
|
|
69
|
+
rescue StandardError
|
|
70
|
+
''
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Only pull_request-shaped events carry pull_request.head.sha; on push
|
|
74
|
+
# GITHUB_EVENT_PATH is still set but the key is absent, so this returns
|
|
75
|
+
# nil and the plain HEAD lookup stands. dig raises TypeError when an
|
|
76
|
+
# intermediate value is not a Hash, so the rescue must cover it too.
|
|
77
|
+
def pull_request_head_sha
|
|
78
|
+
path = ENV.fetch('GITHUB_EVENT_PATH', nil)
|
|
79
|
+
return nil if path.nil? || path.empty? || !File.file?(path)
|
|
80
|
+
|
|
81
|
+
sha = JSON.parse(File.read(path)).dig('pull_request', 'head', 'sha')
|
|
82
|
+
sha if sha.is_a?(String) && !sha.empty?
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Untracked files are new by definition, so they map straight to 'A'
|
|
88
|
+
# and cannot collide with the diff output.
|
|
89
|
+
def merge_untracked(diffed)
|
|
90
|
+
untracked = untracked_files
|
|
91
|
+
return nil unless untracked
|
|
92
|
+
|
|
93
|
+
diffed + untracked.map { |path| { status: 'A', path: } }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def untracked_files
|
|
97
|
+
stdout, status = run('ls-files', '--others', '--exclude-standard', '-z')
|
|
98
|
+
return nil unless status.success?
|
|
99
|
+
|
|
100
|
+
stdout.split("\0").reject(&:empty?)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def parse_diff_line(line)
|
|
104
|
+
fields = line.chomp.split("\t")
|
|
105
|
+
return nil if fields.empty?
|
|
106
|
+
|
|
107
|
+
raw_status, *paths = fields
|
|
108
|
+
|
|
109
|
+
case raw_status[0]
|
|
110
|
+
when 'R'
|
|
111
|
+
{ status: 'R', path: paths[1], old_path: paths[0] }
|
|
112
|
+
when 'C'
|
|
113
|
+
# Copy lines list source then destination; only the destination is new.
|
|
114
|
+
{ status: 'A', path: paths[1] }
|
|
115
|
+
else
|
|
116
|
+
{ status: raw_status[0], path: paths[0] }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run(*)
|
|
121
|
+
stdout, _stderr, status = Open3.capture3('git', '-C', @repo_root, *)
|
|
122
|
+
[stdout, status]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestImpact
|
|
4
|
+
# The coverage map handed between phases: which specs touch which source
|
|
5
|
+
# files, plus the commit it was built from and the backend that built it.
|
|
6
|
+
class Map
|
|
7
|
+
SCHEMA_VERSION = 1
|
|
8
|
+
|
|
9
|
+
attr_reader :schema_version, :generated_at, :commit_sha, :branch, :collector, :known_spec_files, :index
|
|
10
|
+
|
|
11
|
+
def self.build(commit_sha:, branch:, collector:, generated_at: Time.now, known_spec_files: [], index: {})
|
|
12
|
+
new(
|
|
13
|
+
schema_version: SCHEMA_VERSION,
|
|
14
|
+
generated_at:,
|
|
15
|
+
commit_sha:,
|
|
16
|
+
branch:,
|
|
17
|
+
collector:,
|
|
18
|
+
known_spec_files:,
|
|
19
|
+
index:
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(schema_version:, generated_at:, commit_sha:, branch:, collector:, known_spec_files:, index:)
|
|
24
|
+
validate!(generated_at:, commit_sha:, branch:, collector:, known_spec_files:)
|
|
25
|
+
|
|
26
|
+
@schema_version = schema_version
|
|
27
|
+
@generated_at = generated_at
|
|
28
|
+
@commit_sha = commit_sha
|
|
29
|
+
@branch = branch
|
|
30
|
+
@collector = collector
|
|
31
|
+
@known_spec_files = Set.new(known_spec_files)
|
|
32
|
+
@index =
|
|
33
|
+
index.each_with_object({}) do |(k, v), h|
|
|
34
|
+
raise TypeError, "index value for #{k.inspect} must not be nil" if v.nil?
|
|
35
|
+
|
|
36
|
+
h[k] = Set.new(v)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def commit_sha_mismatch?(other)
|
|
41
|
+
commit_sha != other.commit_sha
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Metadata (generated_at, commit_sha, branch, collector) always comes from
|
|
45
|
+
# the newer of the two maps, so merge order cannot pair a fresh timestamp
|
|
46
|
+
# with a stale commit_sha.
|
|
47
|
+
def merge(other)
|
|
48
|
+
merged_index = index.each_with_object({}) { |(k, v), h| h[k] = v.dup }
|
|
49
|
+
other.index.each do |k, v|
|
|
50
|
+
merged_index[k] = (merged_index[k] || Set.new) | v
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
newer = other.generated_at >= generated_at ? other : self
|
|
54
|
+
|
|
55
|
+
self.class.new(
|
|
56
|
+
schema_version: SCHEMA_VERSION,
|
|
57
|
+
generated_at: newer.generated_at,
|
|
58
|
+
commit_sha: newer.commit_sha,
|
|
59
|
+
branch: newer.branch,
|
|
60
|
+
collector: merged_collector(newer:, other:),
|
|
61
|
+
known_spec_files: known_spec_files | other.known_spec_files,
|
|
62
|
+
index: merged_index
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def specs_for(source_path)
|
|
67
|
+
index[source_path] || Set.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def covered?(source_path)
|
|
71
|
+
index.key?(source_path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def empty?
|
|
75
|
+
index.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def spec_count
|
|
79
|
+
index.each_value.with_object(Set.new) { |specs, acc| acc.merge(specs) }.size
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def valid_backend?
|
|
83
|
+
collector['backend'] != 'null'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ==(other)
|
|
87
|
+
other.is_a?(Map) && comparable_fields == other.comparable_fields
|
|
88
|
+
end
|
|
89
|
+
alias eql? ==
|
|
90
|
+
|
|
91
|
+
def hash
|
|
92
|
+
comparable_fields.hash
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
protected
|
|
96
|
+
|
|
97
|
+
def comparable_fields
|
|
98
|
+
[schema_version, generated_at, commit_sha, branch, collector, known_spec_files, index]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Set.new(nil) silently yields an empty Set; nils and wrong types here
|
|
104
|
+
# are malformed payloads and must raise so MapSerializer degrades them
|
|
105
|
+
# to MapFormatError instead of crashing later in Planner.
|
|
106
|
+
def validate!(generated_at:, commit_sha:, branch:, collector:, known_spec_files:)
|
|
107
|
+
raise TypeError, 'known_spec_files must not be nil' if known_spec_files.nil?
|
|
108
|
+
raise TypeError, 'collector must be a Hash' unless collector.is_a?(Hash)
|
|
109
|
+
raise TypeError, 'commit_sha must be a String' unless commit_sha.is_a?(String)
|
|
110
|
+
raise TypeError, 'branch must be a String' unless branch.is_a?(String)
|
|
111
|
+
raise TypeError, 'generated_at must be a Time' unless generated_at.is_a?(Time)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A "null" backend in any merged part means part of the coverage is missing,
|
|
115
|
+
# so the merged map must stay invalid regardless of merge order.
|
|
116
|
+
def merged_collector(newer:, other:)
|
|
117
|
+
if collector['backend'] == 'null' || other.collector['backend'] == 'null'
|
|
118
|
+
newer.collector.merge('backend' => 'null')
|
|
119
|
+
else
|
|
120
|
+
newer.collector
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|