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 +4 -4
- data/RELEASE.md +11 -0
- data/VERSION +1 -1
- data/lib/carson/runtime/local/prune.rb +14 -10
- data/lib/carson/runtime/local/worktree.rb +78 -33
- 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: 29198792c7eb5bb9c02c43fc31d6a2ba04fc1430684e978cc365c2e74b47f810
|
|
4
|
+
data.tar.gz: b0e164e172315cc707017a9c82e83328fdb1e84a5e4f983aa5aeabc9caae1b04
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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 =
|
|
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,14 +1,15 @@
|
|
|
1
1
|
# Safe worktree lifecycle management for coding agents.
|
|
2
|
-
# Three operations: create, done (mark completed), remove (
|
|
3
|
-
#
|
|
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(
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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(
|
|
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
|
-
|
|
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 )
|