carson 3.22.0 → 3.22.1

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.
data/lib/carson/cli.rb CHANGED
@@ -24,7 +24,7 @@ module Carson
24
24
  target_repo_root = parsed.fetch( :repo_root, nil )
25
25
  target_repo_root = repo_root if target_repo_root.to_s.strip.empty?
26
26
  unless Dir.exist?( target_repo_root )
27
- error.puts "#{BADGE} ERROR: repository path does not exist: #{target_repo_root}"
27
+ error.puts "#{BADGE} Repository path not found: #{target_repo_root}"
28
28
  return Runtime::EXIT_ERROR
29
29
  end
30
30
 
@@ -32,10 +32,10 @@ module Carson
32
32
  runtime = Runtime.new( repo_root: target_repo_root, tool_root: tool_root, output: output, error: error, verbose: verbose )
33
33
  dispatch( parsed: parsed, runtime: runtime )
34
34
  rescue ConfigError => exception
35
- error.puts "#{BADGE} CONFIG ERROR: #{exception.message}"
35
+ error.puts "#{BADGE} Configuration problem: #{exception.message}"
36
36
  Runtime::EXIT_ERROR
37
37
  rescue StandardError => exception
38
- error.puts "#{BADGE} ERROR: #{exception.message}"
38
+ error.puts "#{BADGE} #{exception.message}"
39
39
  Runtime::EXIT_ERROR
40
40
  end
41
41
 
@@ -149,7 +149,7 @@ module Carson
149
149
  parser.on( "--main-branch NAME", "Main branch name" ) { |value| options[ "git.main_branch" ] = value }
150
150
  parser.on( "--workflow STYLE", "Workflow style (branch or trunk)" ) { |value| options[ "workflow.style" ] = value }
151
151
  parser.on( "--merge METHOD", "Merge method (squash, rebase, or merge)" ) { |value| options[ "govern.merge.method" ] = value }
152
- parser.on( "--canonical PATH", "Canonical template directory path" ) { |value| options[ "template.canonical" ] = value }
152
+ parser.on( "--canonical PATH", "Canonical lint policy directory path" ) { |value| options[ "lint.canonical" ] = value }
153
153
  parser.separator ""
154
154
  parser.separator "Examples:"
155
155
  parser.separator " carson setup Auto-detect and write config"
@@ -165,6 +165,7 @@ module Carson
165
165
  { command: "setup", cli_choices: options }
166
166
  rescue OptionParser::ParseError => exception
167
167
  error.puts "#{BADGE} #{exception.message}"
168
+ error.puts setup_parser
168
169
  { command: :invalid }
169
170
  end
170
171
 
@@ -195,6 +196,7 @@ module Carson
195
196
  }
196
197
  rescue OptionParser::ParseError => exception
197
198
  error.puts "#{BADGE} #{exception.message}"
199
+ error.puts onboard_parser
198
200
  { command: :invalid }
199
201
  end
200
202
 
@@ -222,6 +224,7 @@ module Carson
222
224
  }
223
225
  rescue OptionParser::ParseError => exception
224
226
  error.puts "#{BADGE} #{exception.message}"
227
+ error.puts offboard_parser
225
228
  { command: :invalid }
226
229
  end
227
230
 
@@ -265,6 +268,7 @@ module Carson
265
268
  }
266
269
  rescue OptionParser::ParseError => exception
267
270
  error.puts "#{BADGE} #{exception.message}"
271
+ error.puts refresh_parser
268
272
  { command: :invalid }
269
273
  end
270
274
 
@@ -292,6 +296,7 @@ module Carson
292
296
  { command: "prune", json: options[ :json ] }
293
297
  rescue OptionParser::ParseError => exception
294
298
  error.puts "#{BADGE} #{exception.message}"
299
+ error.puts prune_parser
295
300
  { command: :invalid }
296
301
  end
297
302
 
@@ -348,6 +353,7 @@ module Carson
348
353
  end
349
354
  rescue OptionParser::ParseError => exception
350
355
  error.puts "#{BADGE} #{exception.message}"
356
+ error.puts worktree_parser
351
357
  { command: :invalid }
352
358
  end
