rspec-tracer 1.2.3 → 2.0.0.pre.2
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 +4 -4
- data/CHANGELOG.md +384 -67
- data/README.md +454 -429
- data/bin/rspec-tracer +15 -0
- data/lib/rspec_tracer/cache/Rakefile +43 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
- data/lib/rspec_tracer/cli/cache_info.rb +104 -0
- data/lib/rspec_tracer/cli/doctor.rb +284 -0
- data/lib/rspec_tracer/cli/explain.rb +158 -0
- data/lib/rspec_tracer/cli/report_open.rb +82 -0
- data/lib/rspec_tracer/cli.rb +116 -0
- data/lib/rspec_tracer/configuration.rb +1196 -3
- data/lib/rspec_tracer/engine.rb +1168 -0
- data/lib/rspec_tracer/example.rb +141 -11
- data/lib/rspec_tracer/filter.rb +35 -0
- data/lib/rspec_tracer/line_stub.rb +61 -0
- data/lib/rspec_tracer/load_config.rb +2 -2
- data/lib/rspec_tracer/logger.rb +15 -0
- data/lib/rspec_tracer/rails/README.md +78 -0
- data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
- data/lib/rspec_tracer/rails/notifications.rb +263 -0
- data/lib/rspec_tracer/rails/preset.rb +94 -0
- data/lib/rspec_tracer/rails/railtie.rb +22 -0
- data/lib/rspec_tracer/rails.rb +15 -0
- data/lib/rspec_tracer/remote_cache/README.md +140 -0
- data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
- data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
- data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
- data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
- data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
- data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
- data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
- data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
- data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
- data/lib/rspec_tracer/remote_cache.rb +22 -0
- data/lib/rspec_tracer/reporters/README.md +103 -0
- data/lib/rspec_tracer/reporters/base.rb +87 -0
- data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
- data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
- data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
- data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
- data/lib/rspec_tracer/reporters/html/README.md +80 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
- data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
- data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
- data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
- data/lib/rspec_tracer/reporters/html/package.json +29 -0
- data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
- data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
- data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
- data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
- data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
- data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
- data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
- data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
- data/lib/rspec_tracer/reporters/registry.rb +120 -0
- data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
- data/lib/rspec_tracer/rspec/README.md +73 -0
- data/lib/rspec_tracer/rspec/installation.rb +97 -0
- data/lib/rspec_tracer/rspec/metadata.rb +96 -0
- data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
- data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
- data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
- data/lib/rspec_tracer/source_file.rb +24 -7
- data/lib/rspec_tracer/storage/README.md +35 -0
- data/lib/rspec_tracer/storage/backend.rb +130 -0
- data/lib/rspec_tracer/storage/json_backend.rb +884 -0
- data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
- data/lib/rspec_tracer/storage/schema.rb +50 -0
- data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
- data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
- data/lib/rspec_tracer/storage/snapshot.rb +141 -0
- data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
- data/lib/rspec_tracer/time_formatter.rb +37 -18
- data/lib/rspec_tracer/tracker/README.md +36 -0
- data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
- data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
- data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
- data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
- data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
- data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
- data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
- data/lib/rspec_tracer/tracker/filter.rb +127 -0
- data/lib/rspec_tracer/tracker/input.rb +99 -0
- data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
- data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
- data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
- data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
- data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
- data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
- data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
- data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
- data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/rspec_tracer/version.rb +4 -1
- data/lib/rspec_tracer.rb +231 -491
- metadata +94 -43
- data/lib/rspec_tracer/cache.rb +0 -207
- data/lib/rspec_tracer/coverage_merger.rb +0 -42
- data/lib/rspec_tracer/coverage_reporter.rb +0 -187
- data/lib/rspec_tracer/coverage_writer.rb +0 -58
- data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
- data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
- data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
- data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
- data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
- data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
- data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
- data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
- data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
- data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
- data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
- data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
- data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
- data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
- data/lib/rspec_tracer/report_generator.rb +0 -158
- data/lib/rspec_tracer/report_merger.rb +0 -68
- data/lib/rspec_tracer/report_writer.rb +0 -141
- data/lib/rspec_tracer/reporter.rb +0 -204
- data/lib/rspec_tracer/rspec_reporter.rb +0 -41
- data/lib/rspec_tracer/rspec_runner.rb +0 -56
- data/lib/rspec_tracer/ruby_coverage.rb +0 -9
- data/lib/rspec_tracer/runner.rb +0 -278
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'rubygems/package'
|
|
5
|
+
require 'zlib'
|
|
6
|
+
|
|
7
|
+
module RSpecTracer
|
|
8
|
+
# Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
|
|
9
|
+
# @api private
|
|
10
|
+
module RemoteCache
|
|
11
|
+
# tar+gzip pack/extract for the remote-cache S3 payload. Pure
|
|
12
|
+
# Ruby stdlib (`rubygems/package` + `zlib`) - no shell-out, no
|
|
13
|
+
# new gem deps, works on every supported interpreter (MRI 3.1+
|
|
14
|
+
# and JRuby 9.4 both bundle both modules).
|
|
15
|
+
#
|
|
16
|
+
# Wire format: single `.tar.gz` containing `last_run.json` at the
|
|
17
|
+
# archive root plus a `<run_id>/` directory with the 15 JSON
|
|
18
|
+
# files. Replaces the 1.x per-file layout on S3 (N+1 objects per
|
|
19
|
+
# cache -> 1 object), shrinks payload ~4-6x via gzip on the highly-
|
|
20
|
+
# redundant JSON (shared example IDs across files), and cuts the
|
|
21
|
+
# per-download/upload request count from 15+ to exactly 2
|
|
22
|
+
# (cp for download, cp for upload).
|
|
23
|
+
#
|
|
24
|
+
# Local cache_path layout is UNCHANGED - the archive is pack/unpack
|
|
25
|
+
# boundary for transit only. User-facing filenames in
|
|
26
|
+
# `USER_FACING_SURFACE.md` section 6 stay as documented; external tooling
|
|
27
|
+
# that walks `rspec_tracer_cache/` sees the same 15-file layout.
|
|
28
|
+
module Archive
|
|
29
|
+
# Internal constant.
|
|
30
|
+
# @api private
|
|
31
|
+
CACHE_FILENAME = 'cache.tar.gz'
|
|
32
|
+
|
|
33
|
+
# Pack the relevant contents of `cache_path` into `dest_path`.
|
|
34
|
+
# Required: `cache_path/last_run.json` and `cache_path/<run_id>/`.
|
|
35
|
+
# Raises ArgumentError on a malformed cache; any I/O error during
|
|
36
|
+
# pack propagates to the caller (S3Backend wraps in S3BackendError
|
|
37
|
+
# + rescues at the orchestrator boundary for graceful degradation).
|
|
38
|
+
def self.pack(cache_path:, run_id:, dest_path:)
|
|
39
|
+
validate_pack_args!(cache_path, run_id, dest_path)
|
|
40
|
+
last_run, run_dir = resolve_pack_sources!(cache_path, run_id)
|
|
41
|
+
|
|
42
|
+
File.open(dest_path, 'wb') do |file|
|
|
43
|
+
Zlib::GzipWriter.wrap(file) do |gz|
|
|
44
|
+
Gem::Package::TarWriter.new(gz) do |tar|
|
|
45
|
+
add_file(tar, last_run, 'last_run.json')
|
|
46
|
+
Dir[File.join(run_dir, '*.json')].each do |path|
|
|
47
|
+
add_file(tar, path, File.join(run_id, File.basename(path)))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
dest_path
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Internal helper for the tracer pipeline.
|
|
56
|
+
# @api private
|
|
57
|
+
def self.validate_pack_args!(cache_path, run_id, dest_path)
|
|
58
|
+
raise ArgumentError, 'cache_path is required' if cache_path.nil? || cache_path.empty?
|
|
59
|
+
raise ArgumentError, 'run_id is required' if run_id.nil? || run_id.empty?
|
|
60
|
+
raise ArgumentError, 'dest_path is required' if dest_path.nil? || dest_path.empty?
|
|
61
|
+
end
|
|
62
|
+
private_class_method :validate_pack_args!
|
|
63
|
+
|
|
64
|
+
# Internal helper for the tracer pipeline.
|
|
65
|
+
# @api private
|
|
66
|
+
def self.resolve_pack_sources!(cache_path, run_id)
|
|
67
|
+
last_run = File.join(cache_path, 'last_run.json')
|
|
68
|
+
raise ArgumentError, "missing last_run.json at #{last_run}" unless File.file?(last_run)
|
|
69
|
+
|
|
70
|
+
run_dir = File.join(cache_path, run_id)
|
|
71
|
+
raise ArgumentError, "missing run dir at #{run_dir}" unless File.directory?(run_dir)
|
|
72
|
+
|
|
73
|
+
[last_run, run_dir]
|
|
74
|
+
end
|
|
75
|
+
private_class_method :resolve_pack_sources!
|
|
76
|
+
|
|
77
|
+
# Extract `archive_path` into `dest_dir`. Overwrites existing
|
|
78
|
+
# files (run-dir already present gets replaced). Raises on a
|
|
79
|
+
# malformed archive; caller rescues.
|
|
80
|
+
def self.extract(archive_path:, dest_dir:)
|
|
81
|
+
raise ArgumentError, 'archive_path is required' if archive_path.nil? || archive_path.empty?
|
|
82
|
+
raise ArgumentError, 'dest_dir is required' if dest_dir.nil? || dest_dir.empty?
|
|
83
|
+
raise ArgumentError, "missing archive at #{archive_path}" unless File.file?(archive_path)
|
|
84
|
+
|
|
85
|
+
FileUtils.mkdir_p(dest_dir)
|
|
86
|
+
File.open(archive_path, 'rb') do |file|
|
|
87
|
+
Zlib::GzipReader.wrap(file) do |gz|
|
|
88
|
+
Gem::Package::TarReader.new(gz) do |tar|
|
|
89
|
+
tar.each { |entry| write_entry(entry, dest_dir) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
dest_dir
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `add_file_simple` (not `add_file`) because Zlib::GzipWriter is
|
|
97
|
+
# non-seekable; the 2-arg `add_file` needs to back-patch the size
|
|
98
|
+
# into the tar header after writing content, which requires
|
|
99
|
+
# `io.pos=`. `add_file_simple` takes the size upfront, writes the
|
|
100
|
+
# header, then streams content - compatible with gzip.
|
|
101
|
+
def self.add_file(tar, local_path, archive_name)
|
|
102
|
+
stat = File.stat(local_path)
|
|
103
|
+
tar.add_file_simple(archive_name, stat.mode & 0o777, stat.size) do |io|
|
|
104
|
+
File.open(local_path, 'rb') { |src| IO.copy_stream(src, io) }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
private_class_method :add_file
|
|
108
|
+
|
|
109
|
+
# Internal helper for the tracer pipeline.
|
|
110
|
+
# @api private
|
|
111
|
+
def self.write_entry(entry, dest_dir)
|
|
112
|
+
return unless entry.file?
|
|
113
|
+
|
|
114
|
+
safe_name = safe_entry_name(entry.full_name)
|
|
115
|
+
return if safe_name.nil?
|
|
116
|
+
|
|
117
|
+
dest = File.join(dest_dir, safe_name)
|
|
118
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
119
|
+
File.open(dest, 'wb') { |out| IO.copy_stream(entry, out) }
|
|
120
|
+
end
|
|
121
|
+
private_class_method :write_entry
|
|
122
|
+
|
|
123
|
+
# Refuse absolute paths or `..` traversal. Both are illegal in a
|
|
124
|
+
# well-formed cache entry name; silently dropping them beats
|
|
125
|
+
# trusting a remote-sourced value to write anywhere on disk.
|
|
126
|
+
# Public because RedisBackend reuses the same guard on hash field
|
|
127
|
+
# names (the Redis equivalent of tar entry names).
|
|
128
|
+
def self.safe_entry_name(name)
|
|
129
|
+
return nil if name.nil? || name.empty?
|
|
130
|
+
return nil if name.start_with?('/')
|
|
131
|
+
return nil if name.split('/').include?('..')
|
|
132
|
+
|
|
133
|
+
name
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSpecTracer
|
|
4
|
+
# Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
|
|
5
|
+
# @api private
|
|
6
|
+
module RemoteCache
|
|
7
|
+
# Protocol every remote-cache backend must satisfy. S3Backend,
|
|
8
|
+
# LocalFsBackend, and RedisBackend all implement it. The shared-
|
|
9
|
+
# examples group in `spec/contracts/remote_cache_backend.rb` asserts
|
|
10
|
+
# the full contract on every implementation.
|
|
11
|
+
#
|
|
12
|
+
# Tier routing lives inside each backend, not in the protocol. A
|
|
13
|
+
# backend is constructed with branch + default_branch + test_suite_id
|
|
14
|
+
# state, and routes uploads/downloads to either the main tier
|
|
15
|
+
# (`main/<sha>/...`) or the pr tier (`pr/<branch>/<sha>/...`) based
|
|
16
|
+
# on whether the current branch is the default.
|
|
17
|
+
#
|
|
18
|
+
# Graceful-degradation contract (same as Storage::Backend):
|
|
19
|
+
# - `download(ref)` returns true/false, never raises on wire or
|
|
20
|
+
# validation failures. A failed download is "cold run" from the
|
|
21
|
+
# orchestrator's perspective.
|
|
22
|
+
# - `upload(ref)` raises on wire failure so the Rake task can
|
|
23
|
+
# report a meaningful exit status, but the orchestrator wraps
|
|
24
|
+
# the call in a rescue so test runs never propagate non-zero.
|
|
25
|
+
# - `branch_refs(branch_name)` returns `{}` when no refs file
|
|
26
|
+
# exists. Missing is not an error.
|
|
27
|
+
# - `write_branch_refs(branch_name, refs)` is a no-op for main
|
|
28
|
+
# tier writes (main branches do not use branch_refs; history
|
|
29
|
+
# rewrites are not expected on the default branch).
|
|
30
|
+
# - `prune!(...)` returns the count of refs removed and never
|
|
31
|
+
# raises on a LIST / DELETE wire error (logs + returns what it
|
|
32
|
+
# managed to delete). Scoped to the backend's own tier.
|
|
33
|
+
# - `prune_all!(pr_branch_ttl_seconds:)` walks every PR branch
|
|
34
|
+
# under the configured prefix, applies the ttl to each, and
|
|
35
|
+
# deletes dead branches whole. Cross-tier (every pr branch),
|
|
36
|
+
# unlike `prune!` which is own-tier only. Returns the count of
|
|
37
|
+
# refs removed across all branches. Never raises. No-op when
|
|
38
|
+
# ttl is nil / non-positive.
|
|
39
|
+
#
|
|
40
|
+
# This module is intentionally documentation-only - it does not
|
|
41
|
+
# define stubs that raise NotImplementedError, because mutant would
|
|
42
|
+
# flag every `raise` as an alive mutation with no way to kill it.
|
|
43
|
+
# The shared-examples contract is the real gate.
|
|
44
|
+
#
|
|
45
|
+
# @example Registering a custom remote-cache backend
|
|
46
|
+
# RSpecTracer.configure do
|
|
47
|
+
# remote_cache_backend MyCustomBackend, custom_opt: 'value'
|
|
48
|
+
# end
|
|
49
|
+
module Backend
|
|
50
|
+
# Internal constant.
|
|
51
|
+
# @api private
|
|
52
|
+
REQUIRED_METHODS = %i[
|
|
53
|
+
download
|
|
54
|
+
upload
|
|
55
|
+
branch_refs
|
|
56
|
+
write_branch_refs
|
|
57
|
+
prune!
|
|
58
|
+
prune_all!
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
# Verifies a candidate object satisfies the backend protocol.
|
|
62
|
+
# Used by {RSpecTracer::Configuration#remote_cache_backend} to
|
|
63
|
+
# gate custom-backend registration.
|
|
64
|
+
#
|
|
65
|
+
# @param backend [Object] candidate backend instance
|
|
66
|
+
# @return [Boolean] true if every {REQUIRED_METHODS} entry is
|
|
67
|
+
# responded to
|
|
68
|
+
def self.conforms?(backend)
|
|
69
|
+
REQUIRED_METHODS.all? { |m| backend.respond_to?(m) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'English'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module RSpecTracer
|
|
7
|
+
# Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
|
|
8
|
+
# @api private
|
|
9
|
+
module RemoteCache
|
|
10
|
+
# Git ancestry walker. Given the current branch + default branch,
|
|
11
|
+
# answers "which commit should I upload cache under?" and "which
|
|
12
|
+
# prior commits should I try as cache candidates?"
|
|
13
|
+
#
|
|
14
|
+
# Behavior preserved verbatim from 1.x `RemoteCache::Repo` -
|
|
15
|
+
# USER_FACING_SURFACE.md pins the 25-commit ancestry, branch_refs,
|
|
16
|
+
# history-rewrite resilience, merge-commit handling, and shallow-
|
|
17
|
+
# clone guidance as user contracts. Any change here affects every
|
|
18
|
+
# existing CI config that sets `fetch-depth: 25` or relies on the
|
|
19
|
+
# current "nearest ancestor" heuristic.
|
|
20
|
+
#
|
|
21
|
+
# Four scenarios this class handles:
|
|
22
|
+
#
|
|
23
|
+
# 1. Main branch build (GIT_BRANCH == GIT_DEFAULT_BRANCH):
|
|
24
|
+
# merge_base_branch! is a no-op. branch_ref = HEAD (or HEAD^1
|
|
25
|
+
# if HEAD is itself a merge commit, e.g. main just absorbed a
|
|
26
|
+
# feature branch via --no-ff merge). Ancestry walks HEAD^'s
|
|
27
|
+
# linear history up to 25 commits.
|
|
28
|
+
#
|
|
29
|
+
# 2. GitHub Actions PR (checkout of refs/pull/N/merge, detached
|
|
30
|
+
# HEAD on a synthetic merge commit):
|
|
31
|
+
# merge_base_branch! runs `git fetch origin <branch>:<branch>`
|
|
32
|
+
# + `git checkout <branch>` + `git merge origin/<default>
|
|
33
|
+
# --no-edit --no-ff`. After this HEAD is a (possibly new) merge
|
|
34
|
+
# commit. branch_ref = HEAD^1 (the PR branch tip). Ancestry
|
|
35
|
+
# walks HEAD^1's 25-commit history UNION `HEAD^1..origin/HEAD`
|
|
36
|
+
# (default branch commits the PR hasn't absorbed yet).
|
|
37
|
+
#
|
|
38
|
+
# 3. Jenkins / CircleCI / Travis PR (checkout of raw PR branch):
|
|
39
|
+
# merge_base_branch! materializes the merge commit that GHA
|
|
40
|
+
# provides automatically. Result is identical to scenario 2.
|
|
41
|
+
#
|
|
42
|
+
# 4. PR branch behind main: same flow as 2/3. The explicit merge
|
|
43
|
+
# ensures RSpec runs against the merged state and ancestry
|
|
44
|
+
# picks up main's recent commits as candidates.
|
|
45
|
+
#
|
|
46
|
+
# The merge is a WORKING-TREE MUTATION. Callers should run
|
|
47
|
+
# `merge_base_branch!` exactly once at the start of each Rake task
|
|
48
|
+
# invocation (download + upload each instantiate a fresh orchestrator
|
|
49
|
+
# and re-run the merge; idempotent on a clean tree). See
|
|
50
|
+
# RSPEC_TRACER.md "Caching on CI" for the user-facing rationale.
|
|
51
|
+
class GitAncestry
|
|
52
|
+
# Internal GitAncestryError — see {RSpecTracer} for the user-facing surface.
|
|
53
|
+
# @api private
|
|
54
|
+
class GitAncestryError < StandardError; end
|
|
55
|
+
|
|
56
|
+
# Matches 1.x. 25 commits is a 14-year-stable tradeoff between
|
|
57
|
+
# cache hit rate on slow trunks and ancestry-walk cost.
|
|
58
|
+
ANCESTRY_DEPTH = 25
|
|
59
|
+
|
|
60
|
+
# Internal attribute.
|
|
61
|
+
# @api private
|
|
62
|
+
attr_reader :default_branch_name, :branch_name
|
|
63
|
+
|
|
64
|
+
# Internal method on the tracer pipeline.
|
|
65
|
+
# @api private
|
|
66
|
+
def initialize(default_branch:, branch:)
|
|
67
|
+
raise GitAncestryError, 'default_branch is required' if default_branch.nil? || default_branch.to_s.empty?
|
|
68
|
+
raise GitAncestryError, 'branch is required' if branch.nil? || branch.to_s.empty?
|
|
69
|
+
|
|
70
|
+
@default_branch_name = default_branch.to_s.chomp
|
|
71
|
+
@branch_name = branch.to_s.chomp
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# True when this is a PR build (current branch != default branch).
|
|
75
|
+
def pr_build?
|
|
76
|
+
@branch_name != @default_branch_name
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# True when HEAD has two parents (i.e., a merge commit). Cached.
|
|
80
|
+
def merge_commit?
|
|
81
|
+
return @merge_commit if defined?(@merge_commit)
|
|
82
|
+
|
|
83
|
+
@merge_commit = system('git', 'rev-parse', 'HEAD^2', out: File::NULL, err: File::NULL)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Materialize the PR-merged state: fetch + checkout + merge default.
|
|
87
|
+
# No-op on main builds. Idempotent on a clean tree (a subsequent
|
|
88
|
+
# `git merge origin/<default>` when already merged produces "Already
|
|
89
|
+
# up to date", no new merge commit).
|
|
90
|
+
#
|
|
91
|
+
# Raises GitAncestryError on fetch/checkout/merge failure. The
|
|
92
|
+
# orchestrator catches and logs; the user's Rake task exits
|
|
93
|
+
# cleanly with a warning but proceeds to cold-run.
|
|
94
|
+
def merge_base_branch!
|
|
95
|
+
return if @default_branch_name == @branch_name
|
|
96
|
+
|
|
97
|
+
pull_remote_branch! if current_branch != @branch_name
|
|
98
|
+
merge_default_branch!
|
|
99
|
+
reset_memo!
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The SHA to upload cache under, and to seed ancestry walk from.
|
|
103
|
+
# When HEAD is a merge commit (normal PR case after merge_base_branch!
|
|
104
|
+
# runs), branch_ref = HEAD^1 (the real branch tip). Uploading under
|
|
105
|
+
# HEAD directly would key the cache under a synthetic merge SHA
|
|
106
|
+
# that no future build's ancestry walk can reach.
|
|
107
|
+
def branch_ref
|
|
108
|
+
return @branch_ref if defined?(@branch_ref)
|
|
109
|
+
|
|
110
|
+
head = head_ref
|
|
111
|
+
if merge_commit?
|
|
112
|
+
parents = merged_parents
|
|
113
|
+
@branch_ref = parents.first
|
|
114
|
+
@ignored_refs = [head]
|
|
115
|
+
else
|
|
116
|
+
@branch_ref = head
|
|
117
|
+
@ignored_refs = []
|
|
118
|
+
end
|
|
119
|
+
@branch_ref
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Hash{sha => committer_timestamp} of candidate ancestor commits.
|
|
123
|
+
# Newest-first ordering is applied by the orchestrator when merging
|
|
124
|
+
# with branch_refs; this method returns insertion order.
|
|
125
|
+
#
|
|
126
|
+
# Walk rule:
|
|
127
|
+
# refs = Set[]
|
|
128
|
+
# if merge_commit?: refs |= rev-list --max-count=25 branch_ref..origin/HEAD
|
|
129
|
+
# refs |= rev-list --max-count=25 branch_ref
|
|
130
|
+
# refs -= ignored_refs # drop synthetic HEAD when merged
|
|
131
|
+
#
|
|
132
|
+
# Returns {} when the walk yields nothing (new repo, shallow
|
|
133
|
+
# clone shorter than 25, etc.). Not an error.
|
|
134
|
+
def ancestry_refs
|
|
135
|
+
return @ancestry_refs if defined?(@ancestry_refs)
|
|
136
|
+
|
|
137
|
+
branch_ref # materialize ignored_refs
|
|
138
|
+
|
|
139
|
+
ref_list = Set.new
|
|
140
|
+
ref_list |= rev_list("#{@branch_ref}..origin/HEAD") if merge_commit?
|
|
141
|
+
ref_list |= rev_list(@branch_ref.to_s)
|
|
142
|
+
|
|
143
|
+
@ancestry_refs = refs_committer_timestamp(ref_list - @ignored_refs)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# Internal method on the tracer pipeline.
|
|
149
|
+
# @api private
|
|
150
|
+
def reset_memo!
|
|
151
|
+
remove_instance_variable(:@merge_commit) if defined?(@merge_commit)
|
|
152
|
+
remove_instance_variable(:@branch_ref) if defined?(@branch_ref)
|
|
153
|
+
remove_instance_variable(:@ignored_refs) if defined?(@ignored_refs)
|
|
154
|
+
remove_instance_variable(:@ancestry_refs) if defined?(@ancestry_refs)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Internal method on the tracer pipeline.
|
|
158
|
+
# @api private
|
|
159
|
+
def current_branch
|
|
160
|
+
branch = `git rev-parse --abbrev-ref HEAD`.chomp
|
|
161
|
+
raise GitAncestryError, 'Could not determine current branch' unless $CHILD_STATUS.success?
|
|
162
|
+
|
|
163
|
+
branch
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Internal method on the tracer pipeline.
|
|
167
|
+
# @api private
|
|
168
|
+
def pull_remote_branch!
|
|
169
|
+
fetched = system(
|
|
170
|
+
'git', 'fetch', 'origin', "#{@branch_name}:#{@branch_name}",
|
|
171
|
+
out: File::NULL, err: File::NULL
|
|
172
|
+
)
|
|
173
|
+
checked_out = fetched && system(
|
|
174
|
+
'git', 'checkout', @branch_name,
|
|
175
|
+
out: File::NULL, err: File::NULL
|
|
176
|
+
)
|
|
177
|
+
raise GitAncestryError, "Could not pull remote branch #{@branch_name}" unless checked_out
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Internal method on the tracer pipeline.
|
|
181
|
+
# @api private
|
|
182
|
+
def merge_default_branch!
|
|
183
|
+
merged = system(
|
|
184
|
+
'git', 'merge', "origin/#{@default_branch_name}",
|
|
185
|
+
'--no-edit', '--no-ff',
|
|
186
|
+
out: File::NULL, err: File::NULL
|
|
187
|
+
)
|
|
188
|
+
raise GitAncestryError, "Could not merge #{@default_branch_name} into #{@branch_name}" unless merged
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Internal method on the tracer pipeline.
|
|
192
|
+
# @api private
|
|
193
|
+
def head_ref
|
|
194
|
+
head = `git rev-parse HEAD`.chomp
|
|
195
|
+
raise GitAncestryError, 'Could not find HEAD commit sha' unless $CHILD_STATUS.success?
|
|
196
|
+
|
|
197
|
+
head
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Internal method on the tracer pipeline.
|
|
201
|
+
# @api private
|
|
202
|
+
def merged_parents
|
|
203
|
+
parents = []
|
|
204
|
+
first_parent = `git rev-parse HEAD^1`.chomp
|
|
205
|
+
parents << first_parent if $CHILD_STATUS.success?
|
|
206
|
+
second_parent = `git rev-parse HEAD^2`.chomp
|
|
207
|
+
parents << second_parent if $CHILD_STATUS.success?
|
|
208
|
+
raise GitAncestryError, 'Could not find merge commit parents' if parents.length != 2
|
|
209
|
+
|
|
210
|
+
parents
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Internal method on the tracer pipeline.
|
|
214
|
+
# @api private
|
|
215
|
+
def rev_list(spec)
|
|
216
|
+
output = `git rev-list --max-count=#{ANCESTRY_DEPTH} #{spec}`.chomp
|
|
217
|
+
raise GitAncestryError, "Could not list revs for #{spec}" unless $CHILD_STATUS.success?
|
|
218
|
+
|
|
219
|
+
output.split
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Internal method on the tracer pipeline.
|
|
223
|
+
# @api private
|
|
224
|
+
def refs_committer_timestamp(ref_list)
|
|
225
|
+
return {} if ref_list.empty?
|
|
226
|
+
|
|
227
|
+
command = <<-COMMAND.strip.gsub(/\s+/, ' ')
|
|
228
|
+
git show
|
|
229
|
+
--no-patch
|
|
230
|
+
--format="%H %ct"
|
|
231
|
+
#{ref_list.to_a.join(' ')}
|
|
232
|
+
COMMAND
|
|
233
|
+
|
|
234
|
+
output = `#{command}`.chomp
|
|
235
|
+
raise GitAncestryError, 'Could not fetch committer timestamps' unless $CHILD_STATUS.success?
|
|
236
|
+
|
|
237
|
+
output.split("\n").to_h(&:split).transform_values(&:to_i)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|