carson 3.15.0 → 3.15.2
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/MANUAL.md +2 -0
- data/RELEASE.md +22 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/housekeep.rb +59 -2
- data/lib/carson/runtime/local/prune.rb +5 -0
- data/lib/carson/runtime/local/worktree.rb +90 -7
- 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: 4f08f650218ee09489aea34bf1525c486827ccfd97f7c3b60ff413ac9a6e072f
|
|
4
|
+
data.tar.gz: fc443ff963e9f864b067ce2ab100988e23588c8719237ec0d980d1d0d33d56ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9a84fb14af25a2c821c17716d4cf9ca68ee0b40d294d038d013496c6861e0e9d9b83f9f0cb6135ec8ea1162b497c356b78562054e0092e16d134204e761f512
|
|
7
|
+
data.tar.gz: 8343c8fcda3a6c6aeae5d25bb7ab563c9c52730ea609a1ddc1d06c4daff898cc97dbe64cf93999a01175663b1a2a55cc5016f5f7842a4fa926504e17dfafb90b
|
data/MANUAL.md
CHANGED
|
@@ -142,6 +142,8 @@ carson prune
|
|
|
142
142
|
|
|
143
143
|
After squash or rebase merge, the content matches main — removal proceeds without `--force`.
|
|
144
144
|
|
|
145
|
+
**Stale worktree recovery** — if a worktree directory is destroyed externally (e.g. by running `gh pr merge --delete-branch` from inside it), `worktree remove` and `prune` handle the stale entry gracefully: they clean up the git registration and delete the branch without error. Use `carson deliver --merge` instead of raw `gh pr merge --delete-branch` to avoid this situation — `deliver` deliberately omits `--delete-branch` so the worktree directory stays intact for orderly cleanup.
|
|
146
|
+
|
|
145
147
|
### Carson vs Claude Code EnterWorktree
|
|
146
148
|
|
|
147
149
|
Claude Code has a built-in `EnterWorktree` tool. Both create a git worktree under `.claude/worktrees/` with a new branch — but they solve different problems and have different trade-offs.
|
data/RELEASE.md
CHANGED
|
@@ -5,6 +5,28 @@ 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.15.2
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **Stale worktree handling** — `worktree remove` and `prune` now handle missing worktree directories gracefully. When a worktree directory is destroyed externally (e.g. by `gh pr merge --delete-branch`), Carson prunes the stale git entry and cleans up the branch without error. Fixes #189, #190.
|
|
13
|
+
- `resolve_worktree_path` always resolves bare names under `.claude/worktrees/`, even when the directory no longer exists.
|
|
14
|
+
- `prune!` runs `git worktree prune` early to clear stale entries before listing branches — unblocks deletion for branches held by dead worktrees.
|
|
15
|
+
- `reap_dead_worktrees!` detects and prunes worktrees whose directories are already gone.
|
|
16
|
+
|
|
17
|
+
### UX improvement
|
|
18
|
+
|
|
19
|
+
- Added safety note to MANUAL.md: use `carson deliver --merge` instead of raw `gh pr merge --delete-branch` from inside worktrees.
|
|
20
|
+
|
|
21
|
+
## 3.15.1
|
|
22
|
+
|
|
23
|
+
### What changed
|
|
24
|
+
|
|
25
|
+
- **Dead worktree reaping** — `housekeep` now reaps dead worktrees before pruning. Unblocks prune for branches held by stale worktrees.
|
|
26
|
+
- Two-layer dead check: fast content-absorbed test, then definitive merged-PR evidence via GitHub API. Covers simple merges and rebase/squash cases where main has evolved.
|
|
27
|
+
- Safe removal only — refuses dirty working trees (`git worktree remove` without `--force`).
|
|
28
|
+
- Deletes the local branch after removal (unless protected).
|
|
29
|
+
|
|
8
30
|
## 3.15.0
|
|
9
31
|
|
|
10
32
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.15.
|
|
1
|
+
3.15.2
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
# Housekeeping — sync
|
|
1
|
+
# Housekeeping — sync, reap dead worktrees, and prune for a repository.
|
|
2
2
|
# carson housekeep <repo> — serve one repo by name or path.
|
|
3
3
|
# carson housekeep — serve the repo you are standing in.
|
|
4
4
|
# carson housekeep --all — serve all governed repos.
|
|
5
5
|
require "json"
|
|
6
|
+
require "open3"
|
|
6
7
|
require "stringio"
|
|
7
8
|
|
|
8
9
|
module Carson
|
|
@@ -41,6 +42,59 @@ module Carson
|
|
|
41
42
|
housekeep_finish( result: result, exit_code: failed.zero? ? EXIT_OK : EXIT_ERROR, json_output: json_output, results: results, succeeded: succeeded, failed: failed )
|
|
42
43
|
end
|
|
43
44
|
|
|
45
|
+
# Removes dead worktrees — those whose content is on main or with merged PR evidence.
|
|
46
|
+
# Unblocks prune for the branches they hold.
|
|
47
|
+
# Two-layer dead check:
|
|
48
|
+
# 1. Content-absorbed: delegates to sweep_stale_worktrees! (shared, no gh needed).
|
|
49
|
+
# 2. Merged PR evidence: covers rebase/squash where main has since evolved
|
|
50
|
+
# the same files (requires gh).
|
|
51
|
+
def reap_dead_worktrees!
|
|
52
|
+
# Layer 1: sweep agent-owned worktrees whose content is on main.
|
|
53
|
+
sweep_stale_worktrees!
|
|
54
|
+
|
|
55
|
+
# Layer 2: merged PR evidence for remaining worktrees.
|
|
56
|
+
return unless gh_available?
|
|
57
|
+
|
|
58
|
+
main_root = main_worktree_root
|
|
59
|
+
worktree_list.each do |wt|
|
|
60
|
+
path = wt.fetch( :path )
|
|
61
|
+
branch = wt.fetch( :branch, nil )
|
|
62
|
+
next if path == main_root
|
|
63
|
+
next unless branch
|
|
64
|
+
next if cwd_inside_worktree?( worktree_path: path )
|
|
65
|
+
|
|
66
|
+
# Missing directory: worktree was destroyed externally.
|
|
67
|
+
# Prune the stale entry and delete the branch immediately.
|
|
68
|
+
unless Dir.exist?( path )
|
|
69
|
+
git_run( "worktree", "prune" )
|
|
70
|
+
puts_verbose "reaped stale worktree entry: #{File.basename( path )} (branch: #{branch})"
|
|
71
|
+
if !config.protected_branches.include?( branch )
|
|
72
|
+
git_run( "branch", "-D", branch )
|
|
73
|
+
puts_verbose "deleted branch: #{branch}"
|
|
74
|
+
end
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
tip_sha = git_capture!( "rev-parse", "--verify", branch ).strip rescue nil
|
|
79
|
+
next unless tip_sha
|
|
80
|
+
|
|
81
|
+
merged_pr, = merged_pr_for_branch( branch: branch, branch_tip_sha: tip_sha )
|
|
82
|
+
next if merged_pr.nil?
|
|
83
|
+
|
|
84
|
+
# Remove the worktree (no --force: refuses if dirty working tree).
|
|
85
|
+
_, _, rm_success, = git_run( "worktree", "remove", path )
|
|
86
|
+
next unless rm_success
|
|
87
|
+
|
|
88
|
+
puts_verbose "reaped dead worktree: #{File.basename( path )} (branch: #{branch})"
|
|
89
|
+
|
|
90
|
+
# Delete the local branch now that no worktree holds it.
|
|
91
|
+
if !config.protected_branches.include?( branch )
|
|
92
|
+
git_run( "branch", "-D", branch )
|
|
93
|
+
puts_verbose "deleted branch: #{branch}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
44
98
|
private
|
|
45
99
|
|
|
46
100
|
# Runs sync + prune on one repo and returns the exit code directly.
|
|
@@ -64,7 +118,10 @@ module Carson
|
|
|
64
118
|
rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: buf, err: err_buf, verbose: verbose? )
|
|
65
119
|
|
|
66
120
|
sync_status = rt.sync!
|
|
67
|
-
|
|
121
|
+
if sync_status == EXIT_OK
|
|
122
|
+
rt.reap_dead_worktrees!
|
|
123
|
+
prune_status = rt.prune!
|
|
124
|
+
end
|
|
68
125
|
|
|
69
126
|
ok = sync_status == EXIT_OK && prune_status == EXIT_OK
|
|
70
127
|
unless verbose? || silent
|
|
@@ -19,6 +19,11 @@ module Carson
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
prune_git!( "fetch", config.git_remote, "--prune", json_output: json_output )
|
|
22
|
+
|
|
23
|
+
# Clean stale worktree entries whose directories no longer exist.
|
|
24
|
+
# Unblocks branch deletion for branches held by dead worktrees.
|
|
25
|
+
git_run( "worktree", "prune" )
|
|
26
|
+
|
|
22
27
|
active_branch = current_branch
|
|
23
28
|
cwd_branch = cwd_worktree_branch
|
|
24
29
|
counters = { deleted: 0, skipped: 0 }
|
|
@@ -7,6 +7,9 @@ module Carson
|
|
|
7
7
|
class Runtime
|
|
8
8
|
module Local
|
|
9
9
|
|
|
10
|
+
# Agent directory names whose worktrees Carson may sweep.
|
|
11
|
+
AGENT_WORKTREE_DIRS = %w[ .claude .codex ].freeze
|
|
12
|
+
|
|
10
13
|
# Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
|
|
11
14
|
# Uses main_worktree_root so this works even when called from inside a worktree.
|
|
12
15
|
def worktree_create!( name:, json_output: false )
|
|
@@ -74,7 +77,13 @@ module Carson
|
|
|
74
77
|
|
|
75
78
|
resolved_path = resolve_worktree_path( worktree_path: worktree_path )
|
|
76
79
|
|
|
77
|
-
|
|
80
|
+
# Missing directory: worktree was destroyed externally (e.g. gh pr merge
|
|
81
|
+
# --delete-branch). Clean up the stale git registration and delete the branch.
|
|
82
|
+
if !Dir.exist?( resolved_path ) && worktree_registered?( path: resolved_path )
|
|
83
|
+
return worktree_remove_missing!( resolved_path: resolved_path, json_output: json_output )
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
unless worktree_registered?( path: resolved_path )
|
|
78
87
|
return worktree_finish(
|
|
79
88
|
result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
|
|
80
89
|
error: "#{resolved_path} is not a registered worktree",
|
|
@@ -166,8 +175,82 @@ module Carson
|
|
|
166
175
|
)
|
|
167
176
|
end
|
|
168
177
|
|
|
178
|
+
# Removes agent-owned worktrees whose branch content is already on main.
|
|
179
|
+
# Scans AGENT_WORKTREE_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
|
|
180
|
+
# under the main repo root. Safe: skips detached HEADs, the caller's CWD,
|
|
181
|
+
# and dirty working trees (git worktree remove refuses without --force).
|
|
182
|
+
def sweep_stale_worktrees!
|
|
183
|
+
main_root = main_worktree_root
|
|
184
|
+
worktrees = worktree_list
|
|
185
|
+
|
|
186
|
+
agent_prefixes = AGENT_WORKTREE_DIRS.filter_map do |dir|
|
|
187
|
+
full = File.join( main_root, dir, "worktrees" )
|
|
188
|
+
File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
|
|
189
|
+
end
|
|
190
|
+
return if agent_prefixes.empty?
|
|
191
|
+
|
|
192
|
+
worktrees.each do |wt|
|
|
193
|
+
path = wt.fetch( :path )
|
|
194
|
+
branch = wt.fetch( :branch, nil )
|
|
195
|
+
next unless branch
|
|
196
|
+
next unless agent_prefixes.any? { |prefix| path.start_with?( prefix ) }
|
|
197
|
+
next if cwd_inside_worktree?( worktree_path: path )
|
|
198
|
+
next unless branch_absorbed_into_main?( branch: branch )
|
|
199
|
+
|
|
200
|
+
# Remove the worktree (no --force: refuses if dirty working tree).
|
|
201
|
+
_, _, rm_success, = git_run( "worktree", "remove", path )
|
|
202
|
+
next unless rm_success
|
|
203
|
+
|
|
204
|
+
puts_verbose "swept stale worktree: #{File.basename( path )} (branch: #{branch})"
|
|
205
|
+
|
|
206
|
+
# Delete the local branch now that no worktree holds it.
|
|
207
|
+
if !config.protected_branches.include?( branch )
|
|
208
|
+
git_run( "branch", "-D", branch )
|
|
209
|
+
puts_verbose "deleted branch: #{branch}"
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
169
214
|
private
|
|
170
215
|
|
|
216
|
+
# Handles removal when the worktree directory is already gone (destroyed
|
|
217
|
+
# externally by gh pr merge --delete-branch or manual deletion).
|
|
218
|
+
# Prunes the stale git worktree entry and cleans up the branch.
|
|
219
|
+
def worktree_remove_missing!( resolved_path:, json_output: )
|
|
220
|
+
branch = worktree_branch( path: resolved_path )
|
|
221
|
+
puts_verbose "worktree_remove_missing: path=#{resolved_path} branch=#{branch}"
|
|
222
|
+
|
|
223
|
+
# Prune the stale worktree entry from git's registry.
|
|
224
|
+
git_run( "worktree", "prune" )
|
|
225
|
+
puts_verbose "pruned stale worktree entry: #{resolved_path}"
|
|
226
|
+
|
|
227
|
+
# Delete the local branch.
|
|
228
|
+
branch_deleted = false
|
|
229
|
+
if branch && !config.protected_branches.include?( branch )
|
|
230
|
+
_, _, del_success, = git_run( "branch", "-D", branch )
|
|
231
|
+
if del_success
|
|
232
|
+
puts_verbose "branch_deleted: #{branch}"
|
|
233
|
+
branch_deleted = true
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Delete the remote branch (best-effort).
|
|
238
|
+
remote_deleted = false
|
|
239
|
+
if branch && !config.protected_branches.include?( branch )
|
|
240
|
+
_, _, rd_success, = git_run( "push", config.git_remote, "--delete", branch )
|
|
241
|
+
if rd_success
|
|
242
|
+
puts_verbose "remote_branch_deleted: #{config.git_remote}/#{branch}"
|
|
243
|
+
remote_deleted = true
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
worktree_finish(
|
|
248
|
+
result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
|
|
249
|
+
branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
|
|
250
|
+
exit_code: EXIT_OK, json_output: json_output
|
|
251
|
+
)
|
|
252
|
+
end
|
|
253
|
+
|
|
171
254
|
# Unified output for worktree results — JSON or human-readable.
|
|
172
255
|
def worktree_finish( result:, exit_code:, json_output: )
|
|
173
256
|
result[ :exit_code ] = exit_code
|
|
@@ -305,9 +388,11 @@ module Carson
|
|
|
305
388
|
# Best-effort — do not block worktree creation if exclude fails.
|
|
306
389
|
end
|
|
307
390
|
|
|
308
|
-
# Resolves a worktree path: if it's a bare name,
|
|
309
|
-
#
|
|
310
|
-
#
|
|
391
|
+
# Resolves a worktree path: if it's a bare name, always resolve under
|
|
392
|
+
# .claude/worktrees/ — even when the directory no longer exists (e.g. after
|
|
393
|
+
# gh pr merge --delete-branch deleted it externally).
|
|
394
|
+
# Returns the canonical (realpath) form so comparisons against git worktree list
|
|
395
|
+
# succeed, even when the OS resolves symlinks differently (e.g. /tmp → /private/tmp).
|
|
311
396
|
# Uses main_worktree_root (not repo_root) so resolution works from inside worktrees.
|
|
312
397
|
def resolve_worktree_path( worktree_path: )
|
|
313
398
|
if worktree_path.include?( "/" )
|
|
@@ -316,9 +401,7 @@ module Carson
|
|
|
316
401
|
|
|
317
402
|
root = main_worktree_root
|
|
318
403
|
candidate = File.join( root, ".claude", "worktrees", worktree_path )
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
realpath_safe( worktree_path )
|
|
404
|
+
realpath_safe( candidate )
|
|
322
405
|
end
|
|
323
406
|
|
|
324
407
|
# Returns true if the path is a registered git worktree.
|