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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +384 -67
  3. data/README.md +454 -429
  4. data/bin/rspec-tracer +15 -0
  5. data/lib/rspec_tracer/cache/Rakefile +43 -0
  6. data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
  7. data/lib/rspec_tracer/cli/cache_info.rb +104 -0
  8. data/lib/rspec_tracer/cli/doctor.rb +284 -0
  9. data/lib/rspec_tracer/cli/explain.rb +158 -0
  10. data/lib/rspec_tracer/cli/report_open.rb +82 -0
  11. data/lib/rspec_tracer/cli.rb +116 -0
  12. data/lib/rspec_tracer/configuration.rb +1196 -3
  13. data/lib/rspec_tracer/engine.rb +1168 -0
  14. data/lib/rspec_tracer/example.rb +141 -11
  15. data/lib/rspec_tracer/filter.rb +35 -0
  16. data/lib/rspec_tracer/line_stub.rb +61 -0
  17. data/lib/rspec_tracer/load_config.rb +2 -2
  18. data/lib/rspec_tracer/logger.rb +15 -0
  19. data/lib/rspec_tracer/rails/README.md +78 -0
  20. data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
  21. data/lib/rspec_tracer/rails/notifications.rb +263 -0
  22. data/lib/rspec_tracer/rails/preset.rb +94 -0
  23. data/lib/rspec_tracer/rails/railtie.rb +22 -0
  24. data/lib/rspec_tracer/rails.rb +15 -0
  25. data/lib/rspec_tracer/remote_cache/README.md +140 -0
  26. data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
  27. data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
  28. data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
  29. data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
  30. data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
  31. data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
  32. data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
  33. data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
  34. data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
  35. data/lib/rspec_tracer/remote_cache.rb +22 -0
  36. data/lib/rspec_tracer/reporters/README.md +103 -0
  37. data/lib/rspec_tracer/reporters/base.rb +87 -0
  38. data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
  39. data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
  40. data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
  41. data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
  42. data/lib/rspec_tracer/reporters/html/README.md +80 -0
  43. data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
  44. data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
  45. data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
  46. data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
  47. data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
  48. data/lib/rspec_tracer/reporters/html/package.json +29 -0
  49. data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
  50. data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
  51. data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
  52. data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
  53. data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
  54. data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
  55. data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
  56. data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
  57. data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
  58. data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
  59. data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
  60. data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
  61. data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
  62. data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
  63. data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
  64. data/lib/rspec_tracer/reporters/registry.rb +120 -0
  65. data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
  66. data/lib/rspec_tracer/rspec/README.md +73 -0
  67. data/lib/rspec_tracer/rspec/installation.rb +97 -0
  68. data/lib/rspec_tracer/rspec/metadata.rb +96 -0
  69. data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
  70. data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
  71. data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
  72. data/lib/rspec_tracer/source_file.rb +24 -7
  73. data/lib/rspec_tracer/storage/README.md +35 -0
  74. data/lib/rspec_tracer/storage/backend.rb +130 -0
  75. data/lib/rspec_tracer/storage/json_backend.rb +884 -0
  76. data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
  77. data/lib/rspec_tracer/storage/schema.rb +50 -0
  78. data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
  79. data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
  80. data/lib/rspec_tracer/storage/snapshot.rb +141 -0
  81. data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
  82. data/lib/rspec_tracer/time_formatter.rb +37 -18
  83. data/lib/rspec_tracer/tracker/README.md +36 -0
  84. data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
  85. data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
  86. data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
  87. data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
  88. data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
  89. data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
  90. data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
  91. data/lib/rspec_tracer/tracker/filter.rb +127 -0
  92. data/lib/rspec_tracer/tracker/input.rb +99 -0
  93. data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
  94. data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
  95. data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
  96. data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
  97. data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
  98. data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
  99. data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
  100. data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
  101. data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
  102. data/lib/rspec_tracer/version.rb +4 -1
  103. data/lib/rspec_tracer.rb +231 -491
  104. metadata +94 -43
  105. data/lib/rspec_tracer/cache.rb +0 -207
  106. data/lib/rspec_tracer/coverage_merger.rb +0 -42
  107. data/lib/rspec_tracer/coverage_reporter.rb +0 -187
  108. data/lib/rspec_tracer/coverage_writer.rb +0 -58
  109. data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
  110. data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
  111. data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
  112. data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
  113. data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
  114. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
  115. data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
  116. data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
  117. data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
  118. data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
  119. data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
  120. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
  121. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
  122. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
  123. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
  124. data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
  125. data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
  126. data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
  127. data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
  128. data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
  129. data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
  130. data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
  131. data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
  132. data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
  133. data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
  134. data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
  135. data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
  136. data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
  137. data/lib/rspec_tracer/report_generator.rb +0 -158
  138. data/lib/rspec_tracer/report_merger.rb +0 -68
  139. data/lib/rspec_tracer/report_writer.rb +0 -141
  140. data/lib/rspec_tracer/reporter.rb +0 -204
  141. data/lib/rspec_tracer/rspec_reporter.rb +0 -41
  142. data/lib/rspec_tracer/rspec_runner.rb +0 -56
  143. data/lib/rspec_tracer/ruby_coverage.rb +0 -9
  144. 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