carson 3.27.1 → 3.29.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 +3 -3
- data/API.md +35 -8
- data/MANUAL.md +17 -14
- data/README.md +15 -8
- data/RELEASE.md +25 -0
- data/VERSION +1 -1
- data/lib/carson/delivery.rb +9 -2
- data/lib/carson/ledger.rb +72 -1
- data/lib/carson/runtime/abandon.rb +12 -9
- data/lib/carson/runtime/deliver.rb +779 -85
- data/lib/carson/runtime/govern.rb +163 -75
- data/lib/carson/runtime/housekeep.rb +5 -9
- data/lib/carson/runtime/local/merge_proof.rb +217 -0
- data/lib/carson/runtime/local/sync.rb +89 -0
- data/lib/carson/runtime/local/worktree.rb +9 -23
- data/lib/carson/runtime/local.rb +1 -0
- data/lib/carson/runtime/loop_runner.rb +90 -0
- data/lib/carson/runtime/status.rb +34 -1
- data/lib/carson/runtime.rb +9 -2
- data/lib/carson/worktree.rb +114 -26
- metadata +4 -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
|
|
@@ -39,17 +39,17 @@ module Carson
|
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def govern_loop!( dry_run:, json_output:, loop_seconds: )
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
run_signal_aware_loop!(
|
|
43
|
+
loop_name: "govern",
|
|
44
|
+
loop_seconds: loop_seconds,
|
|
45
|
+
cycle_line: ->( cycle_count ) { "cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}" },
|
|
46
|
+
sleep_line: ->( seconds ) do
|
|
47
|
+
next_at = Time.now + seconds
|
|
48
|
+
"sleeping #{seconds}s — next cycle at #{next_at.strftime( '%Y-%m-%d %H:%M:%S %z' )}"
|
|
49
|
+
end
|
|
50
|
+
) do
|
|
47
51
|
govern_cycle!( dry_run: dry_run, json_output: json_output )
|
|
48
|
-
sleep loop_seconds
|
|
49
52
|
end
|
|
50
|
-
rescue Interrupt
|
|
51
|
-
puts_line "govern loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
|
|
52
|
-
EXIT_OK
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
private
|
|
@@ -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,16 +75,18 @@ 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
|
|
86
86
|
|
|
87
87
|
reconciled.each do |delivery|
|
|
88
|
+
hint = delivery_action_hint( delivery: delivery, next_to_integrate: next_to_integrate, dry_run: dry_run )
|
|
89
|
+
puts_line " #{delivery.branch} — #{hint}" if hint && !silent
|
|
88
90
|
delivery_report = scoped_runtime.send(
|
|
89
91
|
:decide_delivery_action,
|
|
90
92
|
delivery: delivery,
|
|
@@ -122,7 +124,10 @@ module Carson
|
|
|
122
124
|
delivery: delivery,
|
|
123
125
|
status: "integrated",
|
|
124
126
|
integrated_at: Time.now.utc.iso8601,
|
|
125
|
-
summary: "integrated into #{config.main_branch}"
|
|
127
|
+
summary: "integrated into #{config.main_branch}",
|
|
128
|
+
pull_request_state: "MERGED",
|
|
129
|
+
pull_request_draft: false,
|
|
130
|
+
pull_request_merged_at: pr_state[ "mergedAt" ]
|
|
126
131
|
)
|
|
127
132
|
end
|
|
128
133
|
|
|
@@ -131,47 +136,67 @@ module Carson
|
|
|
131
136
|
delivery: delivery,
|
|
132
137
|
status: "failed",
|
|
133
138
|
cause: "policy",
|
|
134
|
-
summary: "pull request closed without integration"
|
|
139
|
+
summary: "pull request closed without integration",
|
|
140
|
+
pull_request_state: "CLOSED",
|
|
141
|
+
pull_request_draft: pr_state[ "isDraft" ],
|
|
142
|
+
pull_request_merged_at: pr_state[ "mergedAt" ]
|
|
135
143
|
)
|
|
136
144
|
end
|
|
137
145
|
|
|
138
146
|
assess_delivery!( delivery: delivery, branch_name: delivery.branch )
|
|
139
147
|
end
|
|
140
148
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
def decide_delivery_action( delivery:, repo_path:, dry_run:, next_to_integrate: )
|
|
150
|
+
report = {
|
|
151
|
+
key: delivery.key,
|
|
152
|
+
branch: delivery.branch,
|
|
153
|
+
status: delivery.status,
|
|
154
|
+
cause: delivery.cause,
|
|
155
|
+
summary: delivery.summary,
|
|
156
|
+
revision_count: delivery.revision_count,
|
|
157
|
+
action: "none"
|
|
158
|
+
}
|
|
150
159
|
|
|
151
160
|
if delivery.superseded? || delivery.integrated? || delivery.failed?
|
|
152
161
|
return report
|
|
153
162
|
end
|
|
154
163
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
if delivery.ready? && delivery.key == next_to_integrate
|
|
165
|
+
report[ :action ] = dry_run ? "would_integrate" : "integrate"
|
|
166
|
+
unless dry_run
|
|
167
|
+
updated = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run )
|
|
168
|
+
report[ :status ] = updated.status
|
|
169
|
+
report[ :cause ] = updated.cause
|
|
170
|
+
report[ :summary ] = updated.summary
|
|
171
|
+
report[ :merge_proof ] = merge_proof_payload( proof: updated.merge_proof ) if updated.integrated? && updated.merge_proof
|
|
172
|
+
end
|
|
164
173
|
return report
|
|
165
174
|
end
|
|
166
175
|
|
|
167
|
-
if delivery.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
176
|
+
if delivery.blocked?
|
|
177
|
+
if held_delivery?( delivery: delivery )
|
|
178
|
+
report[ :action ] = dry_run ? "would_hold" : "hold"
|
|
179
|
+
return report
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if delivery.revision_count >= 3
|
|
183
|
+
report[ :action ] = dry_run ? "would_escalate" : "escalate"
|
|
184
|
+
unless dry_run
|
|
185
|
+
updated = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run )
|
|
186
|
+
report[ :status ] = updated.status
|
|
187
|
+
report[ :cause ] = updated.cause
|
|
188
|
+
report[ :summary ] = updated.summary
|
|
189
|
+
end
|
|
190
|
+
else
|
|
191
|
+
report[ :action ] = dry_run ? "would_revise" : "revise"
|
|
192
|
+
unless dry_run
|
|
193
|
+
updated = execute_delivery_action!( action: report[ :action ], delivery: delivery, repo_path: repo_path, dry_run: dry_run )
|
|
194
|
+
report[ :status ] = updated.status
|
|
195
|
+
report[ :cause ] = updated.cause
|
|
196
|
+
report[ :summary ] = updated.summary
|
|
197
|
+
end
|
|
198
|
+
end
|
|
173
199
|
end
|
|
174
|
-
end
|
|
175
200
|
|
|
176
201
|
report
|
|
177
202
|
end
|
|
@@ -191,23 +216,46 @@ module Carson
|
|
|
191
216
|
end
|
|
192
217
|
end
|
|
193
218
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
219
|
+
def integrate_delivery!( delivery:, repo_path: )
|
|
220
|
+
result = {}
|
|
221
|
+
freshness = assess_branch_freshness(
|
|
222
|
+
head_ref: delivery.head || delivery.branch,
|
|
223
|
+
remote: config.git_remote,
|
|
224
|
+
main: config.main_branch
|
|
225
|
+
)
|
|
226
|
+
unless freshness.fetch( :ready )
|
|
227
|
+
return ledger.update_delivery(
|
|
228
|
+
delivery: delivery,
|
|
229
|
+
status: "gated",
|
|
230
|
+
cause: "freshness",
|
|
231
|
+
summary: freshness.fetch( :summary )
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
prepared = ledger.update_delivery(
|
|
236
|
+
delivery: delivery,
|
|
237
|
+
status: "integrating",
|
|
238
|
+
summary: "integrating into #{config.main_branch}"
|
|
239
|
+
)
|
|
201
240
|
merge_exit = merge_pr!( number: prepared.pull_request_number, result: result )
|
|
202
241
|
if merge_exit == EXIT_OK
|
|
203
242
|
integrated = ledger.update_delivery(
|
|
204
243
|
delivery: prepared,
|
|
205
244
|
status: "integrated",
|
|
206
245
|
integrated_at: Time.now.utc.iso8601,
|
|
207
|
-
summary: "integrated into #{config.main_branch}"
|
|
246
|
+
summary: "integrated into #{config.main_branch}",
|
|
247
|
+
pull_request_state: "MERGED",
|
|
248
|
+
pull_request_draft: false,
|
|
249
|
+
pull_request_merged_at: Time.now.utc.iso8601
|
|
250
|
+
)
|
|
251
|
+
# Fetch-only: update the remote tracking ref without mutating the
|
|
252
|
+
# main worktree. Reap and prune are deferred to explicit housekeep.
|
|
253
|
+
fetch_for_merge_proof!( repo_path: repo_path )
|
|
254
|
+
proof = merge_proof_for_remote_ref( branch: integrated.branch )
|
|
255
|
+
ledger.update_delivery(
|
|
256
|
+
delivery: integrated,
|
|
257
|
+
merge_proof: proof
|
|
208
258
|
)
|
|
209
|
-
housekeep_repo!( repo_path: repo_path )
|
|
210
|
-
integrated
|
|
211
259
|
else
|
|
212
260
|
ledger.update_delivery(
|
|
213
261
|
delivery: prepared,
|
|
@@ -223,6 +271,27 @@ module Carson
|
|
|
223
271
|
return escalate_delivery!( delivery: delivery, reason: "no agent provider available" ) if provider.nil?
|
|
224
272
|
return escalate_delivery!( delivery: delivery, reason: "worktree missing for revision" ) unless File.directory?( delivery.worktree_path.to_s )
|
|
225
273
|
|
|
274
|
+
# Defer if the target worktree is occupied — temporary hold, not failure.
|
|
275
|
+
worktree = Carson::Worktree.find( path: delivery.worktree_path.to_s, runtime: self )
|
|
276
|
+
if worktree
|
|
277
|
+
if worktree.held_by_other_process?
|
|
278
|
+
return ledger.update_delivery(
|
|
279
|
+
delivery: delivery,
|
|
280
|
+
status: "gated",
|
|
281
|
+
cause: "busy",
|
|
282
|
+
summary: "worktree held by another process — deferring revision"
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
if worktree.dirty?
|
|
286
|
+
return ledger.update_delivery(
|
|
287
|
+
delivery: delivery,
|
|
288
|
+
status: "gated",
|
|
289
|
+
cause: "busy",
|
|
290
|
+
summary: "worktree has uncommitted changes — deferring revision"
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
226
295
|
objective = revision_objective( cause: delivery.cause )
|
|
227
296
|
context = evidence( delivery: delivery, repo_path: repo_path, objective: objective )
|
|
228
297
|
work_order = Adapters::Agent::WorkOrder.new(
|
|
@@ -288,15 +357,33 @@ module Carson
|
|
|
288
357
|
end
|
|
289
358
|
end
|
|
290
359
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
360
|
+
def held_delivery?( delivery: )
|
|
361
|
+
[ "merge", "freshness", "busy" ].include?( delivery.cause )
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def delivery_action_hint( delivery:, next_to_integrate:, dry_run: )
|
|
365
|
+
return nil if dry_run
|
|
366
|
+
return nil if delivery.superseded? || delivery.integrated? || delivery.failed?
|
|
367
|
+
return "integrating…" if delivery.ready? && delivery.key == next_to_integrate
|
|
368
|
+
return nil unless delivery.blocked?
|
|
369
|
+
return nil if held_delivery?( delivery: delivery )
|
|
370
|
+
delivery.revision_count >= 3 ? "escalating…" : "revising…"
|
|
371
|
+
end
|
|
294
372
|
|
|
295
373
|
def housekeep_repo!( repo_path: )
|
|
296
374
|
scoped_runtime = repo_runtime_for( repo_path: repo_path )
|
|
297
375
|
scoped_runtime.send( :housekeep_one_entry, repo_path: repo_path, silent: true )
|
|
298
376
|
end
|
|
299
377
|
|
|
378
|
+
# Fetch-only helper for post-merge proof generation.
|
|
379
|
+
# Updates the remote tracking ref without mutating the main worktree.
|
|
380
|
+
def fetch_for_merge_proof!( repo_path: )
|
|
381
|
+
scoped = repo_runtime_for( repo_path: repo_path )
|
|
382
|
+
scoped.send( :git_run, "fetch", scoped.config.git_remote, "--prune" )
|
|
383
|
+
rescue StandardError
|
|
384
|
+
# Best-effort — merge proof falls back to unavailable if fetch fails.
|
|
385
|
+
end
|
|
386
|
+
|
|
300
387
|
def select_agent_provider
|
|
301
388
|
provider = config.govern_agent_provider
|
|
302
389
|
case provider
|
|
@@ -435,36 +522,37 @@ module Carson
|
|
|
435
522
|
|
|
436
523
|
next if repo_report[ :deliveries ].empty?
|
|
437
524
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
525
|
+
repo_report[ :deliveries ].each do |delivery|
|
|
526
|
+
action_text = format_govern_action( status: delivery[ :status ], action: delivery[ :action ], cause: delivery[ :cause ] )
|
|
527
|
+
puts_line "#{repo_report[ :repository ]}/#{delivery[ :branch ]} — #{action_text}"
|
|
528
|
+
puts_line " #{delivery[ :summary ]}" unless delivery[ :summary ].to_s.empty?
|
|
529
|
+
puts_line " Merge proof: #{delivery.dig( :merge_proof, :summary )}" if delivery[ :merge_proof ]
|
|
530
|
+
end
|
|
442
531
|
end
|
|
443
532
|
end
|
|
444
|
-
end
|
|
445
533
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
534
|
+
def format_govern_action( status:, action:, cause: )
|
|
535
|
+
case action
|
|
536
|
+
when "integrate"
|
|
537
|
+
format_govern_integration_outcome( status: status, cause: cause )
|
|
538
|
+
when "would_integrate" then "ready to integrate (dry run)"
|
|
539
|
+
when "hold" then cause == "freshness" ? "refresh required" : "held at gate"
|
|
540
|
+
when "would_hold" then cause == "freshness" ? "would require refresh (dry run)" : "would hold at gate (dry run)"
|
|
541
|
+
when "revise" then "revision dispatched"
|
|
542
|
+
when "would_revise" then "would revise (dry run)"
|
|
455
543
|
when "escalate" then "escalated"
|
|
456
544
|
when "would_escalate" then "would escalate (dry run)"
|
|
457
545
|
else status
|
|
458
546
|
end
|
|
459
547
|
end
|
|
460
548
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
549
|
+
def format_govern_integration_outcome( status:, cause: )
|
|
550
|
+
case status
|
|
551
|
+
when "integrated" then "integrated"
|
|
552
|
+
when "gated" then cause == "freshness" ? "refresh required" : "held at gate"
|
|
553
|
+
when "failed" then "integration failed"
|
|
554
|
+
when "escalated" then "integration escalated"
|
|
555
|
+
else status
|
|
468
556
|
end
|
|
469
557
|
end
|
|
470
558
|
end
|
|
@@ -81,17 +81,13 @@ module Carson
|
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
def housekeep_loop!( json_output:, dry_run:, loop_seconds: )
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
run_signal_aware_loop!(
|
|
85
|
+
loop_name: "housekeep",
|
|
86
|
+
loop_seconds: loop_seconds,
|
|
87
|
+
cycle_line: ->( cycle_count ) { "housekeep cycle #{cycle_count} at #{Time.now.utc.strftime( '%Y-%m-%d %H:%M:%S UTC' )}" }
|
|
88
|
+
) do
|
|
89
89
|
housekeep_all!( json_output: json_output, dry_run: dry_run )
|
|
90
|
-
sleep loop_seconds
|
|
91
90
|
end
|
|
92
|
-
rescue Interrupt
|
|
93
|
-
puts_line "housekeep loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
|
|
94
|
-
EXIT_OK
|
|
95
91
|
end
|
|
96
92
|
|
|
97
93
|
# Prints a dry-run plan for this repo without making any changes.
|
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
# Generates merge proof against the remote tracking ref directly.
|
|
6
|
+
# Skips the local-main trust check — the caller is responsible for
|
|
7
|
+
# fetching before calling. Used by govern's post-merge path to avoid
|
|
8
|
+
# mutating the main worktree.
|
|
9
|
+
def merge_proof_for_remote_ref( branch:, remote: config.git_remote, main_ref: config.main_branch )
|
|
10
|
+
remote_ref = "#{remote}/#{main_ref}"
|
|
11
|
+
return merge_proof_not_applicable( main_ref: main_ref ) if branch.to_s == main_ref.to_s
|
|
12
|
+
|
|
13
|
+
candidate = merge_proof_candidate( branch: branch, main_ref: remote_ref )
|
|
14
|
+
return candidate if candidate.fetch( :basis ) == "unavailable"
|
|
15
|
+
|
|
16
|
+
# Normalise display: show the local branch name, not the remote tracking ref.
|
|
17
|
+
candidate.merge(
|
|
18
|
+
main_branch: main_ref,
|
|
19
|
+
summary: candidate.fetch( :summary ).gsub( remote_ref, main_ref )
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def merge_proof_for_branch( branch:, main_ref: config.main_branch )
|
|
24
|
+
return merge_proof_not_applicable( main_ref: main_ref ) if branch.to_s == main_ref.to_s
|
|
25
|
+
|
|
26
|
+
candidate = merge_proof_candidate( branch: branch, main_ref: main_ref )
|
|
27
|
+
return candidate if candidate.fetch( :basis ) == "unavailable"
|
|
28
|
+
|
|
29
|
+
trust = merge_proof_main_trust( main_ref: main_ref )
|
|
30
|
+
return candidate if trust.fetch( :trusted )
|
|
31
|
+
|
|
32
|
+
merge_proof_hash(
|
|
33
|
+
applicable: true,
|
|
34
|
+
proven: false,
|
|
35
|
+
basis: "unavailable",
|
|
36
|
+
summary: trust.fetch( :summary ),
|
|
37
|
+
main_branch: main_ref,
|
|
38
|
+
changed_files_count: candidate.fetch( :changed_files_count, 0 )
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def branch_absorbed_into_main?( branch: )
|
|
43
|
+
merge_proof_for_branch( branch: branch ).fetch( :proven )
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def merge_proof_candidate( branch:, main_ref: )
|
|
49
|
+
_, _, ancestor_success, ancestor_exit = git_run( "merge-base", "--is-ancestor", branch, main_ref )
|
|
50
|
+
merge_base_text, merge_base_error, merge_base_success, = git_run( "merge-base", main_ref, branch )
|
|
51
|
+
unless merge_base_success
|
|
52
|
+
return merge_proof_unavailable(
|
|
53
|
+
main_ref: main_ref,
|
|
54
|
+
summary: merge_proof_command_failure_summary(
|
|
55
|
+
remote_ref: merge_proof_remote_ref( main_ref: main_ref ),
|
|
56
|
+
fallback: "proof unavailable — could not determine merge-base with #{main_ref}.",
|
|
57
|
+
error_text: merge_base_error
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
merge_base = merge_base_text.to_s.strip
|
|
63
|
+
return merge_proof_unavailable( main_ref: main_ref, summary: "proof unavailable — merge-base with #{main_ref} is empty." ) if merge_base.empty?
|
|
64
|
+
|
|
65
|
+
changed_text, changed_error, changed_success, = git_run( "diff", "--name-only", merge_base, branch )
|
|
66
|
+
unless changed_success
|
|
67
|
+
return merge_proof_unavailable(
|
|
68
|
+
main_ref: main_ref,
|
|
69
|
+
summary: merge_proof_command_failure_summary(
|
|
70
|
+
remote_ref: merge_proof_remote_ref( main_ref: main_ref ),
|
|
71
|
+
fallback: "proof unavailable — could not list branch changes against #{main_ref}.",
|
|
72
|
+
error_text: changed_error
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
changed_files = changed_text.to_s.lines.map( &:strip ).reject( &:empty? )
|
|
78
|
+
changed_files_count = changed_files.length
|
|
79
|
+
|
|
80
|
+
if ancestor_success
|
|
81
|
+
return merge_proof_hash(
|
|
82
|
+
applicable: true,
|
|
83
|
+
proven: true,
|
|
84
|
+
basis: "ancestor",
|
|
85
|
+
summary: "proven on main — branch tip is already on #{main_ref}.",
|
|
86
|
+
main_branch: main_ref,
|
|
87
|
+
changed_files_count: changed_files_count
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if ancestor_exit != 1
|
|
92
|
+
return merge_proof_unavailable(
|
|
93
|
+
main_ref: main_ref,
|
|
94
|
+
summary: "proof unavailable — could not verify whether #{branch} is already on #{main_ref}.",
|
|
95
|
+
changed_files_count: changed_files_count
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if changed_files.empty?
|
|
100
|
+
return merge_proof_hash(
|
|
101
|
+
applicable: true,
|
|
102
|
+
proven: true,
|
|
103
|
+
basis: "no_changes",
|
|
104
|
+
summary: "proven on main — branch has no unique changes.",
|
|
105
|
+
main_branch: main_ref,
|
|
106
|
+
changed_files_count: 0
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
_, _, identical, identical_exit = git_run( "diff", "--quiet", branch, main_ref, "--", *changed_files )
|
|
111
|
+
if identical
|
|
112
|
+
return merge_proof_hash(
|
|
113
|
+
applicable: true,
|
|
114
|
+
proven: true,
|
|
115
|
+
basis: "content_identical",
|
|
116
|
+
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}.",
|
|
117
|
+
main_branch: main_ref,
|
|
118
|
+
changed_files_count: changed_files_count
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if identical_exit == 1
|
|
123
|
+
return merge_proof_hash(
|
|
124
|
+
applicable: true,
|
|
125
|
+
proven: false,
|
|
126
|
+
basis: "content_differs",
|
|
127
|
+
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}.",
|
|
128
|
+
main_branch: main_ref,
|
|
129
|
+
changed_files_count: changed_files_count
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
merge_proof_unavailable(
|
|
134
|
+
main_ref: main_ref,
|
|
135
|
+
summary: "proof unavailable — could not compare branch content against #{main_ref}.",
|
|
136
|
+
changed_files_count: changed_files_count
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def merge_proof_main_trust( main_ref: )
|
|
141
|
+
remote_ref = merge_proof_remote_ref( main_ref: main_ref )
|
|
142
|
+
_, _, remote_exists, = git_run( "rev-parse", "--verify", remote_ref )
|
|
143
|
+
unless remote_exists
|
|
144
|
+
return {
|
|
145
|
+
trusted: false,
|
|
146
|
+
summary: "proof unavailable — no local #{remote_ref} reference."
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
ahead_behind, _, sync_success, = git_run( "rev-list", "--left-right", "--count", "#{main_ref}...#{remote_ref}" )
|
|
151
|
+
unless sync_success
|
|
152
|
+
return {
|
|
153
|
+
trusted: false,
|
|
154
|
+
summary: "proof unavailable — could not compare local #{main_ref} with #{remote_ref}."
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
ahead, behind = ahead_behind.to_s.strip.split.map( &:to_i )
|
|
159
|
+
return { trusted: true, summary: "local #{main_ref} is in sync with #{remote_ref}." } if ahead.zero? && behind.zero?
|
|
160
|
+
|
|
161
|
+
{
|
|
162
|
+
trusted: false,
|
|
163
|
+
summary: "proof unavailable — local #{main_ref} is not in sync with #{remote_ref}."
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def merge_proof_remote_ref( main_ref: )
|
|
168
|
+
"#{config.git_remote}/#{main_ref}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def merge_proof_not_applicable( main_ref: )
|
|
172
|
+
merge_proof_hash(
|
|
173
|
+
applicable: false,
|
|
174
|
+
proven: false,
|
|
175
|
+
basis: "not_applicable",
|
|
176
|
+
summary: "not applicable — current branch is #{main_ref}.",
|
|
177
|
+
main_branch: main_ref,
|
|
178
|
+
changed_files_count: 0
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def merge_proof_unavailable( main_ref:, summary:, changed_files_count: 0 )
|
|
183
|
+
merge_proof_hash(
|
|
184
|
+
applicable: true,
|
|
185
|
+
proven: false,
|
|
186
|
+
basis: "unavailable",
|
|
187
|
+
summary: summary,
|
|
188
|
+
main_branch: main_ref,
|
|
189
|
+
changed_files_count: changed_files_count
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def merge_proof_hash( applicable:, proven:, basis:, summary:, main_branch:, changed_files_count: )
|
|
194
|
+
{
|
|
195
|
+
applicable: applicable,
|
|
196
|
+
proven: proven,
|
|
197
|
+
basis: basis,
|
|
198
|
+
summary: summary,
|
|
199
|
+
main_branch: main_branch,
|
|
200
|
+
changed_files_count: changed_files_count.to_i
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def merge_proof_files_verb( count:, singular:, plural: )
|
|
205
|
+
count.to_i == 1 ? singular : plural
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def merge_proof_command_failure_summary( remote_ref:, fallback:, error_text: )
|
|
209
|
+
text = error_text.to_s.strip
|
|
210
|
+
return fallback if text.empty?
|
|
211
|
+
return "proof unavailable — local main is not in sync with #{remote_ref}." if text.include?( remote_ref ) && text.include?( "not a valid object name" )
|
|
212
|
+
|
|
213
|
+
fallback
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|