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.
@@ -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
 
@@ -28,6 +28,25 @@ module Carson
28
28
  Worktree.list( runtime: self )
29
29
  end
30
30
 
31
+ # Human and JSON status surface for all registered worktrees.
32
+ def worktree_list!( json_output: false )
33
+ entries = worktree_inventory
34
+ result = {
35
+ command: "worktree list",
36
+ status: "ok",
37
+ worktrees: entries,
38
+ exit_code: EXIT_OK
39
+ }
40
+
41
+ if json_output
42
+ output.puts JSON.pretty_generate( result )
43
+ else
44
+ print_worktree_list( entries: entries )
45
+ end
46
+
47
+ EXIT_OK
48
+ end
49
+
31
50
  # --- Methods that stay on Runtime ---
32
51
 
33
52
  # Returns the branch checked out in the worktree that contains the process CWD,
@@ -87,6 +106,153 @@ module Carson
87
106
 
88
107
  missing_segments.empty? ? base : File.join( base, *missing_segments )
89
108
  end
109
+
110
+ private
111
+
112
+ def worktree_inventory
113
+ worktree_list.map { |worktree| worktree_inventory_entry( worktree: worktree ) }
114
+ end
115
+
116
+ def worktree_inventory_entry( worktree: )
117
+ cleanup = classify_worktree_cleanup( worktree: worktree )
118
+ pull_request = worktree_pull_request( branch: worktree.branch )
119
+
120
+ {
121
+ name: File.basename( worktree.path ),
122
+ branch: worktree.branch,
123
+ path: worktree.path,
124
+ main: worktree.path == main_worktree_root,
125
+ exists: worktree.exists?,
126
+ dirty: worktree.dirty?,
127
+ held_by_current_shell: worktree.holds_cwd?,
128
+ held_by_other_process: worktree.held_by_other_process?,
129
+ absorbed_into_main: cleanup.fetch( :absorbed, false ),
130
+ pull_request: pull_request,
131
+ cleanup: {
132
+ action: cleanup.fetch( :action ).to_s,
133
+ reason: cleanup.fetch( :reason )
134
+ }
135
+ }
136
+ end
137
+
138
+ # Shared cleanup classifier used by `worktree list` and `housekeep`.
139
+ def classify_worktree_cleanup( worktree: )
140
+ return { action: :skip, reason: "main worktree", absorbed: false } if worktree.path == main_worktree_root
141
+ return { action: :skip, reason: "detached HEAD", absorbed: false } if worktree.branch.to_s.strip.empty?
142
+ return { action: :skip, reason: "held by current shell", absorbed: false } if worktree.holds_cwd?
143
+ return { action: :skip, reason: "held by another process", absorbed: false } if worktree.held_by_other_process?
144
+ return { action: :reap, reason: "directory missing (destroyed externally)", absorbed: false } unless worktree.exists?
145
+
146
+ if worktree.dirty?
147
+ absorbed = branch_absorbed_into_main?( branch: worktree.branch )
148
+ return { action: :reap, reason: "dirty worktree with content absorbed into main", absorbed: true, force: true } if absorbed
149
+ return { action: :skip, reason: "gh CLI not available for PR check", absorbed: false } unless gh_available?
150
+
151
+ tip_sha = worktree_branch_tip_sha( branch: worktree.branch )
152
+ return { action: :skip, reason: "cannot read branch tip SHA", absorbed: false } if tip_sha.nil?
153
+
154
+ merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
155
+ return { action: :reap, reason: "dirty worktree with merged #{pr_short_ref( merged_pr.fetch( :url ) )}", absorbed: false, force: true } unless merged_pr.nil?
156
+
157
+ return { action: :skip, reason: "dirty worktree", absorbed: false }
158
+ end
159
+
160
+ absorbed = branch_absorbed_into_main?( branch: worktree.branch )
161
+ return { action: :reap, reason: "content absorbed into main", absorbed: true } if absorbed
162
+ return { action: :skip, reason: "gh CLI not available for PR check", absorbed: false } unless gh_available?
163
+
164
+ tip_sha = worktree_branch_tip_sha( branch: worktree.branch )
165
+ return { action: :skip, reason: "cannot read branch tip SHA", absorbed: false } if tip_sha.nil?
166
+
167
+ merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
168
+ return { action: :reap, reason: "merged #{pr_short_ref( merged_pr.fetch( :url ) )}", absorbed: false } unless merged_pr.nil?
169
+ return { action: :skip, reason: "open PR exists", absorbed: false } if branch_has_open_pr?( branch: worktree.branch )
170
+
171
+ abandoned_pr, = abandoned_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
172
+ return { action: :reap, reason: "closed abandoned #{pr_short_ref( abandoned_pr.fetch( :url ) )}", absorbed: false } unless abandoned_pr.nil?
173
+
174
+ { action: :skip, reason: "no evidence to reap", absorbed: false }
175
+ end
176
+
177
+ def worktree_branch_tip_sha( branch: )
178
+ git_capture!( "rev-parse", "--verify", branch ).strip
179
+ rescue StandardError
180
+ nil
181
+ end
182
+
183
+ def worktree_pull_request( branch: )
184
+ return { state: nil, number: nil, url: nil, error: nil } if branch.to_s.strip.empty?
185
+ return { state: nil, number: nil, url: nil, error: "gh unavailable" } unless gh_available?
186
+
187
+ owner, repo = repository_coordinates
188
+ stdout_text, stderr_text, success, = gh_run(
189
+ "api", "repos/#{owner}/#{repo}/pulls",
190
+ "--method", "GET",
191
+ "-f", "state=all",
192
+ "-f", "head=#{owner}:#{branch}",
193
+ "-f", "per_page=100"
194
+ )
195
+ unless success
196
+ error_text = gh_error_text(
197
+ stdout_text: stdout_text,
198
+ stderr_text: stderr_text,
199
+ fallback: "unable to read pull request for #{branch}"
200
+ )
201
+ return { state: nil, number: nil, url: nil, error: error_text }
202
+ end
203
+
204
+ entries = Array( JSON.parse( stdout_text ) )
205
+ return { state: nil, number: nil, url: nil, error: nil } if entries.empty?
206
+
207
+ chosen = entries.find { |entry| normalise_rest_pull_request_state( entry: entry ) == "OPEN" } ||
208
+ entries.max_by { |entry| parse_time_or_nil( text: entry[ "updated_at" ] ) || Time.at( 0 ) }
209
+
210
+ {
211
+ state: normalise_rest_pull_request_state( entry: chosen ),
212
+ number: chosen[ "number" ],
213
+ url: chosen[ "html_url" ].to_s,
214
+ error: nil
215
+ }
216
+ rescue JSON::ParserError => exception
217
+ { state: nil, number: nil, url: nil, error: "invalid gh JSON response (#{exception.message})" }
218
+ rescue StandardError => exception
219
+ { state: nil, number: nil, url: nil, error: exception.message }
220
+ end
221
+
222
+ def print_worktree_list( entries: )
223
+ puts_line "Worktrees:"
224
+ return puts_line " none" if entries.empty?
225
+
226
+ entries.each do |entry|
227
+ label = entry.fetch( :main ) ? "#{entry.fetch( :name )} (main)" : entry.fetch( :name )
228
+ state = []
229
+ state << entry.fetch( :branch ) unless entry.fetch( :branch ).to_s.empty?
230
+ state << ( entry.fetch( :exists ) ? ( entry.fetch( :dirty ) ? "dirty" : "clean" ) : "missing" )
231
+ state << "held by current shell" if entry.fetch( :held_by_current_shell )
232
+ state << "held by another process" if entry.fetch( :held_by_other_process )
233
+ state << worktree_pull_request_text( pull_request: entry.fetch( :pull_request ) )
234
+ state << "absorbed into main" if entry.fetch( :absorbed_into_main )
235
+
236
+ recommendation = entry.fetch( :cleanup )
237
+ action = recommendation.fetch( :action ) == "reap" ? "reap" : "keep"
238
+ puts_line "- #{label}: #{state.join( ', ' )}"
239
+ puts_line " Recommendation: #{action} — #{recommendation.fetch( :reason )}"
240
+ end
241
+ end
242
+
243
+ def worktree_pull_request_text( pull_request: )
244
+ return "PR unknown (#{pull_request.fetch( :error )})" unless pull_request.fetch( :error ).nil?
245
+ return "PR none" if pull_request.fetch( :number ).nil?
246
+
247
+ "PR ##{pull_request.fetch( :number )} #{pull_request.fetch( :state )}"
248
+ end
249
+
250
+ def pr_short_ref( url )
251
+ return "PR" if url.nil? || url.empty?
252
+
253
+ match = url.match( /\/pull\/(\d+)$/ )
254
+ match ? "PR ##{match[ 1 ]}" : "PR"
255
+ end
90
256
  end
91
257
 
92
258
  include Local