carson 2.24.0 → 2.26.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.
@@ -0,0 +1,356 @@
1
+ module Carson
2
+ class Runtime
3
+ module Local
4
+ # One-command onboarding for new repositories: detect remote, install hooks,
5
+ # apply templates, and run initial audit.
6
+ def onboard!
7
+ fingerprint_status = block_if_outsider_fingerprints!
8
+ return fingerprint_status unless fingerprint_status.nil?
9
+
10
+ unless inside_git_work_tree?
11
+ puts_line "ERROR: #{repo_root} is not a git repository."
12
+ return EXIT_ERROR
13
+ end
14
+
15
+ repo_name = File.basename( repo_root )
16
+ puts_line ""
17
+ puts_line "Onboarding #{repo_name}..."
18
+
19
+ if !global_config_exists? || !git_remote_exists?( remote_name: config.git_remote )
20
+ if self.in.respond_to?( :tty? ) && self.in.tty?
21
+ setup_status = setup!
22
+ return setup_status unless setup_status == EXIT_OK
23
+ else
24
+ silent_setup!
25
+ end
26
+ end
27
+
28
+ onboard_apply!
29
+ end
30
+
31
+ # Re-applies hooks, templates, and audit after upgrading Carson.
32
+ def refresh!
33
+ fingerprint_status = block_if_outsider_fingerprints!
34
+ return fingerprint_status unless fingerprint_status.nil?
35
+
36
+ unless inside_git_work_tree?
37
+ puts_line "ERROR: #{repo_root} is not a git repository."
38
+ return EXIT_ERROR
39
+ end
40
+
41
+ if verbose?
42
+ puts_verbose ""
43
+ puts_verbose "[Refresh]"
44
+ hook_status = prepare!
45
+ return hook_status unless hook_status == EXIT_OK
46
+
47
+ drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
48
+ template_status = template_apply!
49
+ return template_status unless template_status == EXIT_OK
50
+
51
+ @template_sync_result = template_propagate!( drift_count: drift_count )
52
+
53
+ audit_status = audit!
54
+ if audit_status == EXIT_OK
55
+ puts_line "OK: Carson refresh completed for #{repo_root}."
56
+ elsif audit_status == EXIT_BLOCK
57
+ puts_line "BLOCK: Carson refresh completed with policy blocks; resolve and rerun carson audit."
58
+ end
59
+ return audit_status
60
+ end
61
+
62
+ puts_line "Refresh"
63
+ hook_status = with_captured_output { prepare! }
64
+ return hook_status unless hook_status == EXIT_OK
65
+ puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
66
+
67
+ template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
68
+ template_status = with_captured_output { template_apply! }
69
+ return template_status unless template_status == EXIT_OK
70
+ if template_drift_count.positive?
71
+ puts_line "Templates applied (#{template_drift_count} updated)."
72
+ else
73
+ puts_line "Templates in sync."
74
+ end
75
+
76
+ @template_sync_result = template_propagate!( drift_count: template_drift_count )
77
+
78
+ audit_status = audit!
79
+ puts_line "Refresh complete."
80
+ audit_status
81
+ end
82
+
83
+ # Re-applies hooks, templates, and audit across all governed repositories.
84
+ def refresh_all!
85
+ repos = config.govern_repos
86
+ if repos.empty?
87
+ puts_line "No governed repositories configured."
88
+ puts_line " Run carson onboard in each repo to register."
89
+ return EXIT_ERROR
90
+ end
91
+
92
+ puts_line ""
93
+ puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
94
+ refreshed = 0
95
+ failed = 0
96
+
97
+ repos.each do |repo_path|
98
+ repo_name = File.basename( repo_path )
99
+ unless Dir.exist?( repo_path )
100
+ puts_line "#{repo_name}: FAIL (path not found)"
101
+ failed += 1
102
+ next
103
+ end
104
+
105
+ status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
106
+ if status == EXIT_ERROR
107
+ failed += 1
108
+ else
109
+ refreshed += 1
110
+ end
111
+ end
112
+
113
+ puts_line ""
114
+ puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
115
+ failed.zero? ? EXIT_OK : EXIT_ERROR
116
+ end
117
+
118
+ # Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
119
+ def offboard!
120
+ puts_verbose ""
121
+ puts_verbose "[Offboard]"
122
+ unless inside_git_work_tree?
123
+ puts_line "ERROR: #{repo_root} is not a git repository."
124
+ return EXIT_ERROR
125
+ end
126
+ if self.in.respond_to?( :tty? ) && self.in.tty?
127
+ puts_line ""
128
+ puts_line "This will remove Carson hooks, managed .github/ files,"
129
+ puts_line "and deregister this repository from portfolio governance."
130
+ puts_line "Continue?"
131
+ unless prompt_yes_no( default: false )
132
+ puts_line "Offboard cancelled."
133
+ return EXIT_OK
134
+ end
135
+ end
136
+
137
+ hooks_status = disable_carson_hooks_path!
138
+ return hooks_status unless hooks_status == EXIT_OK
139
+
140
+ removed_count = 0
141
+ missing_count = 0
142
+ offboard_cleanup_targets.each do |relative|
143
+ absolute = resolve_repo_path!( relative_path: relative, label: "offboard target #{relative}" )
144
+ if File.exist?( absolute )
145
+ FileUtils.rm_rf( absolute )
146
+ puts_verbose "removed_path: #{relative}"
147
+ removed_count += 1
148
+ else
149
+ puts_verbose "skip_missing_path: #{relative}"
150
+ missing_count += 1
151
+ end
152
+ end
153
+ remove_empty_offboard_directories!
154
+ remove_govern_repo!( repo_path: File.expand_path( repo_root ) )
155
+ puts_verbose "govern_deregistered: #{File.expand_path( repo_root )}"
156
+ puts_verbose "offboard_summary: removed=#{removed_count} missing=#{missing_count}"
157
+ if verbose?
158
+ puts_line "OK: Carson offboard completed for #{repo_root}."
159
+ else
160
+ puts_line "Removed #{removed_count} file#{plural_suffix( count: removed_count )}. Offboard complete."
161
+ end
162
+ puts_line ""
163
+ puts_line "Next: commit the removals and push to finalise offboarding."
164
+ EXIT_OK
165
+ end
166
+
167
+ private
168
+
169
+ # Concise onboard orchestration: hooks, templates, remote, audit, guidance.
170
+ def onboard_apply!
171
+ hook_status = with_captured_output { prepare! }
172
+ return hook_status unless hook_status == EXIT_OK
173
+ puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
174
+
175
+ template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
176
+ template_status = with_captured_output { template_apply! }
177
+ return template_status unless template_status == EXIT_OK
178
+ if template_drift_count.positive?
179
+ puts_line "Templates synced (#{template_drift_count} file#{plural_suffix( count: template_drift_count )} updated)."
180
+ else
181
+ puts_line "Templates in sync."
182
+ end
183
+
184
+ onboard_report_remote!
185
+ audit_status = onboard_run_audit!
186
+
187
+ puts_line ""
188
+ puts_line "Carson at your service."
189
+
190
+ if self.in.respond_to?( :tty? ) && self.in.tty?
191
+ prompt_govern_registration!
192
+ else
193
+ puts_line "To register for portfolio governance: carson onboard (in a TTY)"
194
+ end
195
+
196
+ puts_line ""
197
+ puts_line "Your repository is set up. Carson has placed files in your"
198
+ puts_line "project's .github/ directory — pull request templates,"
199
+ puts_line "guidelines for AI coding assistants, and any canonical"
200
+ puts_line "rules you've configured. Once pushed to GitHub, they'll"
201
+ puts_line "ensure every pull request follows a consistent standard"
202
+ puts_line "and all checks run automatically."
203
+ puts_line ""
204
+ puts_line "Before your first push, have a look through .github/ to"
205
+ puts_line "make sure everything is to your liking."
206
+ puts_line ""
207
+ puts_line "To adjust any setting: carson setup"
208
+
209
+ audit_status
210
+ end
211
+
212
+ # Friendly remote status for onboard output.
213
+ def onboard_report_remote!
214
+ if git_remote_exists?( remote_name: config.git_remote )
215
+ puts_line "Remote: #{config.git_remote} (connected)."
216
+ else
217
+ puts_line "Remote not configured yet — carson setup will walk you through it."
218
+ end
219
+ end
220
+
221
+ # Runs audit with captured output; reports summary instead of full detail.
222
+ def onboard_run_audit!
223
+ audit_error = nil
224
+ audit_status = with_captured_output { audit! }
225
+ rescue StandardError => e
226
+ audit_error = e
227
+ audit_status = EXIT_OK
228
+ ensure
229
+ return onboard_print_audit_result( status: audit_status, error: audit_error )
230
+ end
231
+
232
+ def onboard_print_audit_result( status:, error: )
233
+ if error
234
+ if error.message.to_s.match?( /HEAD|rev-parse/ )
235
+ puts_line "No commits yet — run carson audit after your first commit."
236
+ else
237
+ puts_line "Audit skipped — run carson audit for details."
238
+ end
239
+ return EXIT_OK
240
+ end
241
+
242
+ if status == EXIT_BLOCK
243
+ puts_line "Some checks need attention — run carson audit for details."
244
+ end
245
+ status
246
+ end
247
+
248
+ # Verifies configured remote exists and logs status without mutating remotes.
249
+ def report_detected_remote!
250
+ if git_remote_exists?( remote_name: config.git_remote )
251
+ puts_verbose "remote_ok: #{config.git_remote}"
252
+ else
253
+ puts_line "WARN: remote '#{config.git_remote}' not found; run carson setup to configure."
254
+ end
255
+ end
256
+
257
+ def refresh_sync_suffix( result: )
258
+ return "" if result.nil?
259
+
260
+ case result.fetch( :status )
261
+ when :pushed then " (templates pushed to #{result.fetch( :ref )})"
262
+ when :pr then " (PR: #{result.fetch( :pr_url )})"
263
+ else ""
264
+ end
265
+ end
266
+
267
+ # Refreshes a single governed repository using a scoped Runtime.
268
+ def refresh_single_repo( repo_path:, repo_name: )
269
+ if verbose?
270
+ rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err, verbose: true )
271
+ else
272
+ rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: StringIO.new, err: StringIO.new )
273
+ end
274
+ status = rt.refresh!
275
+ label = refresh_status_label( status: status )
276
+ sync_suffix = refresh_sync_suffix( result: rt.template_sync_result )
277
+ puts_line "#{repo_name}: #{label}#{sync_suffix}"
278
+ status
279
+ rescue StandardError => e
280
+ puts_line "#{repo_name}: FAIL (#{e.message})"
281
+ EXIT_ERROR
282
+ end
283
+
284
+ def refresh_status_label( status: )
285
+ case status
286
+ when EXIT_OK then "OK"
287
+ when EXIT_BLOCK then "BLOCK"
288
+ else "FAIL"
289
+ end
290
+ end
291
+
292
+ def disable_carson_hooks_path!
293
+ configured = configured_hooks_path
294
+ if configured.nil?
295
+ puts_verbose "hooks_path: (unset)"
296
+ return EXIT_OK
297
+ end
298
+ puts_verbose "hooks_path: #{configured}"
299
+ configured_abs = File.expand_path( configured, repo_root )
300
+ unless carson_managed_hooks_path?( configured_abs: configured_abs )
301
+ puts_verbose "hooks_path_kept: #{configured} (not Carson-managed)"
302
+ return EXIT_OK
303
+ end
304
+ git_system!( "config", "--unset", "core.hooksPath" )
305
+ puts_verbose "hooks_path_unset: core.hooksPath"
306
+ EXIT_OK
307
+ rescue StandardError => e
308
+ puts_line "ERROR: unable to update core.hooksPath (#{e.message})"
309
+ EXIT_ERROR
310
+ end
311
+
312
+ def carson_managed_hooks_path?( configured_abs: )
313
+ hooks_root = File.join( File.expand_path( config.hooks_path ), "" )
314
+ return true if configured_abs.start_with?( hooks_root )
315
+
316
+ carson_hook_files_match_templates?( hooks_path: configured_abs )
317
+ end
318
+
319
+ def carson_hook_files_match_templates?( hooks_path: )
320
+ return false unless Dir.exist?( hooks_path )
321
+ config.managed_hooks.all? do |hook_name|
322
+ installed_path = File.join( hooks_path, hook_name )
323
+ template_path = hook_template_path( hook_name: hook_name )
324
+ next false unless File.file?( installed_path ) && File.file?( template_path )
325
+
326
+ installed_content = normalize_text( text: File.read( installed_path ) )
327
+ template_content = normalize_text( text: File.read( template_path ) )
328
+ installed_content == template_content
329
+ end
330
+ rescue StandardError
331
+ false
332
+ end
333
+
334
+ def offboard_cleanup_targets
335
+ ( config.template_managed_files + SUPERSEDED + [
336
+ ".github/workflows/carson-governance.yml",
337
+ ".github/workflows/carson_policy.yml",
338
+ ".carson.yml",
339
+ "bin/carson",
340
+ ".tools/carson"
341
+ ] ).uniq
342
+ end
343
+
344
+ def remove_empty_offboard_directories!
345
+ [ ".github/workflows", ".github", ".tools", "bin" ].each do |relative|
346
+ absolute = resolve_repo_path!( relative_path: relative, label: "offboard cleanup directory #{relative}" )
347
+ next unless Dir.exist?( absolute )
348
+ next unless Dir.empty?( absolute )
349
+
350
+ Dir.rmdir( absolute )
351
+ puts_verbose "removed_empty_dir: #{relative}"
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,292 @@
1
+ module Carson
2
+ class Runtime
3
+ module Local
4
+ # Removes stale local branches (gone upstream) and orphan branches (no tracking) with merged PR evidence.
5
+ def prune!
6
+ fingerprint_status = block_if_outsider_fingerprints!
7
+ return fingerprint_status unless fingerprint_status.nil?
8
+
9
+ git_system!( "fetch", config.git_remote, "--prune" )
10
+ active_branch = current_branch
11
+ counters = { deleted: 0, skipped: 0 }
12
+
13
+ stale_branches = stale_local_branches
14
+ prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters )
15
+
16
+ orphan_branches = orphan_local_branches( active_branch: active_branch )
17
+ prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters )
18
+
19
+ return prune_no_stale_branches if counters.fetch( :deleted ).zero? && counters.fetch( :skipped ).zero?
20
+
21
+ puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
22
+ unless verbose?
23
+ deleted_count = counters.fetch( :deleted )
24
+ if deleted_count.zero?
25
+ puts_line "No stale branches."
26
+ else
27
+ puts_line "Pruned #{deleted_count} stale branch#{plural_suffix( count: deleted_count )}."
28
+ end
29
+ end
30
+ EXIT_OK
31
+ end
32
+
33
+ private
34
+
35
+ def prune_no_stale_branches
36
+ if verbose?
37
+ puts_line "OK: no stale or orphan branches to prune."
38
+ else
39
+ puts_line "No stale branches."
40
+ end
41
+ EXIT_OK
42
+ end
43
+
44
+ def prune_stale_branch_entries( stale_branches:, active_branch:, counters: { deleted: 0, skipped: 0 } )
45
+ stale_branches.each do |entry|
46
+ outcome = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
47
+ counters[ outcome ] += 1
48
+ end
49
+ counters
50
+ end
51
+
52
+ def prune_stale_branch_entry( entry:, active_branch: )
53
+ branch = entry.fetch( :branch )
54
+ upstream = entry.fetch( :upstream )
55
+ return prune_skip_stale_branch( type: :protected, branch: branch, upstream: upstream ) if config.protected_branches.include?( branch )
56
+ return prune_skip_stale_branch( type: :current, branch: branch, upstream: upstream ) if branch == active_branch
57
+
58
+ prune_delete_stale_branch( branch: branch, upstream: upstream )
59
+ end
60
+
61
+ def prune_skip_stale_branch( type:, branch:, upstream: )
62
+ status = type == :protected ? "skip_protected_branch" : "skip_current_branch"
63
+ puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
64
+ :skipped
65
+ end
66
+
67
+ def prune_delete_stale_branch( branch:, upstream: )
68
+ stdout_text, stderr_text, success, = git_run( "branch", "-d", branch )
69
+ return prune_safe_delete_success( branch: branch, upstream: upstream, stdout_text: stdout_text ) if success
70
+
71
+ delete_error_text = normalise_branch_delete_error( error_text: stderr_text )
72
+ prune_force_delete_stale_branch(
73
+ branch: branch,
74
+ upstream: upstream,
75
+ delete_error_text: delete_error_text
76
+ )
77
+ end
78
+
79
+ def prune_safe_delete_success( branch:, upstream:, stdout_text: )
80
+ out.print stdout_text if verbose? && !stdout_text.empty?
81
+ puts_verbose "deleted_local_branch: #{branch} (upstream=#{upstream})"
82
+ :deleted
83
+ end
84
+
85
+ def prune_force_delete_stale_branch( branch:, upstream:, delete_error_text: )
86
+ merged_pr, force_error = force_delete_evidence_for_stale_branch(
87
+ branch: branch,
88
+ delete_error_text: delete_error_text
89
+ )
90
+ return prune_force_delete_skipped( branch: branch, upstream: upstream, delete_error_text: delete_error_text, force_error: force_error ) if merged_pr.nil?
91
+
92
+ force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
93
+ return prune_force_delete_success( branch: branch, upstream: upstream, merged_pr: merged_pr, force_stdout: force_stdout ) if force_success
94
+
95
+ prune_force_delete_failed( branch: branch, upstream: upstream, force_stderr: force_stderr )
96
+ end
97
+
98
+ def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
99
+ out.print force_stdout if verbose? && !force_stdout.empty?
100
+ puts_verbose "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
101
+ :deleted
102
+ end
103
+
104
+ def prune_force_delete_failed( branch:, upstream:, force_stderr: )
105
+ force_error_text = normalise_branch_delete_error( error_text: force_stderr )
106
+ puts_verbose "fail_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error_text}"
107
+ :skipped
108
+ end
109
+
110
+ def prune_force_delete_skipped( branch:, upstream:, delete_error_text:, force_error: )
111
+ puts_verbose "skip_delete_branch: #{branch} (upstream=#{upstream}) reason=#{delete_error_text}"
112
+ puts_verbose "skip_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error}" unless force_error.to_s.strip.empty?
113
+ :skipped
114
+ end
115
+
116
+ def normalise_branch_delete_error( error_text: )
117
+ text = error_text.to_s.strip
118
+ text.empty? ? "unknown error" : text
119
+ end
120
+
121
+ # Detects local branches whose upstream tracking is marked [gone] after fetch --prune.
122
+ def stale_local_branches
123
+ git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
124
+ branch, upstream, track = line.strip.split( "\t", 3 )
125
+ upstream = upstream.to_s
126
+ track = track.to_s
127
+ next if branch.to_s.empty? || upstream.empty?
128
+ next unless upstream.start_with?( "#{config.git_remote}/" ) && track.include?( "gone" )
129
+
130
+ { branch: branch, upstream: upstream, track: track }
131
+ end.compact
132
+ end
133
+
134
+ # Detects local branches with no upstream tracking ref — candidates for orphan pruning.
135
+ def orphan_local_branches( active_branch: )
136
+ git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads" ).lines.filter_map do |line|
137
+ branch, upstream = line.strip.split( "\t", 2 )
138
+ branch = branch.to_s.strip
139
+ upstream = upstream.to_s.strip
140
+ next if branch.empty?
141
+ next unless upstream.empty?
142
+ next if config.protected_branches.include?( branch )
143
+ next if branch == active_branch
144
+ next if branch == TEMPLATE_SYNC_BRANCH
145
+
146
+ branch
147
+ end
148
+ end
149
+
150
+ # Processes orphan branches: verifies merged PR evidence via GitHub API before deleting.
151
+ def prune_orphan_branch_entries( orphan_branches:, counters: )
152
+ return counters if orphan_branches.empty?
153
+ return counters unless gh_available?
154
+
155
+ orphan_branches.each do |branch|
156
+ outcome = prune_orphan_branch_entry( branch: branch )
157
+ counters[ outcome ] += 1
158
+ end
159
+ counters
160
+ end
161
+
162
+ # Checks a single orphan branch for merged PR evidence and force-deletes if confirmed.
163
+ def prune_orphan_branch_entry( branch: )
164
+ tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
165
+ unless tip_sha_success
166
+ error_text = tip_sha_error.to_s.strip
167
+ error_text = "unable to read local branch tip sha" if error_text.empty?
168
+ puts_verbose "skip_orphan_branch: #{branch} reason=#{error_text}"
169
+ return :skipped
170
+ end
171
+ branch_tip_sha = tip_sha_text.to_s.strip
172
+ if branch_tip_sha.empty?
173
+ puts_verbose "skip_orphan_branch: #{branch} reason=unable to read local branch tip sha"
174
+ return :skipped
175
+ end
176
+
177
+ merged_pr, error = merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
178
+ if merged_pr.nil?
179
+ reason = error.to_s.strip
180
+ reason = "no merged PR evidence for branch tip into #{config.main_branch}" if reason.empty?
181
+ puts_verbose "skip_orphan_branch: #{branch} reason=#{reason}"
182
+ return :skipped
183
+ end
184
+
185
+ force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
186
+ if force_success
187
+ out.print force_stdout if verbose? && !force_stdout.empty?
188
+ puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
189
+ return :deleted
190
+ end
191
+
192
+ force_error_text = normalise_branch_delete_error( error_text: force_stderr )
193
+ puts_verbose "fail_delete_orphan_branch: #{branch} reason=#{force_error_text}"
194
+ :skipped
195
+ end
196
+
197
+ # Safe delete can fail after squash merges because branch tip is no longer an ancestor.
198
+ def non_merged_delete_error?( error_text: )
199
+ error_text.to_s.downcase.include?( "not fully merged" )
200
+ end
201
+
202
+ # Guarded force-delete policy for stale branches.
203
+ def force_delete_evidence_for_stale_branch( branch:, delete_error_text: )
204
+ return [ nil, "safe delete failure is not merge-related" ] unless non_merged_delete_error?( error_text: delete_error_text )
205
+ return [ nil, "gh CLI not available; cannot verify merged PR evidence" ] unless gh_available?
206
+
207
+ tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
208
+ unless tip_sha_success
209
+ error_text = tip_sha_error.to_s.strip
210
+ error_text = "unable to read local branch tip sha" if error_text.empty?
211
+ return [ nil, error_text ]
212
+ end
213
+ branch_tip_sha = tip_sha_text.to_s.strip
214
+ return [ nil, "unable to read local branch tip sha" ] if branch_tip_sha.empty?
215
+
216
+ merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
217
+ end
218
+
219
+ # Finds merged PR evidence for the exact local branch tip.
220
+ def merged_pr_for_branch( branch:, branch_tip_sha: )
221
+ owner, repo = repository_coordinates
222
+ results = []
223
+ page = 1
224
+ max_pages = 50
225
+ loop do
226
+ stdout_text, stderr_text, success, = gh_run(
227
+ "api", "repos/#{owner}/#{repo}/pulls",
228
+ "--method", "GET",
229
+ "-f", "state=closed",
230
+ "-f", "base=#{config.main_branch}",
231
+ "-f", "head=#{owner}:#{branch}",
232
+ "-f", "sort=updated",
233
+ "-f", "direction=desc",
234
+ "-f", "per_page=100",
235
+ "-f", "page=#{page}"
236
+ )
237
+ unless success
238
+ error_text = gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to query merged PR evidence for branch #{branch}" )
239
+ return [ nil, error_text ]
240
+ end
241
+ page_nodes = Array( JSON.parse( stdout_text ) )
242
+ break if page_nodes.empty?
243
+
244
+ page_nodes.each do |entry|
245
+ next unless entry.dig( "head", "ref" ).to_s == branch.to_s
246
+ next unless entry.dig( "base", "ref" ).to_s == config.main_branch
247
+ next unless entry.dig( "head", "sha" ).to_s == branch_tip_sha
248
+
249
+ merged_at = parse_time_or_nil( text: entry[ "merged_at" ] )
250
+ next if merged_at.nil?
251
+
252
+ results << {
253
+ number: entry[ "number" ],
254
+ url: entry[ "html_url" ].to_s,
255
+ merged_at: merged_at.utc.iso8601,
256
+ head_sha: entry.dig( "head", "sha" ).to_s
257
+ }
258
+ end
259
+ if page >= max_pages
260
+ probe_stdout_text, probe_stderr_text, probe_success, = gh_run(
261
+ "api", "repos/#{owner}/#{repo}/pulls",
262
+ "--method", "GET",
263
+ "-f", "state=closed",
264
+ "-f", "base=#{config.main_branch}",
265
+ "-f", "head=#{owner}:#{branch}",
266
+ "-f", "sort=updated",
267
+ "-f", "direction=desc",
268
+ "-f", "per_page=100",
269
+ "-f", "page=#{page + 1}"
270
+ )
271
+ unless probe_success
272
+ error_text = gh_error_text( stdout_text: probe_stdout_text, stderr_text: probe_stderr_text, fallback: "unable to verify merged PR pagination limit for branch #{branch}" )
273
+ return [ nil, error_text ]
274
+ end
275
+ probe_nodes = Array( JSON.parse( probe_stdout_text ) )
276
+ return [ nil, "merged PR lookup exceeded pagination safety limit (#{max_pages} pages) for branch #{branch}" ] unless probe_nodes.empty?
277
+ break
278
+ end
279
+ page += 1
280
+ end
281
+ latest = results.max_by { |item| item.fetch( :merged_at ) }
282
+ return [ nil, "no merged PR evidence for branch tip #{branch_tip_sha} into #{config.main_branch}" ] if latest.nil?
283
+
284
+ [ latest, nil ]
285
+ rescue JSON::ParserError => e
286
+ [ nil, "invalid gh JSON response (#{e.message})" ]
287
+ rescue StandardError => e
288
+ [ nil, e.message ]
289
+ end
290
+ end
291
+ end
292
+ end