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