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