carson 3.10.3 → 3.10.5

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: 2bbb3e13e1c89593777ce820ddcf6ef96502693aaa1c2d41303e95cc374a46d9
4
- data.tar.gz: 402e3a4b6152547e7b328c02686aeea4780a329cc61a187e75ec256de8355244
3
+ metadata.gz: 29198792c7eb5bb9c02c43fc31d6a2ba04fc1430684e978cc365c2e74b47f810
4
+ data.tar.gz: b0e164e172315cc707017a9c82e83328fdb1e84a5e4f983aa5aeabc9caae1b04
5
5
  SHA512:
6
- metadata.gz: d415ae9589a2a65587709a3adf079f112c5489890c68ffe42b295b23527dca980669e8433ec3a531f77628dd07099d7368d5c055817463627e5d9aa8710ec61a
7
- data.tar.gz: 45d641154e946ee1f1f37e1d6b3a61971cab37c34433e6fd4ecbc6f44f0691b01268213f1664416061db5cedcf8f306b58698043667613f345005dd3f852050d
6
+ metadata.gz: 45361da34684eaf4d399f7d40eba060a63f3c364f41ed890ac448f6ffeeb4d886961f451ce393adec495240ffc5bff5bc95399b59d7afd08aaba0eadf0579fde
7
+ data.tar.gz: b827c09fcf9cef558f237471abd328906ecda337f2bdef04a7991ee2547b2a31437840eb651d04e1ca659c1d55b891e84890cf57b857247240fd3a5ecd81f71c
data/RELEASE.md CHANGED
@@ -5,6 +5,17 @@ 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.10.5
9
+
10
+ ### What changed
11
+
12
+ - **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.
13
+ - **`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/`).
14
+
15
+ ### Migration
16
+
17
+ - No breaking changes. New safety guard — previously git-protected operations now fail earlier with clearer diagnostics.
18
+
8
19
  ## 3.10.3
9
20
 
10
21
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.10.3
1
+ 3.10.5
@@ -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 = type == :protected ? "protected branch" : "current branch"
113
- status = type == :protected ? "skip_protected_branch" : "skip_current_branch"
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,14 +1,15 @@
1
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.
2
+ # Three operations: create, done (mark completed), remove (full cleanup).
3
+ # Remove guards against unpushed commits and CWD-inside-worktree safe by default.
4
4
  # Supports --json for machine-readable structured output with recovery commands.
5
5
  module Carson
6
6
  class Runtime
7
7
  module Local
8
8
 
9
9
  # Creates a new worktree under .claude/worktrees/<name> with a fresh branch.
10
+ # Uses main_worktree_root so this works even when called from inside a worktree.
10
11
  def worktree_create!( name:, json_output: false )
11
- worktrees_dir = File.join( repo_root, ".claude", "worktrees" )
12
+ worktrees_dir = File.join( main_worktree_root, ".claude", "worktrees" )
12
13
  wt_path = File.join( worktrees_dir, name )
13
14
 
14
15
  if Dir.exist?( wt_path )
@@ -83,35 +84,16 @@ module Carson
83
84
  )
84
85
  end
85
86
 
86
- # Check for unpushed commits.
87
- # If the remote branch does not exist, check whether the branch has unique commits
88
- # versus the main branch. If it does, block — the work exists only locally.
89
- # If the branch has no unique commits (just created, no work done), allow.
90
- # Note: Open3.capture3 returns Process::Status (always truthy), so .success? is required.
87
+ # Check for unpushed commits using shared guard.
91
88
  branch = worktree_branch( path: resolved_path )
92
- if branch
93
- remote = config.git_remote
94
- remote_ref = "#{remote}/#{branch}"
95
- ahead, _, ahead_status, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: resolved_path )
96
- if !ahead_status.success?
97
- # Remote ref does not exist. Only block if the branch has unique commits vs main.
98
- unique, _, unique_status, = Open3.capture3( "git", "rev-list", "--count", "#{config.main_branch}..#{branch}", chdir: resolved_path )
99
- if unique_status.success? && unique.strip.to_i > 0
100
- return worktree_finish(
101
- result: { command: "worktree done", status: "block", name: name, branch: branch,
102
- error: "branch has not been pushed to #{remote}",
103
- recovery: "git -C #{resolved_path} push -u #{remote} #{branch}" },
104
- exit_code: EXIT_BLOCK, json_output: json_output
105
- )
106
- end
107
- elsif ahead.strip.to_i > 0
108
- return worktree_finish(
109
- result: { command: "worktree done", status: "block", name: name, branch: branch,
110
- error: "worktree has unpushed commits",
111
- recovery: "git -C #{resolved_path} push #{remote} #{branch}" },
112
- exit_code: EXIT_BLOCK, json_output: json_output
113
- )
114
- end
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
+ )
115
97
  end
116
98
 
117
99
  # Clear worktree from session state.
@@ -167,6 +149,21 @@ module Carson
167
149
  branch = worktree_branch( path: resolved_path )
168
150
  puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
169
151
 
152
+ # Safety: refuse if the branch has unpushed commits (unless --force).
153
+ # Prevents accidental destruction of work that exists only locally.
154
+ unless force
155
+ unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path )
156
+ if unpushed
157
+ return worktree_finish(
158
+ result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
159
+ branch: branch,
160
+ error: unpushed[ :error ],
161
+ recovery: unpushed[ :recovery ] },
162
+ exit_code: EXIT_BLOCK, json_output: json_output
163
+ )
164
+ end
165
+ end
166
+
170
167
  # Step 1: remove the worktree (directory + git registration).
171
168
  rm_args = [ "worktree", "remove" ]
172
169
  rm_args << "--force" if force
@@ -279,6 +276,51 @@ module Carson
279
276
  false
280
277
  end
281
278
 
279
+ # Checks whether a branch has unpushed commits that would be lost on removal.
280
+ # Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.
281
+ def check_unpushed_commits( branch:, worktree_path: )
282
+ return nil unless branch
283
+
284
+ remote = config.git_remote
285
+ remote_ref = "#{remote}/#{branch}"
286
+ ahead, _, ahead_status, = Open3.capture3( "git", "rev-list", "--count", "#{remote_ref}..#{branch}", chdir: worktree_path )
287
+ if !ahead_status.success?
288
+ # Remote ref does not exist. Only block if the branch has unique commits vs main.
289
+ unique, _, unique_status, = Open3.capture3( "git", "rev-list", "--count", "#{config.main_branch}..#{branch}", chdir: worktree_path )
290
+ if unique_status.success? && unique.strip.to_i > 0
291
+ return { error: "branch has not been pushed to #{remote}",
292
+ recovery: "git -C #{worktree_path} push -u #{remote} #{branch}, or use --force to override" }
293
+ end
294
+ elsif ahead.strip.to_i > 0
295
+ return { error: "worktree has unpushed commits",
296
+ recovery: "git -C #{worktree_path} push #{remote} #{branch}, or use --force to override" }
297
+ end
298
+
299
+ nil
300
+ end
301
+
302
+ # Returns the branch checked out in the worktree that contains the process CWD,
303
+ # or nil if CWD is not inside any worktree. Used by prune to proactively
304
+ # protect the CWD worktree's branch from deletion.
305
+ # Matches the longest (most specific) path because worktree directories
306
+ # live under the main repo tree (.claude/worktrees/).
307
+ def cwd_worktree_branch
308
+ cwd = realpath_safe( Dir.pwd )
309
+ best_branch = nil
310
+ best_length = -1
311
+ worktree_list.each do |wt|
312
+ wt_path = wt.fetch( :path )
313
+ normalised = File.join( wt_path, "" )
314
+ if ( cwd == wt_path || cwd.start_with?( normalised ) ) && wt_path.length > best_length
315
+ best_branch = wt.fetch( :branch, nil )
316
+ best_length = wt_path.length
317
+ end
318
+ end
319
+ best_branch
320
+ rescue StandardError
321
+ nil
322
+ end
323
+
282
324
  # Returns the main (non-worktree) repository root.
283
325
  # Uses git-common-dir to find the shared .git directory, then takes its parent.
284
326
  # Falls back to repo_root if detection fails.
@@ -293,8 +335,9 @@ module Carson
293
335
  # This prevents worktree directories from appearing as untracked files
294
336
  # in the host repository. Uses the local exclude file (never committed)
295
337
  # so the host repo's .gitignore is never touched.
338
+ # Uses main_worktree_root — worktrees have .git as a file, not a directory.
296
339
  def ensure_claude_dir_excluded!
297
- git_dir = File.join( repo_root, ".git" )
340
+ git_dir = File.join( main_worktree_root, ".git" )
298
341
  return unless File.directory?( git_dir )
299
342
 
300
343
  info_dir = File.join( git_dir, "info" )
@@ -312,12 +355,14 @@ module Carson
312
355
  # Resolves a worktree path: if it's a bare name, look under .claude/worktrees/.
313
356
  # Returns the canonical (realpath) form so comparisons against git worktree list succeed,
314
357
  # even when the OS resolves symlinks differently (e.g. /tmp → /private/tmp on macOS).
358
+ # Uses main_worktree_root (not repo_root) so resolution works from inside worktrees.
315
359
  def resolve_worktree_path( worktree_path: )
316
360
  if worktree_path.include?( "/" )
317
361
  return realpath_safe( worktree_path )
318
362
  end
319
363
 
320
- candidate = File.join( repo_root, ".claude", "worktrees", worktree_path )
364
+ root = main_worktree_root
365
+ candidate = File.join( root, ".claude", "worktrees", worktree_path )
321
366
  return realpath_safe( candidate ) if Dir.exist?( candidate )
322
367
 
323
368
  realpath_safe( worktree_path )
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.10.3
4
+ version: 3.10.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang