carson 3.6.0 → 3.7.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: d430ed51ffe8ee87c8aa2a652c63d92ef7e9f0a65713b9104dd4df57dbcba90a
4
- data.tar.gz: d5bf899e7acc479cb5f673c0ba0323dce10e83a6c8d843f418d9db53adbbed38
3
+ metadata.gz: 43f5ee17800cad35a51d2aaaf6ce306197bf2823cc6b0de4858912213d06e01b
4
+ data.tar.gz: 51b3a3e91d84a98266b2bccee868e583c25609c5466e34c938b2fa2bcd6564fc
5
5
  SHA512:
6
- metadata.gz: 903da2928ca57d07422e7450670efb7686603934fc2b7aa8122789fcea6ecce2e1b841420c15c2df39dc33b2789c4d3a85a0cb56dc5816aa1b0216fa103f7cb5
7
- data.tar.gz: 2c67d3aa87576d0124d3ea2fb31fdaf8457cbfb81199c539e103072636a1fcaef4e1274c3642d1a843117c1cd88a69e437cf369c5f981a06fed50d6e57e0d2f3
6
+ metadata.gz: c7bca3554a185fc1951292d62e0bfcfd6b63d77488d22013f14b93885e6b8d043d2d30e20e8e9725796d26bc3aaa031bcce8f0b28474ab3198bf77733c56f0c5
7
+ data.tar.gz: ce3240db72d7fb1c6a5aa9f590628d23e3533778cc492def9c73951a8f5b6772a347cb8d64c79e0063fb79f67f16fb663c9a8ecdc33db7f9af15e0204678adfd
data/RELEASE.md CHANGED
@@ -5,6 +5,24 @@ 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.7.0 — Worktree JSON + Recovery
9
+
10
+ ### What changed
11
+
12
+ - **`carson worktree --json create|done|remove`** — machine-readable JSON output for all three worktree commands. Each returns a structured envelope with `command`, `status`, `name`, `exit_code`, and context-specific fields (`path`, `branch`, `next_step`).
13
+ - **Recovery-aware worktree errors** — every error and block path includes a `recovery` field with the exact command to run next. Dirty worktree, missing name, unregistered worktree, unpushed commits — all include actionable recovery.
14
+ - **Unified `worktree_finish` / `print_worktree_human`** — consistent output pattern matching deliver, sync, and prune.
15
+
16
+ ### UX
17
+
18
+ - Human output preserved unchanged when `--json` is not passed.
19
+ - `worktree done` includes `next_step` in JSON output pointing to `carson worktree remove`.
20
+ - `worktree remove` includes `branch_deleted` and `remote_deleted` booleans for complete lifecycle visibility.
21
+
22
+ ### Migration
23
+
24
+ - No breaking changes. All worktree commands without `--json` behave identically to 3.6.0.
25
+
8
26
  ## 3.6.0 — Prune JSON + Recovery
9
27
 
10
28
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.6.0
1
+ 3.7.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|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree create|done|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
56
+ opts.banner = "Usage: carson [status [--json]|setup|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] 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
 
@@ -175,6 +175,7 @@ module Carson
175
175
  end
176
176
 
177
177
  def self.parse_worktree_subcommand( argv:, parser:, err: )
178
+ json_flag = argv.delete( "--json" ) ? true : false
178
179
  action = argv.shift
179
180
  if action.to_s.strip.empty?
180
181
  err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|done|remove <name>"
@@ -189,10 +190,10 @@ module Carson
189
190
  err.puts "#{BADGE} Missing name for worktree create. Use: carson worktree create <name>"
190
191
  return { command: :invalid }
191
192
  end
192
- { command: "worktree:create", worktree_name: name }
193
+ { command: "worktree:create", worktree_name: name, json: json_flag }
193
194
  when "done"
194
195
  name = argv.shift
195
- { command: "worktree:done", worktree_name: name }
196
+ { command: "worktree:done", worktree_name: name, json: json_flag }
196
197
  when "remove"
197
198
  force = argv.delete( "--force" ) ? true : false
198
199
  worktree_path = argv.shift
@@ -200,7 +201,7 @@ module Carson
200
201
  err.puts "#{BADGE} Missing path for worktree remove. Use: carson worktree remove <name-or-path>"
201
202
  return { command: :invalid }
202
203
  end
203
- { command: "worktree:remove", worktree_path: worktree_path, force: force }
204
+ { command: "worktree:remove", worktree_path: worktree_path, force: force, json: json_flag }
204
205
  else
205
206
  err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|done|remove <name>"
206
207
  { command: :invalid }
@@ -352,11 +353,11 @@ module Carson
352
353
  when "prune:all"
353
354
  runtime.prune_all!
354
355
  when "worktree:create"
355
- runtime.worktree_create!( name: parsed.fetch( :worktree_name ) )
356
+ runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
356
357
  when "worktree:done"
357
- runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ) )
358
+ runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ), json_output: parsed.fetch( :json, false ) )
358
359
  when "worktree:remove"
359
- runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ) )
360
+ runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) )
360
361
  when "onboard"
361
362
  runtime.onboard!
362
363
  when "refresh"
@@ -1,19 +1,23 @@
1
+ # Safe worktree lifecycle management for coding agents.
2
+ # Three operations: create, done (mark completed), remove (batch cleanup).
3
+ # The deferred deletion model: worktrees persist after use, cleaned up later.
4
+ # Supports --json for machine-readable structured output with recovery commands.
1
5
  module Carson
2
6
  class Runtime
3
7
  module Local
4
- # Safe worktree lifecycle management for coding agents.
5
- # Three operations: create, done (mark completed), remove (batch cleanup).
6
- # The deferred deletion model: worktrees persist after use, cleaned up later.
7
8
 
8
9
  # Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
9
- def worktree_create!( name: )
10
+ def worktree_create!( name:, json_output: false )
10
11
  worktrees_dir = File.join( repo_root, ".claude", "worktrees" )
11
12
  wt_path = File.join( worktrees_dir, name )
12
13
 
13
14
  if Dir.exist?( wt_path )
14
- puts_line "ERROR: worktree already exists: #{name}"
15
- puts_line " Path: #{wt_path}"
16
- return EXIT_ERROR
15
+ return worktree_finish(
16
+ result: { command: "worktree create", status: "error", name: name, path: wt_path,
17
+ error: "worktree already exists: #{name}",
18
+ recovery: "carson worktree remove #{name}, then retry" },
19
+ exit_code: EXIT_ERROR, json_output: json_output
20
+ )
17
21
  end
18
22
 
19
23
  # Determine the base branch (main branch from config).
@@ -25,38 +29,51 @@ module Carson
25
29
  unless wt_success
26
30
  error_text = wt_stderr.to_s.strip
27
31
  error_text = "unable to create worktree" if error_text.empty?
28
- puts_line "ERROR: #{error_text}"
29
- return EXIT_ERROR
32
+ return worktree_finish(
33
+ result: { command: "worktree create", status: "error", name: name,
34
+ error: error_text },
35
+ exit_code: EXIT_ERROR, json_output: json_output
36
+ )
30
37
  end
31
38
 
32
- puts_line "Worktree created: #{name}"
33
- puts_line " Path: #{wt_path}"
34
- puts_line " Branch: #{name}"
35
- EXIT_OK
39
+ worktree_finish(
40
+ result: { command: "worktree create", status: "ok", name: name, path: wt_path, branch: name },
41
+ exit_code: EXIT_OK, json_output: json_output
42
+ )
36
43
  end
37
44
 
38
45
  # Marks a worktree as completed without deleting it.
39
46
  # Verifies all changes are committed. Deferred deletion — cleanup happens later.
40
- def worktree_done!( name: nil )
47
+ def worktree_done!( name: nil, json_output: false )
41
48
  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
49
+ return worktree_finish(
50
+ result: { command: "worktree done", status: "error",
51
+ error: "missing worktree name",
52
+ recovery: "carson worktree done <name>" },
53
+ exit_code: EXIT_ERROR, json_output: json_output
54
+ )
45
55
  end
46
56
 
47
57
  resolved_path = resolve_worktree_path( worktree_path: name )
48
58
 
49
59
  unless worktree_registered?( path: resolved_path )
