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.
- checksums.yaml +4 -4
- data/.github/workflows/carson_policy.yml +3 -3
- data/API.md +35 -8
- data/MANUAL.md +17 -14
- data/README.md +15 -8
- data/RELEASE.md +25 -0
- data/VERSION +1 -1
- data/lib/carson/delivery.rb +9 -2
- data/lib/carson/ledger.rb +72 -1
- data/lib/carson/runtime/abandon.rb +12 -9
- data/lib/carson/runtime/deliver.rb +779 -85
- data/lib/carson/runtime/govern.rb +163 -75
- data/lib/carson/runtime/housekeep.rb +5 -9
- data/lib/carson/runtime/local/merge_proof.rb +217 -0
- data/lib/carson/runtime/local/sync.rb +89 -0
- data/lib/carson/runtime/local/worktree.rb +9 -23
- data/lib/carson/runtime/local.rb +1 -0
- data/lib/carson/runtime/loop_runner.rb +90 -0
- data/lib/carson/runtime/status.rb +34 -1
- data/lib/carson/runtime.rb +9 -2
- data/lib/carson/worktree.rb +114 -26
- metadata +4 -2
|
@@ -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: :
|
|
162
|
-
return { action: :skip, reason: "gh CLI not available for PR check", absorbed:
|
|
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:
|
|
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:
|
|
169
|
-
return { action: :skip, reason: "open PR exists", absorbed:
|
|
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:
|
|
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:
|
|
160
|
+
{ action: :skip, reason: "no evidence to reap", absorbed: absorbed }
|
|
175
161
|
end
|
|
176
162
|
|
|
177
163
|
def worktree_branch_tip_sha( branch: )
|
data/lib/carson/runtime/local.rb
CHANGED
|
@@ -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
|
data/lib/carson/runtime.rb
CHANGED
|
@@ -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
|
|
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"
|
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
|
|
|
@@ -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
|
-
#
|
|
83
|
-
#
|
|
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
|
-
|
|
87
|
-
|
|
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(
|
|
95
|
-
|
|
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
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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.
|