353
359
 
@@ -378,6 +384,7 @@ module Carson
378
384
  { command: "review:#{action}" }
379
385
  rescue OptionParser::ParseError => exception
380
386
  error.puts "#{BADGE} #{exception.message}"
387
+ error.puts review_parser
381
388
  { command: :invalid }
382
389
  end
383
390
 
@@ -436,6 +443,7 @@ module Carson
436
443
  { command: "template:apply", push_prep: options.fetch( :push_prep ) }
437
444
  rescue OptionParser::ParseError => exception
438
445
  error.puts "#{BADGE} #{exception.message}"
446
+ error.puts( apply_parser || template_parser )
439
447
  { command: :invalid }
440
448
  end
441
449
 
@@ -468,6 +476,7 @@ module Carson
468
476
  { command: "audit", json: options[ :json ] }
469
477
  rescue OptionParser::ParseError => exception
470
478
  error.puts "#{BADGE} #{exception.message}"
479
+ error.puts audit_parser
471
480
  { command: :invalid }
472
481
  end
473
482
 
@@ -499,6 +508,7 @@ module Carson
499
508
  { command: "sync", json: options[ :json ] }
500
509
  rescue OptionParser::ParseError => exception
501
510
  error.puts "#{BADGE} #{exception.message}"
511
+ error.puts sync_parser
502
512
  { command: :invalid }
503
513
  end
504
514
 
@@ -530,6 +540,7 @@ module Carson
530
540
  { command: "status", json: options[ :json ] }
531
541
  rescue OptionParser::ParseError => exception
532
542
  error.puts "#{BADGE} #{exception.message}"
543
+ error.puts status_parser
533
544
  { command: :invalid }
534
545
  end
535
546
 
@@ -568,6 +579,7 @@ module Carson
568
579
  }
569
580
  rescue OptionParser::ParseError => exception
570
581
  error.puts "#{BADGE} #{exception.message}"
582
+ error.puts deliver_parser
571
583
  { command: :invalid }
572
584
  end
573
585
 
@@ -596,27 +608,30 @@ module Carson
596
608
  { command: "repos", json: options[ :json ] }
597
609
  rescue OptionParser::ParseError => exception
598
610
  error.puts "#{BADGE} #{exception.message}"
611
+ error.puts repos_parser
599
612
  { command: :invalid }
600
613
  end
601
614
 
602
615
  # --- housekeep ---
603
616
 
604
617
  def self.parse_housekeep_command( arguments:, error: )
605
- options = { all: false, json: false }
618
+ options = { all: false, json: false, dry_run: false }
606
619
  housekeep_parser = OptionParser.new do |parser|
607
- parser.banner = "Usage: carson housekeep [REPO] [--all] [--json]"
620
+ parser.banner = "Usage: carson housekeep [REPO] [--all] [--dry-run] [--json]"
608
621
  parser.separator ""
609
622
  parser.separator "Run housekeeping: sync main, reap dead worktrees, and prune stale branches."
610
623
  parser.separator "Defaults to the current repository."
611
624
  parser.separator ""
612
625
  parser.separator "Options:"
613
626
  parser.on( "--all", "Housekeep all governed repositories" ) { options[ :all ] = true }
627
+ parser.on( "--dry-run", "Show what would be reaped/deleted without making changes" ) { options[ :dry_run ] = true }
614
628
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
615
629
  parser.separator ""
616
630
  parser.separator "Examples:"
617
- parser.separator " carson housekeep Housekeep the current repository"
618
- parser.separator " carson housekeep nexus Housekeep a named governed repo"
619
- parser.separator " carson housekeep --all Housekeep all governed repos"
631
+ parser.separator " carson housekeep Housekeep the current repository"
632
+ parser.separator " carson housekeep --dry-run Preview what housekeep would do"
633
+ parser.separator " carson housekeep nexus Housekeep a named governed repo"
634
+ parser.separator " carson housekeep --all Housekeep all governed repos"
620
635
  end
621
636
  housekeep_parser.parse!( arguments )
622
637
 
@@ -625,7 +640,7 @@ module Carson
625
640
  return { command: :invalid }
626
641
  end
627
642
 
628
- return { command: "housekeep:all", json: options[ :json ] } if options[ :all ]
643
+ return { command: "housekeep:all", json: options[ :json ], dry_run: options[ :dry_run ] } if options[ :all ]
629
644
 
630
645
  if arguments.length > 1
631
646
  error.puts "#{BADGE} Too many arguments for housekeep. Use: carson housekeep [repo]"
@@ -633,11 +648,12 @@ module Carson
633
648
  end
634
649
 
635
650
  target = arguments.shift
636
- return { command: "housekeep:target", target: target, json: options[ :json ] } if target
651
+ return { command: "housekeep:target", target: target, json: options[ :json ], dry_run: options[ :dry_run ] } if target
637
652
 
638
- { command: "housekeep", json: options[ :json ] }
653
+ { command: "housekeep", json: options[ :json ], dry_run: options[ :dry_run ] }
639
654
  rescue OptionParser::ParseError => exception
640
655
  error.puts "#{BADGE} #{exception.message}"
656
+ error.puts housekeep_parser
641
657
  { command: :invalid }
642
658
  end
643
659
 
@@ -660,7 +676,7 @@ module Carson
660
676
  parser.on( "--dry-run", "Run all checks but do not merge or dispatch" ) { options[ :dry_run ] = true }
661
677
  parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
662
678
  parser.on( "--loop SECONDS", Integer, "Run continuously, sleeping SECONDS between cycles" ) do |seconds|
663
- error.puts( "#{BADGE} Error: --loop must be a positive integer" ) || ( return { command: :invalid } ) if seconds < 1
679
+ error.puts( "#{BADGE} --loop expects a positive integer" ) || ( return { command: :invalid } ) if seconds < 1
664
680
  options[ :loop_seconds ] = seconds
665
681
  end
666
682
  parser.separator ""
@@ -757,11 +773,11 @@ module Carson
757
773
  when "repos"
758
774
  runtime.repos!( json_output: parsed.fetch( :json, false ) )
759
775
  when "housekeep"
760
- runtime.housekeep!( json_output: parsed.fetch( :json, false ) )
776
+ runtime.housekeep!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
761
777
  when "housekeep:target"
762
- runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ) )
778
+ runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
763
779
  when "housekeep:all"
764
- runtime.housekeep_all!( json_output: parsed.fetch( :json, false ) )
780
+ runtime.housekeep_all!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
765
781
  when "govern"
766
782
  runtime.govern!(
767
783
  dry_run: parsed.fetch( :dry_run, false ),
data/lib/carson/config.rb CHANGED
@@ -7,9 +7,24 @@ module Carson
7
7
 
8
8
  # Config is built-in only for outsider mode; host repositories do not carry Carson config files.
9
9
  class Config
10
+ CANONICAL_GITHUB_DIRECTORIES = %w[actions workflows ISSUE_TEMPLATE DISCUSSION_TEMPLATE linters].freeze
11
+ CANONICAL_GITHUB_ROOT_FILES = [
12
+ ".mega-linter.yml",
13
+ "AGENTS.md",
14
+ "CLAUDE.md",
15
+ "CODEOWNERS",
16
+ "FUNDING.yml",
17
+ "carson.md",
18
+ "copilot-instructions.md",
19
+ "dependabot.yml",
20
+ "funding.yml",
21
+ "labeler.yml",
22
+ "pull_request_template.md"
23
+ ].freeze
24
+
10
25
  attr_accessor :git_remote
11
26
  attr_reader :main_branch, :protected_branches, :hooks_path, :managed_hooks,
12
- :template_managed_files, :template_canonical,
27
+ :template_managed_files, :lint_canonical, :template_canonical,
13
28
  :review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
14
29
  :review_sweep_states, :review_disposition, :review_risk_keywords,
15
30
  :review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
@@ -38,7 +53,10 @@ module Carson
38
53
  "managed" => [ "pre-commit", "prepare-commit-msg", "pre-merge-commit", "pre-push" ]
39
54
  },
40
55
  "template" => {
41
- "managed_files" => [ ".github/carson.md", ".github/copilot-instructions.md", ".github/CLAUDE.md", ".github/AGENTS.md", ".github/pull_request_template.md" ],
56
+ "managed_files" => [],
57
+ "canonical" => nil
58
+ },
59
+ "lint" => {
42
60
  "canonical" => nil
43
61
  },
44
62
  "workflow" => {
@@ -194,8 +212,11 @@ module Carson
194
212
  @hooks_path = fetch_string( hash: fetch_hash( hash: data, key: "hooks" ), key: "path" )
195
213
  @managed_hooks = fetch_string_array( hash: fetch_hash( hash: data, key: "hooks" ), key: "managed" )
196
214
 
197
- @template_managed_files = fetch_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "managed_files" )
198
- @template_canonical = fetch_optional_path( hash: fetch_hash( hash: data, key: "template" ), key: "canonical" )
215
+ template_hash = fetch_hash( hash: data, key: "template" )
216
+ @template_managed_files = fetch_optional_string_array( hash: template_hash, key: "managed_files" )
217
+ @lint_canonical = fetch_optional_path( hash: fetch_hash( hash: data, key: "lint" ), key: "canonical" )
218
+ @lint_canonical ||= fetch_optional_path( hash: template_hash, key: "canonical" )
219
+ @template_canonical = @lint_canonical
199
220
  resolve_canonical_files!
200
221
 
201
222
  workflow_hash = fetch_hash( hash: data, key: "workflow" )
@@ -323,18 +344,32 @@ module Carson
323
344
  safe_expand_path( text )
324
345
  end
325
346
 
326
- # Discovers files in the canonical directory and appends them to managed_files.
327
- # Canonical files mirror the .github/ structure and are synced alongside Carson's own governance files.
347
+ # Discovers files in the canonical lint-policy directory and appends them to managed_files.
348
+ # Explicit GitHub paths stay under .github/; flat policy files default to .github/linters/.
328
349
  def resolve_canonical_files!
329
- return if @template_canonical.nil? || @template_canonical.empty?
330
- return unless Dir.exist?( @template_canonical )
350
+ return if @lint_canonical.nil? || @lint_canonical.empty?
351
+ return unless Dir.exist?( @lint_canonical )
331
352
 
332
- Dir.glob( File.join( @template_canonical, "**", "*" ) ).sort.each do |absolute_path|
353
+ Dir.glob( File.join( @lint_canonical, "**", "*" ), File::FNM_DOTMATCH ).sort.each do |absolute_path|
354
+ basename = File.basename( absolute_path )
355
+ next if basename == "." || basename == ".."
333
356
  next unless File.file?( absolute_path )
334
- relative = absolute_path.delete_prefix( "#{@template_canonical}/" )
335
- managed_path = ".github/#{relative}"
357
+ relative = absolute_path.delete_prefix( "#{@lint_canonical}/" )
358
+ managed_path = canonical_managed_path( relative_path: relative )
336
359
  @template_managed_files << managed_path unless @template_managed_files.include?( managed_path )
337
360
  end
338
361
  end
362
+
363
+ def canonical_managed_path( relative_path: )
364
+ raw_relative = relative_path.to_s
365
+ return ".github/#{raw_relative.delete_prefix( '.github/' )}" if raw_relative.start_with?( ".github/" )
366
+
367
+ clean_relative = raw_relative
368
+ first_segment = clean_relative.split( "/", 2 ).first
369
+ return ".github/#{clean_relative}" if CANONICAL_GITHUB_DIRECTORIES.include?( first_segment )
370
+ return ".github/#{clean_relative}" if !clean_relative.include?( "/" ) && CANONICAL_GITHUB_ROOT_FILES.include?( clean_relative )
371
+
372
+ ".github/linters/#{clean_relative}"
373
+ end
339
374
  end
340
375
  end
@@ -26,6 +26,12 @@ module Carson
26
26
  puts_verbose ""
27
27
  puts_verbose "[Working Tree]"
28
28
  puts_verbose git_capture!( "status", "--short", "--branch" ).strip
29
+ working_tree = audit_working_tree_report
30
+ if working_tree.fetch( :status ) == "block"
31
+ puts_verbose "ACTION: #{working_tree.fetch( :error )}; #{working_tree.fetch( :recovery )}."
32
+ audit_state = "block"
33
+ audit_concise_problems << "Working tree: #{working_tree.fetch( :error )} — #{working_tree.fetch( :recovery )}."
34
+ end
29
35
  puts_verbose ""
30
36
  puts_verbose "[Hooks]"
31
37
  hooks_ok = hooks_health_report
@@ -110,10 +116,10 @@ module Carson
110
116
  end
111
117
  audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )}."
112
118
  end
113
- if config.template_canonical.nil? || config.template_canonical.to_s.empty?
119
+ if config.lint_canonical.nil? || config.lint_canonical.to_s.empty?
114
120
  puts_verbose ""
115
- puts_verbose "[Canonical Templates]"
116
- puts_verbose "HINT: canonical templates not configured — run carson setup to enable."
121
+ puts_verbose "[Canonical Lint Policy]"
122
+ puts_verbose "HINT: lint.canonical not configured — run carson setup to enable."
117
123
  end
118
124
  write_and_print_pr_monitor_report(
119
125
  report: monitor_report.merge(
@@ -128,6 +134,7 @@ module Carson
128
134
  command: "audit",
129
135
  status: audit_state,
130
136
  branch: current_branch,
137
+ working_tree: working_tree,
131
138
  hooks: { status: hooks_status },
132
139
  main_sync: main_sync,
133
140
  pr: monitor_report[ :pr ],
@@ -175,7 +182,7 @@ module Carson
175
182
  repos.each do |repo_path|
176
183
  repo_name = File.basename( repo_path )
177
184
  unless Dir.exist?( repo_path )
178
- puts_line "#{repo_name}: FAIL (path not found)"
185
+ puts_line "#{repo_name}: not found"
179
186
  record_batch_skip( command: "audit", repo_path: repo_path, reason: "path not found" )
180
187
  failed += 1
181
188
  next
@@ -190,15 +197,15 @@ module Carson
190
197
  clear_batch_success( command: "audit", repo_path: repo_path )
191
198
  passed += 1
192
199
  when EXIT_BLOCK
193
- puts_line "#{repo_name}: BLOCK" unless verbose?
200
+ puts_line "#{repo_name}: needs attention" unless verbose?
194
201
  blocked += 1
195
202
  else
196
- puts_line "#{repo_name}: FAIL" unless verbose?
203
+ puts_line "#{repo_name}: could not complete" unless verbose?
197
204
  record_batch_skip( command: "audit", repo_path: repo_path, reason: "audit failed" )
198
205
  failed += 1
199
206
  end
200
207
  rescue StandardError => exception
201
- puts_line "#{repo_name}: FAIL (#{exception.message})"
208
+ puts_line "#{repo_name}: could not complete (#{exception.message})"
202
209
  record_batch_skip( command: "audit", repo_path: repo_path, reason: exception.message )
203
210
  failed += 1
204
211
  end
@@ -207,11 +214,30 @@ module Carson
207
214
  puts_line ""
208
215
  puts_line "Audit all complete: #{passed} ok, #{blocked} blocked, #{failed} failed."
209
216
  blocked.zero? && failed.zero? ? EXIT_OK : EXIT_BLOCK
210
- end
217
+ end
211
218
 
212
- private
213
- def pr_and_check_report
214
- report = {
219
+ # rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
220
+ private
221
+ # rubocop:enable Layout/AccessModifierIndentation
222
+ def audit_working_tree_report
223
+ dirty_reason = dirty_worktree_reason
224
+ return { dirty: false, context: nil, status: "ok" } if dirty_reason.nil?
225
+
226
+ if dirty_reason == "main_worktree"
227
+ {
228
+ dirty: true,
229
+ context: dirty_reason,
230
+ status: "block",
231
+ error: "main working tree has uncommitted changes",
232
+ recovery: "create a worktree with carson worktree create <name>"
233
+ }
234
+ else
235
+ { dirty: true, context: dirty_reason, status: "ok" }
236
+ end
237
+ end
238
+
239
+ def pr_and_check_report
240
+ report = {
215
241
  generated_at: Time.now.utc.iso8601,
216
242
  branch: current_branch,
217
243
  status: "ok",
@@ -18,15 +18,39 @@ module Carson
18
18
  # Guard: cannot deliver from main.
19
19
  if branch == main
20
20
  result[ :error ] = "cannot deliver from #{main}"
21
- result[ :recovery ] = "git checkout -b <branch-name>"
22
- return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
21
+ result[ :recovery ] = "carson worktree create <name>"
22
+ return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
23
+ end
24
+
25
+ # Step 1: sync managed template files before push.
26
+ # `push_prep: true` stages and commits managed drift so the subsequent
27
+ # push carries the canonical content even though deliver uses --no-verify.
28
+ # Output is captured to prevent pollution of --json mode.
29
+ # Diagnostics are preserved for error reporting.
30
+ sync_exit, sync_diagnostics = begin
31
+ saved_output, saved_error = @output, @error
32
+ captured_out = StringIO.new
33
+ captured_err = StringIO.new
34
+ @output = captured_out
35
+ @error = captured_err
36
+ exit_code = template_apply!( push_prep: true )
37
+ [ exit_code, captured_out.string + captured_err.string ]
38
+ rescue StandardError => exception
39
+ [ EXIT_ERROR, "template sync error: #{exception.message}" ]
40
+ ensure
41
+ @output, @error = saved_output, saved_error
42
+ end
43
+
44
+ if sync_exit == EXIT_ERROR
45
+ result[ :error ] = sync_diagnostics.to_s.strip.empty? ? "template sync failed" : sync_diagnostics.strip
46
+ return deliver_finish( result: result, exit_code: sync_exit, json_output: json_output )
23
47
  end
24
48
 
25
- # Step 1: push the branch.
49
+ # Step 2: push the branch.
26
50
  push_exit = push_branch!( branch: branch, remote: remote, result: result )
27
51
  return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
28
52
 
29
- # Step 2: find or create the PR.
53
+ # Step 3: find or create the PR.
30
54
  pr_number, pr_url = find_or_create_pr!(
31
55
  branch: branch, title: title, body_file: body_file, result: result
32
56
  )
@@ -41,7 +65,7 @@ module Carson
41
65
  return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
42
66
  end
43
67
 
44
- # Step 3: check CI status.
68
+ # Step 4: check CI status.
45
69
  ci_status = check_pr_ci( number: pr_number )
46
70
  result[ :ci ] = ci_status.to_s
47
71
 
@@ -56,25 +80,35 @@ module Carson
56
80
  return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
57
81
  end
58
82
 
59
- # Step 4: check review gate — block if changes are requested.
60
- review = check_pr_review( number: pr_number )
61
- result[ :review ] = review.to_s
62
- if review == :changes_requested
83
+ # Step 5: check review gate — block on unresolved review debt.
84
+ review = check_pr_review( number: pr_number, branch: branch, pr_url: pr_url )
85
+ result[ :review ] = review.fetch( :review ).to_s
86
+ if review.fetch( :review ) == :changes_requested
63
87
  result[ :error ] = "review changes requested on PR ##{pr_number}"
64
88
  result[ :recovery ] = "address review comments, push, then `carson deliver --merge`"
65
89
  return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
66
90
  end
91
+ if review.fetch( :status ) == :fail
92
+ result[ :error ] = "review gate blocked on PR ##{pr_number}: #{review.fetch( :detail )}"
93
+ result[ :recovery ] = "resolve review gate blockers, push, then `carson deliver --merge`"
94
+ return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
95
+ end
96
+ if review.fetch( :status ) == :error
97
+ result[ :error ] = "unable to evaluate review gate for PR ##{pr_number}: #{review.fetch( :detail )}"
98
+ result[ :recovery ] = "run `carson review gate`, then retry `carson deliver --merge`"
99
+ return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
100
+ end
67
101
 
68
- # Step 5: merge.
102
+ # Step 6: merge.
69
103
  merge_exit = merge_pr!( number: pr_number, result: result )
70
104
  return deliver_finish( result: result, exit_code: merge_exit, json_output: json_output ) unless merge_exit == EXIT_OK
71
105
 
72
106
  result[ :merged ] = true
73
107
 
74
- # Step 6: sync main in the main worktree.
108
+ # Step 7: sync main in the main worktree.
75
109
  sync_after_merge!( remote: remote, main: main, result: result )
76
110
 
77
- # Step 7: compute next-step guidance for the agent.
111
+ # Step 8: compute next-step guidance for the agent.
78
112
  compute_post_merge_next_step!( result: result )
79
113
 
80
114
  deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
@@ -100,8 +134,8 @@ module Carson
100
134
  exit_code = result.fetch( :exit_code )
101
135
 
102
136
  if result[ :error ]
103
- puts_line "ERROR: #{result[ :error ]}"
104
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
137
+ puts_line result[ :error ]
138
+ puts_line " #{result[ :recovery ]}" if result[ :recovery ]
105
139
  return
106
140
  end
107
141
 
@@ -118,10 +152,10 @@ module Carson
118
152
  puts_line "CI: none — no checks configured, proceeding."
119
153
  when "pending"
120
154
  puts_line "CI: pending — merge when checks complete."
121
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
155
+ puts_line " #{result[ :recovery ]}" if result[ :recovery ]
122
156
  when "fail"
123
- puts_line "CI: failing — fix before merging."
124
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
157
+ puts_line "CI: not passing yet — fix before merging."
158
+ puts_line " #{result[ :recovery ]}" if result[ :recovery ]
125
159
  end
126
160
  end
127
161
 
@@ -132,22 +166,54 @@ module Carson
132
166
  end
133
167
 
134
168
  # Pushes the branch to the remote with tracking.
135
- # Sets CARSON_PUSH=1 so the pre-push hook knows this is a Carson-managed push.
169
+ # Uses --no-verify to skip the pre-push hook that Carson itself installed.
170
+ # The hook blocks raw pushes unconditionally; Carson bypasses by skipping it.
171
+ # Template sync (previously in the hook) now runs in deliver! before push.
172
+ # On non-fast-forward rejection (typically after rebase), retries with
173
+ # --force-with-lease — a protected force push that rejects if the remote
174
+ # ref has been updated by another actor since the last fetch.
136
175
  def push_branch!( branch:, remote:, result: )
137
- _, push_stderr, push_success, = with_env_var( "CARSON_PUSH", "1" ) do
138
- git_run( "push", "-u", remote, branch )
176
+ _, push_stderr, push_success, = git_run( "push", "--no-verify", "-u", remote, branch )
177
+
178
+ if !push_success && push_stderr.to_s.include?( "non-fast-forward" )
179
+ return force_push_with_lease!( branch: branch, remote: remote, result: result )
139
180
  end
181
+
140
182
  unless push_success
141
183
  error_text = push_stderr.to_s.strip
142
184
  error_text = "push failed" if error_text.empty?
143
185
  result[ :error ] = error_text
144
- result[ :recovery ] = "git pull #{remote} #{branch} --rebase && git push -u #{remote} #{branch}"
145
186
  return EXIT_ERROR
146
187
  end
147
188
  puts_verbose "pushed #{branch} to #{remote}"
148
189
  EXIT_OK
149
190
  end
150
191
 
192
+ # Retries push with --force-with-lease after a non-fast-forward rejection.
193
+ # The lease check compares the local tracking ref against the remote — if
194
+ # another actor pushed since our last fetch, the push is refused ("stale info").
195
+ # This is atomic and safe, unlike delete-and-re-push.
196
+ def force_push_with_lease!( branch:, remote:, result: )
197
+ puts_verbose "push rejected (non-fast-forward), retrying with --force-with-lease"
198
+ _, lease_stderr, lease_success, = git_run( "push", "--no-verify", "--force-with-lease", "-u", remote, branch )
199
+
200
+ if lease_success
201
+ puts_verbose "pushed #{branch} to #{remote} (force-with-lease)"
202
+ return EXIT_OK
203
+ end
204
+
205
+ # --force-with-lease rejected — another actor pushed to this branch.
206
+ if lease_stderr.to_s.include?( "stale info" )
207
+ result[ :error ] = "force-with-lease rejected — another push landed on #{branch} since your last fetch"
208
+ result[ :recovery ] = "git fetch #{remote} #{branch} && carson deliver"
209
+ else
210
+ error_text = lease_stderr.to_s.strip
211
+ error_text = "push failed (force-with-lease)" if error_text.empty?
212
+ result[ :error ] = error_text
213
+ end
214
+ EXIT_ERROR
215
+ end
216
+
151
217
  # Finds an existing PR for the branch, or creates a new one.
152
218
  # Returns [number, url] or [nil, nil] on failure.
153
219
  def find_or_create_pr!( branch:, title: nil, body_file: nil, result: )
@@ -161,14 +227,17 @@ module Carson
161
227
 
162
228
  # Queries gh for an open PR on this branch.
163
229
  # Returns [number, url] or [nil, nil].
230
+ # gh pr view returns any PR on the branch — open, merged, or closed.
231
+ # We check state explicitly so merged/closed PRs are treated as absent,
232
+ # letting find_or_create_pr! fall through to create a new PR.
164
233
  def find_existing_pr( branch: )
165
234
  stdout, _, success, = gh_run(
166
235
  "pr", "view", branch,
167
- "--json", "number,url"
236
+ "--json", "number,url,state"
168
237
  )
169
238
  if success
170
239
  data = JSON.parse( stdout ) rescue nil
171
- if data && data[ "number" ]
240
+ if data && data[ "number" ] && data[ "state" ] == "OPEN"
172
241
  return [ data[ "number" ], data[ "url" ].to_s ]
173
242
  end
174
243
  end
@@ -231,22 +300,24 @@ module Carson
231
300
  :pass
232
301
  end
233
302
 
234
- # Checks review decision on a PR. Returns :approved, :changes_requested, :review_required, or :none.
235
- def check_pr_review( number: )
236
- stdout, _, success, = gh_run(
237
- "pr", "view", number.to_s,
238
- "--json", "reviewDecision"
303
+ # Checks the full review gate on a PR. Returns a structured result hash.
304
+ def check_pr_review( number:, branch:, pr_url: nil )
305
+ owner, repo = repository_coordinates
306
+ report = review_gate_report_for_pr(
307
+ owner: owner,
308
+ repo: repo,
309
+ pr_number: number,
310
+ branch_name: branch,
311
+ pr_summary: {
312
+ number: number,
313
+ title: "",
314
+ url: pr_url.to_s,
315
+ state: "OPEN"
316
+ }
239
317
  )
240
- return :none unless success
241
-
242
- data = JSON.parse( stdout ) rescue {}
243
- decision = data[ "reviewDecision" ].to_s.strip.upcase
244
- case decision
245
- when "APPROVED" then :approved
246
- when "CHANGES_REQUESTED" then :changes_requested
247
- when "REVIEW_REQUIRED" then :review_required
248
- else :none
249
- end
318
+ review_gate_result( report: report )
319
+ rescue StandardError => exception
320
+ { status: :error, review: :error, detail: exception.message }
250
321
  end
251
322
 
252
323
  # Merges the PR using the configured merge method.
@@ -296,12 +367,12 @@ module Carson
296
367
  # Detects whether the agent is inside a worktree and suggests cleanup.
297
368
  def compute_post_merge_next_step!( result: )
298
369
  main_root = main_worktree_root
299
- cwd = realpath_safe( Dir.pwd )
300
- current_wt = worktree_list.select { it.fetch( :path ) != realpath_safe( main_root ) }
301
- .find { cwd == it.fetch( :path ) || cwd.start_with?( File.join( it.fetch( :path ), "" ) ) }
370
+ current_wt = worktree_list
371
+ .reject { it.path == realpath_safe( main_root ) }
372
+ .find { it.holds_cwd? }
302
373
 
303
374
  if current_wt
304
- wt_name = File.basename( current_wt.fetch( :path ) )
375
+ wt_name = File.basename( current_wt.path )
305
376
  result[ :next_step ] = "cd #{main_root} && carson worktree remove #{wt_name}"
306
377
  else
307
378
  result[ :next_step ] = "carson prune"