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.
- checksums.yaml +4 -4
- data/API.md +26 -8
- data/MANUAL.md +54 -25
- data/README.md +9 -16
- data/RELEASE.md +31 -2
- data/VERSION +1 -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 +462 -224
- 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 +3 -1
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:,
|
|
141
|
+
def decide_delivery_action( delivery:, repo_path:, dry_run:, next_to_integrate: )
|
|
144
142
|
report = {
|
|
145
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|