carson 3.21.1 → 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 +24 -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 +55 -16
- data/lib/carson/config.rb +55 -14
- data/lib/carson/runtime/audit.rb +42 -17
- data/lib/carson/runtime/deliver.rb +118 -51
- 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 +131 -35
- 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 +36 -13
- data/lib/carson/runtime.rb +18 -4
- 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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# PR delivery lifecycle — push, create PR, and optionally merge.
|
|
2
2
|
# Collapses the 8-step manual PR flow into one or two commands.
|
|
3
3
|
# `carson deliver` pushes and creates the PR.
|
|
4
|
-
# `carson deliver --merge` also merges if CI
|
|
4
|
+
# `carson deliver --merge` also merges if CI passes or no checks are configured.
|
|
5
5
|
# `carson deliver --json` outputs structured result for agent consumption.
|
|
6
6
|
module Carson
|
|
7
7
|
class Runtime
|
|
@@ -18,15 +18,39 @@ module Carson
|
|
|
18
18
|
# Guard: cannot deliver from main.
|
|
19
19
|
if branch == main
|
|
20
20
|
result[ :error ] = "cannot deliver from #{main}"
|
|
21
|
-
result[ :recovery ] = "
|
|
22
|
-
return deliver_finish( result: result, exit_code:
|
|
21
|
+
result[ :recovery ] = "carson worktree create <name>"
|
|
22
|
+
return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Step 1: sync managed template files before push.
|
|
26
|
+
# `push_prep: true` stages and commits managed drift so the subsequent
|
|
27
|
+
# push carries the canonical content even though deliver uses --no-verify.
|
|
28
|
+
# Output is captured to prevent pollution of --json mode.
|
|
29
|
+
# Diagnostics are preserved for error reporting.
|
|
30
|
+
sync_exit, sync_diagnostics = begin
|
|
31
|
+
saved_output, saved_error = @output, @error
|
|
32
|
+
captured_out = StringIO.new
|
|
33
|
+
captured_err = StringIO.new
|
|
34
|
+
@output = captured_out
|
|
35
|
+
@error = captured_err
|
|
36
|
+
exit_code = template_apply!( push_prep: true )
|
|
37
|
+
[ exit_code, captured_out.string + captured_err.string ]
|
|
38
|
+
rescue StandardError => exception
|
|
39
|
+
[ EXIT_ERROR, "template sync error: #{exception.message}" ]
|
|
40
|
+
ensure
|
|
41
|
+
@output, @error = saved_output, saved_error
|
|
23
42
|
end
|
|
24
43
|
|
|
25
|
-
|
|
44
|
+
if sync_exit == EXIT_ERROR
|
|
45
|
+
result[ :error ] = sync_diagnostics.to_s.strip.empty? ? "template sync failed" : sync_diagnostics.strip
|
|
46
|
+
return deliver_finish( result: result, exit_code: sync_exit, json_output: json_output )
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Step 2: push the branch.
|
|
26
50
|
push_exit = push_branch!( branch: branch, remote: remote, result: result )
|
|
27
51
|
return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
|
|
28
52
|
|
|
29
|
-
# Step
|
|
53
|
+
# Step 3: find or create the PR.
|
|
30
54
|
pr_number, pr_url = find_or_create_pr!(
|
|
31
55
|
branch: branch, title: title, body_file: body_file, result: result
|
|
32
56
|
)
|
|
@@ -41,43 +65,50 @@ module Carson
|
|
|
41
65
|
return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
42
66
|
end
|
|
43
67
|
|
|
44
|
-
# Step
|
|
68
|
+
# Step 4: check CI status.
|
|
45
69
|
ci_status = check_pr_ci( number: pr_number )
|
|
46
70
|
result[ :ci ] = ci_status.to_s
|
|
47
71
|
|
|
48
72
|
case ci_status
|
|
49
|
-
when :pass
|
|
50
|
-
# Continue to review gate.
|
|
73
|
+
when :pass, :none
|
|
74
|
+
# Continue to review gate. :none means no checks configured — nothing to wait for.
|
|
51
75
|
when :pending
|
|
52
76
|
result[ :recovery ] = "gh pr checks #{pr_number} --watch && carson deliver --merge"
|
|
53
77
|
return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
54
78
|
when :fail
|
|
55
79
|
result[ :recovery ] = "gh pr checks #{pr_number} — fix failures, push, then `carson deliver --merge`"
|
|
56
80
|
return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
57
|
-
else
|
|
58
|
-
result[ :recovery ] = "gh pr checks #{pr_number}"
|
|
59
|
-
return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
60
81
|
end
|
|
61
82
|
|
|
62
|
-
# Step
|
|
63
|
-
review = check_pr_review( number: pr_number )
|
|
64
|
-
result[ :review ] = review.to_s
|
|
65
|
-
if review == :changes_requested
|
|
83
|
+
# Step 5: check review gate — block on unresolved review debt.
|
|
84
|
+
review = check_pr_review( number: pr_number, branch: branch, pr_url: pr_url )
|
|
85
|
+
result[ :review ] = review.fetch( :review ).to_s
|
|
86
|
+
if review.fetch( :review ) == :changes_requested
|
|
66
87
|
result[ :error ] = "review changes requested on PR ##{pr_number}"
|
|
67
88
|
result[ :recovery ] = "address review comments, push, then `carson deliver --merge`"
|
|
68
89
|
return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
69
90
|
end
|
|
91
|
+
if review.fetch( :status ) == :fail
|
|
92
|
+
result[ :error ] = "review gate blocked on PR ##{pr_number}: #{review.fetch( :detail )}"
|
|
93
|
+
result[ :recovery ] = "resolve review gate blockers, push, then `carson deliver --merge`"
|
|
94
|
+
return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
|
|
95
|
+
end
|
|
96
|
+
if review.fetch( :status ) == :error
|
|
97
|
+
result[ :error ] = "unable to evaluate review gate for PR ##{pr_number}: #{review.fetch( :detail )}"
|
|
98
|
+
result[ :recovery ] = "run `carson review gate`, then retry `carson deliver --merge`"
|
|
99
|
+
return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
|
|
100
|
+
end
|
|
70
101
|
|
|
71
|
-
# Step
|
|
102
|
+
# Step 6: merge.
|
|
72
103
|
merge_exit = merge_pr!( number: pr_number, result: result )
|
|
73
104
|
return deliver_finish( result: result, exit_code: merge_exit, json_output: json_output ) unless merge_exit == EXIT_OK
|
|
74
105
|
|
|
75
106
|
result[ :merged ] = true
|
|
76
107
|
|
|
77
|
-
# Step
|
|
108
|
+
# Step 7: sync main in the main worktree.
|
|
78
109
|
sync_after_merge!( remote: remote, main: main, result: result )
|
|
79
110
|
|
|
80
|
-
# Step
|
|
111
|
+
# Step 8: compute next-step guidance for the agent.
|
|
81
112
|
compute_post_merge_next_step!( result: result )
|
|
82
113
|
|
|
83
114
|
deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
|
|
@@ -103,8 +134,8 @@ module Carson
|
|
|
103
134
|
exit_code = result.fetch( :exit_code )
|
|
104
135
|
|
|
105
136
|
if result[ :error ]
|
|
106
|
-
puts_line
|
|
107
|
-
puts_line "
|
|
137
|
+
puts_line result[ :error ]
|
|
138
|
+
puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
108
139
|
return
|
|
109
140
|
end
|
|
110
141
|
|
|
@@ -117,15 +148,14 @@ module Carson
|
|
|
117
148
|
case ci
|
|
118
149
|
when "pass"
|
|
119
150
|
puts_line "CI: pass"
|
|
151
|
+
when "none"
|
|
152
|
+
puts_line "CI: none — no checks configured, proceeding."
|
|
120
153
|
when "pending"
|
|
121
154
|
puts_line "CI: pending — merge when checks complete."
|
|
122
|
-
puts_line "
|
|
155
|
+
puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
123
156
|
when "fail"
|
|
124
|
-
puts_line "CI:
|
|
125
|
-
puts_line "
|
|
126
|
-
else
|
|
127
|
-
puts_line "CI: #{ci} — check manually."
|
|
128
|
-
puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
|
|
157
|
+
puts_line "CI: not passing yet — fix before merging."
|
|
158
|
+
puts_line " → #{result[ :recovery ]}" if result[ :recovery ]
|
|
129
159
|
end
|
|
130
160
|
end
|
|
131
161
|
|
|
@@ -136,22 +166,54 @@ module Carson
|
|
|
136
166
|
end
|
|
137
167
|
|
|
138
168
|
# Pushes the branch to the remote with tracking.
|
|
139
|
-
#
|
|
169
|
+
# Uses --no-verify to skip the pre-push hook that Carson itself installed.
|
|
170
|
+
# The hook blocks raw pushes unconditionally; Carson bypasses by skipping it.
|
|
171
|
+
# Template sync (previously in the hook) now runs in deliver! before push.
|
|
172
|
+
# On non-fast-forward rejection (typically after rebase), retries with
|
|
173
|
+
# --force-with-lease — a protected force push that rejects if the remote
|
|
174
|
+
# ref has been updated by another actor since the last fetch.
|
|
140
175
|
def push_branch!( branch:, remote:, result: )
|
|
141
|
-
_, push_stderr, push_success, =
|
|
142
|
-
|
|
176
|
+
_, push_stderr, push_success, = git_run( "push", "--no-verify", "-u", remote, branch )
|
|
177
|
+
|
|
178
|
+
if !push_success && push_stderr.to_s.include?( "non-fast-forward" )
|
|
179
|
+
return force_push_with_lease!( branch: branch, remote: remote, result: result )
|
|
143
180
|
end
|
|
181
|
+
|
|
144
182
|
unless push_success
|
|
145
183
|
error_text = push_stderr.to_s.strip
|
|
146
184
|
error_text = "push failed" if error_text.empty?
|
|
147
185
|
result[ :error ] = error_text
|
|
148
|
-
result[ :recovery ] = "git pull #{remote} #{branch} --rebase && git push -u #{remote} #{branch}"
|
|
149
186
|
return EXIT_ERROR
|
|
150
187
|
end
|
|
151
188
|
puts_verbose "pushed #{branch} to #{remote}"
|
|
152
189
|
EXIT_OK
|
|
153
190
|
end
|
|
154
191
|
|
|
192
|
+
# Retries push with --force-with-lease after a non-fast-forward rejection.
|
|
193
|
+
# The lease check compares the local tracking ref against the remote — if
|
|
194
|
+
# another actor pushed since our last fetch, the push is refused ("stale info").
|
|
195
|
+
# This is atomic and safe, unlike delete-and-re-push.
|
|
196
|
+
def force_push_with_lease!( branch:, remote:, result: )
|
|
197
|
+
puts_verbose "push rejected (non-fast-forward), retrying with --force-with-lease"
|
|
198
|
+
_, lease_stderr, lease_success, = git_run( "push", "--no-verify", "--force-with-lease", "-u", remote, branch )
|
|
199
|
+
|
|
200
|
+
if lease_success
|
|
201
|
+
puts_verbose "pushed #{branch} to #{remote} (force-with-lease)"
|
|
202
|
+
return EXIT_OK
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# --force-with-lease rejected — another actor pushed to this branch.
|
|
206
|
+
if lease_stderr.to_s.include?( "stale info" )
|
|
207
|
+
result[ :error ] = "force-with-lease rejected — another push landed on #{branch} since your last fetch"
|
|
208
|
+
result[ :recovery ] = "git fetch #{remote} #{branch} && carson deliver"
|
|
209
|
+
else
|
|
210
|
+
error_text = lease_stderr.to_s.strip
|
|
211
|
+
error_text = "push failed (force-with-lease)" if error_text.empty?
|
|
212
|
+
result[ :error ] = error_text
|
|
213
|
+
end
|
|
214
|
+
EXIT_ERROR
|
|
215
|
+
end
|
|
216
|
+
|
|
155
217
|
# Finds an existing PR for the branch, or creates a new one.
|
|
156
218
|
# Returns [number, url] or [nil, nil] on failure.
|
|
157
219
|
def find_or_create_pr!( branch:, title: nil, body_file: nil, result: )
|
|
@@ -165,14 +227,17 @@ module Carson
|
|
|
165
227
|
|
|
166
228
|
# Queries gh for an open PR on this branch.
|
|
167
229
|
# Returns [number, url] or [nil, nil].
|
|
230
|
+
# gh pr view returns any PR on the branch — open, merged, or closed.
|
|
231
|
+
# We check state explicitly so merged/closed PRs are treated as absent,
|
|
232
|
+
# letting find_or_create_pr! fall through to create a new PR.
|
|
168
233
|
def find_existing_pr( branch: )
|
|
169
234
|
stdout, _, success, = gh_run(
|
|
170
235
|
"pr", "view", branch,
|
|
171
|
-
"--json", "number,url"
|
|
236
|
+
"--json", "number,url,state"
|
|
172
237
|
)
|
|
173
238
|
if success
|
|
174
239
|
data = JSON.parse( stdout ) rescue nil
|
|
175
|
-
if data && data[ "number" ]
|
|
240
|
+
if data && data[ "number" ] && data[ "state" ] == "OPEN"
|
|
176
241
|
return [ data[ "number" ], data[ "url" ].to_s ]
|
|
177
242
|
end
|
|
178
243
|
end
|
|
@@ -235,22 +300,24 @@ module Carson
|
|
|
235
300
|
:pass
|
|
236
301
|
end
|
|
237
302
|
|
|
238
|
-
# Checks review
|
|
239
|
-
def check_pr_review( number: )
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
303
|
+
# Checks the full review gate on a PR. Returns a structured result hash.
|
|
304
|
+
def check_pr_review( number:, branch:, pr_url: nil )
|
|
305
|
+
owner, repo = repository_coordinates
|
|
306
|
+
report = review_gate_report_for_pr(
|
|
307
|
+
owner: owner,
|
|
308
|
+
repo: repo,
|
|
309
|
+
pr_number: number,
|
|
310
|
+
branch_name: branch,
|
|
311
|
+
pr_summary: {
|
|
312
|
+
number: number,
|
|
313
|
+
title: "",
|
|
314
|
+
url: pr_url.to_s,
|
|
315
|
+
state: "OPEN"
|
|
316
|
+
}
|
|
243
317
|
)
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
decision = data[ "reviewDecision" ].to_s.strip.upcase
|
|
248
|
-
case decision
|
|
249
|
-
when "APPROVED" then :approved
|
|
250
|
-
when "CHANGES_REQUESTED" then :changes_requested
|
|
251
|
-
when "REVIEW_REQUIRED" then :review_required
|
|
252
|
-
else :none
|
|
253
|
-
end
|
|
318
|
+
review_gate_result( report: report )
|
|
319
|
+
rescue StandardError => exception
|
|
320
|
+
{ status: :error, review: :error, detail: exception.message }
|
|
254
321
|
end
|
|
255
322
|
|
|
256
323
|
# Merges the PR using the configured merge method.
|
|
@@ -300,12 +367,12 @@ module Carson
|
|
|
300
367
|
# Detects whether the agent is inside a worktree and suggests cleanup.
|
|
301
368
|
def compute_post_merge_next_step!( result: )
|
|
302
369
|
main_root = main_worktree_root
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
.find {
|
|
370
|
+
current_wt = worktree_list
|
|
371
|
+
.reject { it.path == realpath_safe( main_root ) }
|
|
372
|
+
.find { it.holds_cwd? }
|
|
306
373
|
|
|
307
374
|
if current_wt
|
|
308
|
-
wt_name = File.basename( current_wt.
|
|
375
|
+
wt_name = File.basename( current_wt.path )
|
|
309
376
|
result[ :next_step ] = "cd #{main_root} && carson worktree remove #{wt_name}"
|
|
310
377
|
else
|
|
311
378
|
result[ :next_step ] = "carson prune"
|
|
@@ -56,7 +56,7 @@ module Carson
|
|
|
56
56
|
|
|
57
57
|
EXIT_OK
|
|
58
58
|
rescue StandardError => exception
|
|
59
|
-
puts_line "
|
|
59
|
+
puts_line "Govern did not complete: #{exception.message}"
|
|
60
60
|
EXIT_ERROR
|
|
61
61
|
end
|
|
62
62
|
|
|
@@ -70,7 +70,7 @@ module Carson
|
|
|
70
70
|
begin
|
|
71
71
|
govern_cycle!( dry_run: dry_run, json_output: json_output )
|
|
72
72
|
rescue StandardError => exception
|
|
73
|
-
puts_line "
|
|
73
|
+
puts_line "Cycle #{cycle_count} did not complete: #{exception.message}"
|
|
74
74
|
end
|
|
75
75
|
puts_line "sleeping #{loop_seconds}s until next cycle…"
|
|
76
76
|
sleep loop_seconds
|
|
@@ -88,7 +88,7 @@ module Carson
|
|
|
88
88
|
config.govern_repos.map do |path|
|
|
89
89
|
expanded = File.expand_path( path )
|
|
90
90
|
unless Dir.exist?( expanded )
|
|
91
|
-
puts_line "
|
|
91
|
+
puts_line "Skipping #{expanded} — path not found"
|
|
92
92
|
next nil
|
|
93
93
|
end
|
|
94
94
|
expanded
|
|
@@ -107,14 +107,14 @@ module Carson
|
|
|
107
107
|
|
|
108
108
|
unless Dir.exist?( repo_path )
|
|
109
109
|
repo_report[ :error ] = "path does not exist"
|
|
110
|
-
puts_line "
|
|
110
|
+
puts_line "#{repo_path}: path not found, skipping"
|
|
111
111
|
return repo_report
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
prs = list_open_prs( repo_path: repo_path )
|
|
115
115
|
if prs.nil?
|
|
116
116
|
repo_report[ :error ] = "failed to list open PRs"
|
|
117
|
-
puts_line "
|
|
117
|
+
puts_line "#{File.basename(repo_path)}: unable to list open PRs"
|
|
118
118
|
return repo_report
|
|
119
119
|
end
|
|
120
120
|
|
|
@@ -196,8 +196,12 @@ module Carson
|
|
|
196
196
|
if review_decision == "CHANGES_REQUESTED"
|
|
197
197
|
return [ TRIAGE_REVIEW_BLOCKED, "changes requested by reviewer" ]
|
|
198
198
|
end
|
|
199
|
+
if review_decision == "REVIEW_REQUIRED"
|
|
200
|
+
return [ TRIAGE_REVIEW_BLOCKED, "review required" ]
|
|
201
|
+
end
|
|
199
202
|
|
|
200
203
|
review_status, review_detail = check_review_gate_status( pr: pr, repo_path: repo_path )
|
|
204
|
+
return [ TRIAGE_NEEDS_ATTENTION, review_detail ] if review_status == :error
|
|
201
205
|
return [ TRIAGE_REVIEW_BLOCKED, review_detail ] unless review_status == :pass
|
|
202
206
|
|
|
203
207
|
[ TRIAGE_READY, "all gates pass" ]
|
|
@@ -231,17 +235,25 @@ module Carson
|
|
|
231
235
|
|
|
232
236
|
# Checks review gate status. Returns [:pass/:fail, detail].
|
|
233
237
|
def check_review_gate_status( pr:, repo_path: )
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
238
|
+
repo_runtime = scoped_runtime( repo_path: repo_path )
|
|
239
|
+
owner, repo = repo_runtime.send( :repository_coordinates )
|
|
240
|
+
report = repo_runtime.send(
|
|
241
|
+
:review_gate_report_for_pr,
|
|
242
|
+
owner: owner,
|
|
243
|
+
repo: repo,
|
|
244
|
+
pr_number: pr.fetch( "number" ),
|
|
245
|
+
branch_name: pr.fetch( "headRefName" ).to_s,
|
|
246
|
+
pr_summary: {
|
|
247
|
+
number: pr.fetch( "number" ),
|
|
248
|
+
title: pr.fetch( "title" ).to_s,
|
|
249
|
+
url: pr.fetch( "url" ).to_s,
|
|
250
|
+
state: "OPEN"
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
result = repo_runtime.send( :review_gate_result, report: report )
|
|
254
|
+
[ result.fetch( :status ), result.fetch( :detail ) ]
|
|
255
|
+
rescue StandardError => exception
|
|
256
|
+
[ :error, "review gate check failed: #{exception.message}" ]
|
|
245
257
|
end
|
|
246
258
|
|
|
247
259
|
# Maps classification to action.
|
|
@@ -296,7 +308,7 @@ module Carson
|
|
|
296
308
|
housekeep_repo!( repo_path: repo_path )
|
|
297
309
|
else
|
|
298
310
|
error_text = stderr_text.to_s.strip
|
|
299
|
-
puts_line " merge
|
|
311
|
+
puts_line " merge did not succeed: #{error_text}"
|
|
300
312
|
end
|
|
301
313
|
end
|
|
302
314
|
|