branchproof 0.11.0 → 0.12.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 +4 -4
- data/CHANGELOG.md +89 -0
- data/README.md +440 -29
- data/doc/Branchproof/CLI.md +1 -1
- data/doc/Branchproof/ChangedCoverage.md +19 -0
- data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
- data/doc/Branchproof/ChangedDecisionMap.md +20 -0
- data/doc/Branchproof/ChangedScope.md +18 -0
- data/doc/Branchproof/Collation.md +23 -0
- data/doc/Branchproof/CollationManifest.md +29 -0
- data/doc/Branchproof/CoverageSummary.md +20 -0
- data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
- data/doc/Branchproof/Doctor.md +34 -0
- data/doc/Branchproof/FallbackInstrumentation.md +8 -0
- data/doc/Branchproof/FallbackRuntime.md +12 -0
- data/doc/Branchproof/FallbackSyntax.md +13 -0
- data/doc/Branchproof/GitChanges.md +19 -0
- data/doc/Branchproof/GithubReport.md +25 -0
- data/doc/Branchproof/HtmlReport.md +22 -0
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/MinitestAdapter.md +4 -0
- data/doc/Branchproof/RakeTask.md +121 -0
- data/doc/Branchproof/Report.md +41 -3
- data/doc/Branchproof/ReportSelection.md +10 -1
- data/doc/Branchproof/Runtime.md +4 -1
- data/doc/Branchproof/Source.md +6 -1
- data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
- data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
- data/doc/Branchproof/SummaryRanking.md +36 -0
- data/doc/Branchproof/SummaryReport.md +42 -0
- data/doc/Branchproof/Worker.md +1 -1
- data/doc/Branchproof.md +21 -1
- data/doc/CHANGELOG.md +89 -0
- data/doc/README.md +440 -29
- data/lib/branchproof/analyzer.rb +2 -52
- data/lib/branchproof/changed_coverage.rb +177 -0
- data/lib/branchproof/changed_decision_map.rb +359 -0
- data/lib/branchproof/changed_scope.rb +93 -0
- data/lib/branchproof/cli.rb +242 -21
- data/lib/branchproof/collation.rb +525 -0
- data/lib/branchproof/collation_manifest.rb +129 -0
- data/lib/branchproof/comparison.rb +11 -3
- data/lib/branchproof/comparison_report.rb +2 -0
- data/lib/branchproof/configuration.rb +12 -2
- data/lib/branchproof/coverage_policy.rb +122 -0
- data/lib/branchproof/coverage_summary.rb +94 -0
- data/lib/branchproof/doctor.rb +241 -0
- data/lib/branchproof/evidence.rb +5 -1
- data/lib/branchproof/exception_instrumentation.rb +4 -3
- data/lib/branchproof/exception_syntax.rb +15 -1
- data/lib/branchproof/fallback_instrumentation.rb +25 -0
- data/lib/branchproof/fallback_runtime.rb +14 -0
- data/lib/branchproof/fallback_syntax.rb +90 -0
- data/lib/branchproof/focused_report.rb +81 -22
- data/lib/branchproof/git_changes.rb +188 -0
- data/lib/branchproof/github_report.rb +292 -0
- data/lib/branchproof/html_report.rb +412 -0
- data/lib/branchproof/instrumenter.rb +2 -0
- data/lib/branchproof/minimizer.rb +4 -2
- data/lib/branchproof/minitest_adapter.rb +45 -5
- data/lib/branchproof/rails_support.rb +2 -2
- data/lib/branchproof/rake_task.rb +186 -0
- data/lib/branchproof/report.rb +383 -71
- data/lib/branchproof/report_selection.rb +26 -5
- data/lib/branchproof/runtime.rb +2 -0
- data/lib/branchproof/saved_report.rb +323 -5
- data/lib/branchproof/source.rb +25 -4
- data/lib/branchproof/summary_ranking.rb +165 -0
- data/lib/branchproof/summary_report.rb +241 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +14 -9
- data/lib/branchproof.rb +12 -0
- data/llms.txt +21 -1
- data/sig/branchproof.rbs +206 -5
- metadata +38 -22
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Records which operand of a fallback chain supplied the value. Only truthy
|
|
5
|
+
# operands are recorded; `||` itself decides whether evaluation continues.
|
|
6
|
+
module FallbackRuntime
|
|
7
|
+
def fallback_operand(decision_id, index, implicit, value)
|
|
8
|
+
return value unless value
|
|
9
|
+
|
|
10
|
+
implicit ? flow_path(decision_id, index) : flow_select(decision_id, index)
|
|
11
|
+
value
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# A value-context `||` chain that ends in an always-truthy literal returns
|
|
5
|
+
# the first truthy operand and is never false. It is inventoried as
|
|
6
|
+
# alternatives (which operand supplied the value), not as a Boolean decision.
|
|
7
|
+
module FallbackSyntax
|
|
8
|
+
# A jump never supplies a value, so it cannot be a fallback operand.
|
|
9
|
+
JUMP_NODES = [Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode,
|
|
10
|
+
Prism::RetryNode].freeze
|
|
11
|
+
private_constant :JUMP_NODES
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def fallback_chain?(node)
|
|
16
|
+
return false unless symbolic_or?(node)
|
|
17
|
+
|
|
18
|
+
operands = fallback_operands(node)
|
|
19
|
+
last = operands.last
|
|
20
|
+
literal_truth(last) == true && !last.is_a?(Prism::TrueNode) &&
|
|
21
|
+
operands[0...-1].none? do |operand|
|
|
22
|
+
logical_operand?(operand) || jump_operand?(operand) || contextual_operand?(operand)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Only reached for OrNodes already gated by fallback_chain? in Source#decisions_for phase two.
|
|
27
|
+
# rubocop:disable-next Metrics/AbcSize -- alternative/instrumentation assembly mirrors DecisionSyntax#flow_details.
|
|
28
|
+
def flow_details(node, bytes, encoding)
|
|
29
|
+
return super unless node.is_a?(Prism::OrNode)
|
|
30
|
+
|
|
31
|
+
operands = fallback_operands(node)
|
|
32
|
+
alternatives = operands.map do |operand|
|
|
33
|
+
location = operand.location
|
|
34
|
+
{ expression: text_value(bytes.byteslice(location.start_offset, location.length), encoding),
|
|
35
|
+
byte_start: location.start_offset, byte_length: location.length }
|
|
36
|
+
end
|
|
37
|
+
instrumentation = { type: "fallback", operands: operands.map { |operand| byte_range(operand.location) } }
|
|
38
|
+
kind = operands.length == 2 ? "implicit" : "multiway"
|
|
39
|
+
[kind, "fallback", alternatives, instrumentation, unsupported_reasons(node, bytes)]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `a || b || c` parses as `(a || b) || c`, so the chain grows to the left.
|
|
43
|
+
def fallback_operands(node)
|
|
44
|
+
operands = []
|
|
45
|
+
loop do
|
|
46
|
+
operands << node.right
|
|
47
|
+
node = node.left
|
|
48
|
+
break unless symbolic_or?(node)
|
|
49
|
+
end
|
|
50
|
+
operands << node
|
|
51
|
+
operands.reverse!
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def symbolic_or?(node)
|
|
55
|
+
node.is_a?(Prism::OrNode) && node.operator_loc.slice == "||"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def logical_operand?(operand)
|
|
59
|
+
operand = unwrap_predicate(operand)
|
|
60
|
+
boolean_node?(operand) || unary_not?(operand)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def jump_operand?(operand)
|
|
64
|
+
tail = tail_statement(operand)
|
|
65
|
+
JUMP_NODES.any? { |type| tail.is_a?(type) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def contextual_operand?(operand)
|
|
69
|
+
operand = unwrap_predicate(operand)
|
|
70
|
+
operand.is_a?(Prism::MatchLastLineNode) || operand.is_a?(Prism::InterpolatedMatchLastLineNode) ||
|
|
71
|
+
operand.is_a?(Prism::FlipFlopNode)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A `(...)` or `begin...end` operand evaluates to its last statement, so a
|
|
75
|
+
# trailing jump there ends the operand too. Only the last statement
|
|
76
|
+
# matters: an earlier jump would already end evaluation inside its own
|
|
77
|
+
# construct (such as `if`), which is fine as an operand.
|
|
78
|
+
def tail_statement(node)
|
|
79
|
+
loop do
|
|
80
|
+
body = case node
|
|
81
|
+
when Prism::ParenthesesNode then node.body&.body
|
|
82
|
+
when Prism::BeginNode then node.statements&.body
|
|
83
|
+
end
|
|
84
|
+
return node if body.nil? || body.empty?
|
|
85
|
+
|
|
86
|
+
node = body.last
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -14,7 +14,7 @@ module Branchproof
|
|
|
14
14
|
selection: nil)
|
|
15
15
|
@document = document || {}
|
|
16
16
|
@view = view.to_sym
|
|
17
|
-
unless (Report::VIEWS - [
|
|
17
|
+
unless (Report::VIEWS - %i[decisions summary]).include?(@view)
|
|
18
18
|
raise ArgumentError, "view must be :conditions, :tests, or :decision_tables"
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -27,7 +27,8 @@ module Branchproof
|
|
|
27
27
|
counts[[test[:name], test[:relative_path], test[:line]]] += 1
|
|
28
28
|
end
|
|
29
29
|
@coordinator = coordinator || Report.from_document(document: @document, level: @level)
|
|
30
|
-
@matching_decision_ids = @selection.
|
|
30
|
+
@matching_decision_ids = @selection.decision_filter_active? ? @selection.selected_decision_ids(@document) : nil
|
|
31
|
+
@matching_decision_id_set = @matching_decision_ids&.to_h { |id| [id, true] }
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def render
|
|
@@ -51,8 +52,11 @@ module Branchproof
|
|
|
51
52
|
lines << "Empty groups mean no recorded completed observation."
|
|
52
53
|
lines << ""
|
|
53
54
|
render_focus_notice(lines)
|
|
55
|
+
lines << "Whole-run coverage and policy:" if @selection.decision_ids
|
|
54
56
|
lines.concat(@coordinator.coverage_ladder_lines)
|
|
55
57
|
lines.concat(@coordinator.coverage_policy_lines)
|
|
58
|
+
lines.concat(@coordinator.changed_scope_lines) if @selection.decision_ids
|
|
59
|
+
lines.concat(@coordinator.collation_lines)
|
|
56
60
|
case @view
|
|
57
61
|
when :conditions
|
|
58
62
|
render_conditions(lines)
|
|
@@ -64,7 +68,8 @@ module Branchproof
|
|
|
64
68
|
render_unowned(lines)
|
|
65
69
|
render_unsupported(lines)
|
|
66
70
|
Array(fetch(@document, :diagnostics)).each do |diagnostic|
|
|
67
|
-
|
|
71
|
+
prefix = @selection.decision_ids ? "Run-wide diagnostic" : "Diagnostic"
|
|
72
|
+
lines << "#{prefix}: #{@coordinator.diagnostic_message(diagnostic)}"
|
|
68
73
|
end
|
|
69
74
|
lines.join("\n") << "\n"
|
|
70
75
|
end
|
|
@@ -94,7 +99,8 @@ module Branchproof
|
|
|
94
99
|
render_group(lines, "Short-circuited in", row[:short_circuited], row)
|
|
95
100
|
lines << " Unattributed observations: #{row[:unattributed]}" if row[:unattributed].to_i.positive?
|
|
96
101
|
if @level >= 2 && row[:supporting_set].any?
|
|
97
|
-
|
|
102
|
+
heading = @selection.decision_ids ? "Whole-run supporting test set" : "Supporting test set"
|
|
103
|
+
lines << " #{heading}: #{row[:supporting_set].map { |id| test_label(id) }.join(", ")}"
|
|
98
104
|
lines << " Tests outside this MC/DC evidence set may provide other coverage."
|
|
99
105
|
end
|
|
100
106
|
render_constraint(lines, row) if @level >= 3 && row[:constraint_result]
|
|
@@ -112,7 +118,9 @@ module Branchproof
|
|
|
112
118
|
end
|
|
113
119
|
lines << ""
|
|
114
120
|
end
|
|
115
|
-
lines << "No missing conditions" if @missing_only && rows.empty? && focus_match_exists?
|
|
121
|
+
lines << "No missing conditions" if @missing_only && rows.empty? && focus_match_exists? &&
|
|
122
|
+
!empty_changed_scope? &&
|
|
123
|
+
(!@selection.decision_ids || @coordinator.changed_coverage_available?)
|
|
116
124
|
render_alternatives(lines, alternatives)
|
|
117
125
|
end
|
|
118
126
|
|
|
@@ -142,7 +150,11 @@ module Branchproof
|
|
|
142
150
|
rows, hidden = limit_rows(all_rows)
|
|
143
151
|
append_limit_summary(lines, hidden, "decision")
|
|
144
152
|
rendered = 0
|
|
145
|
-
impossible = @
|
|
153
|
+
impossible = if @selection.decision_filter_active?
|
|
154
|
+
all_rows.sum { |row| row[:impossible_rules].to_i }
|
|
155
|
+
else
|
|
156
|
+
@index.decision_tables.sum { |row| row[:impossible_rules].to_i }
|
|
157
|
+
end
|
|
146
158
|
rows.each do |row|
|
|
147
159
|
rules = @missing_only ? row[:rules].select { |rule| rule[:coverage].to_s == "missing" } : row[:rules]
|
|
148
160
|
next if @missing_only && rules.empty? && row[:status].to_s == "calculated"
|
|
@@ -163,7 +175,10 @@ module Branchproof
|
|
|
163
175
|
rules.each { |rule| render_decision_table_rule(lines, row, rule) }
|
|
164
176
|
lines << ""
|
|
165
177
|
end
|
|
166
|
-
lines << "No missing decision-table rules" if @missing_only && rendered.zero?
|
|
178
|
+
lines << "No missing decision-table rules" if @missing_only && rendered.zero? && focus_match_exists? &&
|
|
179
|
+
!empty_changed_scope? &&
|
|
180
|
+
(!@selection.decision_ids ||
|
|
181
|
+
@coordinator.changed_coverage_available?)
|
|
167
182
|
return unless impossible.positive?
|
|
168
183
|
|
|
169
184
|
lines << "#{impossible} statically impossible rule#{"s" unless impossible == 1} excluded"
|
|
@@ -213,7 +228,9 @@ module Branchproof
|
|
|
213
228
|
end
|
|
214
229
|
lines << ""
|
|
215
230
|
end
|
|
216
|
-
lines << "No missing alternatives" if @missing_only && rows.empty? && focus_match_exists?
|
|
231
|
+
lines << "No missing alternatives" if @missing_only && rows.empty? && focus_match_exists? &&
|
|
232
|
+
!empty_changed_scope? &&
|
|
233
|
+
(!@selection.decision_ids || @coordinator.changed_coverage_available?)
|
|
217
234
|
end
|
|
218
235
|
|
|
219
236
|
def render_alternative_group(lines, heading, evidence)
|
|
@@ -340,10 +357,16 @@ module Branchproof
|
|
|
340
357
|
def render_focus_notice(lines)
|
|
341
358
|
return unless @selection.focus_active?
|
|
342
359
|
|
|
360
|
+
focus_label = if @selection.decision_ids
|
|
361
|
+
@coordinator.display_scope_path(@selection.focus_label)
|
|
362
|
+
else
|
|
363
|
+
@selection.focus_label
|
|
364
|
+
end
|
|
365
|
+
no_match = @selection.decision_ids ? "no matching changed decisions" : "no matching decisions"
|
|
343
366
|
lines << if @matching_decision_ids.empty?
|
|
344
|
-
"Focus:
|
|
367
|
+
"Focus: #{no_match} for #{focus_label}"
|
|
345
368
|
else
|
|
346
|
-
"Focus: #{
|
|
369
|
+
"Focus: #{focus_label}"
|
|
347
370
|
end
|
|
348
371
|
end
|
|
349
372
|
|
|
@@ -360,9 +383,17 @@ module Branchproof
|
|
|
360
383
|
!@selection.focus_active? || !@matching_decision_ids.empty?
|
|
361
384
|
end
|
|
362
385
|
|
|
386
|
+
def empty_changed_scope?
|
|
387
|
+
@selection.decision_ids&.empty?
|
|
388
|
+
end
|
|
389
|
+
|
|
363
390
|
def filtered_conditions
|
|
364
391
|
rows = @index.conditions
|
|
365
|
-
|
|
392
|
+
if @selection.decision_filter_active?
|
|
393
|
+
rows = rows.select do |row|
|
|
394
|
+
@matching_decision_id_set.key?(row[:decision_id].to_s)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
366
397
|
rows = rows.reject { |row| row[:status].to_s.upcase == "PROVEN" } if @missing_only && @level > 1
|
|
367
398
|
rows.sort_by do |row|
|
|
368
399
|
[row[:relative_path].to_s, row[:line].to_i, row[:column].to_i,
|
|
@@ -372,7 +403,11 @@ module Branchproof
|
|
|
372
403
|
|
|
373
404
|
def filtered_alternatives
|
|
374
405
|
rows = @index.alternatives
|
|
375
|
-
|
|
406
|
+
if @selection.decision_filter_active?
|
|
407
|
+
rows = rows.select do |row|
|
|
408
|
+
@matching_decision_id_set.key?(row[:decision_id].to_s)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
376
411
|
rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
|
|
377
412
|
rows.sort_by do |row|
|
|
378
413
|
[row[:relative_path].to_s, row[:line].to_i, row[:column].to_i,
|
|
@@ -397,7 +432,11 @@ module Branchproof
|
|
|
397
432
|
|
|
398
433
|
def filtered_decision_tables
|
|
399
434
|
rows = @index.decision_tables
|
|
400
|
-
|
|
435
|
+
if @selection.decision_filter_active?
|
|
436
|
+
rows = rows.select do |row|
|
|
437
|
+
@matching_decision_id_set.key?(row[:decision_id].to_s)
|
|
438
|
+
end
|
|
439
|
+
end
|
|
401
440
|
rows = rows.select do |row|
|
|
402
441
|
!@missing_only || row[:status].to_s != "calculated" || row[:rules].any? do |rule|
|
|
403
442
|
rule[:coverage].to_s == "missing"
|
|
@@ -410,15 +449,12 @@ module Branchproof
|
|
|
410
449
|
rows = @index.tests
|
|
411
450
|
return rows unless @selection.active?
|
|
412
451
|
|
|
413
|
-
if @selection.
|
|
414
|
-
|
|
415
|
-
.map { |row| row[:id].to_s }
|
|
416
|
-
alternative_ids = @index.alternatives.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) }
|
|
417
|
-
.map { |row| row[:alternative_id].to_s }
|
|
452
|
+
if @selection.decision_filter_active?
|
|
453
|
+
ids = selected_observation_ids
|
|
418
454
|
rows = rows.select do |row|
|
|
419
455
|
row[:observations].any? do |observation|
|
|
420
|
-
|
|
421
|
-
|
|
456
|
+
ids[:conditions].key?(observation[:condition_id].to_s) ||
|
|
457
|
+
ids[:alternatives].key?(observation[:alternative_id].to_s)
|
|
422
458
|
end
|
|
423
459
|
end
|
|
424
460
|
end
|
|
@@ -430,15 +466,38 @@ module Branchproof
|
|
|
430
466
|
end
|
|
431
467
|
|
|
432
468
|
def test_observations(row, missing_ids, missing_alternative_ids)
|
|
433
|
-
|
|
469
|
+
observations = row[:observations]
|
|
470
|
+
if @selection.decision_ids
|
|
471
|
+
ids = selected_observation_ids
|
|
472
|
+
observations = observations.select do |observation|
|
|
473
|
+
ids[:conditions].key?(observation[:condition_id].to_s) ||
|
|
474
|
+
ids[:alternatives].key?(observation[:alternative_id].to_s)
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
return observations unless @missing_only
|
|
434
478
|
|
|
435
|
-
|
|
479
|
+
observations.select do |observation|
|
|
436
480
|
missing_ids.include?(observation[:condition_id]) ||
|
|
437
481
|
missing_alternative_ids.include?(observation[:alternative_id])
|
|
438
482
|
end
|
|
439
483
|
end
|
|
440
484
|
|
|
485
|
+
def selected_observation_ids
|
|
486
|
+
@selected_observation_ids ||= begin
|
|
487
|
+
conditions = {}
|
|
488
|
+
alternatives = {}
|
|
489
|
+
@index.conditions.each do |row|
|
|
490
|
+
conditions[row[:id].to_s] = true if @matching_decision_id_set.key?(row[:decision_id].to_s)
|
|
491
|
+
end
|
|
492
|
+
@index.alternatives.each do |row|
|
|
493
|
+
alternatives[row[:alternative_id].to_s] = true if @matching_decision_id_set.key?(row[:decision_id].to_s)
|
|
494
|
+
end
|
|
495
|
+
{ conditions: conditions, alternatives: alternatives }
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
441
499
|
def location(path, line, unavailable: "location unavailable")
|
|
500
|
+
path = @coordinator.display_scope_path(path) if @selection.decision_ids
|
|
442
501
|
return "location unavailable" if path.to_s.empty?
|
|
443
502
|
return "#{path}: #{unavailable}" if line.nil?
|
|
444
503
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Reads the tracked working-tree delta from a validated commit to the current
|
|
8
|
+
# index and worktree. Untracked files are intentionally outside this scope.
|
|
9
|
+
# rubocop:disable-next Metrics/ClassLength -- Git capture phases share one validated process context.
|
|
10
|
+
class GitChanges
|
|
11
|
+
def initialize(root:, ref:)
|
|
12
|
+
raise ArgumentError, "root must be an absolute path" unless root.is_a?(String) && Pathname.new(root).absolute?
|
|
13
|
+
raise ArgumentError, "ref must be a non-empty String" unless ref.is_a?(String) && !ref.empty?
|
|
14
|
+
|
|
15
|
+
@root = File.realpath(root)
|
|
16
|
+
@ref = ref
|
|
17
|
+
rescue SystemCallError => e
|
|
18
|
+
raise ArgumentError, "project root is unavailable: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength -- Capture order is security-sensitive.
|
|
22
|
+
def call(include_patch_lines: false)
|
|
23
|
+
git_root = git_text("rev-parse", "--show-toplevel").strip
|
|
24
|
+
git_root = File.realpath(git_root)
|
|
25
|
+
project_prefix = relative_prefix(git_root, @root)
|
|
26
|
+
base = git_text("rev-parse", "--verify", "--end-of-options", "#{@ref}^{commit}").strip
|
|
27
|
+
unless base.match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/i)
|
|
28
|
+
raise ArgumentError, "Git did not resolve ref #{@ref.inspect} to a commit"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
reject_unmerged_index!
|
|
32
|
+
|
|
33
|
+
files = name_status(base).filter_map do |entry|
|
|
34
|
+
project_entry(entry, git_root, project_prefix, base, include_patch_lines)
|
|
35
|
+
end
|
|
36
|
+
unless include_patch_lines
|
|
37
|
+
files = files.map do |file|
|
|
38
|
+
file.merge(hunks: file[:hunks].map { |hunk| hunk.slice(:old_start, :old_count, :new_start, :new_count) })
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
Records.build(version: "1.0", requested_ref: @ref, base_commit: base,
|
|
42
|
+
comparison: "tracked_worktree", untracked: "excluded",
|
|
43
|
+
status: files.empty? ? "empty" : "complete", files: files)
|
|
44
|
+
rescue ArgumentError
|
|
45
|
+
raise
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
raise ArgumentError, "cannot compare Git changes: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def git_text(*arguments)
|
|
53
|
+
output, status = Open3.capture2e("git", *arguments, chdir: @root)
|
|
54
|
+
return output if status.success?
|
|
55
|
+
|
|
56
|
+
detail = output.strip
|
|
57
|
+
if arguments.first == "rev-parse" && arguments.include?("--show-toplevel")
|
|
58
|
+
raise git_error("project root is not inside a Git repository", detail)
|
|
59
|
+
end
|
|
60
|
+
if arguments.first == "rev-parse"
|
|
61
|
+
raise git_error("invalid or unresolved Git commit reference #{@ref.inspect}", detail)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
raise git_error("Git command failed", detail)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def git_error(message, detail)
|
|
68
|
+
ArgumentError.new(detail.empty? ? message : "#{message}: #{detail}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def relative_prefix(git_root, project_root)
|
|
72
|
+
prefix = Pathname.new(project_root).relative_path_from(Pathname.new(git_root)).to_s
|
|
73
|
+
return "" if prefix == "."
|
|
74
|
+
return prefix if prefix != ".." && !prefix.start_with?("../")
|
|
75
|
+
|
|
76
|
+
raise ArgumentError, "project root must be inside its Git repository"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def reject_unmerged_index!
|
|
80
|
+
output, status = Open3.capture2("git", "ls-files", "-u", "-z", chdir: @root)
|
|
81
|
+
raise ArgumentError, "cannot inspect Git index for merge conflicts" unless status.success?
|
|
82
|
+
return if output.empty?
|
|
83
|
+
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"cannot compare changes while the Git index has unresolved merge entries"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Parses Git's NUL-delimited rename protocol.
|
|
89
|
+
def name_status(base)
|
|
90
|
+
output = git_text("diff", "--no-ext-diff", "--no-textconv", "--no-color", "--name-status", "-z",
|
|
91
|
+
"--find-renames", base, "--")
|
|
92
|
+
fields = output.split("\0", -1)
|
|
93
|
+
entries = []
|
|
94
|
+
index = 0
|
|
95
|
+
while index < fields.length - 1
|
|
96
|
+
status = fields[index]
|
|
97
|
+
index += 1
|
|
98
|
+
if status.start_with?("R", "C")
|
|
99
|
+
old_path, new_path = fields[index, 2]
|
|
100
|
+
index += 2
|
|
101
|
+
entries << { status: status, old_path: safe_path(old_path), path: safe_path(new_path) }
|
|
102
|
+
else
|
|
103
|
+
path = fields[index]
|
|
104
|
+
index += 1
|
|
105
|
+
entries << { status: status, path: safe_path(path) }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
entries
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def safe_path(path)
|
|
112
|
+
path = path.dup.force_encoding(Encoding::UTF_8)
|
|
113
|
+
raise ArgumentError, "Git reported a path that is not valid UTF-8" unless path.valid_encoding?
|
|
114
|
+
raise ArgumentError, "Git reported an unsafe path" if path.empty? || path.include?("\0") || path.start_with?("/")
|
|
115
|
+
|
|
116
|
+
segments = path.split("/")
|
|
117
|
+
raise ArgumentError, "Git reported an unsafe path" if segments.include?("..") || segments.include?(".")
|
|
118
|
+
|
|
119
|
+
path
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Handles project-boundary rename cases.
|
|
123
|
+
def project_entry(entry, git_root, project_prefix, base, include_patch_lines)
|
|
124
|
+
current_git_path = entry[:path]
|
|
125
|
+
old_git_path = entry[:old_path]
|
|
126
|
+
current_relative = within_project(current_git_path, project_prefix)
|
|
127
|
+
old_relative = old_git_path && within_project(old_git_path, project_prefix)
|
|
128
|
+
return if current_relative.nil? && old_relative.nil?
|
|
129
|
+
|
|
130
|
+
# A rename crossing the project boundary is represented only when one
|
|
131
|
+
# side belongs to this project; current paths remain the selection key.
|
|
132
|
+
path = current_relative || old_relative
|
|
133
|
+
old_path = old_relative if old_git_path && current_relative && old_relative != current_relative
|
|
134
|
+
diff_path = current_git_path || old_git_path
|
|
135
|
+
hunks = patch_hunks(base, diff_path, git_root)
|
|
136
|
+
base_path = old_git_path || current_git_path unless entry[:status] == "A"
|
|
137
|
+
old_bytes = git_blob(base, base_path, git_root) if include_patch_lines && base_path
|
|
138
|
+
status = if entry[:status].start_with?("R", "C") && current_relative.nil?
|
|
139
|
+
"D"
|
|
140
|
+
elsif entry[:status].start_with?("R", "C") && old_relative.nil?
|
|
141
|
+
"A"
|
|
142
|
+
else
|
|
143
|
+
entry[:status]
|
|
144
|
+
end
|
|
145
|
+
{ status: status, path: path, old_path: old_path, hunks: hunks, old_bytes: old_bytes }.compact
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def within_project(path, prefix)
|
|
149
|
+
return path if prefix.empty?
|
|
150
|
+
return if path == prefix
|
|
151
|
+
return path.delete_prefix("#{prefix}/") if path.start_with?("#{prefix}/")
|
|
152
|
+
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Collects patch boundaries and line context together.
|
|
157
|
+
def patch_hunks(base, path, git_root)
|
|
158
|
+
literal_path = ":(literal)#{path}"
|
|
159
|
+
output, status = Open3.capture2("git", "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--unified=0",
|
|
160
|
+
base, "--", literal_path, chdir: git_root)
|
|
161
|
+
raise ArgumentError, "cannot read Git patch for #{path.inspect}" unless status.success?
|
|
162
|
+
|
|
163
|
+
hunks = []
|
|
164
|
+
current = nil
|
|
165
|
+
output.each_line do |line|
|
|
166
|
+
match = line.match(/\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
|
|
167
|
+
if match
|
|
168
|
+
current = { old_start: match[1].to_i, old_count: (match[2] || "1").to_i,
|
|
169
|
+
new_start: match[3].to_i, new_count: (match[4] || "1").to_i,
|
|
170
|
+
old_lines: [], new_lines: [] }
|
|
171
|
+
hunks << current
|
|
172
|
+
elsif current && line.start_with?("-") && !line.start_with?("---")
|
|
173
|
+
current[:old_lines] << line.delete_prefix("-").delete_suffix("\n")
|
|
174
|
+
elsif current && line.start_with?("+") && !line.start_with?("+++")
|
|
175
|
+
current[:new_lines] << line.delete_prefix("+").delete_suffix("\n")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
hunks
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def git_blob(base, path, git_root)
|
|
182
|
+
output, status = Open3.capture2("git", "cat-file", "blob", "#{base}:#{path}", chdir: git_root)
|
|
183
|
+
raise ArgumentError, "cannot read base content for #{path.inspect}" unless status.success?
|
|
184
|
+
|
|
185
|
+
output
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|