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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83272d790537e52d22639bbf4831f772496b9ffa5938b23da6d2865ae73d8e38
4
- data.tar.gz: ff4d8db79fc54bcd12d74a4c6c74332861ea58dc3fef78f93b3188237d27c772
3
+ metadata.gz: 4f08f650218ee09489aea34bf1525c486827ccfd97f7c3b60ff413ac9a6e072f
4
+ data.tar.gz: fc443ff963e9f864b067ce2ab100988e23588c8719237ec0d980d1d0d33d56ec
5
5
  SHA512:
6
- metadata.gz: 655f2cb1f8caa036e96dc17e4e360ced9256840ee5f2050d33e9f72f4187a2f0d8fefbe27d82ff2da8e3bd21f5c8bb460886bf31af922818fa74efeb3fe9d59f
7
- data.tar.gz: 44490df6057142b3f58745ac24041e501207599684d9a10816ee505ed192c681661fd4e23003d637a6ccf69f4a86efbd6d754ada2d6079ee99db89b431133495
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.0
1
+ 3.15.2
@@ -1,8 +1,9 @@
1
- # Housekeeping — sync + prune for a repository.
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
- prune_status = rt.prune! if sync_status == EXIT_OK
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
- unless worktree_registered?( path: resolved_path )
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, look under .claude/worktrees/.
309
- # Returns the canonical (realpath) form so comparisons against git worktree list succeed,
310
- # even when the OS resolves symlinks differently (e.g. /tmp → /private/tmp on macOS).
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
- return realpath_safe( candidate ) if Dir.exist?( candidate )
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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.15.0
4
+ version: 3.15.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang