carson 3.18.0 → 3.20.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/RELEASE.md +22 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/audit.rb +67 -63
- data/lib/carson/runtime/deliver.rb +20 -20
- data/lib/carson/runtime/govern.rb +2 -2
- data/lib/carson/runtime/housekeep.rb +11 -3
- data/lib/carson/runtime/local/onboard.rb +31 -10
- data/lib/carson/runtime/local/sync.rb +4 -0
- data/lib/carson/runtime/local/template.rb +7 -4
- data/lib/carson/runtime/local/worktree.rb +50 -7
- data/lib/carson/runtime/review/data_access.rb +1 -0
- data/lib/carson/runtime/review/gate_support.rb +13 -12
- data/lib/carson/runtime/review/query_text.rb +1 -0
- data/lib/carson/runtime/review/sweep_support.rb +3 -2
- data/lib/carson/runtime/review/utility.rb +2 -1
- data/lib/carson/runtime/setup.rb +4 -4
- data/lib/carson/runtime/status.rb +28 -13
- data/lib/carson/runtime.rb +64 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e7fdc44d330fd415b8f6d9ee479d5256d6fc860e5d63c7c9287f28ebbdb2ba4
|
|
4
|
+
data.tar.gz: 19056bd376bcab3b7077e38ba4e42f2b9f5a3ffbf2c8027f0aa6626bb70587fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13f0d5ac83272711329d435a935dd940afc461c5c3b10e20d23842492e8e53f7032cf34a8b027d70db80563af148cda5f99c2c8e7ec4025b94f4d72609ea9d1d
|
|
7
|
+
data.tar.gz: b1903c6a8f8682adc7b942e49b553579d014d56c0bb65587465568119d41583a14e0cb4a7b00bc61b9165fac79094567395dbf2b39191e3c9d673dc0750dc4f5
|
data/RELEASE.md
CHANGED
|
@@ -5,6 +5,28 @@ Release-note scope rule:
|
|
|
5
5
|
- `RELEASE.md` records only version deltas, breaking changes, and migration actions.
|
|
6
6
|
- Operational usage guides live in `MANUAL.md` and `API.md`.
|
|
7
7
|
|
|
8
|
+
## 3.20.0
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **Cross-process CWD guard for worktree removal** — `worktree_remove!` and `sweep_stale_worktrees!` now detect when another process (e.g. an agent session) has its working directory inside a worktree before attempting removal. Uses `lsof -d cwd` to scan for cross-process CWD holds. Blocks removal with a clear recovery message instead of silently destroying the directory and crashing the other session's shell.
|
|
13
|
+
- **Code quality sweep** — fixed indentation errors in `deliver.rb` (`check_pr_ci` method). Converted single-parameter blocks to Ruby 3.4 `it` implicit parameter across `deliver.rb`, `govern.rb`, `setup.rb`, and all review submodules (`gate_support.rb`, `sweep_support.rb`, `utility.rb`). Added file-level purpose comments to review submodules (`gate_support.rb`, `sweep_support.rb`, `utility.rb`, `data_access.rb`, `query_text.rb`).
|
|
14
|
+
|
|
15
|
+
### UX improvement
|
|
16
|
+
|
|
17
|
+
- Worktree removal is now safe across process boundaries. Previously, Carson only checked if the *current* process's CWD was inside the worktree. A separate cleanup process (hook, batch sweep, or another agent) could still remove a worktree while another session's shell was inside it, permanently crashing that shell. The new guard detects this scenario and refuses removal with actionable recovery advice. Fails safe: if `lsof` is unavailable, removal proceeds as before.
|
|
18
|
+
|
|
19
|
+
## 3.19.0
|
|
20
|
+
|
|
21
|
+
### What changed
|
|
22
|
+
|
|
23
|
+
- **Persistent pending tracking for batch operations** — repos skipped during `--all` operations (due to active worktrees, uncommitted changes, or path errors) are now recorded in `~/.carson/cache/batch_pending.json`. On the next run, pending repos are reported and automatically retried. `carson status --all` shows pending operations per repo with attempt count and timestamp.
|
|
24
|
+
- **Ruby 3.4+ `it` implicit parameter** — single-parameter blocks where the parameter is the receiver now use `it` instead of named block variables (e.g. `.map { it.fetch( :name ) }` instead of `.map { |e| e.fetch( :name ) }`). Guard clause early returns replace `elsif` chains in `remote_sync_status`.
|
|
25
|
+
|
|
26
|
+
### UX improvement
|
|
27
|
+
|
|
28
|
+
- Batch operations are now self-healing: repos that can't be reached on one run aren't silently forgotten. They're logged with reasons and retried on the next invocation. `carson status --all` makes pending operations visible so the user always knows what needs attention.
|
|
29
|
+
|
|
8
30
|
## 3.18.0
|
|
9
31
|
|
|
10
32
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.20.0
|
data/lib/carson/runtime/audit.rb
CHANGED
|
@@ -72,7 +72,7 @@ module Carson
|
|
|
72
72
|
fail_n = checks.fetch( :failing_count )
|
|
73
73
|
pend_n = checks.fetch( :pending_count )
|
|
74
74
|
total = checks.fetch( :required_total )
|
|
75
|
-
fail_names = checks.fetch( :failing ).map {
|
|
75
|
+
fail_names = checks.fetch( :failing ).map { it.fetch( :name ) }.join( ", " )
|
|
76
76
|
if fail_n.positive? && pend_n.positive?
|
|
77
77
|
audit_concise_problems << "Checks: #{fail_n} failing (#{fail_names}), #{pend_n} pending of #{total} required."
|
|
78
78
|
elsif fail_n.positive?
|
|
@@ -89,11 +89,11 @@ module Carson
|
|
|
89
89
|
if baseline_st == "block"
|
|
90
90
|
parts = []
|
|
91
91
|
if default_branch_baseline.fetch( :failing_count ).positive?
|
|
92
|
-
names = default_branch_baseline.fetch( :failing ).map {
|
|
92
|
+
names = default_branch_baseline.fetch( :failing ).map { it.fetch( :name ) }.join( ", " )
|
|
93
93
|
parts << "#{default_branch_baseline.fetch( :failing_count )} failing (#{names})"
|
|
94
94
|
end
|
|
95
95
|
if default_branch_baseline.fetch( :pending_count ).positive?
|
|
96
|
-
names = default_branch_baseline.fetch( :pending ).map {
|
|
96
|
+
names = default_branch_baseline.fetch( :pending ).map { it.fetch( :name ) }.join( ", " )
|
|
97
97
|
parts << "#{default_branch_baseline.fetch( :pending_count )} pending (#{names})"
|
|
98
98
|
end
|
|
99
99
|
parts << "no check-runs for active workflows" if default_branch_baseline.fetch( :no_check_evidence )
|
|
@@ -101,11 +101,11 @@ module Carson
|
|
|
101
101
|
elsif baseline_st == "attention"
|
|
102
102
|
parts = []
|
|
103
103
|
if default_branch_baseline.fetch( :advisory_failing_count ).positive?
|
|
104
|
-
names = default_branch_baseline.fetch( :advisory_failing ).map {
|
|
104
|
+
names = default_branch_baseline.fetch( :advisory_failing ).map { it.fetch( :name ) }.join( ", " )
|
|
105
105
|
parts << "#{default_branch_baseline.fetch( :advisory_failing_count )} advisory failing (#{names})"
|
|
106
106
|
end
|
|
107
107
|
if default_branch_baseline.fetch( :advisory_pending_count ).positive?
|
|
108
|
-
names = default_branch_baseline.fetch( :advisory_pending ).map {
|
|
108
|
+
names = default_branch_baseline.fetch( :advisory_pending ).map { it.fetch( :name ) }.join( ", " )
|
|
109
109
|
parts << "#{default_branch_baseline.fetch( :advisory_pending_count )} advisory pending (#{names})"
|
|
110
110
|
end
|
|
111
111
|
audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )}."
|
|
@@ -176,6 +176,7 @@ module Carson
|
|
|
176
176
|
repo_name = File.basename( repo_path )
|
|
177
177
|
unless Dir.exist?( repo_path )
|
|
178
178
|
puts_line "#{repo_name}: FAIL (path not found)"
|
|
179
|
+
record_batch_skip( command: "audit", repo_path: repo_path, reason: "path not found" )
|
|
179
180
|
failed += 1
|
|
180
181
|
next
|
|
181
182
|
end
|
|
@@ -186,16 +187,19 @@ module Carson
|
|
|
186
187
|
case status
|
|
187
188
|
when EXIT_OK
|
|
188
189
|
puts_line "#{repo_name}: ok" unless verbose?
|
|
190
|
+
clear_batch_success( command: "audit", repo_path: repo_path )
|
|
189
191
|
passed += 1
|
|
190
192
|
when EXIT_BLOCK
|
|
191
193
|
puts_line "#{repo_name}: BLOCK" unless verbose?
|
|
192
194
|
blocked += 1
|
|
193
195
|
else
|
|
194
196
|
puts_line "#{repo_name}: FAIL" unless verbose?
|
|
197
|
+
record_batch_skip( command: "audit", repo_path: repo_path, reason: "audit failed" )
|
|
195
198
|
failed += 1
|
|
196
199
|
end
|
|
197
200
|
rescue StandardError => e
|
|
198
201
|
puts_line "#{repo_name}: FAIL (#{e.message})"
|
|
202
|
+
record_batch_skip( command: "audit", repo_path: repo_path, reason: e.message )
|
|
199
203
|
failed += 1
|
|
200
204
|
end
|
|
201
205
|
end
|
|
@@ -208,20 +212,20 @@ module Carson
|
|
|
208
212
|
private
|
|
209
213
|
def pr_and_check_report
|
|
210
214
|
report = {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
215
|
+
generated_at: Time.now.utc.iso8601,
|
|
216
|
+
branch: current_branch,
|
|
217
|
+
status: "ok",
|
|
218
|
+
skip_reason: nil,
|
|
219
|
+
pr: nil,
|
|
220
|
+
checks: {
|
|
221
|
+
status: "unknown",
|
|
222
|
+
skip_reason: nil,
|
|
223
|
+
required_total: 0,
|
|
224
|
+
failing_count: 0,
|
|
225
|
+
pending_count: 0,
|
|
226
|
+
failing: [],
|
|
227
|
+
pending: []
|
|
228
|
+
}
|
|
225
229
|
}
|
|
226
230
|
unless gh_available?
|
|
227
231
|
report[ :status ] = "skipped"
|
|
@@ -239,11 +243,11 @@ module Carson
|
|
|
239
243
|
end
|
|
240
244
|
pr_data = JSON.parse( pr_stdout )
|
|
241
245
|
report[ :pr ] = {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
246
|
+
number: pr_data[ "number" ],
|
|
247
|
+
title: pr_data[ "title" ].to_s,
|
|
248
|
+
url: pr_data[ "url" ].to_s,
|
|
249
|
+
state: pr_data[ "state" ].to_s,
|
|
250
|
+
review_decision: blank_to( value: pr_data[ "reviewDecision" ], default: "NONE" )
|
|
247
251
|
}
|
|
248
252
|
puts_verbose "pr: ##{report.dig( :pr, :number )} #{report.dig( :pr, :title )}"
|
|
249
253
|
puts_verbose "url: #{report.dig( :pr, :url )}"
|
|
@@ -258,7 +262,7 @@ module Carson
|
|
|
258
262
|
return report
|
|
259
263
|
end
|
|
260
264
|
checks_data = JSON.parse( checks_stdout )
|
|
261
|
-
pending = checks_data.select {
|
|
265
|
+
pending = checks_data.select { it[ "bucket" ].to_s == "pending" }
|
|
262
266
|
failing = checks_data.select { |entry| check_entry_failing?( entry: entry ) }
|
|
263
267
|
report[ :checks ][ :status ] = checks_success ? "ok" : ( checks_exit == 8 ? "pending" : "attention" )
|
|
264
268
|
report[ :checks ][ :required_total ] = checks_data.count
|
|
@@ -283,22 +287,22 @@ module Carson
|
|
|
283
287
|
# Evaluates default-branch CI health so stale workflow drift blocks before merge.
|
|
284
288
|
def default_branch_ci_baseline_report
|
|
285
289
|
report = {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
290
|
+
status: "ok",
|
|
291
|
+
skip_reason: nil,
|
|
292
|
+
repository: nil,
|
|
293
|
+
default_branch: nil,
|
|
294
|
+
head_sha: nil,
|
|
295
|
+
workflows_total: 0,
|
|
296
|
+
check_runs_total: 0,
|
|
297
|
+
failing_count: 0,
|
|
298
|
+
pending_count: 0,
|
|
299
|
+
advisory_failing_count: 0,
|
|
300
|
+
advisory_pending_count: 0,
|
|
301
|
+
no_check_evidence: false,
|
|
302
|
+
failing: [],
|
|
303
|
+
pending: [],
|
|
304
|
+
advisory_failing: [],
|
|
305
|
+
advisory_pending: []
|
|
302
306
|
}
|
|
303
307
|
unless gh_available?
|
|
304
308
|
report[ :status ] = "skipped"
|
|
@@ -309,30 +313,30 @@ module Carson
|
|
|
309
313
|
owner, repo = repository_coordinates
|
|
310
314
|
report[ :repository ] = "#{owner}/#{repo}"
|
|
311
315
|
repository_data = gh_json_payload!(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
316
|
+
"api", "repos/#{owner}/#{repo}",
|
|
317
|
+
"--method", "GET",
|
|
318
|
+
fallback: "unable to read repository metadata for #{owner}/#{repo}"
|
|
315
319
|
)
|
|
316
320
|
default_branch = blank_to( value: repository_data[ "default_branch" ], default: config.main_branch )
|
|
317
321
|
report[ :default_branch ] = default_branch
|
|
318
322
|
branch_data = gh_json_payload!(
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
323
|
+
"api", "repos/#{owner}/#{repo}/branches/#{CGI.escape( default_branch )}",
|
|
324
|
+
"--method", "GET",
|
|
325
|
+
fallback: "unable to read default branch #{default_branch}"
|
|
322
326
|
)
|
|
323
327
|
head_sha = branch_data.dig( "commit", "sha" ).to_s.strip
|
|
324
328
|
raise "default branch #{default_branch} has no commit SHA" if head_sha.empty?
|
|
325
329
|
report[ :head_sha ] = head_sha
|
|
326
330
|
workflow_entries = default_branch_workflow_entries(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
331
|
+
owner: owner,
|
|
332
|
+
repo: repo,
|
|
333
|
+
default_branch: default_branch
|
|
330
334
|
)
|
|
331
335
|
report[ :workflows_total ] = workflow_entries.count
|
|
332
336
|
check_runs_payload = gh_json_payload!(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
337
|
+
"api", "repos/#{owner}/#{repo}/commits/#{head_sha}/check-runs",
|
|
338
|
+
"--method", "GET",
|
|
339
|
+
fallback: "unable to read check-runs for #{default_branch}@#{head_sha}"
|
|
336
340
|
)
|
|
337
341
|
check_runs = Array( check_runs_payload[ "check_runs" ] )
|
|
338
342
|
failing, pending = partition_default_branch_check_runs( check_runs: check_runs )
|
|
@@ -395,15 +399,15 @@ module Carson
|
|
|
395
399
|
# Reads workflow files from default branch; missing workflow directory is valid and returns none.
|
|
396
400
|
def default_branch_workflow_entries( owner:, repo:, default_branch: )
|
|
397
401
|
stdout_text, stderr_text, success, = gh_run(
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
402
|
+
"api", "repos/#{owner}/#{repo}/contents/.github/workflows",
|
|
403
|
+
"--method", "GET",
|
|
404
|
+
"-f", "ref=#{default_branch}"
|
|
401
405
|
)
|
|
402
406
|
unless success
|
|
403
407
|
error_text = gh_error_text(
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
408
|
+
stdout_text: stdout_text,
|
|
409
|
+
stderr_text: stderr_text,
|
|
410
|
+
fallback: "unable to read workflow files for #{default_branch}"
|
|
407
411
|
)
|
|
408
412
|
return [] if error_text.match?( /\b404\b/ )
|
|
409
413
|
raise error_text
|
|
@@ -470,10 +474,10 @@ module Carson
|
|
|
470
474
|
blank_to( value: entry[ "status" ], default: "UNKNOWN" )
|
|
471
475
|
end
|
|
472
476
|
{
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
+
workflow: blank_to( value: entry.dig( "app", "name" ), default: "workflow" ),
|
|
478
|
+
name: blank_to( value: entry[ "name" ], default: "check" ),
|
|
479
|
+
state: state.upcase,
|
|
480
|
+
link: entry[ "html_url" ].to_s
|
|
477
481
|
}
|
|
478
482
|
end
|
|
479
483
|
end
|
|
@@ -210,27 +210,27 @@ module Carson
|
|
|
210
210
|
|
|
211
211
|
# Generates a default PR title from the branch name.
|
|
212
212
|
def default_pr_title( branch: )
|
|
213
|
-
branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) {
|
|
213
|
+
branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { it.upcase }
|
|
214
214
|
end
|
|
215
215
|
|
|
216
216
|
# Checks CI status on a PR. Returns :pass, :fail, :pending, or :none.
|
|
217
|
-
# Uses the `bucket` field (pass/fail/pending) from `gh pr checks --json`.
|
|
218
|
-
def check_pr_ci( number: )
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
end
|
|
217
|
+
# Uses the `bucket` field (pass/fail/pending) from `gh pr checks --json`.
|
|
218
|
+
def check_pr_ci( number: )
|
|
219
|
+
stdout, _, success, = gh_run(
|
|
220
|
+
"pr", "checks", number.to_s,
|
|
221
|
+
"--json", "name,bucket"
|
|
222
|
+
)
|
|
223
|
+
return :none unless success
|
|
224
|
+
|
|
225
|
+
checks = JSON.parse( stdout ) rescue []
|
|
226
|
+
return :none if checks.empty?
|
|
227
|
+
|
|
228
|
+
buckets = checks.map { it[ "bucket" ].to_s.downcase }
|
|
229
|
+
return :fail if buckets.include?( "fail" )
|
|
230
|
+
return :pending if buckets.include?( "pending" )
|
|
231
|
+
|
|
232
|
+
:pass
|
|
233
|
+
end
|
|
234
234
|
|
|
235
235
|
# Checks review decision on a PR. Returns :approved, :changes_requested, :review_required, or :none.
|
|
236
236
|
def check_pr_review( number: )
|
|
@@ -298,8 +298,8 @@ end
|
|
|
298
298
|
def compute_post_merge_next_step!( result: )
|
|
299
299
|
main_root = main_worktree_root
|
|
300
300
|
cwd = realpath_safe( Dir.pwd )
|
|
301
|
-
current_wt = worktree_list.select {
|
|
302
|
-
.find {
|
|
301
|
+
current_wt = worktree_list.select { it.fetch( :path ) != realpath_safe( main_root ) }
|
|
302
|
+
.find { cwd == it.fetch( :path ) || cwd.start_with?( File.join( it.fetch( :path ), "" ) ) }
|
|
303
303
|
|
|
304
304
|
if current_wt
|
|
305
305
|
wt_name = File.basename( current_wt.fetch( :path ) )
|
|
@@ -208,10 +208,10 @@ module Carson
|
|
|
208
208
|
checks = Array( pr[ "statusCheckRollup" ] )
|
|
209
209
|
return :green if checks.empty?
|
|
210
210
|
|
|
211
|
-
has_failure = checks.any? {
|
|
211
|
+
has_failure = checks.any? { check_state_failing?( state: it[ "state" ].to_s ) || check_conclusion_failing?( conclusion: it[ "conclusion" ].to_s ) }
|
|
212
212
|
return :red if has_failure
|
|
213
213
|
|
|
214
|
-
has_pending = checks.any? {
|
|
214
|
+
has_pending = checks.any? { check_state_pending?( state: it[ "state" ].to_s ) }
|
|
215
215
|
return :pending if has_pending
|
|
216
216
|
|
|
217
217
|
:green
|
|
@@ -34,10 +34,18 @@ module Carson
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
results = []
|
|
37
|
-
repos.each
|
|
37
|
+
repos.each do |repo_path|
|
|
38
|
+
entry = housekeep_one_entry( repo_path: repo_path, silent: json_output )
|
|
39
|
+
if entry[ :status ] == "ok"
|
|
40
|
+
clear_batch_success( command: "housekeep", repo_path: repo_path )
|
|
41
|
+
else
|
|
42
|
+
record_batch_skip( command: "housekeep", repo_path: repo_path, reason: entry[ :error ] || "housekeep failed" )
|
|
43
|
+
end
|
|
44
|
+
results << entry
|
|
45
|
+
end
|
|
38
46
|
|
|
39
|
-
succeeded = results.count {
|
|
40
|
-
failed = results.count {
|
|
47
|
+
succeeded = results.count { it[ :status ] == "ok" }
|
|
48
|
+
failed = results.count { it[ :status ] != "ok" }
|
|
41
49
|
result = { command: "housekeep", status: failed.zero? ? "ok" : "partial", repos: results, succeeded: succeeded, failed: failed }
|
|
42
50
|
housekeep_finish( result: result, exit_code: failed.zero? ? EXIT_OK : EXIT_ERROR, json_output: json_output, results: results, succeeded: succeeded, failed: failed )
|
|
43
51
|
end
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# Repository onboarding and refresh lifecycle.
|
|
2
|
+
# Onboard: detect remote, install hooks, apply templates, run initial audit.
|
|
3
|
+
# Refresh: re-apply hooks and templates after Carson upgrade.
|
|
4
|
+
# Refresh all: batch refresh across governed portfolio with safety checks.
|
|
1
5
|
module Carson
|
|
2
6
|
class Runtime
|
|
3
7
|
module Local
|
|
@@ -44,7 +48,7 @@ module Carson
|
|
|
44
48
|
hook_status = prepare!
|
|
45
49
|
return hook_status unless hook_status == EXIT_OK
|
|
46
50
|
|
|
47
|
-
drift_count = template_results.count {
|
|
51
|
+
drift_count = template_results.count { it.fetch( :status ) != "ok" }
|
|
48
52
|
template_status = template_apply!
|
|
49
53
|
return template_status unless template_status == EXIT_OK
|
|
50
54
|
|
|
@@ -64,7 +68,7 @@ module Carson
|
|
|
64
68
|
return hook_status unless hook_status == EXIT_OK
|
|
65
69
|
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
66
70
|
|
|
67
|
-
template_drift_count = template_results.count {
|
|
71
|
+
template_drift_count = template_results.count { it.fetch( :status ) != "ok" }
|
|
68
72
|
template_status = with_captured_output { template_apply! }
|
|
69
73
|
return template_status unless template_status == EXIT_OK
|
|
70
74
|
if template_drift_count.positive?
|
|
@@ -82,7 +86,7 @@ module Carson
|
|
|
82
86
|
|
|
83
87
|
# Re-applies hooks, templates, and audit across all governed repositories.
|
|
84
88
|
# Checks each repo for safety (active worktrees, uncommitted changes) and
|
|
85
|
-
#
|
|
89
|
+
# marks unsafe repos as pending to avoid disrupting active work.
|
|
86
90
|
def refresh_all!
|
|
87
91
|
repos = config.govern_repos
|
|
88
92
|
if repos.empty?
|
|
@@ -91,24 +95,32 @@ module Carson
|
|
|
91
95
|
return EXIT_ERROR
|
|
92
96
|
end
|
|
93
97
|
|
|
98
|
+
pending_before = pending_repos_for( command: "refresh" )
|
|
99
|
+
if pending_before.any?
|
|
100
|
+
puts_line "#{pending_before.length} repo#{plural_suffix( count: pending_before.length )} pending from previous run"
|
|
101
|
+
end
|
|
102
|
+
|
|
94
103
|
puts_line ""
|
|
95
104
|
puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
|
|
96
105
|
refreshed = 0
|
|
97
|
-
|
|
106
|
+
pending = 0
|
|
98
107
|
failed = 0
|
|
99
108
|
|
|
100
109
|
repos.each do |repo_path|
|
|
101
110
|
repo_name = File.basename( repo_path )
|
|
102
111
|
unless Dir.exist?( repo_path )
|
|
103
112
|
puts_line "#{repo_name}: FAIL (path not found)"
|
|
113
|
+
record_batch_skip( command: "refresh", repo_path: repo_path, reason: "path not found" )
|
|
104
114
|
failed += 1
|
|
105
115
|
next
|
|
106
116
|
end
|
|
107
117
|
|
|
108
118
|
safety = portfolio_repo_safety( repo_path: repo_path )
|
|
109
119
|
unless safety.fetch( :safe )
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
reason = safety.fetch( :reasons ).join( ", " )
|
|
121
|
+
puts_line "#{repo_name}: PENDING (#{reason})"
|
|
122
|
+
record_batch_skip( command: "refresh", repo_path: repo_path, reason: reason )
|
|
123
|
+
pending += 1
|
|
112
124
|
next
|
|
113
125
|
end
|
|
114
126
|
|
|
@@ -116,16 +128,17 @@ module Carson
|
|
|
116
128
|
if status == EXIT_ERROR
|
|
117
129
|
failed += 1
|
|
118
130
|
else
|
|
131
|
+
clear_batch_success( command: "refresh", repo_path: repo_path )
|
|
119
132
|
refreshed += 1
|
|
120
133
|
end
|
|
121
134
|
end
|
|
122
135
|
|
|
123
136
|
puts_line ""
|
|
124
137
|
parts = [ "#{refreshed} refreshed" ]
|
|
125
|
-
parts << "#{
|
|
138
|
+
parts << "#{pending} still pending (will retry on next run)" if pending.positive?
|
|
126
139
|
parts << "#{failed} failed" if failed.positive?
|
|
127
140
|
puts_line "Refresh all complete: #{parts.join( ', ' )}."
|
|
128
|
-
failed.zero? &&
|
|
141
|
+
failed.zero? && pending.zero? ? EXIT_OK : EXIT_ERROR
|
|
129
142
|
end
|
|
130
143
|
|
|
131
144
|
def prune_all!
|
|
@@ -145,6 +158,7 @@ module Carson
|
|
|
145
158
|
repo_name = File.basename( repo_path )
|
|
146
159
|
unless Dir.exist?( repo_path )
|
|
147
160
|
puts_line "#{repo_name}: FAIL (path not found)"
|
|
161
|
+
record_batch_skip( command: "prune", repo_path: repo_path, reason: "path not found" )
|
|
148
162
|
failed += 1
|
|
149
163
|
next
|
|
150
164
|
end
|
|
@@ -158,9 +172,16 @@ module Carson
|
|
|
158
172
|
summary = buf.string.lines.last.to_s.strip
|
|
159
173
|
puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
|
|
160
174
|
end
|
|
161
|
-
status == EXIT_ERROR
|
|
175
|
+
if status == EXIT_ERROR
|
|
176
|
+
record_batch_skip( command: "prune", repo_path: repo_path, reason: "prune failed" )
|
|
177
|
+
failed += 1
|
|
178
|
+
else
|
|
179
|
+
clear_batch_success( command: "prune", repo_path: repo_path )
|
|
180
|
+
succeeded += 1
|
|
181
|
+
end
|
|
162
182
|
rescue StandardError => e
|
|
163
183
|
puts_line "#{repo_name}: FAIL (#{e.message})"
|
|
184
|
+
record_batch_skip( command: "prune", repo_path: repo_path, reason: e.message )
|
|
164
185
|
failed += 1
|
|
165
186
|
end
|
|
166
187
|
end
|
|
@@ -227,7 +248,7 @@ module Carson
|
|
|
227
248
|
return hook_status unless hook_status == EXIT_OK
|
|
228
249
|
puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
|
|
229
250
|
|
|
230
|
-
template_drift_count = template_results.count {
|
|
251
|
+
template_drift_count = template_results.count { it.fetch( :status ) != "ok" }
|
|
231
252
|
template_status = with_captured_output { template_apply! }
|
|
232
253
|
return template_status unless template_status == EXIT_OK
|
|
233
254
|
if template_drift_count.positive?
|
|
@@ -60,6 +60,7 @@ module Carson
|
|
|
60
60
|
repo_name = File.basename( repo_path )
|
|
61
61
|
unless Dir.exist?( repo_path )
|
|
62
62
|
puts_line "#{repo_name}: FAIL (path not found)"
|
|
63
|
+
record_batch_skip( command: "sync", repo_path: repo_path, reason: "path not found" )
|
|
63
64
|
failed += 1
|
|
64
65
|
next
|
|
65
66
|
end
|
|
@@ -69,13 +70,16 @@ module Carson
|
|
|
69
70
|
status = rt.sync!
|
|
70
71
|
if status == EXIT_OK
|
|
71
72
|
puts_line "#{repo_name}: ok" unless verbose?
|
|
73
|
+
clear_batch_success( command: "sync", repo_path: repo_path )
|
|
72
74
|
synced += 1
|
|
73
75
|
else
|
|
74
76
|
puts_line "#{repo_name}: FAIL" unless verbose?
|
|
77
|
+
record_batch_skip( command: "sync", repo_path: repo_path, reason: "sync failed" )
|
|
75
78
|
failed += 1
|
|
76
79
|
end
|
|
77
80
|
rescue StandardError => e
|
|
78
81
|
puts_line "#{repo_name}: FAIL (#{e.message})"
|
|
82
|
+
record_batch_skip( command: "sync", repo_path: repo_path, reason: e.message )
|
|
79
83
|
failed += 1
|
|
80
84
|
end
|
|
81
85
|
end
|
|
@@ -18,8 +18,8 @@ module Carson
|
|
|
18
18
|
puts_verbose "[Template Sync Check]"
|
|
19
19
|
results = template_results
|
|
20
20
|
stale = template_superseded_present
|
|
21
|
-
drift_count = results.count {
|
|
22
|
-
error_count = results.count {
|
|
21
|
+
drift_count = results.count { it.fetch( :status ) == "drift" }
|
|
22
|
+
error_count = results.count { it.fetch( :status ) == "error" }
|
|
23
23
|
stale_count = stale.count
|
|
24
24
|
results.each do |entry|
|
|
25
25
|
puts_verbose "template_file: #{entry.fetch( :file )} status=#{entry.fetch( :status )} reason=#{entry.fetch( :reason )}"
|
|
@@ -32,7 +32,7 @@ module Carson
|
|
|
32
32
|
summary_parts << "#{drift_count} of #{results.count} drifted" if drift_count.positive?
|
|
33
33
|
summary_parts << "#{stale_count} stale" if stale_count.positive?
|
|
34
34
|
puts_line "Templates: #{summary_parts.join( ", " )}"
|
|
35
|
-
results.select {
|
|
35
|
+
results.select { it.fetch( :status ) == "drift" }.each { |entry| puts_line " #{entry.fetch( :file )}" }
|
|
36
36
|
stale.each { |file| puts_line " #{file} — superseded" }
|
|
37
37
|
else
|
|
38
38
|
puts_line "Templates: #{results.count} files in sync"
|
|
@@ -62,6 +62,7 @@ module Carson
|
|
|
62
62
|
repo_name = File.basename( repo_path )
|
|
63
63
|
unless Dir.exist?( repo_path )
|
|
64
64
|
puts_line "#{repo_name}: FAIL (path not found)"
|
|
65
|
+
record_batch_skip( command: "template_check", repo_path: repo_path, reason: "path not found" )
|
|
65
66
|
failed += 1
|
|
66
67
|
next
|
|
67
68
|
end
|
|
@@ -71,6 +72,7 @@ module Carson
|
|
|
71
72
|
status = rt.template_check!
|
|
72
73
|
if status == EXIT_OK
|
|
73
74
|
puts_line "#{repo_name}: in sync" unless verbose?
|
|
75
|
+
clear_batch_success( command: "template_check", repo_path: repo_path )
|
|
74
76
|
in_sync += 1
|
|
75
77
|
else
|
|
76
78
|
puts_line "#{repo_name}: DRIFT" unless verbose?
|
|
@@ -78,6 +80,7 @@ module Carson
|
|
|
78
80
|
end
|
|
79
81
|
rescue StandardError => e
|
|
80
82
|
puts_line "#{repo_name}: FAIL (#{e.message})"
|
|
83
|
+
record_batch_skip( command: "template_check", repo_path: repo_path, reason: e.message )
|
|
81
84
|
failed += 1
|
|
82
85
|
end
|
|
83
86
|
end
|
|
@@ -124,7 +127,7 @@ module Carson
|
|
|
124
127
|
removed += 1
|
|
125
128
|
end
|
|
126
129
|
|
|
127
|
-
error_count = results.count {
|
|
130
|
+
error_count = results.count { it.fetch( :status ) == "error" }
|
|
128
131
|
puts_verbose "template_apply_summary: updated=#{applied} removed=#{removed} error=#{error_count}"
|
|
129
132
|
unless verbose?
|
|
130
133
|
if applied.positive? || removed.positive?
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Safe worktree lifecycle management for coding agents.
|
|
2
2
|
# Two operations: create and remove. Create auto-syncs main before branching.
|
|
3
|
-
# Remove is safe by default — guards against CWD-inside-worktree
|
|
4
|
-
# commits. Content-aware: allows removal after
|
|
3
|
+
# Remove is safe by default — guards against CWD-inside-worktree, cross-process
|
|
4
|
+
# CWD holds, and unpushed commits. Content-aware: allows removal after
|
|
5
|
+
# squash/rebase merge without --force.
|
|
5
6
|
# Supports --json for machine-readable structured output with recovery commands.
|
|
6
7
|
module Carson
|
|
7
8
|
class Runtime
|
|
@@ -78,12 +79,12 @@ module Carson
|
|
|
78
79
|
resolved_path = resolve_worktree_path( worktree_path: worktree_path )
|
|
79
80
|
|
|
80
81
|
# Missing directory: worktree was destroyed externally (e.g. gh pr merge
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
# --delete-branch). Clean up the stale git registration and delete the branch.
|
|
83
|
+
if !Dir.exist?( resolved_path ) && worktree_registered?( path: resolved_path )
|
|
84
|
+
return worktree_remove_missing!( resolved_path: resolved_path, json_output: json_output )
|
|
85
|
+
end
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
unless worktree_registered?( path: resolved_path )
|
|
87
88
|
return worktree_finish(
|
|
88
89
|
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
89
90
|
error: "#{resolved_path} is not a registered worktree",
|
|
@@ -104,6 +105,18 @@ module Carson
|
|
|
104
105
|
)
|
|
105
106
|
end
|
|
106
107
|
|
|
108
|
+
# Safety: refuse if another process has its CWD inside the worktree.
|
|
109
|
+
# Protects against cross-process CWD crashes (e.g. an agent session
|
|
110
|
+
# removed by a separate cleanup process while the agent's shell is inside).
|
|
111
|
+
if worktree_held_by_other_process?( worktree_path: resolved_path )
|
|
112
|
+
return worktree_finish(
|
|
113
|
+
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
114
|
+
error: "another process has its working directory inside this worktree",
|
|
115
|
+
recovery: "wait for the other session to finish, then retry" },
|
|
116
|
+
exit_code: EXIT_BLOCK, json_output: json_output
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
107
120
|
branch = worktree_branch( path: resolved_path )
|
|
108
121
|
puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
|
|
109
122
|
|
|
@@ -195,6 +208,7 @@ module Carson
|
|
|
195
208
|
next unless branch
|
|
196
209
|
next unless agent_prefixes.any? { |prefix| path.start_with?( prefix ) }
|
|
197
210
|
next if cwd_inside_worktree?( worktree_path: path )
|
|
211
|
+
next if worktree_held_by_other_process?( worktree_path: path )
|
|
198
212
|
next unless branch_absorbed_into_main?( branch: branch )
|
|
199
213
|
|
|
200
214
|
# Remove the worktree (no --force: refuses if dirty working tree).
|
|
@@ -303,6 +317,35 @@ module Carson
|
|
|
303
317
|
false
|
|
304
318
|
end
|
|
305
319
|
|
|
320
|
+
# Checks whether any other process has its working directory inside the worktree.
|
|
321
|
+
# Uses lsof to query CWD file descriptors system-wide, then matches against
|
|
322
|
+
# the worktree path. Catches the cross-process CWD crash scenario: a cleanup
|
|
323
|
+
# process removing a worktree while another session's shell is still inside it.
|
|
324
|
+
# Fails safe: returns false if lsof is unavailable or any error occurs.
|
|
325
|
+
def worktree_held_by_other_process?( worktree_path: )
|
|
326
|
+
canonical = realpath_safe( worktree_path )
|
|
327
|
+
return false if canonical.nil? || canonical.empty?
|
|
328
|
+
return false unless Dir.exist?( canonical )
|
|
329
|
+
|
|
330
|
+
stdout, _, status = Open3.capture3( "lsof", "-d", "cwd" )
|
|
331
|
+
return false unless status.success?
|
|
332
|
+
|
|
333
|
+
normalised = File.join( canonical, "" )
|
|
334
|
+
my_pid = Process.pid
|
|
335
|
+
stdout.lines.drop( 1 ).any? do |line|
|
|
336
|
+
fields = line.strip.split( /\s+/ )
|
|
337
|
+
next false unless fields.length >= 9
|
|
338
|
+
next false if fields[ 1 ].to_i == my_pid
|
|
339
|
+
name = fields[ 8.. ].join( " " )
|
|
340
|
+
name == canonical || name.start_with?( normalised )
|
|
341
|
+
end
|
|
342
|
+
rescue Errno::ENOENT
|
|
343
|
+
# lsof not installed.
|
|
344
|
+
false
|
|
345
|
+
rescue StandardError
|
|
346
|
+
false
|
|
347
|
+
end
|
|
348
|
+
|
|
306
349
|
# Checks whether a branch has unpushed commits that would be lost on removal.
|
|
307
350
|
# Content-aware: after squash/rebase merge, SHAs differ but tree content may match main. Compares content, not SHAs.
|
|
308
351
|
# Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Review gate logic: snapshot convergence, disposition acknowledgements, and merge-readiness checks.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Review
|
|
@@ -30,7 +31,7 @@ module Carson
|
|
|
30
31
|
unresolved_threads = unresolved_thread_entries( details: details )
|
|
31
32
|
actionable_top_level = actionable_top_level_items( details: details, pr_author: pr_author )
|
|
32
33
|
acknowledgements = disposition_acknowledgements( details: details, pr_author: pr_author )
|
|
33
|
-
unacknowledged_actionable = actionable_top_level.reject {
|
|
34
|
+
unacknowledged_actionable = actionable_top_level.reject { acknowledged_by_disposition?( item: it, acknowledgements: acknowledgements ) }
|
|
34
35
|
{
|
|
35
36
|
latest_activity: latest_review_activity( details: details ),
|
|
36
37
|
unresolved_threads: unresolved_threads,
|
|
@@ -44,8 +45,8 @@ module Carson
|
|
|
44
45
|
def review_gate_signature( snapshot: )
|
|
45
46
|
{
|
|
46
47
|
latest_activity: snapshot.fetch( :latest_activity ).to_s,
|
|
47
|
-
unresolved_urls: snapshot.fetch( :unresolved_threads ).map {
|
|
48
|
-
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map {
|
|
48
|
+
unresolved_urls: snapshot.fetch( :unresolved_threads ).map { it.fetch( :url ) }.sort,
|
|
49
|
+
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map { it.fetch( :url ) }.sort
|
|
49
50
|
}
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -67,7 +68,7 @@ module Carson
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def bot_username?( author: )
|
|
70
|
-
config.review_bot_usernames.any? {
|
|
71
|
+
config.review_bot_usernames.any? { it.downcase == author.to_s.downcase }
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def unresolved_thread_entries( details: )
|
|
@@ -78,7 +79,7 @@ module Carson
|
|
|
78
79
|
comments = thread.fetch( :comments )
|
|
79
80
|
first_comment = comments.first || {}
|
|
80
81
|
next if bot_username?( author: first_comment.fetch( :author, "" ) )
|
|
81
|
-
latest_time = comments.map {
|
|
82
|
+
latest_time = comments.map { it.fetch( :created_at ) }.max.to_s
|
|
82
83
|
{
|
|
83
84
|
url: blank_to( value: first_comment.fetch( :url, "" ), default: "#{details.fetch( :url )}#thread-#{index + 1}" ),
|
|
84
85
|
author: first_comment.fetch( :author, "" ),
|
|
@@ -130,7 +131,7 @@ module Carson
|
|
|
130
131
|
sources = []
|
|
131
132
|
sources.concat( Array( details.fetch( :comments ) ) )
|
|
132
133
|
sources.concat( Array( details.fetch( :reviews ) ) )
|
|
133
|
-
sources.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
134
|
+
sources.concat( Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) } )
|
|
134
135
|
sources.map do |entry|
|
|
135
136
|
next unless entry.fetch( :author, "" ) == pr_author
|
|
136
137
|
body = entry.fetch( :body, "" ).to_s
|
|
@@ -151,7 +152,7 @@ module Carson
|
|
|
151
152
|
# True when any disposition acknowledgement references the specific finding URL.
|
|
152
153
|
def acknowledged_by_disposition?( item:, acknowledgements: )
|
|
153
154
|
acknowledgements.any? do |ack|
|
|
154
|
-
Array( ack.fetch( :target_urls ) ).any? {
|
|
155
|
+
Array( ack.fetch( :target_urls ) ).any? { it == item.fetch( :url ) }
|
|
155
156
|
end
|
|
156
157
|
end
|
|
157
158
|
|
|
@@ -159,10 +160,10 @@ module Carson
|
|
|
159
160
|
def latest_review_activity( details: )
|
|
160
161
|
timestamps = []
|
|
161
162
|
timestamps << details.fetch( :updated_at )
|
|
162
|
-
timestamps.concat( Array( details.fetch( :comments ) ).map {
|
|
163
|
-
timestamps.concat( Array( details.fetch( :reviews ) ).map {
|
|
164
|
-
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
165
|
-
timestamps.map {
|
|
163
|
+
timestamps.concat( Array( details.fetch( :comments ) ).map { it.fetch( :created_at ) } )
|
|
164
|
+
timestamps.concat( Array( details.fetch( :reviews ) ).map { it.fetch( :created_at ) } )
|
|
165
|
+
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) }.map { it.fetch( :created_at ) } )
|
|
166
|
+
timestamps.map { parse_time_or_nil( text: it ) }.compact.max&.utc&.iso8601
|
|
166
167
|
end
|
|
167
168
|
|
|
168
169
|
# Writes review gate artefacts using fixed report names in global report output.
|
|
@@ -208,7 +209,7 @@ module Carson
|
|
|
208
209
|
if report.fetch( :block_reasons ).empty?
|
|
209
210
|
lines << "- none"
|
|
210
211
|
else
|
|
211
|
-
report.fetch( :block_reasons ).each {
|
|
212
|
+
report.fetch( :block_reasons ).each { lines << "- #{it}" }
|
|
212
213
|
end
|
|
213
214
|
lines << ""
|
|
214
215
|
lines << "## Unresolved Threads"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Review sweep: late-event detection, tracking issue management, and sweep reporting.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Review
|
|
@@ -59,7 +60,7 @@ module Carson
|
|
|
59
60
|
)
|
|
60
61
|
end
|
|
61
62
|
|
|
62
|
-
Array( details.fetch( :review_threads ) ).flat_map {
|
|
63
|
+
Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) }.each do |comment|
|
|
63
64
|
next if comment.fetch( :author ) == pr_author
|
|
64
65
|
next if bot_username?( author: comment.fetch( :author ) )
|
|
65
66
|
hits = matched_risk_keywords( text: comment.fetch( :body ) )
|
|
@@ -152,7 +153,7 @@ module Carson
|
|
|
152
153
|
stdout_text, stderr_text, success, = gh_run( "issue", "list", "--repo", repo_slug, "--state", "all", "--limit", "100", "--json", "number,title,state,url,labels" )
|
|
153
154
|
raise gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: "unable to list issues for review sweep" ) unless success
|
|
154
155
|
issues = Array( JSON.parse( stdout_text ) )
|
|
155
|
-
node = issues.find {
|
|
156
|
+
node = issues.find { it[ "title" ].to_s == config.review_tracking_issue_title }
|
|
156
157
|
return nil if node.nil?
|
|
157
158
|
{
|
|
158
159
|
number: node[ "number" ],
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Review utilities: risk keyword matching, disposition parsing, URL extraction, and deduplication.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Review
|
|
@@ -24,7 +25,7 @@ module Carson
|
|
|
24
25
|
|
|
25
26
|
# GitHub URL extraction for mapping disposition acknowledgements to finding URLs.
|
|
26
27
|
def extract_github_urls( text: )
|
|
27
|
-
text.to_s.scan( %r{https://github\.com/[^\s\)\]]+} ).map {
|
|
28
|
+
text.to_s.scan( %r{https://github\.com/[^\s\)\]]+} ).map { it.sub( /[.,;:]+$/, "" ) }.uniq
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
# Parse RFC3339 timestamps and return nil on blank/invalid values.
|
data/lib/carson/runtime/setup.rb
CHANGED
|
@@ -137,7 +137,7 @@ module Carson
|
|
|
137
137
|
{ label: "branch — enforce PR-only merges (default)", value: "branch" },
|
|
138
138
|
{ label: "trunk — commit directly to main", value: "trunk" }
|
|
139
139
|
]
|
|
140
|
-
default_index = options.index {
|
|
140
|
+
default_index = options.index { it.fetch( :value ) == current } || 0
|
|
141
141
|
prompt_choice( options: options, default: default_index )
|
|
142
142
|
end
|
|
143
143
|
|
|
@@ -151,7 +151,7 @@ module Carson
|
|
|
151
151
|
{ label: "rebase — linear history, individual commits", value: "rebase" },
|
|
152
152
|
{ label: "merge — merge commits", value: "merge" }
|
|
153
153
|
]
|
|
154
|
-
default_index = options.index {
|
|
154
|
+
default_index = options.index { it.fetch( :value ) == current } || 0
|
|
155
155
|
prompt_choice( options: options, default: default_index )
|
|
156
156
|
end
|
|
157
157
|
|
|
@@ -219,7 +219,7 @@ module Carson
|
|
|
219
219
|
others << entry
|
|
220
220
|
end
|
|
221
221
|
end
|
|
222
|
-
well_known.sort_by {
|
|
222
|
+
well_known.sort_by { WELL_KNOWN_REMOTES.index( it.fetch( :name ) ) || 999 } + others.sort_by { it.fetch( :name ) }
|
|
223
223
|
end
|
|
224
224
|
|
|
225
225
|
# Normalises a remote URL so SSH and HTTPS variants of the same host/path compare equal.
|
|
@@ -295,7 +295,7 @@ module Carson
|
|
|
295
295
|
|
|
296
296
|
def detect_git_remote
|
|
297
297
|
remotes = list_git_remotes
|
|
298
|
-
remote_names = remotes.map {
|
|
298
|
+
remote_names = remotes.map { it.fetch( :name ) }
|
|
299
299
|
return nil if remote_names.empty?
|
|
300
300
|
|
|
301
301
|
return config.git_remote if remote_names.include?( config.git_remote )
|
|
@@ -49,6 +49,7 @@ module Carson
|
|
|
49
49
|
puts_line "Carson #{Carson::VERSION} — Portfolio (#{repos.length} repo#{plural_suffix( count: repos.length )})"
|
|
50
50
|
puts_line ""
|
|
51
51
|
|
|
52
|
+
all_pending = load_batch_pending
|
|
52
53
|
repos.each do |repo_path|
|
|
53
54
|
repo_name = File.basename( repo_path )
|
|
54
55
|
unless Dir.exist?( repo_path )
|
|
@@ -68,6 +69,10 @@ module Carson
|
|
|
68
69
|
parts << "#{worktrees.count} worktree#{plural_suffix( count: worktrees.count )}" if worktrees.any?
|
|
69
70
|
parts << "templates #{gov.fetch( :templates )}" unless gov.fetch( :templates ) == :in_sync
|
|
70
71
|
puts_line "#{repo_name}: #{parts.join( ' ' )}"
|
|
72
|
+
|
|
73
|
+
# Show pending operations for this repo.
|
|
74
|
+
repo_pending = status_pending_for_repo( all_pending: all_pending, repo_path: repo_path )
|
|
75
|
+
repo_pending.each { |desc| puts_line " pending: #{desc}" }
|
|
71
76
|
rescue StandardError => e
|
|
72
77
|
puts_line "#{repo_name}: FAIL (#{e.message})"
|
|
73
78
|
end
|
|
@@ -78,6 +83,21 @@ module Carson
|
|
|
78
83
|
|
|
79
84
|
private
|
|
80
85
|
|
|
86
|
+
# Returns an array of human-readable pending descriptions for a repo.
|
|
87
|
+
def status_pending_for_repo( all_pending:, repo_path: )
|
|
88
|
+
descriptions = []
|
|
89
|
+
all_pending.each do |command, repos|
|
|
90
|
+
next unless repos.is_a?( Hash ) && repos.key?( repo_path )
|
|
91
|
+
|
|
92
|
+
info = repos[ repo_path ]
|
|
93
|
+
attempts = info.fetch( "attempts", 0 )
|
|
94
|
+
skipped_at = info.fetch( "skipped_at", nil )
|
|
95
|
+
time_part = skipped_at ? ", since #{skipped_at[ 11..15 ]}" : ""
|
|
96
|
+
descriptions << "#{command} (#{attempts} attempt#{attempts == 1 ? '' : 's'}#{time_part})"
|
|
97
|
+
end
|
|
98
|
+
descriptions
|
|
99
|
+
end
|
|
100
|
+
|
|
81
101
|
# Collects all status facets into a structured hash.
|
|
82
102
|
def gather_status
|
|
83
103
|
data = {
|
|
@@ -129,15 +149,10 @@ module Carson
|
|
|
129
149
|
ahead = parts[ 0 ].to_i
|
|
130
150
|
behind = parts[ 1 ].to_i
|
|
131
151
|
|
|
132
|
-
if ahead.zero? && behind.zero?
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
elsif ahead.zero? && behind.positive?
|
|
137
|
-
:behind
|
|
138
|
-
else
|
|
139
|
-
:diverged
|
|
140
|
-
end
|
|
152
|
+
return :in_sync if ahead.zero? && behind.zero?
|
|
153
|
+
return :ahead if behind.zero?
|
|
154
|
+
return :behind if ahead.zero?
|
|
155
|
+
:diverged
|
|
141
156
|
end
|
|
142
157
|
|
|
143
158
|
# Lists all worktrees with branch name.
|
|
@@ -147,7 +162,7 @@ module Carson
|
|
|
147
162
|
# Filter out the main worktree (the repository root itself).
|
|
148
163
|
# Use realpath for comparison — git returns canonical paths that may differ from repo_root.
|
|
149
164
|
canonical_root = realpath_safe( repo_root )
|
|
150
|
-
entries.reject {
|
|
165
|
+
entries.reject { it.fetch( :path ) == canonical_root }.map do |wt|
|
|
151
166
|
{
|
|
152
167
|
path: wt.fetch( :path ),
|
|
153
168
|
name: File.basename( wt.fetch( :path ) ),
|
|
@@ -185,9 +200,9 @@ module Carson
|
|
|
185
200
|
entries = Array( rollup )
|
|
186
201
|
return :none if entries.empty?
|
|
187
202
|
|
|
188
|
-
states = entries.map {
|
|
189
|
-
return :fail if states.any? {
|
|
190
|
-
return :pending if states.any? {
|
|
203
|
+
states = entries.map { it[ "conclusion" ].to_s.upcase }
|
|
204
|
+
return :fail if states.any? { it == "FAILURE" || it == "CANCELLED" || it == "TIMED_OUT" }
|
|
205
|
+
return :pending if states.any? { it == "" || it == "PENDING" || it == "QUEUED" || it == "IN_PROGRESS" }
|
|
191
206
|
|
|
192
207
|
:pass
|
|
193
208
|
end
|
data/lib/carson/runtime.rb
CHANGED
|
@@ -213,6 +213,70 @@ module Carson
|
|
|
213
213
|
github_adapter.run( *args )
|
|
214
214
|
end
|
|
215
215
|
|
|
216
|
+
# --- Batch pending tracking (shared by all --all commands) ---
|
|
217
|
+
|
|
218
|
+
# Path to the persistent pending log for batch operations.
|
|
219
|
+
def batch_pending_path
|
|
220
|
+
File.join( report_dir_path, "batch_pending.json" )
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Reads and parses the pending log. Returns empty hash if missing or corrupt.
|
|
224
|
+
def load_batch_pending
|
|
225
|
+
path = batch_pending_path
|
|
226
|
+
return {} unless File.file?( path )
|
|
227
|
+
|
|
228
|
+
JSON.parse( File.read( path ) )
|
|
229
|
+
rescue StandardError
|
|
230
|
+
{}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Writes the pending log atomically.
|
|
234
|
+
def save_batch_pending( data )
|
|
235
|
+
path = batch_pending_path
|
|
236
|
+
FileUtils.mkdir_p( File.dirname( path ) )
|
|
237
|
+
tmp = "#{path}.tmp"
|
|
238
|
+
File.write( tmp, JSON.pretty_generate( data ) )
|
|
239
|
+
File.rename( tmp, path )
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Adds or updates an entry in the pending log, incrementing attempts.
|
|
243
|
+
def record_batch_skip( command:, repo_path:, reason: )
|
|
244
|
+
data = load_batch_pending
|
|
245
|
+
data[ command ] ||= {}
|
|
246
|
+
existing = data[ command ][ repo_path ]
|
|
247
|
+
attempts = existing ? existing.fetch( "attempts", 0 ) + 1 : 1
|
|
248
|
+
data[ command ][ repo_path ] = {
|
|
249
|
+
"skipped_at" => Time.now.utc.iso8601,
|
|
250
|
+
"reason" => reason,
|
|
251
|
+
"attempts" => attempts
|
|
252
|
+
}
|
|
253
|
+
save_batch_pending( data )
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Removes an entry from the pending log after successful completion.
|
|
257
|
+
def clear_batch_success( command:, repo_path: )
|
|
258
|
+
data = load_batch_pending
|
|
259
|
+
return unless data.key?( command )
|
|
260
|
+
|
|
261
|
+
data[ command ].delete( repo_path )
|
|
262
|
+
data.delete( command ) if data[ command ].empty?
|
|
263
|
+
save_batch_pending( data )
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Returns array of pending repo info hashes for a command.
|
|
267
|
+
def pending_repos_for( command: )
|
|
268
|
+
data = load_batch_pending
|
|
269
|
+
entries = data.fetch( command, {} )
|
|
270
|
+
entries.map do |path, info|
|
|
271
|
+
{
|
|
272
|
+
path: path,
|
|
273
|
+
reason: info.fetch( "reason", "unknown" ),
|
|
274
|
+
skipped_at: info.fetch( "skipped_at", nil ),
|
|
275
|
+
attempts: info.fetch( "attempts", 0 )
|
|
276
|
+
}
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
216
280
|
# --- Portfolio helpers (shared by all --all commands) ---
|
|
217
281
|
|
|
218
282
|
# Checks whether a governed repo is safe for batch operations.
|