carson 2.31.0 → 2.33.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96820bedb9187d90787072b441c35129457a7d45202899ea520a9bb1e3b50876
4
- data.tar.gz: f8963204bde45cee5e7e662dcc5f5bb947e84372f0d24a03d6ef494c3653adbf
3
+ metadata.gz: 83e70e6824d3de6da2d444aa9d0b2859f3710e357f87cdcf65cecc512ff63fee
4
+ data.tar.gz: 6c09bd64199ab8ee6f7fa14722b670a123c7cb051ea2937c1e2bbfddb696fded
5
5
  SHA512:
6
- metadata.gz: 616dbf56c4c09e6327767ccaaa8b88595a51ae4e8275290d1b6f1349fb6b2e25cb3789e4891699b3e6b8ff878d209bf4503adcc4974506b1a72ddcee374e432d
7
- data.tar.gz: 83b8827df9139e9a81fa587a00816333d37fc87d03a3f7f0388bbd3fb8722138978b7b99844ccbfa6713243a651c4886774f88400c226c060398f1824b64e7ba
6
+ metadata.gz: 3a6570b397a73c27aab1d33e044bd28f56d1f643e57e76b40d3bbd5cdd1ff5da2779467980c9f595c6a25cbe2524cf5ff6508050a2736677ebb94079434eb749
7
+ data.tar.gz: 33d29172314ab043a5a38122b91fac3c150b3d846ef113cff953fefec5f56f8c35af1290a48ef951fe1154de4caf749cd779daf98c09c939518c0e64f7a72ecd
data/RELEASE.md CHANGED
@@ -5,6 +5,32 @@ 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.33.0 — Safe Worktree Remove
9
+
10
+ ### What changed
11
+
12
+ - **`carson worktree remove` no longer force-removes by default.** If the worktree has uncommitted or untracked changes, Carson refuses with a clear message instead of silently discarding work. Pass `--force` to override when you intentionally want to discard changes.
13
+ - **Template sync cleanup is safer.** Carson's internal template propagation tries safe worktree removal first, falling back to `--force` only for its own ephemeral sync worktree.
14
+
15
+ ### UX
16
+
17
+ - Dirty worktree removal now shows the worktree name and actionable guidance: "Commit or discard changes first, or use --force to override."
18
+
19
+ ### Migration
20
+
21
+ - No action required. Existing `carson worktree remove` calls without `--force` will now refuse on dirty worktrees instead of silently destroying uncommitted work.
22
+
23
+ ## 2.32.0 — Worktree-Aware Pruning
24
+
25
+ ### What changed
26
+
27
+ - **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.
28
+ - **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.
29
+
30
+ ### Migration
31
+
32
+ - No action required. Existing prune behaviour is strictly improved.
33
+
8
34
  ## 2.31.0 — Worktree Lifecycle + Prune --all
9
35
 
10
36
  ### What changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.31.0
1
+ 2.33.0
data/lib/carson/cli.rb CHANGED
@@ -175,12 +175,13 @@ module Carson
175
175
 
176
176
  case action
177
177
  when "remove"
178
+ force = argv.delete( "--force" ) ? true : false
178
179
  worktree_path = argv.shift
179
180
  if worktree_path.to_s.strip.empty?
180
181
  err.puts "#{BADGE} Missing path for worktree remove. Use: carson worktree remove <name-or-path>"
181
182
  return { command: :invalid }
182
183
  end
183
- { command: "worktree:remove", worktree_path: worktree_path }
184
+ { command: "worktree:remove", worktree_path: worktree_path, force: force }
184
185
  else
185
186
  err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree remove <name-or-path>"
186
187
  { command: :invalid }
@@ -276,7 +277,7 @@ module Carson
276
277
  when "prune:all"
277
278
  runtime.prune_all!
278
279
  when "worktree:remove"
279
- runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ) )
280
+ runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ) )
280
281
  when "onboard"
281
282
  runtime.onboard!
282
283
  when "refresh"
@@ -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
- if deleted_count.zero?
29
- puts_line "No stale branches."
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
- puts_line "Pruned #{deleted_count} stale branch#{plural_suffix( count: deleted_count )}."
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, = git_run( "branch", "-D", branch )
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,31 @@ module Carson
122
126
  text.empty? ? "unknown error" : text
123
127
  end
124
128
 
129
+ # Attempts git branch -D. If blocked by a worktree, skips with a diagnostic —
130
+ # prune never removes worktrees because another session may own them.
131
+ def force_delete_local_branch( branch: )
132
+ stdout, stderr, success, = git_run( "branch", "-D", branch )
133
+ return [ stdout, stderr, success ] if success
134
+
135
+ if worktree_blocked_error?( error_text: stderr )
136
+ wt_path = worktree_path_for_branch( branch: branch )
137
+ hint = wt_path ? "run: carson worktree remove #{File.basename( wt_path )}" : "remove the worktree first"
138
+ puts_verbose "skip_worktree_blocked: #{branch} (#{hint})"
139
+ end
140
+
141
+ [ stdout, stderr, false ]
142
+ end
143
+
144
+ def worktree_blocked_error?( error_text: )
145
+ error_text.to_s.downcase.include?( "used by worktree" )
146
+ end
147
+
148
+ # Returns the worktree path for a branch, or nil if not checked out in any worktree.
149
+ def worktree_path_for_branch( branch: )
150
+ entry = worktree_list.find { |wt| wt.fetch( :branch, nil ) == branch }
151
+ entry&.fetch( :path, nil )
152
+ end
153
+
125
154
  # Detects local branches whose upstream tracking is marked [gone] after fetch --prune.
126
155
  def stale_local_branches
127
156
  git_capture!( "for-each-ref", "--format=%(refname:short)\t%(upstream:short)\t%(upstream:track)", "refs/heads" ).lines.map do |line|
@@ -218,7 +247,7 @@ module Carson
218
247
  return :skipped
219
248
  end
220
249
 
221
- force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
250
+ force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
222
251
  unless force_success
223
252
  error_text = normalise_branch_delete_error( error_text: force_stderr )
224
253
  puts_verbose "fail_delete_absorbed_branch: #{branch} reason=#{error_text}"
@@ -287,7 +316,7 @@ module Carson
287
316
  return :skipped
288
317
  end
289
318
 
290
- force_stdout, force_stderr, force_success, = git_run( "branch", "-D", branch )
319
+ force_stdout, force_stderr, force_success = force_delete_local_branch( branch: branch )
291
320
  if force_success
292
321
  out.print force_stdout if verbose? && !force_stdout.empty?
293
322
  puts_verbose "deleted_orphan_branch: #{branch} merged_pr=#{merged_pr.fetch( :url )}"
@@ -244,7 +244,9 @@ module Carson
244
244
  end
245
245
 
246
246
  def template_propagate_cleanup!( worktree_dir: )
247
- git_run( "worktree", "remove", "--force", worktree_dir )
247
+ # Try safe removal first; fall back to force only for Carson's own sync worktree.
248
+ _, _, safe_success, = git_run( "worktree", "remove", worktree_dir )
249
+ git_run( "worktree", "remove", "--force", worktree_dir ) unless safe_success
248
250
  git_run( "branch", "-D", TEMPLATE_SYNC_BRANCH )
249
251
  puts_verbose "template_propagate: worktree and local branch cleaned up"
250
252
  rescue StandardError => e
@@ -3,7 +3,9 @@ module Carson
3
3
  module Local
4
4
  # Safe worktree lifecycle management for coding agents.
5
5
  # Enforces the teardown order: exit worktree → git worktree remove → branch cleanup.
6
- def worktree_remove!( worktree_path: )
6
+ # Never forces removal — if the worktree has uncommitted changes, refuses unless
7
+ # the user explicitly passes force: true via CLI --force flag.
8
+ def worktree_remove!( worktree_path:, force: false )
7
9
  fingerprint_status = block_if_outsider_fingerprints!
8
10
  return fingerprint_status unless fingerprint_status.nil?
9
11
 
@@ -17,14 +19,23 @@ module Carson
17
19
  end
18
20
 
19
21
  branch = worktree_branch( path: resolved_path )
20
- puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch}"
22
+ puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
21
23
 
22
24
  # Step 1: remove the worktree (directory + git registration).
23
- rm_stdout, rm_stderr, rm_success, = git_run( "worktree", "remove", "--force", resolved_path )
25
+ # Try safe removal first. Only use --force if the user explicitly requested it.
26
+ rm_args = [ "worktree", "remove" ]
27
+ rm_args << "--force" if force
28
+ rm_args << resolved_path
29
+ rm_stdout, rm_stderr, rm_success, = git_run( *rm_args )
24
30
  unless rm_success
25
31
  error_text = rm_stderr.to_s.strip
26
32
  error_text = "unable to remove worktree" if error_text.empty?
27
- puts_line "ERROR: #{error_text}"
33
+ if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) )
34
+ puts_line "Worktree has uncommitted changes: #{File.basename( resolved_path )}"
35
+ puts_line " Commit or discard changes first, or use --force to override."
36
+ else
37
+ puts_line "ERROR: #{error_text}"
38
+ end
28
39
  return EXIT_ERROR
29
40
  end
30
41
  puts_verbose "worktree_removed: #{resolved_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: 2.31.0
4
+ version: 2.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang