carson 3.24.0 → 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.
@@ -0,0 +1,418 @@
1
+ # Governed recovery path for baseline-red governance checks.
2
+ module Carson
3
+ class Runtime
4
+ module Recover
5
+ GOVERNANCE_SURFACE_PREFIXES = %w[ .github/ hooks/ ].freeze
6
+
7
+ def recover!( check_name:, json_output: false )
8
+ result = {
9
+ command: "recover",
10
+ branch: current_branch,
11
+ check: check_name,
12
+ main_branch: config.main_branch
13
+ }
14
+
15
+ if current_branch == config.main_branch
16
+ result[ :error ] = "cannot recover from #{config.main_branch}"
17
+ result[ :recovery ] = "carson worktree create <name>"
18
+ return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
19
+ end
20
+
21
+ if working_tree_dirty?
22
+ result[ :error ] = "working tree is dirty"
23
+ result[ :recovery ] = "commit or discard local changes, then rerun carson recover --check #{check_name.inspect}"
24
+ return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
25
+ end
26
+
27
+ unless gh_available?
28
+ result[ :error ] = "gh CLI is required for carson recover"
29
+ result[ :recovery ] = "install and authenticate gh, then retry"
30
+ return recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
31
+ end
32
+
33
+ delivery = ledger.active_delivery( repo_path: repository_record.path, branch_name: current_branch )
34
+ if delivery.nil?
35
+ result[ :error ] = "no active delivery found for #{current_branch}"
36
+ result[ :recovery ] = "carson deliver"
37
+ return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
38
+ end
39
+
40
+ result[ :pr_number ] = delivery.pull_request_number
41
+ result[ :pr_url ] = delivery.pull_request_url
42
+
43
+ pull_request = recover_pull_request_details( number: delivery.pull_request_number )
44
+ result[ :pr_url ] = pull_request.fetch( :url )
45
+
46
+ if pull_request.fetch( :state ) != "OPEN"
47
+ result[ :error ] = "pull request ##{delivery.pull_request_number} is not open"
48
+ result[ :recovery ] = "carson status"
49
+ return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
50
+ end
51
+
52
+ if pull_request.fetch( :branch ) != current_branch
53
+ result[ :error ] = "pull request ##{delivery.pull_request_number} belongs to #{pull_request.fetch( :branch )}, not #{current_branch}"
54
+ result[ :recovery ] = "checkout #{pull_request.fetch( :branch )} or rerun carson deliver for #{current_branch}"
55
+ return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
56
+ end
57
+
58
+ if pull_request.fetch( :head_sha ) != current_head
59
+ result[ :error ] = "pull request ##{delivery.pull_request_number} head no longer matches local #{current_branch}"
60
+ result[ :recovery ] = "push the current branch with carson deliver, then retry"
61
+ return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
62
+ end
63
+
64
+ relation = recovery_governance_surface_report( base_branch: pull_request.fetch( :base_branch ) )
65
+ if relation.fetch( :status ) == "error"
66
+ result[ :error ] = relation.fetch( :error )
67
+ result[ :recovery ] = "git diff --name-only #{pull_request.fetch( :base_branch )}...HEAD"
68
+ return recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
69
+ end
70
+ result[ :changed_files ] = relation.fetch( :files )
71
+
72
+ unless relation.fetch( :related )
73
+ result[ :error ] = "branch does not touch the governance surface for #{check_name}"
74
+ result[ :recovery ] = "update the branch to repair .github/ or hooks/, then rerun carson recover --check #{check_name.inspect}"
75
+ return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
76
+ end
77
+
78
+ baseline = default_branch_ci_baseline_report
79
+ result[ :baseline ] = {
80
+ default_branch: baseline.fetch( :default_branch, config.main_branch ),
81
+ head_sha: baseline[ :head_sha ],
82
+ status: baseline.fetch( :status ),
83
+ check_name: check_name
84
+ }
85
+ if baseline.fetch( :status ) == "skipped"
86
+ result[ :error ] = "unable to verify the default-branch baseline: #{baseline.fetch( :skip_reason )}"
87
+ result[ :recovery ] = "run carson audit after fixing GitHub access"
88
+ return recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
89
+ end
90
+
91
+ baseline_entry = recovery_baseline_entry( baseline: baseline, check_name: check_name )
92
+ if baseline_entry.nil?
93
+ result[ :error ] = "#{check_name} is not red on #{baseline.fetch( :default_branch, config.main_branch )}"
94
+ result[ :recovery ] = "run carson audit to confirm the baseline check state"
95
+ return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
96
+ end
97
+
98
+ review = check_pr_review(
99
+ number: delivery.pull_request_number,
100
+ branch: current_branch,
101
+ pr_url: delivery.pull_request_url
102
+ )
103
+ result[ :review ] = review
104
+ review_issue = recovery_review_issue( review: review, check_name: check_name )
105
+ unless review_issue.nil?
106
+ result[ :error ] = review_issue.fetch( :error )
107
+ result[ :recovery ] = review_issue.fetch( :recovery )
108
+ return recover_finish(
109
+ result: result,
110
+ exit_code: review_issue.fetch( :exit_code ),
111
+ json_output: json_output
112
+ )
113
+ end
114
+
115
+ checks = recover_required_pr_checks_report( number: delivery.pull_request_number )
116
+ result[ :checks ] = checks
117
+ if checks.fetch( :status ) == "error"
118
+ result[ :error ] = checks.fetch( :error )
119
+ result[ :recovery ] = "gh pr checks #{delivery.pull_request_number} --required"
120
+ return recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
121
+ end
122
+
123
+ other_gate_issue = recovery_other_required_check_issue( checks: checks, check_name: check_name )
124
+ unless other_gate_issue.nil?
125
+ result[ :error ] = other_gate_issue.fetch( :error )
126
+ result[ :recovery ] = other_gate_issue.fetch( :recovery )
127
+ return recover_finish(
128
+ result: result,
129
+ exit_code: other_gate_issue.fetch( :exit_code ),
130
+ json_output: json_output
131
+ )
132
+ end
133
+
134
+ pr_state = pull_request_state( number: delivery.pull_request_number )
135
+ merge_issue = recover_mergeability_issue( pr_state: pr_state )
136
+ unless merge_issue.nil?
137
+ result[ :error ] = merge_issue
138
+ result[ :recovery ] = "resolve the merge conflict, then rerun carson recover --check #{check_name.inspect}"
139
+ return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
140
+ end
141
+
142
+ prepared = ledger.update_delivery(
143
+ delivery: delivery,
144
+ status: "integrating",
145
+ summary: "recovering #{check_name} into #{config.main_branch}"
146
+ )
147
+ merge_exit = recover_merge_pr!(
148
+ number: prepared.pull_request_number,
149
+ owner: pull_request.fetch( :owner ),
150
+ repo: pull_request.fetch( :repo ),
151
+ head_sha: pull_request.fetch( :head_sha ),
152
+ result: result
153
+ )
154
+
155
+ if merge_exit == EXIT_OK
156
+ event = ledger.send(
157
+ :record_recovery_event,
158
+ repository: repository_record,
159
+ branch_name: current_branch,
160
+ pr_number: prepared.pull_request_number,
161
+ pr_url: prepared.pull_request_url,
162
+ check_name: check_name,
163
+ default_branch: baseline.fetch( :default_branch, config.main_branch ),
164
+ default_branch_sha: baseline.fetch( :head_sha ),
165
+ pr_sha: pull_request.fetch( :head_sha ),
166
+ actor: recovery_actor,
167
+ merge_method: result.fetch( :merge_method ),
168
+ status: "integrated",
169
+ summary: "recovered #{check_name} into #{config.main_branch}"
170
+ )
171
+
172
+ integrated = ledger.update_delivery(
173
+ delivery: prepared,
174
+ status: "integrated",
175
+ integrated_at: Time.now.utc.iso8601,
176
+ summary: "recovered #{check_name} into #{config.main_branch}"
177
+ )
178
+ sync_after_merge!( remote: config.git_remote, main: config.main_branch, result: result )
179
+ result[ :delivery ] = delivery_payload( delivery: integrated )
180
+ result[ :recovery_event ] = event
181
+ result[ :summary ] = integrated.summary
182
+ result[ :next_step ] = deliver_next_step( delivery: integrated, result: result )
183
+ return recover_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
184
+ end
185
+
186
+ gated = ledger.update_delivery(
187
+ delivery: prepared,
188
+ status: "gated",
189
+ cause: "policy",
190
+ summary: result.fetch( :error, "recovery merge failed" )
191
+ )
192
+ result[ :delivery ] = delivery_payload( delivery: gated )
193
+ result[ :summary ] = gated.summary
194
+ result[ :next_step ] = "carson status"
195
+ recover_finish( result: result, exit_code: merge_exit, json_output: json_output )
196
+ rescue StandardError => exception
197
+ result[ :error ] = exception.message
198
+ result[ :recovery ] = "carson status"
199
+ recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
200
+ end
201
+
202
+ private
203
+
204
+ def recover_pull_request_details( number: )
205
+ owner, repo = repository_coordinates
206
+ data = gh_json_payload!(
207
+ "api", "repos/#{owner}/#{repo}/pulls/#{number}",
208
+ "--method", "GET",
209
+ fallback: "unable to read pull request ##{number}"
210
+ )
211
+ {
212
+ number: data.fetch( "number" ),
213
+ url: data.fetch( "html_url" ).to_s,
214
+ state: data.fetch( "state" ).to_s.upcase,
215
+ branch: data.dig( "head", "ref" ).to_s,
216
+ head_sha: data.dig( "head", "sha" ).to_s,
217
+ base_branch: data.dig( "base", "ref" ).to_s,
218
+ base_sha: data.dig( "base", "sha" ).to_s,
219
+ owner: owner,
220
+ repo: repo
221
+ }
222
+ end
223
+
224
+ def recovery_governance_surface_report( base_branch: )
225
+ stdout_text, stderr_text, success, = git_run( "diff", "--name-only", "#{base_branch}...HEAD" )
226
+ unless success
227
+ error_text = stderr_text.to_s.strip
228
+ error_text = "unable to inspect branch changes against #{base_branch}" if error_text.empty?
229
+ return { status: "error", error: error_text, files: [] }
230
+ end
231
+
232
+ files = stdout_text.lines.map( &:strip ).reject( &:empty? )
233
+ related = files.any? do |path|
234
+ GOVERNANCE_SURFACE_PREFIXES.any? { |prefix| path.start_with?( prefix ) }
235
+ end
236
+
237
+ { status: "ok", related: related, files: files }
238
+ end
239
+
240
+ def recovery_baseline_entry( baseline:, check_name: )
241
+ Array( baseline.fetch( :failing ) ).find do |entry|
242
+ entry.fetch( :name ).to_s == check_name
243
+ end
244
+ end
245
+
246
+ def recovery_review_issue( review:, check_name: )
247
+ if review.fetch( :status, :pass ) == :error
248
+ return {
249
+ exit_code: EXIT_ERROR,
250
+ error: "unable to assess the review gate: #{review.fetch( :detail )}",
251
+ recovery: "carson review gate"
252
+ }
253
+ end
254
+
255
+ if review.fetch( :review, :none ) == :changes_requested
256
+ return {
257
+ exit_code: EXIT_BLOCK,
258
+ error: "review changes are still requested",
259
+ recovery: "address the requested review changes, then rerun carson recover --check #{check_name.inspect}"
260
+ }
261
+ end
262
+
263
+ if review.fetch( :review, :none ) == :review_required
264
+ return {
265
+ exit_code: EXIT_BLOCK,
266
+ error: "review approval is still required",
267
+ recovery: "run carson review gate, then rerun carson recover --check #{check_name.inspect}"
268
+ }
269
+ end
270
+
271
+ return nil if review.fetch( :status, :pass ) == :pass
272
+
273
+ {
274
+ exit_code: EXIT_BLOCK,
275
+ error: review.fetch( :detail ).to_s,
276
+ recovery: "run carson review gate, then rerun carson recover --check #{check_name.inspect}"
277
+ }
278
+ end
279
+
280
+ def recover_required_pr_checks_report( number: )
281
+ stdout_text, stderr_text, success, = gh_run(
282
+ "pr", "checks", number.to_s,
283
+ "--required",
284
+ "--json", "name,state,bucket,workflow,link"
285
+ )
286
+ unless success
287
+ error_text = gh_error_text(
288
+ stdout_text: stdout_text,
289
+ stderr_text: stderr_text,
290
+ fallback: "required checks unavailable"
291
+ )
292
+ return { status: "error", error: error_text, required_total: 0, failing: [], pending: [] }
293
+ end
294
+
295
+ entries = JSON.parse( stdout_text )
296
+ failing = entries.select { |entry| check_entry_failing?( entry: entry ) }
297
+ pending = entries.select { |entry| entry[ "bucket" ].to_s == "pending" }
298
+ {
299
+ status: "ok",
300
+ required_total: entries.count,
301
+ failing: normalise_check_entries( entries: failing ),
302
+ pending: normalise_check_entries( entries: pending )
303
+ }
304
+ rescue JSON::ParserError => exception
305
+ {
306
+ status: "error",
307
+ error: "invalid gh JSON response (#{exception.message})",
308
+ required_total: 0,
309
+ failing: [],
310
+ pending: []
311
+ }
312
+ end
313
+
314
+ def recovery_other_required_check_issue( checks:, check_name: )
315
+ other_failing = Array( checks.fetch( :failing ) ).reject { |entry| entry.fetch( :name ) == check_name }
316
+ other_pending = Array( checks.fetch( :pending ) ).reject { |entry| entry.fetch( :name ) == check_name }
317
+ return nil if other_failing.empty? && other_pending.empty?
318
+
319
+ names = ( other_failing + other_pending ).map { |entry| entry.fetch( :name ) }.uniq.sort
320
+ details = []
321
+ details << "#{other_failing.count} failing" unless other_failing.empty?
322
+ details << "#{other_pending.count} pending" unless other_pending.empty?
323
+ {
324
+ exit_code: EXIT_BLOCK,
325
+ error: "other required checks are still #{details.join( ' and ' )}: #{names.join( ', ' )}",
326
+ recovery: "fix the other required checks, then rerun carson recover --check #{check_name.inspect}"
327
+ }
328
+ end
329
+
330
+ def recover_mergeability_issue( pr_state: )
331
+ return nil unless pr_state.is_a?( Hash )
332
+
333
+ mergeable = pr_state.fetch( "mergeable", "" ).to_s.upcase
334
+ merge_state = pr_state.fetch( "mergeStateStatus", "" ).to_s.upcase
335
+ return "pull request has merge conflicts" if mergeable == "CONFLICTING"
336
+ return "pull request has merge conflicts" if %w[DIRTY CONFLICTING].include?( merge_state )
337
+
338
+ nil
339
+ end
340
+
341
+ def recover_merge_pr!( number:, owner:, repo:, head_sha:, result: )
342
+ method = config.govern_merge_method
343
+ result[ :merge_method ] = method
344
+
345
+ stdout_text, stderr_text, success, = gh_run(
346
+ "api", "repos/#{owner}/#{repo}/pulls/#{number}/merge",
347
+ "--method", "PUT",
348
+ "-f", "sha=#{head_sha}",
349
+ "-f", "merge_method=#{method}"
350
+ )
351
+
352
+ if success
353
+ payload = JSON.parse( stdout_text ) rescue {}
354
+ result[ :merge ] = {
355
+ status: "recovered",
356
+ summary: blank_to( value: payload[ "message" ], default: "merged via governed recovery" ),
357
+ method: method
358
+ }
359
+ return EXIT_OK
360
+ end
361
+
362
+ error_text = gh_error_text(
363
+ stdout_text: stdout_text,
364
+ stderr_text: stderr_text,
365
+ fallback: "recovery merge failed"
366
+ )
367
+ result[ :merge ] = {
368
+ status: "blocked",
369
+ summary: error_text,
370
+ recovery: "carson status",
371
+ method: method
372
+ }
373
+ result[ :error ] = error_text
374
+ result[ :recovery ] = "carson status"
375
+ EXIT_ERROR
376
+ end
377
+
378
+ def recovery_actor
379
+ actor = ENV.fetch( "USER", ENV.fetch( "LOGNAME", "" ) ).to_s.strip
380
+ actor.empty? ? "unknown" : actor
381
+ end
382
+
383
+ def recover_finish( result:, exit_code:, json_output: )
384
+ result[ :exit_code ] = exit_code
385
+
386
+ if json_output
387
+ output.puts JSON.pretty_generate( result )
388
+ else
389
+ print_recover_human( result: result )
390
+ end
391
+
392
+ exit_code
393
+ end
394
+
395
+ def print_recover_human( result: )
396
+ if result[ :error ]
397
+ puts_line result.fetch( :error )
398
+ puts_line " → #{result.fetch( :recovery )}" if result[ :recovery ]
399
+ return
400
+ end
401
+
402
+ puts_line "Recovery: #{result.fetch( :branch )} → #{result.fetch( :main_branch )}"
403
+ puts_line "PR ##{result.fetch( :pr_number )} #{result.fetch( :pr_url )}"
404
+ puts_line "Bypassed baseline-red check #{result.fetch( :check ).inspect}."
405
+ puts_line "Merged into #{result.fetch( :main_branch )} with #{result.fetch( :merge_method )}."
406
+ if result[ :synced ] == false
407
+ puts_line "Local #{result.fetch( :main_branch )} sync failed — #{result.fetch( :sync_error )}."
408
+ elsif result[ :synced ]
409
+ puts_line "Synced local #{result.fetch( :main_branch )}."
410
+ end
411
+ puts_line "Recorded recovery audit for #{result.fetch( :check )}."
412
+ puts_line "Check back with #{result.fetch( :next_step )}" if result[ :next_step ]
413
+ end
414
+ end
415
+
416
+ include Recover
417
+ end
418
+ end
@@ -364,13 +364,13 @@ module Carson
364
364
 
365
365
  # Automatically registers the repo for portfolio governance during onboard.
366
366
  def auto_register_govern!
367
- expanded = File.expand_path( repo_root )
368
- if config.govern_repos.include?( expanded )
369
- puts_verbose "govern_registration: already registered #{expanded}"
367
+ canonical_root = realpath_safe( main_worktree_root )
368
+ if config.govern_repos.any? { |path| realpath_safe( path ) == canonical_root }
369
+ puts_verbose "govern_registration: already registered #{canonical_root}"
370
370
  return
371
371
  end
372
372
 
373
- append_govern_repo!( repo_path: expanded )
373
+ append_govern_repo!( repo_path: canonical_root )
374
374
  puts_line "Registered for portfolio governance."
375
375
  end
376
376
 
@@ -395,7 +395,8 @@ module Carson
395
395
 
396
396
  existing_data = load_existing_config( path: config_path )
397
397
  repos = Array( existing_data.dig( "govern", "repos" ) )
398
- updated = repos.reject { |entry| File.expand_path( entry ) == File.expand_path( repo_path ) }
398
+ target = realpath_safe( repo_path )
399
+ updated = repos.reject { |entry| realpath_safe( entry ) == target }
399
400
  return if updated.length == repos.length
400
401
 
401
402
  existing_data[ "govern" ] ||= {}
@@ -412,8 +413,11 @@ module Carson
412
413
  existing_data = load_existing_config( path: config_path )
413
414
  existing_data[ "govern" ] ||= {}
414
415
  repos = Array( existing_data[ "govern" ][ "repos" ] )
415
- repos << repo_path
416
- existing_data[ "govern" ][ "repos" ] = repos.uniq
416
+ canonical_repo_path = realpath_safe( repo_path )
417
+ unless repos.any? { |entry| realpath_safe( entry ) == canonical_repo_path }
418
+ repos << repo_path
419
+ end
420
+ existing_data[ "govern" ][ "repos" ] = repos
417
421
 
418
422
  FileUtils.mkdir_p( File.dirname( config_path ) )
419
423
  File.write( config_path, JSON.pretty_generate( existing_data ) )
@@ -55,13 +55,13 @@ module Carson
55
55
  repository = repository_record
56
56
  branch = branch_record
57
57
  deliveries = ledger.active_deliveries( repo_path: repository.path )
58
+ next_delivery_key = deliveries.find( &:ready? )&.key
58
59
 
59
60
  {
60
61
  version: Carson::VERSION,
61
62
  repository: {
62
63
  name: repository.name,
63
- path: repository.path,
64
- authority: repository.authority
64
+ path: repository.path
65
65
  },
66
66
  branch: {
67
67
  name: branch.name,
@@ -71,12 +71,13 @@ module Carson
71
71
  dirty_reason: dirty_worktree_reason,
72
72
  sync: remote_sync_status( branch: branch.name )
73
73
  },
74
- branches: deliveries.map { |delivery| status_branch_entry( delivery: delivery ) },
74
+ worktrees: gather_worktree_summary,
75
+ branches: deliveries.map { |delivery| status_branch_entry( delivery: delivery, next_to_integrate: delivery.key == next_delivery_key ) },
75
76
  stale_branches: gather_stale_branch_info
76
77
  }
77
78
  end
78
79
 
79
- def status_branch_entry( delivery: )
80
+ def status_branch_entry( delivery:, next_to_integrate: )
80
81
  {
81
82
  branch: delivery.branch,
82
83
  worktree_path: delivery.worktree_path,
@@ -85,6 +86,7 @@ module Carson
85
86
  delivery_state: delivery.status,
86
87
  revision_count: delivery.revision_count,
87
88
  summary: delivery.summary,
89
+ next_to_integrate: next_to_integrate,
88
90
  updated_at: delivery.updated_at
89
91
  }
90
92
  end
@@ -130,30 +132,43 @@ module Carson
130
132
  { count: gone_branches.size }
131
133
  end
132
134
 
135
+ def gather_worktree_summary
136
+ all = worktree_list
137
+ main_root = main_worktree_root
138
+ non_main = all.reject { |worktree| worktree.path == main_root }
139
+ { count: all.count, non_main_count: non_main.count }
140
+ end
141
+
133
142
  def print_status( data: )
134
- puts_line "Carson #{data.fetch( :version )}"
135
- puts_line "Repository: #{data.dig( :repository, :name )}"
136
- puts_line "Authority: #{data.dig( :repository, :authority )}"
143
+ repo_name = data.dig( :repository, :name )
144
+ puts_line "Carson #{data.fetch( :version )} — #{repo_name}"
137
145
 
138
146
  branch = data.fetch( :branch )
139
- branch_line = "Branch: #{branch.fetch( :name )}"
140
- branch_line += " (dirty #{branch.fetch( :dirty_reason )})" if branch.fetch( :dirty )
141
- branch_line += " [#{format_sync( sync: branch.fetch( :sync ) )}]"
147
+ branch_line = "On #{branch.fetch( :name )}"
148
+ branch_line += " (uncommitted changes)" if branch.fetch( :dirty )
149
+ branch_line += ", #{format_sync( sync: branch.fetch( :sync ) )}."
142
150
  puts_line branch_line
151
+ worktree_summary = data.fetch( :worktrees )
152
+ puts_line "Worktrees: #{worktree_summary.fetch( :non_main_count )} tracked outside main — run carson worktree list." if worktree_summary.fetch( :non_main_count ).positive?
143
153
 
144
154
  deliveries = data.fetch( :branches )
145
155
  if deliveries.empty?
146
- puts_line "Deliveries: none"
156
+ puts_line "No active deliveries."
147
157
  return
148
158
  end
149
159
 
150
- puts_line "Deliveries:"
160
+ count = deliveries.length
161
+ puts_line "#{count} active deliver#{count == 1 ? 'y' : 'ies'}:"
162
+ if (next_delivery = deliveries.find { |delivery| delivery.fetch( :next_to_integrate, false ) })
163
+ pr_number = next_delivery.fetch( :pr_number )
164
+ pr_ref = pr_number ? " (PR ##{pr_number})" : ""
165
+ puts_line "Next delivery: #{next_delivery.fetch( :branch )}#{pr_ref}."
166
+ end
151
167
  deliveries.each do |delivery|
152
- line = "- #{delivery.fetch( :delivery_state )}: #{delivery.fetch( :branch )}"
153
168
  pr_number = delivery.fetch( :pr_number )
154
- line += " (#{"PR ##{pr_number}"} )" if pr_number
155
- puts_line line.gsub( " )", ")" )
156
- puts_line " #{delivery.fetch( :summary )}" unless delivery.fetch( :summary ).to_s.empty?
169
+ pr_ref = pr_number ? " (PR ##{pr_number})" : ""
170
+ puts_line " #{delivery.fetch( :branch )}#{pr_ref} — #{delivery.fetch( :delivery_state )}"
171
+ puts_line " #{delivery.fetch( :summary )}." unless delivery.fetch( :summary ).to_s.empty?
157
172
  end
158
173
  end
159
174
 
@@ -165,22 +180,18 @@ module Carson
165
180
 
166
181
  deliveries = Array( result.fetch( :branches, [] ) )
167
182
  counts = deliveries.each_with_object( Hash.new( 0 ) ) { |delivery, memo| memo[ delivery.fetch( :delivery_state ) ] += 1 }
168
- summary = if counts.empty?
169
- "no active deliveries"
170
- else
171
- counts.map { |state, count| "#{count} #{state}" }.join( ", " )
172
- end
173
- puts_line "#{result.fetch( :name )}: #{result.dig( :repository, :authority )} — #{summary}"
183
+ summary = counts.empty? ? "no active deliveries" : counts.map { |state, count| "#{count} #{state}" }.join( ", " )
184
+ puts_line "#{result.fetch( :name )} — #{summary}"
174
185
  end
175
186
 
176
187
  def format_sync( sync: )
177
188
  case sync
178
- when :in_sync then "in sync"
179
- when :ahead then "ahead"
180
- when :behind then "behind"
181
- when :diverged then "diverged"
182
- when :no_remote then "no remote"
183
- else "unknown"
189
+ when :in_sync then "in sync with remote"
190
+ when :ahead then "ahead of remote"
191
+ when :behind then "behind remote"
192
+ when :diverged then "diverged from remote"
193
+ when :no_remote then "no remote tracking"
194
+ else "sync unknown"
184
195
  end
185
196
  end
186
197
  end
@@ -91,7 +91,7 @@ module Carson
91
91
  # canonical main tree path, regardless of which worktree the command runs from.
92
92
  # This ensures govern (which looks up by main tree path) finds worktree deliveries.
93
93
  def repository_record
94
- Repository.new( path: main_worktree_root, authority: config.govern_authority, runtime: self )
94
+ Repository.new( path: main_worktree_root, runtime: self )
95
95
  end
96
96
 
97
97
  # Passive branch record for the current checkout.
@@ -370,7 +370,9 @@ require_relative "runtime/review"
370
370
  require_relative "runtime/govern"
371
371
  require_relative "runtime/setup"
372
372
  require_relative "runtime/status"
373
+ require_relative "runtime/abandon"
373
374
  require_relative "runtime/deliver"
375
+ require_relative "runtime/recover"
374
376
 
375
377
  # Infrastructure interface for domain objects.
376
378
  # Carson::Worktree and future domain objects call these methods