carson 3.17.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: 5474bed385b09305a9a467655447faa7dc5b908486cf69d3124cca99cd7cc2f2
4
- data.tar.gz: 4efa36d52f6642967abc25b35ae5a9bcfcfb97072064478873c99693c90a54cd
3
+ metadata.gz: 36c68c06630159e0dca3560f62993befe8b50dd324fac5a6f8232895b2a667bd
4
+ data.tar.gz: 1a4462a8b56b6b7fe505705c30ded47e24a279f4fa775baf56b05ae2e48f450a
5
5
  SHA512:
6
- metadata.gz: 51a319323b1c96b15085cbdb5380a7b2b27c088cf4bade168e5b64947734f9aec2ddea05e5a359bee75b93eb253ba275280c4cb9959165457b559b022d61bcd8
7
- data.tar.gz: 8665f26fb62718e6b5b6df1addeedee0caa9130c956e7f40f8aba2e79048438dacf4e49a4cd460cbc1c4a90f7ef85a7916531ec4edae3fc345f3864ff1c7c3f9
6
+ metadata.gz: 3f644435f5ca88527edc597a7b19134b701bc970748b5ef075f9b1e3c600b3be60724d80c8567bb919cf378a9a5da2dc54fcc27f29b0da811c0f3880c990f016
7
+ data.tar.gz: 12e3651de47f5f741115c58308c2fe344d542bcc94c3f1234e08990b506b6ffa2c518e5f782ea9cb0a86a699111926c6df1f6571724b5ef3d318666889f2a13f
data/API.md CHANGED
@@ -29,6 +29,21 @@ carson <command> [subcommand] [arguments]
29
29
  | `carson prune` | Remove stale local branches whose upstream refs no longer exist. |
30
30
  | `carson template check` | Detect drift between managed templates and host `.github/*` files. |
31
31
  | `carson template apply` | Write canonical managed template content into host `.github/*` files. |
32
+ | `carson status` | Show repository state (branch, worktrees, PRs, governance). |
33
+
34
+ ### Batch commands (Layer 2)
35
+
36
+ All batch commands operate across every governed repository registered in `govern.repos`.
37
+
38
+ | Command | Purpose |
39
+ |---|---|
40
+ | `carson refresh --all` | Re-apply hooks, templates, and audit across all governed repos. Skips repos with active worktrees or uncommitted changes. |
41
+ | `carson audit --all` | Run governance audit across all governed repos. Reports pass/block/fail per repo. |
42
+ | `carson sync --all` | Sync main branch across all governed repos. |
43
+ | `carson prune --all` | Remove stale branches across all governed repos. |
44
+ | `carson status --all [--json]` | Portfolio-wide status overview with branch, worktrees, and governance state per repo. |
45
+ | `carson template check --all` | Read-only template drift detection across all governed repos. |
46
+ | `carson housekeep --all` | Sync, reap dead worktrees, and prune across all governed repos. |
32
47
 
33
48
  ### Govern commands
34
49
 
@@ -132,7 +147,7 @@ Environment overrides:
132
147
  ```json
133
148
  {
134
149
  "template": {
135
- "canonical": "~/AI/LINT"
150
+ "canonical": "~/AI/CODING/LINT"
136
151
  }
137
152
  }
138
153
  ```
data/MANUAL.md CHANGED
@@ -89,7 +89,7 @@ Set `template.canonical` in `~/.carson/config.json`:
89
89
  ```json
90
90
  {
91
91
  "template": {
92
- "canonical": "~/AI/LINT"
92
+ "canonical": "~/AI/CODING/LINT"
93
93
  }
94
94
  }
95
95
  ```
@@ -97,7 +97,7 @@ Set `template.canonical` in `~/.carson/config.json`:
97
97
  That directory mirrors the `.github/` structure:
98
98
 
99
99
  ```
100
- ~/AI/LINT/
100
+ ~/AI/CODING/LINT/
101
101
  ├── workflows/
102
102
  │ └── lint.yml → deployed to .github/workflows/lint.yml
103
103
  ├── .mega-linter.yml → deployed to .github/.mega-linter.yml
@@ -207,8 +207,24 @@ carson review gate
207
207
  ```bash
208
208
  carson repos # list all governed repositories
209
209
  carson repos --json # machine-readable output
210
+ carson status --all # branch, worktrees, governance per repo
210
211
  ```
211
212
 
213
+ **Portfolio maintenance (Layer 2):**
214
+
215
+ All `--all` commands run across every governed repository registered via `carson onboard`.
216
+
217
+ ```bash
218
+ carson refresh --all # re-apply hooks, templates, audit across all repos
219
+ carson sync --all # fast-forward main across all repos
220
+ carson audit --all # governance audit across all repos
221
+ carson prune --all # remove stale branches across all repos
222
+ carson template check --all # detect template drift across all repos
223
+ carson housekeep --all # full maintenance cycle across all repos
224
+ ```
225
+
226
+ `refresh --all` checks each repo for safety before operating: repos with active worktrees or uncommitted changes are skipped with clear reasons. Other batch commands attempt each repo and report failures without stopping.
227
+
212
228
  **Periodic maintenance:**
213
229
 
214
230
  ```bash
data/RELEASE.md CHANGED
@@ -5,6 +5,36 @@ 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
+
19
+ ## 3.18.0
20
+
21
+ ### What changed
22
+
23
+ - **Layer 2 batch operations** — every command that operates on a single repo now has an `--all` variant that runs across the entire governed portfolio:
24
+ - `carson refresh --all` — refresh hooks, templates, and audit across all repos. Checks each repo for safety (active worktrees, uncommitted changes) and skips unsafe repos.
25
+ - `carson prune --all` — prune stale branches across all repos.
26
+ - `carson audit --all` — run governance audit across all repos. Reports pass/block/fail per repo.
27
+ - `carson sync --all` — sync main branch across all repos.
28
+ - `carson status --all` — portfolio-wide status overview with branch, worktrees, and governance state per repo. Supports `--json`.
29
+ - `carson template check --all` — read-only template drift detection across all repos.
30
+ - **Portfolio safety checks** — before batch `refresh`, Carson checks each repo for active worktrees and uncommitted changes. Unsafe repos are skipped with reasons, not failed.
31
+ - **Shared `portfolio_repo_safety` helper** — centralised safety check in `Runtime` for all batch operations. Non-git directories pass through (the command reports the real error).
32
+ - **CLI `--all` flag registration** — `audit`, `sync`, and `status` parsers now correctly register the `--all` flag with OptionParser (previously the flag was checked but never registered).
33
+
34
+ ### UX improvement
35
+
36
+ - A user or agent managing multiple repositories can now run a single command to maintain the entire portfolio. `carson status --all` gives a quick overview; `carson refresh --all` keeps everything in sync; `carson housekeep --all` runs the full maintenance cycle. Unsafe repos are clearly reported with skip reasons instead of cryptic failures.
37
+
8
38
  ## 3.17.0
9
39
 
10
40
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.17.0
1
+ 3.19.0
data/lib/carson/cli.rb CHANGED
@@ -12,7 +12,7 @@ module Carson
12
12
  return Runtime::EXIT_OK
13
13
  end
14
14
 
15
- if %w[repos refresh:all prune:all housekeep:all housekeep:target].include?( command )
15
+ if %w[repos refresh:all prune:all housekeep:all housekeep:target template:check:all audit:all sync:all status:all].include?( command )
16
16
  verbose = parsed.fetch( :verbose, false )
17
17
  runtime = Runtime.new( repo_root: repo_root, tool_root: tool_root, out: out, err: err, verbose: verbose )
18
18
  return dispatch( parsed: parsed, runtime: runtime )
@@ -409,6 +409,7 @@ module Carson
409
409
  end
410
410
 
411
411
  action = argv.shift
412
+ return { command: "template:check:all" } if action == "check" && argv.include?( "--all" )
412
413
  return { command: "template:#{action}" } unless action == "apply"
413
414
 
414
415
  options = { push_prep: false }
@@ -438,26 +439,29 @@ module Carson
438
439
  # --- audit ---
439
440
 
440
441
  def self.parse_audit_command( argv:, err: )
441
- options = { json: false }
442
+ options = { json: false, all: false }
442
443
  audit_parser = OptionParser.new do |opts|
443
- opts.banner = "Usage: carson audit [--json]"
444
+ opts.banner = "Usage: carson audit [--all] [--json]"
444
445
  opts.separator ""
445
446
  opts.separator "Run pre-commit health checks on the repository."
446
447
  opts.separator "Validates hooks, main-branch sync, PR status, and CI baseline."
447
448
  opts.separator "Exits with a non-zero status when policy violations are found."
448
449
  opts.separator ""
449
450
  opts.separator "Options:"
451
+ opts.on( "--all", "Audit all governed repositories" ) { options[ :all ] = true }
450
452
  opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
451
453
  opts.separator ""
452
454
  opts.separator "Examples:"
453
455
  opts.separator " carson audit Check repository health (also the default command)"
454
456
  opts.separator " carson audit --json Structured output for agent consumption"
457
+ opts.separator " carson audit --all Audit all governed repos"
455
458
  end
456
459
  audit_parser.parse!( argv )
457
460
  unless argv.empty?
458
461
  err.puts "#{BADGE} Unexpected arguments for audit: #{argv.join( ' ' )}"
459
462
  return { command: :invalid }
460
463
  end
464
+ return { command: "audit:all" } if options[ :all ]
461
465
  { command: "audit", json: options[ :json ] }
462
466
  rescue OptionParser::ParseError => e
463
467
  err.puts "#{BADGE} #{e.message}"
@@ -467,25 +471,28 @@ module Carson
467
471
  # --- sync ---
468
472
 
469
473
  def self.parse_sync_command( argv:, err: )
470
- options = { json: false }
474
+ options = { json: false, all: false }
471
475
  sync_parser = OptionParser.new do |opts|
472
- opts.banner = "Usage: carson sync [--json]"
476
+ opts.banner = "Usage: carson sync [--all] [--json]"
473
477
  opts.separator ""
474
478
  opts.separator "Sync the local main branch with the remote."
475
479
  opts.separator "Fetches and fast-forwards main without switching branches."
476
480
  opts.separator ""
477
481
  opts.separator "Options:"
482
+ opts.on( "--all", "Sync all governed repositories" ) { options[ :all ] = true }
478
483
  opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
479
484
  opts.separator ""
480
485
  opts.separator "Examples:"
481
486
  opts.separator " carson sync Pull latest changes from remote main"
482
487
  opts.separator " carson sync --json Structured output for agent consumption"
488
+ opts.separator " carson sync --all Sync all governed repos"
483
489
  end
484
490
  sync_parser.parse!( argv )
485
491
  unless argv.empty?
486
492
  err.puts "#{BADGE} Unexpected arguments for sync: #{argv.join( ' ' )}"
487
493
  return { command: :invalid }
488
494
  end
495
+ return { command: "sync:all" } if options[ :all ]
489
496
  { command: "sync", json: options[ :json ] }
490
497
  rescue OptionParser::ParseError => e
491
498
  err.puts "#{BADGE} #{e.message}"
@@ -495,25 +502,28 @@ module Carson
495
502
  # --- status ---
496
503
 
497
504
  def self.parse_status_command( argv:, err: )
498
- options = { json: false }
505
+ options = { json: false, all: false }
499
506
  status_parser = OptionParser.new do |opts|
500
- opts.banner = "Usage: carson status [--json]"
507
+ opts.banner = "Usage: carson status [--all] [--json]"
501
508
  opts.separator ""
502
509
  opts.separator "Show the current state of the repository."
503
510
  opts.separator "Reports branch, worktrees, open PRs, stale branches, and version."
504
511
  opts.separator ""
505
512
  opts.separator "Options:"
513
+ opts.on( "--all", "Show status for all governed repositories" ) { options[ :all ] = true }
506
514
  opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
507
515
  opts.separator ""
508
516
  opts.separator "Examples:"
509
517
  opts.separator " carson status Quick overview of repository state"
510
518
  opts.separator " carson status --json Structured output for agent consumption"
519
+ opts.separator " carson status --all Portfolio-wide status overview"
511
520
  end
512
521
  status_parser.parse!( argv )
513
522
  unless argv.empty?
514
523
  err.puts "#{BADGE} Unexpected arguments for status: #{argv.join( ' ' )}"
515
524
  return { command: :invalid }
516
525
  end
526
+ return { command: "status:all", json: options[ :json ] } if options[ :all ]
517
527
  { command: "status", json: options[ :json ] }
518
528
  rescue OptionParser::ParseError => e
519
529
  err.puts "#{BADGE} #{e.message}"
@@ -734,6 +744,14 @@ module Carson
734
744
  json_output: parsed.fetch( :json, false ),
735
745
  loop_seconds: parsed.fetch( :loop_seconds, nil )
736
746
  )
747
+ when "template:check:all"
748
+ runtime.template_check_all!
749
+ when "audit:all"
750
+ runtime.audit_all!
751
+ when "sync:all"
752
+ runtime.sync_all!
753
+ when "status:all"
754
+ runtime.status_all!( json_output: parsed.fetch( :json, false ) )
737
755
  else
738
756
  runtime.send( :puts_line, "Unknown command: #{command}" )
739
757
  Runtime::EXIT_ERROR
@@ -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( ', ' )}."
@@ -157,6 +157,58 @@ module Carson
157
157
  exit_code
158
158
  end
159
159
 
160
+ # Runs audit across all governed repositories.
161
+ def audit_all!
162
+ repos = config.govern_repos
163
+ if repos.empty?
164
+ puts_line "No governed repositories configured."
165
+ puts_line " Run carson onboard in each repo to register."
166
+ return EXIT_ERROR
167
+ end
168
+
169
+ puts_line ""
170
+ puts_line "Audit all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
171
+ passed = 0
172
+ blocked = 0
173
+ failed = 0
174
+
175
+ repos.each do |repo_path|
176
+ repo_name = File.basename( repo_path )
177
+ unless Dir.exist?( repo_path )
178
+ puts_line "#{repo_name}: FAIL (path not found)"
179
+ record_batch_skip( command: "audit", repo_path: repo_path, reason: "path not found" )
180
+ failed += 1
181
+ next
182
+ end
183
+
184
+ begin
185
+ rt = build_scoped_runtime( repo_path: repo_path )
186
+ status = rt.audit!
187
+ case status
188
+ when EXIT_OK
189
+ puts_line "#{repo_name}: ok" unless verbose?
190
+ clear_batch_success( command: "audit", repo_path: repo_path )
191
+ passed += 1
192
+ when EXIT_BLOCK
193
+ puts_line "#{repo_name}: BLOCK" unless verbose?
194
+ blocked += 1
195
+ else
196
+ puts_line "#{repo_name}: FAIL" unless verbose?
197
+ record_batch_skip( command: "audit", repo_path: repo_path, reason: "audit failed" )
198
+ failed += 1
199
+ end
200
+ rescue StandardError => e
201
+ puts_line "#{repo_name}: FAIL (#{e.message})"
202
+ record_batch_skip( command: "audit", repo_path: repo_path, reason: e.message )
203
+ failed += 1
204
+ end
205
+ end
206
+
207
+ puts_line ""
208
+ puts_line "Audit all complete: #{passed} ok, #{blocked} blocked, #{failed} failed."
209
+ blocked.zero? && failed.zero? ? EXIT_OK : EXIT_BLOCK
210
+ end
211
+
160
212
  private
161
213
  def pr_and_check_report
162
214
  report = {
@@ -210,7 +262,7 @@ module Carson
210
262
  return report
211
263
  end
212
264
  checks_data = JSON.parse( checks_stdout )
213
- pending = checks_data.select { |entry| entry[ "bucket" ].to_s == "pending" }
265
+ pending = checks_data.select { it[ "bucket" ].to_s == "pending" }
214
266
  failing = checks_data.select { |entry| check_entry_failing?( entry: entry ) }
215
267
  report[ :checks ][ :status ] = checks_success ? "ok" : ( checks_exit == 8 ? "pending" : "attention" )
216
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?
@@ -81,6 +85,8 @@ module Carson
81
85
  end
82
86
 
83
87
  # Re-applies hooks, templates, and audit across all governed repositories.
88
+ # Checks each repo for safety (active worktrees, uncommitted changes) and
89
+ # marks unsafe repos as pending to avoid disrupting active work.
84
90
  def refresh_all!
85
91
  repos = config.govern_repos
86
92
  if repos.empty?
@@ -89,30 +95,50 @@ module Carson
89
95
  return EXIT_ERROR
90
96
  end
91
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
+
92
103
  puts_line ""
93
104
  puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
94
105
  refreshed = 0
106
+ pending = 0
95
107
  failed = 0
96
108
 
97
109
  repos.each do |repo_path|
98
110
  repo_name = File.basename( repo_path )
99
111
  unless Dir.exist?( repo_path )
100
112
  puts_line "#{repo_name}: FAIL (path not found)"
113
+ record_batch_skip( command: "refresh", repo_path: repo_path, reason: "path not found" )
101
114
  failed += 1
102
115
  next
103
116
  end
104
117
 
118
+ safety = portfolio_repo_safety( repo_path: repo_path )
119
+ unless safety.fetch( :safe )
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
124
+ next
125
+ end
126
+
105
127
  status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
106
128
  if status == EXIT_ERROR
107
129
  failed += 1
108
130
  else
131
+ clear_batch_success( command: "refresh", repo_path: repo_path )
109
132
  refreshed += 1
110
133
  end
111
134
  end
112
135
 
113
136
  puts_line ""
114
- puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
115
- failed.zero? ? EXIT_OK : EXIT_ERROR
137
+ parts = [ "#{refreshed} refreshed" ]
138
+ parts << "#{pending} still pending (will retry on next run)" if pending.positive?
139
+ parts << "#{failed} failed" if failed.positive?
140
+ puts_line "Refresh all complete: #{parts.join( ', ' )}."
141
+ failed.zero? && pending.zero? ? EXIT_OK : EXIT_ERROR
116
142
  end
117
143
 
118
144
  def prune_all!
@@ -132,6 +158,7 @@ module Carson
132
158
  repo_name = File.basename( repo_path )
133
159
  unless Dir.exist?( repo_path )
134
160
  puts_line "#{repo_name}: FAIL (path not found)"
161
+ record_batch_skip( command: "prune", repo_path: repo_path, reason: "path not found" )
135
162
  failed += 1
136
163
  next
137
164
  end
@@ -145,9 +172,16 @@ module Carson
145
172
  summary = buf.string.lines.last.to_s.strip
146
173
  puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
147
174
  end
148
- 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
149
182
  rescue StandardError => e
150
183
  puts_line "#{repo_name}: FAIL (#{e.message})"
184
+ record_batch_skip( command: "prune", repo_path: repo_path, reason: e.message )
151
185
  failed += 1
152
186
  end
153
187
  end
@@ -214,7 +248,7 @@ module Carson
214
248
  return hook_status unless hook_status == EXIT_OK
215
249
  puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
216
250
 
217
- template_drift_count = template_results.count { |entry| entry.fetch( :status ) != "ok" }
251
+ template_drift_count = template_results.count { it.fetch( :status ) != "ok" }
218
252
  template_status = with_captured_output { template_apply! }
219
253
  return template_status unless template_status == EXIT_OK
220
254
  if template_drift_count.positive?
@@ -42,6 +42,53 @@ module Carson
42
42
  git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch )
43
43
  end
44
44
 
45
+ # Syncs main branch across all governed repositories.
46
+ def sync_all!
47
+ repos = config.govern_repos
48
+ if repos.empty?
49
+ puts_line "No governed repositories configured."
50
+ puts_line " Run carson onboard in each repo to register."
51
+ return EXIT_ERROR
52
+ end
53
+
54
+ puts_line ""
55
+ puts_line "Sync all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
56
+ synced = 0
57
+ failed = 0
58
+
59
+ repos.each do |repo_path|
60
+ repo_name = File.basename( repo_path )
61
+ unless Dir.exist?( repo_path )
62
+ puts_line "#{repo_name}: FAIL (path not found)"
63
+ record_batch_skip( command: "sync", repo_path: repo_path, reason: "path not found" )
64
+ failed += 1
65
+ next
66
+ end
67
+
68
+ begin
69
+ rt = build_scoped_runtime( repo_path: repo_path )
70
+ status = rt.sync!
71
+ if status == EXIT_OK
72
+ puts_line "#{repo_name}: ok" unless verbose?
73
+ clear_batch_success( command: "sync", repo_path: repo_path )
74
+ synced += 1
75
+ else
76
+ puts_line "#{repo_name}: FAIL" unless verbose?
77
+ record_batch_skip( command: "sync", repo_path: repo_path, reason: "sync failed" )
78
+ failed += 1
79
+ end
80
+ rescue StandardError => e
81
+ puts_line "#{repo_name}: FAIL (#{e.message})"
82
+ record_batch_skip( command: "sync", repo_path: repo_path, reason: e.message )
83
+ failed += 1
84
+ end
85
+ end
86
+
87
+ puts_line ""
88
+ puts_line "Sync all complete: #{synced} synced, #{failed} failed."
89
+ failed.zero? ? EXIT_OK : EXIT_ERROR
90
+ end
91
+
45
92
  private
46
93
 
47
94
  # Runs a git command, suppressing stdout/stderr in JSON mode to keep output clean.
@@ -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"
@@ -43,6 +43,53 @@ module Carson
43
43
  ( drift_count + stale_count ).positive? ? EXIT_BLOCK : EXIT_OK
44
44
  end
45
45
 
46
+ # Read-only template drift check across all governed repositories.
47
+ def template_check_all!
48
+ repos = config.govern_repos
49
+ if repos.empty?
50
+ puts_line "No governed repositories configured."
51
+ puts_line " Run carson onboard in each repo to register."
52
+ return EXIT_ERROR
53
+ end
54
+
55
+ puts_line ""
56
+ puts_line "Template check all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
57
+ in_sync = 0
58
+ drifted = 0
59
+ failed = 0
60
+
61
+ repos.each do |repo_path|
62
+ repo_name = File.basename( repo_path )
63
+ unless Dir.exist?( repo_path )
64
+ puts_line "#{repo_name}: FAIL (path not found)"
65
+ record_batch_skip( command: "template_check", repo_path: repo_path, reason: "path not found" )
66
+ failed += 1
67
+ next
68
+ end
69
+
70
+ begin
71
+ rt = build_scoped_runtime( repo_path: repo_path )
72
+ status = rt.template_check!
73
+ if status == EXIT_OK
74
+ puts_line "#{repo_name}: in sync" unless verbose?
75
+ clear_batch_success( command: "template_check", repo_path: repo_path )
76
+ in_sync += 1
77
+ else
78
+ puts_line "#{repo_name}: DRIFT" unless verbose?
79
+ drifted += 1
80
+ end
81
+ rescue StandardError => e
82
+ puts_line "#{repo_name}: FAIL (#{e.message})"
83
+ record_batch_skip( command: "template_check", repo_path: repo_path, reason: e.message )
84
+ failed += 1
85
+ end
86
+ end
87
+
88
+ puts_line ""
89
+ puts_line "Template check complete: #{in_sync} in sync, #{drifted} drifted, #{failed} failed."
90
+ drifted.zero? && failed.zero? ? EXIT_OK : EXIT_BLOCK
91
+ end
92
+
46
93
  # Applies managed template files as full-file writes from Carson sources.
47
94
  # Also removes superseded files that are no longer part of the managed set.
48
95
  def template_apply!( push_prep: false )
@@ -80,7 +127,7 @@ module Carson
80
127
  removed += 1
81
128
  end
82
129
 
83
- error_count = results.count { |entry| entry.fetch( :status ) == "error" }
130
+ error_count = results.count { it.fetch( :status ) == "error" }
84
131
  puts_verbose "template_apply_summary: updated=#{applied} removed=#{removed} error=#{error_count}"
85
132
  unless verbose?
86
133
  if applied.positive? || removed.positive?
@@ -17,8 +17,87 @@ module Carson
17
17
  EXIT_OK
18
18
  end
19
19
 
20
+ # Portfolio-wide status overview across all governed repositories.
21
+ def status_all!( json_output: false )
22
+ repos = config.govern_repos
23
+ if repos.empty?
24
+ puts_line "No governed repositories configured."
25
+ puts_line " Run carson onboard in each repo to register."
26
+ return EXIT_ERROR
27
+ end
28
+
29
+ if json_output
30
+ results = []
31
+ repos.each do |repo_path|
32
+ repo_name = File.basename( repo_path )
33
+ unless Dir.exist?( repo_path )
34
+ results << { name: repo_name, status: "error", error: "path not found" }
35
+ next
36
+ end
37
+ begin
38
+ rt = build_scoped_runtime( repo_path: repo_path )
39
+ data = rt.send( :gather_status )
40
+ results << { name: repo_name, status: "ok" }.merge( data )
41
+ rescue StandardError => e
42
+ results << { name: repo_name, status: "error", error: e.message }
43
+ end
44
+ end
45
+ out.puts JSON.pretty_generate( { command: "status", repos: results } )
46
+ return EXIT_OK
47
+ end
48
+
49
+ puts_line "Carson #{Carson::VERSION} — Portfolio (#{repos.length} repo#{plural_suffix( count: repos.length )})"
50
+ puts_line ""
51
+
52
+ all_pending = load_batch_pending
53
+ repos.each do |repo_path|
54
+ repo_name = File.basename( repo_path )
55
+ unless Dir.exist?( repo_path )
56
+ puts_line "#{repo_name}: MISSING"
57
+ next
58
+ end
59
+
60
+ begin
61
+ rt = build_scoped_runtime( repo_path: repo_path )
62
+ data = rt.send( :gather_status )
63
+ branch = data.fetch( :branch )
64
+ dirty = branch.fetch( :dirty ) ? " (dirty)" : ""
65
+ worktrees = data.fetch( :worktrees )
66
+ gov = data.fetch( :governance )
67
+ parts = []
68
+ parts << branch.fetch( :name ) + dirty
69
+ parts << "#{worktrees.count} worktree#{plural_suffix( count: worktrees.count )}" if worktrees.any?
70
+ parts << "templates #{gov.fetch( :templates )}" unless gov.fetch( :templates ) == :in_sync
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}" }
76
+ rescue StandardError => e
77
+ puts_line "#{repo_name}: FAIL (#{e.message})"
78
+ end
79
+ end
80
+
81
+ EXIT_OK
82
+ end
83
+
20
84
  private
21
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
+
22
101
  # Collects all status facets into a structured hash.
23
102
  def gather_status
24
103
  data = {
@@ -70,15 +149,10 @@ module Carson
70
149
  ahead = parts[ 0 ].to_i
71
150
  behind = parts[ 1 ].to_i
72
151
 
73
- if ahead.zero? && behind.zero?
74
- :in_sync
75
- elsif ahead.positive? && behind.zero?
76
- :ahead
77
- elsif ahead.zero? && behind.positive?
78
- :behind
79
- else
80
- :diverged
81
- end
152
+ return :in_sync if ahead.zero? && behind.zero?
153
+ return :ahead if behind.zero?
154
+ return :behind if ahead.zero?
155
+ :diverged
82
156
  end
83
157
 
84
158
  # Lists all worktrees with branch name.
@@ -88,7 +162,7 @@ module Carson
88
162
  # Filter out the main worktree (the repository root itself).
89
163
  # Use realpath for comparison — git returns canonical paths that may differ from repo_root.
90
164
  canonical_root = realpath_safe( repo_root )
91
- entries.reject { |wt| wt.fetch( :path ) == canonical_root }.map do |wt|
165
+ entries.reject { it.fetch( :path ) == canonical_root }.map do |wt|
92
166
  {
93
167
  path: wt.fetch( :path ),
94
168
  name: File.basename( wt.fetch( :path ) ),
@@ -126,9 +200,9 @@ module Carson
126
200
  entries = Array( rollup )
127
201
  return :none if entries.empty?
128
202
 
129
- states = entries.map { |c| c[ "conclusion" ].to_s.upcase }
130
- return :fail if states.any? { |s| s == "FAILURE" || s == "CANCELLED" || s == "TIMED_OUT" }
131
- 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" }
132
206
 
133
207
  :pass
134
208
  end
@@ -212,6 +212,112 @@ module Carson
212
212
  def gh_run( *args )
213
213
  github_adapter.run( *args )
214
214
  end
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
+
280
+ # --- Portfolio helpers (shared by all --all commands) ---
281
+
282
+ # Checks whether a governed repo is safe for batch operations.
283
+ # Returns { safe: true/false, reasons: [...] }.
284
+ # Safe means: no active worktrees beyond main, no uncommitted changes.
285
+ # Non-git directories pass through as safe — let the command handle the error.
286
+ def portfolio_repo_safety( repo_path: )
287
+ git = Adapters::Git.new( repo_root: repo_path )
288
+
289
+ # Non-git directories pass through — the calling command reports the real error.
290
+ stdout, _, git_ok, = git.run( "rev-parse", "--is-inside-work-tree" )
291
+ return { safe: true, reasons: [] } unless git_ok && stdout.to_s.strip == "true"
292
+
293
+ reasons = []
294
+
295
+ # Active worktrees beyond the main working tree.
296
+ rt = build_scoped_runtime( repo_path: repo_path )
297
+ worktrees = rt.send( :worktree_list )
298
+ main_root = rt.send( :realpath_safe, repo_path )
299
+ active = worktrees.reject { |wt| wt.fetch( :path ) == main_root }
300
+ if active.any?
301
+ reasons << "#{active.count} active worktree#{active.count == 1 ? '' : 's'}"
302
+ end
303
+
304
+ # Uncommitted changes in the main working tree.
305
+ stdout, _, success, = git.run( "status", "--porcelain" )
306
+ if success && !stdout.strip.empty?
307
+ reasons << "uncommitted changes"
308
+ end
309
+
310
+ { safe: reasons.empty?, reasons: reasons }
311
+ rescue StandardError => e
312
+ { safe: false, reasons: [ e.message ] }
313
+ end
314
+
315
+ # Creates a scoped Runtime for a governed repo with captured output.
316
+ def build_scoped_runtime( repo_path: )
317
+ buf = verbose? ? out : StringIO.new
318
+ err_buf = verbose? ? err : StringIO.new
319
+ Runtime.new( repo_root: repo_path, tool_root: tool_root, out: buf, err: err_buf, verbose: verbose? )
320
+ end
215
321
  end
216
322
  end
217
323
 
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.17.0
4
+ version: 3.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang