carson 3.0.0 → 3.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20f9db167969ae68e42a19efcc96c40108f4f3ab3f8b460369283457c9449e4b
4
- data.tar.gz: 7e94bf5adcafdbd532bd6e789304e04c2a555b7adcbce441363e681a27493c25
3
+ metadata.gz: 21eb861872b515965379fc3dc8afe24fceb228589b479e475999116aca5eca2a
4
+ data.tar.gz: da1ac628ac9da29969d973a5265c7ddff472370c1865e08af69f8f7bff3203e5
5
5
  SHA512:
6
- metadata.gz: 7bdc870e0ae003717b5e624d61386a41a89b5dcd20ec0e0c6fa5106b26791b53106bc43f763ca6199e79ca2bac8e76efcbff21d1ebbb126996550b009848f79f
7
- data.tar.gz: 19dce95ec2fd4fc5a89de09f8e26b814e6b077285159d425639e68e635eda195d52171c7e388ce27efd27b685f7486e2fc6f8fcf0e7005407cdf672bb9c9f7a2
6
+ metadata.gz: ebafee0b573830243f4706fd5884ab9cece7ec07ecf657b936ea76d48c79af9eec30a9af2ac23ea1d7e7fbad604d49573aa67c2c53e9a500f16225684e81c67c
7
+ data.tar.gz: 0aea5423f60e9d33ca4f42f15b5f7bc4ecfd219960cec6d65d0c868fdc53a44831cb0eb4d12264f489b1fd7baafbf55b7226386b9fb1a7e8e4f360b06f9106f7
data/RELEASE.md CHANGED
@@ -5,6 +5,42 @@ Release-note scope rule:
5
5
  - `RELEASE.md` records only version deltas, breaking changes, and migration actions.
6
6
  - Operational usage guides live in `MANUAL.md` and `API.md`.
7
7
 
8
+ ## 3.2.0 — Deliver
9
+
10
+ ### What changed
11
+
12
+ - **`carson deliver`** — pushes the current branch to the remote, creates a PR if none exists, and reports the PR URL. Collapses the manual push/create-PR/report cycle into one command.
13
+ - **`carson deliver --merge`** — does everything above, plus checks CI status and merges the PR if all checks pass. Reports pending or failing CI with actionable guidance. Uses the configured merge method (squash, rebase, or merge). Syncs main after merge.
14
+ - **`carson deliver --title "..." --body-file <path>`** — optional PR title and body file for custom PR metadata. Title defaults to a humanised branch name.
15
+
16
+ ### UX
17
+
18
+ - `carson deliver` guards against delivering from main — exits with an error and recovery guidance.
19
+ - `carson deliver --merge` reports CI status clearly: pass, pending, or failing.
20
+ - Default PR title is generated from the branch name (e.g. `feature/add-search` becomes "Feature: add search").
21
+
22
+ ### Migration
23
+
24
+ - No breaking changes. All existing commands continue to work unchanged.
25
+
26
+ ## 3.1.0 — Worktree Lifecycle
27
+
28
+ ### What changed
29
+
30
+ - **`carson worktree create <name>`** — creates a worktree under `.claude/worktrees/<name>` with a new branch based on main. One command, one result: path and branch name reported.
31
+ - **`carson worktree done <name>`** — marks a worktree as completed without deleting it. Verifies all changes are committed and pushed. Blocks with actionable guidance if uncommitted changes or unpushed commits exist. The worktree directory persists for batch cleanup later.
32
+ - **Deferred deletion model.** The full worktree lifecycle is now: create → work → done → batch cleanup. No worktree is deleted during an active session. Cleanup happens later via `carson worktree remove` or `carson housekeep`.
33
+
34
+ ### UX
35
+
36
+ - `carson worktree create` reports path and branch — ready to `cd` into immediately.
37
+ - `carson worktree done` gives recovery commands when changes are uncommitted or unpushed.
38
+ - Error messages across worktree subcommands now show `create|done|remove` in usage hints.
39
+
40
+ ### Migration
41
+
42
+ - No breaking changes. `carson worktree remove` continues to work as before.
43
+
8
44
  ## 3.0.0 — Agent-Oriented Carson
9
45
 
10
46
  ### Theme
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.0.0
1
+ 3.2.0
data/lib/carson/cli.rb CHANGED
@@ -53,7 +53,7 @@ module Carson
53
53
 
54
54
  def self.build_parser
55
55
  OptionParser.new do |opts|
56
- opts.banner = "Usage: carson [status [--json]|setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--merge METHOD] [--canonical PATH]|audit|sync|prune [--all]|worktree remove <name-or-path>|onboard [repo_path]|refresh [--all|repo_path]|offboard [repo_path]|template check|template apply|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
56
+ opts.banner = "Usage: carson [status [--json]|setup|audit|sync|deliver [--merge] [--title T] [--body-file F]|prune [--all]|worktree create|done|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
57
57
  end
58
58
  end
59
59
 
@@ -90,6 +90,8 @@ module Carson
90
90
  parse_named_subcommand( command: command, usage: "gate|sweep", argv: argv, parser: parser, err: err )
91
91
  when "status"
92
92
  parse_status_command( argv: argv, err: err )
93
+ when "deliver"
94
+ parse_deliver_command( argv: argv, err: err )
93
95
  when "govern"
94
96
  parse_govern_subcommand( argv: argv, err: err )
95
97
  else
@@ -170,12 +172,22 @@ module Carson
170
172
  def self.parse_worktree_subcommand( argv:, parser:, err: )
171
173
  action = argv.shift
172
174
  if action.to_s.strip.empty?
173
- err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree remove <name-or-path>"
175
+ err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|done|remove <name>"
174
176
  err.puts parser
175
177
  return { command: :invalid }
176
178
  end
177
179
 
178
180
  case action
181
+ when "create"
182
+ name = argv.shift
183
+ if name.to_s.strip.empty?
184
+ err.puts "#{BADGE} Missing name for worktree create. Use: carson worktree create <name>"
185
+ return { command: :invalid }
186
+ end
187
+ { command: "worktree:create", worktree_name: name }
188
+ when "done"
189
+ name = argv.shift
190
+ { command: "worktree:done", worktree_name: name }
179
191
  when "remove"
180
192
  force = argv.delete( "--force" ) ? true : false
181
193
  worktree_path = argv.shift
@@ -185,7 +197,7 @@ module Carson
185
197
  end
186
198
  { command: "worktree:remove", worktree_path: worktree_path, force: force }
187
199
  else
188
- err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree remove <name-or-path>"
200
+ err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|done|remove <name>"
189
201
  { command: :invalid }
190
202
  end
191
203
  end
@@ -239,6 +251,31 @@ module Carson
239
251
  { command: "status", json: json_flag }
240
252
  end
241
253
 
254
+ def self.parse_deliver_command( argv:, err: )
255
+ options = { merge: false, title: nil, body_file: nil }
256
+ deliver_parser = OptionParser.new do |opts|
257
+ opts.banner = "Usage: carson deliver [--merge] [--title TITLE] [--body-file PATH]"
258
+ opts.on( "--merge", "Also merge the PR if CI passes" ) { options[ :merge ] = true }
259
+ opts.on( "--title TITLE", "PR title (defaults to branch name)" ) { |v| options[ :title ] = v }
260
+ opts.on( "--body-file PATH", "File containing PR body text" ) { |v| options[ :body_file ] = v }
261
+ end
262
+ deliver_parser.parse!( argv )
263
+ unless argv.empty?
264
+ err.puts "#{BADGE} Unexpected arguments for deliver: #{argv.join( ' ' )}"
265
+ err.puts deliver_parser
266
+ return { command: :invalid }
267
+ end
268
+ {
269
+ command: "deliver",
270
+ merge: options.fetch( :merge ),
271
+ title: options[ :title ],
272
+ body_file: options[ :body_file ]
273
+ }
274
+ rescue OptionParser::ParseError => e
275
+ err.puts "#{BADGE} #{e.message}"
276
+ { command: :invalid }
277
+ end
278
+
242
279
  def self.parse_govern_subcommand( argv:, err: )
243
280
  options = {
244
281
  dry_run: false,
@@ -289,6 +326,10 @@ module Carson
289
326
  runtime.prune!
290
327
  when "prune:all"
291
328
  runtime.prune_all!
329
+ when "worktree:create"
330
+ runtime.worktree_create!( name: parsed.fetch( :worktree_name ) )
331
+ when "worktree:done"
332
+ runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ) )
292
333
  when "worktree:remove"
293
334
  runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ) )
294
335
  when "onboard"
@@ -303,6 +344,12 @@ module Carson
303
344
  runtime.template_check!
304
345
  when "template:apply"
305
346
  runtime.template_apply!( push_prep: parsed.fetch( :push_prep, false ) )
347
+ when "deliver"
348
+ runtime.deliver!(
349
+ merge: parsed.fetch( :merge, false ),
350
+ title: parsed.fetch( :title, nil ),
351
+ body_file: parsed.fetch( :body_file, nil )
352
+ )
306
353
  when "review:gate"
307
354
  runtime.review_gate!
308
355
  when "review:sweep"
@@ -0,0 +1,192 @@
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 is green.
5
+ module Carson
6
+ class Runtime
7
+ module Deliver
8
+ # Entry point for `carson deliver`.
9
+ # Pushes current branch, creates a PR if needed, reports the PR URL.
10
+ # With merge: true, also merges if CI passes and cleans up.
11
+ def deliver!( merge: false, title: nil, body_file: nil )
12
+ branch = current_branch
13
+ main = config.main_branch
14
+ remote = config.git_remote
15
+
16
+ # Guard: cannot deliver from main.
17
+ if branch == main
18
+ puts_line "ERROR: cannot deliver from #{main}. Switch to a feature branch first."
19
+ return EXIT_ERROR
20
+ end
21
+
22
+ # Step 1: push the branch.
23
+ push_result = push_branch!( branch: branch, remote: remote )
24
+ return push_result unless push_result == EXIT_OK
25
+
26
+ # Step 2: find or create the PR.
27
+ pr_number, pr_url = find_or_create_pr!(
28
+ branch: branch, title: title, body_file: body_file
29
+ )
30
+ return EXIT_ERROR if pr_number.nil?
31
+
32
+ puts_line "PR: ##{pr_number} #{pr_url}"
33
+
34
+ # Without --merge, we are done.
35
+ return EXIT_OK unless merge
36
+
37
+ # Step 3: check CI status.
38
+ ci_status = check_pr_ci( number: pr_number )
39
+ case ci_status
40
+ when :pass
41
+ puts_line "CI: pass"
42
+ when :pending
43
+ puts_line "CI: pending — merge when checks complete."
44
+ return EXIT_OK
45
+ when :fail
46
+ puts_line "CI: failing — fix before merging."
47
+ return EXIT_BLOCK
48
+ else
49
+ puts_line "CI: unknown — check manually."
50
+ return EXIT_OK
51
+ end
52
+
53
+ # Step 4: merge.
54
+ merge_result = merge_pr!( number: pr_number )
55
+ return merge_result unless merge_result == EXIT_OK
56
+
57
+ # Step 5: sync main.
58
+ sync_after_merge!( remote: remote, main: main )
59
+
60
+ EXIT_OK
61
+ end
62
+
63
+ private
64
+
65
+ # Pushes the branch to the remote with tracking.
66
+ def push_branch!( branch:, remote: )
67
+ _, push_stderr, push_success, = git_run( "push", "-u", remote, branch )
68
+ unless push_success
69
+ error_text = push_stderr.to_s.strip
70
+ error_text = "push failed" if error_text.empty?
71
+ puts_line "ERROR: #{error_text}"
72
+ return EXIT_ERROR
73
+ end
74
+ puts_verbose "pushed #{branch} to #{remote}"
75
+ EXIT_OK
76
+ end
77
+
78
+ # Finds an existing PR for the branch, or creates a new one.
79
+ # Returns [number, url] or [nil, nil] on failure.
80
+ def find_or_create_pr!( branch:, title: nil, body_file: nil )
81
+ # Check for existing PR.
82
+ existing = find_existing_pr( branch: branch )
83
+ return existing if existing.first
84
+
85
+ # Create a new PR.
86
+ create_pr!( branch: branch, title: title, body_file: body_file )
87
+ end
88
+
89
+ # Queries gh for an open PR on this branch.
90
+ # Returns [number, url] or [nil, nil].
91
+ def find_existing_pr( branch: )
92
+ stdout, _, success, = gh_run(
93
+ "pr", "view", branch,
94
+ "--json", "number,url"
95
+ )
96
+ if success
97
+ data = JSON.parse( stdout ) rescue nil
98
+ if data && data[ "number" ]
99
+ return [ data[ "number" ], data[ "url" ].to_s ]
100
+ end
101
+ end
102
+ [ nil, nil ]
103
+ end
104
+
105
+ # Creates a PR via gh. Title defaults to branch name humanised.
106
+ # Returns [number, url] or [nil, nil] on failure.
107
+ def create_pr!( branch:, title: nil, body_file: nil )
108
+ pr_title = title || default_pr_title( branch: branch )
109
+
110
+ args = [ "pr", "create", "--title", pr_title, "--head", branch ]
111
+ if body_file && File.exist?( body_file )
112
+ args.push( "--body-file", body_file )
113
+ else
114
+ args.push( "--body", "" )
115
+ end
116
+
117
+ stdout, stderr, success, = gh_run( *args )
118
+ unless success
119
+ error_text = stderr.to_s.strip
120
+ error_text = "pr create failed" if error_text.empty?
121
+ puts_line "ERROR: #{error_text}"
122
+ return [ nil, nil ]
123
+ end
124
+
125
+ # gh pr create prints the URL on success. Parse number from it.
126
+ pr_url = stdout.to_s.strip
127
+ pr_number = pr_url.split( "/" ).last.to_i
128
+ if pr_number > 0
129
+ [ pr_number, pr_url ]
130
+ else
131
+ # Fallback: query the just-created PR.
132
+ find_existing_pr( branch: branch )
133
+ end
134
+ end
135
+
136
+ # Generates a default PR title from the branch name.
137
+ def default_pr_title( branch: )
138
+ branch.tr( "-", " " ).gsub( "/", ": " ).sub( /\A\w/ ) { |c| c.upcase }
139
+ end
140
+
141
+ # Checks CI status on a PR. Returns :pass, :fail, :pending, or :none.
142
+ def check_pr_ci( number: )
143
+ stdout, _, success, = gh_run(
144
+ "pr", "checks", number.to_s,
145
+ "--json", "name,state,conclusion"
146
+ )
147
+ return :none unless success
148
+
149
+ checks = JSON.parse( stdout ) rescue []
150
+ return :none if checks.empty?
151
+
152
+ conclusions = checks.map { |c| c[ "conclusion" ].to_s.upcase }
153
+ states = checks.map { |c| c[ "state" ].to_s.upcase }
154
+
155
+ return :fail if conclusions.any? { |c| c == "FAILURE" || c == "CANCELLED" || c == "TIMED_OUT" }
156
+ return :pending if states.any? { |s| s == "PENDING" || s == "QUEUED" || s == "IN_PROGRESS" } ||
157
+ conclusions.any? { |c| c == "" || c == "PENDING" }
158
+
159
+ :pass
160
+ end
161
+
162
+ # Merges the PR using the configured merge method.
163
+ def merge_pr!( number: )
164
+ method = config.govern_merge_method
165
+ stdout, stderr, success, = gh_run(
166
+ "pr", "merge", number.to_s,
167
+ "--#{method}",
168
+ "--delete-branch"
169
+ )
170
+
171
+ if success
172
+ puts_line "Merged PR ##{number} via #{method}."
173
+ EXIT_OK
174
+ else
175
+ error_text = stderr.to_s.strip
176
+ error_text = "merge failed" if error_text.empty?
177
+ puts_line "ERROR: #{error_text}"
178
+ EXIT_ERROR
179
+ end
180
+ end
181
+
182
+ # Syncs main after a successful merge.
183
+ def sync_after_merge!( remote:, main: )
184
+ git_run( "checkout", main )
185
+ git_run( "pull", remote, main )
186
+ puts_verbose "synced #{main} from #{remote}"
187
+ end
188
+ end
189
+
190
+ include Deliver
191
+ end
192
+ end
@@ -2,7 +2,83 @@ module Carson
2
2
  class Runtime
3
3
  module Local
4
4
  # Safe worktree lifecycle management for coding agents.
5
- # Enforces the teardown order: exit worktree git worktree remove branch cleanup.
5
+ # Three operations: create, done (mark completed), remove (batch cleanup).
6
+ # The deferred deletion model: worktrees persist after use, cleaned up later.
7
+
8
+ # Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
9
+ def worktree_create!( name: )
10
+ worktrees_dir = File.join( repo_root, ".claude", "worktrees" )
11
+ wt_path = File.join( worktrees_dir, name )
12
+
13
+ if Dir.exist?( wt_path )
14
+ puts_line "ERROR: worktree already exists: #{name}"
15
+ puts_line " Path: #{wt_path}"
16
+ return EXIT_ERROR
17
+ end
18
+
19
+ # Determine the base branch (main branch from config).
20
+ base = config.main_branch
21
+
22
+ # Create the worktree with a new branch based on the main branch.
23
+ FileUtils.mkdir_p( worktrees_dir )
24
+ _, wt_stderr, wt_success, = git_run( "worktree", "add", wt_path, "-b", name, base )
25
+ unless wt_success
26
+ error_text = wt_stderr.to_s.strip
27
+ error_text = "unable to create worktree" if error_text.empty?
28
+ puts_line "ERROR: #{error_text}"
29
+ return EXIT_ERROR
30
+ end
31
+
32
+ puts_line "Worktree created: #{name}"
33
+ puts_line " Path: #{wt_path}"
34
+ puts_line " Branch: #{name}"
35
+ EXIT_OK
36
+ end
37
+
38
+ # Marks a worktree as completed without deleting it.
39
+ # Verifies all changes are committed. Deferred deletion — cleanup happens later.
40
+ def worktree_done!( name: nil )
41
+ if name.to_s.strip.empty?
42
+ # Try to detect current worktree from CWD.
43
+ puts_line "ERROR: missing worktree name. Use: carson worktree done <name>"
44
+ return EXIT_ERROR
45
+ end
46
+
47
+ resolved_path = resolve_worktree_path( worktree_path: name )
48
+
49
+ unless worktree_registered?( path: resolved_path )
50
+ puts_line "ERROR: #{name} is not a registered worktree."
51
+ return EXIT_ERROR
52
+ end
53
+
54
+ # Check for uncommitted changes in the worktree.
55
+ wt_status, _, status_success, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path )
56
+ if status_success && !wt_status.strip.empty?
57
+ puts_line "Worktree has uncommitted changes: #{name}"
58
+ puts_line " Commit your changes first, then run `carson worktree done #{name}` again."
59
+ return EXIT_BLOCK
60
+ end
61
+
62
+ # Check for unpushed commits.
63
+ branch = worktree_branch( path: resolved_path )
64
+ if branch
65
+ remote = config.git_remote
66
+ remote_ref = "#{remote}/#{branch}"
67
+ ahead, _, ahead_ok, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: resolved_path )
68
+ if ahead_ok && ahead.strip.to_i > 0
69
+ puts_line "Worktree has unpushed commits: #{name}"
70
+ puts_line " Push with `git -C #{resolved_path} push #{remote} #{branch}` first."
71
+ return EXIT_BLOCK
72
+ end
73
+ end
74
+
75
+ puts_line "Worktree done: #{name}"
76
+ puts_line " Branch: #{branch || '(detached)'}"
77
+ puts_line " Cleanup later with `carson worktree remove #{name}` or `carson housekeep`."
78
+ EXIT_OK
79
+ end
80
+
81
+ # Removes a worktree: directory, git registration, and branch.
6
82
  # Never forces removal — if the worktree has uncommitted changes, refuses unless
7
83
  # the user explicitly passes force: true via CLI --force flag.
8
84
  def worktree_remove!( worktree_path:, force: false )
@@ -221,3 +221,4 @@ require_relative "runtime/review"
221
221
  require_relative "runtime/govern"
222
222
  require_relative "runtime/setup"
223
223
  require_relative "runtime/status"
224
+ require_relative "runtime/deliver"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -52,6 +52,7 @@ files:
52
52
  - lib/carson/config.rb
53
53
  - lib/carson/runtime.rb
54
54
  - lib/carson/runtime/audit.rb
55
+ - lib/carson/runtime/deliver.rb
55
56
  - lib/carson/runtime/govern.rb
56
57
  - lib/carson/runtime/local.rb
57
58
  - lib/carson/runtime/local/hooks.rb