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.
- checksums.yaml +4 -4
- data/.github/workflows/carson_policy.yml +1 -1
- data/API.md +33 -6
- data/MANUAL.md +10 -9
- data/README.md +15 -8
- data/RELEASE.md +12 -0
- data/VERSION +1 -1
- data/lib/carson/delivery.rb +9 -2
- data/lib/carson/ledger.rb +67 -1
- data/lib/carson/runtime/deliver.rb +779 -85
- data/lib/carson/runtime/govern.rb +118 -66
- data/lib/carson/runtime/local/merge_proof.rb +199 -0
- data/lib/carson/runtime/local/sync.rb +89 -0
- data/lib/carson/runtime/local/worktree.rb +7 -21
- data/lib/carson/runtime/local.rb +1 -0
- data/lib/carson/runtime/status.rb +34 -1
- data/lib/carson/worktree.rb +95 -18
- metadata +3 -2
|
@@ -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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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: :
|
|
162
|
-
return { action: :skip, reason: "gh CLI not available for PR check", absorbed:
|
|
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:
|
|
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:
|
|
169
|
-
return { action: :skip, reason: "open PR exists", absorbed:
|
|
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:
|
|
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:
|
|
160
|
+
{ action: :skip, reason: "no evidence to reap", absorbed: absorbed }
|
|
175
161
|
end
|
|
176
162
|
|
|
177
163
|
def worktree_branch_tip_sha( branch: )
|
data/lib/carson/runtime/local.rb
CHANGED