carson 3.22.0 → 3.22.1
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 +4 -9
- data/MANUAL.md +45 -28
- data/README.md +45 -50
- data/RELEASE.md +9 -1
- data/SKILL.md +1 -1
- data/VERSION +1 -1
- data/carson.gemspec +2 -4
- data/hooks/command-guard +1 -1
- data/hooks/pre-push +17 -20
- data/lib/carson/cli.rb +32 -16
- data/lib/carson/config.rb +46 -11
- data/lib/carson/runtime/audit.rb +37 -11
- data/lib/carson/runtime/deliver.rb +113 -42
- data/lib/carson/runtime/govern.rb +29 -17
- data/lib/carson/runtime/housekeep.rb +231 -25
- data/lib/carson/runtime/local/onboard.rb +24 -24
- data/lib/carson/runtime/local/prune.rb +116 -31
- data/lib/carson/runtime/local/sync.rb +29 -7
- data/lib/carson/runtime/local/template.rb +26 -8
- data/lib/carson/runtime/local/worktree.rb +37 -442
- data/lib/carson/runtime/review/gate_support.rb +131 -1
- data/lib/carson/runtime/review.rb +21 -77
- data/lib/carson/runtime/setup.rb +15 -6
- data/lib/carson/runtime/status.rb +35 -12
- data/lib/carson/runtime.rb +15 -3
- data/lib/carson/worktree.rb +497 -0
- data/lib/carson.rb +1 -0
- metadata +11 -16
- data/.github/copilot-instructions.md +0 -1
- data/.github/pull_request_template.md +0 -12
- data/templates/.github/AGENTS.md +0 -1
- data/templates/.github/CLAUDE.md +0 -1
- data/templates/.github/carson.md +0 -47
- data/templates/.github/copilot-instructions.md +0 -1
- data/templates/.github/pull_request_template.md +0 -12
|
@@ -24,15 +24,16 @@ module Carson
|
|
|
24
24
|
puts_line "Review Gate"
|
|
25
25
|
end
|
|
26
26
|
unless gh_available?
|
|
27
|
-
puts_line "
|
|
27
|
+
puts_line "gh CLI not found in PATH — install it to use review commands."
|
|
28
28
|
return EXIT_ERROR
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
owner, repo = repository_coordinates
|
|
32
|
+
branch = current_branch
|
|
32
33
|
pr_number_override = carson_pr_number_override
|
|
33
34
|
pr_summary =
|
|
34
35
|
if pr_number_override.nil?
|
|
35
|
-
current_pull_request_for_branch( branch_name:
|
|
36
|
+
current_pull_request_for_branch( branch_name: branch )
|
|
36
37
|
else
|
|
37
38
|
details = pull_request_details( owner: owner, repo: repo, pr_number: pr_number_override )
|
|
38
39
|
{
|
|
@@ -43,93 +44,36 @@ module Carson
|
|
|
43
44
|
}
|
|
44
45
|
end
|
|
45
46
|
if pr_summary.nil?
|
|
46
|
-
puts_line "
|
|
47
|
-
report =
|
|
48
|
-
generated_at: Time.now.utc.iso8601,
|
|
49
|
-
branch: current_branch,
|
|
50
|
-
status: "block",
|
|
51
|
-
converged: false,
|
|
52
|
-
wait_seconds: config.review_wait_seconds,
|
|
53
|
-
poll_seconds: config.review_poll_seconds,
|
|
54
|
-
max_polls: config.review_max_polls,
|
|
55
|
-
block_reasons: [ "no pull request found for current branch" ],
|
|
56
|
-
pr: nil,
|
|
57
|
-
unresolved_threads: [],
|
|
58
|
-
actionable_top_level: [],
|
|
59
|
-
unacknowledged_actionable: []
|
|
60
|
-
}
|
|
47
|
+
puts_line "No pull request found for branch #{branch}."
|
|
48
|
+
report = review_gate_report_for_missing_pr( branch_name: branch )
|
|
61
49
|
write_review_gate_report( report: report )
|
|
62
50
|
return EXIT_BLOCK
|
|
63
51
|
end
|
|
64
52
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
poll_attempts = index + 1
|
|
73
|
-
snapshot = review_gate_snapshot( owner: owner, repo: repo, pr_number: pr_summary.fetch( :number ) )
|
|
74
|
-
last_snapshot = snapshot
|
|
75
|
-
signature = review_gate_signature( snapshot: snapshot )
|
|
76
|
-
puts_verbose "poll_attempt: #{poll_attempts}/#{config.review_max_polls}"
|
|
77
|
-
puts_verbose "latest_activity: #{snapshot.fetch( :latest_activity ) || 'unknown'}"
|
|
78
|
-
puts_verbose "unresolved_threads: #{snapshot.fetch( :unresolved_threads ).count}"
|
|
79
|
-
puts_verbose "unacknowledged_actionable: #{snapshot.fetch( :unacknowledged_actionable ).count}"
|
|
80
|
-
if !last_signature.nil? && signature == last_signature
|
|
81
|
-
converged = true
|
|
82
|
-
puts_verbose "convergence: stable"
|
|
83
|
-
break
|
|
84
|
-
end
|
|
85
|
-
last_signature = signature
|
|
86
|
-
wait_for_review_poll if index < config.review_max_polls - 1
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
block_reasons = []
|
|
90
|
-
block_reasons << "review snapshot did not converge within #{config.review_max_polls} polls" unless converged
|
|
91
|
-
if last_snapshot.fetch( :unresolved_threads ).any?
|
|
92
|
-
block_reasons << "unresolved review threads remain (#{last_snapshot.fetch( :unresolved_threads ).count})"
|
|
93
|
-
end
|
|
94
|
-
if last_snapshot.fetch( :unacknowledged_actionable ).any?
|
|
95
|
-
block_reasons << "actionable top-level comments/reviews without required disposition (#{last_snapshot.fetch( :unacknowledged_actionable ).count})"
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
report = {
|
|
99
|
-
generated_at: Time.now.utc.iso8601,
|
|
100
|
-
branch: current_branch,
|
|
101
|
-
status: block_reasons.empty? ? "ok" : "block",
|
|
102
|
-
converged: converged,
|
|
103
|
-
wait_seconds: config.review_wait_seconds,
|
|
104
|
-
poll_seconds: config.review_poll_seconds,
|
|
105
|
-
max_polls: config.review_max_polls,
|
|
106
|
-
poll_attempts: poll_attempts,
|
|
107
|
-
block_reasons: block_reasons,
|
|
108
|
-
pr: {
|
|
109
|
-
number: pr_summary.fetch( :number ),
|
|
110
|
-
title: pr_summary.fetch( :title ),
|
|
111
|
-
url: pr_summary.fetch( :url ),
|
|
112
|
-
state: pr_summary.fetch( :state )
|
|
113
|
-
},
|
|
114
|
-
unresolved_threads: last_snapshot.fetch( :unresolved_threads ),
|
|
115
|
-
actionable_top_level: last_snapshot.fetch( :actionable_top_level ),
|
|
116
|
-
unacknowledged_actionable: last_snapshot.fetch( :unacknowledged_actionable )
|
|
117
|
-
}
|
|
53
|
+
report = review_gate_report_for_pr(
|
|
54
|
+
owner: owner,
|
|
55
|
+
repo: repo,
|
|
56
|
+
pr_number: pr_summary.fetch( :number ),
|
|
57
|
+
branch_name: branch,
|
|
58
|
+
pr_summary: pr_summary
|
|
59
|
+
)
|
|
118
60
|
write_review_gate_report( report: report )
|
|
119
61
|
unless verbose?
|
|
62
|
+
poll_attempts = report.fetch( :poll_attempts, 0 )
|
|
120
63
|
puts_line "Polling... (converged after #{poll_attempts} attempt#{plural_suffix( count: poll_attempts )})"
|
|
121
64
|
end
|
|
65
|
+
block_reasons = report.fetch( :block_reasons )
|
|
122
66
|
if block_reasons.empty?
|
|
123
67
|
puts_line "OK: review gate passed."
|
|
124
68
|
return EXIT_OK
|
|
125
69
|
end
|
|
126
|
-
block_reasons.each { |reason| puts_line
|
|
70
|
+
block_reasons.each { |reason| puts_line reason }
|
|
127
71
|
EXIT_BLOCK
|
|
128
72
|
rescue JSON::ParserError => exception
|
|
129
|
-
puts_line "
|
|
73
|
+
puts_line "Unexpected response from gh (#{exception.message})."
|
|
130
74
|
EXIT_ERROR
|
|
131
75
|
rescue StandardError => exception
|
|
132
|
-
puts_line
|
|
76
|
+
puts_line exception.message
|
|
133
77
|
EXIT_ERROR
|
|
134
78
|
end
|
|
135
79
|
|
|
@@ -140,7 +84,7 @@ module Carson
|
|
|
140
84
|
puts_verbose ""
|
|
141
85
|
puts_verbose "[Review Sweep]"
|
|
142
86
|
unless gh_available?
|
|
143
|
-
puts_line "
|
|
87
|
+
puts_line "gh CLI not found in PATH — install it to use review commands."
|
|
144
88
|
return EXIT_ERROR
|
|
145
89
|
end
|
|
146
90
|
|
|
@@ -176,13 +120,13 @@ module Carson
|
|
|
176
120
|
puts_line "OK: no actionable late review activity detected."
|
|
177
121
|
return EXIT_OK
|
|
178
122
|
end
|
|
179
|
-
puts_line "
|
|
123
|
+
puts_line "Late review activity needs attention."
|
|
180
124
|
EXIT_BLOCK
|
|
181
125
|
rescue JSON::ParserError => exception
|
|
182
|
-
puts_line "
|
|
126
|
+
puts_line "Unexpected response from gh (#{exception.message})."
|
|
183
127
|
EXIT_ERROR
|
|
184
128
|
rescue StandardError => exception
|
|
185
|
-
puts_line
|
|
129
|
+
puts_line exception.message
|
|
186
130
|
EXIT_ERROR
|
|
187
131
|
end
|
|
188
132
|
end
|
data/lib/carson/runtime/setup.rb
CHANGED
|
@@ -45,8 +45,8 @@ module Carson
|
|
|
45
45
|
merge_choice = prompt_merge_method
|
|
46
46
|
choices[ "govern.merge.method" ] = merge_choice unless merge_choice.nil?
|
|
47
47
|
|
|
48
|
-
canonical_choice =
|
|
49
|
-
choices[ "
|
|
48
|
+
canonical_choice = prompt_canonical_lint_policy
|
|
49
|
+
choices[ "lint.canonical" ] = canonical_choice unless canonical_choice.nil?
|
|
50
50
|
|
|
51
51
|
write_setup_config( choices: choices )
|
|
52
52
|
end
|
|
@@ -157,15 +157,15 @@ module Carson
|
|
|
157
157
|
prompt_choice( options: options, default: default_index )
|
|
158
158
|
end
|
|
159
159
|
|
|
160
|
-
def
|
|
160
|
+
def prompt_canonical_lint_policy
|
|
161
161
|
puts_line ""
|
|
162
|
-
puts_line "Canonical
|
|
163
|
-
current = config.
|
|
162
|
+
puts_line "Canonical lint policy directory"
|
|
163
|
+
current = config.lint_canonical
|
|
164
164
|
if current && !current.empty?
|
|
165
165
|
puts_line " Currently set to: #{current}"
|
|
166
166
|
puts_line " Leave blank to keep current value."
|
|
167
167
|
else
|
|
168
|
-
puts_line " A directory of
|
|
168
|
+
puts_line " A directory of canonical lint-policy files to sync across governed repos."
|
|
169
169
|
puts_line " Leave blank to skip for now."
|
|
170
170
|
end
|
|
171
171
|
prompt_custom_value( label: "Path" )
|
|
@@ -327,6 +327,7 @@ module Carson
|
|
|
327
327
|
|
|
328
328
|
existing_data = load_existing_config( path: config_path )
|
|
329
329
|
merged = Config.deep_merge( base: existing_data, overlay: config_data )
|
|
330
|
+
remove_deprecated_template_canonical!( data: merged ) if config_data.dig( "lint", "canonical" )
|
|
330
331
|
|
|
331
332
|
FileUtils.mkdir_p( File.dirname( config_path ) )
|
|
332
333
|
File.write( config_path, JSON.pretty_generate( merged ) )
|
|
@@ -361,6 +362,14 @@ module Carson
|
|
|
361
362
|
{}
|
|
362
363
|
end
|
|
363
364
|
|
|
365
|
+
def remove_deprecated_template_canonical!( data: )
|
|
366
|
+
template_hash = data[ "template" ]
|
|
367
|
+
return unless template_hash.is_a?( Hash )
|
|
368
|
+
|
|
369
|
+
template_hash.delete( "canonical" )
|
|
370
|
+
data.delete( "template" ) if template_hash.empty?
|
|
371
|
+
end
|
|
372
|
+
|
|
364
373
|
def reload_config_after_setup!
|
|
365
374
|
@config = Config.load( repo_root: repo_root )
|
|
366
375
|
end
|
|
@@ -53,7 +53,7 @@ module Carson
|
|
|
53
53
|
repos.each do |repo_path|
|
|
54
54
|
repo_name = File.basename( repo_path )
|
|
55
55
|
unless Dir.exist?( repo_path )
|
|
56
|
-
puts_line "#{repo_name}:
|
|
56
|
+
puts_line "#{repo_name}: not found"
|
|
57
57
|
next
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -61,7 +61,7 @@ module Carson
|
|
|
61
61
|
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
62
62
|
data = scoped_runtime.send( :gather_status )
|
|
63
63
|
branch = data.fetch( :branch )
|
|
64
|
-
dirty =
|
|
64
|
+
dirty = format_dirty_marker( branch: branch )
|
|
65
65
|
worktrees = data.fetch( :worktrees )
|
|
66
66
|
gov = data.fetch( :governance )
|
|
67
67
|
parts = []
|
|
@@ -74,14 +74,16 @@ module Carson
|
|
|
74
74
|
repo_pending = status_pending_for_repo( all_pending: all_pending, repo_path: repo_path )
|
|
75
75
|
repo_pending.each { |description| puts_line " pending: #{description}" }
|
|
76
76
|
rescue StandardError => exception
|
|
77
|
-
puts_line "#{repo_name}:
|
|
77
|
+
puts_line "#{repo_name}: could not read (#{exception.message})"
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
EXIT_OK
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
|
|
84
85
|
private
|
|
86
|
+
# rubocop:enable Layout/AccessModifierIndentation
|
|
85
87
|
|
|
86
88
|
# Returns an array of human-readable pending descriptions for a repo.
|
|
87
89
|
def status_pending_for_repo( all_pending:, repo_path: )
|
|
@@ -119,10 +121,10 @@ module Carson
|
|
|
119
121
|
# Branch name, clean/dirty state, sync status with remote.
|
|
120
122
|
def gather_branch_info
|
|
121
123
|
branch = current_branch
|
|
122
|
-
|
|
124
|
+
dirty_reason = dirty_worktree_reason
|
|
123
125
|
sync = remote_sync_status( branch: branch )
|
|
124
126
|
|
|
125
|
-
{ name: branch, dirty:
|
|
127
|
+
{ name: branch, dirty: !dirty_reason.nil?, dirty_reason: dirty_reason, sync: sync }
|
|
126
128
|
end
|
|
127
129
|
|
|
128
130
|
# Returns true when the working tree has uncommitted changes.
|
|
@@ -132,6 +134,17 @@ module Carson
|
|
|
132
134
|
!stdout.strip.empty?
|
|
133
135
|
end
|
|
134
136
|
|
|
137
|
+
def dirty_worktree_reason
|
|
138
|
+
return nil unless working_tree_dirty?
|
|
139
|
+
return "main_worktree" if main_worktree_context?
|
|
140
|
+
|
|
141
|
+
"working_tree"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def main_worktree_context?
|
|
145
|
+
realpath_safe( repo_root ) == realpath_safe( main_worktree_root )
|
|
146
|
+
end
|
|
147
|
+
|
|
135
148
|
# Compares local branch against its remote tracking ref.
|
|
136
149
|
# Returns :in_sync, :ahead, :behind, :diverged, or :no_remote.
|
|
137
150
|
def remote_sync_status( branch: )
|
|
@@ -162,11 +175,11 @@ module Carson
|
|
|
162
175
|
# Filter output the main worktree (the repository root itself).
|
|
163
176
|
# Use realpath for comparison — git returns canonical paths that may differ from repo_root.
|
|
164
177
|
canonical_root = realpath_safe( repo_root )
|
|
165
|
-
entries.reject { it.
|
|
178
|
+
entries.reject { it.path == canonical_root }.map do |worktree|
|
|
166
179
|
{
|
|
167
|
-
path: worktree.
|
|
168
|
-
name: File.basename( worktree.
|
|
169
|
-
branch: worktree.
|
|
180
|
+
path: worktree.path,
|
|
181
|
+
name: File.basename( worktree.path ),
|
|
182
|
+
branch: worktree.branch
|
|
170
183
|
}
|
|
171
184
|
end
|
|
172
185
|
end
|
|
@@ -243,9 +256,12 @@ module Carson
|
|
|
243
256
|
|
|
244
257
|
# Branch
|
|
245
258
|
branch = data.fetch( :branch )
|
|
246
|
-
dirty_marker =
|
|
259
|
+
dirty_marker = format_dirty_marker( branch: branch )
|
|
247
260
|
sync_marker = format_sync( sync: branch.fetch( :sync ) )
|
|
248
261
|
puts_line "Branch: #{branch.fetch( :name )}#{dirty_marker}#{sync_marker}"
|
|
262
|
+
if branch.fetch( :dirty_reason, nil ) == "main_worktree"
|
|
263
|
+
puts_line "Governance: main working tree has uncommitted changes — create a worktree with `carson worktree create <name>`."
|
|
264
|
+
end
|
|
249
265
|
|
|
250
266
|
# Worktrees
|
|
251
267
|
worktrees = data.fetch( :worktrees )
|
|
@@ -299,8 +315,15 @@ module Carson
|
|
|
299
315
|
else ""
|
|
300
316
|
end
|
|
301
317
|
end
|
|
302
|
-
end
|
|
303
318
|
|
|
304
|
-
|
|
319
|
+
def format_dirty_marker( branch: )
|
|
320
|
+
return "" unless branch.fetch( :dirty )
|
|
321
|
+
return " (dirty main worktree)" if branch.fetch( :dirty_reason, nil ) == "main_worktree"
|
|
322
|
+
|
|
323
|
+
" (dirty)"
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
include Status
|
|
305
328
|
end
|
|
306
329
|
end
|
data/lib/carson/runtime.rb
CHANGED
|
@@ -310,9 +310,9 @@ module Carson
|
|
|
310
310
|
# so only genuinely active worktrees block the operation.
|
|
311
311
|
scoped_runtime = build_scoped_runtime( repo_path: repo_path )
|
|
312
312
|
scoped_runtime.sweep_stale_worktrees!
|
|
313
|
-
worktrees = scoped_runtime.
|
|
314
|
-
main_root = scoped_runtime.
|
|
315
|
-
active = worktrees.reject { |worktree| worktree.
|
|
313
|
+
worktrees = scoped_runtime.worktree_list
|
|
314
|
+
main_root = scoped_runtime.realpath_safe( repo_path )
|
|
315
|
+
active = worktrees.reject { |worktree| worktree.path == main_root }
|
|
316
316
|
if active.any?
|
|
317
317
|
reasons << "#{active.count} active worktree#{active.count == 1 ? '' : 's'}"
|
|
318
318
|
end
|
|
@@ -346,3 +346,15 @@ require_relative "runtime/govern"
|
|
|
346
346
|
require_relative "runtime/setup"
|
|
347
347
|
require_relative "runtime/status"
|
|
348
348
|
require_relative "runtime/deliver"
|
|
349
|
+
|
|
350
|
+
# Infrastructure interface for domain objects.
|
|
351
|
+
# Carson::Worktree and future domain objects call these methods
|
|
352
|
+
# on a runtime reference — the way ActiveRecord models use a connection.
|
|
353
|
+
# Defined private for internal use, exposed here for domain object access.
|
|
354
|
+
module Carson
|
|
355
|
+
class Runtime
|
|
356
|
+
public :config, :output, :verbose?, :puts_verbose, :puts_line,
|
|
357
|
+
:git_run, :git_capture!, :main_worktree_root, :realpath_safe,
|
|
358
|
+
:block_if_outsider_fingerprints!, :branch_absorbed_into_main?
|
|
359
|
+
end
|
|
360
|
+
end
|