carson 2.24.0 → 2.25.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.
@@ -1,1230 +1,11 @@
1
+ require_relative "local/sync"
2
+ require_relative "local/prune"
3
+ require_relative "local/template"
4
+ require_relative "local/hooks"
5
+ require_relative "local/onboard"
6
+
1
7
  module Carson
2
8
  class Runtime
3
- module Local
4
- TEMPLATE_SYNC_BRANCH = "carson/template-sync".freeze
5
-
6
- def sync!
7
- fingerprint_status = block_if_outsider_fingerprints!
8
- return fingerprint_status unless fingerprint_status.nil?
9
-
10
- unless working_tree_clean?
11
- puts_line "BLOCK: working tree is dirty; commit/stash first, then run carson sync."
12
- return EXIT_BLOCK
13
- end
14
- start_branch = current_branch
15
- switched = false
16
- git_system!( "fetch", config.git_remote, "--prune" )
17
- if start_branch != config.main_branch
18
- git_system!( "switch", config.main_branch )
19
- switched = true
20
- end
21
- git_system!( "pull", "--ff-only", config.git_remote, config.main_branch )
22
- ahead_count, behind_count, error_text = main_sync_counts
23
- if error_text
24
- puts_line "BLOCK: unable to verify main sync state (#{error_text})."
25
- return EXIT_BLOCK
26
- end
27
- if ahead_count.zero? && behind_count.zero?
28
- puts_line "OK: local #{config.main_branch} is now in sync with #{config.git_remote}/#{config.main_branch}."
29
- return EXIT_OK
30
- end
31
- puts_line "BLOCK: local #{config.main_branch} still diverges (ahead=#{ahead_count}, behind=#{behind_count})."
32
- EXIT_BLOCK
33
- ensure
34
- git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch )
35
- end
36
-
37
- # Removes stale local branches (gone upstream) and orphan branches (no tracking) with merged PR evidence.
38
- def prune!
39
- fingerprint_status = block_if_outsider_fingerprints!
40
- return fingerprint_status unless fingerprint_status.nil?
41
-
42
- git_system!( "fetch", config.git_remote, "--prune" )
43
- active_branch = current_branch
44
- counters = { deleted: 0, skipped: 0 }
45
-
46
- stale_branches = stale_local_branches
47
- prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters )
48
-
49
- orphan_branches = orphan_local_branches( active_branch: active_branch )
50
- prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters )
51
-
52
- return prune_no_stale_branches if counters.fetch( :deleted ).zero? && counters.fetch( :skipped ).zero?
53
-
54
- puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
55
- unless verbose?
56
- deleted_count = counters.fetch( :deleted )
57
- if deleted_count.zero?
58
- puts_line "No stale branches."
59
- else
60
- puts_line "Pruned #{deleted_count} stale branch#{plural_suffix( count: deleted_count )}."
61
- end
62
- end
63
- EXIT_OK
64
- end
65
-
66
- def prune_no_stale_branches
67
- if verbose?
68
- puts_line "OK: no stale or orphan branches to prune."
69
- else
70
- puts_line "No stale branches."
71
- end
72
- EXIT_OK
73
- end
74
-
75
- def prune_stale_branch_entries( stale_branches:, active_branch:, counters: { deleted: 0, skipped: 0 } )
76
- stale_branches.each do |entry|
77
- outcome = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
78
- counters[ outcome ] += 1
79
- end
80
- counters
81
- end
82
-
83
- def prune_stale_branch_entry( entry:, active_branch: )
84
- branch = entry.fetch( :branch )
85
- upstream = entry.fetch( :upstream )
86
- return prune_skip_stale_branch( type: :protected, branch: branch, upstream: upstream ) if config.protected_branches.include?( branch )
87
- return prune_skip_stale_branch( type: :current, branch: branch, upstream: upstream ) if branch == active_branch
88
-
89
- prune_delete_stale_branch( branch: branch, upstream: upstream )
90
- end
91
-
92
- def prune_skip_stale_branch( type:, branch:, upstream: )
93
- status = type == :protected ? "skip_protected_branch" : "skip_current_branch"
94
- puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
95
- :skipped
96
- end
97
-
98
- def prune_delete_stale_branch( branch:, upstream: )
99
- stdout_text, stderr_text, success, = git_run( "branch", "-d", branch )
100
- return prune_safe_delete_success( branch: branch, upstream: upstream, stdout_text: stdout_text ) if success
101
-
102
- delete_error_text = normalise_branch_delete_error( error_text: stderr_text )
103
- prune_force_delete_stale_branch(
104
- branch: branch,
105
- upstream: upstream,
106
- delete_error_text: delete_error_text
107
- )
108
- end
109
-
110
- def prune_safe_delete_success( branch:, upstream:, stdout_text: )
111
- out.print stdout_text if verbose? && !stdout_text.empty?
112
- puts_verbose "deleted_local_branch: #{branch} (upstream=#{upstream})"
113
- :deleted
114
- end
115
-
116
- def prune_force_delete_stale_branch( branch:, upstream:, delete_error_text: )
117
- merged_pr, force_error = force_delete_evidence_for_stale_branch(
118
- branch: branch,
119
- delete_error_text: delete_error_text
120
- )
121
- return prune_force_delete_skipped( branch: branch, upstream: upstream, delete_error_text: delete_error_text, force_error: force_error ) if merged_pr.nil?
122
-
123
- force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
124
- return prune_force_delete_success( branch: branch, upstream: upstream, merged_pr: merged_pr, force_stdout: force_stdout ) if force_success
125
-
126
- prune_force_delete_failed( branch: branch, upstream: upstream, force_stderr: force_stderr )
127
- end
128
-
129
- def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
130
- out.print force_stdout if verbose? && !force_stdout.empty?
131
- puts_verbose "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
132
- :deleted
133
- end
134
-
135
- def prune_force_delete_failed( branch:, upstream:, force_stderr: )
136
- force_error_text = normalise_branch_delete_error( error_text: force_stderr )
137
- puts_verbose "fail_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error_text}"
138
- :skipped
139
- end
140
-
141
- def prune_force_delete_skipped( branch:, upstream:, delete_error_text:, force_error: )
142
- puts_verbose "skip_delete_branch: #{branch} (upstream=#{upstream}) reason=#{delete_error_text}"
143
- puts_verbose "skip_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error}" unless force_error.to_s.strip.empty?
144
- :skipped
145
- end
146
-
147
- def normalise_branch_delete_error( error_text: )
148
- text = error_text.to_s.strip
149
- text.empty? ? "unknown error" : text
150
- end
151
-
152
- # Installs required hook files and enforces repository hook path.
153
- def prepare!
154
- fingerprint_status = block_if_outsider_fingerprints!
155
- return fingerprint_status unless fingerprint_status.nil?
156
-
157
- FileUtils.mkdir_p( hooks_dir )
158
- missing_templates = config.required_hooks.reject { |name| File.file?( hook_template_path( hook_name: name ) ) }
159
- unless missing_templates.empty?
160
- puts_line "BLOCK: missing hook templates in Carson: #{missing_templates.join( ', ' )}."
161
- return EXIT_BLOCK
162
- end
163
-
164
- symlinked = symlink_hook_files
165
- unless symlinked.empty?
166
- puts_line "BLOCK: symlink hook files are not allowed: #{symlinked.join( ', ' )}."
167
- return EXIT_BLOCK
168
- end
169
-
170
- config.required_hooks.each do |hook_name|
171
- source_path = hook_template_path( hook_name: hook_name )
172
- target_path = File.join( hooks_dir, hook_name )
173
- FileUtils.cp( source_path, target_path )
174
- FileUtils.chmod( 0o755, target_path )
175
- puts_verbose "hook_written: #{relative_path( target_path )}"
176
- end
177
- git_system!( "config", "core.hooksPath", hooks_dir )
178
- File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
179
- puts_verbose "configured_hooks_path: #{hooks_dir}"
180
- unless verbose?
181
- puts_line "Hooks installed (#{config.required_hooks.count} hooks)."
182
- return EXIT_OK
183
- end
184
-
185
- inspect!
186
- end
187
-
188
- # One-command onboarding for new repositories: detect remote, install hooks,
189
- # apply templates, and run initial audit.
190
- def onboard!
191
- fingerprint_status = block_if_outsider_fingerprints!
192
- return fingerprint_status unless fingerprint_status.nil?
193
-
194
- unless inside_git_work_tree?
195
- puts_line "ERROR: #{repo_root} is not a git repository."
196
- return EXIT_ERROR
197
- end
198
-
199
- repo_name = File.basename( repo_root )
200
- puts_line ""
201
- puts_line "Onboarding #{repo_name}..."
202
-
203
- if !global_config_exists? || !git_remote_exists?( remote_name: config.git_remote )
204
- if self.in.respond_to?( :tty? ) && self.in.tty?
205
- setup_status = setup!
206
- return setup_status unless setup_status == EXIT_OK
207
- else
208
- silent_setup!
209
- end
210
- end
211
-
212
- onboard_apply!
213
- end
214
-
215
- # Re-applies hooks, templates, and audit after upgrading Carson.
216
- def refresh!
217
- fingerprint_status = block_if_outsider_fingerprints!
218
- return fingerprint_status unless fingerprint_status.nil?
219
-
220
- unless inside_git_work_tree?
221
- puts_line "ERROR: #{repo_root} is not a git repository."
222
- return EXIT_ERROR
223
- end
224
-
225
- if verbose?
226
- puts_verbose ""
227
- puts_verbose "[Refresh]"
228
- hook_status = prepare!
229
- return hook_status unless hook_status == EXIT_OK
230
-
231
- drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
232
- template_status = template_apply!
233
- return template_status unless template_status == EXIT_OK
234
-
235
- @template_sync_result = template_propagate!( drift_count: drift_count )
236
-
237
- audit_status = audit!
238
- if audit_status == EXIT_OK
239
- puts_line "OK: Carson refresh completed for #{repo_root}."
240
- elsif audit_status == EXIT_BLOCK
241
- puts_line "BLOCK: Carson refresh completed with policy blocks; resolve and rerun carson audit."
242
- end
243
- return audit_status
244
- end
245
-
246
- puts_line "Refresh"
247
- hook_status = with_captured_output { prepare! }
248
- return hook_status unless hook_status == EXIT_OK
249
- puts_line "Hooks installed (#{config.required_hooks.count} hooks)."
250
-
251
- template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
252
- template_status = with_captured_output { template_apply! }
253
- return template_status unless template_status == EXIT_OK
254
- if template_drift_count.positive?
255
- puts_line "Templates applied (#{template_drift_count} updated)."
256
- else
257
- puts_line "Templates in sync."
258
- end
259
-
260
- @template_sync_result = template_propagate!( drift_count: template_drift_count )
261
-
262
- audit_status = audit!
263
- puts_line "Refresh complete."
264
- audit_status
265
- end
266
-
267
- # Re-applies hooks, templates, and audit across all governed repositories.
268
- def refresh_all!
269
- repos = config.govern_repos
270
- if repos.empty?
271
- puts_line "No governed repositories configured."
272
- puts_line " Run carson onboard in each repo to register."
273
- return EXIT_ERROR
274
- end
275
-
276
- puts_line ""
277
- puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
278
- refreshed = 0
279
- failed = 0
280
-
281
- repos.each do |repo_path|
282
- repo_name = File.basename( repo_path )
283
- unless Dir.exist?( repo_path )
284
- puts_line "#{repo_name}: FAIL (path not found)"
285
- failed += 1
286
- next
287
- end
288
-
289
- status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
290
- if status == EXIT_ERROR
291
- failed += 1
292
- else
293
- refreshed += 1
294
- end
295
- end
296
-
297
- puts_line ""
298
- puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
299
- failed.zero? ? EXIT_OK : EXIT_ERROR
300
- end
301
-
302
- # Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
303
- def offboard!
304
- puts_verbose ""
305
- puts_verbose "[Offboard]"
306
- unless inside_git_work_tree?
307
- puts_line "ERROR: #{repo_root} is not a git repository."
308
- return EXIT_ERROR
309
- end
310
- hooks_status = disable_carson_hooks_path!
311
- return hooks_status unless hooks_status == EXIT_OK
312
-
313
- removed_count = 0
314
- missing_count = 0
315
- offboard_cleanup_targets.each do |relative|
316
- absolute = resolve_repo_path!( relative_path: relative, label: "offboard target #{relative}" )
317
- if File.exist?( absolute )
318
- FileUtils.rm_rf( absolute )
319
- puts_verbose "removed_path: #{relative}"
320
- removed_count += 1
321
- else
322
- puts_verbose "skip_missing_path: #{relative}"
323
- missing_count += 1
324
- end
325
- end
326
- remove_empty_offboard_directories!
327
- remove_govern_repo!( repo_path: File.expand_path( repo_root ) )
328
- puts_verbose "govern_deregistered: #{File.expand_path( repo_root )}"
329
- puts_verbose "offboard_summary: removed=#{removed_count} missing=#{missing_count}"
330
- if verbose?
331
- puts_line "OK: Carson offboard completed for #{repo_root}."
332
- else
333
- puts_line "Removed #{removed_count} file#{plural_suffix( count: removed_count )}. Offboard complete."
334
- end
335
- EXIT_OK
336
- end
337
-
338
- # Strict hook health check used by humans, hooks, and CI paths.
339
- def inspect!
340
- fingerprint_status = block_if_outsider_fingerprints!
341
- return fingerprint_status unless fingerprint_status.nil?
342
-
343
- puts_verbose ""
344
- puts_verbose "[Inspect]"
345
- ok = hooks_health_report( strict: true )
346
- puts_verbose( ok ? "status: ok" : "status: block" )
347
- unless verbose?
348
- puts_line( ok ? "Hooks: ok" : "Hooks: block" )
349
- end
350
- ok ? EXIT_OK : EXIT_BLOCK
351
- end
352
-
353
- # Read-only template drift check; returns block when managed files are out of sync.
354
- def template_check!
355
- fingerprint_status = block_if_outsider_fingerprints!
356
- return fingerprint_status unless fingerprint_status.nil?
357
-
358
- puts_verbose ""
359
- puts_verbose "[Template Sync Check]"
360
- results = template_results
361
- stale = template_superseded_present
362
- drift_count = results.count { |entry| entry.fetch( :status ) == "drift" }
363
- error_count = results.count { |entry| entry.fetch( :status ) == "error" }
364
- stale_count = stale.count
365
- results.each do |entry|
366
- puts_verbose "template_file: #{entry.fetch( :file )} status=#{entry.fetch( :status )} reason=#{entry.fetch( :reason )}"
367
- end
368
- stale.each { |file| puts_verbose "template_file: #{file} status=stale reason=superseded" }
369
- puts_verbose "template_summary: total=#{results.count} drift=#{drift_count} stale=#{stale_count} error=#{error_count}"
370
- unless verbose?
371
- if ( drift_count + stale_count ).positive?
372
- summary_parts = []
373
- summary_parts << "#{drift_count} of #{results.count} drifted" if drift_count.positive?
374
- summary_parts << "#{stale_count} stale" if stale_count.positive?
375
- puts_line "Templates: #{summary_parts.join( ", " )}"
376
- results.select { |entry| entry.fetch( :status ) == "drift" }.each { |entry| puts_line " #{entry.fetch( :file )}" }
377
- stale.each { |file| puts_line " #{file} — superseded" }
378
- else
379
- puts_line "Templates: #{results.count} files in sync"
380
- end
381
- end
382
- return EXIT_ERROR if error_count.positive?
383
-
384
- ( drift_count + stale_count ).positive? ? EXIT_BLOCK : EXIT_OK
385
- end
386
-
387
- # Applies managed template files as full-file writes from Carson sources.
388
- # Also removes superseded files that are no longer part of the managed set.
389
- def template_apply!( push_prep: false )
390
- fingerprint_status = block_if_outsider_fingerprints!
391
- return fingerprint_status unless fingerprint_status.nil?
392
-
393
- puts_verbose ""
394
- puts_verbose "[Template Sync Apply]"
395
- results = template_results
396
- stale = template_superseded_present
397
- applied = 0
398
- results.each do |entry|
399
- if entry.fetch( :status ) == "error"
400
- puts_verbose "template_file: #{entry.fetch( :file )} status=error reason=#{entry.fetch( :reason )}"
401
- next
402
- end
403
-
404
- file_path = File.join( repo_root, entry.fetch( :file ) )
405
- if entry.fetch( :status ) == "ok"
406
- puts_verbose "template_file: #{entry.fetch( :file )} status=ok reason=in_sync"
407
- next
408
- end
409
-
410
- FileUtils.mkdir_p( File.dirname( file_path ) )
411
- File.write( file_path, entry.fetch( :applied_content ) )
412
- puts_verbose "template_file: #{entry.fetch( :file )} status=updated reason=#{entry.fetch( :reason )}"
413
- applied += 1
414
- end
415
-
416
- removed = 0
417
- stale.each do |file|
418
- file_path = resolve_repo_path!( relative_path: file, label: "template.superseded_files entry #{file}" )
419
- File.delete( file_path )
420
- puts_verbose "template_file: #{file} status=removed reason=superseded"
421
- removed += 1
422
- end
423
-
424
- error_count = results.count { |entry| entry.fetch( :status ) == "error" }
425
- puts_verbose "template_apply_summary: updated=#{applied} removed=#{removed} error=#{error_count}"
426
- unless verbose?
427
- if applied.positive? || removed.positive?
428
- summary_parts = []
429
- summary_parts << "#{applied} updated" if applied.positive?
430
- summary_parts << "#{removed} removed" if removed.positive?
431
- puts_line "Templates applied (#{summary_parts.join( ", " )})."
432
- else
433
- puts_line "Templates in sync."
434
- end
435
- end
436
- return EXIT_ERROR if error_count.positive?
437
-
438
- return EXIT_BLOCK if push_prep && push_prep_commit!
439
- EXIT_OK
440
- end
441
-
442
- private
443
-
444
- # Orchestrates worktree-based template propagation to the remote.
445
- # Skips silently when there is no drift or no remote configured.
446
- # Returns a result hash stored in @template_sync_result.
447
- def template_propagate!( drift_count: )
448
- if drift_count.zero?
449
- puts_verbose "template_propagate: skip (no drift)"
450
- return { status: :skip, reason: "no drift" }
451
- end
452
-
453
- unless git_remote_exists?( remote_name: config.git_remote )
454
- puts_verbose "template_propagate: skip (no remote #{config.git_remote})"
455
- return { status: :skip, reason: "no remote" }
456
- end
457
-
458
- worktree_dir = nil
459
- begin
460
- worktree_dir = template_propagate_create_worktree!
461
- template_propagate_write_files!( worktree_dir: worktree_dir )
462
- committed = template_propagate_commit!( worktree_dir: worktree_dir )
463
- unless committed
464
- puts_verbose "template_propagate: skip (no changes after write)"
465
- return { status: :skip, reason: "no changes" }
466
- end
467
- result = template_propagate_deliver!( worktree_dir: worktree_dir )
468
- template_propagate_report!( result: result )
469
- result
470
- rescue StandardError => e
471
- puts_verbose "template_propagate: error (#{e.message})"
472
- { status: :error, reason: e.message }
473
- ensure
474
- template_propagate_cleanup!( worktree_dir: worktree_dir ) if worktree_dir
475
- end
476
- end
477
-
478
- # Creates a detached worktree from the remote main, checks out the sync branch,
479
- # and disables hooks so Carson's own pre-commit never fires inside the worktree.
480
- def template_propagate_create_worktree!
481
- worktree_dir = File.join( Dir.tmpdir, "carson-template-sync-#{Process.pid}-#{Time.now.to_i}" )
482
- wt_git = Adapters::Git.new( repo_root: worktree_dir )
483
-
484
- git_system!( "fetch", config.git_remote, config.main_branch )
485
- git_system!( "worktree", "add", "--detach", worktree_dir, "#{config.git_remote}/#{config.main_branch}" )
486
- wt_git.run( "checkout", "-B", TEMPLATE_SYNC_BRANCH )
487
- wt_git.run( "config", "core.hooksPath", "/dev/null" )
488
- puts_verbose "template_propagate: worktree created at #{worktree_dir}"
489
- worktree_dir
490
- end
491
-
492
- # Copies all Carson template source files into the worktree.
493
- # Also removes superseded files present in the worktree.
494
- def template_propagate_write_files!( worktree_dir: )
495
- config.template_managed_files.each do |managed_file|
496
- template_path = template_source_path( managed_file: managed_file )
497
- next if template_path.nil?
498
-
499
- target_path = File.join( worktree_dir, managed_file )
500
- FileUtils.mkdir_p( File.dirname( target_path ) )
501
- expected_content = normalize_text( text: File.read( template_path ) )
502
- File.write( target_path, expected_content )
503
- puts_verbose "template_propagate: wrote #{managed_file}"
504
- end
505
-
506
- template_superseded_present_in( root: worktree_dir ).each do |file|
507
- file_path = File.join( worktree_dir, file )
508
- File.delete( file_path )
509
- puts_verbose "template_propagate: removed superseded #{file}"
510
- end
511
- end
512
-
513
- # Stages all changes in the worktree and commits if there is an actual diff.
514
- # Returns true if a commit was created, false if worktree content matches remote.
515
- def template_propagate_commit!( worktree_dir: )
516
- wt_git = Adapters::Git.new( repo_root: worktree_dir )
517
- wt_git.run( "add", "--all" )
518
-
519
- _, _, no_diff, = wt_git.run( "diff", "--cached", "--quiet" )
520
- return false if no_diff
521
-
522
- wt_git.run( "commit", "-m", "chore: sync Carson #{Carson::VERSION} managed templates" )
523
- puts_verbose "template_propagate: committed"
524
- true
525
- end
526
-
527
- # Dispatches to trunk or branch delivery based on workflow style.
528
- def template_propagate_deliver!( worktree_dir: )
529
- if config.workflow_style == "trunk"
530
- template_propagate_deliver_trunk!( worktree_dir: worktree_dir )
531
- else
532
- template_propagate_deliver_branch!( worktree_dir: worktree_dir )
533
- end
534
- end
535
-
536
- # Trunk mode: push template changes directly to main.
537
- def template_propagate_deliver_trunk!( worktree_dir: )
538
- wt_git = Adapters::Git.new( repo_root: worktree_dir )
539
- stdout_text, stderr_text, success, = wt_git.run( "push", config.git_remote, "HEAD:refs/heads/#{config.main_branch}" )
540
- unless success
541
- error_text = stderr_text.to_s.strip
542
- error_text = "push to #{config.main_branch} failed" if error_text.empty?
543
- raise error_text
544
- end
545
- puts_verbose "template_propagate: pushed to #{config.main_branch}"
546
- { status: :pushed, ref: config.main_branch }
547
- end
548
-
549
- # Branch mode: force-push the sync branch and ensure a PR exists.
550
- def template_propagate_deliver_branch!( worktree_dir: )
551
- wt_git = Adapters::Git.new( repo_root: worktree_dir )
552
- stdout_text, stderr_text, success, = wt_git.run( "push", "--force-with-lease", config.git_remote, "#{TEMPLATE_SYNC_BRANCH}:#{TEMPLATE_SYNC_BRANCH}" )
553
- unless success
554
- error_text = stderr_text.to_s.strip
555
- error_text = "push #{TEMPLATE_SYNC_BRANCH} failed" if error_text.empty?
556
- raise error_text
557
- end
558
- puts_verbose "template_propagate: pushed #{TEMPLATE_SYNC_BRANCH}"
559
-
560
- pr_url = template_propagate_ensure_pr!( worktree_dir: worktree_dir )
561
- { status: :pr, branch: TEMPLATE_SYNC_BRANCH, pr_url: pr_url }
562
- end
563
-
564
- # Checks for an existing open PR from the sync branch; creates one if none exists.
565
- # Returns the PR URL.
566
- def template_propagate_ensure_pr!( worktree_dir: )
567
- wt_gh = Adapters::GitHub.new( repo_root: worktree_dir )
568
-
569
- # Check for existing open PR.
570
- stdout_text, _, success, = wt_gh.run(
571
- "pr", "list",
572
- "--head", TEMPLATE_SYNC_BRANCH,
573
- "--base", config.main_branch,
574
- "--state", "open",
575
- "--json", "url",
576
- "--jq", ".[0].url"
577
- )
578
- existing_url = stdout_text.to_s.strip
579
- if success && !existing_url.empty?
580
- puts_verbose "template_propagate: existing PR #{existing_url}"
581
- return existing_url
582
- end
583
-
584
- # Create new PR.
585
- stdout_text, stderr_text, success, = wt_gh.run(
586
- "pr", "create",
587
- "--head", TEMPLATE_SYNC_BRANCH,
588
- "--base", config.main_branch,
589
- "--title", "chore: sync Carson #{Carson::VERSION} managed templates",
590
- "--body", "Auto-generated by `carson refresh`.\n\nUpdates managed template files to match Carson #{Carson::VERSION}."
591
- )
592
- unless success
593
- error_text = stderr_text.to_s.strip
594
- error_text = "gh pr create failed" if error_text.empty?
595
- raise error_text
596
- end
597
- pr_url = stdout_text.to_s.strip
598
- puts_verbose "template_propagate: created PR #{pr_url}"
599
- pr_url
600
- end
601
-
602
- # Removes the worktree and the local sync branch it created.
603
- def template_propagate_cleanup!( worktree_dir: )
604
- git_run( "worktree", "remove", "--force", worktree_dir )
605
- git_run( "branch", "-D", TEMPLATE_SYNC_BRANCH )
606
- puts_verbose "template_propagate: worktree and local branch cleaned up"
607
- rescue StandardError => e
608
- puts_verbose "template_propagate: cleanup warning (#{e.message})"
609
- end
610
-
611
- # Prints a human-readable summary of the propagation result.
612
- def template_propagate_report!( result: )
613
- case result.fetch( :status )
614
- when :pushed
615
- puts_line "Templates pushed to #{result.fetch( :ref )}."
616
- when :pr
617
- puts_line "Template sync PR: #{result.fetch( :pr_url )}"
618
- end
619
- end
620
-
621
- # Checks which superseded files exist under an arbitrary root directory.
622
- def template_superseded_present_in( root: )
623
- config.template_superseded_files.select do |file|
624
- File.file?( File.join( root, file ) )
625
- end
626
- end
627
-
628
- def refresh_sync_suffix( result: )
629
- return "" if result.nil?
630
-
631
- case result.fetch( :status )
632
- when :pushed then " (templates pushed to #{result.fetch( :ref )})"
633
- when :pr then " (PR: #{result.fetch( :pr_url )})"
634
- else ""
635
- end
636
- end
637
-
638
- # Refreshes a single governed repository using a scoped Runtime.
639
- def refresh_single_repo( repo_path:, repo_name: )
640
- if verbose?
641
- rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err, verbose: true )
642
- else
643
- rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: StringIO.new, err: StringIO.new )
644
- end
645
- status = rt.refresh!
646
- label = refresh_status_label( status: status )
647
- sync_suffix = refresh_sync_suffix( result: rt.template_sync_result )
648
- puts_line "#{repo_name}: #{label}#{sync_suffix}"
649
- status
650
- rescue StandardError => e
651
- puts_line "#{repo_name}: FAIL (#{e.message})"
652
- EXIT_ERROR
653
- end
654
-
655
- def refresh_status_label( status: )
656
- case status
657
- when EXIT_OK then "OK"
658
- when EXIT_BLOCK then "BLOCK"
659
- else "FAIL"
660
- end
661
- end
662
-
663
- def template_results
664
- config.template_managed_files.map { |managed_file| template_result_for_file( managed_file: managed_file ) }
665
- end
666
-
667
- def template_superseded_present
668
- config.template_superseded_files.select do |file|
669
- file_path = resolve_repo_path!( relative_path: file, label: "template.superseded_files entry #{file}" )
670
- File.file?( file_path )
671
- end
672
- end
673
-
674
- # Calculates whole-file expected content and returns sync status plus apply payload.
675
- def template_result_for_file( managed_file: )
676
- template_path = template_source_path( managed_file: managed_file )
677
- return { file: managed_file, status: "error", reason: "missing template #{File.basename( managed_file )}", applied_content: nil } if template_path.nil?
678
-
679
- expected_content = normalize_text( text: File.read( template_path ) )
680
- file_path = resolve_repo_path!( relative_path: managed_file, label: "template.managed_files entry #{managed_file}" )
681
- return { file: managed_file, status: "drift", reason: "missing_file", applied_content: expected_content } unless File.file?( file_path )
682
-
683
- current_content = normalize_text( text: File.read( file_path ) )
684
- return { file: managed_file, status: "ok", reason: "in_sync", applied_content: current_content } if current_content == expected_content
685
-
686
- { file: managed_file, status: "drift", reason: "content_mismatch", applied_content: expected_content }
687
- end
688
-
689
- # Uses LF-only normalisation so platform newlines do not cause false drift.
690
- def normalize_text( text: )
691
- "#{text.to_s.gsub( "\r\n", "\n" ).rstrip}\n"
692
- end
693
-
694
- # GitHub managed template source directory inside Carson repository.
695
- def github_templates_dir
696
- File.join( tool_root, "templates", ".github" )
697
- end
698
-
699
- # Resolves the source file path for a managed template.
700
- # Checks the user's canonical directory first, then falls back to Carson's built-in templates.
701
- def template_source_path( managed_file: )
702
- relative_within_github = managed_file.delete_prefix( ".github/" )
703
-
704
- # Canonical source: the user's canonical .github/ files.
705
- canonical = config.template_canonical
706
- if canonical && !canonical.empty?
707
- canonical_path = File.join( canonical, relative_within_github )
708
- return canonical_path if File.file?( canonical_path )
709
- end
710
-
711
- # Carson built-in templates: subdirectory-aware path first, then flat basename fallback.
712
- template_path = File.join( github_templates_dir, relative_within_github )
713
- return template_path if File.file?( template_path )
714
-
715
- basename_path = File.join( github_templates_dir, File.basename( managed_file ) )
716
- return basename_path if File.file?( basename_path )
717
-
718
- nil
719
- end
720
-
721
- # Canonical hook template location inside Carson repository.
722
- def hook_template_path( hook_name: )
723
- File.join( tool_root, "hooks", hook_name )
724
- end
725
-
726
- # Reports full hook health and can enforce stricter action messaging in `check`.
727
- def hooks_health_report( strict: false )
728
- configured = configured_hooks_path
729
- expected = hooks_dir
730
- hooks_path_ok = print_hooks_path_status( configured: configured, expected: expected )
731
- print_required_hook_status
732
- hooks_integrity = hook_integrity_state
733
- hooks_ok = hooks_integrity_ok?( hooks_integrity: hooks_integrity )
734
- print_hook_action(
735
- strict: strict,
736
- hooks_ok: hooks_path_ok && hooks_ok,
737
- hooks_path_ok: hooks_path_ok,
738
- configured: configured,
739
- expected: expected
740
- )
741
- hooks_path_ok && hooks_ok
742
- end
743
-
744
- def print_hooks_path_status( configured:, expected: )
745
- configured_abs = configured.nil? ? nil : File.expand_path( configured )
746
- hooks_path_ok = configured_abs == expected
747
- puts_verbose "hooks_path: #{configured || '(unset)'}"
748
- puts_verbose "hooks_path_expected: #{expected}"
749
- puts_verbose( hooks_path_ok ? "hooks_path_status: ok" : "hooks_path_status: attention" )
750
- hooks_path_ok
751
- end
752
-
753
- def print_required_hook_status
754
- required_hook_paths.each do |path|
755
- exists = File.file?( path )
756
- symlink = File.symlink?( path )
757
- executable = exists && !symlink && File.executable?( path )
758
- puts_verbose "hook_file: #{relative_path( path )} exists=#{exists} symlink=#{symlink} executable=#{executable}"
759
- end
760
- end
761
-
762
- def hook_integrity_state
763
- {
764
- missing: missing_hook_files,
765
- non_executable: non_executable_hook_files,
766
- symlinked: symlink_hook_files
767
- }
768
- end
769
-
770
- def hooks_integrity_ok?( hooks_integrity: )
771
- missing_ok = hooks_integrity.fetch( :missing ).empty?
772
- non_executable_ok = hooks_integrity.fetch( :non_executable ).empty?
773
- symlinked_ok = hooks_integrity.fetch( :symlinked ).empty?
774
- missing_ok && non_executable_ok && symlinked_ok
775
- end
776
-
777
- def print_hook_action( strict:, hooks_ok:, hooks_path_ok:, configured:, expected: )
778
- return if hooks_ok
779
-
780
- if strict && !hooks_path_ok
781
- configured_text = configured.to_s.strip
782
- if configured_text.empty?
783
- puts_verbose "ACTION: hooks path is unset (expected=#{expected})."
784
- else
785
- puts_verbose "ACTION: hooks path mismatch (configured=#{configured_text}, expected=#{expected})."
786
- end
787
- end
788
- message = strict ? "ACTION: run carson prepare to align hooks with Carson #{Carson::VERSION}." : "ACTION: run carson prepare to enforce local main protections."
789
- puts_verbose message
790
- end
791
-
792
- # Returns ahead/behind counts for local main versus configured remote main.
793
- def main_sync_counts
794
- target = "#{config.main_branch}...#{config.git_remote}/#{config.main_branch}"
795
- stdout_text, stderr_text, success, = git_run( "rev-list", "--left-right", "--count", target )
796
- unless success
797
- error_text = stderr_text.to_s.strip
798
- error_text = "git rev-list failed" if error_text.empty?
799
- return [ 0, 0, error_text ]
800
- end
801
- counts = stdout_text.to_s.strip.split( /\s+/ )
802
- return [ 0, 0, "unexpected rev-list output: #{stdout_text.to_s.strip}" ] if counts.length < 2
803
-
804
- [ counts[ 0 ].to_i, counts[ 1 ].to_i, nil ]
805
- end
806
-
807
- # Reads configured core.hooksPath and normalises empty values to nil.
808
- def configured_hooks_path
809
- stdout_text, = git_capture_soft( "config", "--get", "core.hooksPath" )
810
- value = stdout_text.to_s.strip
811
- value.empty? ? nil : value
812
- end
813
-
814
- # Fully-qualified required hook file locations in the target repository.
815
- def required_hook_paths
816
- config.required_hooks.map { |name| File.join( hooks_dir, name ) }
817
- end
818
-
819
- # Missing required hook files.
820
- def missing_hook_files
821
- required_hook_paths.reject { |path| File.file?( path ) }.map { |path| relative_path( path ) }
822
- end
823
-
824
- # Required hook files that exist but are not executable.
825
- def non_executable_hook_files
826
- required_hook_paths.select { |path| File.file?( path ) && !File.executable?( path ) }.map { |path| relative_path( path ) }
827
- end
828
-
829
- # Symlink hooks are disallowed to prevent bypassing managed hook content.
830
- def symlink_hook_files
831
- required_hook_paths.select { |path| File.symlink?( path ) }.map { |path| relative_path( path ) }
832
- end
833
-
834
- # Local directory where managed hooks are installed.
835
- def hooks_dir
836
- File.expand_path( File.join( config.hooks_base_path, Carson::VERSION ) )
837
- end
838
-
839
- # In outsider mode, Carson must not leave Carson-owned fingerprints in host repositories.
840
- def block_if_outsider_fingerprints!
841
- return nil unless outsider_mode?
842
-
843
- violations = outsider_fingerprint_violations
844
- return nil if violations.empty?
845
-
846
- violations.each { |entry| puts_line "BLOCK: #{entry}" }
847
- EXIT_BLOCK
848
- end
849
-
850
- # Carson source repository itself is excluded from host-repository fingerprint checks.
851
- def outsider_mode?
852
- File.expand_path( repo_root ) != File.expand_path( tool_root )
853
- end
854
-
855
- # Detects Carson-owned host artefacts that violate outsider boundary.
856
- def outsider_fingerprint_violations
857
- violations = []
858
- violations << "forbidden file .carson.yml detected" if File.file?( File.join( repo_root, ".carson.yml" ) )
859
- violations << "forbidden file bin/carson detected" if File.file?( File.join( repo_root, "bin", "carson" ) )
860
- violations << "forbidden directory .tools/carson detected" if Dir.exist?( File.join( repo_root, ".tools", "carson" ) )
861
- violations
862
- end
863
-
864
- # Detects local branches whose upstream tracking is marked [gone] after fetch --prune.
865
- # Branches without upstream tracking are handled separately by orphan_local_branches.
866
- def stale_local_branches
867
- git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
868
- branch, upstream, track = line.strip.split( "\t", 3 )
869
- upstream = upstream.to_s
870
- track = track.to_s
871
- next if branch.to_s.empty? || upstream.empty?
872
- next unless upstream.start_with?( "#{config.git_remote}/" ) && track.include?( "gone" )
873
-
874
- { branch: branch, upstream: upstream, track: track }
875
- end.compact
876
- end
877
-
878
- # Detects local branches with no upstream tracking ref — candidates for orphan pruning.
879
- # Filters out protected branches, the active branch, and Carson's own sync branch.
880
- def orphan_local_branches( active_branch: )
881
- git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads" ).lines.filter_map do |line|
882
- branch, upstream = line.strip.split( "\t", 2 )
883
- branch = branch.to_s.strip
884
- upstream = upstream.to_s.strip
885
- next if branch.empty?
886
- next unless upstream.empty?
887
- next if config.protected_branches.include?( branch )
888
- next if branch == active_branch
889
- next if branch == TEMPLATE_SYNC_BRANCH
890
-
891
- branch
892
- end
893
- end
894
-
895
- # Processes orphan branches: verifies merged PR evidence via GitHub API before deleting.
896
- def prune_orphan_branch_entries( orphan_branches:, counters: )
897
- return counters if orphan_branches.empty?
898
- return counters unless gh_available?
899
-
900
- orphan_branches.each do |branch|
901
- outcome = prune_orphan_branch_entry( branch: branch )
902
- counters[ outcome ] += 1
903
- end
904
- counters
905
- end
906
-
907
- # Checks a single orphan branch for merged PR evidence and force-deletes if confirmed.
908
- def prune_orphan_branch_entry( branch: )
909
- tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
910
- unless tip_sha_success
911
- error_text = tip_sha_error.to_s.strip
912
- error_text = "unable to read local branch tip sha" if error_text.empty?
913
- puts_verbose "skip_orphan_branch: #{branch} reason=#{error_text}"
914
- return :skipped
915
- end
916
- branch_tip_sha = tip_sha_text.to_s.strip
917
- if branch_tip_sha.empty?
918
- puts_verbose "skip_orphan_branch: #{branch} reason=unable to read local branch tip sha"
919
- return :skipped
920
- end
921
-
922
- merged_pr, error = merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
923
- if merged_pr.nil?
924
- reason = error.to_s.strip
925
- reason = "no merged PR evidence for branch tip into #{config.main_branch}" if reason.empty?
926
- puts_verbose "skip_orphan_branch: #{branch} reason=#{reason}"
927
- return :skipped
928
- end
929
-
930
- force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
931
- if force_success
932
- out.print force_stdout if verbose? && !force_stdout.empty?
933
- puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
934
- return :deleted
935
- end
936
-
937
- force_error_text = normalise_branch_delete_error( error_text: force_stderr )
938
- puts_verbose "fail_delete_orphan_branch: #{branch} reason=#{force_error_text}"
939
- :skipped
940
- end
941
-
942
- # Safe delete can fail after squash merges because branch tip is no longer an ancestor.
943
- def non_merged_delete_error?( error_text: )
944
- error_text.to_s.downcase.include?( "not fully merged" )
945
- end
946
-
947
- # Guarded force-delete policy for stale branches:
948
- # 1) safe delete failure must be merge-related (`not fully merged`),
949
- # 2) gh must confirm at least one merged PR for this exact branch into configured main.
950
- def force_delete_evidence_for_stale_branch( branch:, delete_error_text: )
951
- return [ nil, "safe delete failure is not merge-related" ] unless non_merged_delete_error?( error_text: delete_error_text )
952
- return [ nil, "gh CLI not available; cannot verify merged PR evidence" ] unless gh_available?
953
-
954
- tip_sha_text, tip_sha_error, tip_sha_success, = git_run( "rev-parse", "--verify", branch.to_s )
955
- unless tip_sha_success
956
- error_text = tip_sha_error.to_s.strip
957
- error_text = "unable to read local branch tip sha" if error_text.empty?
958
- return [ nil, error_text ]
959
- end
960
- branch_tip_sha = tip_sha_text.to_s.strip
961
- return [ nil, "unable to read local branch tip sha" ] if branch_tip_sha.empty?
962
-
963
- merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
964
- end
965
-
966
- # Finds merged PR evidence for the exact local branch tip; this blocks old-PR false positives.
967
- def merged_pr_for_branch( branch:, branch_tip_sha: )
968
- owner, repo = repository_coordinates
969
- results = []
970
- page = 1
971
- max_pages = 50
972
- loop do
973
- stdout_text, stderr_text, success, = gh_run(
974
- "api", "repos/#{owner}/#{repo}/pulls",
975
- "--method", "GET",
976
- "-f", "state=closed",
977
- "-f", "base=#{config.main_branch}",
978
- "-f", "head=#{owner}:#{branch}",
979
- "-f", "sort=updated",
980
- "-f", "direction=desc",
981
- "-f", "per_page=100",
982
- "-f", "page=#{page}"
983
- )
984
- unless success
985
- error_text = gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to query merged PR evidence for branch #{branch}" )
986
- return [ nil, error_text ]
987
- end
988
- page_nodes = Array( JSON.parse( stdout_text ) )
989
- break if page_nodes.empty?
990
-
991
- page_nodes.each do |entry|
992
- next unless entry.dig( "head", "ref" ).to_s == branch.to_s
993
- next unless entry.dig( "base", "ref" ).to_s == config.main_branch
994
- next unless entry.dig( "head", "sha" ).to_s == branch_tip_sha
995
-
996
- merged_at = parse_time_or_nil( text: entry[ "merged_at" ] )
997
- next if merged_at.nil?
998
-
999
- results << {
1000
- number: entry[ "number" ],
1001
- url: entry[ "html_url" ].to_s,
1002
- merged_at: merged_at.utc.iso8601,
1003
- head_sha: entry.dig( "head", "sha" ).to_s
1004
- }
1005
- end
1006
- if page >= max_pages
1007
- probe_stdout_text, probe_stderr_text, probe_success, = gh_run(
1008
- "api", "repos/#{owner}/#{repo}/pulls",
1009
- "--method", "GET",
1010
- "-f", "state=closed",
1011
- "-f", "base=#{config.main_branch}",
1012
- "-f", "head=#{owner}:#{branch}",
1013
- "-f", "sort=updated",
1014
- "-f", "direction=desc",
1015
- "-f", "per_page=100",
1016
- "-f", "page=#{page + 1}"
1017
- )
1018
- unless probe_success
1019
- 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}" )
1020
- return [ nil, error_text ]
1021
- end
1022
- probe_nodes = Array( JSON.parse( probe_stdout_text ) )
1023
- return [ nil, "merged PR lookup exceeded pagination safety limit (#{max_pages} pages) for branch #{branch}" ] unless probe_nodes.empty?
1024
- break
1025
- end
1026
- page += 1
1027
- end
1028
- latest = results.max_by { |item| item.fetch( :merged_at ) }
1029
- return [ nil, "no merged PR evidence for branch tip #{branch_tip_sha} into #{config.main_branch}" ] if latest.nil?
1030
-
1031
- [ latest, nil ]
1032
- rescue JSON::ParserError => e
1033
- [ nil, "invalid gh JSON response (#{e.message})" ]
1034
- rescue StandardError => e
1035
- [ nil, e.message ]
1036
- end
1037
-
1038
- def working_tree_clean?
1039
- git_capture!( "status", "--porcelain" ).strip.empty?
1040
- end
1041
-
1042
- def push_prep_commit!
1043
- # JIT auto-commit is for feature branches only; main is protected from direct commits.
1044
- return if current_branch == config.main_branch
1045
-
1046
- dirty = managed_dirty_paths
1047
- return if dirty.empty?
1048
-
1049
- git_system!( "add", *dirty )
1050
- git_system!( "commit", "-m", "chore: sync Carson managed files" )
1051
- puts_line "Carson committed managed file updates. Push again to include them."
1052
- true
1053
- end
1054
-
1055
- def managed_dirty_paths
1056
- template_paths = config.template_managed_files + config.template_superseded_files
1057
- linters_glob = Dir.glob( File.join( repo_root, ".github/linters/**/*" ) )
1058
- .select { |p| File.file?( p ) }
1059
- .map { |p| p.delete_prefix( "#{repo_root}/" ) }
1060
- candidates = ( template_paths + linters_glob ).uniq
1061
- return [] if candidates.empty?
1062
-
1063
- stdout_text, = git_capture_soft( "status", "--porcelain", "--", *candidates )
1064
- stdout_text.to_s.lines
1065
- .map { |l| l[ 3.. ].strip }
1066
- .reject( &:empty? )
1067
- end
1068
-
1069
- def inside_git_work_tree?
1070
- stdout_text, = git_capture_soft( "rev-parse", "--is-inside-work-tree" )
1071
- stdout_text.to_s.strip == "true"
1072
- end
1073
-
1074
- def disable_carson_hooks_path!
1075
- configured = configured_hooks_path
1076
- if configured.nil?
1077
- puts_verbose "hooks_path: (unset)"
1078
- return EXIT_OK
1079
- end
1080
- puts_verbose "hooks_path: #{configured}"
1081
- configured_abs = File.expand_path( configured, repo_root )
1082
- unless carson_managed_hooks_path?( configured_abs: configured_abs )
1083
- puts_verbose "hooks_path_kept: #{configured} (not Carson-managed)"
1084
- return EXIT_OK
1085
- end
1086
- git_system!( "config", "--unset", "core.hooksPath" )
1087
- puts_verbose "hooks_path_unset: core.hooksPath"
1088
- EXIT_OK
1089
- rescue StandardError => e
1090
- puts_line "ERROR: unable to update core.hooksPath (#{e.message})"
1091
- EXIT_ERROR
1092
- end
1093
-
1094
- def carson_managed_hooks_path?( configured_abs: )
1095
- hooks_root = File.join( File.expand_path( config.hooks_base_path ), "" )
1096
- return true if configured_abs.start_with?( hooks_root )
1097
-
1098
- carson_hook_files_match_templates?( hooks_path: configured_abs )
1099
- end
1100
-
1101
- def carson_hook_files_match_templates?( hooks_path: )
1102
- return false unless Dir.exist?( hooks_path )
1103
- config.required_hooks.all? do |hook_name|
1104
- installed_path = File.join( hooks_path, hook_name )
1105
- template_path = hook_template_path( hook_name: hook_name )
1106
- next false unless File.file?( installed_path ) && File.file?( template_path )
1107
-
1108
- installed_content = normalize_text( text: File.read( installed_path ) )
1109
- template_content = normalize_text( text: File.read( template_path ) )
1110
- installed_content == template_content
1111
- end
1112
- rescue StandardError
1113
- false
1114
- end
1115
-
1116
- def offboard_cleanup_targets
1117
- ( config.template_managed_files + config.template_superseded_files + [
1118
- ".github/workflows/carson-governance.yml",
1119
- ".github/workflows/carson_policy.yml",
1120
- ".carson.yml",
1121
- "bin/carson",
1122
- ".tools/carson"
1123
- ] ).uniq
1124
- end
1125
-
1126
- def remove_empty_offboard_directories!
1127
- [ ".github/workflows", ".github", ".tools", "bin" ].each do |relative|
1128
- absolute = resolve_repo_path!( relative_path: relative, label: "offboard cleanup directory #{relative}" )
1129
- next unless Dir.exist?( absolute )
1130
- next unless Dir.empty?( absolute )
1131
-
1132
- Dir.rmdir( absolute )
1133
- puts_verbose "removed_empty_dir: #{relative}"
1134
- end
1135
- end
1136
-
1137
- # Verifies configured remote exists and logs status without mutating remotes.
1138
- def report_detected_remote!
1139
- if git_remote_exists?( remote_name: config.git_remote )
1140
- puts_verbose "remote_ok: #{config.git_remote}"
1141
- else
1142
- puts_line "WARN: remote '#{config.git_remote}' not found; run carson setup to configure."
1143
- end
1144
- end
1145
-
1146
- # Concise onboard orchestration: hooks, templates, remote, audit, guidance.
1147
- def onboard_apply!
1148
- hook_status = with_captured_output { prepare! }
1149
- return hook_status unless hook_status == EXIT_OK
1150
- puts_line "Hooks installed (#{config.required_hooks.count} hooks)."
1151
-
1152
- template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
1153
- template_status = with_captured_output { template_apply! }
1154
- return template_status unless template_status == EXIT_OK
1155
- if template_drift_count.positive?
1156
- puts_line "Templates synced (#{template_drift_count} file#{plural_suffix( count: template_drift_count )} updated)."
1157
- else
1158
- puts_line "Templates in sync."
1159
- end
1160
-
1161
- onboard_report_remote!
1162
- audit_status = onboard_run_audit!
1163
-
1164
- puts_line ""
1165
- puts_line "Carson at your service."
1166
-
1167
- prompt_govern_registration! if self.in.respond_to?( :tty? ) && self.in.tty?
1168
-
1169
- puts_line ""
1170
- puts_line "Your repository is set up. Carson has placed files in your"
1171
- puts_line "project's .github/ directory — pull request templates,"
1172
- puts_line "guidelines for AI coding assistants, and any CI or lint"
1173
- puts_line "rules you've configured. Once pushed to GitHub, they'll"
1174
- puts_line "ensure every pull request follows a consistent standard"
1175
- puts_line "and all checks run automatically."
1176
- puts_line ""
1177
- puts_line "Before your first push, have a look through .github/ to"
1178
- puts_line "make sure everything is to your liking."
1179
- puts_line ""
1180
- puts_line "To adjust any setting: carson setup"
1181
-
1182
- audit_status
1183
- end
1184
-
1185
- # Friendly remote status for onboard output.
1186
- def onboard_report_remote!
1187
- if git_remote_exists?( remote_name: config.git_remote )
1188
- puts_line "Remote: #{config.git_remote} (connected)."
1189
- else
1190
- puts_line "Remote not configured yet — carson setup will walk you through it."
1191
- end
1192
- end
1193
-
1194
- # Runs audit with captured output; reports summary instead of full detail.
1195
- def onboard_run_audit!
1196
- audit_error = nil
1197
- audit_status = with_captured_output { audit! }
1198
- rescue StandardError => e
1199
- audit_error = e
1200
- audit_status = EXIT_OK
1201
- ensure
1202
- return onboard_print_audit_result( status: audit_status, error: audit_error )
1203
- end
1204
-
1205
- def onboard_print_audit_result( status:, error: )
1206
- if error
1207
- if error.message.to_s.match?( /HEAD|rev-parse/ )
1208
- puts_line "No commits yet — run carson audit after your first commit."
1209
- else
1210
- puts_line "Audit skipped — run carson audit for details."
1211
- end
1212
- return EXIT_OK
1213
- end
1214
-
1215
- if status == EXIT_BLOCK
1216
- puts_line "Some checks need attention — run carson audit for details."
1217
- end
1218
- status
1219
- end
1220
-
1221
- # Uses `git remote get-url` as existence check to avoid parsing remote lists.
1222
- def git_remote_exists?( remote_name: )
1223
- _, _, success, = git_run( "remote", "get-url", remote_name.to_s )
1224
- success
1225
- end
1226
- end
1227
-
1228
9
  include Local
1229
10
  end
1230
11
  end