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 +4 -4
- data/RELEASE.md +18 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +5 -4
- data/lib/carson/runtime/local/prune.rb +87 -42
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d430ed51ffe8ee87c8aa2a652c63d92ef7e9f0a65713b9104dd4df57dbcba90a
|
|
4
|
+
data.tar.gz: d5bf899e7acc479cb5f673c0ba0323dce10e83a6c8d843f418d9db53adbbed38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
counters[
|
|
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
|
-
|
|
238
|
-
counters[
|
|
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
|
-
|
|
291
|
-
counters[
|
|
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.
|