carson 3.19.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 +25 -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 +84 -84
- data/lib/carson/runtime/deliver.rb +27 -24
- data/lib/carson/runtime/govern.rb +29 -29
- 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 +76 -33
- data/lib/carson/runtime/local.rb +1 -0
- data/lib/carson/runtime/repos.rb +1 -1
- data/lib/carson/runtime/review/data_access.rb +1 -0
- data/lib/carson/runtime/review/gate_support.rb +15 -14
- data/lib/carson/runtime/review/query_text.rb +1 -0
- data/lib/carson/runtime/review/sweep_support.rb +5 -4
- data/lib/carson/runtime/review/utility.rb +2 -1
- data/lib/carson/runtime/review.rb +10 -8
- data/lib/carson/runtime/setup.rb +12 -10
- 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
|
@@ -164,12 +164,12 @@ module Carson
|
|
|
164
164
|
end
|
|
165
165
|
|
|
166
166
|
begin
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
status =
|
|
167
|
+
buffer = verbose? ? output : StringIO.new
|
|
168
|
+
error_buffer = verbose? ? error : StringIO.new
|
|
169
|
+
scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: buffer, error: error_buffer, verbose: verbose? )
|
|
170
|
+
status = scoped_runtime.prune!
|
|
171
171
|
unless verbose?
|
|
172
|
-
summary =
|
|
172
|
+
summary = buffer.string.lines.last.to_s.strip
|
|
173
173
|
puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
|
|
174
174
|
end
|
|
175
175
|
if status == EXIT_ERROR
|
|
@@ -179,9 +179,9 @@ module Carson
|
|
|
179
179
|
clear_batch_success( command: "prune", repo_path: repo_path )
|
|
180
180
|
succeeded += 1
|
|
181
181
|
end
|
|
182
|
-
rescue StandardError =>
|
|
183
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
184
|
-
record_batch_skip( command: "prune", repo_path: repo_path, reason:
|
|
182
|
+
rescue StandardError => exception
|
|
183
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
184
|
+
record_batch_skip( command: "prune", repo_path: repo_path, reason: exception.message )
|
|
185
185
|
failed += 1
|
|
186
186
|
end
|
|
187
187
|
end
|
|
@@ -294,7 +294,7 @@ module Carson
|
|
|
294
294
|
def onboard_run_audit!
|
|
295
295
|
audit_error = nil
|
|
296
296
|
audit_status = with_captured_output { audit! }
|
|
297
|
-
rescue StandardError =>
|
|
297
|
+
rescue StandardError => exception
|
|
298
298
|
audit_error = e
|
|
299
299
|
audit_status = EXIT_OK
|
|
300
300
|
ensure
|
|
@@ -339,17 +339,17 @@ module Carson
|
|
|
339
339
|
# Refreshes a single governed repository using a scoped Runtime.
|
|
340
340
|
def refresh_single_repo( repo_path:, repo_name: )
|
|
341
341
|
if verbose?
|
|
342
|
-
|
|
342
|
+
scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: output, error: error, verbose: true )
|
|
343
343
|
else
|
|
344
|
-
|
|
344
|
+
scoped_runtime = Runtime.new( repo_root: repo_path, tool_root: tool_root, output: StringIO.new, error: StringIO.new )
|
|
345
345
|
end
|
|
346
|
-
status =
|
|
346
|
+
status = scoped_runtime.refresh!
|
|
347
347
|
label = refresh_status_label( status: status )
|
|
348
|
-
sync_suffix = refresh_sync_suffix( result:
|
|
348
|
+
sync_suffix = refresh_sync_suffix( result: scoped_runtime.template_sync_result )
|
|
349
349
|
puts_line "#{repo_name}: #{label}#{sync_suffix}"
|
|
350
350
|
status
|
|
351
|
-
rescue StandardError =>
|
|
352
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
351
|
+
rescue StandardError => exception
|
|
352
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
353
353
|
EXIT_ERROR
|
|
354
354
|
end
|
|
355
355
|
|
|
@@ -376,8 +376,8 @@ module Carson
|
|
|
376
376
|
git_system!( "config", "--unset", "core.hooksPath" )
|
|
377
377
|
puts_verbose "hooks_path_unset: core.hooksPath"
|
|
378
378
|
EXIT_OK
|
|
379
|
-
rescue StandardError =>
|
|
380
|
-
puts_line "ERROR: unable to update core.hooksPath (#{
|
|
379
|
+
rescue StandardError => exception
|
|
380
|
+
puts_line "ERROR: unable to update core.hooksPath (#{exception.message})"
|
|
381
381
|
EXIT_ERROR
|
|
382
382
|
end
|
|
383
383
|
|
|
@@ -8,7 +8,7 @@ module Carson
|
|
|
8
8
|
fingerprint_status = block_if_outsider_fingerprints!
|
|
9
9
|
unless fingerprint_status.nil?
|
|
10
10
|
if json_output
|
|
11
|
-
|
|
11
|
+
output.puts JSON.pretty_generate( {
|
|
12
12
|
command: "prune", status: "block",
|
|
13
13
|
error: "Carson-owned artefacts detected in host repository",
|
|
14
14
|
recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
|
|
@@ -51,7 +51,7 @@ module Carson
|
|
|
51
51
|
result[ :exit_code ] = exit_code
|
|
52
52
|
|
|
53
53
|
if json_output
|
|
54
|
-
|
|
54
|
+
output.puts JSON.pretty_generate( result )
|
|
55
55
|
else
|
|
56
56
|
print_prune_human( counters: counters )
|
|
57
57
|
end
|
|
@@ -116,7 +116,7 @@ module Carson
|
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def prune_skip_stale_branch( type:, branch:, upstream: )
|
|
119
|
-
reason = { protected: "protected branch", current: "current branch", cwd_worktree: "checked
|
|
119
|
+
reason = { protected: "protected branch", current: "current branch", cwd_worktree: "checked output in CWD worktree" }.fetch( type, type.to_s )
|
|
120
120
|
status = { protected: "skip_protected_branch", current: "skip_current_branch", cwd_worktree: "skip_cwd_worktree_branch" }.fetch( type, "skip_#{type}" )
|
|
121
121
|
puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
|
|
122
122
|
{ action: :skipped, branch: branch, upstream: upstream, type: "stale", reason: reason }
|
|
@@ -135,7 +135,7 @@ module Carson
|
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
def prune_safe_delete_success( branch:, upstream:, stdout_text: )
|
|
138
|
-
|
|
138
|
+
output.print stdout_text if verbose? && !stdout_text.empty?
|
|
139
139
|
puts_verbose "deleted_local_branch: #{branch} (upstream=#{upstream})"
|
|
140
140
|
{ action: :deleted, branch: branch, upstream: upstream, type: "stale", reason: "upstream gone" }
|
|
141
141
|
end
|
|
@@ -154,7 +154,7 @@ module Carson
|
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
|
|
157
|
-
|
|
157
|
+
output.print force_stdout if verbose? && !force_stdout.empty?
|
|
158
158
|
puts_verbose "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
|
|
159
159
|
{ action: :deleted, branch: branch, upstream: upstream, type: "stale", reason: "force deleted with PR evidence" }
|
|
160
160
|
end
|
|
@@ -195,9 +195,9 @@ module Carson
|
|
|
195
195
|
error_text.to_s.downcase.include?( "used by worktree" )
|
|
196
196
|
end
|
|
197
197
|
|
|
198
|
-
# Returns the worktree path for a branch, or nil if not checked
|
|
198
|
+
# Returns the worktree path for a branch, or nil if not checked output in any worktree.
|
|
199
199
|
def worktree_path_for_branch( branch: )
|
|
200
|
-
entry = worktree_list.find { |
|
|
200
|
+
entry = worktree_list.find { |worktree| worktree.fetch( :branch, nil ) == branch }
|
|
201
201
|
entry&.fetch( :path, nil )
|
|
202
202
|
end
|
|
203
203
|
|
|
@@ -307,7 +307,7 @@ module Carson
|
|
|
307
307
|
return { action: :skipped, branch: branch, upstream: upstream, type: "absorbed", reason: error_text }
|
|
308
308
|
end
|
|
309
309
|
|
|
310
|
-
|
|
310
|
+
output.print force_stdout if verbose? && !force_stdout.empty?
|
|
311
311
|
|
|
312
312
|
remote_branch = upstream.sub( "#{config.git_remote}/", "" )
|
|
313
313
|
git_run( "push", config.git_remote, "--delete", remote_branch )
|
|
@@ -372,7 +372,7 @@ module Carson
|
|
|
372
372
|
|
|
373
373
|
force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
|
|
374
374
|
if force_success
|
|
375
|
-
|
|
375
|
+
output.print force_stdout if verbose? && !force_stdout.empty?
|
|
376
376
|
puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
|
|
377
377
|
return { action: :deleted, branch: branch, upstream: "", type: "orphan", reason: "merged PR evidence found" }
|
|
378
378
|
end
|
|
@@ -486,10 +486,10 @@ module Carson
|
|
|
486
486
|
return [ nil, "no merged PR evidence for branch tip #{branch_tip_sha} into #{config.main_branch}" ] if latest.nil?
|
|
487
487
|
|
|
488
488
|
[ latest, nil ]
|
|
489
|
-
rescue JSON::ParserError =>
|
|
490
|
-
[ nil, "invalid gh JSON response (#{
|
|
491
|
-
rescue StandardError =>
|
|
492
|
-
[ nil,
|
|
489
|
+
rescue JSON::ParserError => exception
|
|
490
|
+
[ nil, "invalid gh JSON response (#{exception.message})" ]
|
|
491
|
+
rescue StandardError => exception
|
|
492
|
+
[ nil, exception.message ]
|
|
493
493
|
end
|
|
494
494
|
end
|
|
495
495
|
end
|
|
@@ -66,8 +66,8 @@ module Carson
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
begin
|
|
69
|
-
|
|
70
|
-
status =
|
|
69
|
+
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
70
|
+
status = scoped_runtime.sync!
|
|
71
71
|
if status == EXIT_OK
|
|
72
72
|
puts_line "#{repo_name}: ok" unless verbose?
|
|
73
73
|
clear_batch_success( command: "sync", repo_path: repo_path )
|
|
@@ -77,9 +77,9 @@ module Carson
|
|
|
77
77
|
record_batch_skip( command: "sync", repo_path: repo_path, reason: "sync failed" )
|
|
78
78
|
failed += 1
|
|
79
79
|
end
|
|
80
|
-
rescue StandardError =>
|
|
81
|
-
puts_line "#{repo_name}: FAIL (#{
|
|
82
|
-
record_batch_skip( command: "sync", repo_path: repo_path, reason:
|
|
80
|
+
rescue StandardError => exception
|
|
81
|
+
puts_line "#{repo_name}: FAIL (#{exception.message})"
|
|
82
|
+
record_batch_skip( command: "sync", repo_path: repo_path, reason: exception.message )
|
|
83
83
|
failed += 1
|
|
84
84
|
end
|
|
85
85
|
end
|
|
@@ -106,7 +106,7 @@ module Carson
|
|
|
106
106
|
result[ :exit_code ] = exit_code
|
|
107
107
|
|
|
108
108
|
if json_output
|
|
109
|
-
|
|
109
|
+
output.puts JSON.pretty_generate( result )
|
|
110
110
|
else
|
|
111
111
|
print_sync_human( result: result )
|
|
112
112
|
end
|
|
@@ -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
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Safe worktree lifecycle management for coding agents.
|
|
2
2
|
# Two operations: create and remove. Create auto-syncs main before branching.
|
|
3
|
-
# Remove is safe by default — guards against CWD-inside-worktree
|
|
4
|
-
# commits. Content-aware: allows removal after
|
|
3
|
+
# Remove is safe by default — guards against CWD-inside-worktree, cross-process
|
|
4
|
+
# CWD holds, and unpushed commits. Content-aware: allows removal after
|
|
5
|
+
# squash/rebase merge without --force.
|
|
5
6
|
# Supports --json for machine-readable structured output with recovery commands.
|
|
6
7
|
module Carson
|
|
7
8
|
class Runtime
|
|
@@ -14,11 +15,11 @@ module Carson
|
|
|
14
15
|
# Uses main_worktree_root so this works even when called from inside a worktree.
|
|
15
16
|
def worktree_create!( name:, json_output: false )
|
|
16
17
|
worktrees_dir = File.join( main_worktree_root, ".claude", "worktrees" )
|
|
17
|
-
|
|
18
|
+
worktree_path = File.join( worktrees_dir, name )
|
|
18
19
|
|
|
19
|
-
if Dir.exist?(
|
|
20
|
+
if Dir.exist?( worktree_path )
|
|
20
21
|
return worktree_finish(
|
|
21
|
-
result: { command: "worktree create", status: "error", name: name, path:
|
|
22
|
+
result: { command: "worktree create", status: "error", name: name, path: worktree_path,
|
|
22
23
|
error: "worktree already exists: #{name}",
|
|
23
24
|
recovery: "carson worktree remove #{name}, then retry" },
|
|
24
25
|
exit_code: EXIT_ERROR, json_output: json_output
|
|
@@ -41,9 +42,9 @@ module Carson
|
|
|
41
42
|
|
|
42
43
|
# Create the worktree with a new branch based on the main branch.
|
|
43
44
|
FileUtils.mkdir_p( worktrees_dir )
|
|
44
|
-
_,
|
|
45
|
-
unless
|
|
46
|
-
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
|
|
47
48
|
error_text = "unable to create worktree" if error_text.empty?
|
|
48
49
|
return worktree_finish(
|
|
49
50
|
result: { command: "worktree create", status: "error", name: name,
|
|
@@ -53,7 +54,7 @@ module Carson
|
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
worktree_finish(
|
|
56
|
-
result: { command: "worktree create", status: "ok", name: name, path:
|
|
57
|
+
result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: name },
|
|
57
58
|
exit_code: EXIT_OK, json_output: json_output
|
|
58
59
|
)
|
|
59
60
|
end
|
|
@@ -65,7 +66,7 @@ module Carson
|
|
|
65
66
|
fingerprint_status = block_if_outsider_fingerprints!
|
|
66
67
|
unless fingerprint_status.nil?
|
|
67
68
|
if json_output
|
|
68
|
-
|
|
69
|
+
output.puts JSON.pretty_generate( {
|
|
69
70
|
command: "worktree remove", status: "block",
|
|
70
71
|
error: "Carson-owned artefacts detected in host repository",
|
|
71
72
|
recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
|
|
@@ -78,12 +79,12 @@ module Carson
|
|
|
78
79
|
resolved_path = resolve_worktree_path( worktree_path: worktree_path )
|
|
79
80
|
|
|
80
81
|
# Missing directory: worktree was destroyed externally (e.g. gh pr merge
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
# --delete-branch). Clean up the stale git registration and delete the branch.
|
|
83
|
+
if !Dir.exist?( resolved_path ) && worktree_registered?( path: resolved_path )
|
|
84
|
+
return worktree_remove_missing!( resolved_path: resolved_path, json_output: json_output )
|
|
85
|
+
end
|
|
85
86
|
|
|
86
|
-
|
|
87
|
+
unless worktree_registered?( path: resolved_path )
|
|
87
88
|
return worktree_finish(
|
|
88
89
|
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
89
90
|
error: "#{resolved_path} is not a registered worktree",
|
|
@@ -104,6 +105,18 @@ module Carson
|
|
|
104
105
|
)
|
|
105
106
|
end
|
|
106
107
|
|
|
108
|
+
# Safety: refuse if another process has its CWD inside the worktree.
|
|
109
|
+
# Protects against cross-process CWD crashes (e.g. an agent session
|
|
110
|
+
# removed by a separate cleanup process while the agent's shell is inside).
|
|
111
|
+
if worktree_held_by_other_process?( worktree_path: resolved_path )
|
|
112
|
+
return worktree_finish(
|
|
113
|
+
result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
|
|
114
|
+
error: "another process has its working directory inside this worktree",
|
|
115
|
+
recovery: "wait for the other session to finish, then retry" },
|
|
116
|
+
exit_code: EXIT_BLOCK, json_output: json_output
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
107
120
|
branch = worktree_branch( path: resolved_path )
|
|
108
121
|
puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
|
|
109
122
|
|
|
@@ -189,12 +202,13 @@ module Carson
|
|
|
189
202
|
end
|
|
190
203
|
return if agent_prefixes.empty?
|
|
191
204
|
|
|
192
|
-
worktrees.each do |
|
|
193
|
-
path =
|
|
194
|
-
branch =
|
|
205
|
+
worktrees.each do |worktree|
|
|
206
|
+
path = worktree.fetch( :path )
|
|
207
|
+
branch = worktree.fetch( :branch, nil )
|
|
195
208
|
next unless branch
|
|
196
209
|
next unless agent_prefixes.any? { |prefix| path.start_with?( prefix ) }
|
|
197
210
|
next if cwd_inside_worktree?( worktree_path: path )
|
|
211
|
+
next if worktree_held_by_other_process?( worktree_path: path )
|
|
198
212
|
next unless branch_absorbed_into_main?( branch: branch )
|
|
199
213
|
|
|
200
214
|
# Remove the worktree (no --force: refuses if dirty working tree).
|
|
@@ -256,7 +270,7 @@ module Carson
|
|
|
256
270
|
result[ :exit_code ] = exit_code
|
|
257
271
|
|
|
258
272
|
if json_output
|
|
259
|
-
|
|
273
|
+
output.puts JSON.pretty_generate( result )
|
|
260
274
|
else
|
|
261
275
|
print_worktree_human( result: result )
|
|
262
276
|
end
|
|
@@ -296,9 +310,38 @@ module Carson
|
|
|
296
310
|
# Uses realpath on both sides to handle symlink differences (e.g. /tmp vs /private/tmp).
|
|
297
311
|
def cwd_inside_worktree?( worktree_path: )
|
|
298
312
|
cwd = realpath_safe( Dir.pwd )
|
|
299
|
-
|
|
300
|
-
normalised_wt = File.join(
|
|
301
|
-
cwd ==
|
|
313
|
+
worktree = realpath_safe( worktree_path )
|
|
314
|
+
normalised_wt = File.join( worktree, "" )
|
|
315
|
+
cwd == worktree || cwd.start_with?( normalised_wt )
|
|
316
|
+
rescue StandardError
|
|
317
|
+
false
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Checks whether any other process has its working directory inside the worktree.
|
|
321
|
+
# Uses lsof to query CWD file descriptors system-wide, then matches against
|
|
322
|
+
# the worktree path. Catches the cross-process CWD crash scenario: a cleanup
|
|
323
|
+
# process removing a worktree while another session's shell is still inside it.
|
|
324
|
+
# Fails safe: returns false if lsof is unavailable or any error occurs.
|
|
325
|
+
def worktree_held_by_other_process?( worktree_path: )
|
|
326
|
+
canonical = realpath_safe( worktree_path )
|
|
327
|
+
return false if canonical.nil? || canonical.empty?
|
|
328
|
+
return false unless Dir.exist?( canonical )
|
|
329
|
+
|
|
330
|
+
stdout, _, status = Open3.capture3( "lsof", "-d", "cwd" )
|
|
331
|
+
return false unless status.success?
|
|
332
|
+
|
|
333
|
+
normalised = File.join( canonical, "" )
|
|
334
|
+
my_pid = Process.pid
|
|
335
|
+
stdout.lines.drop( 1 ).any? do |line|
|
|
336
|
+
fields = line.strip.split( /\s+/ )
|
|
337
|
+
next false unless fields.length >= 9
|
|
338
|
+
next false if fields[ 1 ].to_i == my_pid
|
|
339
|
+
name = fields[ 8.. ].join( " " )
|
|
340
|
+
name == canonical || name.start_with?( normalised )
|
|
341
|
+
end
|
|
342
|
+
rescue Errno::ENOENT
|
|
343
|
+
# lsof not installed.
|
|
344
|
+
false
|
|
302
345
|
rescue StandardError
|
|
303
346
|
false
|
|
304
347
|
end
|
|
@@ -335,7 +378,7 @@ module Carson
|
|
|
335
378
|
nil
|
|
336
379
|
end
|
|
337
380
|
|
|
338
|
-
# Returns the branch checked
|
|
381
|
+
# Returns the branch checked output in the worktree that contains the process CWD,
|
|
339
382
|
# or nil if CWD is not inside any worktree. Used by prune to proactively
|
|
340
383
|
# protect the CWD worktree's branch from deletion.
|
|
341
384
|
# Matches the longest (most specific) path because worktree directories
|
|
@@ -344,12 +387,12 @@ module Carson
|
|
|
344
387
|
cwd = realpath_safe( Dir.pwd )
|
|
345
388
|
best_branch = nil
|
|
346
389
|
best_length = -1
|
|
347
|
-
worktree_list.each do |
|
|
348
|
-
|
|
349
|
-
normalised = File.join(
|
|
350
|
-
if ( cwd ==
|
|
351
|
-
best_branch =
|
|
352
|
-
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
|
|
353
396
|
end
|
|
354
397
|
end
|
|
355
398
|
best_branch
|
|
@@ -383,7 +426,7 @@ module Carson
|
|
|
383
426
|
existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
|
|
384
427
|
return if existing.lines.any? { |line| line.strip == ".claude/" }
|
|
385
428
|
|
|
386
|
-
File.open( exclude_path, "a" ) { |
|
|
429
|
+
File.open( exclude_path, "a" ) { |file| file.puts ".claude/" }
|
|
387
430
|
rescue StandardError
|
|
388
431
|
# Best-effort — do not block worktree creation if exclude fails.
|
|
389
432
|
end
|
|
@@ -408,14 +451,14 @@ module Carson
|
|
|
408
451
|
# Compares using realpath to handle symlink differences.
|
|
409
452
|
def worktree_registered?( path: )
|
|
410
453
|
canonical = realpath_safe( path )
|
|
411
|
-
worktree_list.any? { |
|
|
454
|
+
worktree_list.any? { |worktree| worktree.fetch( :path ) == canonical }
|
|
412
455
|
end
|
|
413
456
|
|
|
414
|
-
# Returns the branch name checked
|
|
457
|
+
# Returns the branch name checked output in a worktree, or nil.
|
|
415
458
|
# Compares using realpath to handle symlink differences.
|
|
416
459
|
def worktree_branch( path: )
|
|
417
460
|
canonical = realpath_safe( path )
|
|
418
|
-
entry = worktree_list.find { |
|
|
461
|
+
entry = worktree_list.find { |worktree| worktree.fetch( :path ) == canonical }
|
|
419
462
|
entry&.fetch( :branch, nil )
|
|
420
463
|
end
|
|
421
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."
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# Review gate logic: snapshot convergence, disposition acknowledgements, and merge-readiness checks.
|
|
1
2
|
module Carson
|
|
2
3
|
class Runtime
|
|
3
4
|
module Review
|
|
@@ -30,7 +31,7 @@ module Carson
|
|
|
30
31
|
unresolved_threads = unresolved_thread_entries( details: details )
|
|
31
32
|
actionable_top_level = actionable_top_level_items( details: details, pr_author: pr_author )
|
|
32
33
|
acknowledgements = disposition_acknowledgements( details: details, pr_author: pr_author )
|
|
33
|
-
unacknowledged_actionable = actionable_top_level.reject {
|
|
34
|
+
unacknowledged_actionable = actionable_top_level.reject { acknowledged_by_disposition?( item: it, acknowledgements: acknowledgements ) }
|
|
34
35
|
{
|
|
35
36
|
latest_activity: latest_review_activity( details: details ),
|
|
36
37
|
unresolved_threads: unresolved_threads,
|
|
@@ -44,8 +45,8 @@ module Carson
|
|
|
44
45
|
def review_gate_signature( snapshot: )
|
|
45
46
|
{
|
|
46
47
|
latest_activity: snapshot.fetch( :latest_activity ).to_s,
|
|
47
|
-
unresolved_urls: snapshot.fetch( :unresolved_threads ).map {
|
|
48
|
-
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map {
|
|
48
|
+
unresolved_urls: snapshot.fetch( :unresolved_threads ).map { it.fetch( :url ) }.sort,
|
|
49
|
+
unacknowledged_urls: snapshot.fetch( :unacknowledged_actionable ).map { it.fetch( :url ) }.sort
|
|
49
50
|
}
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -67,7 +68,7 @@ module Carson
|
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
def bot_username?( author: )
|
|
70
|
-
config.review_bot_usernames.any? {
|
|
71
|
+
config.review_bot_usernames.any? { it.downcase == author.to_s.downcase }
|
|
71
72
|
end
|
|
72
73
|
|
|
73
74
|
def unresolved_thread_entries( details: )
|
|
@@ -78,7 +79,7 @@ module Carson
|
|
|
78
79
|
comments = thread.fetch( :comments )
|
|
79
80
|
first_comment = comments.first || {}
|
|
80
81
|
next if bot_username?( author: first_comment.fetch( :author, "" ) )
|
|
81
|
-
latest_time = comments.map {
|
|
82
|
+
latest_time = comments.map { it.fetch( :created_at ) }.max.to_s
|
|
82
83
|
{
|
|
83
84
|
url: blank_to( value: first_comment.fetch( :url, "" ), default: "#{details.fetch( :url )}#thread-#{index + 1}" ),
|
|
84
85
|
author: first_comment.fetch( :author, "" ),
|
|
@@ -130,7 +131,7 @@ module Carson
|
|
|
130
131
|
sources = []
|
|
131
132
|
sources.concat( Array( details.fetch( :comments ) ) )
|
|
132
133
|
sources.concat( Array( details.fetch( :reviews ) ) )
|
|
133
|
-
sources.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
134
|
+
sources.concat( Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) } )
|
|
134
135
|
sources.map do |entry|
|
|
135
136
|
next unless entry.fetch( :author, "" ) == pr_author
|
|
136
137
|
body = entry.fetch( :body, "" ).to_s
|
|
@@ -151,7 +152,7 @@ module Carson
|
|
|
151
152
|
# True when any disposition acknowledgement references the specific finding URL.
|
|
152
153
|
def acknowledged_by_disposition?( item:, acknowledgements: )
|
|
153
154
|
acknowledgements.any? do |ack|
|
|
154
|
-
Array( ack.fetch( :target_urls ) ).any? {
|
|
155
|
+
Array( ack.fetch( :target_urls ) ).any? { it == item.fetch( :url ) }
|
|
155
156
|
end
|
|
156
157
|
end
|
|
157
158
|
|
|
@@ -159,10 +160,10 @@ module Carson
|
|
|
159
160
|
def latest_review_activity( details: )
|
|
160
161
|
timestamps = []
|
|
161
162
|
timestamps << details.fetch( :updated_at )
|
|
162
|
-
timestamps.concat( Array( details.fetch( :comments ) ).map {
|
|
163
|
-
timestamps.concat( Array( details.fetch( :reviews ) ).map {
|
|
164
|
-
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map {
|
|
165
|
-
timestamps.map {
|
|
163
|
+
timestamps.concat( Array( details.fetch( :comments ) ).map { it.fetch( :created_at ) } )
|
|
164
|
+
timestamps.concat( Array( details.fetch( :reviews ) ).map { it.fetch( :created_at ) } )
|
|
165
|
+
timestamps.concat( Array( details.fetch( :review_threads ) ).flat_map { it.fetch( :comments ) }.map { it.fetch( :created_at ) } )
|
|
166
|
+
timestamps.map { parse_time_or_nil( text: it ) }.compact.max&.utc&.iso8601
|
|
166
167
|
end
|
|
167
168
|
|
|
168
169
|
# Writes review gate artefacts using fixed report names in global report output.
|
|
@@ -175,8 +176,8 @@ module Carson
|
|
|
175
176
|
)
|
|
176
177
|
puts_verbose "review_gate_report_markdown: #{markdown_path}"
|
|
177
178
|
puts_verbose "review_gate_report_json: #{json_path}"
|
|
178
|
-
rescue StandardError =>
|
|
179
|
-
puts_verbose "review_gate_report_write: SKIP (#{
|
|
179
|
+
rescue StandardError => exception
|
|
180
|
+
puts_verbose "review_gate_report_write: SKIP (#{exception.message})"
|
|
180
181
|
end
|
|
181
182
|
|
|
182
183
|
# Human-readable review gate report for merge-readiness evidence.
|
|
@@ -208,7 +209,7 @@ module Carson
|
|
|
208
209
|
if report.fetch( :block_reasons ).empty?
|
|
209
210
|
lines << "- none"
|
|
210
211
|
else
|
|
211
|
-
report.fetch( :block_reasons ).each {
|
|
212
|
+
report.fetch( :block_reasons ).each { lines << "- #{it}" }
|
|
212
213
|
end
|
|
213
214
|
lines << ""
|
|
214
215
|
lines << "## Unresolved Threads"
|