carson 3.22.0 → 3.23.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/API.md +19 -20
  3. data/MANUAL.md +76 -65
  4. data/README.md +42 -50
  5. data/RELEASE.md +24 -1
  6. data/SKILL.md +1 -1
  7. data/VERSION +1 -1
  8. data/carson.gemspec +3 -4
  9. data/hooks/command-guard +1 -1
  10. data/hooks/pre-push +17 -20
  11. data/lib/carson/adapters/agent.rb +2 -2
  12. data/lib/carson/branch.rb +38 -0
  13. data/lib/carson/cli.rb +45 -30
  14. data/lib/carson/config.rb +80 -29
  15. data/lib/carson/delivery.rb +64 -0
  16. data/lib/carson/ledger.rb +305 -0
  17. data/lib/carson/repository.rb +47 -0
  18. data/lib/carson/revision.rb +30 -0
  19. data/lib/carson/runtime/audit.rb +43 -17
  20. data/lib/carson/runtime/deliver.rb +163 -149
  21. data/lib/carson/runtime/govern.rb +233 -357
  22. data/lib/carson/runtime/housekeep.rb +233 -27
  23. data/lib/carson/runtime/local/onboard.rb +29 -29
  24. data/lib/carson/runtime/local/prune.rb +120 -35
  25. data/lib/carson/runtime/local/sync.rb +29 -7
  26. data/lib/carson/runtime/local/template.rb +30 -12
  27. data/lib/carson/runtime/local/worktree.rb +37 -442
  28. data/lib/carson/runtime/review/gate_support.rb +144 -12
  29. data/lib/carson/runtime/review/sweep_support.rb +2 -2
  30. data/lib/carson/runtime/review/utility.rb +1 -1
  31. data/lib/carson/runtime/review.rb +21 -77
  32. data/lib/carson/runtime/setup.rb +25 -33
  33. data/lib/carson/runtime/status.rb +96 -212
  34. data/lib/carson/runtime.rb +39 -4
  35. data/lib/carson/worktree.rb +497 -0
  36. data/lib/carson.rb +6 -0
  37. metadata +37 -17
  38. data/.github/copilot-instructions.md +0 -1
  39. data/.github/pull_request_template.md +0 -12
  40. data/templates/.github/AGENTS.md +0 -1
  41. data/templates/.github/CLAUDE.md +0 -1
  42. data/templates/.github/carson.md +0 -47
  43. data/templates/.github/copilot-instructions.md +0 -1
  44. data/templates/.github/pull_request_template.md +0 -12
@@ -1,86 +1,118 @@
1
- # PR delivery lifecycle — push, create PR, and optionally merge.
2
- # Collapses the 8-step manual PR flow into one or two commands.
3
- # `carson deliver` pushes and creates the PR.
4
- # `carson deliver --merge` also merges if CI passes or no checks are configured.
5
- # `carson deliver --json` outputs structured result for agent consumption.
1
+ # Branch delivery lifecycle — push, create/update PR, and register Carson-owned delivery state.
2
+ # `carson deliver` is now the async handoff point. It does not wait for merge.
6
3
  module Carson
7
4
  class Runtime
8
5
  module Deliver
9
6
  # Entry point for `carson deliver`.
10
- # Pushes current branch, creates a PR if needed, reports the PR URL.
11
- # With merge: true, also merges if CI passes and cleans up.
12
- def deliver!( merge: false, title: nil, body_file: nil, json_output: false )
13
- branch = current_branch
14
- main = config.main_branch
15
- remote = config.git_remote
16
- result = { command: "deliver", branch: branch }
17
-
18
- # Guard: cannot deliver from main.
19
- if branch == main
20
- result[ :error ] = "cannot deliver from #{main}"
21
- result[ :recovery ] = "git checkout -b <branch-name>"
22
- return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
7
+ # Pushes the current branch, ensures a PR exists, records delivery state, and returns.
8
+ def deliver!( title: nil, body_file: nil, json_output: false )
9
+ branch_name = current_branch
10
+ main_branch = config.main_branch
11
+ remote_name = config.git_remote
12
+ result = { command: "deliver", branch: branch_name }
13
+
14
+ if branch_name == main_branch
15
+ result[ :error ] = "cannot deliver from #{main_branch}"
16
+ result[ :recovery ] = "carson worktree create <name>"
17
+ return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
18
+ end
19
+
20
+ sync_exit, sync_diagnostics = deliver_template_sync
21
+ if sync_exit == EXIT_ERROR
22
+ result[ :error ] = sync_diagnostics.to_s.strip.empty? ? "template sync failed" : sync_diagnostics.strip
23
+ return deliver_finish( result: result, exit_code: sync_exit, json_output: json_output )
23
24
  end
24
25
 
25
- # Step 1: push the branch.
26
- push_exit = push_branch!( branch: branch, remote: remote, result: result )
26
+ push_exit = push_branch!( branch: branch_name, remote: remote_name, result: result )
27
27
  return deliver_finish( result: result, exit_code: push_exit, json_output: json_output ) unless push_exit == EXIT_OK
28
28
 
29
- # Step 2: find or create the PR.
30
29
  pr_number, pr_url = find_or_create_pr!(
31
- branch: branch, title: title, body_file: body_file, result: result
30
+ branch: branch_name,
31
+ title: title,
32
+ body_file: body_file,
33
+ result: result
32
34
  )
33
- if pr_number.nil?
34
- return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
35
- end
35
+ return deliver_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output ) if pr_number.nil?
36
+
37
+ branch = branch_record( name: branch_name )
38
+ delivery = ledger.upsert_delivery(
39
+ repository: repository_record,
40
+ branch_name: branch.name,
41
+ head: branch.head || current_head,
42
+ worktree_path: branch.worktree || repo_root,
43
+ authority: config.govern_authority,
44
+ pr_number: pr_number,
45
+ pr_url: pr_url,
46
+ status: "preparing",
47
+ summary: "delivery accepted",
48
+ cause: nil
49
+ )
50
+ delivery = assess_delivery!( delivery: delivery, branch_name: branch.name )
36
51
 
37
52
  result[ :pr_number ] = pr_number
38
53
  result[ :pr_url ] = pr_url
39
- # Without --merge, we are done.
40
- unless merge
41
- return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
42
- end
54
+ result[ :ci ] = check_pr_ci( number: pr_number ).to_s
55
+ result[ :delivery ] = delivery_payload( delivery: delivery )
56
+ result[ :summary ] = delivery.summary
57
+ result[ :next_step ] = "carson status"
43
58
 
44
- # Step 3: check CI status.
45
- ci_status = check_pr_ci( number: pr_number )
46
- result[ :ci ] = ci_status.to_s
47
-
48
- case ci_status
49
- when :pass, :none
50
- # Continue to review gate. :none means no checks configured — nothing to wait for.
51
- when :pending
52
- result[ :recovery ] = "gh pr checks #{pr_number} --watch && carson deliver --merge"
53
- return deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
54
- when :fail
55
- result[ :recovery ] = "gh pr checks #{pr_number} — fix failures, push, then `carson deliver --merge`"
56
- return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
57
- end
58
-
59
- # Step 4: check review gate — block if changes are requested.
60
- review = check_pr_review( number: pr_number )
61
- result[ :review ] = review.to_s
62
- if review == :changes_requested
63
- result[ :error ] = "review changes requested on PR ##{pr_number}"
64
- result[ :recovery ] = "address review comments, push, then `carson deliver --merge`"
65
- return deliver_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
66
- end
59
+ deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
60
+ end
67
61
 
68
- # Step 5: merge.
69
- merge_exit = merge_pr!( number: pr_number, result: result )
70
- return deliver_finish( result: result, exit_code: merge_exit, json_output: json_output ) unless merge_exit == EXIT_OK
62
+ private
71
63
 
72
- result[ :merged ] = true
64
+ def deliver_template_sync
65
+ saved_output, saved_error = @output, @error
66
+ captured_out = StringIO.new
67
+ captured_err = StringIO.new
68
+ @output = captured_out
69
+ @error = captured_err
70
+ exit_code = template_apply!( push_prep: true )
71
+ [ exit_code, captured_out.string + captured_err.string ]
72
+ rescue StandardError => exception
73
+ [ EXIT_ERROR, "template sync error: #{exception.message}" ]
74
+ ensure
75
+ @output, @error = saved_output, saved_error
76
+ end
73
77
 
74
- # Step 6: sync main in the main worktree.
75
- sync_after_merge!( remote: remote, main: main, result: result )
78
+ # Assesses delivery readiness and records Carson's current branch state.
79
+ def assess_delivery!( delivery:, branch_name: )
80
+ review = check_pr_review( number: delivery.pull_request_number, branch: branch_name, pr_url: delivery.pull_request_url )
81
+ ci = check_pr_ci( number: delivery.pull_request_number )
82
+ status, cause, summary = delivery_assessment( ci: ci, review: review )
83
+
84
+ ledger.update_delivery(
85
+ delivery: delivery,
86
+ status: status,
87
+ cause: cause,
88
+ summary: summary,
89
+ pr_number: delivery.pull_request_number,
90
+ pr_url: delivery.pull_request_url,
91
+ worktree_path: delivery.worktree_path
92
+ )
93
+ end
76
94
 
77
- # Step 7: compute next-step guidance for the agent.
78
- compute_post_merge_next_step!( result: result )
95
+ def delivery_assessment( ci:, review: )
96
+ return [ "gated", "ci", "waiting for CI checks" ] if ci == :pending
97
+ return [ "gated", "ci", "CI checks are failing" ] if ci == :fail
98
+ return [ "gated", "review", "review changes requested" ] if review.fetch( :review, :none ) == :changes_requested
99
+ return [ "gated", "review", "waiting for review" ] if review.fetch( :review, :none ) == :review_required
100
+ return [ "gated", "review", review.fetch( :detail ).to_s ] if review.fetch( :status, :pass ) == :fail
101
+ return [ "gated", "policy", "unable to assess review gate: #{review.fetch( :detail )}" ] if review.fetch( :status, :pass ) == :error
79
102
 
80
- deliver_finish( result: result, exit_code: EXIT_OK, json_output: json_output )
103
+ [ "queued", nil, "ready to integrate into #{config.main_branch}" ]
81
104
  end
82
105
 
83
- private
106
+ def delivery_payload( delivery: )
107
+ {
108
+ id: delivery.id,
109
+ status: delivery.status,
110
+ head: delivery.head,
111
+ worktree_path: delivery.worktree_path,
112
+ revision_count: delivery.revision_count,
113
+ cause: delivery.cause
114
+ }
115
+ end
84
116
 
85
117
  # Outputs the final result — JSON or human-readable — and returns exit code.
86
118
  def deliver_finish( result:, exit_code:, json_output: )
@@ -97,78 +129,76 @@ module Carson
97
129
 
98
130
  # Human-readable output for deliver results.
99
131
  def print_deliver_human( result: )
100
- exit_code = result.fetch( :exit_code )
101
-
102
132
  if result[ :error ]
103
- puts_line "ERROR: #{result[ :error ]}"
104
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
133
+ puts_line result[ :error ]
134
+ puts_line " #{result[ :recovery ]}" if result[ :recovery ]
105
135
  return
106
136
  end
107
137
 
108
- if result[ :pr_number ]
109
- puts_line "PR: ##{result[ :pr_number ]} #{result[ :pr_url ]}"
110
- end
111
-
112
- if result[ :ci ]
113
- ci = result[ :ci ]
114
- case ci
115
- when "pass"
116
- puts_line "CI: pass"
117
- when "none"
118
- puts_line "CI: none — no checks configured, proceeding."
119
- when "pending"
120
- puts_line "CI: pending — merge when checks complete."
121
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
122
- when "fail"
123
- puts_line "CI: failing — fix before merging."
124
- puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
125
- end
126
- end
127
-
128
- if result[ :merged ]
129
- puts_line "Merged PR ##{result[ :pr_number ]} via #{result[ :merge_method ]}."
130
- puts_line " Next: #{result[ :next_step ]}" if result[ :next_step ]
138
+ puts_line "PR: ##{result[ :pr_number ]} #{result[ :pr_url ]}" if result[ :pr_number ]
139
+ if result[ :delivery ]
140
+ puts_line "Delivery: #{result.dig( :delivery, :status )}"
131
141
  end
142
+ puts_line "Summary: #{result[ :summary ]}" if result[ :summary ]
143
+ puts_line " Next: #{result[ :next_step ]}" if result[ :next_step ]
132
144
  end
133
145
 
134
146
  # Pushes the branch to the remote with tracking.
135
- # Sets CARSON_PUSH=1 so the pre-push hook knows this is a Carson-managed push.
136
147
  def push_branch!( branch:, remote:, result: )
137
- _, push_stderr, push_success, = with_env_var( "CARSON_PUSH", "1" ) do
138
- git_run( "push", "-u", remote, branch )
148
+ _, push_stderr, push_success, = git_run( "push", "--no-verify", "-u", remote, branch )
149
+
150
+ if !push_success && push_stderr.to_s.include?( "non-fast-forward" )
151
+ return force_push_with_lease!( branch: branch, remote: remote, result: result )
139
152
  end
153
+
140
154
  unless push_success
141
155
  error_text = push_stderr.to_s.strip
142
156
  error_text = "push failed" if error_text.empty?
143
157
  result[ :error ] = error_text
144
- result[ :recovery ] = "git pull #{remote} #{branch} --rebase && git push -u #{remote} #{branch}"
145
158
  return EXIT_ERROR
146
159
  end
147
160
  puts_verbose "pushed #{branch} to #{remote}"
148
161
  EXIT_OK
149
162
  end
150
163
 
164
+ # Retries push with --force-with-lease after a non-fast-forward rejection.
165
+ def force_push_with_lease!( branch:, remote:, result: )
166
+ puts_verbose "push rejected (non-fast-forward), retrying with --force-with-lease"
167
+ _, lease_stderr, lease_success, = git_run( "push", "--no-verify", "--force-with-lease", "-u", remote, branch )
168
+
169
+ if lease_success
170
+ puts_verbose "pushed #{branch} to #{remote} (force-with-lease)"
171
+ return EXIT_OK
172
+ end
173
+
174
+ if lease_stderr.to_s.include?( "stale info" )
175
+ result[ :error ] = "force-with-lease rejected — another push landed on #{branch} since your last fetch"
176
+ result[ :recovery ] = "git fetch #{remote} #{branch} && carson deliver"
177
+ else
178
+ error_text = lease_stderr.to_s.strip
179
+ error_text = "push failed (force-with-lease)" if error_text.empty?
180
+ result[ :error ] = error_text
181
+ end
182
+ EXIT_ERROR
183
+ end
184
+
151
185
  # Finds an existing PR for the branch, or creates a new one.
152
- # Returns [number, url] or [nil, nil] on failure.
153
186
  def find_or_create_pr!( branch:, title: nil, body_file: nil, result: )
154
- # Check for existing PR.
155
187
  existing = find_existing_pr( branch: branch )
156
188
  return existing if existing.first
157
189
 
158
- # Create a new PR.
159
190
  create_pr!( branch: branch, title: title, body_file: body_file, result: result )
160
191
  end
161
192
 
162
193
  # Queries gh for an open PR on this branch.
163
- # Returns [number, url] or [nil, nil].
164
194
  def find_existing_pr( branch: )
165
195
  stdout, _, success, = gh_run(
166
196
  "pr", "view", branch,
167
- "--json", "number,url"
197
+ "--json", "number,url,state"
168
198
  )
169
199
  if success
170
200
  data = JSON.parse( stdout ) rescue nil
171
- if data && data[ "number" ]
201
+ if data && data[ "number" ] && data[ "state" ] == "OPEN"
172
202
  return [ data[ "number" ], data[ "url" ].to_s ]
173
203
  end
174
204
  end
@@ -176,11 +206,10 @@ module Carson
176
206
  end
177
207
 
178
208
  # Creates a PR via gh. Title defaults to branch name humanised.
179
- # Returns [number, url] or [nil, nil] on failure.
180
209
  def create_pr!( branch:, title: nil, body_file: nil, result: )
181
210
  pr_title = title || default_pr_title( branch: branch )
182
-
183
211
  args = [ "pr", "create", "--title", pr_title, "--head", branch ]
212
+
184
213
  if body_file && File.exist?( body_file )
185
214
  args.push( "--body-file", body_file )
186
215
  else
@@ -196,24 +225,18 @@ module Carson
196
225
  return [ nil, nil ]
197
226
  end
198
227
 
199
- # gh pr create prints the URL on success. Parse number from it.
200
228
  pr_url = stdout.to_s.strip
201
229
  pr_number = pr_url.split( "/" ).last.to_i
202
- if pr_number > 0
203
- [ pr_number, pr_url ]
204
- else
205
- # Fallback: query the just-created PR.
206
- find_existing_pr( branch: branch )
207
- end
230
+ return [ pr_number, pr_url ] if pr_number > 0
231
+
232
+ find_existing_pr( branch: branch )
208
233
  end
209
234
 
210
- # Generates a default PR title from the branch name.
211
235
  def default_pr_title( branch: )
212
- branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { it.upcase }
236
+ branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { |character| character.upcase }
213
237
  end
214
238
 
215
239
  # Checks CI status on a PR. Returns :pass, :fail, :pending, or :none.
216
- # Uses the `bucket` field (pass/fail/pending) from `gh pr checks --json`.
217
240
  def check_pr_ci( number: )
218
241
  stdout, _, success, = gh_run(
219
242
  "pr", "checks", number.to_s,
@@ -224,35 +247,47 @@ module Carson
224
247
  checks = JSON.parse( stdout ) rescue []
225
248
  return :none if checks.empty?
226
249
 
227
- buckets = checks.map { it[ "bucket" ].to_s.downcase }
250
+ buckets = checks.map { |entry| entry[ "bucket" ].to_s.downcase }
228
251
  return :fail if buckets.include?( "fail" )
229
252
  return :pending if buckets.include?( "pending" )
230
253
 
231
254
  :pass
232
255
  end
233
256
 
234
- # Checks review decision on a PR. Returns :approved, :changes_requested, :review_required, or :none.
235
- def check_pr_review( number: )
257
+ # Checks the full review gate on a PR. Returns a structured result hash.
258
+ def check_pr_review( number:, branch:, pr_url: nil )
259
+ owner, repo = repository_coordinates
260
+ report = review_gate_report_for_pr(
261
+ owner: owner,
262
+ repo: repo,
263
+ pr_number: number,
264
+ branch_name: branch,
265
+ pr_summary: {
266
+ number: number,
267
+ title: "",
268
+ url: pr_url.to_s,
269
+ state: "OPEN"
270
+ }
271
+ )
272
+ review_gate_result( report: report )
273
+ rescue StandardError => exception
274
+ { status: :error, review: :error, detail: exception.message }
275
+ end
276
+
277
+ # Returns the current PR state for govern reconciliation.
278
+ def pull_request_state( number: )
236
279
  stdout, _, success, = gh_run(
237
280
  "pr", "view", number.to_s,
238
- "--json", "reviewDecision"
281
+ "--json", "number,state,isDraft,url"
239
282
  )
240
- return :none unless success
283
+ return nil unless success
241
284
 
242
- data = JSON.parse( stdout ) rescue {}
243
- decision = data[ "reviewDecision" ].to_s.strip.upcase
244
- case decision
245
- when "APPROVED" then :approved
246
- when "CHANGES_REQUESTED" then :changes_requested
247
- when "REVIEW_REQUIRED" then :review_required
248
- else :none
249
- end
285
+ JSON.parse( stdout )
286
+ rescue JSON::ParserError
287
+ nil
250
288
  end
251
289
 
252
- # Merges the PR using the configured merge method.
253
- # Deliberately omits --delete-branch: gh tries to switch the local
254
- # checkout to main afterwards, which fails inside a worktree where
255
- # main is already checked output. Branch cleanup deferred to `carson prune`.
290
+ # Merges the PR using the governed merge method.
256
291
  def merge_pr!( number:, result: )
257
292
  method = config.govern_merge_method
258
293
  result[ :merge_method ] = method
@@ -274,9 +309,6 @@ module Carson
274
309
  end
275
310
 
276
311
  # Syncs main after a successful merge.
277
- # Pulls into the main worktree directly — does not attempt checkout,
278
- # because checkout would fail when running inside a feature worktree
279
- # (main is already checked output in the main tree).
280
312
  def sync_after_merge!( remote:, main:, result: )
281
313
  main_root = main_worktree_root
282
314
  _, pull_stderr, pull_success, = Open3.capture3(
@@ -291,24 +323,6 @@ module Carson
291
323
  puts_verbose "sync failed: #{pull_stderr.to_s.strip}"
292
324
  end
293
325
  end
294
-
295
- # Builds next-step guidance after a successful merge.
296
- # Detects whether the agent is inside a worktree and suggests cleanup.
297
- def compute_post_merge_next_step!( result: )
298
- main_root = main_worktree_root
299
- cwd = realpath_safe( Dir.pwd )
300
- current_wt = worktree_list.select { it.fetch( :path ) != realpath_safe( main_root ) }
301
- .find { cwd == it.fetch( :path ) || cwd.start_with?( File.join( it.fetch( :path ), "" ) ) }
302
-
303
- if current_wt
304
- wt_name = File.basename( current_wt.fetch( :path ) )
305
- result[ :next_step ] = "cd #{main_root} && carson worktree remove #{wt_name}"
306
- else
307
- result[ :next_step ] = "carson prune"
308
- end
309
- rescue StandardError
310
- # Best-effort — do not fail deliver because of next-step detection.
311
- end
312
326
  end
313
327
 
314
328
  include Deliver