carson 3.27.1 → 3.29.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.
@@ -18,12 +18,12 @@ module Carson
18
18
  def govern_cycle!( dry_run:, json_output: )
19
19
  repositories = governed_repo_paths
20
20
  repositories = [ repository_record.path ] if repositories.empty?
21
- print_header "Governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
21
+ print_header "Governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}" unless json_output
22
22
 
23
23
  report = {
24
24
  cycle_at: Time.now.utc.iso8601,
25
25
  dry_run: dry_run,
26
- repositories: repositories.map { |path| govern_repo!( repo_path: path, dry_run: dry_run ) }
26
+ repositories: repositories.map { |path| govern_repo!( repo_path: path, dry_run: dry_run, silent: json_output ) }
27
27
  }
28
28
 
29
29
  if json_output
@@ -39,17 +39,17 @@ module Carson
39
39
  end
40
40
 
41
41
  def govern_loop!( dry_run:, json_output:, loop_seconds: )
42
- cycle_count = 0
43
- loop do
44
- cycle_count += 1
45
- puts_line ""
46
- puts_line "cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}"
42
+ run_signal_aware_loop!(
43
+ loop_name: "govern",
44
+ loop_seconds: loop_seconds,
45
+ cycle_line: ->( cycle_count ) { "cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}" },
46
+ sleep_line: ->( seconds ) do
47
+ next_at = Time.now + seconds
48
+ "sleeping #{seconds}s — next cycle at #{next_at.strftime( '%Y-%m-%d %H:%M:%S %z' )}"
49
+ end
50
+ ) do
47
51
  govern_cycle!( dry_run: dry_run, json_output: json_output )
48
- sleep loop_seconds
49
52
  end
50
- rescue Interrupt
51
- puts_line "govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
52
- EXIT_OK
53
53
  end
54
54
 
55
55
  private
@@ -62,7 +62,7 @@ module Carson
62
62
  end.compact
63
63
  end
64
64
 
65
- def govern_repo!( repo_path:, dry_run: )
65
+ def govern_repo!( repo_path:, dry_run:, silent: false )
66
66
  scoped_runtime = repo_runtime_for( repo_path: repo_path )
67
67
  repository = Repository.new( path: repo_path, runtime: scoped_runtime )
68
68
  deliveries = scoped_runtime.ledger.active_deliveries( repo_path: repo_path )
@@ -75,16 +75,18 @@ module Carson
75
75
  }
76
76
 
77
77
  if deliveries.empty?
78
- puts_line "#{repository.name}: no active deliveries"
78
+ puts_line "#{repository.name}: no active deliveries" unless silent
79
79
  return repo_report
80
80
  end
81
81
 
82
- puts_line "#{repository.name}: #{deliveries.length} active deliver#{deliveries.length == 1 ? 'y' : 'ies'}"
82
+ puts_line "#{repository.name}: #{deliveries.length} active deliver#{deliveries.length == 1 ? 'y' : 'ies'}" unless silent
83
83
 
84
84
  reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
85
85
  next_to_integrate = reconciled.find( &:ready? )&.key
86
86
 
87
87
  reconciled.each do |delivery|
88
+ hint = delivery_action_hint( delivery: delivery, next_to_integrate: next_to_integrate, dry_run: dry_run )
89
+ puts_line " #{delivery.branch} — #{hint}" if hint && !silent
88
90
  delivery_report = scoped_runtime.send(
89
91
  :decide_delivery_action,
90
92
  delivery: delivery,
@@ -122,7 +124,10 @@ module Carson
122
124
  delivery: delivery,
123
125
  status: "integrated",
124
126
  integrated_at: Time.now.utc.iso8601,
125
- summary: "integrated into #{config.main_branch}"
127
+ summary: "integrated into #{config.main_branch}",
128
+ pull_request_state: "MERGED",
129
+ pull_request_draft: false,
130
+ pull_request_merged_at: pr_state[ "mergedAt" ]
126
131
  )
127
132
  end
128
133
 
@@ -131,47 +136,67 @@ module Carson
131
136
  delivery: delivery,
132
137
  status: "failed",
133
138
  cause: "policy",
134
- summary: "pull request closed without integration"
139
+ summary: "pull request closed without integration",
140
+ pull_request_state: "CLOSED",
141
+ pull_request_draft: pr_state[ "isDraft" ],
142
+ pull_request_merged_at: pr_state[ "mergedAt" ]
135
143
  )
136
144
  end
137
145
 
138
146
  assess_delivery!( delivery: delivery, branch_name: delivery.branch )
139
147
  end
140
148
 
141
- def decide_delivery_action( delivery:, repo_path:, dry_run:, next_to_integrate: )
142
- report = {
143
- key: delivery.key,
144
- branch: delivery.branch,
145
- status: delivery.status,
146
- summary: delivery.summary,
147
- revision_count: delivery.revision_count,
148
- action: "none"
149
- }
149
+ def decide_delivery_action( delivery:, repo_path:, dry_run:, next_to_integrate: )
150
+ report = {
151
+ key: delivery.key,
152
+ branch: delivery.branch,
153
+ status: delivery.status,
154
+ cause: delivery.cause,
155
+ summary: delivery.summary,
156
+ revision_count: delivery.revision_count,
157
+ action: "none"
158
+ }
150
159
 
151
160
  if delivery.superseded? || delivery.integrated? || delivery.failed?
152
161
  return report
153
162
  end
154
163
 
155
- if delivery.ready? && delivery.key == next_to_integrate
156
- report[ :action ] = dry_run ? "would_integrate" : "integrate"
157
- report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
158
- return report
159
- end
160
-
161
- if delivery.blocked?
162
- if merge_blocked_delivery?( delivery: delivery )
163
- report[ :action ] = dry_run ? "would_hold" : "hold"
164
+ if delivery.ready? && delivery.key == next_to_integrate
165
+ report[ :action ] = dry_run ? "would_integrate" : "integrate"
166
+ unless dry_run
167
+ updated = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run )
168
+ report[ :status ] = updated.status
169
+ report[ :cause ] = updated.cause
170
+ report[ :summary ] = updated.summary
171
+ report[ :merge_proof ] = merge_proof_payload( proof: updated.merge_proof ) if updated.integrated? && updated.merge_proof
172
+ end
164
173
  return report
165
174
  end
166
175
 
167
- if delivery.revision_count >= 3
168
- report[ :action ] = dry_run ? "would_escalate" : "escalate"
169
- report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
170
- else
171
- report[ :action ] = dry_run ? "would_revise" : "revise"
172
- report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
176
+ if delivery.blocked?
177
+ if held_delivery?( delivery: delivery )
178
+ report[ :action ] = dry_run ? "would_hold" : "hold"
179
+ return report
180
+ end
181
+
182
+ if delivery.revision_count >= 3
183
+ report[ :action ] = dry_run ? "would_escalate" : "escalate"
184
+ unless dry_run
185
+ updated = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run )
186
+ report[ :status ] = updated.status
187
+ report[ :cause ] = updated.cause
188
+ report[ :summary ] = updated.summary
189
+ end
190
+ else
191
+ report[ :action ] = dry_run ? "would_revise" : "revise"
192
+ unless dry_run
193
+ updated = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run )
194
+ report[ :status ] = updated.status
195
+ report[ :cause ] = updated.cause
196
+ report[ :summary ] = updated.summary
197
+ end
198
+ end
173
199
  end
174
- end
175
200
 
176
201
  report
177
202
  end
@@ -191,23 +216,46 @@ module Carson
191
216
  end
192
217
  end
193
218
 
194
- def integrate_delivery!( delivery:, repo_path: )
195
- result = {}
196
- prepared = ledger.update_delivery(
197
- delivery: delivery,
198
- status: "integrating",
199
- summary: "integrating into #{config.main_branch}"
200
- )
219
+ def integrate_delivery!( delivery:, repo_path: )
220
+ result = {}
221
+ freshness = assess_branch_freshness(
222
+ head_ref: delivery.head || delivery.branch,
223
+ remote: config.git_remote,
224
+ main: config.main_branch
225
+ )
226
+ unless freshness.fetch( :ready )
227
+ return ledger.update_delivery(
228
+ delivery: delivery,
229
+ status: "gated",
230
+ cause: "freshness",
231
+ summary: freshness.fetch( :summary )
232
+ )
233
+ end
234
+
235
+ prepared = ledger.update_delivery(
236
+ delivery: delivery,
237
+ status: "integrating",
238
+ summary: "integrating into #{config.main_branch}"
239
+ )
201
240
  merge_exit = merge_pr!( number: prepared.pull_request_number, result: result )
202
241
  if merge_exit == EXIT_OK
203
242
  integrated = ledger.update_delivery(
204
243
  delivery: prepared,
205
244
  status: "integrated",
206
245
  integrated_at: Time.now.utc.iso8601,
207
- summary: "integrated into #{config.main_branch}"
246
+ summary: "integrated into #{config.main_branch}",
247
+ pull_request_state: "MERGED",
248
+ pull_request_draft: false,
249
+ pull_request_merged_at: Time.now.utc.iso8601
250
+ )
251
+ # Fetch-only: update the remote tracking ref without mutating the
252
+ # main worktree. Reap and prune are deferred to explicit housekeep.
253
+ fetch_for_merge_proof!( repo_path: repo_path )
254
+ proof = merge_proof_for_remote_ref( branch: integrated.branch )
255
+ ledger.update_delivery(
256
+ delivery: integrated,
257
+ merge_proof: proof
208
258
  )
209
- housekeep_repo!( repo_path: repo_path )
210
- integrated
211
259
  else
212
260
  ledger.update_delivery(
213
261
  delivery: prepared,
@@ -223,6 +271,27 @@ module Carson
223
271
  return escalate_delivery!( delivery: delivery, reason: "no agent provider available" ) if provider.nil?
224
272
  return escalate_delivery!( delivery: delivery, reason: "worktree missing for revision" ) unless File.directory?( delivery.worktree_path.to_s )
225
273
 
274
+ # Defer if the target worktree is occupied — temporary hold, not failure.
275
+ worktree = Carson::Worktree.find( path: delivery.worktree_path.to_s, runtime: self )
276
+ if worktree
277
+ if worktree.held_by_other_process?
278
+ return ledger.update_delivery(
279
+ delivery: delivery,
280
+ status: "gated",
281
+ cause: "busy",
282
+ summary: "worktree held by another process — deferring revision"
283
+ )
284
+ end
285
+ if worktree.dirty?
286
+ return ledger.update_delivery(
287
+ delivery: delivery,
288
+ status: "gated",
289
+ cause: "busy",
290
+ summary: "worktree has uncommitted changes — deferring revision"
291
+ )
292
+ end
293
+ end
294
+
226
295
  objective = revision_objective( cause: delivery.cause )
227
296
  context = evidence( delivery: delivery, repo_path: repo_path, objective: objective )
228
297
  work_order = Adapters::Agent::WorkOrder.new(
@@ -288,15 +357,33 @@ module Carson
288
357
  end
289
358
  end
290
359
 
291
- def merge_blocked_delivery?( delivery: )
292
- delivery.cause == "merge"
293
- end
360
+ def held_delivery?( delivery: )
361
+ [ "merge", "freshness", "busy" ].include?( delivery.cause )
362
+ end
363
+
364
+ def delivery_action_hint( delivery:, next_to_integrate:, dry_run: )
365
+ return nil if dry_run
366
+ return nil if delivery.superseded? || delivery.integrated? || delivery.failed?
367
+ return "integrating…" if delivery.ready? && delivery.key == next_to_integrate
368
+ return nil unless delivery.blocked?
369
+ return nil if held_delivery?( delivery: delivery )
370
+ delivery.revision_count >= 3 ? "escalating…" : "revising…"
371
+ end
294
372
 
295
373
  def housekeep_repo!( repo_path: )
296
374
  scoped_runtime = repo_runtime_for( repo_path: repo_path )
297
375
  scoped_runtime.send( :housekeep_one_entry, repo_path: repo_path, silent: true )
298
376
  end
299
377
 
378
+ # Fetch-only helper for post-merge proof generation.
379
+ # Updates the remote tracking ref without mutating the main worktree.
380
+ def fetch_for_merge_proof!( repo_path: )
381
+ scoped = repo_runtime_for( repo_path: repo_path )
382
+ scoped.send( :git_run, "fetch", scoped.config.git_remote, "--prune" )
383
+ rescue StandardError
384
+ # Best-effort — merge proof falls back to unavailable if fetch fails.
385
+ end
386
+
300
387
  def select_agent_provider
301
388
  provider = config.govern_agent_provider
302
389
  case provider
@@ -435,36 +522,37 @@ module Carson
435
522
 
436
523
  next if repo_report[ :deliveries ].empty?
437
524
 
438
- repo_report[ :deliveries ].each do |delivery|
439
- action_text = format_govern_action( status: delivery[ :status ], action: delivery[ :action ] )
440
- puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]} — #{action_text}"
441
- puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
525
+ repo_report[ :deliveries ].each do |delivery|
526
+ action_text = format_govern_action( status: delivery[ :status ], action: delivery[ :action ], cause: delivery[ :cause ] )
527
+ puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]} — #{action_text}"
528
+ puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
529
+ puts_line " Merge proof: #{delivery.dig( :merge_proof, :summary )}" if delivery[ :merge_proof ]
530
+ end
442
531
  end
443
532
  end
444
- end
445
533
 
446
- def format_govern_action( status:, action: )
447
- case action
448
- when "integrate"
449
- format_govern_integration_outcome( status: status )
450
- when "would_integrate" then "ready to integrate (dry run)"
451
- when "hold" then "held at gate"
452
- when "would_hold" then "would hold at gate (dry run)"
453
- when "revise" then "revision dispatched"
454
- when "would_revise" then "would revise (dry run)"
534
+ def format_govern_action( status:, action:, cause: )
535
+ case action
536
+ when "integrate"
537
+ format_govern_integration_outcome( status: status, cause: cause )
538
+ when "would_integrate" then "ready to integrate (dry run)"
539
+ when "hold" then cause == "freshness" ? "refresh required" : "held at gate"
540
+ when "would_hold" then cause == "freshness" ? "would require refresh (dry run)" : "would hold at gate (dry run)"
541
+ when "revise" then "revision dispatched"
542
+ when "would_revise" then "would revise (dry run)"
455
543
  when "escalate" then "escalated"
456
544
  when "would_escalate" then "would escalate (dry run)"
457
545
  else status
458
546
  end
459
547
  end
460
548
 
461
- def format_govern_integration_outcome( status: )
462
- case status
463
- when "integrated" then "integrated"
464
- when "gated" then "held at gate"
465
- when "failed" then "integration failed"
466
- when "escalated" then "integration escalated"
467
- else status
549
+ def format_govern_integration_outcome( status:, cause: )
550
+ case status
551
+ when "integrated" then "integrated"
552
+ when "gated" then cause == "freshness" ? "refresh required" : "held at gate"
553
+ when "failed" then "integration failed"
554
+ when "escalated" then "integration escalated"
555
+ else status
468
556
  end
469
557
  end
470
558
  end
@@ -81,17 +81,13 @@ module Carson
81
81
  end
82
82
 
83
83
  def housekeep_loop!( json_output:, dry_run:, loop_seconds: )
84
- cycle_count = 0
85
- loop do
86
- cycle_count += 1
87
- puts_line ""
88
- puts_line "housekeep cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}"
84
+ run_signal_aware_loop!(
85
+ loop_name: "housekeep",
86
+ loop_seconds: loop_seconds,
87
+ cycle_line: ->( cycle_count ) { "housekeep cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}" }
88
+ ) do
89
89
  housekeep_all!( json_output: json_output, dry_run: dry_run )
90
- sleep loop_seconds
91
90
  end
92
- rescue Interrupt
93
- puts_line "housekeep loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
94
- EXIT_OK
95
91
  end
96
92
 
97
93
  # Prints a dry-run plan for this repo without making any changes.
@@ -0,0 +1,217 @@
1
+ # Shared local merge-proof detection for commands that need content-aware "already on main" evidence.
2
+ module Carson
3
+ class Runtime
4
+ module Local
5
+ # Generates merge proof against the remote tracking ref directly.
6
+ # Skips the local-main trust check — the caller is responsible for
7
+ # fetching before calling. Used by govern's post-merge path to avoid
8
+ # mutating the main worktree.
9
+ def merge_proof_for_remote_ref( branch:, remote: config.git_remote, main_ref: config.main_branch )
10
+ remote_ref = "#{remote}/#{main_ref}"
11
+ return merge_proof_not_applicable( main_ref: main_ref ) if branch.to_s == main_ref.to_s
12
+
13
+ candidate = merge_proof_candidate( branch: branch, main_ref: remote_ref )
14
+ return candidate if candidate.fetch( :basis ) == "unavailable"
15
+
16
+ # Normalise display: show the local branch name, not the remote tracking ref.
17
+ candidate.merge(
18
+ main_branch: main_ref,
19
+ summary: candidate.fetch( :summary ).gsub( remote_ref, main_ref )
20
+ )
21
+ end
22
+
23
+ def merge_proof_for_branch( branch:, main_ref: config.main_branch )
24
+ return merge_proof_not_applicable( main_ref: main_ref ) if branch.to_s == main_ref.to_s
25
+
26
+ candidate = merge_proof_candidate( branch: branch, main_ref: main_ref )
27
+ return candidate if candidate.fetch( :basis ) == "unavailable"
28
+
29
+ trust = merge_proof_main_trust( main_ref: main_ref )
30
+ return candidate if trust.fetch( :trusted )
31
+
32
+ merge_proof_hash(
33
+ applicable: true,
34
+ proven: false,
35
+ basis: "unavailable",
36
+ summary: trust.fetch( :summary ),
37
+ main_branch: main_ref,
38
+ changed_files_count: candidate.fetch( :changed_files_count, 0 )
39
+ )
40
+ end
41
+
42
+ def branch_absorbed_into_main?( branch: )
43
+ merge_proof_for_branch( branch: branch ).fetch( :proven )
44
+ end
45
+
46
+ private
47
+
48
+ def merge_proof_candidate( branch:, main_ref: )
49
+ _, _, ancestor_success, ancestor_exit = git_run( "merge-base", "--is-ancestor", branch, main_ref )
50
+ merge_base_text, merge_base_error, merge_base_success, = git_run( "merge-base", main_ref, branch )
51
+ unless merge_base_success
52
+ return merge_proof_unavailable(
53
+ main_ref: main_ref,
54
+ summary: merge_proof_command_failure_summary(
55
+ remote_ref: merge_proof_remote_ref( main_ref: main_ref ),
56
+ fallback: "proof unavailable — could not determine merge-base with #{main_ref}.",
57
+ error_text: merge_base_error
58
+ )
59
+ )
60
+ end
61
+
62
+ merge_base = merge_base_text.to_s.strip
63
+ return merge_proof_unavailable( main_ref: main_ref, summary: "proof unavailable — merge-base with #{main_ref} is empty." ) if merge_base.empty?
64
+
65
+ changed_text, changed_error, changed_success, = git_run( "diff", "--name-only", merge_base, branch )
66
+ unless changed_success
67
+ return merge_proof_unavailable(
68
+ main_ref: main_ref,
69
+ summary: merge_proof_command_failure_summary(
70
+ remote_ref: merge_proof_remote_ref( main_ref: main_ref ),
71
+ fallback: "proof unavailable — could not list branch changes against #{main_ref}.",
72
+ error_text: changed_error
73
+ )
74
+ )
75
+ end
76
+
77
+ changed_files = changed_text.to_s.lines.map( &:strip ).reject( &:empty? )
78
+ changed_files_count = changed_files.length
79
+
80
+ if ancestor_success
81
+ return merge_proof_hash(
82
+ applicable: true,
83
+ proven: true,
84
+ basis: "ancestor",
85
+ summary: "proven on main — branch tip is already on #{main_ref}.",
86
+ main_branch: main_ref,
87
+ changed_files_count: changed_files_count
88
+ )
89
+ end
90
+
91
+ if ancestor_exit != 1
92
+ return merge_proof_unavailable(
93
+ main_ref: main_ref,
94
+ summary: "proof unavailable — could not verify whether #{branch} is already on #{main_ref}.",
95
+ changed_files_count: changed_files_count
96
+ )
97
+ end
98
+
99
+ if changed_files.empty?
100
+ return merge_proof_hash(
101
+ applicable: true,
102
+ proven: true,
103
+ basis: "no_changes",
104
+ summary: "proven on main — branch has no unique changes.",
105
+ main_branch: main_ref,
106
+ changed_files_count: 0
107
+ )
108
+ end
109
+
110
+ _, _, identical, identical_exit = git_run( "diff", "--quiet", branch, main_ref, "--", *changed_files )
111
+ if identical
112
+ return merge_proof_hash(
113
+ applicable: true,
114
+ proven: true,
115
+ basis: "content_identical",
116
+ summary: "proven on main — #{changed_files_count} changed file#{plural_suffix( count: changed_files_count )} already #{merge_proof_files_verb( count: changed_files_count, singular: 'matches', plural: 'match' )} #{main_ref}.",
117
+ main_branch: main_ref,
118
+ changed_files_count: changed_files_count
119
+ )
120
+ end
121
+
122
+ if identical_exit == 1
123
+ return merge_proof_hash(
124
+ applicable: true,
125
+ proven: false,
126
+ basis: "content_differs",
127
+ summary: "not proven on main — #{changed_files_count} changed file#{plural_suffix( count: changed_files_count )} still #{merge_proof_files_verb( count: changed_files_count, singular: 'differs', plural: 'differ' )} from #{main_ref}.",
128
+ main_branch: main_ref,
129
+ changed_files_count: changed_files_count
130
+ )
131
+ end
132
+
133
+ merge_proof_unavailable(
134
+ main_ref: main_ref,
135
+ summary: "proof unavailable — could not compare branch content against #{main_ref}.",
136
+ changed_files_count: changed_files_count
137
+ )
138
+ end
139
+
140
+ def merge_proof_main_trust( main_ref: )
141
+ remote_ref = merge_proof_remote_ref( main_ref: main_ref )
142
+ _, _, remote_exists, = git_run( "rev-parse", "--verify", remote_ref )
143
+ unless remote_exists
144
+ return {
145
+ trusted: false,
146
+ summary: "proof unavailable — no local #{remote_ref} reference."
147
+ }
148
+ end
149
+
150
+ ahead_behind, _, sync_success, = git_run( "rev-list", "--left-right", "--count", "#{main_ref}...#{remote_ref}" )
151
+ unless sync_success
152
+ return {
153
+ trusted: false,
154
+ summary: "proof unavailable — could not compare local #{main_ref} with #{remote_ref}."
155
+ }
156
+ end
157
+
158
+ ahead, behind = ahead_behind.to_s.strip.split.map( &:to_i )
159
+ return { trusted: true, summary: "local #{main_ref} is in sync with #{remote_ref}." } if ahead.zero? && behind.zero?
160
+
161
+ {
162
+ trusted: false,
163
+ summary: "proof unavailable — local #{main_ref} is not in sync with #{remote_ref}."
164
+ }
165
+ end
166
+
167
+ def merge_proof_remote_ref( main_ref: )
168
+ "#{config.git_remote}/#{main_ref}"
169
+ end
170
+
171
+ def merge_proof_not_applicable( main_ref: )
172
+ merge_proof_hash(
173
+ applicable: false,
174
+ proven: false,
175
+ basis: "not_applicable",
176
+ summary: "not applicable — current branch is #{main_ref}.",
177
+ main_branch: main_ref,
178
+ changed_files_count: 0
179
+ )
180
+ end
181
+
182
+ def merge_proof_unavailable( main_ref:, summary:, changed_files_count: 0 )
183
+ merge_proof_hash(
184
+ applicable: true,
185
+ proven: false,
186
+ basis: "unavailable",
187
+ summary: summary,
188
+ main_branch: main_ref,
189
+ changed_files_count: changed_files_count
190
+ )
191
+ end
192
+
193
+ def merge_proof_hash( applicable:, proven:, basis:, summary:, main_branch:, changed_files_count: )
194
+ {
195
+ applicable: applicable,
196
+ proven: proven,
197
+ basis: basis,
198
+ summary: summary,
199
+ main_branch: main_branch,
200
+ changed_files_count: changed_files_count.to_i
201
+ }
202
+ end
203
+
204
+ def merge_proof_files_verb( count:, singular:, plural: )
205
+ count.to_i == 1 ? singular : plural
206
+ end
207
+
208
+ def merge_proof_command_failure_summary( remote_ref:, fallback:, error_text: )
209
+ text = error_text.to_s.strip
210
+ return fallback if text.empty?
211
+ return "proof unavailable — local main is not in sync with #{remote_ref}." if text.include?( remote_ref ) && text.include?( "not a valid object name" )
212
+
213
+ fallback
214
+ end
215
+ end
216
+ end
217
+ end