carson 3.24.0 → 3.27.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.
@@ -103,6 +103,15 @@ module Carson
103
103
  )
104
104
  end
105
105
 
106
+ unless creation_verified?( path: worktree_path, branch: name, runtime: runtime )
107
+ return finish(
108
+ result: { command: "worktree create", status: "error", name: name, path: worktree_path, branch: name,
109
+ error: "git reported success but Carson could not verify the worktree and branch",
110
+ recovery: "git worktree list && git branch --list '#{name}'" },
111
+ exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
112
+ )
113
+ end
114
+
106
115
  finish(
107
116
  result: { command: "worktree create", status: "ok", name: name, path: worktree_path, branch: name },
108
117
  exit_code: Runtime::EXIT_OK, runtime: runtime, json_output: json_output
@@ -126,66 +135,28 @@ module Carson
126
135
  return fingerprint_status
127
136
  end
128
137
 
129
- resolved_path = resolve_path( path: path, runtime: runtime )
130
-
131
- # Missing directory: worktree was destroyed externally (e.g. gh pr merge
132
- # --delete-branch). Clean up the stale git registration and delete the branch.
133
- if !Dir.exist?( resolved_path ) && registered?( path: resolved_path, runtime: runtime )
134
- return remove_missing!( resolved_path: resolved_path, runtime: runtime, json_output: json_output )
135
- end
136
-
137
- unless registered?( path: resolved_path, runtime: runtime )
138
+ check = remove_check( path: path, runtime: runtime, force: force )
139
+ unless check.fetch( :status ) == :ok
138
140
  return finish(
139
- result: { command: "worktree remove", status: "error", name: File.basename( resolved_path ),
140
- error: "#{resolved_path} is not a registered worktree",
141
- recovery: "git worktree list" },
142
- exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
141
+ result: { command: "worktree remove", status: check.fetch( :result_status ), name: File.basename( check.fetch( :resolved_path ) ),
142
+ branch: check.fetch( :branch, nil ),
143
+ error: check.fetch( :error ),
144
+ recovery: check.fetch( :recovery, nil ) },
145
+ exit_code: check.fetch( :exit_code ), runtime: runtime, json_output: json_output
143
146
  )
144
147
  end
145
148
 
146
- # Safety: refuse if the caller's shell CWD is inside the worktree.
147
- # Removing a directory while a shell is inside it kills the shell permanently.
148
- entry = find( path: resolved_path, runtime: runtime )
149
- if entry&.holds_cwd?
150
- safe_root = runtime.main_worktree_root
151
- return finish(
152
- result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
153
- error: "current working directory is inside this worktree",
154
- recovery: "cd #{safe_root} && carson worktree remove #{File.basename( resolved_path )}" },
155
- exit_code: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
156
- )
157
- end
149
+ resolved_path = check.fetch( :resolved_path )
150
+ branch = check.fetch( :branch )
158
151
 
159
- # Safety: refuse if another process has its CWD inside the worktree.
160
- # Protects against cross-process CWD crashes (e.g. an agent session
161
- # removed by a separate cleanup process while the agent's shell is inside).
162
- if entry&.held_by_other_process?
163
- return finish(
164
- result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
165
- error: "another process has its working directory inside this worktree",
166
- recovery: "wait for the other session to finish, then retry" },
167
- exit_code: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
168
- )
152
+ # Missing directory: worktree was destroyed externally (e.g. gh pr merge
153
+ # --delete-branch). Clean up the stale git registration and delete the branch.
154
+ if check.fetch( :missing )
155
+ return remove_missing!( resolved_path: resolved_path, runtime: runtime, json_output: json_output )
169
156
  end
170
157
 
171
- branch = entry&.branch
172
158
  runtime.puts_verbose "worktree_remove: path=#{resolved_path} branch=#{branch} force=#{force}"
173
159
 
174
- # Safety: refuse if the branch has unpushed commits (unless --force).
175
- # Prevents accidental destruction of work that exists only locally.
176
- unless force
177
- unpushed = check_unpushed_commits( branch: branch, worktree_path: resolved_path, runtime: runtime )
178
- if unpushed
179
- return finish(
180
- result: { command: "worktree remove", status: "block", name: File.basename( resolved_path ),
181
- branch: branch,
182
- error: unpushed[ :error ],
183
- recovery: unpushed[ :recovery ] },
184
- exit_code: Runtime::EXIT_BLOCK, runtime: runtime, json_output: json_output
185
- )
186
- end
187
- end
188
-
189
160
  # Step 1: remove the worktree (directory + git registration).
190
161
  rm_args = [ "worktree", "remove" ]
191
162
  rm_args << "--force" if force
@@ -239,6 +210,87 @@ module Carson
239
210
  )
240
211
  end
241
212
 
213
+ # Preflight guard for worktree removal. Shared by `worktree remove` and
214
+ # other runtime flows that need to know whether cleanup is safe before
215
+ # mutating GitHub or branch state.
216
+ def self.remove_check( path:, runtime:, force: false )
217
+ resolved_path = resolve_path( path: path, runtime: runtime )
218
+
219
+ if !Dir.exist?( resolved_path ) && registered?( path: resolved_path, runtime: runtime )
220
+ entry = find( path: resolved_path, runtime: runtime )
221
+ return { status: :ok, resolved_path: resolved_path, branch: entry&.branch, missing: true }
222
+ end
223
+
224
+ unless registered?( path: resolved_path, runtime: runtime )
225
+ return {
226
+ status: :error,
227
+ result_status: "error",
228
+ exit_code: Runtime::EXIT_ERROR,
229
+ resolved_path: resolved_path,
230
+ branch: nil,
231
+ error: "#{resolved_path} is not a registered worktree",
232
+ recovery: "git worktree list"
233
+ }
234
+ end
235
+
236
+ entry = find( path: resolved_path, runtime: runtime )
237
+ branch = entry&.branch
238
+
239
+ if entry&.holds_cwd?
240
+ safe_root = runtime.main_worktree_root
241
+ return {
242
+ status: :block,
243
+ result_status: "block",
244
+ exit_code: Runtime::EXIT_BLOCK,
245
+ resolved_path: resolved_path,
246
+ branch: branch,
247
+ error: "current working directory is inside this worktree",
248
+ recovery: "cd #{safe_root} && carson worktree remove #{File.basename( resolved_path )}"
249
+ }
250
+ end
251
+
252
+ if entry&.held_by_other_process?
253
+ return {
254
+ status: :block,
255
+ result_status: "block",
256
+ exit_code: Runtime::EXIT_BLOCK,
257
+ resolved_path: resolved_path,
258
+ branch: branch,
259
+ error: "another process has its working directory inside this worktree",
260
+ recovery: "wait for the other session to finish, then retry"
261
+ }
262
+ end
263
+
264
+ if !force && entry&.dirty?
265
+ return {
266
+ status: :error,
267
+ result_status: "error",
268
+ exit_code: Runtime::EXIT_ERROR,
269
+ resolved_path: resolved_path,
270
+ branch: branch,
271
+ error: "worktree has uncommitted changes",
272
+ recovery: "commit or discard changes first, or use --force to override"
273
+ }
274
+ end
275
+
276
+ unless force
277
+ unpushed = branch_unpushed_issue( branch: branch, worktree_path: resolved_path, runtime: runtime )
278
+ if unpushed
279
+ return {
280
+ status: :block,
281
+ result_status: "block",
282
+ exit_code: Runtime::EXIT_BLOCK,
283
+ resolved_path: resolved_path,
284
+ branch: branch,
285
+ error: unpushed.fetch( :error ),
286
+ recovery: unpushed.fetch( :recovery )
287
+ }
288
+ end
289
+ end
290
+
291
+ { status: :ok, resolved_path: resolved_path, branch: branch, missing: false }
292
+ end
293
+
242
294
  # Removes agent-owned worktrees whose branch content is already on main.
243
295
  # Scans AGENT_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
244
296
  # under the main repo root. Safe: skips detached HEADs, the caller's CWD,
@@ -313,6 +365,19 @@ module Carson
313
365
  false
314
366
  end
315
367
 
368
+ def exists?
369
+ Dir.exist?( path )
370
+ end
371
+
372
+ def dirty?
373
+ return false unless exists?
374
+
375
+ stdout, = Open3.capture3( "git", "status", "--porcelain", chdir: path )
376
+ !stdout.to_s.strip.empty?
377
+ rescue StandardError
378
+ false
379
+ end
380
+
316
381
  # rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
317
382
  private
318
383
  # rubocop:enable Layout/AccessModifierIndentation
@@ -374,6 +439,17 @@ module Carson
374
439
  end
375
440
  private_class_method :finish
376
441
 
442
+ def self.creation_verified?( path:, branch:, runtime: )
443
+ registered?( path: path, runtime: runtime ) && branch_exists?( branch: branch, runtime: runtime )
444
+ end
445
+ private_class_method :creation_verified?
446
+
447
+ def self.branch_exists?( branch:, runtime: )
448
+ _, _, success, = runtime.git_run( "show-ref", "--verify", "--quiet", "refs/heads/#{branch}" )
449
+ success
450
+ end
451
+ private_class_method :branch_exists?
452
+
377
453
  # Human-readable output for worktree results.
378
454
  def self.print_human( result:, runtime: )
379
455
  command = result[ :command ]
@@ -405,7 +481,7 @@ module Carson
405
481
  # Content-aware: after squash/rebase merge, SHAs differ but tree content may match main.
406
482
  # Compares content, not SHAs.
407
483
  # Returns nil if safe, or { error:, recovery: } hash if unpushed work exists.
408
- def self.check_unpushed_commits( branch:, worktree_path:, runtime: )
484
+ def self.branch_unpushed_issue( branch:, worktree_path:, runtime: )
409
485
  return nil unless branch
410
486
 
411
487
  remote = runtime.config.git_remote
@@ -433,7 +509,6 @@ module Carson
433
509
 
434
510
  nil
435
511
  end
436
- private_class_method :check_unpushed_commits
437
512
 
438
513
  # Resolves a worktree path: if it's a bare name, first tries the flat
439
514
  # .claude/worktrees/<name> convention; if that isn't registered, searches
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.24.0
4
+ version: 3.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -10,27 +10,7 @@ authors:
10
10
  bindir: exe
11
11
  cert_chain: []
12
12
  date: 1980-01-02 00:00:00.000000000 Z
13
- dependencies:
14
- - !ruby/object:Gem::Dependency
15
- name: sqlite3
16
- requirement: !ruby/object:Gem::Requirement
17
- requirements:
18
- - - ">="
19
- - !ruby/object:Gem::Version
20
- version: '1.3'
21
- - - "<"
22
- - !ruby/object:Gem::Version
23
- version: '3'
24
- type: :runtime
25
- prerelease: false
26
- version_requirements: !ruby/object:Gem::Requirement
27
- requirements:
28
- - - ">="
29
- - !ruby/object:Gem::Version
30
- version: '1.3'
31
- - - "<"
32
- - !ruby/object:Gem::Version
33
- version: '3'
13
+ dependencies: []
34
14
  description: 'Carson is an autonomous git strategist and repositories governor that
35
15
  lives outside the repositories it governs — no Carson-owned artefacts in your repo.
36
16
  As strategist, Carson knows when to branch, how to isolate concurrent work, and
@@ -76,6 +56,7 @@ files:
76
56
  - lib/carson/repository.rb
77
57
  - lib/carson/revision.rb
78
58
  - lib/carson/runtime.rb
59
+ - lib/carson/runtime/abandon.rb
79
60
  - lib/carson/runtime/audit.rb
80
61
  - lib/carson/runtime/deliver.rb
81
62
  - lib/carson/runtime/govern.rb
@@ -87,6 +68,7 @@ files:
87
68
  - lib/carson/runtime/local/sync.rb
88
69
  - lib/carson/runtime/local/template.rb
89
70
  - lib/carson/runtime/local/worktree.rb
71
+ - lib/carson/runtime/recover.rb
90
72
  - lib/carson/runtime/repos.rb
91
73
  - lib/carson/runtime/review.rb
92
74
  - lib/carson/runtime/review/data_access.rb