carson 3.16.0 → 3.18.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: f88fb85694afee9d6cb92ed785152851dd88e53a354bcc27f35ae2ab40f034da
4
- data.tar.gz: 366e6b00767ca21554e016329a3a8112aaec7177fee2668c9678b99d90e0a4f0
3
+ metadata.gz: 5c18ffa310a6fdb0b0e1581eba83b9ab7dcb8c88a546c391f83200871e17d7f6
4
+ data.tar.gz: 77c0d6e97438a1f9512e3dbef51a22981caf1b6b0126ffe8ab3bdbf3c7ec3bbb
5
5
  SHA512:
6
- metadata.gz: 8030cfb9fe88b844de38c015bb951ddc1dc4225bdad98faa6a7d7f0197246801043f6c7c0e5d881590fb892fd3cf8eba9ce45fd2a2a2110309a23c9251208dc2
7
- data.tar.gz: 2b30204a052702fb44990fd76afcf456c443a861ce9a076b2cdbeebb951968c015a3f1fffd4662483e6620d96d285536d5a383327e47b51817744f1ae1f3c17c
6
+ metadata.gz: 5ac7c43bd96d94c1013c5ade599bd906dbda305b3c0216c184093b62292de30c93d7b8f7bd0e090ffe13fd272e97345d6adb30b321df3188f48b2e5ef50913d6
7
+ data.tar.gz: 00da3a36874c8dca3ade2736e2e0d5852018cbe13c7a68cb0af746a59cbcbda51b4f992ebe862a88cf855824d7388c2a3b09b04d5950da385f3bdcf6ff09af12
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.18.0
9
+
10
+ ### What changed
11
+
12
+ - **Layer 2 batch operations** — every command that operates on a single repo now has an `--all` variant that runs across the entire governed portfolio:
13
+ - `carson refresh --all` — refresh hooks, templates, and audit across all repos. Checks each repo for safety (active worktrees, uncommitted changes) and skips unsafe repos.
14
+ - `carson prune --all` — prune stale branches across all repos.
15
+ - `carson audit --all` — run governance audit across all repos. Reports pass/block/fail per repo.
16
+ - `carson sync --all` — sync main branch across all repos.
17
+ - `carson status --all` — portfolio-wide status overview with branch, worktrees, and governance state per repo. Supports `--json`.
18
+ - `carson template check --all` — read-only template drift detection across all repos.
19
+ - **Portfolio safety checks** — before batch `refresh`, Carson checks each repo for active worktrees and uncommitted changes. Unsafe repos are skipped with reasons, not failed.
20
+ - **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).
21
+ - **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).
22
+
23
+ ### UX improvement
24
+
25
+ - 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.
26
+
27
+ ## 3.17.0
28
+
29
+ ### What changed
30
+
31
+ - **Descriptive help text for all CLI commands** — every command now shows purpose, description, and usage examples when invoked with `--help`. Commands that previously used manual flag parsing (`audit`, `sync`, `status`, `repos`, `prune`, `housekeep`, `worktree`, `onboard`, `offboard`, `refresh`, `review`, `template`) now use `OptionParser` so `--help` is handled consistently.
32
+ - **Structured top-level help** — `carson --help` shows a command catalogue with one-line descriptions and a footer guiding to per-command help, replacing the previous single-line usage banner.
33
+
34
+ ### UX improvement
35
+
36
+ - A user encountering Carson for the first time can now understand what each command does without reading external documentation. Every `--help` surface answers: what does this do, what options are available, and how do I use it.
37
+
8
38
  ## 3.16.0
9
39
 
10
40
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.16.0
1
+ 3.18.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 )
@@ -43,7 +43,7 @@ module Carson
43
43
  return preset.merge( verbose: verbose ) unless preset.nil?
44
44
 
45
45
  command = argv.shift
46
- result = parse_command( command: command, argv: argv, parser: parser, err: err )
46
+ result = parse_command( command: command, argv: argv, err: err )
47
47
  result.merge( verbose: verbose )
48
48
  rescue OptionParser::ParseError => e
49
49
  err.puts "#{BADGE} #{e.message}"
@@ -53,7 +53,29 @@ module Carson
53
53
 
54
54
  def self.build_parser
55
55
  OptionParser.new do |opts|
56
- opts.banner = "Usage: carson [status [--json]|setup|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] create|remove <name>|housekeep [repo] [--json]|repos [--json]|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
56
+ opts.banner = "Usage: carson <command> [options]"
57
+ opts.separator ""
58
+ opts.separator "Repository governance and workflow automation for coding agents."
59
+ opts.separator ""
60
+ opts.separator "Commands:"
61
+ opts.separator " status Show repository state (branch, PRs, worktrees)"
62
+ opts.separator " setup Initialise Carson configuration"
63
+ opts.separator " audit Run pre-commit health checks"
64
+ opts.separator " sync Sync local main with remote"
65
+ opts.separator " deliver Push, create PR, and optionally merge"
66
+ opts.separator " prune Remove stale local branches"
67
+ opts.separator " worktree Manage isolated coding worktrees"
68
+ opts.separator " housekeep Sync, reap worktrees, and prune branches"
69
+ opts.separator " repos List governed repositories"
70
+ opts.separator " onboard Register a repository for governance"
71
+ opts.separator " offboard Remove a repository from governance"
72
+ opts.separator " refresh Re-install hooks and configuration"
73
+ opts.separator " template Manage canonical template files"
74
+ opts.separator " review Manage PR review workflow"
75
+ opts.separator " govern Portfolio-level PR triage loop"
76
+ opts.separator " version Show Carson version"
77
+ opts.separator ""
78
+ opts.separator "Run `carson <command> --help` for details on a specific command."
57
79
  end
