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.
@@ -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
@@ -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
@@ -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
- expanded = File.expand_path( repo_root )
368
- if config.govern_repos.include?( expanded )
369
- puts_verbose "govern_registration: already registered #{expanded}"
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: expanded )
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
- updated = repos.reject { |entry| File.expand_path( entry ) == File.expand_path( repo_path ) }
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
- repos << repo_path
416
- existing_data[ "govern" ][ "repos" ] = repos.uniq
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 ) )