carson 2.30.0 → 2.32.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 -0
- data/VERSION +1 -1
- data/lib/carson/cli.rb +39 -2
- data/lib/carson/runtime/local/onboard.rb +42 -0
- data/lib/carson/runtime/local/prune.rb +41 -6
- data/lib/carson/runtime/local/worktree.rb +103 -0
- data/lib/carson/runtime/local.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99666417783ceb949cdef39bbf1595465bf0a5f674607229b4b937a4ae8b6197
|
|
4
|
+
data.tar.gz: a8e55f943e2ab34a26d50b0468e10cecbdbf5879eaeef1a7323e99e80715dac3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2342dcf7bde01d3632741e76adaefe6c499a03a36d11bda476870419e43f87caa51ae9837fc5f1cdbbc9b5c083b4b2058824fc5c0f346788ca7f2658aecbe2ab
|
|
7
|
+
data.tar.gz: f0f8fe556fa65044b3789a45cd25d1fc354e2865a5b923843bf3249a30259bac989eb74f0cb5bb1ff2072c4dc548911ca5f5d26d13da933baceb70161a527993
|
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
|
+
## 2.32.0 — Worktree-Aware Pruning
|
|
9
|
+
|
|
10
|
+
### What changed
|
|
11
|
+
|
|
12
|
+
- **Prune now clears worktrees that block branch deletion.** When a branch is checked out in a worktree, `carson prune` safely removes the worktree first (refuses if uncommitted changes exist), then deletes the branch. Previously, these branches were silently skipped.
|
|
13
|
+
- **Honest skip reporting.** Non-verbose output no longer says "No stale branches" when branches were detected but couldn't be pruned. Shows "Skipped N branch(es)" or "Pruned N, skipped M" as appropriate, with a `--verbose` hint.
|
|
14
|
+
|
|
15
|
+
### Migration
|
|
16
|
+
|
|
17
|
+
- No action required. Existing prune behaviour is strictly improved.
|
|
18
|
+
|
|
19
|
+
## 2.31.0 — Worktree Lifecycle + Prune --all
|
|
20
|
+
|
|
21
|
+
### What changed
|
|
22
|
+
|
|
23
|
+
- **`carson worktree remove <name-or-path>`** — Safe worktree teardown in the correct order: removes the worktree directory and git registration, deletes the local branch, and cleans up the remote branch. Accepts either a full path or a short name (resolves under `.claude/worktrees/`). Prevents agents from being stranded in deleted directories.
|
|
24
|
+
- **`carson prune --all`** — Prunes stale branches across all governed repositories. Same discipline as single-repo prune, applied to the full estate.
|
|
25
|
+
|
|
26
|
+
### Migration
|
|
27
|
+
|
|
28
|
+
- No action required. Both commands are additive.
|
|
29
|
+
|
|
8
30
|
## 2.30.0 — Prune Rebase Merge Support + Audit Noise Reduction
|
|
9
31
|
|
|
10
32
|
### What changed
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.32.0
|
data/lib/carson/cli.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Carson
|
|
|
12
12
|
return Runtime::EXIT_OK
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
if
|
|
15
|
+
if %w[refresh:all prune:all].include?( command )
|
|
16
16
|
verbose = parsed.fetch( :verbose, false )
|
|
17
17
|
runtime = Runtime.new( repo_root: repo_root, tool_root: tool_root, out: out, err: err, verbose: verbose )
|
|
18
18
|
return dispatch( parsed: parsed, runtime: runtime )
|
|
@@ -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 [setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--merge METHOD] [--canonical PATH]|audit|sync|prune|onboard [repo_path]|refresh [--all|repo_path]|offboard [repo_path]|template check|template apply|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
|
|
56
|
+
opts.banner = "Usage: carson [setup [--remote NAME] [--main-branch NAME] [--workflow STYLE] [--merge METHOD] [--canonical PATH]|audit|sync|prune [--all]|worktree remove <name-or-path>|onboard [repo_path]|refresh [--all|repo_path]|offboard [repo_path]|template check|template apply|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|version]"
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -82,6 +82,10 @@ module Carson
|
|
|
82
82
|
parse_refresh_command( argv: argv, parser: parser, err: err )
|
|
83
83
|
when "template"
|
|
84
84
|
parse_template_subcommand( argv: argv, parser: parser, err: err )
|
|
85
|
+
when "prune"
|
|
86
|
+
parse_prune_command( argv: argv, parser: parser, err: err )
|
|
87
|
+
when "worktree"
|
|
88
|
+
parse_worktree_subcommand( argv: argv, parser: parser, err: err )
|
|
85
89
|
when "review"
|
|
86
90
|
parse_named_subcommand( command: command, usage: "gate|sweep", argv: argv, parser: parser, err: err )
|
|
87
91
|
when "govern"
|
|
@@ -154,6 +158,35 @@ module Carson
|
|
|
154
158
|
}
|
|
155
159
|
end
|
|
156
160
|
|
|
161
|
+
def self.parse_prune_command( argv:, parser:, err: )
|
|
162
|
+
all_flag = argv.delete( "--all" ) ? true : false
|
|
163
|
+
parser.parse!( argv )
|
|
164
|
+
return { command: "prune:all" } if all_flag
|
|
165
|
+
{ command: "prune" }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.parse_worktree_subcommand( argv:, parser:, err: )
|
|
169
|
+
action = argv.shift
|
|
170
|
+
if action.to_s.strip.empty?
|
|
171
|
+
err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree remove <name-or-path>"
|
|
172
|
+
err.puts parser
|
|
173
|
+
return { command: :invalid }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
case action
|
|
177
|
+
when "remove"
|
|
178
|
+
worktree_path = argv.shift
|
|
179
|
+
if worktree_path.to_s.strip.empty?
|
|
180
|
+
err.puts "#{BADGE} Missing path for worktree remove. Use: carson worktree remove <name-or-path>"
|
|
181
|
+
return { command: :invalid }
|
|
182
|
+
end
|
|
183
|
+
{ command: "worktree:remove", worktree_path: worktree_path }
|
|
184
|
+
else
|
|
185
|
+
err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree remove <name-or-path>"
|
|
186
|
+
{ command: :invalid }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
157
190
|
def self.parse_named_subcommand( command:, usage:, argv:, parser:, err: )
|
|
158
191
|
action = argv.shift
|
|
159
192
|
parser.parse!( argv )
|
|
@@ -240,6 +273,10 @@ module Carson
|
|
|
240
273
|
runtime.sync!
|
|
241
274
|
when "prune"
|
|
242
275
|
runtime.prune!
|
|
276
|
+
when "prune:all"
|
|
277
|
+
runtime.prune_all!
|
|
278
|
+
when "worktree:remove"
|
|
279
|
+
runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ) )
|
|
243
280
|
when "onboard"
|
|
244
281
|
runtime.onboard!
|
|
245
282
|
when "refresh"
|
|
@@ -115,6 +115,48 @@ module Carson
|
|
|
115
115
|
failed.zero? ? EXIT_OK : EXIT_ERROR
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
+
def prune_all!
|
|
119
|
+
repos = config.govern_repos
|
|
120
|
+
if repos.empty?
|
|
121
|
+
puts_line "No governed repositories configured."
|
|
122
|
+
puts_line " Run carson onboard in each repo to register."
|
|
123
|
+
return EXIT_ERROR
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
puts_line ""
|
|
127
|
+
puts_line "Prune all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
|
|
128
|
+
succeeded = 0
|
|
129
|
+
failed = 0
|
|
130
|
+
|
|
131
|
+
repos.each do |repo_path|
|
|
132
|
+
repo_name = File.basename( repo_path )
|
|
133
|
+
unless Dir.exist?( repo_path )
|
|
134
|
+
puts_line "#{repo_name}: FAIL (path not found)"
|
|
135
|
+
failed += 1
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
begin
|
|
140
|
+
buf = verbose? ? out : StringIO.new
|
|
141
|
+
err_buf = verbose? ? err : StringIO.new
|
|
142
|
+
rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: buf, err: err_buf, verbose: verbose? )
|
|
143
|
+
status = rt.prune!
|
|
144
|
+
unless verbose?
|
|
145
|
+
summary = buf.string.lines.last.to_s.strip
|
|
146
|
+
puts_line "#{repo_name}: #{summary.empty? ? 'OK' : summary}"
|
|
147
|
+
end
|
|
148
|
+
status == EXIT_ERROR ? ( failed += 1 ) : ( succeeded += 1 )
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
puts_line "#{repo_name}: FAIL (#{e.message})"
|
|
151
|
+
failed += 1
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
puts_line ""
|
|
156
|
+
puts_line "Prune all complete: #{succeeded} pruned, #{failed} failed."
|
|
157
|
+
failed.zero? ? EXIT_OK : EXIT_ERROR
|
|
158
|
+
end
|
|
159
|
+
|
|
118
160
|
# Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
|
|
119
161
|
def offboard!
|
|
120
162
|
puts_verbose ""
|
|
@@ -25,11 +25,15 @@ module Carson
|
|
|
25
25
|
puts_verbose "prune_summary: deleted=#{counters.fetch( :deleted )} skipped=#{counters.fetch( :skipped )}"
|
|
26
26
|
unless verbose?
|
|
27
27
|
deleted_count = counters.fetch( :deleted )
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
skipped_count = counters.fetch( :skipped )
|
|
29
|
+
message = if deleted_count > 0 && skipped_count > 0
|
|
30
|
+
"Pruned #{deleted_count}, skipped #{skipped_count} (--verbose for details)."
|
|
31
|
+
elsif deleted_count > 0
|
|
32
|
+
"Pruned #{deleted_count} stale branch#{plural_suffix( count: deleted_count )}."
|
|
30
33
|
else
|
|
31
|
-
|
|
34
|
+
"Skipped #{skipped_count} branch#{plural_suffix( count: skipped_count )} (--verbose for details)."
|
|
32
35
|
end
|
|
36
|
+
puts_line message
|
|
33
37
|
end
|
|
34
38
|
EXIT_OK
|
|
35
39
|
end
|
|
@@ -93,7 +97,7 @@ module Carson
|
|
|
93
97
|
)
|
|
94
98
|
return prune_force_delete_skipped( branch: branch, upstream: upstream, delete_error_text: delete_error_text, force_error: force_error ) if merged_pr.nil?
|
|
95
99
|
|
|
96
|
-
force_stdout, force_stderr, force_success
|
|
100
|
+
force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
|
|
97
101
|
return prune_force_delete_success( branch: branch, upstream: upstream, merged_pr: merged_pr, force_stdout: force_stdout ) if force_success
|
|
98
102
|
|
|
99
103
|
prune_force_delete_failed( branch: branch, upstream: upstream, force_stderr: force_stderr )
|
|
@@ -122,6 +126,37 @@ module Carson
|
|
|
122
126
|
text.empty? ? "unknown error" : text
|
|
123
127
|
end
|
|
124
128
|
|
|
129
|
+
# Attempts git branch -D. If blocked by a worktree, safely removes the worktree
|
|
130
|
+
# first (no --force — refuses if worktree has uncommitted changes) and retries.
|
|
131
|
+
def force_delete_local_branch( branch: )
|
|
132
|
+
stdout, stderr, success, = git_run( "branch", "-D", branch )
|
|
133
|
+
return [ stdout, stderr, success ] if success
|
|
134
|
+
return [ stdout, stderr, false ] unless worktree_blocked_error?( error_text: stderr )
|
|
135
|
+
|
|
136
|
+
wt_path = worktree_path_for_branch( branch: branch )
|
|
137
|
+
return [ stdout, stderr, false ] if wt_path.nil?
|
|
138
|
+
|
|
139
|
+
rm_stdout, rm_stderr, rm_success, = git_run( "worktree", "remove", wt_path )
|
|
140
|
+
unless rm_success
|
|
141
|
+
error_text = rm_stderr.to_s.strip
|
|
142
|
+
puts_verbose "skip_worktree_remove: #{wt_path} (branch=#{branch}) reason=#{error_text}"
|
|
143
|
+
return [ stdout, stderr, false ]
|
|
144
|
+
end
|
|
145
|
+
puts_verbose "worktree_removed_for_prune: #{wt_path} (branch=#{branch})"
|
|
146
|
+
|
|
147
|
+
git_run( "branch", "-D", branch )
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def worktree_blocked_error?( error_text: )
|
|
151
|
+
error_text.to_s.downcase.include?( "used by worktree" )
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Returns the worktree path for a branch, or nil if not checked out in any worktree.
|
|
155
|
+
def worktree_path_for_branch( branch: )
|
|
156
|
+
entry = worktree_list.find { |wt| wt.fetch( :branch, nil ) == branch }
|
|
157
|
+
entry&.fetch( :path, nil )
|
|
158
|
+
end
|
|
159
|
+
|
|
125
160
|
# Detects local branches whose upstream tracking is marked [gone] after fetch --prune.
|
|
126
161
|
def stale_local_branches
|
|
127
162
|
git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
|
|
@@ -218,7 +253,7 @@ module Carson
|
|
|
218
253
|
return :skipped
|
|
219
254
|
end
|
|
220
255
|
|
|
221
|
-
force_stdout, force_stderr, force_success
|
|
256
|
+
force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
|
|
222
257
|
unless force_success
|
|
223
258
|
error_text = normalise_branch_delete_error( error_text: force_stderr )
|
|
224
259
|
puts_verbose "fail_delete_absorbed_branch: #{branch} reason=#{error_text}"
|
|
@@ -287,7 +322,7 @@ module Carson
|
|
|
287
322
|
return :skipped
|
|
288
323
|
end
|
|
289
324
|
|
|
290
|
-
force_stdout, force_stderr, force_success
|
|
325
|
+
force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
|
|
291
326
|
if force_success
|
|
292
327
|
out.print force_stdout if verbose? && !force_stdout.empty?
|
|
293
328
|
puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
module Carson
|
|
2
|
+
class Runtime
|
|
3
|
+
module Local
|
|
4
|
+
# Safe worktree lifecycle management for coding agents.
|
|
5
|
+
# Enforces the teardown order: exit worktree → git worktree remove → branch cleanup.
|
|
6
|
+
def worktree_remove!( worktree_path: )
|
|
7
|
+
fingerprint_status = block_if_outsider_fingerprints!
|
|
8
|
+
return fingerprint_status unless fingerprint_status.nil?
|
|
9
|
+
|
|
10
|
+
resolved_path = resolve_worktree_path( worktree_path: worktree_path )
|
|
11
|
+
|
|
12
|
+
unless worktree_registered?( path: resolved_path )
|
|
13
|
+
puts_line "ERROR: #{resolved_path} is not a registered worktree."
|
|
14
|
+
puts_line " Registered worktrees:"
|
|
15
|
+
worktree_list.each { |wt| puts_line " - #{wt.fetch( :path )} [#{wt.fetch( :branch )}]" }
|
|
16
|
+
return EXIT_ERROR
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
branch = worktree_branch( path: resolved_path )
|
|
20
|
+
puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch}"
|
|
21
|
+
|
|
22
|
+
# Step 1: remove the worktree (directory + git registration).
|
|
23
|
+
rm_stdout, rm_stderr, rm_success, = git_run( "worktree", "remove", "--force", resolved_path )
|
|
24
|
+
unless rm_success
|
|
25
|
+
error_text = rm_stderr.to_s.strip
|
|
26
|
+
error_text = "unable to remove worktree" if error_text.empty?
|
|
27
|
+
puts_line "ERROR: #{error_text}"
|
|
28
|
+
return EXIT_ERROR
|
|
29
|
+
end
|
|
30
|
+
puts_verbose "worktree_removed: #{resolved_path}"
|
|
31
|
+
|
|
32
|
+
# Step 2: delete the local branch.
|
|
33
|
+
if branch && !config.protected_branches.include?( branch )
|
|
34
|
+
_, del_stderr, del_success, = git_run( "branch", "-D", branch )
|
|
35
|
+
if del_success
|
|
36
|
+
puts_verbose "branch_deleted: #{branch}"
|
|
37
|
+
else
|
|
38
|
+
puts_verbose "branch_delete_skipped: #{branch} reason=#{del_stderr.to_s.strip}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Step 3: delete the remote branch (best-effort).
|
|
43
|
+
if branch && !config.protected_branches.include?( branch )
|
|
44
|
+
remote_branch = branch
|
|
45
|
+
git_run( "push", config.git_remote, "--delete", remote_branch )
|
|
46
|
+
puts_verbose "remote_branch_deleted: #{config.git_remote}/#{remote_branch}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
unless verbose?
|
|
50
|
+
puts_line "Worktree removed: #{File.basename( resolved_path )}"
|
|
51
|
+
end
|
|
52
|
+
EXIT_OK
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# Resolves a worktree path: if it's a bare name, look under .claude/worktrees/.
|
|
58
|
+
def resolve_worktree_path( worktree_path: )
|
|
59
|
+
return File.expand_path( worktree_path ) if worktree_path.include?( "/" )
|
|
60
|
+
|
|
61
|
+
candidate = File.join( repo_root, ".claude", "worktrees", worktree_path )
|
|
62
|
+
return candidate if Dir.exist?( candidate )
|
|
63
|
+
|
|
64
|
+
File.expand_path( worktree_path )
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns true if the path is a registered git worktree.
|
|
68
|
+
def worktree_registered?( path: )
|
|
69
|
+
worktree_list.any? { |wt| wt.fetch( :path ) == path }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the branch name checked out in a worktree, or nil.
|
|
73
|
+
def worktree_branch( path: )
|
|
74
|
+
entry = worktree_list.find { |wt| wt.fetch( :path ) == path }
|
|
75
|
+
entry&.fetch( :branch, nil )
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Parses `git worktree list --porcelain` into structured entries.
|
|
79
|
+
def worktree_list
|
|
80
|
+
output = git_capture!( "worktree", "list", "--porcelain" )
|
|
81
|
+
entries = []
|
|
82
|
+
current = {}
|
|
83
|
+
output.lines.each do |line|
|
|
84
|
+
line = line.strip
|
|
85
|
+
if line.empty?
|
|
86
|
+
entries << current unless current.empty?
|
|
87
|
+
current = {}
|
|
88
|
+
elsif line.start_with?( "worktree " )
|
|
89
|
+
current[ :path ] = line.sub( "worktree ", "" )
|
|
90
|
+
elsif line.start_with?( "branch " )
|
|
91
|
+
current[ :branch ] = line.sub( "branch refs/heads/", "" )
|
|
92
|
+
elsif line == "detached"
|
|
93
|
+
current[ :branch ] = nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
entries << current unless current.empty?
|
|
97
|
+
entries
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
include Local
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/carson/runtime/local.rb
CHANGED
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: 2.
|
|
4
|
+
version: 2.32.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hailei Wang
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- lib/carson/runtime/local/prune.rb
|
|
60
60
|
- lib/carson/runtime/local/sync.rb
|
|
61
61
|
- lib/carson/runtime/local/template.rb
|
|
62
|
+
- lib/carson/runtime/local/worktree.rb
|
|
62
63
|
- lib/carson/runtime/review.rb
|
|
63
64
|
- lib/carson/runtime/review/data_access.rb
|
|
64
65
|
- lib/carson/runtime/review/gate_support.rb
|