58
80
  end
59
81
 
@@ -69,29 +91,30 @@ module Carson
69
91
  nil
70
92
  end
71
93
 
72
- def self.parse_command( command:, argv:, parser:, err: )
94
+ def self.parse_command( command:, argv:, err: )
73
95
  case command
74
96
  when "version"
75
- parser.parse!( argv )
76
97
  { command: "version" }
77
98
  when "setup"
78
- parse_setup_command( argv: argv, parser: parser, err: err )
79
- when "onboard", "offboard"
80
- parse_repo_path_command( command: command, argv: argv, parser: parser, err: err )
99
+ parse_setup_command( argv: argv, err: err )
100
+ when "onboard"
101
+ parse_onboard_command( argv: argv, err: err )
102
+ when "offboard"
103
+ parse_offboard_command( argv: argv, err: err )
81
104
  when "refresh"
82
- parse_refresh_command( argv: argv, parser: parser, err: err )
105
+ parse_refresh_command( argv: argv, err: err )
83
106
  when "template"
84
- parse_template_subcommand( argv: argv, parser: parser, err: err )
107
+ parse_template_subcommand( argv: argv, err: err )
85
108
  when "prune"
86
- parse_prune_command( argv: argv, parser: parser, err: err )
109
+ parse_prune_command( argv: argv, err: err )
87
110
  when "worktree"
88
- parse_worktree_subcommand( argv: argv, parser: parser, err: err )
111
+ parse_worktree_subcommand( argv: argv, err: err )
89
112
  when "repos"
90
113
  parse_repos_command( argv: argv, err: err )
91
114
  when "housekeep"
92
115
  parse_housekeep_command( argv: argv, err: err )
93
116
  when "review"
94
- parse_named_subcommand( command: command, usage: "gate|sweep", argv: argv, parser: parser, err: err )
117
+ parse_review_subcommand( argv: argv, err: err )
95
118
  when "audit"
96
119
  parse_audit_command( argv: argv, err: err )
97
120
  when "sync"
@@ -103,20 +126,32 @@ module Carson
103
126
  when "govern"
104
127
  parse_govern_subcommand( argv: argv, err: err )
105
128
  else
106
- parser.parse!( argv )
107
129
  { command: command }
108
130
  end
109
131
  end
110
132
 
111
- def self.parse_setup_command( argv:, parser:, err: )
133
+ # --- setup ---
134
+
135
+ def self.parse_setup_command( argv:, err: )
112
136
  options = {}
113
137
  setup_parser = OptionParser.new do |opts|
114
138
  opts.banner = "Usage: carson setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--merge METHOD] [--canonical PATH]"
139
+ opts.separator ""
140
+ opts.separator "Initialise Carson configuration for the current repository."
141
+ opts.separator "Detects git remote, main branch, and workflow style, then writes .carson.yml."
142
+ opts.separator "Pass flags to override detected values."
143
+ opts.separator ""
144
+ opts.separator "Options:"
115
145
  opts.on( "--remote NAME", "Git remote name" ) { |v| options[ "git.remote" ] = v }
116
146
  opts.on( "--main-branch NAME", "Main branch name" ) { |v| options[ "git.main_branch" ] = v }
117
147
  opts.on( "--workflow STYLE", "Workflow style (branch or trunk)" ) { |v| options[ "workflow.style" ] = v }
118
148
  opts.on( "--merge METHOD", "Merge method (squash, rebase, or merge)" ) { |v| options[ "govern.merge.method" ] = v }
119
149
  opts.on( "--canonical PATH", "Canonical template directory path" ) { |v| options[ "template.canonical" ] = v }
150
+ opts.separator ""
151
+ opts.separator "Examples:"
152
+ opts.separator " carson setup Auto-detect and write config"
153
+ opts.separator " carson setup --remote github Use 'github' as the git remote"
154
+ opts.separator " carson setup --merge squash Set squash as the merge method"
120
155
  end
121
156
  setup_parser.parse!( argv )
122
157
  unless argv.empty?
@@ -130,36 +165,93 @@ module Carson
130
165
  { command: :invalid }
131
166
  end
132
167
 
133
- def self.parse_repo_path_command( command:, argv:, parser:, err: )
134
- parser.parse!( argv )
168
+ # --- onboard / offboard ---
169
+
170
+ def self.parse_onboard_command( argv:, err: )
171
+ onboard_parser = OptionParser.new do |opts|
172
+ opts.banner = "Usage: carson onboard [REPO_PATH]"
173
+ opts.separator ""
174
+ opts.separator "Register a repository for Carson governance."
175
+ opts.separator "Detects the remote, installs hooks, applies templates, and runs initial audit."
176
+ opts.separator "Defaults to the current directory if no path is given."
177
+ opts.separator ""
178
+ opts.separator "Examples:"
179
+ opts.separator " carson onboard Onboard the current repository"
180
+ opts.separator " carson onboard ~/Dev/app Onboard a specific repository"
181
+ end
182
+ onboard_parser.parse!( argv )
135
183
  if argv.length > 1
136
- err.puts "#{BADGE} Too many arguments for #{command}. Use: carson #{command} [repo_path]"
137
- err.puts parser
184
+ err.puts "#{BADGE} Too many arguments for onboard. Use: carson onboard [repo_path]"
185
+ err.puts onboard_parser
138
186
  return { command: :invalid }
139
187
  end
188
+ repo_path = argv.first
189
+ {
190
+ command: "onboard",
191
+ repo_root: repo_path.to_s.strip.empty? ? nil : File.expand_path( repo_path )
192
+ }
193
+ rescue OptionParser::ParseError => e
194
+ err.puts "#{BADGE} #{e.message}"
195
+ { command: :invalid }
196
+ end
140
197
 
198
+ def self.parse_offboard_command( argv:, err: )
199
+ offboard_parser = OptionParser.new do |opts|
200
+ opts.banner = "Usage: carson offboard [REPO_PATH]"
201
+ opts.separator ""
202
+ opts.separator "Remove a repository from Carson governance."
203
+ opts.separator "Unregisters the repo from Carson's portfolio and removes hooks."
204
+ opts.separator "Defaults to the current directory if no path is given."
205
+ opts.separator ""
206
+ opts.separator "Examples:"
207
+ opts.separator " carson offboard Offboard the current repository"
208
+ end
209
+ offboard_parser.parse!( argv )
210
+ if argv.length > 1
211
+ err.puts "#{BADGE} Too many arguments for offboard. Use: carson offboard [repo_path]"
212
+ err.puts offboard_parser
213
+ return { command: :invalid }
214
+ end
141
215
  repo_path = argv.first
142
216
  {
143
- command: command,
217
+ command: "offboard",
144
218
  repo_root: repo_path.to_s.strip.empty? ? nil : File.expand_path( repo_path )
145
219
  }
220
+ rescue OptionParser::ParseError => e
221
+ err.puts "#{BADGE} #{e.message}"
222
+ { command: :invalid }
146
223
  end
147
224
 
148
- def self.parse_refresh_command( argv:, parser:, err: )
149
- all_flag = argv.delete( "--all" ) ? true : false
150
- parser.parse!( argv )
225
+ # --- refresh ---
226
+
227
+ def self.parse_refresh_command( argv:, err: )
228
+ options = { all: false }
229
+ refresh_parser = OptionParser.new do |opts|
230
+ opts.banner = "Usage: carson refresh [--all] [REPO_PATH]"
231
+ opts.separator ""
232
+ opts.separator "Re-install Carson hooks and configuration for a repository."
233
+ opts.separator "Defaults to the current directory. Use --all to refresh all governed repos."
234
+ opts.separator ""
235
+ opts.separator "Options:"
236
+ opts.on( "--all", "Refresh all governed repositories" ) { options[ :all ] = true }
237
+ opts.separator ""
238
+ opts.separator "Examples:"
239
+ opts.separator " carson refresh Refresh the current repository"
240
+ opts.separator " carson refresh --all Refresh all governed repos"
241
+ end
242
+ refresh_parser.parse!( argv )
151
243
 
152
- if all_flag && !argv.empty?
244
+ if options[ :all ] && !argv.empty?
153
245
  err.puts "#{BADGE} --all and repo_path are mutually exclusive. Use: carson refresh --all OR carson refresh [repo_path]"
154
- err.puts parser
246
+ err.puts refresh_parser
155
247
  return { command: :invalid }
156
248
  end
157
249
 
158
- return { command: "refresh:all" } if all_flag
250
+ return { command: "refresh:all" } if options[ :all ]
159
251
 
160
252
  if argv.length > 1
161
253
  err.puts "#{BADGE} Too many arguments for refresh. Use: carson refresh [repo_path]"
162
- err.puts parser
254
+ err.puts refresh_parser
163
255
  return { command: :invalid }
164
256
  end
165
257
 
@@ -168,22 +260,67 @@ module Carson
168
260
  command: "refresh",
169
261
  repo_root: repo_path.to_s.strip.empty? ? nil : File.expand_path( repo_path )
170
262
  }
263
+ rescue OptionParser::ParseError => e
264
+ err.puts "#{BADGE} #{e.message}"
265
+ { command: :invalid }
171
266
  end
172
267
 
173
- def self.parse_prune_command( argv:, parser:, err: )
174
- all_flag = argv.delete( "--all" ) ? true : false
175
- json_flag = argv.delete( "--json" ) ? true : false
176
- parser.parse!( argv )
177
- return { command: "prune:all", json: json_flag } if all_flag
178
- { command: "prune", json: json_flag }
268
+ # --- prune ---
269
+
270
+ def self.parse_prune_command( argv:, err: )
271
+ options = { all: false, json: false }
272
+ prune_parser = OptionParser.new do |opts|
273
+ opts.banner = "Usage: carson prune [--all] [--json]"
274
+ opts.separator ""
275
+ opts.separator "Remove stale local branches."
276
+ opts.separator "Cleans up branches gone from the remote, orphan branches with merged PRs,"
277
+ opts.separator "and absorbed branches whose content is already on main."
278
+ opts.separator ""
279
+ opts.separator "Options:"
280
+ opts.on( "--all", "Prune all governed repositories" ) { options[ :all ] = true }
281
+ opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
282
+ opts.separator ""
283
+ opts.separator "Examples:"
284
+ opts.separator " carson prune Clean up stale branches in this repo"
285
+ opts.separator " carson prune --all Clean up across all governed repos"
286
+ end
287
+ prune_parser.parse!( argv )
288
+ return { command: "prune:all", json: options[ :json ] } if options[ :all ]
289
+ { command: "prune", json: options[ :json ] }
290
+ rescue OptionParser::ParseError => e
291
+ err.puts "#{BADGE} #{e.message}"
292
+ { command: :invalid }
179
293
  end
180
294
 
181
- def self.parse_worktree_subcommand( argv:, parser:, err: )
182
- json_flag = argv.delete( "--json" ) ? true : false
295
+ # --- worktree ---
296
+
297
+ def self.parse_worktree_subcommand( argv:, err: )
298
+ options = { json: false, force: false }
299
+ worktree_parser = OptionParser.new do |opts|
300
+ opts.banner = "Usage: carson worktree <create|remove> <name> [options]"
301
+ opts.separator ""
302
+ opts.separator "Manage isolated worktrees for coding agents."
303
+ opts.separator "Create auto-syncs main before branching. Remove guards against"
304
+ opts.separator "unpushed commits and CWD-inside-worktree by default."
305
+ opts.separator ""
306
+ opts.separator "Subcommands:"
307
+ opts.separator " create <name> Create a new worktree with a fresh branch"
308
+ opts.separator " remove <name> [--force] Remove a worktree (--force skips safety checks)"
309
+ opts.separator ""
310
+ opts.separator "Options:"
311
+ opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
312
+ opts.on( "--force", "Skip safety checks on remove" ) { options[ :force ] = true }
313
+ opts.separator ""
314
+ opts.separator "Examples:"
315
+ opts.separator " carson worktree create feature-x Create an isolated worktree"
316
+ opts.separator " carson worktree remove feature-x Remove after work is pushed"
317
+ end
318
+ worktree_parser.parse!( argv )
319
+
183
320
  action = argv.shift
184
321
  if action.to_s.strip.empty?
185
322
  err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|remove <name>"
186
- err.puts parser
323
+ err.puts worktree_parser
187
324
  return { command: :invalid }
188
325
  end
189
326
 
@@ -194,45 +331,95 @@ module Carson
194
331
  err.puts "#{BADGE} Missing name for worktree create. Use: carson worktree create <name>"
195
332
  return { command: :invalid }
196
333
  end
197
- { command: "worktree:create", worktree_name: name, json: json_flag }
334
+ { command: "worktree:create", worktree_name: name, json: options[ :json ] }
198
335
  when "remove"
199
- force = argv.delete( "--force" ) ? true : false
200
336
  worktree_path = argv.shift
201
337
  if worktree_path.to_s.strip.empty?
202
338
  err.puts "#{BADGE} Missing path for worktree remove. Use: carson worktree remove <name-or-path>"
203
339
  return { command: :invalid }
204
340
  end
205
- { command: "worktree:remove", worktree_path: worktree_path, force: force, json: json_flag }
341
+ { command: "worktree:remove", worktree_path: worktree_path, force: options[ :force ], json: options[ :json ] }
206
342
  else
207
343
  err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|remove <name>"
208
344
  { command: :invalid }
209
345
  end
346
+ rescue OptionParser::ParseError => e
347
+ err.puts "#{BADGE} #{e.message}"
348
+ { command: :invalid }
210
349
  end
211
350
 
212
- def self.parse_named_subcommand( command:, usage:, argv:, parser:, err: )
351
+ # --- review ---
352
+
353
+ def self.parse_review_subcommand( argv:, err: )
354
+ review_parser = OptionParser.new do |opts|
355
+ opts.banner = "Usage: carson review <gate|sweep>"
356
+ opts.separator ""
357
+ opts.separator "Manage PR review workflow."
358
+ opts.separator ""
359
+ opts.separator "Subcommands:"
360
+ opts.separator " gate Check if review requirements are met for merge"
361
+ opts.separator " sweep Scan and resolve pending review threads"
362
+ opts.separator ""
363
+ opts.separator "Examples:"
364
+ opts.separator " carson review gate Check merge readiness"
365
+ opts.separator " carson review sweep Resolve pending review threads"
366
+ end
367
+ review_parser.parse!( argv )
368
+
213
369
  action = argv.shift
214
- parser.parse!( argv )
215
370
  if action.to_s.strip.empty?
216
- err.puts "#{BADGE} Missing subcommand for #{command}. Use: carson #{command} #{usage}"
217
- err.puts parser
371
+ err.puts "#{BADGE} Missing subcommand for review. Use: carson review gate|sweep"
372
+ err.puts review_parser
218
373
  return { command: :invalid }
219
374
  end
220
- { command: "#{command}:#{action}" }
375
+ { command: "review:#{action}" }
376
+ rescue OptionParser::ParseError => e
377
+ err.puts "#{BADGE} #{e.message}"
378
+ { command: :invalid }
221
379
  end
222
380
 
223
- def self.parse_template_subcommand( argv:, parser:, err: )
224
- action = argv.shift
225
- if action.to_s.strip.empty?
226
- err.puts "#{BADGE} Missing subcommand for template. Use: carson template check|apply"
227
- err.puts parser
228
- return { command: :invalid }
381
+ # --- template ---
382
+
383
+ def self.parse_template_subcommand( argv:, err: )
384
+ # Handle parent-level help or missing subcommand.
385
+ if argv.empty? || [ "--help", "-h" ].include?( argv.first )
386
+ template_parser = OptionParser.new do |opts|
387
+ opts.banner = "Usage: carson template <check|apply> [options]"
388
+ opts.separator ""
389
+ opts.separator "Manage canonical template files (CI workflows, lint configs)."
390
+ opts.separator ""
391
+ opts.separator "Subcommands:"
392
+ opts.separator " check Show template drift without making changes"
393
+ opts.separator " apply [--push-prep] Sync templates into the repository"
394
+ opts.separator ""
395
+ opts.separator "Examples:"
396
+ opts.separator " carson template check Check for template drift"
397
+ opts.separator " carson template apply Apply canonical templates"
398
+ end
399
+
400
+ if argv.empty?
401
+ err.puts "#{BADGE} Missing subcommand for template. Use: carson template check|apply"
402
+ err.puts template_parser
403
+ return { command: :invalid }
404
+ end
405
+
406
+ # Let OptionParser handle --help (prints and exits).
407
+ template_parser.parse!( argv )
408
+ return { command: :help }
229
409
  end
230
410
 
411
+ action = argv.shift
412
+ return { command: "template:check:all" } if action == "check" && argv.include?( "--all" )
231
413
  return { command: "template:#{action}" } unless action == "apply"
232
414
 
233
415
  options = { push_prep: false }
234
416
  apply_parser = OptionParser.new do |opts|
235
417
  opts.banner = "Usage: carson template apply [--push-prep]"
418
+ opts.separator ""
419
+ opts.separator "Sync canonical template files (CI workflows, lint configs) into the repository."
420
+ opts.separator "Copies managed files from the configured canonical directory."
421
+ opts.separator ""
422
+ opts.separator "Options:"
236
423
  opts.on( "--push-prep", "Apply templates and auto-commit any managed file changes (used by pre-push hook)" ) do
237
424
  options[ :push_prep ] = true
238
425
  end
@@ -249,41 +436,119 @@ module Carson
249
436
  { command: :invalid }
250
437
  end
251
438
 
439
+ # --- audit ---
440
+
252
441
  def self.parse_audit_command( argv:, err: )
253
- json_flag = argv.delete( "--json" ) ? true : false
442
+ options = { json: false, all: false }
443
+ audit_parser = OptionParser.new do |opts|
444
+ opts.banner = "Usage: carson audit [--all] [--json]"
445
+ opts.separator ""
446
+ opts.separator "Run pre-commit health checks on the repository."
447
+ opts.separator "Validates hooks, main-branch sync, PR status, and CI baseline."
448
+ opts.separator "Exits with a non-zero status when policy violations are found."
449
+ opts.separator ""
450
+ opts.separator "Options:"
451
+ opts.on( "--all", "Audit all governed repositories" ) { options[ :all ] = true }
452
+ opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
453
+ opts.separator ""
454
+ opts.separator "Examples:"
455
+ opts.separator " carson audit Check repository health (also the default command)"
456
+ opts.separator " carson audit --json Structured output for agent consumption"
457
+ opts.separator " carson audit --all Audit all governed repos"
458
+ end
459
+ audit_parser.parse!( argv )
254
460
  unless argv.empty?
255
461
  err.puts "#{BADGE} Unexpected arguments for audit: #{argv.join( ' ' )}"
256
462
  return { command: :invalid }
257
463
  end
258
- { command: "audit", json: json_flag }
464
+ return { command: "audit:all" } if options[ :all ]
465
+ { command: "audit", json: options[ :json ] }
466
+ rescue OptionParser::ParseError => e
467
+ err.puts "#{BADGE} #{e.message}"
468
+ { command: :invalid }
259
469
  end
260
470
 
471
+ # --- sync ---
472
+
261
473
  def self.parse_sync_command( argv:, err: )
262
- json_flag = argv.delete( "--json" ) ? true : false
474
+ options = { json: false, all: false }
475
+ sync_parser = OptionParser.new do |opts|
476
+ opts.banner = "Usage: carson sync [--all] [--json]"
477
+ opts.separator ""
478
+ opts.separator "Sync the local main branch with the remote."
479
+ opts.separator "Fetches and fast-forwards main without switching branches."
480
+ opts.separator ""
481
+ opts.separator "Options:"
482
+ opts.on( "--all", "Sync all governed repositories" ) { options[ :all ] = true }
483
+ opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
484
+ opts.separator ""
485
+ opts.separator "Examples:"
486
+ opts.separator " carson sync Pull latest changes from remote main"
487
+ opts.separator " carson sync --json Structured output for agent consumption"
488
+ opts.separator " carson sync --all Sync all governed repos"
489
+ end
490
+ sync_parser.parse!( argv )
263
491
  unless argv.empty?
264
492
  err.puts "#{BADGE} Unexpected arguments for sync: #{argv.join( ' ' )}"
265
493
  return { command: :invalid }
266
494
  end
267
- { command: "sync", json: json_flag }
495
+ return { command: "sync:all" } if options[ :all ]
496
+ { command: "sync", json: options[ :json ] }
497
+ rescue OptionParser::ParseError => e
498
+ err.puts "#{BADGE} #{e.message}"
499
+ { command: :invalid }
268
500
  end
269
501
 
502
+ # --- status ---
503
+
270
504
  def self.parse_status_command( argv:, err: )
271
- json_flag = argv.delete( "--json" ) ? true : false
505
+ options = { json: false, all: false }
506
+ status_parser = OptionParser.new do |opts|
507
+ opts.banner = "Usage: carson status [--all] [--json]"
508
+ opts.separator ""
509
+ opts.separator "Show the current state of the repository."
510
+ opts.separator "Reports branch, worktrees, open PRs, stale branches, and version."
511
+ opts.separator ""
512
+ opts.separator "Options:"
513
+ opts.on( "--all", "Show status for all governed repositories" ) { options[ :all ] = true }
514
+ opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
515
+ opts.separator ""
516
+ opts.separator "Examples:"
517
+ opts.separator " carson status Quick overview of repository state"
518
+ opts.separator " carson status --json Structured output for agent consumption"
519
+ opts.separator " carson status --all Portfolio-wide status overview"
520
+ end
521
+ status_parser.parse!( argv )
272
522
  unless argv.empty?
273
523
  err.puts "#{BADGE} Unexpected arguments for status: #{argv.join( ' ' )}"
274
524
  return { command: :invalid }
275
525
  end
276
- { command: "status", json: json_flag }
526
+ return { command: "status:all", json: options[ :json ] } if options[ :all ]
527
+ { command: "status", json: options[ :json ] }
528
+ rescue OptionParser::ParseError => e
529
+ err.puts "#{BADGE} #{e.message}"
530
+ { command: :invalid }
277
531
  end
278
532
 
533
+ # --- deliver ---
534
+
279
535
  def self.parse_deliver_command( argv:, err: )
280
536
  options = { merge: false, json: false, title: nil, body_file: nil }
281
537
  deliver_parser = OptionParser.new do |opts|
282
538
  opts.banner = "Usage: carson deliver [--merge] [--json] [--title TITLE] [--body-file PATH]"
539
+ opts.separator ""
540
+ opts.separator "Push the current branch, create a pull request, and optionally merge."
541
+ opts.separator "Collapses the manual push → PR → merge flow into a single command."
542
+ opts.separator ""
543
+ opts.separator "Options:"
283
544
  opts.on( "--merge", "Also merge the PR if CI passes" ) { options[ :merge ] = true }
284
545
  opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
285
546
  opts.on( "--title TITLE", "PR title (defaults to branch name)" ) { |v| options[ :title ] = v }
286
547
  opts.on( "--body-file PATH", "File containing PR body text" ) { |v| options[ :body_file ] = v }
548
+ opts.separator ""
549
+ opts.separator "Examples:"
550
+ opts.separator " carson deliver Push and open a PR"
551
+ opts.separator " carson deliver --merge Push, open a PR, and merge if CI passes"
287
552
  end
288
553
  deliver_parser.parse!( argv )
289
554
  unless argv.empty?
@@ -303,25 +568,61 @@ module Carson
303
568
  { command: :invalid }
304
569
  end
305
570
 
571
+ # --- repos ---
572
+
306
573
  def self.parse_repos_command( argv:, err: )
307
- json_flag = argv.delete( "--json" ) ? true : false
574
+ options = { json: false }
575
+ repos_parser = OptionParser.new do |opts|
576
+ opts.banner = "Usage: carson repos [--json]"
577
+ opts.separator ""
578
+ opts.separator "List all repositories governed by Carson."
579
+ opts.separator "Shows the portfolio of repos registered via carson onboard."
580
+ opts.separator ""
581
+ opts.separator "Options:"
582
+ opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
583
+ opts.separator ""
584
+ opts.separator "Examples:"
585
+ opts.separator " carson repos List governed repositories"
586
+ opts.separator " carson repos --json Structured output for agent consumption"
587
+ end
588
+ repos_parser.parse!( argv )
308
589
  unless argv.empty?
309
590
  err.puts "#{BADGE} Unexpected arguments for repos: #{argv.join( ' ' )}"
310
591
  return { command: :invalid }
311
592
  end
312
- { command: "repos", json: json_flag }
593
+ { command: "repos", json: options[ :json ] }
594
+ rescue OptionParser::ParseError => e
595
+ err.puts "#{BADGE} #{e.message}"
596
+ { command: :invalid }
313
597
  end
314
598
 
599
+ # --- housekeep ---
600
+
315
601
  def self.parse_housekeep_command( argv:, err: )
316
- all_flag = argv.delete( "--all" ) ? true : false
317
- json_flag = argv.delete( "--json" ) ? true : false
602
+ options = { all: false, json: false }
603
+ housekeep_parser = OptionParser.new do |opts|
604
+ opts.banner = "Usage: carson housekeep [REPO] [--all] [--json]"
605
+ opts.separator ""
606
+ opts.separator "Run housekeeping: sync main, reap dead worktrees, and prune stale branches."
607
+ opts.separator "Defaults to the current repository."
608
+ opts.separator ""
609
+ opts.separator "Options:"
610
+ opts.on( "--all", "Housekeep all governed repositories" ) { options[ :all ] = true }
611
+ opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
612
+ opts.separator ""
613
+ opts.separator "Examples:"
614
+ opts.separator " carson housekeep Housekeep the current repository"
615
+ opts.separator " carson housekeep nexus Housekeep a named governed repo"
616
+ opts.separator " carson housekeep --all Housekeep all governed repos"
617
+ end
618
+ housekeep_parser.parse!( argv )
318
619
 
319
- if all_flag && !argv.empty?
620
+ if options[ :all ] && !argv.empty?
320
621
  err.puts "#{BADGE} --all and repo target are mutually exclusive. Use: carson housekeep --all OR carson housekeep [repo]"
321
622
  return { command: :invalid }
322
623
  end
323
624
 
324
- return { command: "housekeep:all", json: json_flag } if all_flag
625
+ return { command: "housekeep:all", json: options[ :json ] } if options[ :all ]
325
626
 
326
627
  if argv.length > 1
327
628
  err.puts "#{BADGE} Too many arguments for housekeep. Use: carson housekeep [repo]"
@@ -329,11 +630,16 @@ module Carson
329
630
  end
330
631
 
331
632
  target = argv.shift
332
- return { command: "housekeep:target", target: target, json: json_flag } if target
633
+ return { command: "housekeep:target", target: target, json: options[ :json ] } if target
333
634
 
334
- { command: "housekeep", json: json_flag }
635
+ { command: "housekeep", json: options[ :json ] }
636
+ rescue OptionParser::ParseError => e
637
+ err.puts "#{BADGE} #{e.message}"
638
+ { command: :invalid }
335
639
  end
336
640
 
641
+ # --- govern ---
642
+
337
643
  def self.parse_govern_subcommand( argv:, err: )
338
644
  options = {
339
645
  dry_run: false,
@@ -342,12 +648,23 @@ module Carson
342
648
  }
343
649
  govern_parser = OptionParser.new do |opts|
344
650
  opts.banner = "Usage: carson govern [--dry-run] [--json] [--loop SECONDS]"
651
+ opts.separator ""
652
+ opts.separator "Portfolio-level PR triage loop."
653
+ opts.separator "Scans governed repositories, classifies open PRs, and takes action"
654
+ opts.separator "(merge, request review, or report). Runs once by default."
655
+ opts.separator ""
656
+ opts.separator "Options:"
345
657
  opts.on( "--dry-run", "Run all checks but do not merge or dispatch" ) { options[ :dry_run ] = true }
346
658
  opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
347
659
  opts.on( "--loop SECONDS", Integer, "Run continuously, sleeping SECONDS between cycles" ) do |s|
348
660
  err.puts( "#{BADGE} Error: --loop must be a positive integer" ) || ( return { command: :invalid } ) if s < 1
349
661
  options[ :loop_seconds ] = s
350
662
  end
663
+ opts.separator ""
664
+ opts.separator "Examples:"
665
+ opts.separator " carson govern Triage all governed repos once"
666
+ opts.separator " carson govern --dry-run Preview actions without applying them"
667
+ opts.separator " carson govern --loop 300 Run continuously every 5 minutes"
351
668
  end
352
669
  govern_parser.parse!( argv )
353
670
  unless argv.empty?
@@ -367,6 +684,8 @@ module Carson
367
684
  { command: :invalid }
368
685
  end
369
686
 
687
+ # --- dispatch ---
688
+
370
689
  def self.dispatch( parsed:, runtime: )
371
690
  command = parsed.fetch( :command )
372
691
  return Runtime::EXIT_ERROR if command == :invalid
@@ -425,6 +744,14 @@ module Carson
425
744
  json_output: parsed.fetch( :json, false ),
426
745
  loop_seconds: parsed.fetch( :loop_seconds, nil )
427
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 ) )
428
755
  else
429
756
  runtime.send( :puts_line, "Unknown command: #{command}" )
430
757
  Runtime::EXIT_ERROR
@@ -157,6 +157,54 @@ 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
+ failed += 1
180
+ next
181
+ end
182
+
183
+ begin
184
+ rt = build_scoped_runtime( repo_path: repo_path )
185
+ status = rt.audit!
186
+ case status
187
+ when EXIT_OK
188
+ puts_line "#{repo_name}: ok" unless verbose?
189
+ passed += 1
190
+ when EXIT_BLOCK
191
+ puts_line "#{repo_name}: BLOCK" unless verbose?
192
+ blocked += 1
193
+ else
194
+ puts_line "#{repo_name}: FAIL" unless verbose?
195
+ failed += 1
196
+ end
197
+ rescue StandardError => e
198
+ puts_line "#{repo_name}: FAIL (#{e.message})"
199
+ failed += 1
200
+ end
201
+ end
202
+
203
+ puts_line ""
204
+ puts_line "Audit all complete: #{passed} ok, #{blocked} blocked, #{failed} failed."
205
+ blocked.zero? && failed.zero? ? EXIT_OK : EXIT_BLOCK
206
+ end
207
+
160
208
  private
161
209
  def pr_and_check_report
