carson 3.5.0 → 3.6.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: d430ed51ffe8ee87c8aa2a652c63d92ef7e9f0a65713b9104dd4df57dbcba90a
4
+ data.tar.gz: d5bf899e7acc479cb5f673c0ba0323dce10e83a6c8d843f418d9db53adbbed38
5
5
  SHA512:
6
- metadata.gz: 9d3733f98a23dffdc69c1ebde71e94595c22e662f5ead86b97988fa3f5cf0e8daf56806deb82ceb1d034e0913640d9a8b7cafc26992f94e43dd96a3e304d6812
7
- data.tar.gz: eb44eca84a943a461668b478e2bed295b3a73ee7e916cc5d85a837c52f5be09326b369e07a99812d4ffdc883914511d18c7f0489e151f3d4d66afe8ddaf97402
6
+ metadata.gz: 903da2928ca57d07422e7450670efb7686603934fc2b7aa8122789fcea6ecce2e1b841420c15c2df39dc33b2789c4d3a85a0cb56dc5816aa1b0216fa103f7cb5
7
+ data.tar.gz: 2c67d3aa87576d0124d3ea2fb31fdaf8457cbfb81199c539e103072636a1fcaef4e1274c3642d1a843117c1cd88a69e437cf369c5f981a06fed50d6e57e0d2f3
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.6.0 — Prune JSON + Recovery
9
+
10
+ ### What changed
11
+
12
+ - **`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`.
13
+ - **Recovery-aware prune errors** — outsider fingerprint blocks now include the specific recovery command in JSON output.
14
+ - **Structured branch entries** — internal prune methods now return structured hashes instead of bare symbols, enabling per-branch detail collection for JSON mode.
15
+
16
+ ### UX
17
+
18
+ - JSON output suppresses `git fetch` stdout to keep the JSON envelope clean.
19
+ - Human output remains unchanged when `--json` is not passed.
20
+ - All existing prune tests pass unchanged — human output behaviour is preserved.
21
+
22
+ ### Migration
23
+
24
+ - No breaking changes. `carson prune` without `--json` behaves identically to 3.5.0.
25
+
8
26
  ## 3.5.0 — Sync JSON + Recovery
9
27
 
10
28
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.5.0
1
+ 3.6.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 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,9 +168,10 @@ 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: )
@@ -347,7 +348,7 @@ module Carson
347
348
  when "sync"
348
349
  runtime.sync!( json_output: parsed.fetch( :json, false ) )
349
350
  when "prune"
350
- runtime.prune!
351
+ runtime.prune!( json_output: parsed.fetch( :json, false ) )
351
352
  when "prune:all"
352
353
  runtime.prune_all!
353
354
  when "worktree:create"
@@ -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.
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.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang