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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +440 -29
  4. data/doc/Branchproof/CLI.md +1 -1
  5. data/doc/Branchproof/ChangedCoverage.md +19 -0
  6. data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
  7. data/doc/Branchproof/ChangedDecisionMap.md +20 -0
  8. data/doc/Branchproof/ChangedScope.md +18 -0
  9. data/doc/Branchproof/Collation.md +23 -0
  10. data/doc/Branchproof/CollationManifest.md +29 -0
  11. data/doc/Branchproof/CoverageSummary.md +20 -0
  12. data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
  13. data/doc/Branchproof/Doctor.md +34 -0
  14. data/doc/Branchproof/FallbackInstrumentation.md +8 -0
  15. data/doc/Branchproof/FallbackRuntime.md +12 -0
  16. data/doc/Branchproof/FallbackSyntax.md +13 -0
  17. data/doc/Branchproof/GitChanges.md +19 -0
  18. data/doc/Branchproof/GithubReport.md +25 -0
  19. data/doc/Branchproof/HtmlReport.md +22 -0
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/MinitestAdapter.md +4 -0
  22. data/doc/Branchproof/RakeTask.md +121 -0
  23. data/doc/Branchproof/Report.md +41 -3
  24. data/doc/Branchproof/ReportSelection.md +10 -1
  25. data/doc/Branchproof/Runtime.md +4 -1
  26. data/doc/Branchproof/Source.md +6 -1
  27. data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
  28. data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
  29. data/doc/Branchproof/SummaryRanking.md +36 -0
  30. data/doc/Branchproof/SummaryReport.md +42 -0
  31. data/doc/Branchproof/Worker.md +1 -1
  32. data/doc/Branchproof.md +21 -1
  33. data/doc/CHANGELOG.md +89 -0
  34. data/doc/README.md +440 -29
  35. data/lib/branchproof/analyzer.rb +2 -52
  36. data/lib/branchproof/changed_coverage.rb +177 -0
  37. data/lib/branchproof/changed_decision_map.rb +359 -0
  38. data/lib/branchproof/changed_scope.rb +93 -0
  39. data/lib/branchproof/cli.rb +242 -21
  40. data/lib/branchproof/collation.rb +525 -0
  41. data/lib/branchproof/collation_manifest.rb +129 -0
  42. data/lib/branchproof/comparison.rb +11 -3
  43. data/lib/branchproof/comparison_report.rb +2 -0
  44. data/lib/branchproof/configuration.rb +12 -2
  45. data/lib/branchproof/coverage_policy.rb +122 -0
  46. data/lib/branchproof/coverage_summary.rb +94 -0
  47. data/lib/branchproof/doctor.rb +241 -0
  48. data/lib/branchproof/evidence.rb +5 -1
  49. data/lib/branchproof/exception_instrumentation.rb +4 -3
  50. data/lib/branchproof/exception_syntax.rb +15 -1
  51. data/lib/branchproof/fallback_instrumentation.rb +25 -0
  52. data/lib/branchproof/fallback_runtime.rb +14 -0
  53. data/lib/branchproof/fallback_syntax.rb +90 -0
  54. data/lib/branchproof/focused_report.rb +81 -22
  55. data/lib/branchproof/git_changes.rb +188 -0
  56. data/lib/branchproof/github_report.rb +292 -0
  57. data/lib/branchproof/html_report.rb +412 -0
  58. data/lib/branchproof/instrumenter.rb +2 -0
  59. data/lib/branchproof/minimizer.rb +4 -2
  60. data/lib/branchproof/minitest_adapter.rb +45 -5
  61. data/lib/branchproof/rails_support.rb +2 -2
  62. data/lib/branchproof/rake_task.rb +186 -0
  63. data/lib/branchproof/report.rb +383 -71
  64. data/lib/branchproof/report_selection.rb +26 -5
  65. data/lib/branchproof/runtime.rb +2 -0
  66. data/lib/branchproof/saved_report.rb +323 -5
  67. data/lib/branchproof/source.rb +25 -4
  68. data/lib/branchproof/summary_ranking.rb +165 -0
  69. data/lib/branchproof/summary_report.rb +241 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +14 -9
  72. data/lib/branchproof.rb +12 -0
  73. data/llms.txt +21 -1
  74. data/sig/branchproof.rbs +206 -5
  75. 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 - [:decisions]).include?(@view)
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.focus_active? ? @selection.matching_decision_ids(@document) : nil
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
- lines << "Diagnostic: #{@coordinator.diagnostic_message(diagnostic)}"
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
- lines << " Supporting test set: #{row[:supporting_set].map { |id| test_label(id) }.join(", ")}"
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 = @index.decision_tables.sum { |row| row[:impossible_rules].to_i }
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: no matching decisions for #{@selection.focus_label}"
367
+ "Focus: #{no_match} for #{focus_label}"
345
368
  else
346
- "Focus: #{@selection.focus_label}"
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
- rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
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
- rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
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
- rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
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.focus_active?
414
- condition_ids = @index.conditions.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) }
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
- condition_ids.include?(observation[:condition_id].to_s) ||
421
- alternative_ids.include?(observation[:alternative_id].to_s)
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
- return row[:observations] unless @missing_only
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
- row[:observations].select do |observation|
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