carson 3.10.4 → 3.11.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 +22 -1
- data/VERSION +1 -1
- data/lib/carson/cli.rb +3 -8
- data/lib/carson/runtime/deliver.rb +1 -1
- data/lib/carson/runtime/local/prune.rb +14 -10
- data/lib/carson/runtime/local/worktree.rb +27 -62
- 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: 0d6a7526cb4973b9f911b8fd28d151ca719cbb53be7d42beb29e676d0d9354f4
|
|
4
|
+
data.tar.gz: 4b116460df0e2e9397edf0f60432abb907d7b81f1e28a4d7e8bceda47e030db7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 807185f0bbeb5c2762b89c8a59aed681006a1589a66024f2cf546972416bde79ad858be69cceb685a6b762a8594d95efa41607323f1d5469f3d70709b64a57f1
|
|
7
|
+
data.tar.gz: 7d7c6a06a7bf4b75428a57afa891f04dadf1ca402ac35cfee2d968658963e180b8b2ed27a1aa2a8c78c057456d319977ddfce6d022f945281081c9d7b936a7ff
|
data/RELEASE.md
CHANGED
|
@@ -5,12 +5,33 @@ 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.11.0
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **Drop `worktree done`** — removed the `worktree done` subcommand entirely. `worktree remove` now handles everything: CWD safety guard, unpushed-commits guard, session state cleanup, branch and remote deletion. Two operations (create, remove) instead of three. Simpler, safer, less to remember.
|
|
13
|
+
|
|
14
|
+
### Breaking changes
|
|
15
|
+
|
|
16
|
+
- `carson worktree done <name>` no longer exists. Use `carson worktree remove <name>` instead. Add `--force` to override safety guards.
|
|
17
|
+
|
|
18
|
+
## 3.10.5
|
|
19
|
+
|
|
20
|
+
### What changed
|
|
21
|
+
|
|
22
|
+
- **CWD guard for prune** — `carson prune` now proactively detects when the process CWD is inside a worktree and skips that worktree's branch in all prune paths (stale, orphan, absorbed). Previously, prune relied on git's own refusal to delete a branch checked out in a worktree — accidental protection, not principled safety. The new guard matches the same CWD-awareness that `worktree remove` already has.
|
|
23
|
+
- **`cwd_worktree_branch` helper** — new method that finds the branch checked out in the worktree containing the process CWD. Uses longest-path matching because worktree directories live inside the main repo tree (`.claude/worktrees/`).
|
|
24
|
+
|
|
25
|
+
### Migration
|
|
26
|
+
|
|
27
|
+
- No breaking changes. New safety guard — previously git-protected operations now fail earlier with clearer diagnostics.
|
|
28
|
+
|
|
8
29
|
## 3.10.4
|
|
9
30
|
|
|
10
31
|
### What changed
|
|
11
32
|
|
|
12
33
|
- **Worktree remove guards unpushed commits** — `carson worktree remove` now checks for unpushed commits before deleting a worktree. Blocks with recovery guidance (push command or `--force` to override). Prevents accidental destruction of work that exists only locally.
|
|
13
|
-
- **Shared unpushed-commits check** — extracted `check_unpushed_commits` method used by
|
|
34
|
+
- **Shared unpushed-commits check** — extracted `check_unpushed_commits` method used by `worktree remove`, eliminating code duplication.
|
|
14
35
|
- **Fix resolve path from inside worktrees** — `resolve_worktree_path` now uses `main_worktree_root` instead of `repo_root` for bare-name resolution. Previously, calling `carson worktree remove <name>` from inside a worktree would look in the wrong directory.
|
|
15
36
|
|
|
16
37
|
### Migration
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.11.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 [--json] create|
|
|
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|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|session [--json] [--task T]|session clear [--json]|version]"
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -180,7 +180,7 @@ module Carson
|
|
|
180
180
|
json_flag = argv.delete( "--json" ) ? true : false
|
|
181
181
|
action = argv.shift
|
|
182
182
|
if action.to_s.strip.empty?
|
|
183
|
-
err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|
|
|
183
|
+
err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|remove <name>"
|
|
184
184
|
err.puts parser
|
|
185
185
|
return { command: :invalid }
|
|
186
186
|
end
|
|
@@ -193,9 +193,6 @@ module Carson
|
|
|
193
193
|
return { command: :invalid }
|
|
194
194
|
end
|
|
195
195
|
{ command: "worktree:create", worktree_name: name, json: json_flag }
|
|
196
|
-
when "done"
|
|
197
|
-
name = argv.shift
|
|
198
|
-
{ command: "worktree:done", worktree_name: name, json: json_flag }
|
|
199
196
|
when "remove"
|
|
200
197
|
force = argv.delete( "--force" ) ? true : false
|
|
201
198
|
worktree_path = argv.shift
|
|
@@ -205,7 +202,7 @@ module Carson
|
|
|
205
202
|
end
|
|
206
203
|
{ command: "worktree:remove", worktree_path: worktree_path, force: force, json: json_flag }
|
|
207
204
|
else
|
|
208
|
-
err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|
|
|
205
|
+
err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|remove <name>"
|
|
209
206
|
{ command: :invalid }
|
|
210
207
|
end
|
|
211
208
|
end
|
|
@@ -379,8 +376,6 @@ module Carson
|
|
|
379
376
|
runtime.prune_all!
|
|
380
377
|
when "worktree:create"
|
|
381
378
|
runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
|
|
382
|
-
when "worktree:done"
|
|
383
|
-
runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ), json_output: parsed.fetch( :json, false ) )
|
|
384
379
|
when "worktree:remove"
|
|
385
380
|
runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) )
|
|
386
381
|
when "onboard"
|
|
@@ -20,16 +20,17 @@ module Carson
|
|
|
20
20
|
|
|
21
21
|
prune_git!( "fetch", config.git_remote, "--prune", json_output: json_output )
|
|
22
22
|
active_branch = current_branch
|
|
23
|
+
cwd_branch = cwd_worktree_branch
|
|
23
24
|
counters = { deleted: 0, skipped: 0 }
|
|
24
25
|
branches = []
|
|
25
26
|
|
|
26
27
|
stale_branches = stale_local_branches
|
|
27
|
-
prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, counters: counters, branches: branches )
|
|
28
|
+
prune_stale_branch_entries( stale_branches: stale_branches, active_branch: active_branch, cwd_branch: cwd_branch, counters: counters, branches: branches )
|
|
28
29
|
|
|
29
|
-
orphan_branches = orphan_local_branches( active_branch: active_branch )
|
|
30
|
+
orphan_branches = orphan_local_branches( active_branch: active_branch, cwd_branch: cwd_branch )
|
|
30
31
|
prune_orphan_branch_entries( orphan_branches: orphan_branches, counters: counters, branches: branches )
|
|
31
32
|
|
|
32
|
-
absorbed_branches = absorbed_local_branches( active_branch: active_branch )
|
|
33
|
+
absorbed_branches = absorbed_local_branches( active_branch: active_branch, cwd_branch: cwd_branch )
|
|
33
34
|
prune_absorbed_branch_entries( absorbed_branches: absorbed_branches, counters: counters, branches: branches )
|
|
34
35
|
|
|
35
36
|
prune_finish(
|
|
@@ -90,27 +91,28 @@ module Carson
|
|
|
90
91
|
end
|
|
91
92
|
end
|
|
92
93
|
|
|
93
|
-
def prune_stale_branch_entries( stale_branches:, active_branch:, counters: { deleted: 0, skipped: 0 }, branches: [] )
|
|
94
|
+
def prune_stale_branch_entries( stale_branches:, active_branch:, cwd_branch: nil, counters: { deleted: 0, skipped: 0 }, branches: [] )
|
|
94
95
|
stale_branches.each do |entry|
|
|
95
|
-
result = prune_stale_branch_entry( entry: entry, active_branch: active_branch )
|
|
96
|
+
result = prune_stale_branch_entry( entry: entry, active_branch: active_branch, cwd_branch: cwd_branch )
|
|
96
97
|
counters[ result.fetch( :action ) ] += 1
|
|
97
98
|
branches << result
|
|
98
99
|
end
|
|
99
100
|
counters
|
|
100
101
|
end
|
|
101
102
|
|
|
102
|
-
def prune_stale_branch_entry( entry:, active_branch: )
|
|
103
|
+
def prune_stale_branch_entry( entry:, active_branch:, cwd_branch: nil )
|
|
103
104
|
branch = entry.fetch( :branch )
|
|
104
105
|
upstream = entry.fetch( :upstream )
|
|
105
106
|
return prune_skip_stale_branch( type: :protected, branch: branch, upstream: upstream ) if config.protected_branches.include?( branch )
|
|
106
107
|
return prune_skip_stale_branch( type: :current, branch: branch, upstream: upstream ) if branch == active_branch
|
|
108
|
+
return prune_skip_stale_branch( type: :cwd_worktree, branch: branch, upstream: upstream ) if cwd_branch && branch == cwd_branch
|
|
107
109
|
|
|
108
110
|
prune_delete_stale_branch( branch: branch, upstream: upstream )
|
|
109
111
|
end
|
|
110
112
|
|
|
111
113
|
def prune_skip_stale_branch( type:, branch:, upstream: )
|
|
112
|
-
reason =
|
|
113
|
-
status =
|
|
114
|
+
reason = { protected: "protected branch", current: "current branch", cwd_worktree: "checked out in CWD worktree" }.fetch( type, type.to_s )
|
|
115
|
+
status = { protected: "skip_protected_branch", current: "skip_current_branch", cwd_worktree: "skip_cwd_worktree_branch" }.fetch( type, "skip_#{type}" )
|
|
114
116
|
puts_verbose "#{status}: #{branch} (upstream=#{upstream})"
|
|
115
117
|
{ action: :skipped, branch: branch, upstream: upstream, type: "stale", reason: reason }
|
|
116
118
|
end
|
|
@@ -208,7 +210,7 @@ module Carson
|
|
|
208
210
|
end
|
|
209
211
|
|
|
210
212
|
# Detects local branches with no upstream tracking ref — candidates for orphan pruning.
|
|
211
|
-
def orphan_local_branches( active_branch: )
|
|
213
|
+
def orphan_local_branches( active_branch:, cwd_branch: nil )
|
|
212
214
|
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads" ).lines.filter_map do |line|
|
|
213
215
|
branch, upstream = line.strip.split( "\t", 2 )
|
|
214
216
|
branch = branch.to_s.strip
|
|
@@ -217,6 +219,7 @@ module Carson
|
|
|
217
219
|
next unless upstream.empty?
|
|
218
220
|
next if config.protected_branches.include?( branch )
|
|
219
221
|
next if branch == active_branch
|
|
222
|
+
next if cwd_branch && branch == cwd_branch
|
|
220
223
|
next if branch == TEMPLATE_SYNC_BRANCH
|
|
221
224
|
|
|
222
225
|
branch
|
|
@@ -226,7 +229,7 @@ module Carson
|
|
|
226
229
|
# Detects local branches whose upstream still exists but whose content is already on main.
|
|
227
230
|
# Two-step evidence: (1) find the merge-base, (2) verify every file the branch changed
|
|
228
231
|
# relative to the merge-base has identical content on main.
|
|
229
|
-
def absorbed_local_branches( active_branch: )
|
|
232
|
+
def absorbed_local_branches( active_branch:, cwd_branch: nil )
|
|
230
233
|
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.filter_map do |line|
|
|
231
234
|
branch, upstream, track = line.strip.split( "\t", 3 )
|
|
232
235
|
branch = branch.to_s.strip
|
|
@@ -237,6 +240,7 @@ module Carson
|
|
|
237
240
|
next if track.include?( "gone" )
|
|
238
241
|
next if config.protected_branches.include?( branch )
|
|
239
242
|
next if branch == active_branch
|
|
243
|
+
next if cwd_branch && branch == cwd_branch
|
|
240
244
|
next if branch == TEMPLATE_SYNC_BRANCH
|
|
241
245
|
|
|
242
246
|
next unless branch_absorbed_into_main?( branch: branch )
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Safe worktree lifecycle management for coding agents.
|
|
2
|
-
#
|
|
3
|
-
#
|
|
2
|
+
# Two operations: create and remove. Remove is safe by default — guards against
|
|
3
|
+
# CWD-inside-worktree and unpushed commits. Use --force to override.
|
|
4
4
|
# Supports --json for machine-readable structured output with recovery commands.
|
|
5
5
|
module Carson
|
|
6
6
|
class Runtime
|
|
@@ -50,62 +50,6 @@ module Carson
|
|
|
50
50
|
)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
# Marks a worktree as completed without deleting it.
|
|
54
|
-
# Verifies all changes are committed. Deferred deletion — cleanup happens later.
|
|
55
|
-
def worktree_done!( name: nil, json_output: false )
|
|
56
|
-
if name.to_s.strip.empty?
|
|
57
|
-
return worktree_finish(
|
|
58
|
-
result: { command: "worktree done", status: "error",
|
|
59
|
-
error: "missing worktree name",
|
|
60
|
-
recovery: "carson worktree done <name>" },
|
|
61
|
-
exit_code: EXIT_ERROR, json_output: json_output
|
|
62
|
-
)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
resolved_path = resolve_worktree_path( worktree_path: name )
|
|
66
|
-
|
|
67
|
-
unless worktree_registered?( path: resolved_path )
|
|
68
|
-
return worktree_finish(
|
|
69
|
-
result: { command: "worktree done", status: "error", name: name,
|
|
70
|
-
error: "#{name} is not a registered worktree",
|
|
71
|
-
recovery: "git worktree list" },
|
|
72
|
-
exit_code: EXIT_ERROR, json_output: json_output
|
|
73
|
-
)
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
# Check for uncommitted changes in the worktree.
|
|
77
|
-
wt_status, _, status_result, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path )
|
|
78
|
-
if status_result.success? && !wt_status.strip.empty?
|
|
79
|
-
return worktree_finish(
|
|
80
|
-
result: { command: "worktree done", status: "block", name: name,
|
|
81
|
-
error: "worktree has uncommitted changes",
|
|
82
|
-
recovery: "git -C #{resolved_path} add -A && git -C #{resolved_path} commit, then carson worktree done #{name}" },
|
|
83
|
-
exit_code: EXIT_BLOCK, json_output: json_output
|
|
84
|
-
)
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Check for unpushed commits using shared guard.
|
|
88
|
-
branch = worktree_branch( path: resolved_path )
|
|
89
|
-
unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path )
|
|
90
|
-
if unpushed
|
|
91
|
-
return worktree_finish(
|
|
92
|
-
result: { command: "worktree done", status: "block", name: name, branch: branch,
|
|
93
|
-
error: unpushed[ :error ],
|
|
94
|
-
recovery: unpushed[ :recovery ] },
|
|
95
|
-
exit_code: EXIT_BLOCK, json_output: json_output
|
|
96
|
-
)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# Clear worktree from session state.
|
|
100
|
-
update_session( worktree: :clear )
|
|
101
|
-
|
|
102
|
-
worktree_finish(
|
|
103
|
-
result: { command: "worktree done", status: "ok", name: name, branch: branch || "(detached)",
|
|
104
|
-
next_step: "carson worktree remove #{name}" },
|
|
105
|
-
exit_code: EXIT_OK, json_output: json_output
|
|
106
|
-
)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
53
|
# Removes a worktree: directory, git registration, and branch.
|
|
110
54
|
# Never forces removal — if the worktree has uncommitted changes, refuses unless
|
|
111
55
|
# the user explicitly passes force: true via CLI --force flag.
|
|
@@ -211,6 +155,9 @@ module Carson
|
|
|
211
155
|
end
|
|
212
156
|
end
|
|
213
157
|
|
|
158
|
+
# Clear worktree from session state.
|
|
159
|
+
update_session( worktree: :clear )
|
|
160
|
+
|
|
214
161
|
worktree_finish(
|
|
215
162
|
result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
|
|
216
163
|
branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
|
|
@@ -245,10 +192,6 @@ module Carson
|
|
|
245
192
|
puts_line "Worktree created: #{result[ :name ]}"
|
|
246
193
|
puts_line " Path: #{result[ :path ]}"
|
|
247
194
|
puts_line " Branch: #{result[ :branch ]}"
|
|
248
|
-
when "worktree done"
|
|
249
|
-
puts_line "Worktree done: #{result[ :name ]}"
|
|
250
|
-
puts_line " Branch: #{result[ :branch ]}"
|
|
251
|
-
puts_line " Cleanup later with `#{result[ :next_step ]}` or `carson housekeep`."
|
|
252
195
|
when "worktree remove"
|
|
253
196
|
unless verbose?
|
|
254
197
|
puts_line "Worktree removed: #{result[ :name ]}"
|
|
@@ -299,6 +242,28 @@ module Carson
|
|
|
299
242
|
nil
|
|
300
243
|
end
|
|
301
244
|
|
|
245
|
+
# Returns the branch checked out in the worktree that contains the process CWD,
|
|
246
|
+
# or nil if CWD is not inside any worktree. Used by prune to proactively
|
|
247
|
+
# protect the CWD worktree's branch from deletion.
|
|
248
|
+
# Matches the longest (most specific) path because worktree directories
|
|
249
|
+
# live under the main repo tree (.claude/worktrees/).
|
|
250
|
+
def cwd_worktree_branch
|
|
251
|
+
cwd = realpath_safe( Dir.pwd )
|
|
252
|
+
best_branch = nil
|
|
253
|
+
best_length = -1
|
|
254
|
+
worktree_list.each do |wt|
|
|
255
|
+
wt_path = wt.fetch( :path )
|
|
256
|
+
normalised = File.join( wt_path, "" )
|
|
257
|
+
if ( cwd == wt_path || cwd.start_with?( normalised ) ) && wt_path.length > best_length
|
|
258
|
+
best_branch = wt.fetch( :branch, nil )
|
|
259
|
+
best_length = wt_path.length
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
best_branch
|
|
263
|
+
rescue StandardError
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
302
267
|
# Returns the main (non-worktree) repository root.
|
|
303
268
|
# Uses git-common-dir to find the shared .git directory, then takes its parent.
|
|
304
269
|
# Falls back to repo_root if detection fails.
|