carson 3.5.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: e8fc2b54698568aaec7d7f4ec39ecd32f56f44a395555a34099b24ec52a1d608
4
- data.tar.gz: e3b0a5a14c733d6b2e9f5c08475d89a53f0a3d1faf8a2fb85ed72bcb76f662ef
3
+ metadata.gz: 43f5ee17800cad35a51d2aaaf6ce306197bf2823cc6b0de4858912213d06e01b
4
+ data.tar.gz: 51b3a3e91d84a98266b2bccee868e583c25609c5466e34c938b2fa2bcd6564fc
5
5
  SHA512:
6
- metadata.gz: 9d3733f98a23dffdc69c1ebde71e94595c22e662f5ead86b97988fa3f5cf0e8daf56806deb82ceb1d034e0913640d9a8b7cafc26992f94e43dd96a3e304d6812
7
- data.tar.gz: eb44eca84a943a461668b478e2bed295b3a73ee7e916cc5d85a837c52f5be09326b369e07a99812d4ffdc883914511d18c7f0489e151f3d4d66afe8ddaf97402
6
+ metadata.gz: c7bca3554a185fc1951292d62e0bfcfd6b63d77488d22013f14b93885e6b8d043d2d30e20e8e9725796d26bc3aaa031bcce8f0b28474ab3198bf77733c56f0c5
7
+ data.tar.gz: ce3240db72d7fb1c6a5aa9f590628d23e3533778cc492def9c73951a8f5b6772a347cb8d64c79e0063fb79f67f16fb663c9a8ecdc33db7f9af15e0204678adfd
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.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
+
26
+ ## 3.6.0 — Prune JSON + Recovery
27
+
28
+ ### What changed
29
+
30
+ - **`carson prune --json`** — machine-readable JSON output for the prune command. The JSON envelope includes `command`, `status`, `branches` (array of per-branch details), `deleted`, `skipped`, and `exit_code`. Each branch entry has `branch`, `upstream`, `type` (stale/orphan/absorbed), `action` (deleted/skipped), and `reason`.
31
+ - **Recovery-aware prune errors** — outsider fingerprint blocks now include the specific recovery command in JSON output.
32
+ - **Structured branch entries** — internal prune methods now return structured hashes instead of bare symbols, enabling per-branch detail collection for JSON mode.
33
+
34
+ ### UX
35
+
36
+ - JSON output suppresses `git fetch` stdout to keep the JSON envelope clean.
37
+ - Human output remains unchanged when `--json` is not passed.
38
+ - All existing prune tests pass unchanged — human output behaviour is preserved.
39
+
40
+ ### Migration
41
+
42
+ - No breaking changes. `carson prune` without `--json` behaves identically to 3.5.0.
43
+
8
44
  ## 3.5.0 — Sync JSON + Recovery
9
45
 
10
46
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.5.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]|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
 
@@ -168,12 +168,14 @@ module Carson
168
168
 
169
169
  def self.parse_prune_command( argv:, parser:, err: )
170
170
  all_flag = argv.delete( "--all" ) ? true : false
171
+ json_flag = argv.delete( "--json" ) ? true : false
171
172
  parser.parse!( argv )
172
- return { command: "prune:all" } if all_flag
173
- { command: "prune" }
173
+ return { command: "prune:all", json: json_flag } if all_flag
174
+ { command: "prune", json: json_flag }
174
175
  end
175
176
 
176
177
  def self.parse_worktree_subcommand( argv:, parser:, err: )
178
+ json_flag = argv.delete( "--json" ) ? true : false
177
179
  action = argv.shift
178
180
  if action.to_s.strip.empty?
179
181
  err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|done|remove <name>"
@@ -188,10 +190,10 @@ module Carson
188
190
  err.puts "#{BADGE} Missing name for worktree create. Use: carson worktree create <name>"
189
191
  return { command: :invalid }
190
192
  end
191
- { command: "worktree:create", worktree_name: name }
193
+ { command: "worktree:create", worktree_name: name, json: json_flag }
192
194
  when "done"
193
195
  name = argv.shift
194
- { command: "worktree:done", worktree_name: name }
196
+ { command: "worktree:done", worktree_name: name, json: json_flag }
195
197
  when "remove"
196
198
  force = argv.delete( "--force" ) ? true : false
197
199
  worktree_path = argv.shift
@@ -199,7 +201,7 @@ module Carson
199
201
  err.puts "#{BADGE} Missing path for worktree remove. Use: carson worktree remove <name-or-path>"
200
202
  return { command: :invalid }
201
203
  end
202
- { command: "worktree:remove", worktree_path: worktree_path, force: force }
204
+ { command: "worktree:remove", worktree_path: worktree_path, force: force, json: json_flag }
203
205
  else
204
206
  err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|done|remove <name>"
205
207
  { command: :invalid }
@@ -347,15 +349,15 @@ module Carson
347
349
  when "sync"
348
350
  runtime.sync!( json_output: parsed.fetch( :json, false ) )
349
351
  when "prune"
350
- runtime.prune!
352
+ runtime.prune!( json_output: parsed.fetch( :json, false ) )
351
353
  when "prune:all"
352
354
  runtime.prune_all!
353
355
  when "worktree:create"
354
- runtime.worktree_create!( name: parsed.fetch( :worktree_name ) )
356
+ runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
355
357
  when "worktree:done"
356
- 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 ) )
357
359
  when "worktree:remove"
358
- 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 ) )
359
361
  when "onboard"
360
362
  runtime.onboard!
361
363
  when "refresh"
@@ -1,31 +1,74 @@
1
+ # Removes stale local branches (gone upstream), orphan branches (no tracking) with merged PR evidence,
2
+ # and absorbed branches (content already on main, no open PR).
3
+ # Supports --json for machine-readable structured output with per-branch action details.
1
4
  module Carson
2
5
  class Runtime
3
6
  module Local
4
- # Removes stale local branches (gone upstream), orphan branches (no tracking) with merged PR evidence,
5
- # and absorbed branches (content already on main, no open PR).
6
- def prune!
7
+ def prune!( json_output: false )
7
8
  fingerprint_status = block_if_outsider_fingerprints!
8
- return fingerprint_status unless fingerprint_status.nil?
9
+ unless fingerprint_status.nil?
10
+ if json_output
11
+ out.puts JSON.pretty_generate( {
12
+ command: "prune", status: "block",
13
+ error: "Carson-owned artefacts detected in host repository",
14
+ recovery: "remove Carson-owned files (.carson.yml, bin/carson, .tools/carson) then retry",
15
+ exit_code: EXIT_BLOCK
16
+ } )
17
+ end
18
+ return fingerprint_status
19
+ end
9
20
 
10
- git_system!( "fetch", config.git_remote, "--prune" )
21
+ prune_git!( "fetch", config.git_remote, "--prune", json_output: json_output )
11
22
  active_branch = current_branch
12
23
  counters = { deleted: 0, skipped: 0 }
24
+ branches = []
13
25
 
14
26
  stale_branches = stale_local_branches
15
- prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters )
27
+ prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters, branches: branches )
16
28
 
17
29
  orphan_branches = orphan_local_branches( active_branch: active_branch )
18
- prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters )
30
+ prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters, branches: branches )
19
31
 
20
32
  absorbed_branches = absorbed_local_branches( active_branch: active_branch )
21
- prune_absorbed_branch_entries( absorbed_branches: absorbed_branches, counters: counters )
33
+ prune_absorbed_branch_entries( absorbed_branches: absorbed_branches, counters: counters, branches: branches )
34
+
35
+ prune_finish(
36
+ result: { command: "prune", status: "ok", branches: branches, deleted: counters.fetch( :deleted ), skipped: counters.fetch( :skipped ) },
37
+ exit_code: EXIT_OK, json_output: json_output, counters: counters
38
+ )
39
+ end
22
40
 
23
- return prune_no_stale_branches if counters.fetch( :deleted ).zero? && counters.fetch( :skipped ).zero?
41
+ private
42
+
43
+ # Unified output for prune results — JSON or human-readable.
44
+ def prune_finish( result:, exit_code:, json_output:, counters: )
45
+ result[ :exit_code ] = exit_code
46
+
47
+ if json_output
48
+ out.puts JSON.pretty_generate( result )
49
+ else
50
+ print_prune_human( counters: counters )
51
+ end
24
52
 
25
- puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
53
+ exit_code
54
+ end
55
+
56
+ # Human-readable output for prune results.
57
+ def print_prune_human( counters: )
58
+ deleted_count = counters.fetch( :deleted )
59
+ skipped_count = counters.fetch( :skipped )
60
+
61
+ if deleted_count.zero? && skipped_count.zero?
62
+ if verbose?
63
+ puts_line "OK: no stale or orphan branches to prune."
64
+ else
65
+ puts_line "No stale branches."
66
+ end
67
+ return
68
+ end
69
+
70
+ puts_verbose "prune_summary: deleted=#{deleted_count} skipped=#{skipped_count}"
26
71
  unless verbose?
27
- deleted_count = counters.fetch( :deleted )
28
- skipped_count = counters.fetch( :skipped )
29
72
  message = if deleted_count > 0 && skipped_count > 0
30
73
  "Pruned #{deleted_count}, skipped #{skipped_count} (--verbose for details)."
31
74
  elsif deleted_count > 0
@@ -35,24 +78,23 @@ module Carson
35
78
  end
36
79
  puts_line message
37
80
  end
38
- EXIT_OK
39
81
  end
40
82
 
41
- private
42
-
43
- def prune_no_stale_branches
44
- if verbose?
45
- puts_line "OK: no stale or orphan branches to prune."
83
+ # Runs a git command, suppressing stdout in JSON mode to keep output clean.
84
+ def prune_git!( *args, json_output: false )
85
+ if json_output
86
+ _, stderr_text, success, = git_run( *args )
87
+ raise "git #{args.join( ' ' )} failed: #{stderr_text.to_s.strip}" unless success
46
88
  else
47
- puts_line "No stale branches."
89
+ git_system!( *args )
48
90
  end
49
- EXIT_OK
50
91
  end
51
92
 
52
- def prune_stale_branch_entries( stale_branches:, active_branch:, counters: { deleted: 0, skipped: 0 } )
93
+ def prune_stale_branch_entries( stale_branches:, active_branch:, counters: { deleted: 0, skipped: 0 }, branches: [] )
53
94
  stale_branches.each do |entry|
54
- outcome = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
55
- counters[ outcome ] += 1
95
+ result = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
96
+ counters[ result.fetch( :action ) ] += 1
97
+ branches << result
56
98
  end
57
99
  counters
58
100
  end
@@ -67,9 +109,10 @@ module Carson
67
109
  end
68
110
 
69
111
  def prune_skip_stale_branch( type:, branch:, upstream: )
112
+ reason = type == :protected ? "protected branch" : "current branch"
70
113
  status = type == :protected ? "skip_protected_branch" : "skip_current_branch"
71
114
  puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
72
- :skipped
115
+ { action: :skipped, branch: branch, upstream: upstream, type: "stale", reason: reason }
73
116
  end
74
117
 
75
118
  def prune_delete_stale_branch( branch:, upstream: )
@@ -87,7 +130,7 @@ module Carson
87
130
  def prune_safe_delete_success( branch:, upstream:, stdout_text: )
88
131
  out.print stdout_text if verbose? && !stdout_text.empty?
89
132
  puts_verbose "deleted_local_branch: #{branch} (upstream=#{upstream})"
90
- :deleted
133
+ { action: :deleted, branch: branch, upstream: upstream, type: "stale", reason: "upstream gone" }
91
134
  end
92
135
 
93
136
  def prune_force_delete_stale_branch( branch:, upstream:, delete_error_text: )
@@ -106,19 +149,19 @@ module Carson
106
149
  def prune_force_delete_success( branch:, upstream:, merged_pr:, force_stdout: )
107
150
  out.print force_stdout if verbose? && !force_stdout.empty?
108
151
  puts_verbose "deleted_local_branch_force: #{branch} (upstream=#{upstream}) merged_pr=#{merged_pr.fetch( :url )}"
109
- :deleted
152
+ { action: :deleted, branch: branch, upstream: upstream, type: "stale", reason: "force deleted with PR evidence" }
110
153
  end
111
154
 
112
155
  def prune_force_delete_failed( branch:, upstream:, force_stderr: )
113
156
  force_error_text = normalise_branch_delete_error( error_text: force_stderr )
114
157
  puts_verbose "fail_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error_text}"
115
- :skipped
158
+ { action: :skipped, branch: branch, upstream: upstream, type: "stale", reason: force_error_text }
116
159
  end
117
160
 
118
161
  def prune_force_delete_skipped( branch:, upstream:, delete_error_text:, force_error: )
119
162
  puts_verbose "skip_delete_branch: #{branch} (upstream=#{upstream}) reason=#{delete_error_text}"
120
163
  puts_verbose "skip_force_delete_branch: #{branch} (upstream=#{upstream}) reason=#{force_error}" unless force_error.to_s.strip.empty?
121
- :skipped
164
+ { action: :skipped, branch: branch, upstream: upstream, type: "stale", reason: delete_error_text }
122
165
  end
123
166
 
124
167
  def normalise_branch_delete_error( error_text: )
@@ -229,13 +272,14 @@ module Carson
229
272
  end
230
273
 
231
274
  # Processes absorbed branches: verifies no open PR exists before deleting local and remote.
232
- def prune_absorbed_branch_entries( absorbed_branches:, counters: )
275
+ def prune_absorbed_branch_entries( absorbed_branches:, counters:, branches: [] )
233
276
  return counters if absorbed_branches.empty?
234
277
  return counters unless gh_available?
235
278
 
236
279
  absorbed_branches.each do |entry|
237
- outcome = prune_absorbed_branch_entry( branch: entry.fetch( :branch ), upstream: entry.fetch( :upstream ) )
238
- counters[ outcome ] += 1
280
+ result = prune_absorbed_branch_entry( branch: entry.fetch( :branch ), upstream: entry.fetch( :upstream ) )
281
+ counters[ result.fetch( :action ) ] += 1
282
+ branches << result
239
283
  end
240
284
  counters
241
285
  end
@@ -244,14 +288,14 @@ module Carson
244
288
  def prune_absorbed_branch_entry( branch:, upstream: )
245
289
  if branch_has_open_pr?( branch: branch )
246
290
  puts_verbose "skip_absorbed_branch: #{branch} reason=open PR exists"
247
- return :skipped
291
+ return { action: :skipped, branch: branch, upstream: upstream, type: "absorbed", reason: "open PR exists" }
248
292
  end
249
293
 
250
294
  force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
251
295
  unless force_success
252
296
  error_text = normalise_branch_delete_error( error_text: force_stderr )
253
297
  puts_verbose "fail_delete_absorbed_branch: #{branch} reason=#{error_text}"
254
- return :skipped
298
+ return { action: :skipped, branch: branch, upstream: upstream, type: "absorbed", reason: error_text }
255
299
  end
256
300
 
257
301
  out.print force_stdout if verbose? && !force_stdout.empty?
@@ -260,7 +304,7 @@ module Carson
260
304
  git_run( "push", config.git_remote, "--delete", remote_branch )
261
305
 
262
306
  puts_verbose "deleted_absorbed_branch: #{branch} (upstream=#{upstream})"
263
- :deleted
307
+ { action: :deleted, branch: branch, upstream: upstream, type: "absorbed", reason: "content already on main" }
264
308
  end
265
309
 
266
310
  # Returns true if the branch has at least one open PR.
@@ -282,13 +326,14 @@ module Carson
282
326
  end
283
327
 
284
328
  # Processes orphan branches: verifies merged PR evidence via GitHub API before deleting.
285
- def prune_orphan_branch_entries( orphan_branches:, counters: )
329
+ def prune_orphan_branch_entries( orphan_branches:, counters:, branches: [] )
286
330
  return counters if orphan_branches.empty?
287
331
  return counters unless gh_available?
288
332
 
289
333
  orphan_branches.each do |branch|
290
- outcome = prune_orphan_branch_entry( branch: branch )
291
- counters[ outcome ] += 1
334
+ result = prune_orphan_branch_entry( branch: branch )
335
+ counters[ result.fetch( :action ) ] += 1
336
+ branches << result
292
337
  end
293
338
  counters
294
339
  end
@@ -300,12 +345,12 @@ module Carson
300
345
  error_text = tip_sha_error.to_s.strip
301
346
  error_text = "unable to read local branch tip sha" if error_text.empty?
302
347
  puts_verbose "skip_orphan_branch: #{branch} reason=#{error_text}"
303
- return :skipped
348
+ return { action: :skipped, branch: branch, upstream: "", type: "orphan", reason: error_text }
304
349
  end
305
350
  branch_tip_sha = tip_sha_text.to_s.strip
306
351
  if branch_tip_sha.empty?
307
352
  puts_verbose "skip_orphan_branch: #{branch} reason=unable to read local branch tip sha"
308
- return :skipped
353
+ return { action: :skipped, branch: branch, upstream: "", type: "orphan", reason: "unable to read local branch tip sha" }
309
354
  end
310
355
 
311
356
  merged_pr, error = merged_pr_for_branch( branch: branch, branch_tip_sha: branch_tip_sha )
@@ -313,19 +358,19 @@ module Carson
313
358
  reason = error.to_s.strip
314
359
  reason = "no merged PR evidence for branch tip into #{config.main_branch}" if reason.empty?
315
360
  puts_verbose "skip_orphan_branch: #{branch} reason=#{reason}"
316
- return :skipped
361
+ return { action: :skipped, branch: branch, upstream: "", type: "orphan", reason: reason }
317
362
  end
318
363
 
319
364
  force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
320
365
  if force_success
321
366
  out.print force_stdout if verbose? && !force_stdout.empty?
322
367
  puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
323
- return :deleted
368
+ return { action: :deleted, branch: branch, upstream: "", type: "orphan", reason: "merged PR evidence found" }
324
369
  end
325
370
 
326
371
  force_error_text = normalise_branch_delete_error( error_text: force_stderr )
327
372
  puts_verbose "fail_delete_orphan_branch: #{branch} reason=#{force_error_text}"
328
- :skipped
373
+ { action: :skipped, branch: branch, upstream: "", type: "orphan", reason: force_error_text }
329
374
  end
330
375
 
331
376
  # Safe delete can fail after squash merges because branch tip is no longer an ancestor.
@@ -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.5.0
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang