carson 3.24.0 → 3.27.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.
@@ -16,10 +16,9 @@ module Carson
16
16
  end
17
17
 
18
18
  def govern_cycle!( dry_run:, json_output: )
19
- print_header "Carson Govern"
20
19
  repositories = governed_repo_paths
21
20
  repositories = [ repository_record.path ] if repositories.empty?
22
- puts_line "governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
21
+ print_header "Governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
23
22
 
24
23
  report = {
25
24
  cycle_at: Time.now.utc.iso8601,
@@ -65,13 +64,12 @@ module Carson
65
64
 
66
65
  def govern_repo!( repo_path:, dry_run: )
67
66
  scoped_runtime = repo_runtime_for( repo_path: repo_path )
68
- repository = Repository.new( path: repo_path, authority: scoped_runtime.config.govern_authority, runtime: scoped_runtime )
67
+ repository = Repository.new( path: repo_path, runtime: scoped_runtime )
69
68
  deliveries = scoped_runtime.ledger.active_deliveries( repo_path: repo_path )
70
69
 
71
70
  repo_report = {
72
71
  repository: repository.name,
73
72
  path: repo_path,
74
- authority: repository.authority,
75
73
  deliveries: [],
76
74
  error: nil
77
75
  }
@@ -84,7 +82,7 @@ module Carson
84
82
  puts_line "#{repository.name}: #{deliveries.length} active deliver#{deliveries.length == 1 ? 'y' : 'ies'}"
85
83
 
86
84
  reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
87
- next_integration_id = reconciled.find( &:ready? )&.id
85
+ next_to_integrate = reconciled.find( &:ready? )&.key
88
86
 
89
87
  reconciled.each do |delivery|
90
88
  delivery_report = scoped_runtime.send(
@@ -92,7 +90,7 @@ module Carson
92
90
  delivery: delivery,
93
91
  repo_path: repo_path,
94
92
  dry_run: dry_run,
95
- next_integration_id: next_integration_id
93
+ next_to_integrate: next_to_integrate
96
94
  )
97
95
  repo_report[ :deliveries ] << delivery_report
98
96
  end
@@ -140,9 +138,9 @@ module Carson
140
138
  assess_delivery!( delivery: delivery, branch_name: delivery.branch )
141
139
  end
142
140
 
143
- def decide_delivery_action( delivery:, repo_path:, dry_run:, next_integration_id: )
141
+ def decide_delivery_action( delivery:, repo_path:, dry_run:, next_to_integrate: )
144
142
  report = {
145
- id: delivery.id,
143
+ key: delivery.key,
146
144
  branch: delivery.branch,
147
145
  status: delivery.status,
148
146
  summary: delivery.summary,
@@ -154,13 +152,18 @@ module Carson
154
152
  return report
155
153
  end
156
154
 
157
- if delivery.ready? && delivery.id == next_integration_id
155
+ if delivery.ready? && delivery.key == next_to_integrate
158
156
  report[ :action ] = dry_run ? "would_integrate" : "integrate"
159
157
  report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
160
158
  return report
161
159
  end
162
160
 
163
161
  if delivery.blocked?
162
+ if merge_blocked_delivery?( delivery: delivery )
163
+ report[ :action ] = dry_run ? "would_hold" : "hold"
164
+ return report
165
+ end
166
+
164
167
  if delivery.revision_count >= 3
165
168
  report[ :action ] = dry_run ? "would_escalate" : "escalate"
166
169
  report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
@@ -244,8 +247,7 @@ module Carson
244
247
  updated = ledger.update_delivery(
245
248
  delivery: delivery,
246
249
  status: "gated",
247
- summary: "revision #{revision.number} completed — waiting for reassessment",
248
- revision_count: revision.number
250
+ summary: "revision #{revision.number} completed — waiting for reassessment"
249
251
  )
250
252
  return reconcile_delivery!( delivery: updated )
251
253
  end
@@ -256,8 +258,7 @@ module Carson
256
258
  ledger.update_delivery(
257
259
  delivery: delivery,
258
260
  status: "gated",
259
- summary: "revision #{revision.number} failed: #{result.summary}",
260
- revision_count: revision.number
261
+ summary: "revision #{revision.number} failed: #{result.summary}"
261
262
  )
262
263
  end
263
264
  end
@@ -287,10 +288,13 @@ module Carson
287
288
  end
288
289
  end
289
290
 
291
+ def merge_blocked_delivery?( delivery: )
292
+ delivery.cause == "merge"
293
+ end
294
+
290
295
  def housekeep_repo!( repo_path: )
291
296
  scoped_runtime = repo_runtime_for( repo_path: repo_path )
292
- sync_status = scoped_runtime.sync!
293
- scoped_runtime.prune! if sync_status == EXIT_OK
297
+ scoped_runtime.send( :housekeep_one_entry, repo_path: repo_path, silent: true )
294
298
  end
295
299
 
296
300
  def select_agent_provider
@@ -394,7 +398,7 @@ module Carson
394
398
  end
395
399
 
396
400
  def prior_attempt( delivery: )
397
- revision = ledger.revisions_for_delivery( delivery_id: delivery.id ).last
401
+ revision = delivery.revisions.last
398
402
  return nil unless revision&.failed?
399
403
  { summary: revision.summary.to_s, dispatched_at: revision.started_at.to_s }
400
404
  end
@@ -429,17 +433,40 @@ module Carson
429
433
  next
430
434
  end
431
435
 
432
- if repo_report[ :deliveries ].empty?
433
- puts_line "#{repo_report[ :repository ]}: no active deliveries"
434
- next
435
- end
436
+ next if repo_report[ :deliveries ].empty?
436
437
 
437
438
  repo_report[ :deliveries ].each do |delivery|
438
- puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]}: #{delivery[ :status ]} -> #{delivery[ :action ]}"
439
+ action_text = format_govern_action( status: delivery[ :status ], action: delivery[ :action ] )
440
+ puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]} — #{action_text}"
439
441
  puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
440
442
  end
441
443
  end
442
444
  end
445
+
446
+ def format_govern_action( status:, action: )
447
+ case action
448
+ when "integrate"
449
+ format_govern_integration_outcome( status: status )
450
+ when "would_integrate" then "ready to integrate (dry run)"
451
+ when "hold" then "held at gate"
452
+ when "would_hold" then "would hold at gate (dry run)"
453
+ when "revise" then "revision dispatched"
454
+ when "would_revise" then "would revise (dry run)"
455
+ when "escalate" then "escalated"
456
+ when "would_escalate" then "would escalate (dry run)"
457
+ else status
458
+ end
459
+ end
460
+
461
+ def format_govern_integration_outcome( status: )
462
+ case status
463
+ when "integrated" then "integrated"
464
+ when "gated" then "held at gate"
465
+ when "failed" then "integration failed"
466
+ when "escalated" then "integration escalated"
467
+ else status
468
+ end
469
+ end
443
470
  end
444
471
 
445
472
  include Govern
@@ -10,10 +10,17 @@ module Carson
10
10
  class Runtime
11
11
  module Housekeep
12
12
  # Serves the current repo: sync + prune.
13
+ # Resolves to the canonical main worktree root so the command works
14
+ # correctly when invoked from inside an agent worktree.
13
15
  def housekeep!( json_output: false, dry_run: false )
14
- return housekeep_one_dry_run if dry_run
16
+ canonical = main_worktree_root
15
17
 
16
- housekeep_one( repo_path: repo_root, json_output: json_output )
18
+ if dry_run
19
+ scoped = Runtime.new( repo_root: canonical, tool_root: tool_root, output: output, error: error, verbose: verbose? )
20
+ return scoped.housekeep_one_dry_run
21
+ end
22
+
23
+ housekeep_one( repo_path: canonical, json_output: json_output )
17
24
  end
18
25
 
19
26
  # Resolves a target name to a governed repo, then serves it.
@@ -73,6 +80,20 @@ module Carson
73
80
  housekeep_finish( result: result, exit_code: failed.zero? ? EXIT_OK : EXIT_ERROR, json_output: json_output, results: results, succeeded: succeeded, failed: failed )
74
81
  end
75
82
 
83
+ def housekeep_loop!( json_output:, dry_run:, loop_seconds: )
84
+ cycle_count = 0
85
+ loop do
86
+ cycle_count += 1
87
+ puts_line ""
88
+ puts_line "housekeep cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}"
89
+ housekeep_all!( json_output: json_output, dry_run: dry_run )
90
+ sleep loop_seconds
91
+ end
92
+ rescue Interrupt
93
+ puts_line "housekeep loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
94
+ EXIT_OK
95
+ end
96
+
76
97
  # Prints a dry-run plan for this repo without making any changes.
77
98
  # Calls reap_dead_worktrees_plan and prune_plan on self (already scoped to the repo).
78
99
  def housekeep_one_dry_run
@@ -87,154 +108,37 @@ module Carson
87
108
  # non-main worktree, without executing any mutations.
88
109
  # Each item: { name:, branch:, action: :reap|:skip, reason: }
89
110
  def reap_dead_worktrees_plan
90
- main_root = main_worktree_root
91
- items = []
92
-
93
- agent_prefixes = Worktree::AGENT_DIRS.map do |dir|
94
- full = File.join( main_root, dir, "worktrees" )
95
- File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
96
- end.compact
97
-
98
- worktree_list.each do |worktree|
99
- next if worktree.path == main_root
100
- next unless worktree.branch
101
-
102
- item = { name: File.basename( worktree.path ), branch: worktree.branch }
103
-
104
- if worktree.holds_cwd?
105
- items << item.merge( action: :skip, reason: "held by current shell" )
106
- next
107
- end
108
-
109
- if worktree.held_by_other_process?
110
- items << item.merge( action: :skip, reason: "held by another process" )
111
- next
112
- end
113
-
114
- # Missing directory — would be reaped by worktree prune + branch delete.
115
- unless Dir.exist?( worktree.path )
116
- items << item.merge( action: :reap, reason: "directory missing (destroyed externally)" )
117
- next
118
- end
119
-
120
- # Layer 1: agent-owned + content absorbed into main (no gh needed).
121
- if agent_prefixes.any? { |prefix| worktree.path.start_with?( prefix ) } &&
122
- branch_absorbed_into_main?( branch: worktree.branch )
123
- items << item.merge( action: :reap, reason: "content absorbed into main" )
124
- next
125
- end
126
-
127
- # Layers 2 + 3: PR evidence — requires gh CLI.
128
- unless gh_available?
129
- items << item.merge( action: :skip, reason: "gh CLI not available for PR check" )
130
- next
131
- end
132
-
133
- tip_sha = begin
134
- git_capture!( "rev-parse", "--verify", worktree.branch ).strip
135
- rescue StandardError
136
- nil
137
- end
138
-
139
- unless tip_sha
140
- items << item.merge( action: :skip, reason: "cannot read branch tip SHA" )
141
- next
142
- end
143
-
144
- merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
145
- if merged_pr
146
- items << item.merge( action: :reap, reason: "merged #{pr_short_ref( merged_pr[ :url ] )}" )
147
- next
148
- end
149
-
150
- if branch_has_open_pr?( branch: worktree.branch )
151
- items << item.merge( action: :skip, reason: "open PR exists" )
152
- next
153
- end
154
-
155
- abandoned_pr, = abandoned_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
156
- if abandoned_pr
157
- items << item.merge( action: :reap, reason: "closed abandoned #{pr_short_ref( abandoned_pr[ :url ] )}" )
158
- next
159
- end
160
-
161
- items << item.merge( action: :skip, reason: "no evidence to reap" )
111
+ worktree_list.filter_map do |worktree|
112
+ next if worktree.path == main_worktree_root
113
+
114
+ classification = classify_worktree_cleanup( worktree: worktree )
115
+ {
116
+ name: File.basename( worktree.path ),
117
+ branch: worktree.branch,
118
+ action: classification.fetch( :action ),
119
+ reason: classification.fetch( :reason )
120
+ }
162
121
  end
163
-
164
- items
165
122
  end
166
123
 
167
- # Removes dead worktrees — those whose content is on main, with merged PR evidence,
168
- # or with closed-unmerged PR evidence and no open PR.
169
- # Unblocks prune for the branches they hold.
170
- # Three-layer dead check:
171
- # 1. Content-absorbed: delegates to sweep_stale_worktrees! (shared, no gh needed).
172
- # 2. Merged PR evidence: covers rebase/squash where main has since evolved
173
- # the same files (requires gh).
174
- # 3. Abandoned PR evidence: closed-but-unmerged PR on the exact branch tip,
175
- # but only when no open PR still exists for the branch.
176
124
  def reap_dead_worktrees!
177
- # Layer 1: sweep agent-owned worktrees whose content is on main.
178
- sweep_stale_worktrees!
179
-
180
- # Layers 2 and 3: PR evidence for remaining worktrees.
181
- return unless gh_available?
182
-
183
- main_root = main_worktree_root
184
125
  worktree_list.each do |worktree|
185
- next if worktree.path == main_root
186
- next unless worktree.branch
187
- next if worktree.holds_cwd?
188
- next if worktree.held_by_other_process?
189
-
190
- # Missing directory: worktree was destroyed externally.
191
- # Prune the stale entry and delete the branch immediately.
192
- unless Dir.exist?( worktree.path )
193
- git_run( "worktree", "prune" )
194
- puts_verbose "reaped stale worktree entry: #{File.basename( worktree.path )} (branch: #{worktree.branch})"
195
- if !config.protected_branches.include?( worktree.branch )
196
- git_run( "branch", "-D", worktree.branch )
197
- puts_verbose "deleted branch: #{worktree.branch}"
198
- end
199
- next
200
- end
201
-
202
- tip_sha = git_capture!( "rev-parse", "--verify", worktree.branch ).strip rescue nil
203
- next unless tip_sha
126
+ next if worktree.path == main_worktree_root
204
127
 
205
- merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
206
- if !merged_pr.nil?
207
- # Remove the worktree (no --force: refuses if dirty working tree).
208
- _, _, rm_success, = git_run( "worktree", "remove", worktree.path )
209
- next unless rm_success
210
-
211
- puts_verbose "reaped dead worktree: #{File.basename( worktree.path )} (branch: #{worktree.branch})"
212
-
213
- # Delete the local branch now that no worktree holds it.
214
- if !config.protected_branches.include?( worktree.branch )
215
- git_run( "branch", "-D", worktree.branch )
216
- puts_verbose "deleted branch: #{worktree.branch}"
217
- end
128
+ classification = classify_worktree_cleanup( worktree: worktree )
129
+ if classification.fetch( :action ) == :skip
130
+ puts_line "Kept worktree: #{worktree_housekeep_label( worktree: worktree )} #{classification.fetch( :reason )}" unless verbose?
218
131
  next
219
132
  end
220
133
 
221
- next if branch_has_open_pr?( branch: worktree.branch )
222
-
223
- abandoned_pr, = abandoned_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
224
- next if abandoned_pr.nil?
225
-
226
- # Remove the worktree (no --force: refuses if dirty working tree).
227
- _, _, rm_success, = git_run( "worktree", "remove", worktree.path )
228
- next unless rm_success
229
-
230
- puts_verbose "reaped abandoned worktree: #{File.basename( worktree.path )} (branch: #{worktree.branch}, closed PR: #{abandoned_pr.fetch( :url )})"
231
-
232
- # Delete the local branch now that no worktree holds it.
233
- if !config.protected_branches.include?( worktree.branch )
234
- git_run( "branch", "-D", worktree.branch )
235
- puts_verbose "deleted branch: #{worktree.branch}"
236
- end
134
+ reap_one_worktree!(
135
+ worktree: worktree,
136
+ reason: classification.fetch( :reason ),
137
+ force: classification.fetch( :force, false )
138
+ )
237
139
  end
140
+
141
+ reap_integrated_delivery_worktrees!
238
142
  end
239
143
 
240
144
  private
@@ -260,28 +164,31 @@ module Carson
260
164
  scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: buffer, error: error_buffer, verbose: verbose? )
261
165
 
262
166
  sync_status = scoped_runtime.sync!
263
- if sync_status == EXIT_OK
264
- scoped_runtime.reap_dead_worktrees!
265
- prune_status = scoped_runtime.prune!
266
- end
167
+ reap_status = housekeep_reap_status( scoped_runtime: scoped_runtime )
168
+ prune_status = scoped_runtime.prune!
267
169
 
268
- ok = sync_status == EXIT_OK && prune_status == EXIT_OK
170
+ ok = sync_status == EXIT_OK && reap_status == EXIT_OK && prune_status == EXIT_OK
269
171
  unless verbose? || silent
270
- summary = strip_badge( buffer.string.lines.last.to_s.strip )
271
- puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
172
+ puts_line "#{repo_name}:"
173
+ output.print buffer.string
174
+ puts_line " OK" if buffer.string.to_s.strip.empty?
272
175
  end
273
176
 
274
- { name: repo_name, path: repo_path, status: ok ? "ok" : "error" }
177
+ entry = {
178
+ name: repo_name,
179
+ path: repo_path,
180
+ status: ok ? "ok" : "error",
181
+ sync_status: housekeep_step_status( exit_code: sync_status ),
182
+ reap_status: housekeep_step_status( exit_code: reap_status ),
183
+ prune_status: housekeep_step_status( exit_code: prune_status )
184
+ }
185
+ entry[ :error ] = housekeep_failure_summary( entry: entry ) unless ok
186
+ entry
275
187
  rescue StandardError => exception
276
188
  puts_line "#{repo_name}: did not complete (#{exception.message})" unless silent
277
189
  { name: repo_name, path: repo_path, status: "error", error: exception.message }
278
190
  end
279
191
 
280
- # Strips the Carson badge prefix from a message to avoid double-badging.
281
- def strip_badge( text )
282
- text.sub( /\A#{Regexp.escape( BADGE )}\s*/, "" )
283
- end
284
-
285
192
  # Resolves a user-supplied target to a governed repository path.
286
193
  # Accepts: exact path, expandable path, or basename match (case-insensitive).
287
194
  def resolve_governed_repo( target: )
@@ -378,6 +285,135 @@ module Carson
378
285
  puts_line " #{item[ :branch ].ljust( name_width )} #{item[ :reason ].ljust( reason_width )} #{action_str}"
379
286
  end
380
287
 
288
+ def housekeep_reap_status( scoped_runtime: )
289
+ scoped_runtime.reap_dead_worktrees!
290
+ EXIT_OK
291
+ rescue StandardError
292
+ EXIT_ERROR
293
+ end
294
+
295
+ def housekeep_step_status( exit_code: )
296
+ case exit_code
297
+ when EXIT_OK then "ok"
298
+ when EXIT_BLOCK then "block"
299
+ else "error"
300
+ end
301
+ end
302
+
303
+ def housekeep_failure_summary( entry: )
304
+ failures = []
305
+ failures << "sync #{entry.fetch( :sync_status )}" unless entry.fetch( :sync_status ) == "ok"
306
+ failures << "reap #{entry.fetch( :reap_status )}" unless entry.fetch( :reap_status ) == "ok"
307
+ failures << "prune #{entry.fetch( :prune_status )}" unless entry.fetch( :prune_status ) == "ok"
308
+ failures.join( ", " )
309
+ end
310
+
311
+ def reap_integrated_delivery_worktrees!
312
+ ledger.integrated_deliveries( repo_path: main_worktree_root ).each do |delivery|
313
+ worktree_path = delivery.worktree_path.to_s
314
+ next if worktree_path.strip.empty?
315
+
316
+ unless Dir.exist?( worktree_path )
317
+ clear_integrated_delivery_worktree_path!( delivery: delivery, reason: "directory missing" )
318
+ next
319
+ end
320
+
321
+ worktree = Worktree.find( path: worktree_path, runtime: self )
322
+ next if worktree.nil?
323
+
324
+ current_head = integrated_delivery_worktree_head( worktree_path: worktree.path )
325
+ if current_head && current_head != delivery.head
326
+ clear_integrated_delivery_worktree_path!( delivery: delivery, reason: "worktree moved beyond integrated head" )
327
+ next
328
+ end
329
+
330
+ if worktree.holds_cwd?
331
+ puts_line "Kept worktree: #{worktree_housekeep_label( worktree: worktree )} — held by current shell" unless verbose?
332
+ next
333
+ end
334
+
335
+ if worktree.held_by_other_process?
336
+ puts_line "Kept worktree: #{worktree_housekeep_label( worktree: worktree )} — held by another process" unless verbose?
337
+ next
338
+ end
339
+
340
+ if worktree.dirty?
341
+ puts_line "Kept worktree: #{worktree_housekeep_label( worktree: worktree )} — dirty worktree" unless verbose?
342
+ next
343
+ end
344
+
345
+ next unless current_head == delivery.head
346
+
347
+ reason = "integrated delivery recorded in ledger"
348
+ reaped = reap_one_worktree!( worktree: worktree, reason: reason )
349
+ next unless reaped
350
+
351
+ if worktree.branch.to_s.strip.empty? &&
352
+ !delivery.branch.to_s.strip.empty? &&
353
+ worktree_branch_tip_sha( branch: delivery.branch ) == delivery.head
354
+ delete_branch_after_reap!( branch: delivery.branch )
355
+ end
356
+
357
+ clear_integrated_delivery_worktree_path!( delivery: delivery, reason: reason )
358
+ end
359
+ end
360
+
361
+ def reap_one_worktree!( worktree:, reason:, force: false )
362
+ label = worktree_housekeep_label( worktree: worktree )
363
+
364
+ unless worktree.exists?
365
+ git_run( "worktree", "prune" )
366
+ delete_branch_after_reap!( branch: worktree.branch )
367
+ puts_line "Reaped worktree: #{label} — #{reason}" unless verbose?
368
+ return true
369
+ end
370
+
371
+ _, _, rm_success, = git_run( "worktree", "remove", worktree.path )
372
+ if !rm_success && force
373
+ _, _, rm_success, = git_run( "worktree", "remove", "--force", worktree.path )
374
+ puts_verbose "force-reaped dirty worktree: #{File.basename( worktree.path )}" if rm_success
375
+ end
376
+
377
+ unless rm_success
378
+ puts_line "Kept worktree: #{label} — removal failed" unless verbose?
379
+ return false
380
+ end
381
+
382
+ delete_branch_after_reap!( branch: worktree.branch )
383
+ puts_line "Reaped worktree: #{label} — #{reason}" unless verbose?
384
+ true
385
+ end
386
+
387
+ def delete_branch_after_reap!( branch: )
388
+ return if branch.to_s.strip.empty?
389
+ return if config.protected_branches.include?( branch )
390
+
391
+ git_run( "branch", "-D", branch )
392
+ puts_verbose "deleted branch: #{branch}"
393
+ end
394
+
395
+ def clear_integrated_delivery_worktree_path!( delivery:, reason: )
396
+ ledger.update_delivery( delivery: delivery, worktree_path: nil )
397
+ puts_verbose "cleared integrated delivery worktree path: #{delivery.branch} (#{reason})"
398
+ end
399
+
400
+ def integrated_delivery_worktree_head( worktree_path: )
401
+ stdout_text, _stderr_text, status = Open3.capture3( "git", "-C", worktree_path, "rev-parse", "HEAD" )
402
+ return nil unless status.success?
403
+
404
+ head = stdout_text.to_s.strip
405
+ head.empty? ? nil : head
406
+ rescue StandardError
407
+ nil
408
+ end
409
+
410
+ def worktree_housekeep_label( worktree: )
411
+ name = File.basename( worktree.path )
412
+ return name if worktree.branch.to_s.strip.empty?
413
+
414
+ "#{name} (#{worktree.branch})"
415
+ end
416
+
381
417
  # Extracts a short PR reference (e.g. "PR #123") from a GitHub URL.
382
418
  def pr_short_ref( url )
383
419
  return "PR" if url.nil? || url.empty?
@@ -230,8 +230,9 @@ module Carson
230
230
  end
231
231
  end
232
232
  remove_empty_offboard_directories!
233
- remove_govern_repo!( repo_path: File.expand_path( repo_root ) )
234
- puts_verbose "govern_deregistered: #{File.expand_path( repo_root )}"
233
+ canonical_root = realpath_safe( main_worktree_root )
234
+ remove_govern_repo!( repo_path: canonical_root )
235
+ puts_verbose "govern_deregistered: #{canonical_root}"
235
236
  puts_verbose "offboard_summary: removed=#{removed_count} missing=#{missing_count}"
236
237
  if verbose?
237
238
  puts_line "OK: Carson offboard completed for #{repo_root}."
@@ -239,7 +240,7 @@ module Carson
239
240
  puts_line "Removed #{removed_count} file#{plural_suffix( count: removed_count )}. Offboard complete."
240
241
  end
241
242
  puts_line ""
242
- puts_line "Next: commit the removals and push to finalise offboarding."
243
+ puts_line "Commit the removals and push to finalise offboarding."
243
244
  EXIT_OK
244
245
  end
245
246
 
@@ -115,14 +115,14 @@ module Carson
115
115
  if json_output
116
116
  output.puts JSON.pretty_generate( result )
117
117
  else
118
- print_prune_human( counters: counters )
118
+ print_prune_human( branches: result.fetch( :branches ), counters: counters )
119
119
  end
120
120
 
121
121
  exit_code
122
122
  end
123
123
 
124
124
  # Human-readable output for prune results.
125
- def print_prune_human( counters: )
125
+ def print_prune_human( branches:, counters: )
126
126
  deleted_count = counters.fetch( :deleted )
127
127
  skipped_count = counters.fetch( :skipped )
128
128
 
@@ -136,16 +136,11 @@ module Carson
136
136
  end
137
137
 
138
138
  puts_verbose "prune_summary: deleted=#{deleted_count} skipped=#{skipped_count}"
139
- unless verbose?
140
- message = if deleted_count > 0 && skipped_count > 0
141
- "Pruned #{deleted_count}, skipped #{skipped_count} (--verbose for details)."
142
- elsif deleted_count > 0
143
- "Pruned #{deleted_count} stale #{ deleted_count == 1 ? 'branch' : 'branches' }."
144
- else
145
- "Skipped #{skipped_count} #{ skipped_count == 1 ? 'branch' : 'branches' } (--verbose for details)."
146
- end
147
- puts_line message
139
+ branches.each do |entry|
140
+ action = entry.fetch( :action ) == :deleted ? "Deleted" : "Kept"
141
+ puts_line "#{action} #{entry.fetch( :type )} branch: #{entry.fetch( :branch )} — #{entry.fetch( :reason )}"
148
142
  end
143
+ puts_line "Prune complete: #{deleted_count} deleted, #{skipped_count} kept."
149
144
  end
150
145
 
151
146
  # Runs a git command, suppressing stdout in JSON mode to keep output clean.
@@ -4,6 +4,15 @@ module Carson
4
4
  class Runtime
5
5
  module Local
6
6
  def sync!( json_output: false )
7
+ # Sync always operates on the main worktree. When called from inside
8
+ # a worktree, delegate to a runtime rooted at the main tree so
9
+ # git switch main does not collide with the main tree's checkout.
10
+ main_root = main_worktree_root
11
+ if realpath_safe( repo_root ) != realpath_safe( main_root )
12
+ main_runtime = Runtime.new( repo_root: main_root, tool_root: tool_root, output: output, error: error, verbose: verbose? )
13
+ return main_runtime.sync!( json_output: json_output )
14
+ end
15
+
7
16
  fingerprint_status = block_if_outsider_fingerprints!
8
17
  return fingerprint_status unless fingerprint_status.nil?
9
18