carson 3.17.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 +19 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +25 -7
- 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,25 @@ 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
|
+
|
|
8
27
|
## 3.17.0
|
|
9
28
|
|
|
10
29
|
### 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 )
|
|
@@ -409,6 +409,7 @@ module Carson
|
|
|
409
409
|
end
|
|
410
410
|
|
|
411
411
|
action = argv.shift
|
|
412
|
+
return { command: "template:check:all" } if action == "check" && argv.include?( "--all" )
|
|
412
413
|
return { command: "template:#{action}" } unless action == "apply"
|
|
413
414
|
|
|
414
415
|
options = { push_prep: false }
|
|
@@ -438,26 +439,29 @@ module Carson
|
|
|
438
439
|
# --- audit ---
|
|
439
440
|
|
|
440
441
|
def self.parse_audit_command( argv:, err: )
|
|
441
|
-
options = { json: false }
|
|
442
|
+
options = { json: false, all: false }
|
|
442
443
|
audit_parser = OptionParser.new do |opts|
|
|
443
|
-
opts.banner = "Usage: carson audit [--json]"
|
|
444
|
+
opts.banner = "Usage: carson audit [--all] [--json]"
|
|
444
445
|
opts.separator ""
|
|
445
446
|
opts.separator "Run pre-commit health checks on the repository."
|
|
446
447
|
opts.separator "Validates hooks, main-branch sync, PR status, and CI baseline."
|
|
447
448
|
opts.separator "Exits with a non-zero status when policy violations are found."
|
|
448
449
|
opts.separator ""
|
|
449
450
|
opts.separator "Options:"
|
|
451
|
+
opts.on( "--all", "Audit all governed repositories" ) { options[ :all ] = true }
|
|
450
452
|
opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
451
453
|
opts.separator ""
|
|
452
454
|
opts.separator "Examples:"
|
|
453
455
|
opts.separator " carson audit Check repository health (also the default command)"
|
|
454
456
|
opts.separator " carson audit --json Structured output for agent consumption"
|
|
457
|
+
opts.separator " carson audit --all Audit all governed repos"
|
|
455
458
|
end
|
|
456
459
|
audit_parser.parse!( argv )
|
|
457
460
|
unless argv.empty?
|
|
458
461
|
err.puts "#{BADGE} Unexpected arguments for audit: #{argv.join( ' ' )}"
|
|
459
462
|
return { command: :invalid }
|
|
460
463
|
end
|
|
464
|
+
return { command: "audit:all" } if options[ :all ]
|
|
461
465
|
{ command: "audit", json: options[ :json ] }
|
|
462
466
|
rescue OptionParser::ParseError => e
|
|
463
467
|
err.puts "#{BADGE} #{e.message}"
|
|
@@ -467,25 +471,28 @@ module Carson
|
|
|
467
471
|
# --- sync ---
|
|
468
472
|
|
|
469
473
|
def self.parse_sync_command( argv:, err: )
|
|
470
|
-
options = { json: false }
|
|
474
|
+
options = { json: false, all: false }
|
|
471
475
|
sync_parser = OptionParser.new do |opts|
|
|
472
|
-
opts.banner = "Usage: carson sync [--json]"
|
|
476
|
+
opts.banner = "Usage: carson sync [--all] [--json]"
|
|
473
477
|
opts.separator ""
|
|
474
478
|
opts.separator "Sync the local main branch with the remote."
|
|
475
479
|
opts.separator "Fetches and fast-forwards main without switching branches."
|
|
476
480
|
opts.separator ""
|
|
477
481
|
opts.separator "Options:"
|
|
482
|
+
opts.on( "--all", "Sync all governed repositories" ) { options[ :all ] = true }
|
|
478
483
|
opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
479
484
|
opts.separator ""
|
|
480
485
|
opts.separator "Examples:"
|
|
481
486
|
opts.separator " carson sync Pull latest changes from remote main"
|
|
482
487
|
opts.separator " carson sync --json Structured output for agent consumption"
|
|
488
|
+
opts.separator " carson sync --all Sync all governed repos"
|
|
483
489
|
end
|
|
484
490
|
sync_parser.parse!( argv )
|
|
485
491
|
unless argv.empty?
|
|
486
492
|
err.puts "#{BADGE} Unexpected arguments for sync: #{argv.join( ' ' )}"
|
|
487
493
|
return { command: :invalid }
|
|
488
494
|
end
|
|
495
|
+
return { command: "sync:all" } if options[ :all ]
|
|
489
496
|
{ command: "sync", json: options[ :json ] }
|
|
490
497
|
rescue OptionParser::ParseError => e
|
|
491
498
|
err.puts "#{BADGE} #{e.message}"
|
|
@@ -495,25 +502,28 @@ module Carson
|
|
|
495
502
|
# --- status ---
|
|
496
503
|
|
|
497
504
|
def self.parse_status_command( argv:, err: )
|
|
498
|
-
options = { json: false }
|
|
505
|
+
options = { json: false, all: false }
|
|
499
506
|
status_parser = OptionParser.new do |opts|
|
|
500
|
-
opts.banner = "Usage: carson status [--json]"
|
|
507
|
+
opts.banner = "Usage: carson status [--all] [--json]"
|
|
501
508
|
opts.separator ""
|
|
502
509
|
opts.separator "Show the current state of the repository."
|
|
503
510
|
opts.separator "Reports branch, worktrees, open PRs, stale branches, and version."
|
|
504
511
|
opts.separator ""
|
|
505
512
|
opts.separator "Options:"
|
|
513
|
+
opts.on( "--all", "Show status for all governed repositories" ) { options[ :all ] = true }
|
|
506
514
|
opts.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
|
|
507
515
|
opts.separator ""
|
|
508
516
|
opts.separator "Examples:"
|
|
509
517
|
opts.separator " carson status Quick overview of repository state"
|
|
510
518
|
opts.separator " carson status --json Structured output for agent consumption"
|
|
519
|
+
opts.separator " carson status --all Portfolio-wide status overview"
|
|
511
520
|
end
|
|
512
521
|
status_parser.parse!( argv )
|
|
513
522
|
unless argv.empty?
|
|
514
523
|
err.puts "#{BADGE} Unexpected arguments for status: #{argv.join( ' ' )}"
|
|
515
524
|
return { command: :invalid }
|
|
516
525
|
end
|
|
526
|
+
return { command: "status:all", json: options[ :json ] } if options[ :all ]
|
|
517
527
|
{ command: "status", json: options[ :json ] }
|
|
518
528
|
rescue OptionParser::ParseError => e
|
|
519
529
|
err.puts "#{BADGE} #{e.message}"
|
|
@@ -734,6 +744,14 @@ module Carson
|
|
|
734
744
|
json_output: parsed.fetch( :json, false ),
|
|
735
745
|
loop_seconds: parsed.fetch( :loop_seconds, nil )
|
|
736
746
|
)
|
|
747
|
+
when "template:check:all"
|
|
748
|
+
runtime.template_check_all!
|
|
749
|
+
when "audit:all"
|
|
750
|
+
runtime.audit_all!
|
|
751
|
+
when "sync:all"
|
|
752
|
+
runtime.sync_all!
|
|
753
|
+
when "status:all"
|
|
754
|
+
runtime.status_all!( json_output: parsed.fetch( :json, false ) )
|
|
737
755
|
else
|
|
738
756
|
runtime.send( :puts_line, "Unknown command: #{command}" )
|
|
739
757
|
Runtime::EXIT_ERROR
|
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
|
|