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.
- checksums.yaml +4 -4
- data/API.md +4 -9
- data/MANUAL.md +45 -28
- data/README.md +45 -50
- data/RELEASE.md +9 -1
- data/SKILL.md +1 -1
- data/VERSION +1 -1
- data/carson.gemspec +2 -4
- data/hooks/command-guard +1 -1
- data/hooks/pre-push +17 -20
- data/lib/carson/cli.rb +32 -16
- data/lib/carson/config.rb +46 -11
- data/lib/carson/runtime/audit.rb +37 -11
- data/lib/carson/runtime/deliver.rb +113 -42
- data/lib/carson/runtime/govern.rb +29 -17
- data/lib/carson/runtime/housekeep.rb +231 -25
- data/lib/carson/runtime/local/onboard.rb +24 -24
- data/lib/carson/runtime/local/prune.rb +116 -31
- data/lib/carson/runtime/local/sync.rb +29 -7
- data/lib/carson/runtime/local/template.rb +26 -8
- data/lib/carson/runtime/local/worktree.rb +37 -442
- data/lib/carson/runtime/review/gate_support.rb +131 -1
- data/lib/carson/runtime/review.rb +21 -77
- data/lib/carson/runtime/setup.rb +15 -6
- data/lib/carson/runtime/status.rb +35 -12
- data/lib/carson/runtime.rb +15 -3
- data/lib/carson/worktree.rb +497 -0
- data/lib/carson.rb +1 -0
- metadata +11 -16
- data/.github/copilot-instructions.md +0 -1
- data/.github/pull_request_template.md +0 -12
- data/templates/.github/AGENTS.md +0 -1
- data/templates/.github/CLAUDE.md +0 -1
- data/templates/.github/carson.md +0 -47
- data/templates/.github/copilot-instructions.md +0 -1
- data/templates/.github/pull_request_template.md +0 -12
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}
|
|
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}
|
|
35
|
+
error.puts "#{BADGE} Configuration problem: #{exception.message}"
|
|
36
36
|
Runtime::EXIT_ERROR
|
|
37
37
|
rescue StandardError => exception
|
|
38
|
-
error.puts "#{BADGE}
|
|
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
|
|
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
|
|
618
|
-
parser.separator " carson housekeep
|
|
619
|
-
parser.separator " carson housekeep
|
|
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}
|
|
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" => [
|
|
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
|
-
|
|
198
|
-
@
|
|
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
|
-
#
|
|
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 @
|
|
330
|
-
return unless Dir.exist?( @
|
|
350
|
+
return if @lint_canonical.nil? || @lint_canonical.empty?
|
|
351
|
+
return unless Dir.exist?( @lint_canonical )
|
|
331
352
|
|
|
332
|
-
Dir.glob( File.join( @
|
|
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( "#{@
|
|
335
|
-
managed_path =
|
|
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
|
data/lib/carson/runtime/audit.rb
CHANGED
|
@@ -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.
|
|
119
|
+
if config.lint_canonical.nil? || config.lint_canonical.to_s.empty?
|
|
114
120
|
puts_verbose ""
|
|
115
|
-
puts_verbose "[Canonical
|
|
116
|
-
puts_verbose "HINT: canonical
|
|
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}:
|
|
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}:
|
|
200
|
+
puts_line "#{repo_name}: needs attention" unless verbose?
|
|
194
201
|
blocked += 1
|
|
195
202
|
else
|
|
196
|
-
puts_line "#{repo_name}:
|
|
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}:
|
|
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
|
-
|
|
217
|
+
end
|
|
211
218
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 ] = "
|
|
22
|
-
return deliver_finish( result: result, exit_code:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
108
|
+
# Step 7: sync main in the main worktree.
|
|
75
109
|
sync_after_merge!( remote: remote, main: main, result: result )
|
|
76
110
|
|
|
77
|
-
# Step
|
|
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
|
|
104
|
-
puts_line "
|
|
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 "
|
|
155
|
+
puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
122
156
|
when "fail"
|
|
123
|
-
puts_line "CI:
|
|
124
|
-
puts_line "
|
|
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
|
-
#
|
|
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, =
|
|
138
|
-
|
|
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
|
|
235
|
-
def check_pr_review( number: )
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
.find {
|
|
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.
|
|
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"
|