50
- puts_line "ERROR: #{name} is not a registered worktree."
51
- return EXIT_ERROR
60
+ return worktree_finish(
61
+ result: { command: "worktree done", status: "error", name: name,
62
+ error: "#{name} is not a registered worktree",
63
+ recovery: "git worktree list" },
64
+ exit_code: EXIT_ERROR, json_output: json_output
65
+ )
52
66
  end
53
67
 
54
68
  # Check for uncommitted changes in the worktree.
55
69
  wt_status, _, status_success, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path )
56
70
  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
71
+ return worktree_finish(
72
+ result: { command: "worktree done", status: "block", name: name,
73
+ error: "worktree has uncommitted changes",
74
+ recovery: "git -C #{resolved_path} add -A && git -C #{resolved_path} commit, then carson worktree done #{name}" },
75
+ exit_code: EXIT_BLOCK, json_output: json_output
76
+ )
60
77
  end
61
78
 
62
79
  # Check for unpushed commits.
@@ -66,39 +83,54 @@ module Carson
66
83
  remote_ref = "#{remote}/#{branch}"
67
84
  ahead, _, ahead_ok, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: resolved_path )
68
85
  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
86
+ return worktree_finish(
87
+ result: { command: "worktree done", status: "block", name: name, branch: branch,
88
+ error: "worktree has unpushed commits",
89
+ recovery: "git -C #{resolved_path} push #{remote} #{branch}" },
90
+ exit_code: EXIT_BLOCK, json_output: json_output
91
+ )
72
92
  end
73
93
  end
74
94
 
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
95
+ worktree_finish(
96
+ result: { command: "worktree done", status: "ok", name: name, branch: branch || "(detached)",
97
+ next_step: "carson worktree remove #{name}" },
98
+ exit_code: EXIT_OK, json_output: json_output
99
+ )
79
100
  end
80
101
 
81
102
  # Removes a worktree: directory, git registration, and branch.
82
103
  # Never forces removal — if the worktree has uncommitted changes, refuses unless
83
104
  # the user explicitly passes force: true via CLI --force flag.
84
- def worktree_remove!( worktree_path:, force: false )
105
+ def worktree_remove!( worktree_path:, force: false, json_output: false )
85
106
  fingerprint_status = block_if_outsider_fingerprints!
86
- return fingerprint_status unless fingerprint_status.nil?
107
+ unless fingerprint_status.nil?
108
+ if json_output
109
+ out.puts JSON.pretty_generate( {
110
+ command: "worktree remove", status: "block",
111
+ error: "Carson-owned artefacts detected in host repository",
112
+ recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
113
+ exit_code: EXIT_BLOCK
114
+ } )
115
+ end
116
+ return fingerprint_status
117
+ end
87
118
 
88
119
  resolved_path = resolve_worktree_path( worktree_path: worktree_path )
89
120
 
90
121
  unless worktree_registered?( path: resolved_path )
91
- puts_line "ERROR: #{resolved_path} is not a registered worktree."
92
- puts_line " Registered worktrees:"
93
- worktree_list.each { |wt| puts_line " - #{wt.fetch( :path )} [#{wt.fetch( :branch )}]" }
94
- return EXIT_ERROR
122
+ return worktree_finish(
123
+ result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
124
+ error: "#{resolved_path} is not a registered worktree",
125
+ recovery: "git worktree list" },
126
+ exit_code: EXIT_ERROR, json_output: json_output
127
+ )
95
128
  end
96
129
 
97
130
  branch = worktree_branch( path: resolved_path )
98
131
  puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
99
132
 
100
133
  # Step 1: remove the worktree (directory + git registration).
101
- # Try safe removal first. Only use --force if the user explicitly requested it.
102
134
  rm_args = [ "worktree", "remove" ]
103
135
  rm_args << "--force" if force
104
136
  rm_args << resolved_path
@@ -107,40 +139,96 @@ module Carson
107
139
  error_text = rm_stderr.to_s.strip
108
140
  error_text = "unable to remove worktree" if error_text.empty?
109
141
  if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) )
110
- puts_line "Worktree has uncommitted changes: #{File.basename( resolved_path )}"
111
- puts_line " Commit or discard changes first, or use --force to override."
112
- else
113
- puts_line "ERROR: #{error_text}"
142
+ return worktree_finish(
143
+ result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
144
+ error: "worktree has uncommitted changes",
145
+ recovery: "commit or discard changes first, or use --force to override" },
146
+ exit_code: EXIT_ERROR, json_output: json_output
147
+ )
114
148
  end
115
- return EXIT_ERROR
149
+ return worktree_finish(
150
+ result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
151
+ error: error_text },
152
+ exit_code: EXIT_ERROR, json_output: json_output
153
+ )
116
154
  end
117
155
  puts_verbose "worktree_removed: #{resolved_path}"
118
156
 
119
157
  # Step 2: delete the local branch.
158
+ branch_deleted = false
120
159
  if branch && !config.protected_branches.include?( branch )
121
160
  _, del_stderr, del_success, = git_run( "branch", "-D", branch )
122
161
  if del_success
123
162
  puts_verbose "branch_deleted: #{branch}"
163
+ branch_deleted = true
124
164
  else
125
165
  puts_verbose "branch_delete_skipped: #{branch} reason=#{del_stderr.to_s.strip}"
126
166
  end
127
167
  end
128
168
 
129
169
  # Step 3: delete the remote branch (best-effort).
170
+ remote_deleted = false
130
171
  if branch && !config.protected_branches.include?( branch )
131
172
  remote_branch = branch
132
- git_run( "push", config.git_remote, "--delete", remote_branch )
133
- puts_verbose "remote_branch_deleted: #{config.git_remote}/#{remote_branch}"
173
+ _, _, rd_success, = git_run( "push", config.git_remote, "--delete", remote_branch )
174
+ if rd_success
175
+ puts_verbose "remote_branch_deleted: #{config.git_remote}/#{remote_branch}"
176
+ remote_deleted = true
177
+ end
134
178
  end
135
179
 
136
- unless verbose?
137
- puts_line "Worktree removed: #{File.basename( resolved_path )}"
138
- end
139
- EXIT_OK
180
+ worktree_finish(
181
+ result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
182
+ branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
183
+ exit_code: EXIT_OK, json_output: json_output
184
+ )
140
185
  end
141
186
 
142
187
  private
143
188
 
189
+ # Unified output for worktree results — JSON or human-readable.
190
+ def worktree_finish( result:, exit_code:, json_output: )
191
+ result[ :exit_code ] = exit_code
192
+
193
+ if json_output
194
+ out.puts JSON.pretty_generate( result )
195
+ else
196
+ print_worktree_human( result: result )
197
+ end
198
+
199
+ exit_code
200
+ end
201
+
202
+ # Human-readable output for worktree results.
203
+ def print_worktree_human( result: )
204
+ command = result[ :command ]
205
+ status = result[ :status ]
206
+
207
+ case status
208
+ when "ok"
209
+ case command
210
+ when "worktree create"
211
+ puts_line "Worktree created: #{result[ :name ]}"
212
+ puts_line " Path: #{result[ :path ]}"
213
+ puts_line " Branch: #{result[ :branch ]}"
214
+ when "worktree done"
215
+ puts_line "Worktree done: #{result[ :name ]}"
216
+ puts_line " Branch: #{result[ :branch ]}"
217
+ puts_line " Cleanup later with `#{result[ :next_step ]}` or `carson housekeep`."
218
+ when "worktree remove"
219
+ unless verbose?
220
+ puts_line "Worktree removed: #{result[ :name ]}"
221
+ end
222
+ end
223
+ when "error"
224
+ puts_line "ERROR: #{result[ :error ]}"
225
+ puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
226
+ when "block"
227
+ puts_line "#{result[ :error ]&.capitalize || 'Blocked'}: #{result[ :name ]}"
228
+ puts_line " Recovery: #{result[ :recovery ]}" if result[ :recovery ]
229
+ end
230
+ end
231
+
144
232
  # Resolves a worktree path: if it's a bare name, look under .claude/worktrees/.
145
233
  def resolve_worktree_path( worktree_path: )
146
234
  return File.expand_path( worktree_path ) if worktree_path.include?( "/" )
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.6.0
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang