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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5474bed385b09305a9a467655447faa7dc5b908486cf69d3124cca99cd7cc2f2
4
- data.tar.gz: 4efa36d52f6642967abc25b35ae5a9bcfcfb97072064478873c99693c90a54cd
3
+ metadata.gz: 5c18ffa310a6fdb0b0e1581eba83b9ab7dcb8c88a546c391f83200871e17d7f6
4
+ data.tar.gz: 77c0d6e97438a1f9512e3dbef51a22981caf1b6b0126ffe8ab3bdbf3c7ec3bbb
5
5
  SHA512:
6
- metadata.gz: 51a319323b1c96b15085cbdb5380a7b2b27c088cf4bade168e5b64947734f9aec2ddea05e5a359bee75b93eb253ba275280c4cb9959165457b559b022d61bcd8
7
- data.tar.gz: 8665f26fb62718e6b5b6df1addeedee0caa9130c956e7f40f8aba2e79048438dacf4e49a4cd460cbc1c4a90f7ef85a7916531ec4edae3fc345f3864ff1c7c3f9
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.17.0
1
+ 3.18.0
data/lib/carson/cli.rb CHANGED
@@ -12,7 +12,7 @@ module Carson
12
12
  return Runtime::EXIT_OK
13
13
  end
14
14
 
15
- if %w[repos refresh:all prune:all housekeep:all housekeep:target].include?( command )
15
+ if %w[repos refresh:all prune:all housekeep:all housekeep:target template:check:all audit:all sync:all status:all].include?( command )
16
16
  verbose = parsed.fetch( :verbose, false )
17
17
  runtime = Runtime.new( repo_root: repo_root, tool_root: tool_root, out: out, err: err, verbose: verbose )
18
18
  return dispatch( parsed: parsed, runtime: runtime )
@@ -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
@@ -157,6 +157,54 @@ module Carson
157
157
  exit_code
158
158
  end
159
159
 
160
+ # Runs audit across all governed repositories.
161
+ def audit_all!
162
+ repos = config.govern_repos
163
+ if repos.empty?
164
+ puts_line "No governed repositories configured."
165
+ puts_line " Run carson onboard in each repo to register."
166
+ return EXIT_ERROR
167
+ end
168
+
169
+ puts_line ""
170
+ puts_line "Audit all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
171
+ passed = 0
172
+ blocked = 0
173
+ failed = 0
174
+
175
+ repos.each do |repo_path|
176
+ repo_name = File.basename( repo_path )
177
+ unless Dir.exist?( repo_path )
178
+ puts_line "#{repo_name}: FAIL (path not found)"
179
+ failed += 1
180
+ next
181
+ end
182
+
183
+ begin
184
+ rt = build_scoped_runtime( repo_path: repo_path )
185
+ status = rt.audit!
186
+ case status
187
+ when EXIT_OK
188
+ puts_line "#{repo_name}: ok" unless verbose?
189
+ passed += 1
190
+ when EXIT_BLOCK
191
+ puts_line "#{repo_name}: BLOCK" unless verbose?
192
+ blocked += 1
193
+ else
194
+ puts_line "#{repo_name}: FAIL" unless verbose?
195
+ failed += 1
196
+ end
197
+ rescue StandardError => e
198
+ puts_line "#{repo_name}: FAIL (#{e.message})"
199
+ failed += 1
200
+ end
201
+ end
202
+
203
+ puts_line ""
204
+ puts_line "Audit all complete: #{passed} ok, #{blocked} blocked, #{failed} failed."
205
+ blocked.zero? && failed.zero? ? EXIT_OK : EXIT_BLOCK
206
+ end
207
+
160
208
  private
161
209
  def pr_and_check_report
162
210
  report = {
@@ -81,6 +81,8 @@ module Carson
81
81
  end
82
82
 
83
83
  # Re-applies hooks, templates, and audit across all governed repositories.
84
+ # Checks each repo for safety (active worktrees, uncommitted changes) and
85
+ # skips unsafe repos to avoid disrupting active work.
84
86
  def refresh_all!
85
87
  repos = config.govern_repos
86
88
  if repos.empty?
@@ -92,6 +94,7 @@ module Carson
92
94
  puts_line ""
93
95
  puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
94
96
  refreshed = 0
97
+ skipped = 0
95
98
  failed = 0
96
99
 
97
100
  repos.each do |repo_path|
@@ -102,6 +105,13 @@ module Carson
102
105
  next
103
106
  end
104
107
 
108
+ safety = portfolio_repo_safety( repo_path: repo_path )
109
+ unless safety.fetch( :safe )
110
+ puts_line "#{repo_name}: SKIP (#{safety.fetch( :reasons ).join( ', ' )})"
111
+ skipped += 1
112
+ next
113
+ end
114
+
105
115
  status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
106
116
  if status == EXIT_ERROR
107
117
  failed += 1
@@ -111,8 +121,11 @@ module Carson
111
121
  end
112
122
 
113
123
  puts_line ""
114
- puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
115
- failed.zero? ? EXIT_OK : EXIT_ERROR
124
+ parts = [ "#{refreshed} refreshed" ]
125
+ parts << "#{skipped} skipped" if skipped.positive?
126
+ parts << "#{failed} failed" if failed.positive?
127
+ puts_line "Refresh all complete: #{parts.join( ', ' )}."
128
+ failed.zero? && skipped.zero? ? EXIT_OK : EXIT_ERROR
116
129
  end
117
130
 
118
131
  def prune_all!
@@ -42,6 +42,49 @@ module Carson
42
42
  git_system!( "switch", start_branch ) if switched && branch_exists?( branch_name: start_branch )
43
43
  end
44
44
 
45
+ # Syncs main branch across all governed repositories.
46
+ def sync_all!
47
+ repos = config.govern_repos
48
+ if repos.empty?
49
+ puts_line "No governed repositories configured."
50
+ puts_line " Run carson onboard in each repo to register."
51
+ return EXIT_ERROR
52
+ end
53
+
54
+ puts_line ""
55
+ puts_line "Sync all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
56
+ synced = 0
57
+ failed = 0
58
+
59
+ repos.each do |repo_path|
60
+ repo_name = File.basename( repo_path )
61
+ unless Dir.exist?( repo_path )
62
+ puts_line "#{repo_name}: FAIL (path not found)"
63
+ failed += 1
64
+ next
65
+ end
66
+
67
+ begin
68
+ rt = build_scoped_runtime( repo_path: repo_path )
69
+ status = rt.sync!
70
+ if status == EXIT_OK
71
+ puts_line "#{repo_name}: ok" unless verbose?
72
+ synced += 1
73
+ else
74
+ puts_line "#{repo_name}: FAIL" unless verbose?
75
+ failed += 1
76
+ end
77
+ rescue StandardError => e
78
+ puts_line "#{repo_name}: FAIL (#{e.message})"
79
+ failed += 1
80
+ end
81
+ end
82
+
83
+ puts_line ""
84
+ puts_line "Sync all complete: #{synced} synced, #{failed} failed."
85
+ failed.zero? ? EXIT_OK : EXIT_ERROR
86
+ end
87
+
45
88
  private
46
89
 
47
90
  # Runs a git command, suppressing stdout/stderr in JSON mode to keep output clean.
@@ -43,6 +43,50 @@ module Carson
43
43
  ( drift_count + stale_count ).positive? ? EXIT_BLOCK : EXIT_OK
44
44
  end
45
45
 
46
+ # Read-only template drift check across all governed repositories.
47
+ def template_check_all!
48
+ repos = config.govern_repos
49
+ if repos.empty?
50
+ puts_line "No governed repositories configured."
51
+ puts_line " Run carson onboard in each repo to register."
52
+ return EXIT_ERROR
53
+ end
54
+
55
+ puts_line ""
56
+ puts_line "Template check all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
57
+ in_sync = 0
58
+ drifted = 0
59
+ failed = 0
60
+
61
+ repos.each do |repo_path|
62
+ repo_name = File.basename( repo_path )
63
+ unless Dir.exist?( repo_path )
64
+ puts_line "#{repo_name}: FAIL (path not found)"
65
+ failed += 1
66
+ next
67
+ end
68
+
69
+ begin
70
+ rt = build_scoped_runtime( repo_path: repo_path )
71
+ status = rt.template_check!
72
+ if status == EXIT_OK
73
+ puts_line "#{repo_name}: in sync" unless verbose?
74
+ in_sync += 1
75
+ else
76
+ puts_line "#{repo_name}: DRIFT" unless verbose?
77
+ drifted += 1
78
+ end
79
+ rescue StandardError => e
80
+ puts_line "#{repo_name}: FAIL (#{e.message})"
81
+ failed += 1
82
+ end
83
+ end
84
+
85
+ puts_line ""
86
+ puts_line "Template check complete: #{in_sync} in sync, #{drifted} drifted, #{failed} failed."
87
+ drifted.zero? && failed.zero? ? EXIT_OK : EXIT_BLOCK
88
+ end
89
+
46
90
  # Applies managed template files as full-file writes from Carson sources.
47
91
  # Also removes superseded files that are no longer part of the managed set.
48
92
  def template_apply!( push_prep: false )
@@ -17,6 +17,65 @@ module Carson
17
17
  EXIT_OK
18
18
  end
19
19
 
20
+ # Portfolio-wide status overview across all governed repositories.
21
+ def status_all!( json_output: false )
22
+ repos = config.govern_repos
23
+ if repos.empty?
24
+ puts_line "No governed repositories configured."
25
+ puts_line " Run carson onboard in each repo to register."
26
+ return EXIT_ERROR
27
+ end
28
+
29
+ if json_output
30
+ results = []
31
+ repos.each do |repo_path|
32
+ repo_name = File.basename( repo_path )
33
+ unless Dir.exist?( repo_path )
34
+ results << { name: repo_name, status: "error", error: "path not found" }
35
+ next
36
+ end
37
+ begin
38
+ rt = build_scoped_runtime( repo_path: repo_path )
39
+ data = rt.send( :gather_status )
40
+ results << { name: repo_name, status: "ok" }.merge( data )
41
+ rescue StandardError => e
42
+ results << { name: repo_name, status: "error", error: e.message }
43
+ end
44
+ end
45
+ out.puts JSON.pretty_generate( { command: "status", repos: results } )
46
+ return EXIT_OK
47
+ end
48
+
49
+ puts_line "Carson #{Carson::VERSION} — Portfolio (#{repos.length} repo#{plural_suffix( count: repos.length )})"
50
+ puts_line ""
51
+
52
+ repos.each do |repo_path|
53
+ repo_name = File.basename( repo_path )
54
+ unless Dir.exist?( repo_path )
55
+ puts_line "#{repo_name}: MISSING"
56
+ next
57
+ end
58
+
59
+ begin
60
+ rt = build_scoped_runtime( repo_path: repo_path )
61
+ data = rt.send( :gather_status )
62
+ branch = data.fetch( :branch )
63
+ dirty = branch.fetch( :dirty ) ? " (dirty)" : ""
64
+ worktrees = data.fetch( :worktrees )
65
+ gov = data.fetch( :governance )
66
+ parts = []
67
+ parts << branch.fetch( :name ) + dirty
68
+ parts << "#{worktrees.count} worktree#{plural_suffix( count: worktrees.count )}" if worktrees.any?
69
+ parts << "templates #{gov.fetch( :templates )}" unless gov.fetch( :templates ) == :in_sync
70
+ puts_line "#{repo_name}: #{parts.join( ' ' )}"
71
+ rescue StandardError => e
72
+ puts_line "#{repo_name}: FAIL (#{e.message})"
73
+ end
74
+ end
75
+
76
+ EXIT_OK
77
+ end
78
+
20
79
  private
21
80
 
22
81
  # Collects all status facets into a structured hash.
@@ -212,6 +212,48 @@ module Carson
212
212
  def gh_run( *args )
213
213
  github_adapter.run( *args )
214
214
  end
215
+
216
+ # --- Portfolio helpers (shared by all --all commands) ---
217
+
218
+ # Checks whether a governed repo is safe for batch operations.
219
+ # Returns { safe: true/false, reasons: [...] }.
220
+ # Safe means: no active worktrees beyond main, no uncommitted changes.
221
+ # Non-git directories pass through as safe — let the command handle the error.
222
+ def portfolio_repo_safety( repo_path: )
223
+ git = Adapters::Git.new( repo_root: repo_path )
224
+
225
+ # Non-git directories pass through — the calling command reports the real error.
226
+ stdout, _, git_ok, = git.run( "rev-parse", "--is-inside-work-tree" )
227
+ return { safe: true, reasons: [] } unless git_ok && stdout.to_s.strip == "true"
228
+
229
+ reasons = []
230
+
231
+ # Active worktrees beyond the main working tree.
232
+ rt = build_scoped_runtime( repo_path: repo_path )
233
+ worktrees = rt.send( :worktree_list )
234
+ main_root = rt.send( :realpath_safe, repo_path )
235
+ active = worktrees.reject { |wt| wt.fetch( :path ) == main_root }
236
+ if active.any?
237
+ reasons << "#{active.count} active worktree#{active.count == 1 ? '' : 's'}"
238
+ end
239
+
240
+ # Uncommitted changes in the main working tree.
241
+ stdout, _, success, = git.run( "status", "--porcelain" )
242
+ if success && !stdout.strip.empty?
243
+ reasons << "uncommitted changes"
244
+ end
245
+
246
+ { safe: reasons.empty?, reasons: reasons }
247
+ rescue StandardError => e
248
+ { safe: false, reasons: [ e.message ] }
249
+ end
250
+
251
+ # Creates a scoped Runtime for a governed repo with captured output.
252
+ def build_scoped_runtime( repo_path: )
253
+ buf = verbose? ? out : StringIO.new
254
+ err_buf = verbose? ? err : StringIO.new
255
+ Runtime.new( repo_root: repo_path, tool_root: tool_root, out: buf, err: err_buf, verbose: verbose? )
256
+ end
215
257
  end
216
258
  end
217
259
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.17.0
4
+ version: 3.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang