carson 3.18.0 → 3.19.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: 36c68c06630159e0dca3560f62993befe8b50dd324fac5a6f8232895b2a667bd
4
+ data.tar.gz: 1a4462a8b56b6b7fe505705c30ded47e24a279f4fa775baf56b05ae2e48f450a
5
5
  SHA512:
6
- metadata.gz: 5ac7c43bd96d94c1013c5ade599bd906dbda305b3c0216c184093b62292de30c93d7b8f7bd0e090ffe13fd272e97345d6adb30b321df3188f48b2e5ef50913d6
7
- data.tar.gz: 00da3a36874c8dca3ade2736e2e0d5852018cbe13c7a68cb0af746a59cbcbda51b4f992ebe862a88cf855824d7388c2a3b09b04d5950da385f3bdcf6ff09af12
6
+ metadata.gz: 3f644435f5ca88527edc597a7b19134b701bc970748b5ef075f9b1e3c600b3be60724d80c8567bb919cf378a9a5da2dc54fcc27f29b0da811c0f3880c990f016
7
+ data.tar.gz: 12e3651de47f5f741115c58308c2fe344d542bcc94c3f1234e08990b506b6ffa2c518e5f782ea9cb0a86a699111926c6df1f6571724b5ef3d318666889f2a13f
data/RELEASE.md CHANGED
@@ -5,6 +5,17 @@ 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.19.0
9
+
10
+ ### What changed
11
+
12
+ - **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.
13
+ - **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`.
14
+
15
+ ### UX improvement
16
+
17
+ - 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.
18
+
8
19
  ## 3.18.0
9
20
 
10
21
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.18.0
1
+ 3.19.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
@@ -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
@@ -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?
@@ -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.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang