carson 3.23.3 → 3.27.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/API.md +26 -8
- data/MANUAL.md +51 -22
- data/README.md +9 -16
- data/RELEASE.md +25 -1
- data/VERSION +1 -1
- data/carson.gemspec +0 -1
- data/hooks/command-guard +60 -16
- data/lib/carson/cli.rb +116 -5
- data/lib/carson/config.rb +3 -8
- data/lib/carson/delivery.rb +17 -9
- data/lib/carson/ledger.rb +242 -222
- data/lib/carson/repository.rb +2 -4
- data/lib/carson/revision.rb +2 -4
- data/lib/carson/runtime/abandon.rb +238 -0
- data/lib/carson/runtime/audit.rb +12 -2
- data/lib/carson/runtime/deliver.rb +162 -15
- data/lib/carson/runtime/govern.rb +48 -21
- data/lib/carson/runtime/housekeep.rb +189 -153
- data/lib/carson/runtime/local/onboard.rb +4 -3
- data/lib/carson/runtime/local/prune.rb +6 -11
- data/lib/carson/runtime/local/sync.rb +9 -0
- data/lib/carson/runtime/local/worktree.rb +166 -0
- data/lib/carson/runtime/recover.rb +418 -0
- data/lib/carson/runtime/setup.rb +11 -7
- data/lib/carson/runtime/status.rb +39 -28
- data/lib/carson/runtime.rb +3 -1
- data/lib/carson/worktree.rb +128 -53
- metadata +4 -22
data/lib/carson/cli.rb
CHANGED
|
@@ -64,8 +64,10 @@ module Carson
|
|
|
64
64
|
parser.separator " status Show repository delivery state"
|
|
65
65
|
parser.separator " setup Initialise Carson configuration"
|
|
66
66
|
parser.separator " audit Run pre-commit health checks"
|
|
67
|
+
parser.separator " abandon Close and clean up abandoned delivery work"
|
|
67
68
|
parser.separator " sync Sync local main with remote"
|
|
68
69
|
parser.separator " deliver Start autonomous branch delivery"
|
|
70
|
+
parser.separator " recover Merge the repair PR for one baseline-red governance check"
|
|
69
71
|
parser.separator " prune Remove stale local branches"
|
|
70
72
|
parser.separator " worktree Manage isolated coding worktrees"
|
|
71
73
|
parser.separator " housekeep Sync, reap worktrees, and prune branches"
|
|
@@ -120,12 +122,16 @@ module Carson
|
|
|
120
122
|
parse_review_subcommand( arguments: arguments, error: error )
|
|
121
123
|
when "audit"
|
|
122
124
|
parse_audit_command( arguments: arguments, error: error )
|
|
125
|
+
when "abandon"
|
|
126
|
+
parse_abandon_command( arguments: arguments, error: error )
|
|
123
127
|
when "sync"
|
|
124
128
|
parse_sync_command( arguments: arguments, error: error )
|
|
125
129
|
when "status"
|
|
126
130
|
parse_status_command( arguments: arguments, error: error )
|
|
127
131
|
when "deliver"
|
|
128
132
|
parse_deliver_command( arguments: arguments, error: error )
|
|
133
|
+
when "recover"
|
|
134
|
+
parse_recover_command( arguments: arguments, error: error )
|
|
129
135
|
when "govern"
|
|
130
136
|
parse_govern_subcommand( arguments: arguments, error: error )
|
|
131
137
|
else
|
|
@@ -303,7 +309,7 @@ module Carson
|
|
|
303
309
|
def self.parse_worktree_subcommand( arguments:, error: )
|
|
304
310
|
options = { json: false, force: false }
|
|
305
311
|
worktree_parser = OptionParser.new do |parser|
|
|
306
|
-
parser.banner = "Usage: carson worktree <create|remove> <name> [options]"
|
|
312
|
+
parser.banner = "Usage: carson worktree <create|list|remove> <name> [options]"
|
|
307
313
|
parser.separator ""
|
|
308
314
|
parser.separator "Manage isolated worktrees for coding agents."
|
|
309
315
|
parser.separator "Create auto-syncs main before branching. Remove guards against"
|
|
@@ -311,6 +317,7 @@ module Carson
|
|
|
311
317
|
parser.separator ""
|
|
312
318
|
parser.separator "Subcommands:"
|
|
313
319
|
parser.separator " create <name> Create a new worktree with a fresh branch"
|
|
320
|
+
parser.separator " list List registered worktrees with cleanup status"
|
|
314
321
|
parser.separator " remove <name> [--force] Remove a worktree (--force skips safety checks)"
|
|
315
322
|
parser.separator ""
|
|
316
323
|
parser.separator "Options:"
|
|
@@ -319,6 +326,7 @@ module Carson
|
|
|
319
326
|
parser.separator ""
|
|
320
327
|
parser.separator "Examples:"
|
|
321
328
|
parser.separator " carson worktree create feature-x Create an isolated worktree"
|
|
329
|
+
parser.separator " carson worktree list Show registered worktrees"
|
|
322
330
|
parser.separator " carson worktree remove feature-x Remove after work is pushed"
|
|
323
331
|
end
|
|
324
332
|
worktree_parser.parse!( arguments )
|
|
@@ -338,6 +346,8 @@ module Carson
|
|
|
338
346
|
return { command: :invalid }
|
|
339
347
|
end
|
|
340
348
|
{ command: "worktree:create", worktree_name: name, json: options[ :json ] }
|
|
349
|
+
when "list"
|
|
350
|
+
{ command: "worktree:list", json: options[ :json ] }
|
|
341
351
|
when "remove"
|
|
342
352
|
worktree_path = arguments.shift
|
|
343
353
|
if worktree_path.to_s.strip.empty?
|
|
@@ -478,6 +488,38 @@ module Carson
|
|
|
478
488
|
{ command: :invalid }
|
|
479
489
|
end
|
|
480
490
|
|
|
491
|
+
# --- abandon ---
|
|
492
|
+
|
|
493
|
+
def self.parse_abandon_command( arguments:, error: )
|
|
494
|
+
options = { json: false }
|
|
495
|
+
abandon_parser = OptionParser.new do |parser|
|
|
496
|
+
parser.banner = "Usage: carson abandon <pr-number|pr-url|branch> [--json]"
|
|
497
|
+
parser.separator ""
|
|
498
|
+
parser.separator "Close an abandoned delivery and clean up its worktree and branch when safe."
|
|
499
|
+
parser.separator ""
|
|
500
|
+
parser.separator "Options:"
|
|
501
|
+
parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
502
|
+
parser.separator ""
|
|
503
|
+
parser.separator "Examples:"
|
|
504
|
+
parser.separator " carson abandon 301"
|
|
505
|
+
parser.separator " carson abandon https://github.com/acme/widgets/pull/301"
|
|
506
|
+
parser.separator " carson abandon codex/feature-branch"
|
|
507
|
+
end
|
|
508
|
+
abandon_parser.parse!( arguments )
|
|
509
|
+
target = arguments.shift.to_s.strip
|
|
510
|
+
if target.empty? || !arguments.empty?
|
|
511
|
+
error.puts "#{BADGE} Use: carson abandon <pr-number|pr-url|branch>"
|
|
512
|
+
error.puts abandon_parser
|
|
513
|
+
return { command: :invalid }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
{ command: "abandon", target: target, json: options.fetch( :json ) }
|
|
517
|
+
rescue OptionParser::ParseError => exception
|
|
518
|
+
error.puts "#{BADGE} #{exception.message}"
|
|
519
|
+
error.puts abandon_parser
|
|
520
|
+
{ command: :invalid }
|
|
521
|
+
end
|
|
522
|
+
|
|
481
523
|
# --- sync ---
|
|
482
524
|
|
|
483
525
|
def self.parse_sync_command( arguments:, error: )
|
|
@@ -591,6 +633,47 @@ module Carson
|
|
|
591
633
|
{ command: :invalid }
|
|
592
634
|
end
|
|
593
635
|
|
|
636
|
+
# --- recover ---
|
|
637
|
+
|
|
638
|
+
def self.parse_recover_command( arguments:, error: )
|
|
639
|
+
options = { json: false, check_name: nil }
|
|
640
|
+
recover_parser = OptionParser.new do |parser|
|
|
641
|
+
parser.banner = "Usage: carson recover --check NAME [--json]"
|
|
642
|
+
parser.separator ""
|
|
643
|
+
parser.separator "Merge the current repair PR when one governance-owned required check is already red on the default branch."
|
|
644
|
+
parser.separator "Recovery is narrow: Carson verifies the baseline failure, keeps every other gate intact, and records an audit event."
|
|
645
|
+
parser.separator ""
|
|
646
|
+
parser.separator "Options:"
|
|
647
|
+
parser.on( "--check NAME", "Name of the governance-owned required check to recover" ) { |value| options[ :check_name ] = value }
|
|
648
|
+
parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
649
|
+
parser.separator ""
|
|
650
|
+
parser.separator "Examples:"
|
|
651
|
+
parser.separator " carson recover --check \"Carson governance\""
|
|
652
|
+
parser.separator " carson recover --check \"Carson governance\" --json"
|
|
653
|
+
end
|
|
654
|
+
recover_parser.parse!( arguments )
|
|
655
|
+
if options.fetch( :check_name, nil ).to_s.strip.empty?
|
|
656
|
+
error.puts "#{BADGE} --check requires a non-empty governance check name"
|
|
657
|
+
error.puts recover_parser
|
|
658
|
+
return { command: :invalid }
|
|
659
|
+
end
|
|
660
|
+
unless arguments.empty?
|
|
661
|
+
error.puts "#{BADGE} Unexpected arguments for recover: #{arguments.join( ' ' )}"
|
|
662
|
+
error.puts recover_parser
|
|
663
|
+
return { command: :invalid }
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
{
|
|
667
|
+
command: "recover",
|
|
668
|
+
json: options.fetch( :json ),
|
|
669
|
+
check_name: options.fetch( :check_name )
|
|
670
|
+
}
|
|
671
|
+
rescue OptionParser::ParseError => exception
|
|
672
|
+
error.puts "#{BADGE} #{exception.message}"
|
|
673
|
+
error.puts recover_parser
|
|
674
|
+
{ command: :invalid }
|
|
675
|
+
end
|
|
676
|
+
|
|
594
677
|
# --- repos ---
|
|
595
678
|
|
|
596
679
|
def self.parse_repos_command( arguments:, error: )
|
|
@@ -623,9 +706,9 @@ module Carson
|
|
|
623
706
|
# --- housekeep ---
|
|
624
707
|
|
|
625
708
|
def self.parse_housekeep_command( arguments:, error: )
|
|
626
|
-
options = { all: false, json: false, dry_run: false }
|
|
709
|
+
options = { all: false, json: false, dry_run: false, loop_seconds: nil }
|
|
627
710
|
housekeep_parser = OptionParser.new do |parser|
|
|
628
|
-
parser.banner = "Usage: carson housekeep [REPO] [--all] [--dry-run] [--json]"
|
|
711
|
+
parser.banner = "Usage: carson housekeep [REPO] [--all] [--dry-run] [--json] [--loop SECONDS]"
|
|
629
712
|
parser.separator ""
|
|
630
713
|
parser.separator "Run housekeeping: sync main, reap dead worktrees, and prune stale branches."
|
|
631
714
|
parser.separator "Defaults to the current repository."
|
|
@@ -634,21 +717,31 @@ module Carson
|
|
|
634
717
|
parser.on( "--all", "Housekeep all governed repositories" ) { options[ :all ] = true }
|
|
635
718
|
parser.on( "--dry-run", "Show what would be reaped/deleted without making changes" ) { options[ :dry_run ] = true }
|
|
636
719
|
parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
720
|
+
parser.on( "--loop SECONDS", Integer, "Run continuously, sleeping SECONDS between cycles (requires --all)" ) do |seconds|
|
|
721
|
+
error.puts( "#{BADGE} --loop expects a positive integer" ) || ( return { command: :invalid } ) if seconds < 1
|
|
722
|
+
options[ :loop_seconds ] = seconds
|
|
723
|
+
end
|
|
637
724
|
parser.separator ""
|
|
638
725
|
parser.separator "Examples:"
|
|
639
726
|
parser.separator " carson housekeep Housekeep the current repository"
|
|
640
727
|
parser.separator " carson housekeep --dry-run Preview what housekeep would do"
|
|
641
728
|
parser.separator " carson housekeep nexus Housekeep a named governed repo"
|
|
642
729
|
parser.separator " carson housekeep --all Housekeep all governed repos"
|
|
730
|
+
parser.separator " carson housekeep --all --loop 300 Housekeep every 5 minutes"
|
|
643
731
|
end
|
|
644
732
|
housekeep_parser.parse!( arguments )
|
|
645
733
|
|
|
734
|
+
if options[ :loop_seconds ] && !options[ :all ]
|
|
735
|
+
error.puts "#{BADGE} --loop requires --all"
|
|
736
|
+
return { command: :invalid }
|
|
737
|
+
end
|
|
738
|
+
|
|
646
739
|
if options[ :all ] && !arguments.empty?
|
|
647
740
|
error.puts "#{BADGE} --all and repo target are mutually exclusive. Use: carson housekeep --all OR carson housekeep [repo]"
|
|
648
741
|
return { command: :invalid }
|
|
649
742
|
end
|
|
650
743
|
|
|
651
|
-
return { command: "housekeep:all", json: options[ :json ], dry_run: options[ :dry_run ] } if options[ :all ]
|
|
744
|
+
return { command: "housekeep:all", json: options[ :json ], dry_run: options[ :dry_run ], loop_seconds: options[ :loop_seconds ] } if options[ :all ]
|
|
652
745
|
|
|
653
746
|
if arguments.length > 1
|
|
654
747
|
error.puts "#{BADGE} Too many arguments for housekeep. Use: carson housekeep [repo]"
|
|
@@ -745,6 +838,8 @@ module Carson
|
|
|
745
838
|
runtime.setup!( cli_choices: parsed.fetch( :cli_choices, {} ) )
|
|
746
839
|
when "audit"
|
|
747
840
|
runtime.audit!( json_output: parsed.fetch( :json, false ) )
|
|
841
|
+
when "abandon"
|
|
842
|
+
runtime.abandon!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ) )
|
|
748
843
|
when "sync"
|
|
749
844
|
runtime.sync!( json_output: parsed.fetch( :json, false ) )
|
|
750
845
|
when "prune"
|
|
@@ -753,6 +848,8 @@ module Carson
|
|
|
753
848
|
runtime.prune_all!
|
|
754
849
|
when "worktree:create"
|
|
755
850
|
runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
|
|
851
|
+
when "worktree:list"
|
|
852
|
+
runtime.worktree_list!( json_output: parsed.fetch( :json, false ) )
|
|
756
853
|
when "worktree:remove"
|
|
757
854
|
runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) )
|
|
758
855
|
when "onboard"
|
|
@@ -774,6 +871,11 @@ module Carson
|
|
|
774
871
|
commit_message: parsed.fetch( :commit_message, nil ),
|
|
775
872
|
json_output: parsed.fetch( :json, false )
|
|
776
873
|
)
|
|
874
|
+
when "recover"
|
|
875
|
+
runtime.recover!(
|
|
876
|
+
check_name: parsed.fetch( :check_name ),
|
|
877
|
+
json_output: parsed.fetch( :json, false )
|
|
878
|
+
)
|
|
777
879
|
when "review:gate"
|
|
778
880
|
runtime.review_gate!
|
|
779
881
|
when "review:sweep"
|
|
@@ -785,7 +887,16 @@ module Carson
|
|
|
785
887
|
when "housekeep:target"
|
|
786
888
|
runtime.housekeep_target!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
|
|
787
889
|
when "housekeep:all"
|
|
788
|
-
|
|
890
|
+
loop_seconds = parsed.fetch( :loop_seconds, nil )
|
|
891
|
+
if loop_seconds
|
|
892
|
+
runtime.housekeep_loop!(
|
|
893
|
+
json_output: parsed.fetch( :json, false ),
|
|
894
|
+
dry_run: parsed.fetch( :dry_run, false ),
|
|
895
|
+
loop_seconds: loop_seconds
|
|
896
|
+
)
|
|
897
|
+
else
|
|
898
|
+
runtime.housekeep_all!( json_output: parsed.fetch( :json, false ), dry_run: parsed.fetch( :dry_run, false ) )
|
|
899
|
+
end
|
|
789
900
|
when "govern"
|
|
790
901
|
runtime.govern!(
|
|
791
902
|
dry_run: parsed.fetch( :dry_run, false ),
|
data/lib/carson/config.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Carson
|
|
|
30
30
|
:review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
|
|
31
31
|
:audit_advisory_check_names,
|
|
32
32
|
:workflow_style,
|
|
33
|
-
:govern_repos, :
|
|
33
|
+
:govern_repos, :govern_merge_method,
|
|
34
34
|
:govern_agent_provider, :govern_state_path,
|
|
35
35
|
:govern_check_wait
|
|
36
36
|
|
|
@@ -83,7 +83,6 @@ module Carson
|
|
|
83
83
|
},
|
|
84
84
|
"govern" => {
|
|
85
85
|
"repos" => [],
|
|
86
|
-
"authority" => "remote",
|
|
87
86
|
"merge" => {
|
|
88
87
|
"method" => "squash"
|
|
89
88
|
},
|
|
@@ -92,7 +91,7 @@ module Carson
|
|
|
92
91
|
"codex" => {},
|
|
93
92
|
"claude" => {}
|
|
94
93
|
},
|
|
95
|
-
"state_path" => "~/.carson/state.
|
|
94
|
+
"state_path" => "~/.carson/state.json",
|
|
96
95
|
"check_wait" => 30
|
|
97
96
|
}
|
|
98
97
|
}
|
|
@@ -171,8 +170,6 @@ module Carson
|
|
|
171
170
|
govern = fetch_hash_section( data: copy, key: "govern" )
|
|
172
171
|
govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
|
|
173
172
|
govern[ "repos" ] = govern_repos unless govern_repos.empty?
|
|
174
|
-
govern_authority = ENV.fetch( "CARSON_GOVERN_AUTHORITY", "" ).to_s.strip
|
|
175
|
-
govern[ "authority" ] = govern_authority unless govern_authority.empty?
|
|
176
173
|
govern_method = ENV.fetch( "CARSON_GOVERN_MERGE_METHOD", "" ).to_s.strip
|
|
177
174
|
unless govern_method.empty?
|
|
178
175
|
govern[ "merge" ] ||= {}
|
|
@@ -243,14 +240,13 @@ module Carson
|
|
|
243
240
|
|
|
244
241
|
govern_hash = fetch_hash( hash: data, key: "govern" )
|
|
245
242
|
@govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |path| safe_expand_path( path ) }
|
|
246
|
-
@govern_authority = fetch_string( hash: govern_hash, key: "authority" ).downcase
|
|
247
243
|
govern_merge_hash = fetch_hash( hash: govern_hash, key: "merge" )
|
|
248
244
|
@govern_merge_method = fetch_string( hash: govern_merge_hash, key: "method" ).downcase
|
|
249
245
|
govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
|
|
250
246
|
@govern_agent_provider = fetch_string( hash: govern_agent_hash, key: "provider" ).downcase
|
|
251
247
|
@govern_state_path = resolve_runtime_path(
|
|
252
248
|
path: govern_hash.fetch( "state_path" ).to_s,
|
|
253
|
-
fallback_leaf: "state.
|
|
249
|
+
fallback_leaf: "state.json"
|
|
254
250
|
)
|
|
255
251
|
@govern_check_wait = fetch_non_negative_integer( hash: govern_hash, key: "check_wait" )
|
|
256
252
|
|
|
@@ -272,7 +268,6 @@ module Carson
|
|
|
272
268
|
raise ConfigError, "review.tracking_issue.title cannot be empty" if review_tracking_issue_title.empty?
|
|
273
269
|
raise ConfigError, "review.tracking_issue.label cannot be empty" if review_tracking_issue_label.empty?
|
|
274
270
|
raise ConfigError, "workflow.style must be one of trunk, branch" unless [ "trunk", "branch" ].include?( workflow_style )
|
|
275
|
-
raise ConfigError, "govern.authority must be one of remote, local" unless [ "remote", "local" ].include?( govern_authority )
|
|
276
271
|
raise ConfigError, "govern.merge.method must be squash" unless govern_merge_method == "squash"
|
|
277
272
|
raise ConfigError, "govern.agent.provider must be one of auto, codex, claude" unless [ "auto", "codex", "claude" ].include?( govern_agent_provider )
|
|
278
273
|
end
|
data/lib/carson/delivery.rb
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Passive ledger record for one branch
|
|
1
|
+
# Passive ledger record for one branch delivery attempt.
|
|
2
2
|
module Carson
|
|
3
3
|
class Delivery
|
|
4
4
|
ACTIVE_STATES = %w[preparing gated queued integrating escalated].freeze
|
|
@@ -6,25 +6,25 @@ module Carson
|
|
|
6
6
|
READY_STATES = %w[queued].freeze
|
|
7
7
|
TERMINAL_STATES = %w[integrated failed superseded].freeze
|
|
8
8
|
|
|
9
|
-
attr_reader :
|
|
10
|
-
:pull_request_number, :pull_request_url, :
|
|
9
|
+
attr_reader :repo_path, :repository, :branch, :head, :worktree_path, :status,
|
|
10
|
+
:pull_request_number, :pull_request_url, :revisions, :cause, :summary,
|
|
11
11
|
:created_at, :updated_at, :integrated_at, :superseded_at
|
|
12
12
|
|
|
13
13
|
def initialize(
|
|
14
|
-
|
|
15
|
-
pull_request_number:, pull_request_url:,
|
|
16
|
-
created_at:, updated_at:, integrated_at:, superseded_at
|
|
14
|
+
repo_path:, branch:, head:, worktree_path:, status:,
|
|
15
|
+
pull_request_number:, pull_request_url:, cause:, summary:,
|
|
16
|
+
created_at:, updated_at:, integrated_at:, superseded_at:,
|
|
17
|
+
revisions: [], repository: nil
|
|
17
18
|
)
|
|
18
|
-
@
|
|
19
|
+
@repo_path = repo_path
|
|
19
20
|
@repository = repository
|
|
20
21
|
@branch = branch
|
|
21
22
|
@head = head
|
|
22
23
|
@worktree_path = worktree_path
|
|
23
|
-
@authority = authority
|
|
24
24
|
@status = status
|
|
25
25
|
@pull_request_number = pull_request_number
|
|
26
26
|
@pull_request_url = pull_request_url
|
|
27
|
-
@
|
|
27
|
+
@revisions = revisions
|
|
28
28
|
@cause = cause
|
|
29
29
|
@summary = summary
|
|
30
30
|
@created_at = created_at
|
|
@@ -33,6 +33,14 @@ module Carson
|
|
|
33
33
|
@superseded_at = superseded_at
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def key
|
|
37
|
+
"#{repo_path}:#{branch}:#{head}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def revision_count
|
|
41
|
+
revisions.length
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
def active?
|
|
37
45
|
ACTIVE_STATES.include?( status )
|
|
38
46
|
end
|