162
210
  report = {
@@ -81,6 +81,8 @@ module Carson
81
81
  end
82
82
 
83
83
  # Re-applies hooks, templates, and audit across all governed repositories.
84
+ # Checks each repo for safety (active worktrees, uncommitted changes) and
85
+ # skips unsafe repos to avoid disrupting active work.
84
86
  def refresh_all!
85
87
  repos = config.govern_repos
86
88
  if repos.empty?
@@ -92,6 +94,7 @@ module Carson
92
94
  puts_line ""
93
95
  puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
94
96
  refreshed = 0
97
+ skipped = 0
95
98
  failed = 0
96
99
 
97
100
  repos.each do |repo_path|
@@ -102,6 +105,13 @@ module Carson
102
105
  next
103
106
  end
104
107
 
108
+ safety = portfolio_repo_safety( repo_path: repo_path )
109
+ unless safety.fetch( :safe )
110
+ puts_line "#{repo_name}: SKIP (#{safety.fetch( :reasons ).join( ', ' )})"
111
+ skipped += 1
112
+ next
113
+ end
114
+
105
115
  status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
106
116
  if status == EXIT_ERROR
107
117
  failed += 1
@@ -111,8 +121,11 @@ module Carson
111
121
  end
112
122
 
113
123
  puts_line ""
114
- puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
115
- failed.zero? ? EXIT_OK : EXIT_ERROR
124
+ parts = [ "#{refreshed} refreshed" ]
125
+ parts << "#{skipped} skipped" if skipped.positive?
126
+ parts << "#{failed} failed" if failed.positive?
127
+ puts_line "Refresh all complete: #{parts.join( ', ' )}."
128
+ failed.zero? && skipped.zero? ? EXIT_OK : EXIT_ERROR
116
129
  end
117
130
 
118
131
  def prune_all!
@@ -42,6 +42,49 @@ 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
+ failed += 1
64
+ next
65
+ end
66
+
67
+ begin
68
+ rt = build_scoped_runtime( repo_path: repo_path )
69
+ status = rt.sync!
70
+ if status == EXIT_OK
71
+ puts_line "#{repo_name}: ok" unless verbose?
72
+ synced += 1
73
+ else
74
+ puts_line "#{repo_name}: FAIL" unless verbose?
75
+ failed += 1
76
+ end
77
+ rescue StandardError => e
78
+ puts_line "#{repo_name}: FAIL (#{e.message})"
79
+ failed += 1
80
+ end
81
+ end
82
+
83
+ puts_line ""
84
+ puts_line "Sync all complete: #{synced} synced, #{failed} failed."
85
+ failed.zero? ? EXIT_OK : EXIT_ERROR
86
+ end
87
+
45
88
  private
46
89
 
47
90
  # Runs a git command, suppressing stdout/stderr in JSON mode to keep output clean.
@@ -43,6 +43,50 @@ 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
+ failed += 1
66
+ next
67
+ end
68
+
69
+ begin
70
+ rt = build_scoped_runtime( repo_path: repo_path )
71
+ status = rt.template_check!
72
+ if status == EXIT_OK
73
+ puts_line "#{repo_name}: in sync" unless verbose?
74
+ in_sync += 1
75
+ else
76
+ puts_line "#{repo_name}: DRIFT" unless verbose?
77
+ drifted += 1
78
+ end
79
+ rescue StandardError => e
80
+ puts_line "#{repo_name}: FAIL (#{e.message})"
81
+ failed += 1
82
+ end
83
+ end
84
+
85
+ puts_line ""
86
+ puts_line "Template check complete: #{in_sync} in sync, #{drifted} drifted, #{failed} failed."
87
+ drifted.zero? && failed.zero? ? EXIT_OK : EXIT_BLOCK
88
+ end
89
+
46
90
  # Applies managed template files as full-file writes from Carson sources.
47
91
  # Also removes superseded files that are no longer part of the managed set.
48
92
  def template_apply!( push_prep: false )
@@ -17,6 +17,65 @@ 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
+ repos.each do |repo_path|
53
+ repo_name = File.basename( repo_path )
54
+ unless Dir.exist?( repo_path )
55
+ puts_line "#{repo_name}: MISSING"
56
+ next
57
+ end
58
+
59
+ begin
60
+ rt = build_scoped_runtime( repo_path: repo_path )
61
+ data = rt.send( :gather_status )
62
+ branch = data.fetch( :branch )
63
+ dirty = branch.fetch( :dirty ) ? " (dirty)" : ""
64
+ worktrees = data.fetch( :worktrees )
65
+ gov = data.fetch( :governance )
66
+ parts = []
67
+ parts << branch.fetch( :name ) + dirty
68
+ parts << "#{worktrees.count} worktree#{plural_suffix( count: worktrees.count )}" if worktrees.any?
69
+ parts << "templates #{gov.fetch( :templates )}" unless gov.fetch( :templates ) == :in_sync
70
+ puts_line "#{repo_name}: #{parts.join( ' ' )}"
71
+ rescue StandardError => e
72
+ puts_line "#{repo_name}: FAIL (#{e.message})"
73
+ end
74
+ end
75
+
76
+ EXIT_OK
77
+ end
78
+
20
79
  private
21
80
 
22
81
  # Collects all status facets into a structured hash.
@@ -212,6 +212,48 @@ module Carson
212
212
  def gh_run( *args )
213
213
  github_adapter.run( *args )
214
214
  end
215
+
216
+ # --- Portfolio helpers (shared by all --all commands) ---
217
+
218
+ # Checks whether a governed repo is safe for batch operations.
219
+ # Returns { safe: true/false, reasons: [...] }.
220
+ # Safe means: no active worktrees beyond main, no uncommitted changes.
221
+ # Non-git directories pass through as safe — let the command handle the error.
222
+ def portfolio_repo_safety( repo_path: )
223
+ git = Adapters::Git.new( repo_root: repo_path )
224
+
225
+ # Non-git directories pass through — the calling command reports the real error.
226
+ stdout, _, git_ok, = git.run( "rev-parse", "--is-inside-work-tree" )
227
+ return { safe: true, reasons: [] } unless git_ok && stdout.to_s.strip == "true"
228
+
229
+ reasons = []
230
+
231
+ # Active worktrees beyond the main working tree.
232
+ rt = build_scoped_runtime( repo_path: repo_path )
233
+ worktrees = rt.send( :worktree_list )
234
+ main_root = rt.send( :realpath_safe, repo_path )
235
+ active = worktrees.reject { |wt| wt.fetch( :path ) == main_root }
236
+ if active.any?
237
+ reasons << "#{active.count} active worktree#{active.count == 1 ? '' : 's'}"
238
+ end
239
+
240
+ # Uncommitted changes in the main working tree.
241
+ stdout, _, success, = git.run( "status", "--porcelain" )
242
+ if success && !stdout.strip.empty?
243
+ reasons << "uncommitted changes"
244
+ end
245
+
246
+ { safe: reasons.empty?, reasons: reasons }
247
+ rescue StandardError => e
248
+ { safe: false, reasons: [ e.message ] }
249
+ end
250
+
251
+ # Creates a scoped Runtime for a governed repo with captured output.
252
+ def build_scoped_runtime( repo_path: )
253
+ buf = verbose? ? out : StringIO.new
254
+ err_buf = verbose? ? err : StringIO.new
255
+ Runtime.new( repo_root: repo_path, tool_root: tool_root, out: buf, err: err_buf, verbose: verbose? )
256
+ end
215
257
  end
216
258
  end
217
259
 
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.16.0
4
+ version: 3.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang