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 +4 -4
- data/API.md +16 -1
- data/MANUAL.md +18 -2
- data/RELEASE.md +30 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +391 -64
- data/lib/carson/runtime/audit.rb +48 -0
- data/lib/carson/runtime/local/onboard.rb +15 -2
- data/lib/carson/runtime/local/sync.rb +43 -0
- data/lib/carson/runtime/local/template.rb +44 -0
- data/lib/carson/runtime/status.rb +59 -0
- data/lib/carson/runtime.rb +42 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5c18ffa310a6fdb0b0e1581eba83b9ab7dcb8c88a546c391f83200871e17d7f6
|
|
4
|
+
data.tar.gz: 77c0d6e97438a1f9512e3dbef51a22981caf1b6b0126ffe8ab3bdbf3c7ec3bbb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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,
|
|
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
|
|
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:,
|
|
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,
|
|
79
|
-
when "onboard"
|
|
80
|
-
|
|
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,
|
|
105
|
+
parse_refresh_command( argv: argv, err: err )
|
|
83
106
|
when "template"
|
|
84
|
-
parse_template_subcommand( argv: argv,
|
|
107
|
+
parse_template_subcommand( argv: argv, err: err )
|
|
85
108
|
when "prune"
|
|
86
|
-
parse_prune_command( argv: argv,
|
|
109
|
+
parse_prune_command( argv: argv, err: err )
|
|
87
110
|
when "worktree"
|
|
88
|
-
parse_worktree_subcommand( argv: argv,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
|
137
|
-
err.puts
|
|
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:
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
|
246
|
+
err.puts refresh_parser
|
|
155
247
|
return { command: :invalid }
|
|
156
248
|
end
|
|
157
249
|
|
|
158
|
-
return { command: "refresh:all" } if
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
217
|
-
err.puts
|
|
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: "
|
|
375
|
+
{ command: "review:#{action}" }
|
|
376
|
+
rescue OptionParser::ParseError => e
|
|
377
|
+
err.puts "#{BADGE} #{e.message}"
|
|
378
|
+
{ command: :invalid }
|
|
221
379
|
end
|
|
222
380
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
317
|
-
|
|
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
|
|
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:
|
|
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:
|
|
633
|
+
return { command: "housekeep:target", target: target, json: options[ :json ] } if target
|
|
333
634
|
|
|
334
|
-
{ command: "housekeep", json:
|
|
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
|
data/lib/carson/runtime/audit.rb
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
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.
|
data/lib/carson/runtime.rb
CHANGED
|
@@ -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
|
|