carson 3.27.0 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/carson_policy.yml +1 -1
- data/API.md +34 -7
- data/MANUAL.md +10 -9
- data/README.md +15 -8
- data/RELEASE.md +27 -1
- data/VERSION +1 -1
- data/carson.gemspec +1 -0
- data/lib/carson/delivery.rb +9 -2
- data/lib/carson/ledger.rb +318 -34
- data/lib/carson/runtime/deliver.rb +779 -85
- data/lib/carson/runtime/govern.rb +118 -66
- data/lib/carson/runtime/local/merge_proof.rb +199 -0
- data/lib/carson/runtime/local/sync.rb +89 -0
- data/lib/carson/runtime/local/worktree.rb +7 -21
- data/lib/carson/runtime/local.rb +1 -0
- data/lib/carson/runtime/status.rb +34 -1
- data/lib/carson/worktree.rb +95 -18
- metadata +24 -3
|
@@ -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
|
data/lib/carson/worktree.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
95
|
-
|
|
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
|
|
295
|
-
# Scans AGENT_DIRS (e.g. .claude/worktrees/, .codex/worktrees/)
|
|
296
|
-
# under the main repo root.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
4
|
+
version: 3.28.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Hailei Wang
|
|
@@ -10,7 +10,27 @@ authors:
|
|
|
10
10
|
bindir: exe
|
|
11
11
|
cert_chain: []
|
|
12
12
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
13
|
-
dependencies:
|
|
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'
|
|
14
34
|
description: 'Carson is an autonomous git strategist and repositories governor that
|
|
15
35
|
lives outside the repositories it governs — no Carson-owned artefacts in your repo.
|
|
16
36
|
As strategist, Carson knows when to branch, how to isolate concurrent work, and
|
|
@@ -63,6 +83,7 @@ files:
|
|
|
63
83
|
- lib/carson/runtime/housekeep.rb
|
|
64
84
|
- lib/carson/runtime/local.rb
|
|
65
85
|
- lib/carson/runtime/local/hooks.rb
|
|
86
|
+
- lib/carson/runtime/local/merge_proof.rb
|
|
66
87
|
- lib/carson/runtime/local/onboard.rb
|
|
67
88
|
- lib/carson/runtime/local/prune.rb
|
|
68
89
|
- lib/carson/runtime/local/sync.rb
|
|
@@ -106,7 +127,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
106
127
|
- !ruby/object:Gem::Version
|
|
107
128
|
version: '0'
|
|
108
129
|
requirements: []
|
|
109
|
-
rubygems_version:
|
|
130
|
+
rubygems_version: 3.6.9
|
|
110
131
|
specification_version: 4
|
|
111
132
|
summary: Autonomous git strategist and repositories governor — you write the code,
|
|
112
133
|
Carson manages everything else.
|