carson 3.20.0 → 3.21.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/README.md +11 -3
- data/RELEASE.md +14 -0
- data/VERSION +1 -1
- data/exe/carson +3 -3
- data/hooks/command-guard +56 -0
- data/hooks/pre-push +37 -1
- data/lib/carson/adapters/agent.rb +1 -0
- data/lib/carson/adapters/claude.rb +2 -0
- data/lib/carson/adapters/codex.rb +2 -0
- data/lib/carson/adapters/git.rb +2 -0
- data/lib/carson/adapters/github.rb +2 -0
- data/lib/carson/adapters/prompt.rb +2 -0
- data/lib/carson/cli.rb +415 -414
- data/lib/carson/config.rb +4 -3
- data/lib/carson/runtime/audit.rb +27 -27
- data/lib/carson/runtime/deliver.rb +7 -4
- data/lib/carson/runtime/govern.rb +27 -27
- data/lib/carson/runtime/housekeep.rb +15 -15
- data/lib/carson/runtime/local/hooks.rb +20 -0
- data/lib/carson/runtime/local/onboard.rb +17 -17
- data/lib/carson/runtime/local/prune.rb +13 -13
- data/lib/carson/runtime/local/sync.rb +6 -6
- data/lib/carson/runtime/local/template.rb +26 -25
- data/lib/carson/runtime/local/worktree.rb +26 -26
- data/lib/carson/runtime/local.rb +1 -0
- data/lib/carson/runtime/repos.rb +1 -1
- data/lib/carson/runtime/review/gate_support.rb +2 -2
- data/lib/carson/runtime/review/sweep_support.rb +2 -2
- data/lib/carson/runtime/review.rb +10 -8
- data/lib/carson/runtime/setup.rb +8 -6
- data/lib/carson/runtime/status.rb +20 -20
- data/lib/carson/runtime.rb +39 -25
- data/lib/carson/version.rb +1 -0
- data/lib/carson.rb +1 -0
- data/templates/.github/carson.md +7 -4
- metadata +2 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Detects template drift, applies canonical files, and propagates changes via PR.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Local
|
|
@@ -9,7 +10,7 @@ module Carson
|
|
|
9
10
|
".github/.mega-linter.yml"
|
|
10
11
|
].freeze
|
|
11
12
|
|
|
12
|
-
# Read-only template drift check; returns block when managed files are
|
|
13
|
+
# Read-only template drift check; returns block when managed files are output of sync.
|
|
13
14
|
def template_check!
|
|
14
15
|
fingerprint_status = block_if_outsider_fingerprints!
|
|
15
16
|
return fingerprint_status unless fingerprint_status.nil?
|
|
@@ -68,8 +69,8 @@ module Carson
|
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
begin
|
|
71
|
-
|
|
72
|
-
status =
|
|
72
|
+
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
73
|
+
status = scoped_runtime.template_check!
|
|
73
74
|
if status == EXIT_OK
|
|
74
75
|
puts_line "#{repo_name}: in sync" unless verbose?
|
|
75
76
|
clear_batch_success( command: "template_check", repo_path: repo_path )
|
|
@@ -78,9 +79,9 @@ module Carson
|
|
|
78
79
|
puts_line "#{repo_name}: DRIFT" unless verbose?
|
|
79
80
|
drifted += 1
|
|
80
81
|
end
|
|
81
|
-
rescue StandardError =>
|
|
82
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
83
|
-
record_batch_skip( command: "template_check", repo_path: repo_path, reason:
|
|
82
|
+
rescue StandardError => exception
|
|
83
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
84
|
+
record_batch_skip( command: "template_check", repo_path: repo_path, reason: exception.message )
|
|
84
85
|
failed += 1
|
|
85
86
|
end
|
|
86
87
|
end
|
|
@@ -171,9 +172,9 @@ module Carson
|
|
|
171
172
|
result = template_propagate_deliver!( worktree_dir: worktree_dir )
|
|
172
173
|
template_propagate_report!( result: result )
|
|
173
174
|
result
|
|
174
|
-
rescue StandardError =>
|
|
175
|
-
puts_verbose "template_propagate: error (#{
|
|
176
|
-
{ status: :error, reason:
|
|
175
|
+
rescue StandardError => exception
|
|
176
|
+
puts_verbose "template_propagate: error (#{exception.message})"
|
|
177
|
+
{ status: :error, reason: exception.message }
|
|
177
178
|
ensure
|
|
178
179
|
template_propagate_cleanup!( worktree_dir: worktree_dir ) if worktree_dir
|
|
179
180
|
end
|
|
@@ -181,12 +182,12 @@ module Carson
|
|
|
181
182
|
|
|
182
183
|
def template_propagate_create_worktree!
|
|
183
184
|
worktree_dir = File.join( Dir.tmpdir, "carson-template-sync-#{Process.pid}-#{Time.now.to_i}" )
|
|
184
|
-
|
|
185
|
+
worktree_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
185
186
|
|
|
186
187
|
git_system!( "fetch", config.git_remote, config.main_branch )
|
|
187
188
|
git_system!( "worktree", "add", "--detach", worktree_dir, "#{config.git_remote}/#{config.main_branch}" )
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
worktree_git.run( "checkout", "-B", TEMPLATE_SYNC_BRANCH )
|
|
190
|
+
worktree_git.run( "config", "core.hooksPath", "/dev/null" )
|
|
190
191
|
puts_verbose "template_propagate: worktree created at #{worktree_dir}"
|
|
191
192
|
worktree_dir
|
|
192
193
|
end
|
|
@@ -211,13 +212,13 @@ module Carson
|
|
|
211
212
|
end
|
|
212
213
|
|
|
213
214
|
def template_propagate_commit!( worktree_dir: )
|
|
214
|
-
|
|
215
|
-
|
|
215
|
+
worktree_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
216
|
+
worktree_git.run( "add", "--all" )
|
|
216
217
|
|
|
217
|
-
_, _, no_diff, =
|
|
218
|
+
_, _, no_diff, = worktree_git.run( "diff", "--cached", "--quiet" )
|
|
218
219
|
return false if no_diff
|
|
219
220
|
|
|
220
|
-
|
|
221
|
+
worktree_git.run( "commit", "-m", "chore: sync Carson #{Carson::VERSION} managed templates" )
|
|
221
222
|
puts_verbose "template_propagate: committed"
|
|
222
223
|
true
|
|
223
224
|
end
|
|
@@ -231,8 +232,8 @@ module Carson
|
|
|
231
232
|
end
|
|
232
233
|
|
|
233
234
|
def template_propagate_deliver_trunk!( worktree_dir: )
|
|
234
|
-
|
|
235
|
-
stdout_text, stderr_text, success, =
|
|
235
|
+
worktree_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
236
|
+
stdout_text, stderr_text, success, = worktree_git.run( "push", config.git_remote, "HEAD:refs/heads/#{config.main_branch}" )
|
|
236
237
|
unless success
|
|
237
238
|
error_text = stderr_text.to_s.strip
|
|
238
239
|
error_text = "push to #{config.main_branch} failed" if error_text.empty?
|
|
@@ -243,8 +244,8 @@ module Carson
|
|
|
243
244
|
end
|
|
244
245
|
|
|
245
246
|
def template_propagate_deliver_branch!( worktree_dir: )
|
|
246
|
-
|
|
247
|
-
stdout_text, stderr_text, success, =
|
|
247
|
+
worktree_git = Adapters::Git.new( repo_root: worktree_dir )
|
|
248
|
+
stdout_text, stderr_text, success, = worktree_git.run( "push", "--force-with-lease", config.git_remote, "#{TEMPLATE_SYNC_BRANCH}:#{TEMPLATE_SYNC_BRANCH}" )
|
|
248
249
|
unless success
|
|
249
250
|
error_text = stderr_text.to_s.strip
|
|
250
251
|
error_text = "push #{TEMPLATE_SYNC_BRANCH} failed" if error_text.empty?
|
|
@@ -296,8 +297,8 @@ module Carson
|
|
|
296
297
|
git_run( "worktree", "remove", "--force", worktree_dir ) unless safe_success
|
|
297
298
|
git_run( "branch", "-D", TEMPLATE_SYNC_BRANCH )
|
|
298
299
|
puts_verbose "template_propagate: worktree and local branch cleaned up"
|
|
299
|
-
rescue StandardError =>
|
|
300
|
-
puts_verbose "template_propagate: cleanup warning (#{
|
|
300
|
+
rescue StandardError => exception
|
|
301
|
+
puts_verbose "template_propagate: cleanup warning (#{exception.message})"
|
|
301
302
|
end
|
|
302
303
|
|
|
303
304
|
def template_propagate_report!( result: )
|
|
@@ -381,14 +382,14 @@ module Carson
|
|
|
381
382
|
def managed_dirty_paths
|
|
382
383
|
template_paths = config.template_managed_files + SUPERSEDED
|
|
383
384
|
linters_glob = Dir.glob( File.join( repo_root, ".github/linters/**/*" ) )
|
|
384
|
-
.select { |
|
|
385
|
-
.map { |
|
|
385
|
+
.select { |path| File.file?( path ) }
|
|
386
|
+
.map { |path| path.delete_prefix( "#{repo_root}/" ) }
|
|
386
387
|
candidates = ( template_paths + linters_glob ).uniq
|
|
387
388
|
return [] if candidates.empty?
|
|
388
389
|
|
|
389
390
|
stdout_text, = git_capture_soft( "status", "--porcelain", "--", *candidates )
|
|
390
391
|
stdout_text.to_s.lines
|
|
391
|
-
.map { |
|
|
392
|
+
.map { |line| line[ 3.. ].strip }
|
|
392
393
|
.reject( &:empty? )
|
|
393
394
|
end
|
|
394
395
|
end
|
|
@@ -15,11 +15,11 @@ module Carson
|
|
|
15
15
|
# Uses main_worktree_root so this works even when called from inside a worktree.
|
|
16
16
|
def worktree_create!( name:, json_output: false )
|
|
17
17
|
worktrees_dir = File.join( main_worktree_root, ".claude", "worktrees" )
|
|
18
|
-
|
|
18
|
+
worktree_path = File.join( worktrees_dir, name )
|
|
19
19
|
|
|
20
|
-
if Dir.exist?(
|
|
20
|
+
if Dir.exist?( worktree_path )
|
|
21
21
|
return worktree_finish(
|
|
22
|
-
result: { command: "worktree create", status: "error", name: name, path:
|
|
22
|
+
result: { command: "worktree create", status: "error", name: name, path: worktree_path,
|
|
23
23
|
error: "worktree already exists: #{name}",
|
|
24
24
|
recovery: "carson worktree remove #{name}, then retry" },
|
|
25
25
|
exit_code: EXIT_ERROR, json_output: json_output
|
|
@@ -42,9 +42,9 @@ module Carson
|
|
|
42
42
|
|
|
43
43
|
# Create the worktree with a new branch based on the main branch.
|
|
44
44
|
FileUtils.mkdir_p( worktrees_dir )
|
|
45
|
-
_,
|
|
46
|
-
unless
|
|
47
|
-
error_text =
|
|
45
|
+
_, worktree_stderr, worktree_success, = git_run( "worktree", "add", worktree_path, "-b", name, base )
|
|
46
|
+
unless worktree_success
|
|
47
|
+
error_text = worktree_stderr.to_s.strip
|
|
48
48
|
error_text = "unable to create worktree" if error_text.empty?
|
|
49
49
|
return worktree_finish(
|
|
50
50
|
result: { command: "worktree create", status: "error", name: name,
|
|
@@ -54,7 +54,7 @@ module Carson
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
worktree_finish(
|
|
57
|
-
result: { command: "worktree create", status: "ok", name: name, path:
|
|
57
|
+
result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: name },
|
|
58
58
|
exit_code: EXIT_OK, json_output: json_output
|
|
59
59
|
)
|
|
60
60
|
end
|
|
@@ -66,7 +66,7 @@ module Carson
|
|
|
66
66
|
fingerprint_status = block_if_outsider_fingerprints!
|
|
67
67
|
unless fingerprint_status.nil?
|
|
68
68
|
if json_output
|
|
69
|
-
|
|
69
|
+
output.puts JSON.pretty_generate( {
|
|
70
70
|
command: "worktree remove", status: "block",
|
|
71
71
|
error: "Carson-owned artefacts detected in host repository",
|
|
72
72
|
recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
|
|
@@ -202,9 +202,9 @@ module Carson
|
|
|
202
202
|
end
|
|
203
203
|
return if agent_prefixes.empty?
|
|
204
204
|
|
|
205
|
-
worktrees.each do |
|
|
206
|
-
path =
|
|
207
|
-
branch =
|
|
205
|
+
worktrees.each do |worktree|
|
|
206
|
+
path = worktree.fetch( :path )
|
|
207
|
+
branch = worktree.fetch( :branch, nil )
|
|
208
208
|
next unless branch
|
|
209
209
|
next unless agent_prefixes.any? { |prefix| path.start_with?( prefix ) }
|
|
210
210
|
next if cwd_inside_worktree?( worktree_path: path )
|
|
@@ -270,7 +270,7 @@ module Carson
|
|
|
270
270
|
result[ :exit_code ] = exit_code
|
|
271
271
|
|
|
272
272
|
if json_output
|
|
273
|
-
|
|
273
|
+
output.puts JSON.pretty_generate( result )
|
|
274
274
|
else
|
|
275
275
|
print_worktree_human( result: result )
|
|
276
276
|
end
|
|
@@ -310,9 +310,9 @@ module Carson
|
|
|
310
310
|
# Uses realpath on both sides to handle symlink differences (e.g. /tmp vs /private/tmp).
|
|
311
311
|
def cwd_inside_worktree?( worktree_path: )
|
|
312
312
|
cwd = realpath_safe( Dir.pwd )
|
|
313
|
-
|
|
314
|
-
normalised_wt = File.join(
|
|
315
|
-
cwd ==
|
|
313
|
+
worktree = realpath_safe( worktree_path )
|
|
314
|
+
normalised_wt = File.join( worktree, "" )
|
|
315
|
+
cwd == worktree || cwd.start_with?( normalised_wt )
|
|
316
316
|
rescue StandardError
|
|
317
317
|
false
|
|
318
318
|
end
|
|
@@ -378,7 +378,7 @@ module Carson
|
|
|
378
378
|
nil
|
|
379
379
|
end
|
|
380
380
|
|
|
381
|
-
# Returns the branch checked
|
|
381
|
+
# Returns the branch checked output in the worktree that contains the process CWD,
|
|
382
382
|
# or nil if CWD is not inside any worktree. Used by prune to proactively
|
|
383
383
|
# protect the CWD worktree's branch from deletion.
|
|
384
384
|
# Matches the longest (most specific) path because worktree directories
|
|
@@ -387,12 +387,12 @@ module Carson
|
|
|
387
387
|
cwd = realpath_safe( Dir.pwd )
|
|
388
388
|
best_branch = nil
|
|
389
389
|
best_length = -1
|
|
390
|
-
worktree_list.each do |
|
|
391
|
-
|
|
392
|
-
normalised = File.join(
|
|
393
|
-
if ( cwd ==
|
|
394
|
-
best_branch =
|
|
395
|
-
best_length =
|
|
390
|
+
worktree_list.each do |worktree|
|
|
391
|
+
worktree_path = worktree.fetch( :path )
|
|
392
|
+
normalised = File.join( worktree_path, "" )
|
|
393
|
+
if ( cwd == worktree_path || cwd.start_with?( normalised ) ) && worktree_path.length > best_length
|
|
394
|
+
best_branch = worktree.fetch( :branch, nil )
|
|
395
|
+
best_length = worktree_path.length
|
|
396
396
|
end
|
|
397
397
|
end
|
|
398
398
|
best_branch
|
|
@@ -426,7 +426,7 @@ module Carson
|
|
|
426
426
|
existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
|
|
427
427
|
return if existing.lines.any? { |line| line.strip == ".claude/" }
|
|
428
428
|
|
|
429
|
-
File.open( exclude_path, "a" ) { |
|
|
429
|
+
File.open( exclude_path, "a" ) { |file| file.puts ".claude/" }
|
|
430
430
|
rescue StandardError
|
|
431
431
|
# Best-effort — do not block worktree creation if exclude fails.
|
|
432
432
|
end
|
|
@@ -451,14 +451,14 @@ module Carson
|
|
|
451
451
|
# Compares using realpath to handle symlink differences.
|
|
452
452
|
def worktree_registered?( path: )
|
|
453
453
|
canonical = realpath_safe( path )
|
|
454
|
-
worktree_list.any? { |
|
|
454
|
+
worktree_list.any? { |worktree| worktree.fetch( :path ) == canonical }
|
|
455
455
|
end
|
|
456
456
|
|
|
457
|
-
# Returns the branch name checked
|
|
457
|
+
# Returns the branch name checked output in a worktree, or nil.
|
|
458
458
|
# Compares using realpath to handle symlink differences.
|
|
459
459
|
def worktree_branch( path: )
|
|
460
460
|
canonical = realpath_safe( path )
|
|
461
|
-
entry = worktree_list.find { |
|
|
461
|
+
entry = worktree_list.find { |worktree| worktree.fetch( :path ) == canonical }
|
|
462
462
|
entry&.fetch( :branch, nil )
|
|
463
463
|
end
|
|
464
464
|
|
data/lib/carson/runtime/local.rb
CHANGED
data/lib/carson/runtime/repos.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Carson
|
|
|
9
9
|
repos = config.govern_repos
|
|
10
10
|
|
|
11
11
|
if json_output
|
|
12
|
-
|
|
12
|
+
output.puts JSON.pretty_generate( { command: "repos", repos: repos } )
|
|
13
13
|
else
|
|
14
14
|
if repos.empty?
|
|
15
15
|
puts_line "No governed repositories."
|
|
@@ -176,8 +176,8 @@ module Carson
|
|
|
176
176
|
)
|
|
177
177
|
puts_verbose "review_gate_report_markdown: #{markdown_path}"
|
|
178
178
|
puts_verbose "review_gate_report_json: #{json_path}"
|
|
179
|
-
rescue StandardError =>
|
|
180
|
-
puts_verbose "review_gate_report_write: SKIP (#{
|
|
179
|
+
rescue StandardError => exception
|
|
180
|
+
puts_verbose "review_gate_report_write: SKIP (#{exception.message})"
|
|
181
181
|
end
|
|
182
182
|
|
|
183
183
|
# Human-readable review gate report for merge-readiness evidence.
|
|
@@ -208,8 +208,8 @@ module Carson
|
|
|
208
208
|
)
|
|
209
209
|
puts_verbose "review_sweep_report_markdown: #{markdown_path}"
|
|
210
210
|
puts_verbose "review_sweep_report_json: #{json_path}"
|
|
211
|
-
rescue StandardError =>
|
|
212
|
-
puts_verbose "review_sweep_report_write: SKIP (#{
|
|
211
|
+
rescue StandardError => exception
|
|
212
|
+
puts_verbose "review_sweep_report_write: SKIP (#{exception.message})"
|
|
213
213
|
end
|
|
214
214
|
|
|
215
215
|
# Human-readable scheduled sweep report.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Implements the review gate (merge readiness) and sweep (late activity scan) workflows.
|
|
1
2
|
require_relative "review/query_text"
|
|
2
3
|
require_relative "review/data_access"
|
|
3
4
|
require_relative "review/gate_support"
|
|
@@ -6,6 +7,7 @@ require_relative "review/utility"
|
|
|
6
7
|
|
|
7
8
|
module Carson
|
|
8
9
|
class Runtime
|
|
10
|
+
# PR review gate and sweep workflow.
|
|
9
11
|
module Review
|
|
10
12
|
include QueryText
|
|
11
13
|
include DataAccess
|
|
@@ -123,11 +125,11 @@ module Carson
|
|
|
123
125
|
end
|
|
124
126
|
block_reasons.each { |reason| puts_line "BLOCK: #{reason}" }
|
|
125
127
|
EXIT_BLOCK
|
|
126
|
-
rescue JSON::ParserError =>
|
|
127
|
-
puts_line "ERROR: invalid gh JSON response (#{
|
|
128
|
+
rescue JSON::ParserError => exception
|
|
129
|
+
puts_line "ERROR: invalid gh JSON response (#{exception.message})."
|
|
128
130
|
EXIT_ERROR
|
|
129
|
-
rescue StandardError =>
|
|
130
|
-
puts_line "ERROR: #{
|
|
131
|
+
rescue StandardError => exception
|
|
132
|
+
puts_line "ERROR: #{exception.message}"
|
|
131
133
|
EXIT_ERROR
|
|
132
134
|
end
|
|
133
135
|
|
|
@@ -176,11 +178,11 @@ module Carson
|
|
|
176
178
|
end
|
|
177
179
|
puts_line "BLOCK: actionable late review activity detected."
|
|
178
180
|
EXIT_BLOCK
|
|
179
|
-
rescue JSON::ParserError =>
|
|
180
|
-
puts_line "ERROR: invalid gh JSON response (#{
|
|
181
|
+
rescue JSON::ParserError => exception
|
|
182
|
+
puts_line "ERROR: invalid gh JSON response (#{exception.message})."
|
|
181
183
|
EXIT_ERROR
|
|
182
|
-
rescue StandardError =>
|
|
183
|
-
puts_line "ERROR: #{
|
|
184
|
+
rescue StandardError => exception
|
|
185
|
+
puts_line "ERROR: #{exception.message}"
|
|
184
186
|
EXIT_ERROR
|
|
185
187
|
end
|
|
186
188
|
end
|
data/lib/carson/runtime/setup.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
# Handles first-time setup, onboard, offboard, refresh, and config persistence.
|
|
1
2
|
require "set"
|
|
2
3
|
require "uri"
|
|
3
4
|
|
|
4
5
|
module Carson
|
|
5
6
|
class Runtime
|
|
7
|
+
# First-time setup, onboard, offboard, and refresh operations.
|
|
6
8
|
module Setup
|
|
7
9
|
WELL_KNOWN_REMOTES = %w[origin github upstream].freeze
|
|
8
10
|
|
|
@@ -173,8 +175,8 @@ module Carson
|
|
|
173
175
|
options.each_with_index do |option, index|
|
|
174
176
|
puts_line " #{index + 1}) #{option.fetch( :label )}"
|
|
175
177
|
end
|
|
176
|
-
|
|
177
|
-
|
|
178
|
+
output.print "#{BADGE} Choice [#{default + 1}]: "
|
|
179
|
+
output.flush
|
|
178
180
|
raw = self.in.gets
|
|
179
181
|
return options[ default ].fetch( :value ) if raw.nil?
|
|
180
182
|
|
|
@@ -190,8 +192,8 @@ module Carson
|
|
|
190
192
|
end
|
|
191
193
|
|
|
192
194
|
def prompt_custom_value( label: )
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
output.print "#{BADGE} #{label}: "
|
|
196
|
+
output.flush
|
|
195
197
|
raw = self.in.gets
|
|
196
198
|
return nil if raw.nil?
|
|
197
199
|
|
|
@@ -383,8 +385,8 @@ module Carson
|
|
|
383
385
|
# Reusable Y/n prompt following existing prompt_choice conventions.
|
|
384
386
|
def prompt_yes_no( default: true )
|
|
385
387
|
hint = default ? "Y/n" : "y/N"
|
|
386
|
-
|
|
387
|
-
|
|
388
|
+
output.print "#{BADGE} [#{hint}]: "
|
|
389
|
+
output.flush
|
|
388
390
|
raw = self.in.gets
|
|
389
391
|
return default if raw.nil?
|
|
390
392
|
|
|
@@ -9,7 +9,7 @@ module Carson
|
|
|
9
9
|
data = gather_status
|
|
10
10
|
|
|
11
11
|
if json_output
|
|
12
|
-
|
|
12
|
+
output.puts JSON.pretty_generate( data )
|
|
13
13
|
else
|
|
14
14
|
print_status( data: data )
|
|
15
15
|
end
|
|
@@ -35,14 +35,14 @@ module Carson
|
|
|
35
35
|
next
|
|
36
36
|
end
|
|
37
37
|
begin
|
|
38
|
-
|
|
39
|
-
data =
|
|
38
|
+
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
39
|
+
data = scoped_runtime.send( :gather_status )
|
|
40
40
|
results << { name: repo_name, status: "ok" }.merge( data )
|
|
41
|
-
rescue StandardError =>
|
|
42
|
-
results << { name: repo_name, status: "error", error:
|
|
41
|
+
rescue StandardError => exception
|
|
42
|
+
results << { name: repo_name, status: "error", error: exception.message }
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
|
-
|
|
45
|
+
output.puts JSON.pretty_generate( { command: "status", repos: results } )
|
|
46
46
|
return EXIT_OK
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -58,8 +58,8 @@ module Carson
|
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
begin
|
|
61
|
-
|
|
62
|
-
data =
|
|
61
|
+
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
62
|
+
data = scoped_runtime.send( :gather_status )
|
|
63
63
|
branch = data.fetch( :branch )
|
|
64
64
|
dirty = branch.fetch( :dirty ) ? " (dirty)" : ""
|
|
65
65
|
worktrees = data.fetch( :worktrees )
|
|
@@ -72,9 +72,9 @@ module Carson
|
|
|
72
72
|
|
|
73
73
|
# Show pending operations for this repo.
|
|
74
74
|
repo_pending = status_pending_for_repo( all_pending: all_pending, repo_path: repo_path )
|
|
75
|
-
repo_pending.each { |
|
|
76
|
-
rescue StandardError =>
|
|
77
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
75
|
+
repo_pending.each { |description| puts_line " pending: #{description}" }
|
|
76
|
+
rescue StandardError => exception
|
|
77
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
@@ -159,14 +159,14 @@ module Carson
|
|
|
159
159
|
def gather_worktree_info
|
|
160
160
|
entries = worktree_list
|
|
161
161
|
|
|
162
|
-
# Filter
|
|
162
|
+
# Filter output the main worktree (the repository root itself).
|
|
163
163
|
# Use realpath for comparison — git returns canonical paths that may differ from repo_root.
|
|
164
164
|
canonical_root = realpath_safe( repo_root )
|
|
165
|
-
entries.reject { it.fetch( :path ) == canonical_root }.map do |
|
|
165
|
+
entries.reject { it.fetch( :path ) == canonical_root }.map do |worktree|
|
|
166
166
|
{
|
|
167
|
-
path:
|
|
168
|
-
name: File.basename(
|
|
169
|
-
branch:
|
|
167
|
+
path: worktree.fetch( :path ),
|
|
168
|
+
name: File.basename( worktree.fetch( :path ) ),
|
|
169
|
+
branch: worktree.fetch( :branch, nil )
|
|
170
170
|
}
|
|
171
171
|
end
|
|
172
172
|
end
|
|
@@ -222,7 +222,7 @@ module Carson
|
|
|
222
222
|
stdout, _, success, = git_run( "branch", "-vv" )
|
|
223
223
|
return { count: 0 } unless success
|
|
224
224
|
|
|
225
|
-
gone_branches = stdout.lines.select { |
|
|
225
|
+
gone_branches = stdout.lines.select { |line| line.include?( ": gone]" ) }
|
|
226
226
|
{ count: gone_branches.size }
|
|
227
227
|
end
|
|
228
228
|
|
|
@@ -252,9 +252,9 @@ module Carson
|
|
|
252
252
|
if worktrees.any?
|
|
253
253
|
puts_line ""
|
|
254
254
|
puts_line "Worktrees:"
|
|
255
|
-
worktrees.each do |
|
|
256
|
-
branch_label =
|
|
257
|
-
puts_line " #{
|
|
255
|
+
worktrees.each do |worktree|
|
|
256
|
+
branch_label = worktree.fetch( :branch ) || "(detached)"
|
|
257
|
+
puts_line " #{worktree.fetch( :name )} #{branch_label}"
|
|
258
258
|
end
|
|
259
259
|
end
|
|
260
260
|
|
data/lib/carson/runtime.rb
CHANGED
|
@@ -23,11 +23,11 @@ module Carson
|
|
|
23
23
|
DISPOSITION_TOKENS = %w[accepted rejected deferred].freeze
|
|
24
24
|
|
|
25
25
|
# Runtime wiring for repository context, tool paths, and output streams.
|
|
26
|
-
def initialize( repo_root:, tool_root:,
|
|
26
|
+
def initialize( repo_root:, tool_root:, output:, error:, in_stream: $stdin, verbose: false )
|
|
27
27
|
@repo_root = repo_root
|
|
28
28
|
@tool_root = tool_root
|
|
29
|
-
@
|
|
30
|
-
@
|
|
29
|
+
@output = output
|
|
30
|
+
@error = error
|
|
31
31
|
@in = in_stream
|
|
32
32
|
@verbose = verbose
|
|
33
33
|
@config = Config.load( repo_root: repo_root )
|
|
@@ -40,7 +40,7 @@ module Carson
|
|
|
40
40
|
|
|
41
41
|
private
|
|
42
42
|
|
|
43
|
-
attr_reader :repo_root, :tool_root, :
|
|
43
|
+
attr_reader :repo_root, :tool_root, :output, :error, :in, :config, :git_adapter, :github_adapter
|
|
44
44
|
|
|
45
45
|
# Returns true when full diagnostic output is enabled via --verbose.
|
|
46
46
|
def verbose?
|
|
@@ -55,12 +55,12 @@ module Carson
|
|
|
55
55
|
# Runs a block with all output captured (suppressed from the user).
|
|
56
56
|
# Returns the block's return value; output is silently discarded.
|
|
57
57
|
def with_captured_output
|
|
58
|
-
|
|
59
|
-
@
|
|
60
|
-
@
|
|
58
|
+
saved_output, saved_error = @output, @error
|
|
59
|
+
@output = StringIO.new
|
|
60
|
+
@error = StringIO.new
|
|
61
61
|
yield
|
|
62
62
|
ensure
|
|
63
|
-
@
|
|
63
|
+
@output, @error = saved_output, saved_error
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# Returns true when the repository has at least one commit (HEAD exists).
|
|
@@ -95,9 +95,9 @@ module Carson
|
|
|
95
95
|
# Prefixes non-empty lines with the Carson badge (⧓).
|
|
96
96
|
def puts_line( message )
|
|
97
97
|
if message.to_s.strip.empty?
|
|
98
|
-
|
|
98
|
+
output.puts ""
|
|
99
99
|
else
|
|
100
|
-
|
|
100
|
+
output.puts "#{BADGE} #{message}"
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
103
|
|
|
@@ -160,6 +160,20 @@ module Carson
|
|
|
160
160
|
text.empty? ? default : text
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
+
# Temporarily sets an environment variable for the duration of the block.
|
|
164
|
+
# Restores the previous value (or deletes the key) when the block completes.
|
|
165
|
+
def with_env_var( key, value )
|
|
166
|
+
previous = ENV.key?( key ) ? ENV.fetch( key ) : nil
|
|
167
|
+
ENV[ key ] = value
|
|
168
|
+
yield
|
|
169
|
+
ensure
|
|
170
|
+
if previous.nil?
|
|
171
|
+
ENV.delete( key )
|
|
172
|
+
else
|
|
173
|
+
ENV[ key ] = previous
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
163
177
|
# Chooses best available error text from gh stderr/stdout.
|
|
164
178
|
def gh_error_text( stdout_text:, stderr_text:, fallback: )
|
|
165
179
|
combined = [ stderr_text.to_s.strip, stdout_text.to_s.strip ].reject( &:empty? ).join( " | " )
|
|
@@ -182,8 +196,8 @@ module Carson
|
|
|
182
196
|
# Runs git command, streams outputs, and raises on non-zero exit.
|
|
183
197
|
def git_system!( *args )
|
|
184
198
|
stdout_text, stderr_text, success, = git_run( *args )
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
output.print stdout_text unless stdout_text.empty?
|
|
200
|
+
error.print stderr_text unless stderr_text.empty?
|
|
187
201
|
raise "git #{args.join( ' ' )} failed" unless success
|
|
188
202
|
end
|
|
189
203
|
|
|
@@ -191,7 +205,7 @@ module Carson
|
|
|
191
205
|
def git_capture!( *args )
|
|
192
206
|
stdout_text, stderr_text, success, = git_run( *args )
|
|
193
207
|
unless success
|
|
194
|
-
|
|
208
|
+
error.print stderr_text unless stderr_text.empty?
|
|
195
209
|
raise "git #{args.join( ' ' )} failed"
|
|
196
210
|
end
|
|
197
211
|
stdout_text
|
|
@@ -234,9 +248,9 @@ module Carson
|
|
|
234
248
|
def save_batch_pending( data )
|
|
235
249
|
path = batch_pending_path
|
|
236
250
|
FileUtils.mkdir_p( File.dirname( path ) )
|
|
237
|
-
|
|
238
|
-
File.write(
|
|
239
|
-
File.rename(
|
|
251
|
+
temporary_path = "#{path}.tmp"
|
|
252
|
+
File.write( temporary_path, JSON.pretty_generate( data ) )
|
|
253
|
+
File.rename( temporary_path, path )
|
|
240
254
|
end
|
|
241
255
|
|
|
242
256
|
# Adds or updates an entry in the pending log, incrementing attempts.
|
|
@@ -293,10 +307,10 @@ module Carson
|
|
|
293
307
|
reasons = []
|
|
294
308
|
|
|
295
309
|
# Active worktrees beyond the main working tree.
|
|
296
|
-
|
|
297
|
-
worktrees =
|
|
298
|
-
main_root =
|
|
299
|
-
active = worktrees.reject { |
|
|
310
|
+
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
311
|
+
worktrees = scoped_runtime.send( :worktree_list )
|
|
312
|
+
main_root = scoped_runtime.send( :realpath_safe, repo_path )
|
|
313
|
+
active = worktrees.reject { |worktree| worktree.fetch( :path ) == main_root }
|
|
300
314
|
if active.any?
|
|
301
315
|
reasons << "#{active.count} active worktree#{active.count == 1 ? '' : 's'}"
|
|
302
316
|
end
|
|
@@ -308,15 +322,15 @@ module Carson
|
|
|
308
322
|
end
|
|
309
323
|
|
|
310
324
|
{ safe: reasons.empty?, reasons: reasons }
|
|
311
|
-
rescue StandardError =>
|
|
312
|
-
{ safe: false, reasons: [
|
|
325
|
+
rescue StandardError => exception
|
|
326
|
+
{ safe: false, reasons: [ exception.message ] }
|
|
313
327
|
end
|
|
314
328
|
|
|
315
329
|
# Creates a scoped Runtime for a governed repo with captured output.
|
|
316
330
|
def build_scoped_runtime( repo_path: )
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
Runtime.new( repo_root: repo_path, tool_root: tool_root,
|
|
331
|
+
buffer = verbose? ? output : StringIO.new
|
|
332
|
+
error_buffer = verbose? ? error : StringIO.new
|
|
333
|
+
Runtime.new( repo_root: repo_path, tool_root: tool_root, output: buffer, error: error_buffer, verbose: verbose? )
|
|
320
334
|
end
|
|
321
335
|
end
|
|
322
336
|
end
|
data/lib/carson/version.rb
CHANGED
data/lib/carson.rb
CHANGED
data/templates/.github/carson.md
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
This repository is governed by [Carson](https://github.com/wanghailei/carson), an autonomous governance runtime. Carson lives on the maintainer's workstation, not inside this repository.
|
|
4
4
|
|
|
5
|
-
## What Carson Does Not Do
|
|
6
|
-
|
|
7
|
-
Carson has no `commit`, `push`, or `pr` commands. Use `git` and `gh` for those. Carson audits and governs; you execute.
|
|
8
|
-
|
|
9
5
|
## Commands
|
|
10
6
|
|
|
7
|
+
**Delivery:**
|
|
8
|
+
```bash
|
|
9
|
+
carson deliver # push branch, create PR
|
|
10
|
+
carson deliver --merge # push, create PR, merge if CI green and review clear
|
|
11
|
+
```
|
|
12
|
+
|
|
11
13
|
**Before committing:**
|
|
12
14
|
```bash
|
|
13
15
|
carson audit # full governance check — run before every commit
|
|
@@ -24,6 +26,7 @@ carson review gate # block until actionable review findings are resolved
|
|
|
24
26
|
```bash
|
|
25
27
|
carson sync # fast-forward local main from remote
|
|
26
28
|
carson prune # remove stale branches (safer than git branch -d on squash repos)
|
|
29
|
+
carson housekeep # sync + prune + sweep stale worktrees
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
## Exit Codes
|