carson 3.20.0 → 3.21.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.
- checksums.yaml +4 -4
- data/README.md +11 -3
- data/RELEASE.md +14 -0
- data/VERSION +1 -1
- data/exe/carson +3 -3
- data/hooks/command-guard +56 -0
- data/hooks/pre-push +37 -1
- data/lib/carson/adapters/agent.rb +1 -0
- data/lib/carson/adapters/claude.rb +2 -0
- data/lib/carson/adapters/codex.rb +2 -0
- data/lib/carson/adapters/git.rb +2 -0
- data/lib/carson/adapters/github.rb +2 -0
- data/lib/carson/adapters/prompt.rb +2 -0
- data/lib/carson/cli.rb +415 -414
- data/lib/carson/config.rb +4 -3
- data/lib/carson/runtime/audit.rb +27 -27
- data/lib/carson/runtime/deliver.rb +7 -4
- data/lib/carson/runtime/govern.rb +27 -27
- data/lib/carson/runtime/housekeep.rb +15 -15
- data/lib/carson/runtime/local/hooks.rb +20 -0
- data/lib/carson/runtime/local/onboard.rb +17 -17
- data/lib/carson/runtime/local/prune.rb +13 -13
- data/lib/carson/runtime/local/sync.rb +6 -6
- data/lib/carson/runtime/local/template.rb +26 -25
- data/lib/carson/runtime/local/worktree.rb +26 -26
- data/lib/carson/runtime/local.rb +1 -0
- data/lib/carson/runtime/repos.rb +1 -1
- data/lib/carson/runtime/review/gate_support.rb +2 -2
- data/lib/carson/runtime/review/sweep_support.rb +2 -2
- data/lib/carson/runtime/review.rb +10 -8
- data/lib/carson/runtime/setup.rb +8 -6
- data/lib/carson/runtime/status.rb +20 -20
- data/lib/carson/runtime.rb +39 -25
- data/lib/carson/version.rb +1 -0
- data/lib/carson.rb +1 -0
- data/templates/.github/carson.md +7 -4
- metadata +2 -1
data/lib/carson/config.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Loads and validates Carson configuration from global config and environment overrides.
|
|
1
2
|
require "json"
|
|
2
3
|
|
|
3
4
|
module Carson
|
|
@@ -85,8 +86,8 @@ module Carson
|
|
|
85
86
|
parsed = JSON.parse( raw )
|
|
86
87
|
raise ConfigError, "global config must be a JSON object at #{path}" unless parsed.is_a?( Hash )
|
|
87
88
|
parsed
|
|
88
|
-
rescue JSON::ParserError =>
|
|
89
|
-
raise ConfigError, "invalid global config JSON at #{path} (#{
|
|
89
|
+
rescue JSON::ParserError => exception
|
|
90
|
+
raise ConfigError, "invalid global config JSON at #{path} (#{exception.message})"
|
|
90
91
|
end
|
|
91
92
|
|
|
92
93
|
def self.global_config_path( repo_root: )
|
|
@@ -212,7 +213,7 @@ module Carson
|
|
|
212
213
|
@audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
|
|
213
214
|
|
|
214
215
|
govern_hash = fetch_hash( hash: data, key: "govern" )
|
|
215
|
-
@govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |
|
|
216
|
+
@govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
|
|
216
217
|
@govern_auto_merge = fetch_optional_boolean( hash: govern_hash, key: "auto_merge", default: true, key_path: "govern.auto_merge" )
|
|
217
218
|
@govern_merge_method = fetch_string( hash: govern_hash, key: "merge_method" ).downcase
|
|
218
219
|
govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
|
data/lib/carson/runtime/audit.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Carson
|
|
|
11
11
|
return fingerprint_status unless fingerprint_status.nil?
|
|
12
12
|
unless head_exists?
|
|
13
13
|
if json_output
|
|
14
|
-
|
|
14
|
+
output.puts JSON.pretty_generate( { command: "audit", status: "skipped", reason: "no commits yet", exit_code: EXIT_OK } )
|
|
15
15
|
else
|
|
16
16
|
puts_line "No commits yet — audit skipped for initial commit."
|
|
17
17
|
end
|
|
@@ -69,24 +69,24 @@ module Carson
|
|
|
69
69
|
audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( monitor_report.fetch( :status ) )
|
|
70
70
|
if monitor_report.fetch( :status ) == "attention"
|
|
71
71
|
checks = monitor_report.fetch( :checks )
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
failing_count = checks.fetch( :failing_count )
|
|
73
|
+
pending_count = checks.fetch( :pending_count )
|
|
74
74
|
total = checks.fetch( :required_total )
|
|
75
75
|
fail_names = checks.fetch( :failing ).map { it.fetch( :name ) }.join( ", " )
|
|
76
|
-
if
|
|
77
|
-
audit_concise_problems << "Checks: #{
|
|
78
|
-
elsif
|
|
79
|
-
audit_concise_problems << "Checks: #{
|
|
80
|
-
elsif
|
|
81
|
-
audit_concise_problems << "Checks: pending (#{total -
|
|
76
|
+
if failing_count.positive? && pending_count.positive?
|
|
77
|
+
audit_concise_problems << "Checks: #{failing_count} failing (#{fail_names}), #{pending_count} pending of #{total} required."
|
|
78
|
+
elsif failing_count.positive?
|
|
79
|
+
audit_concise_problems << "Checks: #{failing_count} of #{total} failing (#{fail_names})."
|
|
80
|
+
elsif pending_count.positive?
|
|
81
|
+
audit_concise_problems << "Checks: pending (#{total - pending_count} of #{total} complete)."
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
puts_verbose ""
|
|
85
85
|
puts_verbose "[Default Branch CI Baseline (gh)]"
|
|
86
86
|
default_branch_baseline = default_branch_ci_baseline_report
|
|
87
87
|
audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( default_branch_baseline.fetch( :status ) )
|
|
88
|
-
|
|
89
|
-
if
|
|
88
|
+
baseline_status = default_branch_baseline.fetch( :status )
|
|
89
|
+
if baseline_status == "block"
|
|
90
90
|
parts = []
|
|
91
91
|
if default_branch_baseline.fetch( :failing_count ).positive?
|
|
92
92
|
names = default_branch_baseline.fetch( :failing ).map { it.fetch( :name ) }.join( ", " )
|
|
@@ -98,7 +98,7 @@ module Carson
|
|
|
98
98
|
end
|
|
99
99
|
parts << "no check-runs for active workflows" if default_branch_baseline.fetch( :no_check_evidence )
|
|
100
100
|
audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )} — fix before merge."
|
|
101
|
-
elsif
|
|
101
|
+
elsif baseline_status == "attention"
|
|
102
102
|
parts = []
|
|
103
103
|
if default_branch_baseline.fetch( :advisory_failing_count ).positive?
|
|
104
104
|
names = default_branch_baseline.fetch( :advisory_failing ).map { it.fetch( :name ) }.join( ", " )
|
|
@@ -143,7 +143,7 @@ module Carson
|
|
|
143
143
|
problems: audit_concise_problems,
|
|
144
144
|
exit_code: exit_code
|
|
145
145
|
}
|
|
146
|
-
|
|
146
|
+
output.puts JSON.pretty_generate( result )
|
|
147
147
|
else
|
|
148
148
|
puts_verbose ""
|
|
149
149
|
puts_verbose "[Audit Result]"
|
|
@@ -182,8 +182,8 @@ module Carson
|
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
begin
|
|
185
|
-
|
|
186
|
-
status =
|
|
185
|
+
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
186
|
+
status = scoped_runtime.audit!
|
|
187
187
|
case status
|
|
188
188
|
when EXIT_OK
|
|
189
189
|
puts_line "#{repo_name}: ok" unless verbose?
|
|
@@ -197,9 +197,9 @@ module Carson
|
|
|
197
197
|
record_batch_skip( command: "audit", repo_path: repo_path, reason: "audit failed" )
|
|
198
198
|
failed += 1
|
|
199
199
|
end
|
|
200
|
-
rescue StandardError =>
|
|
201
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
202
|
-
record_batch_skip( command: "audit", repo_path: repo_path, reason:
|
|
200
|
+
rescue StandardError => exception
|
|
201
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
202
|
+
record_batch_skip( command: "audit", repo_path: repo_path, reason: exception.message )
|
|
203
203
|
failed += 1
|
|
204
204
|
end
|
|
205
205
|
end
|
|
@@ -277,9 +277,9 @@ module Carson
|
|
|
277
277
|
report.dig( :checks, :pending ).each { |entry| puts_verbose "check_pending: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
|
|
278
278
|
report[ :status ] = "attention" if report.dig( :checks, :failing_count ).positive? || report.dig( :checks, :pending_count ).positive?
|
|
279
279
|
report
|
|
280
|
-
rescue JSON::ParserError =>
|
|
280
|
+
rescue JSON::ParserError => exception
|
|
281
281
|
report[ :status ] = "skipped"
|
|
282
|
-
report[ :skip_reason ] = "invalid gh JSON response (#{
|
|
282
|
+
report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
|
|
283
283
|
puts_verbose "SKIP: #{report.fetch( :skip_reason )}"
|
|
284
284
|
report
|
|
285
285
|
end
|
|
@@ -374,14 +374,14 @@ module Carson
|
|
|
374
374
|
puts_verbose "ACTION: default branch has workflow files but no check-runs; align workflow triggers and branch protection check names."
|
|
375
375
|
end
|
|
376
376
|
report
|
|
377
|
-
rescue JSON::ParserError =>
|
|
377
|
+
rescue JSON::ParserError => exception
|
|
378
378
|
report[ :status ] = "skipped"
|
|
379
|
-
report[ :skip_reason ] = "invalid gh JSON response (#{
|
|
379
|
+
report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
|
|
380
380
|
puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
|
|
381
381
|
report
|
|
382
|
-
rescue StandardError =>
|
|
382
|
+
rescue StandardError => exception
|
|
383
383
|
report[ :status ] = "skipped"
|
|
384
|
-
report[ :skip_reason ] =
|
|
384
|
+
report[ :skip_reason ] = exception.message
|
|
385
385
|
puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
|
|
386
386
|
report
|
|
387
387
|
end
|
|
@@ -443,7 +443,7 @@ module Carson
|
|
|
443
443
|
end
|
|
444
444
|
|
|
445
445
|
# Returns true when a required-check entry is in a non-passing, non-pending state.
|
|
446
|
-
# Cancelled, errored, timed-
|
|
446
|
+
# Cancelled, errored, timed-output, and any unknown bucket all count as failing.
|
|
447
447
|
def check_entry_failing?( entry: )
|
|
448
448
|
!%w[pass pending].include?( entry[ "bucket" ].to_s )
|
|
449
449
|
end
|
|
@@ -487,8 +487,8 @@ module Carson
|
|
|
487
487
|
markdown_path, json_path = write_pr_monitor_report( report: report )
|
|
488
488
|
puts_verbose "report_markdown: #{markdown_path}"
|
|
489
489
|
puts_verbose "report_json: #{json_path}"
|
|
490
|
-
rescue StandardError =>
|
|
491
|
-
puts_verbose "report_write: SKIP (#{
|
|
490
|
+
rescue StandardError => exception
|
|
491
|
+
puts_verbose "report_write: SKIP (#{exception.message})"
|
|
492
492
|
end
|
|
493
493
|
|
|
494
494
|
# Persists report in both machine-readable JSON and human-readable Markdown.
|
|
@@ -90,7 +90,7 @@ module Carson
|
|
|
90
90
|
result[ :exit_code ] = exit_code
|
|
91
91
|
|
|
92
92
|
if json_output
|
|
93
|
-
|
|
93
|
+
output.puts JSON.pretty_generate( result )
|
|
94
94
|
else
|
|
95
95
|
print_deliver_human( result: result )
|
|
96
96
|
end
|
|
@@ -136,8 +136,11 @@ module Carson
|
|
|
136
136
|
end
|
|
137
137
|
|
|
138
138
|
# Pushes the branch to the remote with tracking.
|
|
139
|
+
# Sets CARSON_PUSH=1 so the pre-push hook knows this is a Carson-managed push.
|
|
139
140
|
def push_branch!( branch:, remote:, result: )
|
|
140
|
-
_, push_stderr, push_success, =
|
|
141
|
+
_, push_stderr, push_success, = with_env_var( "CARSON_PUSH", "1" ) do
|
|
142
|
+
git_run( "push", "-u", remote, branch )
|
|
143
|
+
end
|
|
141
144
|
unless push_success
|
|
142
145
|
error_text = push_stderr.to_s.strip
|
|
143
146
|
error_text = "push failed" if error_text.empty?
|
|
@@ -253,7 +256,7 @@ module Carson
|
|
|
253
256
|
# Merges the PR using the configured merge method.
|
|
254
257
|
# Deliberately omits --delete-branch: gh tries to switch the local
|
|
255
258
|
# checkout to main afterwards, which fails inside a worktree where
|
|
256
|
-
# main is already checked
|
|
259
|
+
# main is already checked output. Branch cleanup deferred to `carson prune`.
|
|
257
260
|
def merge_pr!( number:, result: )
|
|
258
261
|
method = config.govern_merge_method
|
|
259
262
|
result[ :merge_method ] = method
|
|
@@ -277,7 +280,7 @@ module Carson
|
|
|
277
280
|
# Syncs main after a successful merge.
|
|
278
281
|
# Pulls into the main worktree directly — does not attempt checkout,
|
|
279
282
|
# because checkout would fail when running inside a feature worktree
|
|
280
|
-
# (main is already checked
|
|
283
|
+
# (main is already checked output in the main tree).
|
|
281
284
|
def sync_after_merge!( remote:, main:, result: )
|
|
282
285
|
main_root = main_worktree_root
|
|
283
286
|
_, pull_stderr, pull_success, = Open3.capture3(
|
|
@@ -55,8 +55,8 @@ module Carson
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
EXIT_OK
|
|
58
|
-
rescue StandardError =>
|
|
59
|
-
puts_line "ERROR: govern failed — #{
|
|
58
|
+
rescue StandardError => exception
|
|
59
|
+
puts_line "ERROR: govern failed — #{exception.message}"
|
|
60
60
|
EXIT_ERROR
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -69,8 +69,8 @@ module Carson
|
|
|
69
69
|
puts_line "── cycle #{cycle_count} at #{Time.now.utc.strftime( "%Y-%m-%d %H:%M:%S UTC" )} ──"
|
|
70
70
|
begin
|
|
71
71
|
govern_cycle!( dry_run: dry_run, json_output: json_output )
|
|
72
|
-
rescue StandardError =>
|
|
73
|
-
puts_line "ERROR: cycle #{cycle_count} failed — #{
|
|
72
|
+
rescue StandardError => exception
|
|
73
|
+
puts_line "ERROR: cycle #{cycle_count} failed — #{exception.message}"
|
|
74
74
|
end
|
|
75
75
|
puts_line "sleeping #{loop_seconds}s until next cycle…"
|
|
76
76
|
sleep loop_seconds
|
|
@@ -145,8 +145,8 @@ module Carson
|
|
|
145
145
|
return nil
|
|
146
146
|
end
|
|
147
147
|
JSON.parse( stdout_text )
|
|
148
|
-
rescue JSON::ParserError =>
|
|
149
|
-
puts_line "gh pr list returned invalid JSON: #{
|
|
148
|
+
rescue JSON::ParserError => exception
|
|
149
|
+
puts_line "gh pr list returned invalid JSON: #{exception.message}"
|
|
150
150
|
nil
|
|
151
151
|
end
|
|
152
152
|
|
|
@@ -345,13 +345,13 @@ module Carson
|
|
|
345
345
|
|
|
346
346
|
# Runs sync + prune in the given repo after a successful merge.
|
|
347
347
|
def housekeep_repo!( repo_path: )
|
|
348
|
-
|
|
348
|
+
scoped_runtime = if repo_path == self.repo_root
|
|
349
349
|
self
|
|
350
350
|
else
|
|
351
|
-
Runtime.new( repo_root: repo_path, tool_root: tool_root,
|
|
351
|
+
Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
|
|
352
352
|
end
|
|
353
|
-
sync_status =
|
|
354
|
-
|
|
353
|
+
sync_status = scoped_runtime.sync!
|
|
354
|
+
scoped_runtime.prune! if sync_status == EXIT_OK
|
|
355
355
|
end
|
|
356
356
|
|
|
357
357
|
# Selects which agent provider to use based on config and availability.
|
|
@@ -410,18 +410,18 @@ module Carson
|
|
|
410
410
|
|
|
411
411
|
# Evidence gathering — builds structured context Hash for agent work orders.
|
|
412
412
|
def evidence( pr:, repo_path:, objective: )
|
|
413
|
-
|
|
413
|
+
context = { title: pr.fetch( "title", "" ) }
|
|
414
414
|
case objective
|
|
415
415
|
when "fix_ci"
|
|
416
|
-
|
|
416
|
+
context.merge!( ci_evidence( pr: pr, repo_path: repo_path ) )
|
|
417
417
|
when "address_review"
|
|
418
|
-
|
|
418
|
+
context.merge!( review_evidence( pr: pr, repo_path: repo_path ) )
|
|
419
419
|
end
|
|
420
420
|
prior = prior_attempt( pr: pr, repo_path: repo_path )
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
rescue StandardError =>
|
|
424
|
-
puts_line " evidence gathering failed: #{
|
|
421
|
+
context[ :prior_attempt ] = prior if prior
|
|
422
|
+
context
|
|
423
|
+
rescue StandardError => exception
|
|
424
|
+
puts_line " evidence gathering failed: #{exception.message}"
|
|
425
425
|
{ title: pr.fetch( "title", "" ) }
|
|
426
426
|
end
|
|
427
427
|
|
|
@@ -452,8 +452,8 @@ module Carson
|
|
|
452
452
|
return { ci_run_url: run_url } unless log_status.success?
|
|
453
453
|
|
|
454
454
|
{ ci_logs: truncate_log( text: log_stdout ), ci_run_url: run_url }
|
|
455
|
-
rescue StandardError =>
|
|
456
|
-
puts_line " ci_evidence failed: #{
|
|
455
|
+
rescue StandardError => exception
|
|
456
|
+
puts_line " ci_evidence failed: #{exception.message}"
|
|
457
457
|
{}
|
|
458
458
|
end
|
|
459
459
|
|
|
@@ -464,13 +464,13 @@ module Carson
|
|
|
464
464
|
end
|
|
465
465
|
|
|
466
466
|
def review_evidence( pr:, repo_path: )
|
|
467
|
-
|
|
468
|
-
owner, repo =
|
|
467
|
+
scoped_runtime = scoped_runtime( repo_path: repo_path )
|
|
468
|
+
owner, repo = scoped_runtime.send( :repository_coordinates )
|
|
469
469
|
pr_number = pr[ "number" ]
|
|
470
|
-
details =
|
|
470
|
+
details = scoped_runtime.send( :pull_request_details, owner: owner, repo: repo, pr_number: pr_number )
|
|
471
471
|
pr_author = details.dig( :author, :login ).to_s
|
|
472
|
-
threads =
|
|
473
|
-
top_level =
|
|
472
|
+
threads = scoped_runtime.send( :unresolved_thread_entries, details: details )
|
|
473
|
+
top_level = scoped_runtime.send( :actionable_top_level_items, details: details, pr_author: pr_author )
|
|
474
474
|
|
|
475
475
|
findings = []
|
|
476
476
|
threads.each do |entry|
|
|
@@ -483,14 +483,14 @@ module Carson
|
|
|
483
483
|
end
|
|
484
484
|
|
|
485
485
|
{ review_findings: findings }
|
|
486
|
-
rescue StandardError =>
|
|
487
|
-
puts_line " review_evidence failed: #{
|
|
486
|
+
rescue StandardError => exception
|
|
487
|
+
puts_line " review_evidence failed: #{exception.message}"
|
|
488
488
|
{}
|
|
489
489
|
end
|
|
490
490
|
|
|
491
491
|
def scoped_runtime( repo_path: )
|
|
492
492
|
return self if repo_path == self.repo_root
|
|
493
|
-
Runtime.new( repo_root: repo_path, tool_root: tool_root,
|
|
493
|
+
Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error )
|
|
494
494
|
end
|
|
495
495
|
|
|
496
496
|
def prior_attempt( pr:, repo_path: )
|
|
@@ -64,9 +64,9 @@ module Carson
|
|
|
64
64
|
return unless gh_available?
|
|
65
65
|
|
|
66
66
|
main_root = main_worktree_root
|
|
67
|
-
worktree_list.each do |
|
|
68
|
-
path =
|
|
69
|
-
branch =
|
|
67
|
+
worktree_list.each do |worktree|
|
|
68
|
+
path = worktree.fetch( :path )
|
|
69
|
+
branch = worktree.fetch( :branch, nil )
|
|
70
70
|
next if path == main_root
|
|
71
71
|
next unless branch
|
|
72
72
|
next if cwd_inside_worktree?( worktree_path: path )
|
|
@@ -121,26 +121,26 @@ module Carson
|
|
|
121
121
|
return { name: repo_name, path: repo_path, status: "error", error: "path not found" }
|
|
122
122
|
end
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
buffer = verbose? ? output : StringIO.new
|
|
125
|
+
error_buffer = verbose? ? error : StringIO.new
|
|
126
|
+
scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: buffer, error: error_buffer, verbose: verbose? )
|
|
127
127
|
|
|
128
|
-
sync_status =
|
|
128
|
+
sync_status = scoped_runtime.sync!
|
|
129
129
|
if sync_status == EXIT_OK
|
|
130
|
-
|
|
131
|
-
prune_status =
|
|
130
|
+
scoped_runtime.reap_dead_worktrees!
|
|
131
|
+
prune_status = scoped_runtime.prune!
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
ok = sync_status == EXIT_OK && prune_status == EXIT_OK
|
|
135
135
|
unless verbose? || silent
|
|
136
|
-
summary = strip_badge(
|
|
136
|
+
summary = strip_badge( buffer.string.lines.last.to_s.strip )
|
|
137
137
|
puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
{ name: repo_name, path: repo_path, status: ok ? "ok" : "error" }
|
|
141
|
-
rescue StandardError =>
|
|
142
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
143
|
-
{ name: repo_name, path: repo_path, status: "error", error:
|
|
141
|
+
rescue StandardError => exception
|
|
142
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})" unless silent
|
|
143
|
+
{ name: repo_name, path: repo_path, status: "error", error: exception.message }
|
|
144
144
|
end
|
|
145
145
|
|
|
146
146
|
# Strips the Carson badge prefix from a message to avoid double-badging.
|
|
@@ -156,7 +156,7 @@ module Carson
|
|
|
156
156
|
return expanded if repos.include?( expanded )
|
|
157
157
|
|
|
158
158
|
downcased = File.basename( target ).downcase
|
|
159
|
-
repos.find { |
|
|
159
|
+
repos.find { |repo_path| File.basename( repo_path ).downcase == downcased }
|
|
160
160
|
end
|
|
161
161
|
|
|
162
162
|
# Unified output — JSON or human-readable.
|
|
@@ -164,7 +164,7 @@ module Carson
|
|
|
164
164
|
result[ :exit_code ] = exit_code
|
|
165
165
|
|
|
166
166
|
if json_output
|
|
167
|
-
|
|
167
|
+
output.puts JSON.pretty_generate( result )
|
|
168
168
|
else
|
|
169
169
|
if results && ( succeeded || failed )
|
|
170
170
|
total = ( succeeded || 0 ) + ( failed || 0 )
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Installs, validates, and reports on managed git hooks for governed repositories.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Local
|
|
@@ -30,11 +31,30 @@ module Carson
|
|
|
30
31
|
end
|
|
31
32
|
git_system!( "config", "core.hooksPath", hooks_dir )
|
|
32
33
|
File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
|
|
34
|
+
install_command_guard!
|
|
33
35
|
puts_verbose "configured_hooks_path: #{hooks_dir}"
|
|
34
36
|
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
35
37
|
EXIT_OK
|
|
36
38
|
end
|
|
37
39
|
|
|
40
|
+
# Installs the command guard hook to a stable (non-versioned) path.
|
|
41
|
+
# Claude Code's PreToolUse hook references this path — it must not change across upgrades.
|
|
42
|
+
def install_command_guard!
|
|
43
|
+
source = hook_template_path( hook_name: "command-guard" )
|
|
44
|
+
return unless File.file?( source )
|
|
45
|
+
|
|
46
|
+
target = command_guard_path
|
|
47
|
+
FileUtils.mkdir_p( File.dirname( target ) )
|
|
48
|
+
FileUtils.cp( source, target )
|
|
49
|
+
FileUtils.chmod( 0o755, target )
|
|
50
|
+
puts_verbose "command_guard: #{target}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Stable path for the command guard script — not versioned so external references survive upgrades.
|
|
54
|
+
def command_guard_path
|
|
55
|
+
File.expand_path( File.join( config.hooks_path, "command-guard" ) )
|
|
56
|
+
end
|
|
57
|
+
|
|
38
58
|
# Canonical hook template location inside Carson repository.
|
|
39
59
|
def hook_template_path( hook_name: )
|
|
40
60
|
File.join( tool_root, "hooks", hook_name )
|
|
@@ -164,12 +164,12 @@ module Carson
|
|
|
164
164
|
end
|
|
165
165
|
|
|
166
166
|
begin
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
status =
|
|
167
|
+
buffer = verbose? ? output : StringIO.new
|
|
168
|
+
error_buffer = verbose? ? error : StringIO.new
|
|
169
|
+
scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: buffer, error: error_buffer, verbose: verbose? )
|
|
170
|
+
status = scoped_runtime.prune!
|
|
171
171
|
unless verbose?
|
|
172
|
-
summary =
|
|
172
|
+
summary = buffer.string.lines.last.to_s.strip
|
|
173
173
|
puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
|
|
174
174
|
end
|
|
175
175
|
if status == EXIT_ERROR
|
|
@@ -179,9 +179,9 @@ module Carson
|
|
|
179
179
|
clear_batch_success( command: "prune", repo_path: repo_path )
|
|
180
180
|
succeeded += 1
|
|
181
181
|
end
|
|
182
|
-
rescue StandardError =>
|
|
183
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
184
|
-
record_batch_skip( command: "prune", repo_path: repo_path, reason:
|
|
182
|
+
rescue StandardError => exception
|
|
183
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
184
|
+
record_batch_skip( command: "prune", repo_path: repo_path, reason: exception.message )
|
|
185
185
|
failed += 1
|
|
186
186
|
end
|
|
187
187
|
end
|
|
@@ -294,7 +294,7 @@ module Carson
|
|
|
294
294
|
def onboard_run_audit!
|
|
295
295
|
audit_error = nil
|
|
296
296
|
audit_status = with_captured_output { audit! }
|
|
297
|
-
rescue StandardError =>
|
|
297
|
+
rescue StandardError => exception
|
|
298
298
|
audit_error = e
|
|
299
299
|
audit_status = EXIT_OK
|
|
300
300
|
ensure
|
|
@@ -339,17 +339,17 @@ module Carson
|
|
|
339
339
|
# Refreshes a single governed repository using a scoped Runtime.
|
|
340
340
|
def refresh_single_repo( repo_path:, repo_name: )
|
|
341
341
|
if verbose?
|
|
342
|
-
|
|
342
|
+
scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error, verbose: true )
|
|
343
343
|
else
|
|
344
|
-
|
|
344
|
+
scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: StringIO.new, error: StringIO.new )
|
|
345
345
|
end
|
|
346
|
-
status =
|
|
346
|
+
status = scoped_runtime.refresh!
|
|
347
347
|
label = refresh_status_label( status: status )
|
|
348
|
-
sync_suffix = refresh_sync_suffix( result:
|
|
348
|
+
sync_suffix = refresh_sync_suffix( result: scoped_runtime.template_sync_result )
|
|
349
349
|
puts_line "#{repo_name}: #{label}#{sync_suffix}"
|
|
350
350
|
status
|
|
351
|
-
rescue StandardError =>
|
|
352
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
351
|
+
rescue StandardError => exception
|
|
352
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
353
353
|
EXIT_ERROR
|
|
354
354
|
end
|
|
355
355
|
|
|
@@ -376,8 +376,8 @@ module Carson
|
|
|
376
376
|
git_system!( "config", "--unset", "core.hooksPath" )
|
|
377
377
|
puts_verbose "hooks_path_unset: core.hooksPath"
|
|
378
378
|
EXIT_OK
|
|
379
|
-
rescue StandardError =>
|
|
380
|
-
puts_line "ERROR: unable to update core.hooksPath (#{
|
|
379
|
+
rescue StandardError => exception
|
|
380
|
+
puts_line "ERROR: unable to update core.hooksPath (#{exception.message})"
|
|
381
381
|
EXIT_ERROR
|
|
382
382
|
end
|
|
383
383
|
|
|
@@ -8,7 +8,7 @@ module Carson
|
|
|
8
8
|
fingerprint_status = block_if_outsider_fingerprints!
|
|
9
9
|
unless fingerprint_status.nil?
|
|
10
10
|
if json_output
|
|
11
|
-
|
|
11
|
+
output.puts JSON.pretty_generate( {
|
|
12
12
|
command: "prune", status: "block",
|
|
13
13
|
error: "Carson-owned artefacts detected in host repository",
|
|
14
14
|
recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
|
|
@@ -51,7 +51,7 @@ module Carson
|
|
|
51
51
|
result[ :exit_code ] = exit_code
|
|
52
52
|
|
|
53
53
|
if json_output
|
|
54
|
-
|
|
54
|
+
output.puts JSON.pretty_generate( result )
|
|
55
55
|
else
|
|
56
56
|
print_prune_human( counters: counters )
|
|
57
57
|
end
|
|
@@ -116,7 +116,7 @@ module Carson
|
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def prune_skip_stale_branch( type:, branch:, upstream: )
|
|
119
|
-
reason = { protected: "protected branch", current: "current branch", cwd_worktree: "checked
|
|
119
|
+
reason = { protected: "protected branch", current: "current branch", cwd_worktree: "checked output in CWD worktree" }.fetch( type, type.to_s )
|
|
120
120
|
status = { protected: "skip_protected_branch", current: "skip_current_branch", cwd_worktree: "skip_cwd_worktree_branch" }.fetch( type, "skip_#{type}" )
|
|
121
121
|
puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
|
|
122
122
|
{ action: :skipped, branch: branch, upstream: upstream, type: "stale", reason: reason }
|
|
@@ -135,7 +135,7 @@ module Carson
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def prune_safe_delete_success( branch:, upstream:, stdout_text: )
|
|
138
|
-
|
|
138
|
+
output.print stdout_text if verbose? && !stdout_text.empty?
|
|
139
139
|
puts_verbose "deleted_local_branch: #{branch} (upstream=#{upstream})"
|
|
140
140
|
{ action: :deleted, branch: branch, upstream: upstream, type: "stale", reason: "upstream gone" }
|
|
141
141
|
end
|
|
@@ -154,7 +154,7 @@ module Carson
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
|
|
157
|
-
|
|
157
|
+
output.print force_stdout if verbose? && !force_stdout.empty?
|
|
158
158
|
puts_verbose "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
|
|
159
159
|
{ action: :deleted, branch: branch, upstream: upstream, type: "stale", reason: "force deleted with PR evidence" }
|
|
160
160
|
end
|
|
@@ -195,9 +195,9 @@ module Carson
|
|
|
195
195
|
error_text.to_s.downcase.include?( "used by worktree" )
|
|
196
196
|
end
|
|
197
197
|
|
|
198
|
-
# Returns the worktree path for a branch, or nil if not checked
|
|
198
|
+
# Returns the worktree path for a branch, or nil if not checked output in any worktree.
|
|
199
199
|
def worktree_path_for_branch( branch: )
|
|
200
|
-
entry = worktree_list.find { |
|
|
200
|
+
entry = worktree_list.find { |worktree| worktree.fetch( :branch, nil ) == branch }
|
|
201
201
|
entry&.fetch( :path, nil )
|
|
202
202
|
end
|
|
203
203
|
|
|
@@ -307,7 +307,7 @@ module Carson
|
|
|
307
307
|
return { action: :skipped, branch: branch, upstream: upstream, type: "absorbed", reason: error_text }
|
|
308
308
|
end
|
|
309
309
|
|
|
310
|
-
|
|
310
|
+
output.print force_stdout if verbose? && !force_stdout.empty?
|
|
311
311
|
|
|
312
312
|
remote_branch = upstream.sub( "#{config.git_remote}/", "" )
|
|
313
313
|
git_run( "push", config.git_remote, "--delete", remote_branch )
|
|
@@ -372,7 +372,7 @@ module Carson
|
|
|
372
372
|
|
|
373
373
|
force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
|
|
374
374
|
if force_success
|
|
375
|
-
|
|
375
|
+
output.print force_stdout if verbose? && !force_stdout.empty?
|
|
376
376
|
puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
|
|
377
377
|
return { action: :deleted, branch: branch, upstream: "", type: "orphan", reason: "merged PR evidence found" }
|
|
378
378
|
end
|
|
@@ -486,10 +486,10 @@ module Carson
|
|
|
486
486
|
return [ nil, "no merged PR evidence for branch tip #{branch_tip_sha} into #{config.main_branch}" ] if latest.nil?
|
|
487
487
|
|
|
488
488
|
[ latest, nil ]
|
|
489
|
-
rescue JSON::ParserError =>
|
|
490
|
-
[ nil, "invalid gh JSON response (#{
|
|
491
|
-
rescue StandardError =>
|
|
492
|
-
[ nil,
|
|
489
|
+
rescue JSON::ParserError => exception
|
|
490
|
+
[ nil, "invalid gh JSON response (#{exception.message})" ]
|
|
491
|
+
rescue StandardError => exception
|
|
492
|
+
[ nil, exception.message ]
|
|
493
493
|
end
|
|
494
494
|
end
|
|
495
495
|
end
|
|
@@ -66,8 +66,8 @@ module Carson
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
begin
|
|
69
|
-
|
|
70
|
-
status =
|
|
69
|
+
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
70
|
+
status = scoped_runtime.sync!
|
|
71
71
|
if status == EXIT_OK
|
|
72
72
|
puts_line "#{repo_name}: ok" unless verbose?
|
|
73
73
|
clear_batch_success( command: "sync", repo_path: repo_path )
|
|
@@ -77,9 +77,9 @@ module Carson
|
|
|
77
77
|
record_batch_skip( command: "sync", repo_path: repo_path, reason: "sync failed" )
|
|
78
78
|
failed += 1
|
|
79
79
|
end
|
|
80
|
-
rescue StandardError =>
|
|
81
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
82
|
-
record_batch_skip( command: "sync", repo_path: repo_path, reason:
|
|
80
|
+
rescue StandardError => exception
|
|
81
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
82
|
+
record_batch_skip( command: "sync", repo_path: repo_path, reason: exception.message )
|
|
83
83
|
failed += 1
|
|
84
84
|
end
|
|
85
85
|
end
|
|
@@ -106,7 +106,7 @@ module Carson
|
|
|
106
106
|
result[ :exit_code ] = exit_code
|
|
107
107
|
|
|
108
108
|
if json_output
|
|
109
|
-
|
|
109
|
+
output.puts JSON.pretty_generate( result )
|
|
110
110
|
else
|
|
111
111
|
print_sync_human( result: result )
|
|
112
112
|
end
|