carson 3.10.4 → 3.11.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: 15dca7c329b421bc534d0ea857d38af70bfe89bad5eb2ec40be9bbe09015e37f
4
- data.tar.gz: a2a997bf5f9be278a33034155fffa61caa1c28e78c24fa3d8af85e43f72241c0
3
+ metadata.gz: 0d6a7526cb4973b9f911b8fd28d151ca719cbb53be7d42beb29e676d0d9354f4
4
+ data.tar.gz: 4b116460df0e2e9397edf0f60432abb907d7b81f1e28a4d7e8bceda47e030db7
5
5
  SHA512:
6
- metadata.gz: 8e7f231146222978f1f880a08e16ce99bb75ae3c69899f20d23413bd88b712edc0cc8a21b3b1515437496ef625e079318a62f435dd4634490cafa3ac034c09b9
7
- data.tar.gz: 2e3ec460ec390a021b48e132597899e5c6d7df5f3d1e667aa29572f0b45de673a076410bf81a6b768b6dd06fb60adec5672278e4cbbd695a5d598688afaecbb2
6
+ metadata.gz: 807185f0bbeb5c2762b89c8a59aed681006a1589a66024f2cf546972416bde79ad858be69cceb685a6b762a8594d95efa41607323f1d5469f3d70709b64a57f1
7
+ data.tar.gz: 7d7c6a06a7bf4b75428a57afa891f04dadf1ca402ac35cfee2d968658963e180b8b2ed27a1aa2a8c78c057456d319977ddfce6d022f945281081c9d7b936a7ff
data/RELEASE.md CHANGED
@@ -5,12 +5,33 @@ 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.11.0
9
+
10
+ ### What changed
11
+
12
+ - **Drop `worktree done`** — removed the `worktree done` subcommand entirely. `worktree remove` now handles everything: CWD safety guard, unpushed-commits guard, session state cleanup, branch and remote deletion. Two operations (create, remove) instead of three. Simpler, safer, less to remember.
13
+
14
+ ### Breaking changes
15
+
16
+ - `carson worktree done <name>` no longer exists. Use `carson worktree remove <name>` instead. Add `--force` to override safety guards.
17
+
18
+ ## 3.10.5
19
+
20
+ ### What changed
21
+
22
+ - **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.
23
+ - **`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/`).
24
+
25
+ ### Migration
26
+
27
+ - No breaking changes. New safety guard — previously git-protected operations now fail earlier with clearer diagnostics.
28
+
8
29
  ## 3.10.4
9
30
 
10
31
  ### What changed
11
32
 
12
33
  - **Worktree remove guards unpushed commits** — `carson worktree remove` now checks for unpushed commits before deleting a worktree. Blocks with recovery guidance (push command or `--force` to override). Prevents accidental destruction of work that exists only locally.
13
- - **Shared unpushed-commits check** — extracted `check_unpushed_commits` method used by both `worktree done` and `worktree remove`, eliminating code duplication.
34
+ - **Shared unpushed-commits check** — extracted `check_unpushed_commits` method used by `worktree remove`, eliminating code duplication.
14
35
  - **Fix resolve path from inside worktrees** — `resolve_worktree_path` now uses `main_worktree_root` instead of `repo_root` for bare-name resolution. Previously, calling `carson worktree remove <name>` from inside a worktree would look in the wrong directory.
15
36
 
16
37
  ### Migration
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.10.4
1
+ 3.11.0
data/lib/carson/cli.rb CHANGED
@@ -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 [status [--json]|setup|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] create|done|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|session [--json] [--task T]|session clear [--json]|version]"
56
+ opts.banner = "Usage: carson [status [--json]|setup|audit [--json]|sync [--json]|deliver [--merge] [--json] [--title T] [--body-file F]|prune [--all] [--json]|worktree [--json] create|remove <name>|onboard|refresh [--all]|offboard|template check|apply|review gate|sweep|govern [--dry-run] [--json] [--loop SECONDS]|session [--json] [--task T]|session clear [--json]|version]"
57
57
  end
58
58
  end
59
59
 
@@ -180,7 +180,7 @@ module Carson
180
180
  json_flag = argv.delete( "--json" ) ? true : false
181
181
  action = argv.shift
182
182
  if action.to_s.strip.empty?
183
- err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|done|remove <name>"
183
+ err.puts "#{BADGE} Missing subcommand for worktree. Use: carson worktree create|remove <name>"
184
184
  err.puts parser
185
185
  return { command: :invalid }
186
186
  end
@@ -193,9 +193,6 @@ module Carson
193
193
  return { command: :invalid }
194
194
  end
195
195
  { command: "worktree:create", worktree_name: name, json: json_flag }
196
- when "done"
197
- name = argv.shift
198
- { command: "worktree:done", worktree_name: name, json: json_flag }
199
196
  when "remove"
200
197
  force = argv.delete( "--force" ) ? true : false
201
198
  worktree_path = argv.shift
@@ -205,7 +202,7 @@ module Carson
205
202
  end
206
203
  { command: "worktree:remove", worktree_path: worktree_path, force: force, json: json_flag }
207
204
  else
208
- err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|done|remove <name>"
205
+ err.puts "#{BADGE} Unknown worktree subcommand: #{action}. Use: carson worktree create|remove <name>"
209
206
  { command: :invalid }
210
207
  end
211
208
  end
@@ -379,8 +376,6 @@ module Carson
379
376
  runtime.prune_all!
380
377
  when "worktree:create"
381
378
  runtime.worktree_create!( name: parsed.fetch( :worktree_name ), json_output: parsed.fetch( :json, false ) )
382
- when "worktree:done"
383
- runtime.worktree_done!( name: parsed.fetch( :worktree_name, nil ), json_output: parsed.fetch( :json, false ) )
384
379
  when "worktree:remove"
385
380
  runtime.worktree_remove!( worktree_path: parsed.fetch( :worktree_path ), force: parsed.fetch( :force, false ), json_output: parsed.fetch( :json, false ) )
386
381
  when "onboard"
@@ -78,7 +78,7 @@ module Carson
78
78
 
79
79
  result[ :merged ] = true
80
80
 
81
- # Step 6: mark worktree done in session state.
81
+ # Step 6: clear worktree from session state.
82
82
  update_session( worktree: :clear )
83
83
 
84
84
  # Step 7: sync main in the main worktree.
@@ -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,6 +1,6 @@
1
1
  # Safe worktree lifecycle management for coding agents.
2
- # Three operations: create, done (mark completed), remove (full cleanup).
3
- # Remove guards against unpushed commits and CWD-inside-worktree safe by default.
2
+ # Two operations: create and remove. Remove is safe by default — guards against
3
+ # CWD-inside-worktree and unpushed commits. Use --force to override.
4
4
  # Supports --json for machine-readable structured output with recovery commands.
5
5
  module Carson
6
6
  class Runtime
@@ -50,62 +50,6 @@ module Carson
50
50
  )
51
51
  end
52
52
 
53
- # Marks a worktree as completed without deleting it.
54
- # Verifies all changes are committed. Deferred deletion — cleanup happens later.
55
- def worktree_done!( name: nil, json_output: false )
56
- if name.to_s.strip.empty?
57
- return worktree_finish(
58
- result: { command: "worktree done", status: "error",
59
- error: "missing worktree name",
60
- recovery: "carson worktree done <name>" },
61
- exit_code: EXIT_ERROR, json_output: json_output
62
- )
63
- end
64
-
65
- resolved_path = resolve_worktree_path( worktree_path: name )
66
-
67
- unless worktree_registered?( path: resolved_path )
68
- return worktree_finish(
69
- result: { command: "worktree done", status: "error", name: name,
70
- error: "#{name} is not a registered worktree",
71
- recovery: "git worktree list" },
72
- exit_code: EXIT_ERROR, json_output: json_output
73
- )
74
- end
75
-
76
- # Check for uncommitted changes in the worktree.
77
- wt_status, _, status_result, = Open3.capture3( "git", "status", "--porcelain", chdir: resolved_path )
78
- if status_result.success? && !wt_status.strip.empty?
79
- return worktree_finish(
80
- result: { command: "worktree done", status: "block", name: name,
81
- error: "worktree has uncommitted changes",
82
- recovery: "git -C #{resolved_path} add -A && git -C #{resolved_path} commit, then carson worktree done #{name}" },
83
- exit_code: EXIT_BLOCK, json_output: json_output
84
- )
85
- end
86
-
87
- # Check for unpushed commits using shared guard.
88
- branch = worktree_branch( path: resolved_path )
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
- )
97
- end
98
-
99
- # Clear worktree from session state.
100
- update_session( worktree: :clear )
101
-
102
- worktree_finish(
103
- result: { command: "worktree done", status: "ok", name: name, branch: branch || "(detached)",
104
- next_step: "carson worktree remove #{name}" },
105
- exit_code: EXIT_OK, json_output: json_output
106
- )
107
- end
108
-
109
53
  # Removes a worktree: directory, git registration, and branch.
110
54
  # Never forces removal — if the worktree has uncommitted changes, refuses unless
111
55
  # the user explicitly passes force: true via CLI --force flag.
@@ -211,6 +155,9 @@ module Carson
211
155
  end
212
156
  end
213
157
 
158
+ # Clear worktree from session state.
159
+ update_session( worktree: :clear )
160
+
214
161
  worktree_finish(
215
162
  result: { command: "worktree remove", status: "ok", name: File.basename( resolved_path ),
216
163
  branch: branch, branch_deleted: branch_deleted, remote_deleted: remote_deleted },
@@ -245,10 +192,6 @@ module Carson
245
192
  puts_line "Worktree created: #{result[ :name ]}"
246
193
  puts_line " Path: #{result[ :path ]}"
247
194
  puts_line " Branch: #{result[ :branch ]}"
248
- when "worktree done"
249
- puts_line "Worktree done: #{result[ :name ]}"
250
- puts_line " Branch: #{result[ :branch ]}"
251
- puts_line " Cleanup later with `#{result[ :next_step ]}` or `carson housekeep`."
252
195
  when "worktree remove"
253
196
  unless verbose?
254
197
  puts_line "Worktree removed: #{result[ :name ]}"
@@ -299,6 +242,28 @@ module Carson
299
242
  nil
300
243
  end
301
244
 
245
+ # Returns the branch checked out in the worktree that contains the process CWD,
246
+ # or nil if CWD is not inside any worktree. Used by prune to proactively
247
+ # protect the CWD worktree's branch from deletion.
248
+ # Matches the longest (most specific) path because worktree directories
249
+ # live under the main repo tree (.claude/worktrees/).
250
+ def cwd_worktree_branch
251
+ cwd = realpath_safe( Dir.pwd )
252
+ best_branch = nil
253
+ best_length = -1
254
+ worktree_list.each do |wt|
255
+ wt_path = wt.fetch( :path )
256
+ normalised = File.join( wt_path, "" )
257
+ if ( cwd == wt_path || cwd.start_with?( normalised ) ) && wt_path.length > best_length
258
+ best_branch = wt.fetch( :branch, nil )
259
+ best_length = wt_path.length
260
+ end
261
+ end
262
+ best_branch
263
+ rescue StandardError
264
+ nil
265
+ end
266
+
302
267
  # Returns the main (non-worktree) repository root.
303
268
  # Uses git-common-dir to find the shared .git directory, then takes its parent.
304
269
  # Falls back to repo_root if detection fails.
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.4
4
+ version: 3.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang