carson 3.27.1 → 3.28.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.
@@ -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
@@ -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
 
@@ -84,15 +101,15 @@ module Carson
84
101
  # Best-effort — if pull fails (non-ff, offline), continue anyway.
85
102
  main_root = runtime.main_worktree_root
86
103
  _, _, 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}"
104
+ runtime.puts_verbose( pull_ok.success? ? "synced #{base} before branching" : "sync skipped — continuing from local #{base}" ) unless json_output
88
105
 
89
106
  # Ensure .claude/ is excluded from git status in the host repository.
90
107
  # Uses .git/info/exclude (local-only, never committed) to respect the outsider boundary.
91
108
  ensure_claude_dir_excluded!( runtime: runtime )
92
109
 
93
110
  # 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 )
111
+ FileUtils.mkdir_p( File.dirname( worktree_path ) )
112
+ worktree_stdout, worktree_stderr, worktree_success, = runtime.git_run( "worktree", "add", worktree_path, "-b", name, base )
96
113
  unless worktree_success
97
114
  error_text = worktree_stderr.to_s.strip
98
115
  error_text = "unable to create worktree" if error_text.empty?
@@ -104,10 +121,16 @@ module Carson
104
121
  end
105
122
 
106
123
  unless creation_verified?( path: worktree_path, branch: name, runtime: runtime )
124
+ diagnostics = gather_create_diagnostics(
125
+ git_stdout: worktree_stdout, git_stderr: worktree_stderr,
126
+ name: name, runtime: runtime
127
+ )
128
+ cleanup_partial_create!( path: worktree_path, branch: name, runtime: runtime )
107
129
  return finish(
108
130
  result: { command: "worktree create", status: "error", name: name, path: worktree_path, branch: name,
109
131
  error: "git reported success but Carson could not verify the worktree and branch",
110
- recovery: "git worktree list && git branch --list '#{name}'" },
132
+ recovery: "git worktree list --porcelain && git branch --list '#{name}'",
133
+ diagnostics: diagnostics },
111
134
  exit_code: Runtime::EXIT_ERROR, runtime: runtime, json_output: json_output
112
135
  )
113
136
  end
@@ -291,10 +314,9 @@ module Carson
291
314
  { status: :ok, resolved_path: resolved_path, branch: branch, missing: false }
292
315
  end
293
316
 
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).
317
+ # Removes agent-owned worktrees that the shared cleanup classifier judges
318
+ # safe to reap. Scans AGENT_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
319
+ # under the main repo root.
298
320
  def self.sweep_stale!( runtime: )
299
321
  main_root = runtime.main_worktree_root
300
322
  worktrees = list( runtime: runtime )
@@ -308,11 +330,16 @@ module Carson
308
330
  worktrees.each do |worktree|
309
331
  next unless worktree.branch
310
332
  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
333
 
315
- # Remove the worktree (no --force: refuses if dirty working tree).
334
+ classification = runtime.send( :classify_worktree_cleanup, worktree: worktree )
335
+ next unless classification.fetch( :action ) == :reap
336
+
337
+ unless worktree.exists?
338
+ remove_missing!( resolved_path: worktree.path, runtime: runtime, json_output: false )
339
+ next
340
+ end
341
+
342
+ # Remove the worktree (no --force: automatic sweep never force-removes dirty worktrees).
316
343
  _, _, rm_success, = runtime.git_run( "worktree", "remove", worktree.path )
317
344
  next unless rm_success
318
345
 
@@ -369,6 +396,10 @@ module Carson
369
396
  Dir.exist?( path )
370
397
  end
371
398
 
399
+ def prunable?
400
+ !prunable_reason.to_s.strip.empty?
401
+ end
402
+
372
403
  def dirty?
373
404
  return false unless exists?
374
405
 
@@ -440,7 +471,12 @@ module Carson
440
471
  private_class_method :finish
441
472
 
442
473
  def self.creation_verified?( path:, branch:, runtime: )
443
- registered?( path: path, runtime: runtime ) && branch_exists?( branch: branch, runtime: runtime )
474
+ entry = find( path: path, runtime: runtime )
475
+ return false if entry.nil?
476
+ return false if entry.prunable?
477
+ return false unless Dir.exist?( path )
478
+
479
+ branch_exists?( branch: branch, runtime: runtime )
444
480
  end
445
481
  private_class_method :creation_verified?
446
482
 
@@ -450,6 +486,39 @@ module Carson
450
486
  end
451
487
  private_class_method :branch_exists?
452
488
 
489
+ # Removes partial state left behind when git worktree add reports success
490
+ # but verification reveals the worktree or branch is incomplete.
491
+ def self.cleanup_partial_create!( path:, branch:, runtime: )
492
+ FileUtils.rm_rf( path ) if Dir.exist?( path )
493
+ runtime.git_run( "worktree", "prune" )
494
+ runtime.git_run( "branch", "-D", branch ) if branch_exists?( branch: branch, runtime: runtime )
495
+ end
496
+ private_class_method :cleanup_partial_create!
497
+
498
+ # Captures diagnostic state for a verification failure so the next
499
+ # incident is self-diagnosing without manual investigation.
500
+ def self.gather_create_diagnostics( git_stdout:, git_stderr:, name:, runtime: )
501
+ wt_list, = runtime.git_run( "worktree", "list", "--porcelain" )
502
+ branch_list, = runtime.git_run( "branch", "--list", name )
503
+ git_version, = Open3.capture3( "git", "--version" )
504
+ worktree_path = File.join( runtime.main_worktree_root, ".claude", "worktrees", name )
505
+ entry = find( path: worktree_path, runtime: runtime )
506
+ {
507
+ git_stdout: git_stdout.to_s.strip,
508
+ git_stderr: git_stderr.to_s.strip,
509
+ repo_root: runtime.send( :repo_root ),
510
+ main_worktree_root: runtime.main_worktree_root,
511
+ worktree_list: wt_list.to_s.strip,
512
+ branch_list: branch_list.to_s.strip,
513
+ git_version: git_version.to_s.strip,
514
+ worktree_directory_exists: Dir.exist?( worktree_path ),
515
+ registered_worktree: !entry.nil?,
516
+ prunable_reason: entry&.prunable_reason
517
+ }
518
+ end
519
+ private_class_method :gather_create_diagnostics
520
+
521
+
453
522
  # Human-readable output for worktree results.
454
523
  def self.print_human( result:, runtime: )
455
524
  command = result[ :command ]
@@ -519,10 +588,18 @@ module Carson
519
588
  # succeed, even when the OS resolves symlinks differently.
520
589
  # Uses main_worktree_root (not repo_root) so resolution works from inside worktrees.
521
590
  def self.resolve_path( path:, runtime: )
522
- if path.include?( "/" )
591
+ if Pathname.new( path ).absolute?
523
592
  return runtime.realpath_safe( path )
524
593
  end
525
594
 
595
+ relative_candidate = runtime.realpath_safe( File.expand_path( path, Dir.pwd ) )
596
+ return relative_candidate if registered?( path: relative_candidate, runtime: runtime )
597
+
598
+ if path.include?( "/" )
599
+ scoped_candidate = runtime.realpath_safe( File.join( runtime.main_worktree_root, ".claude", "worktrees", path ) )
600
+ return scoped_candidate if registered?( path: scoped_candidate, runtime: runtime )
601
+ end
602
+
526
603
  root = runtime.main_worktree_root
527
604
  candidate = File.join( root, ".claude", "worktrees", path )
528
605
  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.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -83,6 +83,7 @@ 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
@@ -126,7 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
126
127
  - !ruby/object:Gem::Version
127
128
  version: '0'
128
129
  requirements: []
129
- rubygems_version: 4.0.3
130
+ rubygems_version: 3.6.9
130
131
  specification_version: 4
131
132
  summary: Autonomous git strategist and repositories governor — you write the code,
132
133
  Carson manages everything else.