carson 3.27.1 → 3.28.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.
@@ -18,12 +18,12 @@ module Carson
18
18
  def govern_cycle!( dry_run:, json_output: )
19
19
  repositories = governed_repo_paths
20
20
  repositories = [ repository_record.path ] if repositories.empty?
21
- print_header "Governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}"
21
+ print_header "Governing #{repositories.length} repo#{plural_suffix( count: repositories.length )}" unless json_output
22
22
 
23
23
  report = {
24
24
  cycle_at: Time.now.utc.iso8601,
25
25
  dry_run: dry_run,
26
- repositories: repositories.map { |path| govern_repo!( repo_path: path, dry_run: dry_run ) }
26
+ repositories: repositories.map { |path| govern_repo!( repo_path: path, dry_run: dry_run, silent: json_output ) }
27
27
  }
28
28
 
29
29
  if json_output
@@ -62,7 +62,7 @@ module Carson
62
62
  end.compact
63
63
  end
64
64
 
65
- def govern_repo!( repo_path:, dry_run: )
65
+ def govern_repo!( repo_path:, dry_run:, silent: false )
66
66
  scoped_runtime = repo_runtime_for( repo_path: repo_path )
67
67
  repository = Repository.new( path: repo_path, runtime: scoped_runtime )
68
68
  deliveries = scoped_runtime.ledger.active_deliveries( repo_path: repo_path )
@@ -75,11 +75,11 @@ module Carson
75
75
  }
76
76
 
77
77
  if deliveries.empty?
78
- puts_line "#{repository.name}: no active deliveries"
78
+ puts_line "#{repository.name}: no active deliveries" unless silent
79
79
  return repo_report
80
80
  end
81
81
 
82
- puts_line "#{repository.name}: #{deliveries.length} active deliver#{deliveries.length == 1 ? 'y' : 'ies'}"
82
+ puts_line "#{repository.name}: #{deliveries.length} active deliver#{deliveries.length == 1 ? 'y' : 'ies'}" unless silent
83
83
 
84
84
  reconciled = deliveries.map { |item| scoped_runtime.send( :reconcile_delivery!, delivery: item ) }
85
85
  next_to_integrate = reconciled.find( &:ready? )&.key
@@ -122,7 +122,10 @@ module Carson
122
122
  delivery: delivery,
123
123
  status: "integrated",
124
124
  integrated_at: Time.now.utc.iso8601,
125
- summary: "integrated into #{config.main_branch}"
125
+ summary: "integrated into #{config.main_branch}",
126
+ pull_request_state: "MERGED",
127
+ pull_request_draft: false,
128
+ pull_request_merged_at: pr_state[ "mergedAt" ]
126
129
  )
127
130
  end
128
131
 
@@ -131,47 +134,67 @@ module Carson
131
134
  delivery: delivery,
132
135
  status: "failed",
133
136
  cause: "policy",
134
- summary: "pull request closed without integration"
137
+ summary: "pull request closed without integration",
138
+ pull_request_state: "CLOSED",
139
+ pull_request_draft: pr_state[ "isDraft" ],
140
+ pull_request_merged_at: pr_state[ "mergedAt" ]
135
141
  )
136
142
  end
137
143
 
138
144
  assess_delivery!( delivery: delivery, branch_name: delivery.branch )
139
145
  end
140
146
 
141
- def decide_delivery_action( delivery:, repo_path:, dry_run:, next_to_integrate: )
142
- report = {
143
- key: delivery.key,
144
- branch: delivery.branch,
145
- status: delivery.status,
146
- summary: delivery.summary,
147
- revision_count: delivery.revision_count,
148
- action: "none"
149
- }
147
+ def decide_delivery_action( delivery:, repo_path:, dry_run:, next_to_integrate: )
148
+ report = {
149
+ key: delivery.key,
150
+ branch: delivery.branch,
151
+ status: delivery.status,
152
+ cause: delivery.cause,
153
+ summary: delivery.summary,
154
+ revision_count: delivery.revision_count,
155
+ action: "none"
156
+ }
150
157
 
151
158
  if delivery.superseded? || delivery.integrated? || delivery.failed?
152
159
  return report
153
160
  end
154
161
 
155
- if delivery.ready? && delivery.key == next_to_integrate
156
- report[ :action ] = dry_run ? "would_integrate" : "integrate"
157
- report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
158
- return report
159
- end
160
-
161
- if delivery.blocked?
162
- if merge_blocked_delivery?( delivery: delivery )
163
- report[ :action ] = dry_run ? "would_hold" : "hold"
162
+ if delivery.ready? && delivery.key == next_to_integrate
163
+ report[ :action ] = dry_run ? "would_integrate" : "integrate"
164
+ unless dry_run
165
+ updated = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run )
166
+ report[ :status ] = updated.status
167
+ report[ :cause ] = updated.cause
168
+ report[ :summary ] = updated.summary
169
+ report[ :merge_proof ] = merge_proof_payload( proof: updated.merge_proof ) if updated.integrated? && updated.merge_proof
170
+ end
164
171
  return report
165
172
  end
166
173
 
167
- if delivery.revision_count >= 3
168
- report[ :action ] = dry_run ? "would_escalate" : "escalate"
169
- report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
170
- else
171
- report[ :action ] = dry_run ? "would_revise" : "revise"
172
- report[ :status ] = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run ).status unless dry_run
174
+ if delivery.blocked?
175
+ if held_delivery?( delivery: delivery )
176
+ report[ :action ] = dry_run ? "would_hold" : "hold"
177
+ return report
178
+ end
179
+
180
+ if delivery.revision_count >= 3
181
+ report[ :action ] = dry_run ? "would_escalate" : "escalate"
182
+ unless dry_run
183
+ updated = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run )
184
+ report[ :status ] = updated.status
185
+ report[ :cause ] = updated.cause
186
+ report[ :summary ] = updated.summary
187
+ end
188
+ else
189
+ report[ :action ] = dry_run ? "would_revise" : "revise"
190
+ unless dry_run
191
+ updated = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run )
192
+ report[ :status ] = updated.status
193
+ report[ :cause ] = updated.cause
194
+ report[ :summary ] = updated.summary
195
+ end
196
+ end
173
197
  end
174
- end
175
198
 
176
199
  report
177
200
  end
@@ -191,23 +214,51 @@ module Carson
191
214
  end
192
215
  end
193
216
 
194
- def integrate_delivery!( delivery:, repo_path: )
195
- result = {}
196
- prepared = ledger.update_delivery(
197
- delivery: delivery,
198
- status: "integrating",
199
- summary: "integrating into #{config.main_branch}"
200
- )
217
+ def integrate_delivery!( delivery:, repo_path: )
218
+ result = {}
219
+ freshness = assess_branch_freshness(
220
+ head_ref: delivery.head || delivery.branch,
221
+ remote: config.git_remote,
222
+ main: config.main_branch
223
+ )
224
+ unless freshness.fetch( :ready )
225
+ return ledger.update_delivery(
226
+ delivery: delivery,
227
+ status: "gated",
228
+ cause: "freshness",
229
+ summary: freshness.fetch( :summary )
230
+ )
231
+ end
232
+
233
+ prepared = ledger.update_delivery(
234
+ delivery: delivery,
235
+ status: "integrating",
236
+ summary: "integrating into #{config.main_branch}"
237
+ )
201
238
  merge_exit = merge_pr!( number: prepared.pull_request_number, result: result )
202
239
  if merge_exit == EXIT_OK
203
240
  integrated = ledger.update_delivery(
204
241
  delivery: prepared,
205
242
  status: "integrated",
206
243
  integrated_at: Time.now.utc.iso8601,
207
- summary: "integrated into #{config.main_branch}"
244
+ summary: "integrated into #{config.main_branch}",
245
+ pull_request_state: "MERGED",
246
+ pull_request_draft: false,
247
+ pull_request_merged_at: Time.now.utc.iso8601
248
+ )
249
+ housekeep_result = housekeep_repo!( repo_path: repo_path )
250
+ proof = if housekeep_result.is_a?( Hash ) && housekeep_result[ :sync_status ] != "ok"
251
+ merge_proof_unavailable(
252
+ main_ref: config.main_branch,
253
+ summary: "proof unavailable — local #{config.main_branch} sync did not complete."
254
+ )
255
+ else
256
+ merge_proof_for_branch( branch: integrated.branch, main_ref: config.main_branch )
257
+ end
258
+ ledger.update_delivery(
259
+ delivery: integrated,
260
+ merge_proof: proof
208
261
  )
209
- housekeep_repo!( repo_path: repo_path )
210
- integrated
211
262
  else
212
263
  ledger.update_delivery(
213
264
  delivery: prepared,
@@ -288,9 +339,9 @@ module Carson
288
339
  end
289
340
  end
290
341
 
291
- def merge_blocked_delivery?( delivery: )
292
- delivery.cause == "merge"
293
- end
342
+ def held_delivery?( delivery: )
343
+ [ "merge", "freshness" ].include?( delivery.cause )
344
+ end
294
345
 
295
346
  def housekeep_repo!( repo_path: )
296
347
  scoped_runtime = repo_runtime_for( repo_path: repo_path )
@@ -435,36 +486,37 @@ module Carson
435
486
 
436
487
  next if repo_report[ :deliveries ].empty?
437
488
 
438
- repo_report[ :deliveries ].each do |delivery|
439
- action_text = format_govern_action( status: delivery[ :status ], action: delivery[ :action ] )
440
- puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]} — #{action_text}"
441
- puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
489
+ repo_report[ :deliveries ].each do |delivery|
490
+ action_text = format_govern_action( status: delivery[ :status ], action: delivery[ :action ], cause: delivery[ :cause ] )
491
+ puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]} — #{action_text}"
492
+ puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
493
+ puts_line " Merge proof: #{delivery.dig( :merge_proof, :summary )}" if delivery[ :merge_proof ]
494
+ end
442
495
  end
443
496
  end
444
- end
445
497
 
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)"
498
+ def format_govern_action( status:, action:, cause: )
499
+ case action
500
+ when "integrate"
501
+ format_govern_integration_outcome( status: status, cause: cause )
502
+ when "would_integrate" then "ready to integrate (dry run)"
503
+ when "hold" then cause == "freshness" ? "refresh required" : "held at gate"
504
+ when "would_hold" then cause == "freshness" ? "would require refresh (dry run)" : "would hold at gate (dry run)"
505
+ when "revise" then "revision dispatched"
506
+ when "would_revise" then "would revise (dry run)"
455
507
  when "escalate" then "escalated"
456
508
  when "would_escalate" then "would escalate (dry run)"
457
509
  else status
458
510
  end
459
511
  end
460
512
 
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
513
+ def format_govern_integration_outcome( status:, cause: )
514
+ case status
515
+ when "integrated" then "integrated"
516
+ when "gated" then cause == "freshness" ? "refresh required" : "held at gate"
517
+ when "failed" then "integration failed"
518
+ when "escalated" then "integration escalated"
519
+ else status
468
520
  end
469
521
  end
470
522
  end
@@ -0,0 +1,199 @@
1
+ # Shared local merge-proof detection for commands that need content-aware "already on main" evidence.
2
+ module Carson
3
+ class Runtime
4
+ module Local
5
+ def merge_proof_for_branch( branch:, main_ref: config.main_branch )
6
+ return merge_proof_not_applicable( main_ref: main_ref ) if branch.to_s == main_ref.to_s
7
+
8
+ candidate = merge_proof_candidate( branch: branch, main_ref: main_ref )
9
+ return candidate if candidate.fetch( :basis ) == "unavailable"
10
+
11
+ trust = merge_proof_main_trust( main_ref: main_ref )
12
+ return candidate if trust.fetch( :trusted )
13
+
14
+ merge_proof_hash(
15
+ applicable: true,
16
+ proven: false,
17
+ basis: "unavailable",
18
+ summary: trust.fetch( :summary ),
19
+ main_branch: main_ref,
20
+ changed_files_count: candidate.fetch( :changed_files_count, 0 )
21
+ )
22
+ end
23
+
24
+ def branch_absorbed_into_main?( branch: )
25
+ merge_proof_for_branch( branch: branch ).fetch( :proven )
26
+ end
27
+
28
+ private
29
+
30
+ def merge_proof_candidate( branch:, main_ref: )
31
+ _, _, ancestor_success, ancestor_exit = git_run( "merge-base", "--is-ancestor", branch, main_ref )
32
+ merge_base_text, merge_base_error, merge_base_success, = git_run( "merge-base", main_ref, branch )
33
+ unless merge_base_success
34
+ return merge_proof_unavailable(
35
+ main_ref: main_ref,
36
+ summary: merge_proof_command_failure_summary(
37
+ remote_ref: merge_proof_remote_ref( main_ref: main_ref ),
38
+ fallback: "proof unavailable — could not determine merge-base with #{main_ref}.",
39
+ error_text: merge_base_error
40
+ )
41
+ )
42
+ end
43
+
44
+ merge_base = merge_base_text.to_s.strip
45
+ return merge_proof_unavailable( main_ref: main_ref, summary: "proof unavailable — merge-base with #{main_ref} is empty." ) if merge_base.empty?
46
+
47
+ changed_text, changed_error, changed_success, = git_run( "diff", "--name-only", merge_base, branch )
48
+ unless changed_success
49
+ return merge_proof_unavailable(
50
+ main_ref: main_ref,
51
+ summary: merge_proof_command_failure_summary(
52
+ remote_ref: merge_proof_remote_ref( main_ref: main_ref ),
53
+ fallback: "proof unavailable — could not list branch changes against #{main_ref}.",
54
+ error_text: changed_error
55
+ )
56
+ )
57
+ end
58
+
59
+ changed_files = changed_text.to_s.lines.map( &:strip ).reject( &:empty? )
60
+ changed_files_count = changed_files.length
61
+
62
+ if ancestor_success
63
+ return merge_proof_hash(
64
+ applicable: true,
65
+ proven: true,
66
+ basis: "ancestor",
67
+ summary: "proven on main — branch tip is already on #{main_ref}.",
68
+ main_branch: main_ref,
69
+ changed_files_count: changed_files_count
70
+ )
71
+ end
72
+
73
+ if ancestor_exit != 1
74
+ return merge_proof_unavailable(
75
+ main_ref: main_ref,
76
+ summary: "proof unavailable — could not verify whether #{branch} is already on #{main_ref}.",
77
+ changed_files_count: changed_files_count
78
+ )
79
+ end
80
+
81
+ if changed_files.empty?
82
+ return merge_proof_hash(
83
+ applicable: true,
84
+ proven: true,
85
+ basis: "no_changes",
86
+ summary: "proven on main — branch has no unique changes.",
87
+ main_branch: main_ref,
88
+ changed_files_count: 0
89
+ )
90
+ end
91
+
92
+ _, _, identical, identical_exit = git_run( "diff", "--quiet", branch, main_ref, "--", *changed_files )
93
+ if identical
94
+ return merge_proof_hash(
95
+ applicable: true,
96
+ proven: true,
97
+ basis: "content_identical",
98
+ summary: "proven on main — #{changed_files_count} changed file#{plural_suffix( count: changed_files_count )} already #{merge_proof_files_verb( count: changed_files_count, singular: 'matches', plural: 'match' )} #{main_ref}.",
99
+ main_branch: main_ref,
100
+ changed_files_count: changed_files_count
101
+ )
102
+ end
103
+
104
+ if identical_exit == 1
105
+ return merge_proof_hash(
106
+ applicable: true,
107
+ proven: false,
108
+ basis: "content_differs",
109
+ summary: "not proven on main — #{changed_files_count} changed file#{plural_suffix( count: changed_files_count )} still #{merge_proof_files_verb( count: changed_files_count, singular: 'differs', plural: 'differ' )} from #{main_ref}.",
110
+ main_branch: main_ref,
111
+ changed_files_count: changed_files_count
112
+ )
113
+ end
114
+
115
+ merge_proof_unavailable(
116
+ main_ref: main_ref,
117
+ summary: "proof unavailable — could not compare branch content against #{main_ref}.",
118
+ changed_files_count: changed_files_count
119
+ )
120
+ end
121
+
122
+ def merge_proof_main_trust( main_ref: )
123
+ remote_ref = merge_proof_remote_ref( main_ref: main_ref )
124
+ _, _, remote_exists, = git_run( "rev-parse", "--verify", remote_ref )
125
+ unless remote_exists
126
+ return {
127
+ trusted: false,
128
+ summary: "proof unavailable — no local #{remote_ref} reference."
129
+ }
130
+ end
131
+
132
+ ahead_behind, _, sync_success, = git_run( "rev-list", "--left-right", "--count", "#{main_ref}...#{remote_ref}" )
133
+ unless sync_success
134
+ return {
135
+ trusted: false,
136
+ summary: "proof unavailable — could not compare local #{main_ref} with #{remote_ref}."
137
+ }
138
+ end
139
+
140
+ ahead, behind = ahead_behind.to_s.strip.split.map( &:to_i )
141
+ return { trusted: true, summary: "local #{main_ref} is in sync with #{remote_ref}." } if ahead.zero? && behind.zero?
142
+
143
+ {
144
+ trusted: false,
145
+ summary: "proof unavailable — local #{main_ref} is not in sync with #{remote_ref}."
146
+ }
147
+ end
148
+
149
+ def merge_proof_remote_ref( main_ref: )
150
+ "#{config.git_remote}/#{main_ref}"
151
+ end
152
+
153
+ def merge_proof_not_applicable( main_ref: )
154
+ merge_proof_hash(
155
+ applicable: false,
156
+ proven: false,
157
+ basis: "not_applicable",
158
+ summary: "not applicable — current branch is #{main_ref}.",
159
+ main_branch: main_ref,
160
+ changed_files_count: 0
161
+ )
162
+ end
163
+
164
+ def merge_proof_unavailable( main_ref:, summary:, changed_files_count: 0 )
165
+ merge_proof_hash(
166
+ applicable: true,
167
+ proven: false,
168
+ basis: "unavailable",
169
+ summary: summary,
170
+ main_branch: main_ref,
171
+ changed_files_count: changed_files_count
172
+ )
173
+ end
174
+
175
+ def merge_proof_hash( applicable:, proven:, basis:, summary:, main_branch:, changed_files_count: )
176
+ {
177
+ applicable: applicable,
178
+ proven: proven,
179
+ basis: basis,
180
+ summary: summary,
181
+ main_branch: main_branch,
182
+ changed_files_count: changed_files_count.to_i
183
+ }
184
+ end
185
+
186
+ def merge_proof_files_verb( count:, singular:, plural: )
187
+ count.to_i == 1 ? singular : plural
188
+ end
189
+
190
+ def merge_proof_command_failure_summary( remote_ref:, fallback:, error_text: )
191
+ text = error_text.to_s.strip
192
+ return fallback if text.empty?
193
+ return "proof unavailable — local main is not in sync with #{remote_ref}." if text.include?( remote_ref ) && text.include?( "not a valid object name" )
194
+
195
+ fallback
196
+ end
197
+ end
198
+ end
199
+ end
@@ -22,6 +22,15 @@ module Carson
22
22
  exit_code: EXIT_BLOCK, json_output: json_output
23
23
  )
24
24
  end
25
+
26
+ attachment = ensure_main_attached!
27
+ unless attachment.fetch( :ok )
28
+ return sync_finish(
29
+ result: { command: "sync", status: "block", error: attachment.fetch( :error ), recovery: attachment[ :recovery ] },
30
+ exit_code: EXIT_BLOCK, json_output: json_output
31
+ )
32
+ end
33
+
25
34
  start_branch = current_branch
26
35
  switched = false
27
36
  sync_git!( "fetch", config.git_remote, "--prune", json_output: json_output )
@@ -186,6 +195,86 @@ module Carson
186
195
  success
187
196
  end
188
197
 
198
+ # Ensures the main worktree is attached to the main branch, not detached HEAD
199
+ # or on a wrong branch.
200
+ #
201
+ # Returns { ok: true } when already attached or successfully reattached.
202
+ # Returns { ok: false, error: "...", recovery: "..." } when blocked.
203
+ #
204
+ # Why this exists: git pull --ff-only on a detached HEAD succeeds (exit 0)
205
+ # but fast-forwards the detached HEAD instead of updating the local main
206
+ # branch ref. This means sync_after_merge! can report synced: true while
207
+ # local main is still stale.
208
+ def ensure_main_attached!( main_root: nil )
209
+ main_root ||= repo_root
210
+ main = config.main_branch
211
+
212
+ head_ref, _, head_success, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--abbrev-ref", "HEAD" )
213
+ return { ok: true } unless head_success
214
+
215
+ head_ref = head_ref.strip
216
+ return { ok: true } if head_ref == main
217
+
218
+ if head_ref != "HEAD"
219
+ # On a wrong branch — switch to main if the tree is clean.
220
+ status_out, = Open3.capture3( "git", "-C", main_root, "status", "--porcelain" )
221
+ unless status_out.to_s.strip.empty?
222
+ return {
223
+ ok: false,
224
+ error: "main worktree is on branch #{head_ref} with uncommitted changes, expected #{main}",
225
+ recovery: "cd #{main_root} && git stash && git switch #{main}"
226
+ }
227
+ end
228
+
229
+ _, switch_err, switch_status, = Open3.capture3( "git", "-C", main_root, "switch", main )
230
+ unless switch_status.success?
231
+ return {
232
+ ok: false,
233
+ error: "main worktree is on branch #{head_ref}, could not switch to #{main}: #{switch_err.strip}",
234
+ recovery: "cd #{main_root} && git switch #{main}"
235
+ }
236
+ end
237
+
238
+ puts_verbose "switched main worktree from #{head_ref} to #{main}"
239
+ return { ok: true, reattached: true }
240
+ end
241
+
242
+ # Detached HEAD — check if safe to reattach.
243
+ detached_sha, = Open3.capture3( "git", "-C", main_root, "rev-parse", "HEAD" )
244
+ detached_sha = detached_sha.strip
245
+
246
+ main_sha, _, main_exists, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--verify", "refs/heads/#{main}" )
247
+ main_sha = main_sha.strip if main_exists&.success?
248
+
249
+ remote = config.git_remote
250
+ remote_sha, _, remote_exists, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--verify", "refs/remotes/#{remote}/#{main}" )
251
+ remote_sha = remote_sha.strip if remote_exists&.success?
252
+
253
+ safe = ( main_exists&.success? && detached_sha == main_sha ) ||
254
+ ( remote_exists&.success? && detached_sha == remote_sha )
255
+
256
+ unless safe
257
+ short_sha = detached_sha[ 0, 8 ]
258
+ return {
259
+ ok: false,
260
+ error: "main worktree is detached at #{short_sha} which differs from #{main}",
261
+ recovery: "cd #{main_root} && git switch #{main}"
262
+ }
263
+ end
264
+
265
+ _, switch_err, switch_status, = Open3.capture3( "git", "-C", main_root, "switch", main )
266
+ unless switch_status.success?
267
+ return {
268
+ ok: false,
269
+ error: "could not reattach main worktree to #{main}: #{switch_err.strip}",
270
+ recovery: "cd #{main_root} && git switch #{main}"
271
+ }
272
+ end
273
+
274
+ puts_verbose "reattached main worktree to #{main}"
275
+ { ok: true, reattached: true }
276
+ end
277
+
189
278
  # In outsider mode, Carson must not leave Carson-owned fingerprints in host repositories.
190
279
  def block_if_outsider_fingerprints!
191
280
  return nil unless outsider_mode?
@@ -143,35 +143,21 @@ module Carson
143
143
  return { action: :skip, reason: "held by another process", absorbed: false } if worktree.held_by_other_process?
144
144
  return { action: :reap, reason: "directory missing (destroyed externally)", absorbed: false } unless worktree.exists?
145
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
146
  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?
147
+ return { action: :skip, reason: "dirty worktree", absorbed: absorbed } if worktree.dirty?
148
+ return { action: :skip, reason: "gh CLI not available for PR check", absorbed: absorbed } unless gh_available?
163
149
 
164
150
  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?
151
+ return { action: :skip, reason: "cannot read branch tip SHA", absorbed: absorbed } if tip_sha.nil?
166
152
 
167
153
  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 )
154
+ return { action: :reap, reason: "merged #{pr_short_ref( merged_pr.fetch( :url ) )}", absorbed: absorbed } unless merged_pr.nil?
155
+ return { action: :skip, reason: "open PR exists", absorbed: absorbed } if branch_has_open_pr?( branch: worktree.branch )
170
156
 
171
157
  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?
158
+ return { action: :reap, reason: "closed abandoned #{pr_short_ref( abandoned_pr.fetch( :url ) )}", absorbed: absorbed } unless abandoned_pr.nil?
173
159
 
174
- { action: :skip, reason: "no evidence to reap", absorbed: false }
160
+ { action: :skip, reason: "no evidence to reap", absorbed: absorbed }
175
161
  end
176
162
 
177
163
  def worktree_branch_tip_sha( branch: )
@@ -1,5 +1,6 @@
1
1
  # Aggregates local repository operation modules (sync, prune, hooks, worktree, template).
2
2
  require_relative "local/sync"
3
+ require_relative "local/merge_proof"
3
4
  require_relative "local/prune"
4
5
  require_relative "local/template"
5
6
  require_relative "local/hooks"