carson 1.0.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,661 @@
1
+ module Carson
2
+ class Runtime
3
+ module Local
4
+ def sync!
5
+ fingerprint_status = block_if_outsider_fingerprints!
6
+ return fingerprint_status unless fingerprint_status.nil?
7
+
8
+ unless working_tree_clean?
9
+ puts_line "BLOCK: working tree is dirty; commit/stash first, then run carson sync."
10
+ return EXIT_BLOCK
11
+ end
12
+ start_branch = current_branch
13
+ switched = false
14
+ git_system!( "fetch", config.git_remote, "--prune" )
15
+ if start_branch != config.main_branch
16
+ git_system!( "switch", config.main_branch )
17
+ switched = true
18
+ end
19
+ git_system!( "pull", "--ff-only", config.git_remote, config.main_branch )
20
+ ahead_count, behind_count, error_text = main_sync_counts
21
+ if error_text
22
+ puts_line "BLOCK: unable to verify main sync state (#{error_text})."
23
+ return EXIT_BLOCK
24
+ end
25
+ if ahead_count.zero? && behind_count.zero?
26
+ puts_line "OK: local #{config.main_branch} is now in sync with #{config.git_remote}/#{config.main_branch}."
27
+ return EXIT_OK
28
+ end
29
+ puts_line "BLOCK: local #{config.main_branch} still diverges (ahead=#{ahead_count}, behind=#{behind_count})."
30
+ EXIT_BLOCK
31
+ ensure
32
+ git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch )
33
+ end
34
+
35
+ # Removes stale local branches that track remote refs already deleted upstream.
36
+ def prune!
37
+ fingerprint_status = block_if_outsider_fingerprints!
38
+ return fingerprint_status unless fingerprint_status.nil?
39
+
40
+ git_system!( "fetch", config.git_remote, "--prune" )
41
+ active_branch = current_branch
42
+ stale_branches = stale_local_branches
43
+ return prune_no_stale_branches if stale_branches.empty?
44
+
45
+ counters = prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch )
46
+ puts_line "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
47
+ EXIT_OK
48
+ end
49
+
50
+ def prune_no_stale_branches
51
+ puts_line "OK: no stale local branches tracking deleted #{config.git_remote} branches."
52
+ EXIT_OK
53
+ end
54
+
55
+ def prune_stale_branch_entries( stale_branches:, active_branch: )
56
+ counters = { deleted: 0, skipped: 0 }
57
+ stale_branches.each do |entry|
58
+ outcome = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
59
+ counters[ outcome ] += 1
60
+ end
61
+ counters
62
+ end
63
+
64
+ def prune_stale_branch_entry( entry:, active_branch: )
65
+ branch = entry.fetch( :branch )
66
+ upstream = entry.fetch( :upstream )
67
+ return prune_skip_stale_branch( type: :protected, branch: branch, upstream: upstream ) if config.protected_branches.include?( branch )
68
+ return prune_skip_stale_branch( type: :current, branch: branch, upstream: upstream ) if branch == active_branch
69
+
70
+ prune_delete_stale_branch( branch: branch, upstream: upstream )
71
+ end
72
+
73
+ def prune_skip_stale_branch( type:, branch:, upstream: )
74
+ status = type == :protected ? "skip_protected_branch" : "skip_current_branch"
75
+ puts_line "#{status}: #{branch} (upstream=#{upstream})"
76
+ :skipped
77
+ end
78
+
79
+ def prune_delete_stale_branch( branch:, upstream: )
80
+ stdout_text, stderr_text, success, = git_run( "branch", "-d", branch )
81
+ return prune_safe_delete_success( branch: branch, upstream: upstream, stdout_text: stdout_text ) if success
82
+
83
+ delete_error_text = normalise_branch_delete_error( error_text: stderr_text )
84
+ prune_force_delete_stale_branch(
85
+ branch: branch,
86
+ upstream: upstream,
87
+ delete_error_text: delete_error_text
88
+ )
89
+ end
90
+
91
+ def prune_safe_delete_success( branch:, upstream:, stdout_text: )
92
+ out.print stdout_text unless stdout_text.empty?
93
+ puts_line "deleted_local_branch: #{branch} (upstream=#{upstream})"
94
+ :deleted
95
+ end
96
+
97
+ def prune_force_delete_stale_branch( branch:, upstream:, delete_error_text: )
98
+ merged_pr, force_error = force_delete_evidence_for_stale_branch(
99
+ branch: branch,
100
+ delete_error_text: delete_error_text
101
+ )
102
+ return prune_force_delete_skipped( branch: branch, upstream: upstream, delete_error_text: delete_error_text, force_error: force_error ) if merged_pr.nil?
103
+
104
+ force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
105
+ return prune_force_delete_success( branch: branch, upstream: upstream, merged_pr: merged_pr, force_stdout: force_stdout ) if force_success
106
+
107
+ prune_force_delete_failed( branch: branch, upstream: upstream, force_stderr: force_stderr )
108
+ end
109
+
110
+ def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
111
+ out.print force_stdout unless force_stdout.empty?
112
+ puts_line "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
113
+ :deleted
114
+ end
115
+
116
+ def prune_force_delete_failed( branch:, upstream:, force_stderr: )
117
+ force_error_text = normalise_branch_delete_error( error_text: force_stderr )
118
+ puts_line "fail_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error_text}"
119
+ :skipped
120
+ end
121
+
122
+ def prune_force_delete_skipped( branch:, upstream:, delete_error_text:, force_error: )
123
+ puts_line "skip_delete_branch: #{branch} (upstream=#{upstream}) reason=#{delete_error_text}"
124
+ puts_line "skip_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error}" unless force_error.to_s.strip.empty?
125
+ :skipped
126
+ end
127
+
128
+ def normalise_branch_delete_error( error_text: )
129
+ text = error_text.to_s.strip
130
+ text.empty? ? "unknown error" : text
131
+ end
132
+
133
+ # Installs required hook files and enforces repository hook path.
134
+ def hook!
135
+ fingerprint_status = block_if_outsider_fingerprints!
136
+ return fingerprint_status unless fingerprint_status.nil?
137
+
138
+ FileUtils.mkdir_p( hooks_dir )
139
+ missing_templates = config.required_hooks.reject { |name| File.file?( hook_template_path( hook_name: name ) ) }
140
+ unless missing_templates.empty?
141
+ puts_line "BLOCK: missing hook templates in Carson: #{missing_templates.join( ', ' )}."
142
+ return EXIT_BLOCK
143
+ end
144
+
145
+ symlinked = symlink_hook_files
146
+ unless symlinked.empty?
147
+ puts_line "BLOCK: symlink hook files are not allowed: #{symlinked.join( ', ' )}."
148
+ return EXIT_BLOCK
149
+ end
150
+
151
+ config.required_hooks.each do |hook_name|
152
+ source_path = hook_template_path( hook_name: hook_name )
153
+ target_path = File.join( hooks_dir, hook_name )
154
+ FileUtils.cp( source_path, target_path )
155
+ FileUtils.chmod( 0o755, target_path )
156
+ puts_line "hook_written: #{relative_path( target_path )}"
157
+ end
158
+ git_system!( "config", "core.hooksPath", hooks_dir )
159
+ puts_line "configured_hooks_path: #{hooks_dir}"
160
+ check!
161
+ end
162
+
163
+ # One-command initialisation for new repositories: align remote naming, install hooks,
164
+ # apply templates, and produce a first audit report.
165
+ def init!
166
+ fingerprint_status = block_if_outsider_fingerprints!
167
+ return fingerprint_status unless fingerprint_status.nil?
168
+
169
+ print_header "Init"
170
+ unless inside_git_work_tree?
171
+ puts_line "ERROR: #{repo_root} is not a git repository."
172
+ return EXIT_ERROR
173
+ end
174
+ align_remote_name_for_carson!
175
+ hook_status = hook!
176
+ return hook_status unless hook_status == EXIT_OK
177
+
178
+ template_status = template_apply!
179
+ return template_status unless template_status == EXIT_OK
180
+
181
+ audit_status = audit!
182
+ if audit_status == EXIT_OK
183
+ puts_line "OK: Carson initialisation completed for #{repo_root}."
184
+ elsif audit_status == EXIT_BLOCK
185
+ puts_line "BLOCK: Carson initialisation completed with policy blocks; resolve and rerun carson audit."
186
+ end
187
+ audit_status
188
+ end
189
+
190
+ # Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
191
+ def offboard!
192
+ print_header "Offboard"
193
+ unless inside_git_work_tree?
194
+ puts_line "ERROR: #{repo_root} is not a git repository."
195
+ return EXIT_ERROR
196
+ end
197
+ hooks_status = disable_carson_hooks_path!
198
+ return hooks_status unless hooks_status == EXIT_OK
199
+
200
+ removed_count = 0
201
+ missing_count = 0
202
+ offboard_cleanup_targets.each do |relative|
203
+ absolute = resolve_repo_path!( relative_path: relative, label: "offboard target #{relative}" )
204
+ if File.exist?( absolute )
205
+ FileUtils.rm_rf( absolute )
206
+ puts_line "removed_path: #{relative}"
207
+ removed_count += 1
208
+ else
209
+ puts_line "skip_missing_path: #{relative}"
210
+ missing_count += 1
211
+ end
212
+ end
213
+ remove_empty_offboard_directories!
214
+ puts_line "offboard_summary: removed=#{removed_count} missing=#{missing_count}"
215
+ puts_line "OK: Carson offboard completed for #{repo_root}."
216
+ EXIT_OK
217
+ end
218
+
219
+ # Strict hook health check used by humans, hooks, and CI paths.
220
+ def check!
221
+ fingerprint_status = block_if_outsider_fingerprints!
222
+ return fingerprint_status unless fingerprint_status.nil?
223
+
224
+ print_header "Hooks Check"
225
+ ok = hooks_health_report( strict: true )
226
+ puts_line( ok ? "status: ok" : "status: block" )
227
+ ok ? EXIT_OK : EXIT_BLOCK
228
+ end
229
+
230
+ # Read-only template drift check; returns block when managed files are out of sync.
231
+ def template_check!
232
+ fingerprint_status = block_if_outsider_fingerprints!
233
+ return fingerprint_status unless fingerprint_status.nil?
234
+
235
+ print_header "Template Sync Check"
236
+ results = template_results
237
+ drift_count = results.count { |entry| entry.fetch( :status ) == "drift" }
238
+ error_count = results.count { |entry| entry.fetch( :status ) == "error" }
239
+ results.each do |entry|
240
+ puts_line "template_file: #{entry.fetch( :file )} status=#{entry.fetch( :status )} reason=#{entry.fetch( :reason )}"
241
+ end
242
+ puts_line "template_summary: total=#{results.count} drift=#{drift_count} error=#{error_count}"
243
+ return EXIT_ERROR if error_count.positive?
244
+
245
+ drift_count.positive? ? EXIT_BLOCK : EXIT_OK
246
+ end
247
+
248
+ # Applies managed template files as full-file writes from Carson sources.
249
+ def template_apply!
250
+ fingerprint_status = block_if_outsider_fingerprints!
251
+ return fingerprint_status unless fingerprint_status.nil?
252
+
253
+ print_header "Template Sync Apply"
254
+ results = template_results
255
+ applied = 0
256
+ results.each do |entry|
257
+ if entry.fetch( :status ) == "error"
258
+ puts_line "template_file: #{entry.fetch( :file )} status=error reason=#{entry.fetch( :reason )}"
259
+ next
260
+ end
261
+
262
+ file_path = File.join( repo_root, entry.fetch( :file ) )
263
+ if entry.fetch( :status ) == "ok"
264
+ puts_line "template_file: #{entry.fetch( :file )} status=ok reason=in_sync"
265
+ next
266
+ end
267
+
268
+ FileUtils.mkdir_p( File.dirname( file_path ) )
269
+ File.write( file_path, entry.fetch( :applied_content ) )
270
+ puts_line "template_file: #{entry.fetch( :file )} status=updated reason=#{entry.fetch( :reason )}"
271
+ applied += 1
272
+ end
273
+
274
+ error_count = results.count { |entry| entry.fetch( :status ) == "error" }
275
+ puts_line "template_apply_summary: updated=#{applied} error=#{error_count}"
276
+ error_count.positive? ? EXIT_ERROR : EXIT_OK
277
+ end
278
+
279
+ private
280
+
281
+ def template_results
282
+ config.template_managed_files.map { |managed_file| template_result_for_file( managed_file: managed_file ) }
283
+ end
284
+
285
+ # Calculates whole-file expected content and returns sync status plus apply payload.
286
+ def template_result_for_file( managed_file: )
287
+ template_path = File.join( github_templates_dir, File.basename( managed_file ) )
288
+ return { file: managed_file, status: "error", reason: "missing template #{File.basename( managed_file )}", applied_content: nil } unless File.file?( template_path )
289
+
290
+ expected_content = normalize_text( text: File.read( template_path ) )
291
+ file_path = resolve_repo_path!( relative_path: managed_file, label: "template.managed_files entry #{managed_file}" )
292
+ return { file: managed_file, status: "drift", reason: "missing_file", applied_content: expected_content } unless File.file?( file_path )
293
+
294
+ current_content = normalize_text( text: File.read( file_path ) )
295
+ return { file: managed_file, status: "ok", reason: "in_sync", applied_content: current_content } if current_content == expected_content
296
+
297
+ { file: managed_file, status: "drift", reason: "content_mismatch", applied_content: expected_content }
298
+ end
299
+
300
+ # Uses LF-only normalisation so platform newlines do not cause false drift.
301
+ def normalize_text( text: )
302
+ "#{text.to_s.gsub( "\r\n", "\n" ).rstrip}\n"
303
+ end
304
+
305
+ # GitHub managed template source directory inside Carson repository.
306
+ def github_templates_dir
307
+ File.join( tool_root, "templates", ".github" )
308
+ end
309
+
310
+ # Canonical hook template location inside Carson repository.
311
+ def hook_template_path( hook_name: )
312
+ File.join( tool_root, "assets", "hooks", hook_name )
313
+ end
314
+
315
+ # Reports full hook health and can enforce stricter action messaging in `check`.
316
+ def hooks_health_report( strict: false )
317
+ configured = configured_hooks_path
318
+ expected = hooks_dir
319
+ hooks_path_ok = print_hooks_path_status( configured: configured, expected: expected )
320
+ print_required_hook_status
321
+ hooks_integrity = hook_integrity_state
322
+ hooks_ok = hooks_integrity_ok?( hooks_integrity: hooks_integrity )
323
+ print_hook_action(
324
+ strict: strict,
325
+ hooks_ok: hooks_path_ok && hooks_ok,
326
+ hooks_path_ok: hooks_path_ok,
327
+ configured: configured,
328
+ expected: expected
329
+ )
330
+ hooks_path_ok && hooks_ok
331
+ end
332
+
333
+ def print_hooks_path_status( configured:, expected: )
334
+ configured_abs = configured.nil? ? nil : File.expand_path( configured )
335
+ hooks_path_ok = configured_abs == expected
336
+ puts_line "hooks_path: #{configured || '(unset)'}"
337
+ puts_line "hooks_path_expected: #{expected}"
338
+ puts_line( hooks_path_ok ? "hooks_path_status: ok" : "hooks_path_status: attention" )
339
+ hooks_path_ok
340
+ end
341
+
342
+ def print_required_hook_status
343
+ required_hook_paths.each do |path|
344
+ exists = File.file?( path )
345
+ symlink = File.symlink?( path )
346
+ executable = exists && !symlink && File.executable?( path )
347
+ puts_line "hook_file: #{relative_path( path )} exists=#{exists} symlink=#{symlink} executable=#{executable}"
348
+ end
349
+ end
350
+
351
+ def hook_integrity_state
352
+ {
353
+ missing: missing_hook_files,
354
+ non_executable: non_executable_hook_files,
355
+ symlinked: symlink_hook_files
356
+ }
357
+ end
358
+
359
+ def hooks_integrity_ok?( hooks_integrity: )
360
+ missing_ok = hooks_integrity.fetch( :missing ).empty?
361
+ non_executable_ok = hooks_integrity.fetch( :non_executable ).empty?
362
+ symlinked_ok = hooks_integrity.fetch( :symlinked ).empty?
363
+ missing_ok && non_executable_ok && symlinked_ok
364
+ end
365
+
366
+ def print_hook_action( strict:, hooks_ok:, hooks_path_ok:, configured:, expected: )
367
+ return if hooks_ok
368
+
369
+ if strict && !hooks_path_ok
370
+ configured_text = configured.to_s.strip
371
+ if configured_text.empty?
372
+ puts_line "ACTION: hooks path is unset (expected=#{expected})."
373
+ else
374
+ puts_line "ACTION: hooks path mismatch (configured=#{configured_text}, expected=#{expected})."
375
+ end
376
+ end
377
+ message = strict ? "ACTION: run carson hook to align hooks with Carson #{Carson::VERSION}." : "ACTION: run carson hook to enforce local main protections."
378
+ puts_line message
379
+ end
380
+
381
+ # Returns ahead/behind counts for local main versus configured remote main.
382
+ def main_sync_counts
383
+ target = "#{config.main_branch}...#{config.git_remote}/#{config.main_branch}"
384
+ stdout_text, stderr_text, success, = git_run( "rev-list", "--left-right", "--count", target )
385
+ unless success
386
+ error_text = stderr_text.to_s.strip
387
+ error_text = "git rev-list failed" if error_text.empty?
388
+ return [ 0, 0, error_text ]
389
+ end
390
+ counts = stdout_text.to_s.strip.split( /\s+/ )
391
+ return [ 0, 0, "unexpected rev-list output: #{stdout_text.to_s.strip}" ] if counts.length < 2
392
+
393
+ [ counts[ 0 ].to_i, counts[ 1 ].to_i, nil ]
394
+ end
395
+
396
+ # Reads configured core.hooksPath and normalises empty values to nil.
397
+ def configured_hooks_path
398
+ stdout_text, = git_capture_soft( "config", "--get", "core.hooksPath" )
399
+ value = stdout_text.to_s.strip
400
+ value.empty? ? nil : value
401
+ end
402
+
403
+ # Fully-qualified required hook file locations in the target repository.
404
+ def required_hook_paths
405
+ config.required_hooks.map { |name| File.join( hooks_dir, name ) }
406
+ end
407
+
408
+ # Missing required hook files.
409
+ def missing_hook_files
410
+ required_hook_paths.reject { |path| File.file?( path ) }.map { |path| relative_path( path ) }
411
+ end
412
+
413
+ # Required hook files that exist but are not executable.
414
+ def non_executable_hook_files
415
+ required_hook_paths.select { |path| File.file?( path ) && !File.executable?( path ) }.map { |path| relative_path( path ) }
416
+ end
417
+
418
+ # Symlink hooks are disallowed to prevent bypassing managed hook content.
419
+ def symlink_hook_files
420
+ required_hook_paths.select { |path| File.symlink?( path ) }.map { |path| relative_path( path ) }
421
+ end
422
+
423
+ # Local directory where managed hooks are installed.
424
+ def hooks_dir
425
+ File.expand_path( File.join( config.hooks_base_path, Carson::VERSION ) )
426
+ end
427
+
428
+ # In outsider mode, Carson must not leave Carson-owned fingerprints in host repositories.
429
+ def block_if_outsider_fingerprints!
430
+ return nil unless outsider_mode?
431
+
432
+ violations = outsider_fingerprint_violations
433
+ return nil if violations.empty?
434
+
435
+ violations.each { |entry| puts_line "BLOCK: #{entry}" }
436
+ EXIT_BLOCK
437
+ end
438
+
439
+ # Carson source repository itself is excluded from host-repository fingerprint checks.
440
+ def outsider_mode?
441
+ File.expand_path( repo_root ) != File.expand_path( tool_root )
442
+ end
443
+
444
+ # Detects Carson-owned host artefacts that violate outsider boundary.
445
+ def outsider_fingerprint_violations
446
+ violations = []
447
+ violations << "forbidden file .carson.yml detected" if File.file?( File.join( repo_root, ".carson.yml" ) )
448
+ violations << "forbidden file bin/carson detected" if File.file?( File.join( repo_root, "bin", "carson" ) )
449
+ violations << "forbidden directory .tools/carson detected" if Dir.exist?( File.join( repo_root, ".tools", "carson" ) )
450
+ violations
451
+ end
452
+
453
+ # NOTE: prune only targets local branches that meet both conditions:
454
+ # 1) branch tracks configured remote (`github/*` by default), and
455
+ # 2) upstream tracking state is marked as gone after fetch --prune.
456
+ # Branches without upstream tracking are intentionally excluded.
457
+ def stale_local_branches
458
+ git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
459
+ branch, upstream, track = line.strip.split( "\t", 3 )
460
+ upstream = upstream.to_s
461
+ track = track.to_s
462
+ next if branch.to_s.empty? || upstream.empty?
463
+ next unless upstream.start_with?( "#{config.git_remote}/" ) && track.include?( "gone" )
464
+
465
+ { branch: branch, upstream: upstream, track: track }
466
+ end.compact
467
+ end
468
+
469
+ # Safe delete can fail after squash merges because branch tip is no longer an ancestor.
470
+ def non_merged_delete_error?( error_text: )
471
+ error_text.to_s.downcase.include?( "not fully merged" )
472
+ end
473
+
474
+ # Guarded force-delete policy for stale branches:
475
+ # 1) safe delete failure must be merge-related (`not fully merged`),
476
+ # 2) gh must confirm at least one merged PR for this exact branch into configured main.
477
+ def force_delete_evidence_for_stale_branch( branch:, delete_error_text: )
478
+ return [ nil, "safe delete failure is not merge-related" ] unless non_merged_delete_error?( error_text: delete_error_text )
479
+ return [ nil, "gh CLI not available; cannot verify merged PR evidence" ] unless gh_available?
480
+
481
+ tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
482
+ unless tip_sha_success
483
+ error_text = tip_sha_error.to_s.strip
484
+ error_text = "unable to read local branch tip sha" if error_text.empty?
485
+ return [ nil, error_text ]
486
+ end
487
+ branch_tip_sha = tip_sha_text.to_s.strip
488
+ return [ nil, "unable to read local branch tip sha" ] if branch_tip_sha.empty?
489
+
490
+ merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
491
+ end
492
+
493
+ # Finds merged PR evidence for the exact local branch tip; this blocks old-PR false positives.
494
+ def merged_pr_for_branch( branch:, branch_tip_sha: )
495
+ owner, repo = repository_coordinates
496
+ results = []
497
+ page = 1
498
+ max_pages = 50
499
+ loop do
500
+ stdout_text, stderr_text, success, = gh_run(
501
+ "api", "repos/#{owner}/#{repo}/pulls",
502
+ "--method", "GET",
503
+ "-f", "state=closed",
504
+ "-f", "base=#{config.main_branch}",
505
+ "-f", "head=#{owner}:#{branch}",
506
+ "-f", "sort=updated",
507
+ "-f", "direction=desc",
508
+ "-f", "per_page=100",
509
+ "-f", "page=#{page}"
510
+ )
511
+ unless success
512
+ error_text = gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to query merged PR evidence for branch #{branch}" )
513
+ return [ nil, error_text ]
514
+ end
515
+ page_nodes = Array( JSON.parse( stdout_text ) )
516
+ break if page_nodes.empty?
517
+
518
+ page_nodes.each do |entry|
519
+ next unless entry.dig( "head", "ref" ).to_s == branch.to_s
520
+ next unless entry.dig( "base", "ref" ).to_s == config.main_branch
521
+ next unless entry.dig( "head", "sha" ).to_s == branch_tip_sha
522
+
523
+ merged_at = parse_time_or_nil( text: entry[ "merged_at" ] )
524
+ next if merged_at.nil?
525
+
526
+ results << {
527
+ number: entry[ "number" ],
528
+ url: entry[ "html_url" ].to_s,
529
+ merged_at: merged_at.utc.iso8601,
530
+ head_sha: entry.dig( "head", "sha" ).to_s
531
+ }
532
+ end
533
+ if page >= max_pages
534
+ probe_stdout_text, probe_stderr_text, probe_success, = gh_run(
535
+ "api", "repos/#{owner}/#{repo}/pulls",
536
+ "--method", "GET",
537
+ "-f", "state=closed",
538
+ "-f", "base=#{config.main_branch}",
539
+ "-f", "head=#{owner}:#{branch}",
540
+ "-f", "sort=updated",
541
+ "-f", "direction=desc",
542
+ "-f", "per_page=100",
543
+ "-f", "page=#{page + 1}"
544
+ )
545
+ unless probe_success
546
+ 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}" )
547
+ return [ nil, error_text ]
548
+ end
549
+ probe_nodes = Array( JSON.parse( probe_stdout_text ) )
550
+ return [ nil, "merged PR lookup exceeded pagination safety limit (#{max_pages} pages) for branch #{branch}" ] unless probe_nodes.empty?
551
+ break
552
+ end
553
+ page += 1
554
+ end
555
+ latest = results.max_by { |item| item.fetch( :merged_at ) }
556
+ return [ nil, "no merged PR evidence for branch tip #{branch_tip_sha} into #{config.main_branch}" ] if latest.nil?
557
+
558
+ [ latest, nil ]
559
+ rescue JSON::ParserError => e
560
+ [ nil, "invalid gh JSON response (#{e.message})" ]
561
+ rescue StandardError => e
562
+ [ nil, e.message ]
563
+ end
564
+
565
+ def working_tree_clean?
566
+ git_capture!( "status", "--porcelain" ).strip.empty?
567
+ end
568
+
569
+ def inside_git_work_tree?
570
+ stdout_text, = git_capture_soft( "rev-parse", "--is-inside-work-tree" )
571
+ stdout_text.to_s.strip == "true"
572
+ end
573
+
574
+ def disable_carson_hooks_path!
575
+ configured = configured_hooks_path
576
+ if configured.nil?
577
+ puts_line "hooks_path: (unset)"
578
+ return EXIT_OK
579
+ end
580
+ puts_line "hooks_path: #{configured}"
581
+ configured_abs = File.expand_path( configured, repo_root )
582
+ unless carson_managed_hooks_path?( configured_abs: configured_abs )
583
+ puts_line "hooks_path_kept: #{configured} (not Carson-managed)"
584
+ return EXIT_OK
585
+ end
586
+ git_system!( "config", "--unset", "core.hooksPath" )
587
+ puts_line "hooks_path_unset: core.hooksPath"
588
+ EXIT_OK
589
+ rescue StandardError => e
590
+ puts_line "ERROR: unable to update core.hooksPath (#{e.message})"
591
+ EXIT_ERROR
592
+ end
593
+
594
+ def carson_managed_hooks_path?( configured_abs: )
595
+ hooks_root = File.join( File.expand_path( config.hooks_base_path ), "" )
596
+ return true if configured_abs.start_with?( hooks_root )
597
+
598
+ carson_hook_files_match_templates?( hooks_path: configured_abs )
599
+ end
600
+
601
+ def carson_hook_files_match_templates?( hooks_path: )
602
+ return false unless Dir.exist?( hooks_path )
603
+ config.required_hooks.all? do |hook_name|
604
+ installed_path = File.join( hooks_path, hook_name )
605
+ template_path = hook_template_path( hook_name: hook_name )
606
+ next false unless File.file?( installed_path ) && File.file?( template_path )
607
+
608
+ installed_content = normalize_text( text: File.read( installed_path ) )
609
+ template_content = normalize_text( text: File.read( template_path ) )
610
+ installed_content == template_content
611
+ end
612
+ rescue StandardError
613
+ false
614
+ end
615
+
616
+ def offboard_cleanup_targets
617
+ ( config.template_managed_files + [
618
+ ".github/workflows/carson-governance.yml",
619
+ ".github/workflows/carson_policy.yml",
620
+ ".carson.yml",
621
+ "bin/carson",
622
+ ".tools/carson"
623
+ ] ).uniq
624
+ end
625
+
626
+ def remove_empty_offboard_directories!
627
+ [ ".github/workflows", ".github", ".tools", "bin" ].each do |relative|
628
+ absolute = resolve_repo_path!( relative_path: relative, label: "offboard cleanup directory #{relative}" )
629
+ next unless Dir.exist?( absolute )
630
+ next unless Dir.empty?( absolute )
631
+
632
+ Dir.rmdir( absolute )
633
+ puts_line "removed_empty_dir: #{relative}"
634
+ end
635
+ end
636
+
637
+ # Ensures Carson expected remote naming (`github`) while keeping existing
638
+ # repositories safe when neither `github` nor `origin` exists.
639
+ def align_remote_name_for_carson!
640
+ if git_remote_exists?( remote_name: config.git_remote )
641
+ puts_line "remote_ok: #{config.git_remote}"
642
+ return
643
+ end
644
+ if git_remote_exists?( remote_name: "origin" )
645
+ git_system!( "remote", "rename", "origin", config.git_remote )
646
+ puts_line "remote_renamed: origin -> #{config.git_remote}"
647
+ return
648
+ end
649
+ puts_line "WARN: no #{config.git_remote} or origin remote configured; continue with local baseline only."
650
+ end
651
+
652
+ # Uses `git remote get-url` as existence check to avoid parsing remote lists.
653
+ def git_remote_exists?( remote_name: )
654
+ _, _, success, = git_run( "remote", "get-url", remote_name.to_s )
655
+ success
656
+ end
657
+ end
658
+
659
+ include Local
660
+ end
661
+ end