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.
- checksums.yaml +4 -4
- data/API.md +26 -8
- data/MANUAL.md +51 -22
- data/README.md +9 -16
- data/RELEASE.md +16 -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
|
@@ -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
|
-
|
|
16
|
+
canonical = main_worktree_root
|
|
15
17
|
|
|
16
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 ==
|
|
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
|
-
|
|
206
|
-
if
|
|
207
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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 "
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|