carson 3.6.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/RELEASE.md +18 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +8 -7
- 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,24 @@ Release-note scope rule:
|
|
|
5
5
|
- `RELEASE.md` records only version deltas, breaking changes, and migration actions.
|
|
6
6
|
- Operational usage guides live in `MANUAL.md` and `API.md`.
|
|
7
7
|
|
|
8
|
+
## 3.7.0 — Worktree JSON + Recovery
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **`carson worktree --json create|done|remove`** — machine-readable JSON output for all three worktree commands. Each returns a structured envelope with `command`, `status`, `name`, `exit_code`, and context-specific fields (`path`, `branch`, `next_step`).
|
|
13
|
+
- **Recovery-aware worktree errors** — every error and block path includes a `recovery` field with the exact command to run next. Dirty worktree, missing name, unregistered worktree, unpushed commits — all include actionable recovery.
|
|
14
|
+
- **Unified `worktree_finish` / `print_worktree_human`** — consistent output pattern matching deliver, sync, and prune.
|
|
15
|
+
|
|
16
|
+
### UX
|
|
17
|
+
|
|
18
|
+
- Human output preserved unchanged when `--json` is not passed.
|
|
19
|
+
- `worktree done` includes `next_step` in JSON output pointing to `carson worktree remove`.
|
|
20
|
+
- `worktree remove` includes `branch_deleted` and `remote_deleted` booleans for complete lifecycle visibility.
|
|
21
|
+
|
|
22
|
+
### Migration
|
|
23
|
+
|
|
24
|
+
- No breaking changes. All worktree commands without `--json` behave identically to 3.6.0.
|
|
25
|
+
|
|
8
26
|
## 3.6.0 — Prune JSON + Recovery
|
|
9
27
|
|
|
10
28
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.7.0
|
data/lib/carson/cli.rb
CHANGED
|
@@ -53,7 +53,7 @@ module Carson
|
|
|
53
53
|
|
|
54
54
|
def self.build_parser
|
|
55
55
|
OptionParser.new do |opts|
|
|
56
|
-
opts.banner = "Usage: carson [status [--json]|setup|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree create|done|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
|
|
56
|
+
opts.banner = "Usage: carson [status [--json]|setup|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] create|done|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -175,6 +175,7 @@ module Carson
|
|
|
175
175
|
end
|
|
176
176
|
|
|
177
177
|
def self.parse_worktree_subcommand( argv:, parser:, err: )
|
|
178
|
+
json_flag = argv.delete( "--json" ) ? true : false
|
|
178
179
|
action = argv.shift
|
|
179
180
|
if action.to_s.strip.empty?
|
|
180
181
|
err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|done|remove <name>"
|
|
@@ -189,10 +190,10 @@ module Carson
|
|
|
189
190
|
err.puts "#{BADGE} Missing name for worktree create. Use: carson worktree create <name>"
|
|
190
191
|
return { command: :invalid }
|
|
191
192
|
end
|
|
192
|
-
{ command: "worktree:create", worktree_name: name }
|
|
193
|
+
{ command: "worktree:create", worktree_name: name, json: json_flag }
|
|
193
194
|
when "done"
|
|
194
195
|
name = argv.shift
|
|
195
|
-
{ command: "worktree:done", worktree_name: name }
|
|
196
|
+
{ command: "worktree:done", worktree_name: name, json: json_flag }
|
|
196
197
|
when "remove"
|
|
197
198
|
force = argv.delete( "--force" ) ? true : false
|
|
198
199
|
worktree_path = argv.shift
|
|
@@ -200,7 +201,7 @@ module Carson
|
|
|
200
201
|
err.puts "#{BADGE} Missing path for worktree remove. Use: carson worktree remove <name-or-path>"
|
|
201
202
|
return { command: :invalid }
|
|
202
203
|
end
|
|
203
|
-
{ command: "worktree:remove", worktree_path: worktree_path, force: force }
|
|
204
|
+
{ command: "worktree:remove", worktree_path: worktree_path, force: force, json: json_flag }
|
|
204
205
|
else
|
|
205
206
|
err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|done|remove <name>"
|
|
206
207
|
{ command: :invalid }
|
|
@@ -352,11 +353,11 @@ module Carson
|
|
|
352
353
|
when "prune:all"
|
|
353
354
|
runtime.prune_all!
|
|
354
355
|
when "worktree:create"
|
|
355
|
-
runtime.worktree_create!( name: parsed.fetch( :worktree_name ) )
|
|
356
|
+
runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
|
|
356
357
|
when "worktree:done"
|
|
357
|
-
runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ) )
|
|
358
|
+
runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ), json_output: parsed.fetch( :json, false ) )
|
|
358
359
|
when "worktree:remove"
|
|
359
|
-
runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ) )
|
|
360
|
+
runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) )
|
|
360
361
|
when "onboard"
|
|
361
362
|
runtime.onboard!
|
|
362
363
|
when "refresh"
|
|
@@ -1,19 +1,23 @@
|
|
|
1
|
+
# Safe worktree lifecycle management for coding agents.
|
|
2
|
+
# Three operations: create, done (mark completed), remove (batch cleanup).
|
|
3
|
+
# The deferred deletion model: worktrees persist after use, cleaned up later.
|
|
4
|
+
# Supports --json for machine-readable structured output with recovery commands.
|
|
1
5
|
module Carson
|
|
2
6
|
class Runtime
|
|
3
7
|
module Local
|
|
4
|
-
# Safe worktree lifecycle management for coding agents.
|
|
5
|
-
# Three operations: create, done (mark completed), remove (batch cleanup).
|
|
6
|
-
# The deferred deletion model: worktrees persist after use, cleaned up later.
|
|
7
8
|
|
|
8
9
|
# Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
|
|
9
|
-
def worktree_create!( name: )
|
|
10
|
+
def worktree_create!( name:, json_output: false )
|
|
10
11
|
worktrees_dir = File.join( repo_root, ".claude", "worktrees" )
|
|
11
12
|
wt_path = File.join( worktrees_dir, name )
|
|
12
13
|
|
|
13
14
|
if Dir.exist?( wt_path )
|
|
14
|
-
|
|
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?( "/" )
|