carson 3.27.1 → 3.29.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.
@@ -22,6 +22,15 @@ module Carson
22
22
  exit_code: EXIT_BLOCK, json_output: json_output
23
23
  )
24
24
  end
25
+
26
+ attachment = ensure_main_attached!
27
+ unless attachment.fetch( :ok )
28
+ return sync_finish(
29
+ result: { command: "sync", status: "block", error: attachment.fetch( :error ), recovery: attachment[ :recovery ] },
30
+ exit_code: EXIT_BLOCK, json_output: json_output
31
+ )
32
+ end
33
+
25
34
  start_branch = current_branch
26
35
  switched = false
27
36
  sync_git!( "fetch", config.git_remote, "--prune", json_output: json_output )
@@ -186,6 +195,86 @@ module Carson
186
195
  success
187
196
  end
188
197
 
198
+ # Ensures the main worktree is attached to the main branch, not detached HEAD
199
+ # or on a wrong branch.
200
+ #
201
+ # Returns { ok: true } when already attached or successfully reattached.
202
+ # Returns { ok: false, error: "...", recovery: "..." } when blocked.
203
+ #
204
+ # Why this exists: git pull --ff-only on a detached HEAD succeeds (exit 0)
205
+ # but fast-forwards the detached HEAD instead of updating the local main
206
+ # branch ref. This means sync_after_merge! can report synced: true while
207
+ # local main is still stale.
208
+ def ensure_main_attached!( main_root: nil )
209
+ main_root ||= repo_root
210
+ main = config.main_branch
211
+
212
+ head_ref, _, head_success, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--abbrev-ref", "HEAD" )
213
+ return { ok: true } unless head_success
214
+
215
+ head_ref = head_ref.strip
216
+ return { ok: true } if head_ref == main
217
+
218
+ if head_ref != "HEAD"
219
+ # On a wrong branch — switch to main if the tree is clean.
220
+ status_out, = Open3.capture3( "git", "-C", main_root, "status", "--porcelain" )
221
+ unless status_out.to_s.strip.empty?
222
+ return {
223
+ ok: false,
224
+ error: "main worktree is on branch #{head_ref} with uncommitted changes, expected #{main}",
225
+ recovery: "cd #{main_root} && git stash && git switch #{main}"
226
+ }
227
+ end
228
+
229
+ _, switch_err, switch_status, = Open3.capture3( "git", "-C", main_root, "switch", main )
230
+ unless switch_status.success?
231
+ return {
232
+ ok: false,
233
+ error: "main worktree is on branch #{head_ref}, could not switch to #{main}: #{switch_err.strip}",
234
+ recovery: "cd #{main_root} && git switch #{main}"
235
+ }
236
+ end
237
+
238
+ puts_verbose "switched main worktree from #{head_ref} to #{main}"
239
+ return { ok: true, reattached: true }
240
+ end
241
+
242
+ # Detached HEAD — check if safe to reattach.
243
+ detached_sha, = Open3.capture3( "git", "-C", main_root, "rev-parse", "HEAD" )
244
+ detached_sha = detached_sha.strip
245
+
246
+ main_sha, _, main_exists, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--verify", "refs/heads/#{main}" )
247
+ main_sha = main_sha.strip if main_exists&.success?
248
+
249
+ remote = config.git_remote
250
+ remote_sha, _, remote_exists, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--verify", "refs/remotes/#{remote}/#{main}" )
251
+ remote_sha = remote_sha.strip if remote_exists&.success?
252
+
253
+ safe = ( main_exists&.success? && detached_sha == main_sha ) ||
254
+ ( remote_exists&.success? && detached_sha == remote_sha )
255
+
256
+ unless safe
257
+ short_sha = detached_sha[ 0, 8 ]
258
+ return {
259
+ ok: false,
260
+ error: "main worktree is detached at #{short_sha} which differs from #{main}",
261
+ recovery: "cd #{main_root} && git switch #{main}"
262
+ }
263
+ end
264
+
265
+ _, switch_err, switch_status, = Open3.capture3( "git", "-C", main_root, "switch", main )
266
+ unless switch_status.success?
267
+ return {
268
+ ok: false,
269
+ error: "could not reattach main worktree to #{main}: #{switch_err.strip}",
270
+ recovery: "cd #{main_root} && git switch #{main}"
271
+ }
272
+ end
273
+
274
+ puts_verbose "reattached main worktree to #{main}"
275
+ { ok: true, reattached: true }
276
+ end
277
+
189
278
  # In outsider mode, Carson must not leave Carson-owned fingerprints in host repositories.
190
279
  def block_if_outsider_fingerprints!
191
280
  return nil unless outsider_mode?
@@ -14,8 +14,8 @@ module Carson
14
14
  end
15
15
 
16
16
  # Removes a worktree: directory, git registration, and branch.
17
- def worktree_remove!( worktree_path:, force: false, json_output: false )
18
- Worktree.remove!( path: worktree_path, runtime: self, force: force, json_output: json_output )
17
+ def worktree_remove!( worktree_path:, force: false, skip_unpushed: false, json_output: false )
18
+ Worktree.remove!( path: worktree_path, runtime: self, force: force, skip_unpushed: skip_unpushed, json_output: json_output )
19
19
  end
20
20
 
21
21
  # Removes agent-owned worktrees whose branch content is already on main.
@@ -143,35 +143,21 @@ module Carson
143
143
  return { action: :skip, reason: "held by another process", absorbed: false } if worktree.held_by_other_process?
144
144
  return { action: :reap, reason: "directory missing (destroyed externally)", absorbed: false } unless worktree.exists?
145
145
 
146
- if worktree.dirty?
147
- absorbed = branch_absorbed_into_main?( branch: worktree.branch )
148
- return { action: :reap, reason: "dirty worktree with content absorbed into main", absorbed: true, force: true } if absorbed
149
- return { action: :skip, reason: "gh CLI not available for PR check", absorbed: false } unless gh_available?
150
-
151
- tip_sha = worktree_branch_tip_sha( branch: worktree.branch )
152
- return { action: :skip, reason: "cannot read branch tip SHA", absorbed: false } if tip_sha.nil?
153
-
154
- merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
155
- return { action: :reap, reason: "dirty worktree with merged #{pr_short_ref( merged_pr.fetch( :url ) )}", absorbed: false, force: true } unless merged_pr.nil?
156
-
157
- return { action: :skip, reason: "dirty worktree", absorbed: false }
158
- end
159
-
160
146
  absorbed = branch_absorbed_into_main?( branch: worktree.branch )
161
- return { action: :reap, reason: "content absorbed into main", absorbed: true } if absorbed
162
- return { action: :skip, reason: "gh CLI not available for PR check", absorbed: false } unless gh_available?
147
+ return { action: :skip, reason: "dirty worktree", absorbed: absorbed } if worktree.dirty?
148
+ return { action: :skip, reason: "gh CLI not available for PR check", absorbed: absorbed } unless gh_available?
163
149
 
164
150
  tip_sha = worktree_branch_tip_sha( branch: worktree.branch )
165
- return { action: :skip, reason: "cannot read branch tip SHA", absorbed: false } if tip_sha.nil?
151
+ return { action: :skip, reason: "cannot read branch tip SHA", absorbed: absorbed } if tip_sha.nil?
166
152
 
167
153
  merged_pr, = merged_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
168
- return { action: :reap, reason: "merged #{pr_short_ref( merged_pr.fetch( :url ) )}", absorbed: false } unless merged_pr.nil?
169
- return { action: :skip, reason: "open PR exists", absorbed: false } if branch_has_open_pr?( branch: worktree.branch )
154
+ return { action: :reap, reason: "merged #{pr_short_ref( merged_pr.fetch( :url ) )}", absorbed: absorbed } unless merged_pr.nil?
155
+ return { action: :skip, reason: "open PR exists", absorbed: absorbed } if branch_has_open_pr?( branch: worktree.branch )
170
156
 
171
157
  abandoned_pr, = abandoned_pr_for_branch( branch: worktree.branch, branch_tip_sha: tip_sha )
172
- return { action: :reap, reason: "closed abandoned #{pr_short_ref( abandoned_pr.fetch( :url ) )}", absorbed: false } unless abandoned_pr.nil?
158
+ return { action: :reap, reason: "closed abandoned #{pr_short_ref( abandoned_pr.fetch( :url ) )}", absorbed: absorbed } unless abandoned_pr.nil?
173
159
 
174
- { action: :skip, reason: "no evidence to reap", absorbed: false }
160
+ { action: :skip, reason: "no evidence to reap", absorbed: absorbed }
175
161
  end
176
162
 
177
163
  def worktree_branch_tip_sha( branch: )
@@ -1,5 +1,6 @@
1
1
  # Aggregates local repository operation modules (sync, prune, hooks, worktree, template).
2
2
  require_relative "local/sync"
3
+ require_relative "local/merge_proof"
3
4
  require_relative "local/prune"
4
5
  require_relative "local/template"
5
6
  require_relative "local/hooks"
@@ -0,0 +1,90 @@
1
+ # Shared loop runner for commands that poll on a schedule.
2
+ module Carson
3
+ class Runtime
4
+ module LoopRunner
5
+ LOOP_STOP_SIGNALS = %w[INT TERM].freeze
6
+ LOOP_SLEEP_SLICE_SECONDS = 0.25
7
+
8
+ private
9
+
10
+ def run_signal_aware_loop!( loop_name:, loop_seconds:, cycle_line:, sleep_line: nil )
11
+ cycle_count = 0
12
+ stop_requested = false
13
+ previous_handlers = install_loop_stop_handlers! do
14
+ stop_requested = true
15
+ end
16
+
17
+ loop do
18
+ break if stop_requested
19
+
20
+ cycle_count += 1
21
+ puts_line ""
22
+ puts_line cycle_line.call( cycle_count )
23
+ yield cycle_count
24
+ break if stop_requested
25
+
26
+ puts_line sleep_line.call( loop_seconds ) if sleep_line
27
+ loop_runner_wait( seconds: loop_seconds ) { stop_requested }
28
+ end
29
+
30
+ puts_line "#{loop_name} loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
31
+ EXIT_OK
32
+ rescue Interrupt
33
+ puts_line "#{loop_name} loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
34
+ EXIT_OK
35
+ rescue SignalException => exception
36
+ raise unless graceful_loop_signal?( exception )
37
+
38
+ puts_line "#{loop_name} loop stopped after #{cycle_count} cycle#{plural_suffix( count: cycle_count )}"
39
+ EXIT_OK
40
+ ensure
41
+ restore_loop_stop_handlers!( previous_handlers ) if previous_handlers
42
+ end
43
+
44
+ def install_loop_stop_handlers!
45
+ LOOP_STOP_SIGNALS.each_with_object( {} ) do |signal_name, handlers|
46
+ handlers[ signal_name ] = loop_runner_trap( signal_name ) { yield signal_name }
47
+ end
48
+ end
49
+
50
+ def restore_loop_stop_handlers!( previous_handlers )
51
+ previous_handlers.each do |signal_name, previous_handler|
52
+ loop_runner_trap( signal_name, previous_handler )
53
+ end
54
+ end
55
+
56
+ def graceful_loop_signal?( exception )
57
+ signo = exception.respond_to?( :signo ) ? exception.signo : nil
58
+ LOOP_STOP_SIGNALS.any? do |signal_name|
59
+ Signal.list.fetch( signal_name, nil ) == signo
60
+ end
61
+ end
62
+
63
+ def loop_runner_wait( seconds: )
64
+ deadline = loop_runner_monotonic_now + seconds.to_f
65
+ while ( remaining = deadline - loop_runner_monotonic_now ) > 0
66
+ break if block_given? && yield
67
+ loop_runner_sleep( [ remaining, LOOP_SLEEP_SLICE_SECONDS ].min )
68
+ end
69
+ end
70
+
71
+ def loop_runner_trap( signal_name, handler = nil, &block )
72
+ if handler
73
+ Signal.trap( signal_name, handler )
74
+ else
75
+ Signal.trap( signal_name, &block )
76
+ end
77
+ end
78
+
79
+ def loop_runner_monotonic_now
80
+ Process.clock_gettime( Process::CLOCK_MONOTONIC )
81
+ end
82
+
83
+ def loop_runner_sleep( seconds )
84
+ sleep seconds
85
+ end
86
+ end
87
+
88
+ include LoopRunner
89
+ end
90
+ end
@@ -54,6 +54,7 @@ module Carson
54
54
  def gather_status
55
55
  repository = repository_record
56
56
  branch = branch_record
57
+ tracked_delivery = status_branch_delivery( branch_name: branch.name )
57
58
  deliveries = ledger.active_deliveries( repo_path: repository.path )
58
59
  next_delivery_key = deliveries.find( &:ready? )&.key
59
60
 
@@ -69,7 +70,9 @@ module Carson
69
70
  worktree: branch.worktree,
70
71
  dirty: working_tree_dirty?,
71
72
  dirty_reason: dirty_worktree_reason,
72
- sync: remote_sync_status( branch: branch.name )
73
+ sync: remote_sync_status( branch: branch.name ),
74
+ pull_request: status_branch_pull_request( delivery: tracked_delivery ),
75
+ merge_proof: status_branch_merge_proof( branch_name: branch.name, delivery: tracked_delivery )
73
76
  },
74
77
  worktrees: gather_worktree_summary,
75
78
  branches: deliveries.map { |delivery| status_branch_entry( delivery: delivery, next_to_integrate: delivery.key == next_delivery_key ) },
@@ -150,6 +153,12 @@ module Carson
150
153
  puts_line branch_line
151
154
  worktree_summary = data.fetch( :worktrees )
152
155
  puts_line "Worktrees: #{worktree_summary.fetch( :non_main_count )} tracked outside main — run carson worktree list." if worktree_summary.fetch( :non_main_count ).positive?
156
+ if (pull_request = branch[ :pull_request ])
157
+ puts_line pull_request.fetch( :summary )
158
+ end
159
+ if branch.fetch( :name ) != config.main_branch && (merge_proof = branch[ :merge_proof ])
160
+ puts_line "Merge proof: #{merge_proof.fetch( :summary )}"
161
+ end
153
162
 
154
163
  deliveries = data.fetch( :branches )
155
164
  if deliveries.empty?
@@ -194,6 +203,30 @@ module Carson
194
203
  else "sync unknown"
195
204
  end
196
205
  end
206
+
207
+ def status_branch_delivery( branch_name: )
208
+ return nil if branch_name == config.main_branch
209
+
210
+ ledger.latest_delivery(
211
+ repo_path: repository_record.path,
212
+ branch_name: branch_name
213
+ )
214
+ end
215
+
216
+ def status_branch_pull_request( delivery: )
217
+ return nil unless delivery
218
+
219
+ pull_request_payload( delivery: delivery )
220
+ end
221
+
222
+ def status_branch_merge_proof( branch_name:, delivery: )
223
+ return merge_proof_payload( proof: merge_proof_for_branch( branch: branch_name ) ) if branch_name == config.main_branch
224
+ return nil unless delivery
225
+
226
+ return merge_proof_payload( proof: delivery.merge_proof ) if delivery.merge_proof
227
+
228
+ merge_proof_payload( proof: merge_proof_for_branch( branch: branch_name ) )
229
+ end
197
230
  end
198
231
 
199
232
  include Status
@@ -33,11 +33,17 @@ module Carson
33
33
  @config = Config.load( repo_root: repo_root )
34
34
  @git_adapter = Adapters::Git.new( repo_root: repo_root )
35
35
  @github_adapter = Adapters::GitHub.new( repo_root: repo_root )
36
- @ledger = Ledger.new( path: @config.govern_state_path )
37
36
  @template_sync_result = nil
38
37
  end
39
38
 
40
- attr_reader :template_sync_result, :ledger
39
+ attr_reader :template_sync_result
40
+
41
+ # Lazy ledger: only constructed when a command actually needs delivery state.
42
+ # Read-only commands (worktree list, audit, prune, sync) never touch the
43
+ # govern state lock file.
44
+ def ledger
45
+ @ledger ||= Ledger.new( path: @config.govern_state_path )
46
+ end
41
47
 
42
48
  private
43
49
 
@@ -364,6 +370,7 @@ end
364
370
 
365
371
  require_relative "runtime/local"
366
372
  require_relative "runtime/audit"
373
+ require_relative "runtime/loop_runner"
367
374
  require_relative "runtime/housekeep"
368
375
  require_relative "runtime/repos"
369
376
  require_relative "runtime/review"
@@ -7,18 +7,20 @@
7
7
  require "fileutils"
8
8
  require "json"
9
9
  require "open3"
10
+ require "pathname"
10
11
 
11
12
  module Carson
12
13
  class Worktree
13
14
  # Agent directory names whose worktrees Carson may sweep.
14
15
  AGENT_DIRS = %w[ .claude .codex ].freeze
15
16
 
16
- attr_reader :path, :branch
17
+ attr_reader :path, :branch, :prunable_reason
17
18
 
18
- def initialize( path:, branch:, runtime: nil )
19
+ def initialize( path:, branch:, runtime: nil, prunable_reason: nil )
19
20
  @path = path
20
21
  @branch = branch
21
22
  @runtime = runtime
23
+ @prunable_reason = prunable_reason
22
24
  end
23
25
 
24
26
  # --- Class lifecycle methods ---
@@ -30,21 +32,36 @@ module Carson
30
32
  entries = []
31
33
  current_path = nil
32
34
  current_branch = :unset
35
+ current_prunable_reason = nil
33
36
  raw.lines.each do |line|
34
37
  line = line.strip
35
38
  if line.empty?
36
- entries << new( path: current_path, branch: current_branch == :unset ? nil : current_branch, runtime: runtime ) if current_path
39
+ entries << new(
40
+ path: current_path,
41
+ branch: current_branch == :unset ? nil : current_branch,
42
+ runtime: runtime,
43
+ prunable_reason: current_prunable_reason
44
+ ) if current_path
37
45
  current_path = nil
38
46
  current_branch = :unset
47
+ current_prunable_reason = nil
39
48
  elsif line.start_with?( "worktree " )
40
49
  current_path = runtime.realpath_safe( line.sub( "worktree ", "" ) )
41
50
  elsif line.start_with?( "branch " )
42
51
  current_branch = line.sub( "branch refs/heads/", "" )
43
52
  elsif line == "detached"
44
53
  current_branch = nil
54
+ elsif line.start_with?( "prunable" )
55
+ reason = line.sub( "prunable", "" ).strip
56
+ current_prunable_reason = reason.empty? ? "prunable" : reason
45
57
  end
46
58
  end
47
- entries << new( path: current_path, branch: current_branch == :unset ? nil : current_branch, runtime: runtime ) if current_path
59
+ entries << new(
60
+ path: current_path,
61
+ branch: current_branch == :unset ? nil : current_branch,
62
+ runtime: runtime,
63
+ prunable_reason: current_prunable_reason
64
+ ) if current_path
48
65
  entries
49
66
  end
50
67
 
@@ -79,20 +96,31 @@ module Carson
79
96
  # Determine the base branch (main branch from config).
80
97
  base = runtime.config.main_branch
81
98
 
82
- # Sync main from remote before branching so the worktree starts
83
- # from the latest code. Prevents stale-base merge conflicts later.
84
- # Best-effort — if pull fails (non-ff, offline), continue anyway.
99
+ # Fetch to update the remote tracking ref without mutating the main worktree.
100
+ # Best-effort if fetch fails (no remote, offline), branch from local main.
85
101
  main_root = runtime.main_worktree_root
86
- _, _, pull_ok, = Open3.capture3( "git", "-C", main_root, "pull", "--ff-only", runtime.config.git_remote, base )
87
- runtime.puts_verbose pull_ok.success? ? "synced #{base} before branching" : "sync skipped continuing from local #{base}"
102
+ remote = runtime.config.git_remote
103
+ _, _, fetch_ok, = Open3.capture3( "git", "-C", main_root, "fetch", remote, base )
104
+ if fetch_ok.success?
105
+ remote_ref = "#{remote}/#{base}"
106
+ _, _, ref_ok, = Open3.capture3( "git", "-C", main_root, "rev-parse", "--verify", remote_ref )
107
+ if ref_ok.success?
108
+ base = remote_ref
109
+ runtime.puts_verbose( "branching from #{remote_ref}" ) unless json_output
110
+ else
111
+ runtime.puts_verbose( "fetch succeeded but #{remote_ref} not found — branching from local #{runtime.config.main_branch}" ) unless json_output
112
+ end
113
+ else
114
+ runtime.puts_verbose( "fetch skipped — branching from local #{runtime.config.main_branch}" ) unless json_output
115
+ end
88
116
 
89
117
  # Ensure .claude/ is excluded from git status in the host repository.
90
118
  # Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
91
119
  ensure_claude_dir_excluded!( runtime: runtime )
92
120
 
93
121
  # Create the worktree with a new branch based on the main branch.
94
- FileUtils.mkdir_p( worktrees_dir )
95
- _, worktree_stderr, worktree_success, = runtime.git_run( "worktree", "add", worktree_path, "-b", name, base )
122
+ FileUtils.mkdir_p( File.dirname( worktree_path ) )
123
+ worktree_stdout, worktree_stderr, worktree_success, = runtime.git_run( "worktree", "add", worktree_path, "-b", name, base )
96
124
  unless worktree_success
97
125
  error_text = worktree_stderr.to_s.strip
98
126
  error_text = "unable to create worktree" if error_text.empty?
@@ -104,10 +132,16 @@ module Carson
104
132
  end
105
133
 
106
134
  unless creation_verified?( path: worktree_path, branch: name, runtime: runtime )
135
+ diagnostics = gather_create_diagnostics(
136
+ git_stdout: worktree_stdout, git_stderr: worktree_stderr,
137
+ name: name, runtime: runtime
138
+ )
139
+ cleanup_partial_create!( path: worktree_path, branch: name, runtime: runtime )
107
140
  return finish(
108
141
  result: { command: "worktree create", status: "error", name: name, path: worktree_path, branch: name,
109
142
  error: "git reported success but Carson could not verify the worktree and branch",
110
- recovery: "git worktree list && git branch --list '#{name}'" },
143
+ recovery: "git worktree list --porcelain && git branch --list '#{name}'",
144
+ diagnostics: diagnostics },
111
145
  exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
112
146
  )
113
147
  end
@@ -121,7 +155,7 @@ module Carson
121
155
  # Removes a worktree: directory, git registration, and branch.
122
156
  # Never forces removal — if the worktree has uncommitted changes, refuses unless
123
157
  # the caller explicitly passes force: true via CLI --force flag.
124
- def self.remove!( path:, runtime:, force: false, json_output: false )
158
+ def self.remove!( path:, runtime:, force: false, skip_unpushed: false, json_output: false )
125
159
  fingerprint_status = runtime.block_if_outsider_fingerprints!
126
160
  unless fingerprint_status.nil?
127
161
  if json_output
@@ -135,7 +169,7 @@ module Carson
135
169
  return fingerprint_status
136
170
  end
137
171
 
138
- check = remove_check( path: path, runtime: runtime, force: force )
172
+ check = remove_check( path: path, runtime: runtime, force: force, skip_unpushed: skip_unpushed )
139
173
  unless check.fetch( :status ) == :ok
140
174
  return finish(
141
175
  result: { command: "worktree remove", status: check.fetch( :result_status ), name: File.basename( check.fetch( :resolved_path ) ),
@@ -213,7 +247,7 @@ module Carson
213
247
  # Preflight guard for worktree removal. Shared by `worktree remove` and
214
248
  # other runtime flows that need to know whether cleanup is safe before
215
249
  # mutating GitHub or branch state.
216
- def self.remove_check( path:, runtime:, force: false )
250
+ def self.remove_check( path:, runtime:, force: false, skip_unpushed: false )
217
251
  resolved_path = resolve_path( path: path, runtime: runtime )
218
252
 
219
253
  if !Dir.exist?( resolved_path ) && registered?( path: resolved_path, runtime: runtime )
@@ -273,7 +307,7 @@ module Carson
273
307
  }
274
308
  end
275
309
 
276
- unless force
310
+ unless force || skip_unpushed
277
311
  unpushed = branch_unpushed_issue( branch: branch, worktree_path: resolved_path, runtime: runtime )
278
312
  if unpushed
279
313
  return {
@@ -291,10 +325,9 @@ module Carson
291
325
  { status: :ok, resolved_path: resolved_path, branch: branch, missing: false }
292
326
  end
293
327
 
294
- # Removes agent-owned worktrees whose branch content is already on main.
295
- # Scans AGENT_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
296
- # under the main repo root. Safe: skips detached HEADs, the caller's CWD,
297
- # and dirty working trees (git worktree remove refuses without --force).
328
+ # Removes agent-owned worktrees that the shared cleanup classifier judges
329
+ # safe to reap. Scans AGENT_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
330
+ # under the main repo root.
298
331
  def self.sweep_stale!( runtime: )
299
332
  main_root = runtime.main_worktree_root
300
333
  worktrees = list( runtime: runtime )
@@ -308,11 +341,16 @@ module Carson
308
341
  worktrees.each do |worktree|
309
342
  next unless worktree.branch
310
343
  next unless agent_prefixes.any? { |prefix| worktree.path.start_with?( prefix ) }
311
- next if worktree.holds_cwd?
312
- next if worktree.held_by_other_process?
313
- next unless runtime.branch_absorbed_into_main?( branch: worktree.branch )
314
344
 
315
- # Remove the worktree (no --force: refuses if dirty working tree).
345
+ classification = runtime.send( :classify_worktree_cleanup, worktree: worktree )
346
+ next unless classification.fetch( :action ) == :reap
347
+
348
+ unless worktree.exists?
349
+ remove_missing!( resolved_path: worktree.path, runtime: runtime, json_output: false )
350
+ next
351
+ end
352
+
353
+ # Remove the worktree (no --force: automatic sweep never force-removes dirty worktrees).
316
354
  _, _, rm_success, = runtime.git_run( "worktree", "remove", worktree.path )
317
355
  next unless rm_success
318
356
 
@@ -369,6 +407,10 @@ module Carson
369
407
  Dir.exist?( path )
370
408
  end
371
409
 
410
+ def prunable?
411
+ !prunable_reason.to_s.strip.empty?
412
+ end
413
+
372
414
  def dirty?
373
415
  return false unless exists?
374
416
 
@@ -440,7 +482,12 @@ module Carson
440
482
  private_class_method :finish
441
483
 
442
484
  def self.creation_verified?( path:, branch:, runtime: )
443
- registered?( path: path, runtime: runtime ) && branch_exists?( branch: branch, runtime: runtime )
485
+ entry = find( path: path, runtime: runtime )
486
+ return false if entry.nil?
487
+ return false if entry.prunable?
488
+ return false unless Dir.exist?( path )
489
+
490
+ branch_exists?( branch: branch, runtime: runtime )
444
491
  end
445
492
  private_class_method :creation_verified?
446
493
 
@@ -450,6 +497,39 @@ module Carson
450
497
  end
451
498
  private_class_method :branch_exists?
452
499
 
500
+ # Removes partial state left behind when git worktree add reports success
501
+ # but verification reveals the worktree or branch is incomplete.
502
+ def self.cleanup_partial_create!( path:, branch:, runtime: )
503
+ FileUtils.rm_rf( path ) if Dir.exist?( path )
504
+ runtime.git_run( "worktree", "prune" )
505
+ runtime.git_run( "branch", "-D", branch ) if branch_exists?( branch: branch, runtime: runtime )
506
+ end
507
+ private_class_method :cleanup_partial_create!
508
+
509
+ # Captures diagnostic state for a verification failure so the next
510
+ # incident is self-diagnosing without manual investigation.
511
+ def self.gather_create_diagnostics( git_stdout:, git_stderr:, name:, runtime: )
512
+ wt_list, = runtime.git_run( "worktree", "list", "--porcelain" )
513
+ branch_list, = runtime.git_run( "branch", "--list", name )
514
+ git_version, = Open3.capture3( "git", "--version" )
515
+ worktree_path = File.join( runtime.main_worktree_root, ".claude", "worktrees", name )
516
+ entry = find( path: worktree_path, runtime: runtime )
517
+ {
518
+ git_stdout: git_stdout.to_s.strip,
519
+ git_stderr: git_stderr.to_s.strip,
520
+ repo_root: runtime.send( :repo_root ),
521
+ main_worktree_root: runtime.main_worktree_root,
522
+ worktree_list: wt_list.to_s.strip,
523
+ branch_list: branch_list.to_s.strip,
524
+ git_version: git_version.to_s.strip,
525
+ worktree_directory_exists: Dir.exist?( worktree_path ),
526
+ registered_worktree: !entry.nil?,
527
+ prunable_reason: entry&.prunable_reason
528
+ }
529
+ end
530
+ private_class_method :gather_create_diagnostics
531
+
532
+
453
533
  # Human-readable output for worktree results.
454
534
  def self.print_human( result:, runtime: )
455
535
  command = result[ :command ]
@@ -519,10 +599,18 @@ module Carson
519
599
  # succeed, even when the OS resolves symlinks differently.
520
600
  # Uses main_worktree_root (not repo_root) so resolution works from inside worktrees.
521
601
  def self.resolve_path( path:, runtime: )
522
- if path.include?( "/" )
602
+ if Pathname.new( path ).absolute?
523
603
  return runtime.realpath_safe( path )
524
604
  end
525
605
 
606
+ relative_candidate = runtime.realpath_safe( File.expand_path( path, Dir.pwd ) )
607
+ return relative_candidate if registered?( path: relative_candidate, runtime: runtime )
608
+
609
+ if path.include?( "/" )
610
+ scoped_candidate = runtime.realpath_safe( File.join( runtime.main_worktree_root, ".claude", "worktrees", path ) )
611
+ return scoped_candidate if registered?( path: scoped_candidate, runtime: runtime )
612
+ end
613
+
526
614
  root = runtime.main_worktree_root
527
615
  candidate = File.join( root, ".claude", "worktrees", path )
528
616
  canonical = runtime.realpath_safe( candidate )
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.27.1
4
+ version: 3.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -83,11 +83,13 @@ files:
83
83
  - lib/carson/runtime/housekeep.rb
84
84
  - lib/carson/runtime/local.rb
85
85
  - lib/carson/runtime/local/hooks.rb
86
+ - lib/carson/runtime/local/merge_proof.rb
86
87
  - lib/carson/runtime/local/onboard.rb
87
88
  - lib/carson/runtime/local/prune.rb
88
89
  - lib/carson/runtime/local/sync.rb
89
90
  - lib/carson/runtime/local/template.rb
90
91
  - lib/carson/runtime/local/worktree.rb
92
+ - lib/carson/runtime/loop_runner.rb
91
93
  - lib/carson/runtime/recover.rb
92
94
  - lib/carson/runtime/repos.rb
93
95
  - lib/carson/runtime/review.rb
@@ -126,7 +128,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
126
128
  - !ruby/object:Gem::Version
127
129
  version: '0'
128
130
  requirements: []
129
- rubygems_version: 4.0.3
131
+ rubygems_version: 3.6.9
130
132
  specification_version: 4
131
133
  summary: Autonomous git strategist and repositories governor — you write the code,
132
134
  Carson manages everything else.