carson 3.24.0 → 3.27.1

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.
@@ -55,13 +55,13 @@ module Carson
55
55
  repository = repository_record
56
56
  branch = branch_record
57
57
  deliveries = ledger.active_deliveries( repo_path: repository.path )
58
+ next_delivery_key = deliveries.find( &:ready? )&.key
58
59
 
59
60
  {
60
61
  version: Carson::VERSION,
61
62
  repository: {
62
63
  name: repository.name,
63
- path: repository.path,
64
- authority: repository.authority
64
+ path: repository.path
65
65
  },
66
66
  branch: {
67
67
  name: branch.name,
@@ -71,12 +71,13 @@ module Carson
71
71
  dirty_reason: dirty_worktree_reason,
72
72
  sync: remote_sync_status( branch: branch.name )
73
73
  },
74
- branches: deliveries.map { |delivery| status_branch_entry( delivery: delivery ) },
74
+ worktrees: gather_worktree_summary,
75
+ branches: deliveries.map { |delivery| status_branch_entry( delivery: delivery, next_to_integrate: delivery.key == next_delivery_key ) },
75
76
  stale_branches: gather_stale_branch_info
76
77
  }
77
78
  end
78
79
 
79
- def status_branch_entry( delivery: )
80
+ def status_branch_entry( delivery:, next_to_integrate: )
80
81
  {
81
82
  branch: delivery.branch,
82
83
  worktree_path: delivery.worktree_path,
@@ -85,6 +86,7 @@ module Carson
85
86
  delivery_state: delivery.status,
86
87
  revision_count: delivery.revision_count,
87
88
  summary: delivery.summary,
89
+ next_to_integrate: next_to_integrate,
88
90
  updated_at: delivery.updated_at
89
91
  }
90
92
  end
@@ -130,30 +132,43 @@ module Carson
130
132
  { count: gone_branches.size }
131
133
  end
132
134
 
135
+ def gather_worktree_summary
136
+ all = worktree_list
137
+ main_root = main_worktree_root
138
+ non_main = all.reject { |worktree| worktree.path == main_root }
139
+ { count: all.count, non_main_count: non_main.count }
140
+ end
141
+
133
142
  def print_status( data: )
134
- puts_line "Carson #{data.fetch( :version )}"
135
- puts_line "Repository: #{data.dig( :repository, :name )}"
136
- puts_line "Authority: #{data.dig( :repository, :authority )}"
143
+ repo_name = data.dig( :repository, :name )
144
+ puts_line "Carson #{data.fetch( :version )} — #{repo_name}"
137
145
 
138
146
  branch = data.fetch( :branch )
139
- branch_line = "Branch: #{branch.fetch( :name )}"
140
- branch_line += " (dirty #{branch.fetch( :dirty_reason )})" if branch.fetch( :dirty )
141
- branch_line += " [#{format_sync( sync: branch.fetch( :sync ) )}]"
147
+ branch_line = "On #{branch.fetch( :name )}"
148
+ branch_line += " (uncommitted changes)" if branch.fetch( :dirty )
149
+ branch_line += ", #{format_sync( sync: branch.fetch( :sync ) )}."
142
150
  puts_line branch_line
151
+ worktree_summary = data.fetch( :worktrees )
152
+ puts_line "Worktrees: #{worktree_summary.fetch( :non_main_count )} tracked outside main — run carson worktree list." if worktree_summary.fetch( :non_main_count ).positive?
143
153
 
144
154
  deliveries = data.fetch( :branches )
145
155
  if deliveries.empty?
146
- puts_line "Deliveries: none"
156
+ puts_line "No active deliveries."
147
157
  return
148
158
  end
149
159
 
150
- puts_line "Deliveries:"
160
+ count = deliveries.length
161
+ puts_line "#{count} active deliver#{count == 1 ? 'y' : 'ies'}:"
162
+ if (next_delivery = deliveries.find { |delivery| delivery.fetch( :next_to_integrate, false ) })
163
+ pr_number = next_delivery.fetch( :pr_number )
164
+ pr_ref = pr_number ? " (PR ##{pr_number})" : ""
165
+ puts_line "Next delivery: #{next_delivery.fetch( :branch )}#{pr_ref}."
166
+ end
151
167
  deliveries.each do |delivery|
152
- line = "- #{delivery.fetch( :delivery_state )}: #{delivery.fetch( :branch )}"
153
168
  pr_number = delivery.fetch( :pr_number )
154
- line += " (#{"PR ##{pr_number}"} )" if pr_number
155
- puts_line line.gsub( " )", ")" )
156
- puts_line " #{delivery.fetch( :summary )}" unless delivery.fetch( :summary ).to_s.empty?
169
+ pr_ref = pr_number ? " (PR ##{pr_number})" : ""
170
+ puts_line " #{delivery.fetch( :branch )}#{pr_ref} — #{delivery.fetch( :delivery_state )}"
171
+ puts_line " #{delivery.fetch( :summary )}." unless delivery.fetch( :summary ).to_s.empty?
157
172
  end
158
173
  end
159
174
 
@@ -165,22 +180,18 @@ module Carson
165
180
 
166
181
  deliveries = Array( result.fetch( :branches, [] ) )
167
182
  counts = deliveries.each_with_object( Hash.new( 0 ) ) { |delivery, memo| memo[ delivery.fetch( :delivery_state ) ] += 1 }
168
- summary = if counts.empty?
169
- "no active deliveries"
170
- else
171
- counts.map { |state, count| "#{count} #{state}" }.join( ", " )
172
- end
173
- puts_line "#{result.fetch( :name )}: #{result.dig( :repository, :authority )} — #{summary}"
183
+ summary = counts.empty? ? "no active deliveries" : counts.map { |state, count| "#{count} #{state}" }.join( ", " )
184
+ puts_line "#{result.fetch( :name )} — #{summary}"
174
185
  end
175
186
 
176
187
  def format_sync( sync: )
177
188
  case sync
178
- when :in_sync then "in sync"
179
- when :ahead then "ahead"
180
- when :behind then "behind"
181
- when :diverged then "diverged"
182
- when :no_remote then "no remote"
183
- else "unknown"
189
+ when :in_sync then "in sync with remote"
190
+ when :ahead then "ahead of remote"
191
+ when :behind then "behind remote"
192
+ when :diverged then "diverged from remote"
193
+ when :no_remote then "no remote tracking"
194
+ else "sync unknown"
184
195
  end
185
196
  end
186
197
  end
@@ -91,7 +91,7 @@ module Carson
91
91
  # canonical main tree path, regardless of which worktree the command runs from.
92
92
  # This ensures govern (which looks up by main tree path) finds worktree deliveries.
93
93
  def repository_record
94
- Repository.new( path: main_worktree_root, authority: config.govern_authority, runtime: self )
94
+ Repository.new( path: main_worktree_root, runtime: self )
95
95
  end
96
96
 
97
97
  # Passive branch record for the current checkout.
@@ -370,7 +370,9 @@ require_relative "runtime/review"
370
370
  require_relative "runtime/govern"
371
371
  require_relative "runtime/setup"
372
372
  require_relative "runtime/status"
373
+ require_relative "runtime/abandon"
373
374
  require_relative "runtime/deliver"
375
+ require_relative "runtime/recover"
374
376
 
375
377
  # Infrastructure interface for domain objects.
376
378
  # Carson::Worktree and future domain objects call these methods
@@ -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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -76,6 +76,7 @@ files:
76
76
  - lib/carson/repository.rb
77
77
  - lib/carson/revision.rb
78
78
  - lib/carson/runtime.rb
79
+ - lib/carson/runtime/abandon.rb
79
80
  - lib/carson/runtime/audit.rb
80
81
  - lib/carson/runtime/deliver.rb
81
82
  - lib/carson/runtime/govern.rb
@@ -87,6 +88,7 @@ files:
87
88
  - lib/carson/runtime/local/sync.rb
88
89
  - lib/carson/runtime/local/template.rb
89
90
  - lib/carson/runtime/local/worktree.rb
91
+ - lib/carson/runtime/recover.rb
90
92
  - lib/carson/runtime/repos.rb
91
93
  - lib/carson/runtime/review.rb
92
94
  - lib/carson/runtime/review/data_access.rb