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 +4 -4
- data/RELEASE.md +36 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +12 -10
- data/lib/carson/runtime/local/prune.rb +87 -42
- data/lib/carson/runtime/local/worktree.rb +135 -47
- 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: 43f5ee17800cad35a51d2aaaf6ce306197bf2823cc6b0de4858912213d06e01b
|
|
4
|
+
data.tar.gz: 51b3a3e91d84a98266b2bccee868e583c25609c5466e34c938b2fa2bcd6564fc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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?( "/" )
|