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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c18ffa310a6fdb0b0e1581eba83b9ab7dcb8c88a546c391f83200871e17d7f6
4
- data.tar.gz: 77c0d6e97438a1f9512e3dbef51a22981caf1b6b0126ffe8ab3bdbf3c7ec3bbb
3
+ metadata.gz: 0e7fdc44d330fd415b8f6d9ee479d5256d6fc860e5d63c7c9287f28ebbdb2ba4
4
+ data.tar.gz: 19056bd376bcab3b7077e38ba4e42f2b9f5a3ffbf2c8027f0aa6626bb70587fb
5
5
  SHA512:
6
- metadata.gz: 5ac7c43bd96d94c1013c5ade599bd906dbda305b3c0216c184093b62292de30c93d7b8f7bd0e090ffe13fd272e97345d6adb30b321df3188f48b2e5ef50913d6
7
- data.tar.gz: 00da3a36874c8dca3ade2736e2e0d5852018cbe13c7a68cb0af746a59cbcbda51b4f992ebe862a88cf855824d7388c2a3b09b04d5950da385f3bdcf6ff09af12
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.18.0
1
+ 3.20.0
@@ -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 { |e| e.fetch( :name ) }.join( ", " )
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 { |e| e.fetch( :name ) }.join( ", " )
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 { |e| e.fetch( :name ) }.join( ", " )
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 { |e| e.fetch( :name ) }.join( ", " )
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 { |e| e.fetch( :name ) }.join( ", " )
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
- generated_at: Time.now.utc.iso8601,
212
- branch: current_branch,
213
- status: "ok",
214
- skip_reason: nil,
215
- pr: nil,
216
- checks: {
217
- status: "unknown",
218
- skip_reason: nil,
219
- required_total: 0,
220
- failing_count: 0,
221
- pending_count: 0,
222
- failing: [],
223
- pending: []
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
- number: pr_data[ "number" ],
243
- title: pr_data[ "title" ].to_s,
244
- url: pr_data[ "url" ].to_s,
245
- state: pr_data[ "state" ].to_s,
246
- review_decision: blank_to( value: pr_data[ "reviewDecision" ], default: "NONE" )
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 { |entry| entry[ "bucket" ].to_s == "pending" }
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
- status: "ok",
287
- skip_reason: nil,
288
- repository: nil,
289
- default_branch: nil,
290
- head_sha: nil,
291
- workflows_total: 0,
292
- check_runs_total: 0,
293
- failing_count: 0,
294
- pending_count: 0,
295
- advisory_failing_count: 0,
296
- advisory_pending_count: 0,
297
- no_check_evidence: false,
298
- failing: [],
299
- pending: [],
300
- advisory_failing: [],
301
- advisory_pending: []
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
- "api", "repos/#{owner}/#{repo}",
313
- "--method", "GET",
314
- fallback: "unable to read repository metadata for #{owner}/#{repo}"
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
- "api", "repos/#{owner}/#{repo}/branches/#{CGI.escape( default_branch )}",
320
- "--method", "GET",
321
- fallback: "unable to read default branch #{default_branch}"
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
- owner: owner,
328
- repo: repo,
329
- default_branch: default_branch
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
- "api", "repos/#{owner}/#{repo}/commits/#{head_sha}/check-runs",
334
- "--method", "GET",
335
- fallback: "unable to read check-runs for #{default_branch}@#{head_sha}"
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
- "api", "repos/#{owner}/#{repo}/contents/.github/workflows",
399
- "--method", "GET",
400
- "-f", "ref=#{default_branch}"
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
- stdout_text: stdout_text,
405
- stderr_text: stderr_text,
406
- fallback: "unable to read workflow files for #{default_branch}"
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
- workflow: blank_to( value: entry.dig( "app", "name" ), default: "workflow" ),
474
- name: blank_to( value: entry[ "name" ], default: "check" ),
475
- state: state.upcase,
476
- link: entry[ "html_url" ].to_s
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/ ) { |c| c.upcase }
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
- 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 { |c| c[ "bucket" ].to_s.downcase }
229
- return :fail if buckets.include?( "fail" )
230
- return :pending if buckets.include?( "pending" )
231
-
232
- :pass
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 { |wt| wt.fetch( :path ) != realpath_safe( main_root ) }
302
- .find { |wt| cwd == wt.fetch( :path ) || cwd.start_with?( File.join( wt.fetch( :path ), "" ) ) }
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? { |c| check_state_failing?( state: c[ "state" ].to_s ) || check_conclusion_failing?( conclusion: c[ "conclusion" ].to_s ) }
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? { |c| check_state_pending?( state: c[ "state" ].to_s ) }
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 { |repo_path| results << housekeep_one_entry( repo_path: repo_path, silent: json_output ) }
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 { |r| r[ :status ] == "ok" }
40
- failed = results.count { |r| r[ :status ] != "ok" }
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 { |entry| entry.fetch( :status ) != "ok" }
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 { |entry| entry.fetch( :status ) != "ok" }
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
- # skips unsafe repos to avoid disrupting active work.
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
- skipped = 0
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
- puts_line "#{repo_name}: SKIP (#{safety.fetch( :reasons ).join( ', ' )})"
111
- skipped += 1
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 << "#{skipped} skipped" if skipped.positive?
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? && skipped.zero? ? EXIT_OK : EXIT_ERROR
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 ? ( failed += 1 ) : ( succeeded += 1 )
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 { |entry| entry.fetch( :status ) != "ok" }
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 { |entry| entry.fetch( :status ) == "drift" }
22
- error_count = results.count { |entry| entry.fetch( :status ) == "error" }
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 { |entry| entry.fetch( :status ) == "drift" }.each { |entry| puts_line " #{entry.fetch( :file )}" }
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 { |entry| entry.fetch( :status ) == "error" }
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 and unpushed
4
- # commits. Content-aware: allows removal after squash/rebase merge without --force.
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
- # --delete-branch). Clean up the stale git registration and delete the branch.
82
- if !Dir.exist?( resolved_path ) && worktree_registered?( path: resolved_path )
83
- return worktree_remove_missing!( resolved_path: resolved_path, json_output: json_output )
84
- end
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
- unless worktree_registered?( path: resolved_path )
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
+ # GraphQL data access: PR details, pagination, and normalisation for review gate and sweep.
1
2
  module Carson
2
3
  class Runtime
3
4
  module Review
@@ -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 { |item| acknowledged_by_disposition?( item: item, acknowledgements: acknowledgements ) }
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 { |entry| entry.fetch( :url ) }.sort,
48
- unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map { |entry| entry.fetch( :url ) }.sort
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? { |bot| bot.downcase == author.to_s.downcase }
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 { |entry| entry.fetch( :created_at ) }.max.to_s
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 { |thread| thread.fetch( :comments ) } )
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? { |url| url == item.fetch( :url ) }
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 { |entry| entry.fetch( :created_at ) } )
163
- timestamps.concat( Array( details.fetch( :reviews ) ).map { |entry| entry.fetch( :created_at ) } )
164
- timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map { |thread| thread.fetch( :comments ) }.map { |entry| entry.fetch( :created_at ) } )
165
- timestamps.map { |text| parse_time_or_nil( text: text ) }.compact.max&.utc&.iso8601
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 { |reason| lines << "- #{reason}" }
212
+ report.fetch( :block_reasons ).each { lines << "- #{it}" }
212
213
  end
213
214
  lines << ""
214
215
  lines << "## Unresolved Threads"
@@ -1,3 +1,4 @@
1
+ # GraphQL query templates for pull request review data retrieval.
1
2
  module Carson
2
3
  class Runtime
3
4
  module Review
@@ -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 { |thread| thread.fetch( :comments ) }.each do |comment|
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 { |entry| entry[ "title" ].to_s == config.review_tracking_issue_title }
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 { |value| value.sub( /[.,;:]+$/, "" ) }.uniq
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.
@@ -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 { |o| o.fetch( :value ) == current } || 0
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 { |o| o.fetch( :value ) == current } || 0
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 { |e| WELL_KNOWN_REMOTES.index( e.fetch( :name ) ) || 999 } + others.sort_by { |e| e.fetch( :name ) }
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 { |entry| entry.fetch( :name ) }
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
- :in_sync
134
- elsif ahead.positive? && behind.zero?
135
- :ahead
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 { |wt| wt.fetch( :path ) == canonical_root }.map do |wt|
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 { |c| c[ "conclusion" ].to_s.upcase }
189
- return :fail if states.any? { |s| s == "FAILURE" || s == "CANCELLED" || s == "TIMED_OUT" }
190
- return :pending if states.any? { |s| s == "" || s == "PENDING" || s == "QUEUED" || s == "IN_PROGRESS" }
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
@@ -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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.18.0
4
+ version: 3.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang