carson 4.1.1 → 4.2.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/API.md +1 -1
- data/RELEASE.md +25 -0
- data/VERSION +1 -1
- data/carson.gemspec +0 -1
- data/lib/carson/ledger.rb +0 -122
- data/lib/carson/runtime/abandon.rb +6 -5
- data/lib/carson/runtime/local/hooks.rb +1 -1
- data/lib/carson/runtime/local/worktree.rb +81 -7
- data/lib/carson/runtime/recover.rb +2 -2
- data/lib/carson/warehouse/bureau.rb +117 -0
- data/lib/carson/warehouse/seal.rb +55 -0
- data/lib/carson/warehouse/workbench.rb +516 -0
- data/lib/carson/warehouse.rb +28 -180
- data/lib/carson/worktree.rb +10 -0
- data/lib/carson.rb +1 -1
- data/lib/{carson/cli.rb → cli.rb} +147 -2
- metadata +11 -28
- /data/config/{.github/hooks → hooks}/command-guard +0 -0
- /data/config/{.github/hooks → hooks}/pre-commit +0 -0
- /data/config/{.github/hooks → hooks}/pre-merge-commit +0 -0
- /data/config/{.github/hooks → hooks}/pre-push +0 -0
- /data/config/{.github/hooks → hooks}/prepare-commit-msg +0 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
# The warehouse's workbench concern.
|
|
2
|
+
# Builds, removes, sweeps, and inventories workbenches.
|
|
3
|
+
# Workbenches are passive objects — the warehouse acts on them.
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "pathname"
|
|
7
|
+
|
|
8
|
+
module Carson
|
|
9
|
+
class Warehouse
|
|
10
|
+
module Workbench
|
|
11
|
+
|
|
12
|
+
# Agent directory names whose workbenches the warehouse may sweep.
|
|
13
|
+
AGENT_DIRS = %w[ .claude .codex ].freeze
|
|
14
|
+
|
|
15
|
+
# --- Inventory ---
|
|
16
|
+
|
|
17
|
+
# All workbenches in this warehouse.
|
|
18
|
+
# Parses the git worktree registry into Worktree instances.
|
|
19
|
+
# Normalises paths with realpath so comparisons work across symlinks.
|
|
20
|
+
def workbenches
|
|
21
|
+
raw, = git( "worktree", "list", "--porcelain" )
|
|
22
|
+
entries = []
|
|
23
|
+
current_path = nil
|
|
24
|
+
current_branch = :unset
|
|
25
|
+
current_prunable_reason = nil
|
|
26
|
+
|
|
27
|
+
raw.lines.each do |line|
|
|
28
|
+
line = line.strip
|
|
29
|
+
if line.empty?
|
|
30
|
+
entries << Carson::Worktree.new(
|
|
31
|
+
path: current_path,
|
|
32
|
+
branch: current_branch == :unset ? nil : current_branch,
|
|
33
|
+
prunable_reason: current_prunable_reason
|
|
34
|
+
) if current_path
|
|
35
|
+
current_path = nil
|
|
36
|
+
current_branch = :unset
|
|
37
|
+
current_prunable_reason = nil
|
|
38
|
+
elsif line.start_with?( "worktree " )
|
|
39
|
+
current_path = realpath_safe( line.sub( "worktree ", "" ) )
|
|
40
|
+
elsif line.start_with?( "branch " )
|
|
41
|
+
current_branch = line.sub( "branch refs/heads/", "" )
|
|
42
|
+
elsif line == "detached"
|
|
43
|
+
current_branch = nil
|
|
44
|
+
elsif line.start_with?( "prunable" )
|
|
45
|
+
reason = line.sub( "prunable", "" ).strip
|
|
46
|
+
current_prunable_reason = reason.empty? ? "prunable" : reason
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Handle the last entry (porcelain output may not end with a blank line).
|
|
51
|
+
entries << Carson::Worktree.new(
|
|
52
|
+
path: current_path,
|
|
53
|
+
branch: current_branch == :unset ? nil : current_branch,
|
|
54
|
+
prunable_reason: current_prunable_reason
|
|
55
|
+
) if current_path
|
|
56
|
+
|
|
57
|
+
entries
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Find a workbench by canonical path.
|
|
61
|
+
def workbench_at( path: )
|
|
62
|
+
canonical = realpath_safe( path )
|
|
63
|
+
workbenches.find { |wb| wb.path == canonical }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resolve a bare name and find the workbench.
|
|
67
|
+
# Tries .claude/worktrees/<name> first, then searches all registered
|
|
68
|
+
# workbenches by directory name.
|
|
69
|
+
def workbench_named( name )
|
|
70
|
+
if Pathname.new( name ).absolute?
|
|
71
|
+
return workbench_at( path: name )
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Try as a relative path from CWD.
|
|
75
|
+
relative_candidate = realpath_safe( File.expand_path( name, Dir.pwd ) )
|
|
76
|
+
found = workbench_at( path: relative_candidate )
|
|
77
|
+
return found if found
|
|
78
|
+
|
|
79
|
+
# Try scoped path (e.g. "claude/foo" → .claude/worktrees/claude/foo).
|
|
80
|
+
if name.include?( "/" )
|
|
81
|
+
scoped_candidate = realpath_safe( File.join( main_worktree_root, ".claude", "worktrees", name ) )
|
|
82
|
+
found = workbench_at( path: scoped_candidate )
|
|
83
|
+
return found if found
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Try flat layout: .claude/worktrees/<name>.
|
|
87
|
+
root = main_worktree_root
|
|
88
|
+
candidate = realpath_safe( File.join( root, ".claude", "worktrees", name ) )
|
|
89
|
+
found = workbench_at( path: candidate )
|
|
90
|
+
return found if found
|
|
91
|
+
|
|
92
|
+
# Search all registered workbenches by dirname.
|
|
93
|
+
matches = workbenches.select { |wb| File.basename( wb.path ) == name }
|
|
94
|
+
return matches.first if matches.size == 1
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Is this path a registered workbench?
|
|
100
|
+
def workbench_registered?( path: )
|
|
101
|
+
canonical = realpath_safe( path )
|
|
102
|
+
workbenches.any? { |wb| wb.path == canonical }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- Lifecycle ---
|
|
106
|
+
|
|
107
|
+
# Build a new workbench from the latest production standard.
|
|
108
|
+
# Creates the directory, branches from the latest standard,
|
|
109
|
+
# ensures .claude/ is excluded from git status.
|
|
110
|
+
def build_workbench!( name: )
|
|
111
|
+
root = main_worktree_root
|
|
112
|
+
worktrees_dir = File.join( root, ".claude", "worktrees" )
|
|
113
|
+
workbench_path = File.join( worktrees_dir, name )
|
|
114
|
+
|
|
115
|
+
if Dir.exist?( workbench_path )
|
|
116
|
+
return { command: "worktree create", status: "error", name: name,
|
|
117
|
+
path: workbench_path,
|
|
118
|
+
error: "worktree already exists: #{name}",
|
|
119
|
+
recovery: "carson worktree remove #{name}, then retry" }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Determine the base branch.
|
|
123
|
+
base = @main_label
|
|
124
|
+
|
|
125
|
+
# Fetch to update remote tracking ref without mutating the main worktree.
|
|
126
|
+
_, _, fetch_ok = git( "fetch", @bureau_address, base )
|
|
127
|
+
if fetch_ok.success?
|
|
128
|
+
remote_ref = "#{@bureau_address}/#{base}"
|
|
129
|
+
_, _, ref_ok = git( "rev-parse", "--verify", remote_ref )
|
|
130
|
+
base = remote_ref if ref_ok.success?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Ensure .claude/ is excluded from git status.
|
|
134
|
+
ensure_claude_dir_excluded!
|
|
135
|
+
|
|
136
|
+
# Create the worktree with a new branch.
|
|
137
|
+
FileUtils.mkdir_p( File.dirname( workbench_path ) )
|
|
138
|
+
wt_stdout, wt_stderr, wt_status = git( "worktree", "add", workbench_path, "-b", name, base )
|
|
139
|
+
unless wt_status.success?
|
|
140
|
+
error_text = wt_stderr.to_s.strip
|
|
141
|
+
error_text = "unable to create worktree" if error_text.empty?
|
|
142
|
+
return { command: "worktree create", status: "error", name: name,
|
|
143
|
+
error: error_text }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Verify the build succeeded.
|
|
147
|
+
unless workbench_creation_verified?( path: workbench_path, branch: name )
|
|
148
|
+
diagnostics = gather_build_diagnostics(
|
|
149
|
+
git_stdout: wt_stdout, git_stderr: wt_stderr, name: name )
|
|
150
|
+
cleanup_partial_build!( path: workbench_path, branch: name )
|
|
151
|
+
return { command: "worktree create", status: "error", name: name,
|
|
152
|
+
path: workbench_path, branch: name,
|
|
153
|
+
error: "git reported success but Carson could not verify the worktree and branch",
|
|
154
|
+
recovery: "git worktree list --porcelain && git branch --list '#{name}'",
|
|
155
|
+
diagnostics: diagnostics }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
{ command: "worktree create", status: "ok", name: name,
|
|
159
|
+
path: workbench_path, branch: name }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Agent checks in — prepare a fresh workbench from the latest standard.
|
|
163
|
+
# Sweeps delivered workbenches first — the Warehouse cleans behind the agent.
|
|
164
|
+
def checkin!( name: )
|
|
165
|
+
receive_latest_standard!
|
|
166
|
+
sweep_delivered_workbenches!
|
|
167
|
+
result = build_workbench!( name: name )
|
|
168
|
+
result[ :command ] = "checkin"
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Agent checks out — release the workbench when safe.
|
|
173
|
+
# A sealed workbench has a parcel in flight at the Bureau.
|
|
174
|
+
def checkout!( workbench, force: false )
|
|
175
|
+
unless force
|
|
176
|
+
seal_check = Warehouse.new( path: workbench.path )
|
|
177
|
+
if seal_check.sealed?
|
|
178
|
+
tracking = seal_check.sealed_tracking_number || "unknown"
|
|
179
|
+
return { command: "checkout", status: "block",
|
|
180
|
+
name: File.basename( workbench.path ), branch: workbench.branch,
|
|
181
|
+
error: "workbench is sealed — PR ##{tracking} is still in flight",
|
|
182
|
+
recovery: "wait for CI checks to complete, or run carson deliver to check status" }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
result = remove_workbench!( workbench, force: force )
|
|
187
|
+
result[ :command ] = "checkout"
|
|
188
|
+
result
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Remove a workbench — directory, registration, local branch.
|
|
192
|
+
# The warehouse checks safety before acting.
|
|
193
|
+
# Remote branch cleanup is GitHub's concern, not the warehouse's.
|
|
194
|
+
def remove_workbench!( workbench, force: false, skip_unpushed: false )
|
|
195
|
+
# If the directory is already gone, repair the stale registration.
|
|
196
|
+
unless workbench.exists?
|
|
197
|
+
return repair_missing_workbench!( workbench )
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Safety assessment.
|
|
201
|
+
assessment = assess_removal( workbench, force: force, skip_unpushed: skip_unpushed )
|
|
202
|
+
unless assessment[ :status ] == :ok
|
|
203
|
+
return { command: "worktree remove", status: assessment[ :result_status ] || "error",
|
|
204
|
+
name: File.basename( workbench.path ), branch: workbench.branch,
|
|
205
|
+
error: assessment[ :error ], recovery: assessment[ :recovery ] }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Step 1: remove the worktree (directory + git registration).
|
|
209
|
+
rm_args = [ "worktree", "remove" ]
|
|
210
|
+
rm_args << "--force" if force
|
|
211
|
+
rm_args << workbench.path
|
|
212
|
+
_, rm_stderr, rm_status = git( *rm_args )
|
|
213
|
+
unless rm_status.success?
|
|
214
|
+
error_text = rm_stderr.to_s.strip
|
|
215
|
+
error_text = "unable to remove worktree" if error_text.empty?
|
|
216
|
+
if !force && ( error_text.downcase.include?( "untracked" ) || error_text.downcase.include?( "modified" ) )
|
|
217
|
+
return { command: "worktree remove", status: "error",
|
|
218
|
+
name: File.basename( workbench.path ),
|
|
219
|
+
error: "worktree has uncommitted changes",
|
|
220
|
+
recovery: "commit or discard changes first, or use --force to override" }
|
|
221
|
+
end
|
|
222
|
+
return { command: "worktree remove", status: "error",
|
|
223
|
+
name: File.basename( workbench.path ), error: error_text }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Step 2: delete the local branch.
|
|
227
|
+
branch = workbench.branch
|
|
228
|
+
branch_deleted = false
|
|
229
|
+
if branch
|
|
230
|
+
_, _, del_ok = git( "branch", "-D", branch )
|
|
231
|
+
branch_deleted = del_ok.success?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
{ command: "worktree remove", status: "ok",
|
|
235
|
+
name: File.basename( workbench.path ),
|
|
236
|
+
branch: branch, branch_deleted: branch_deleted }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Full safety assessment before removal.
|
|
240
|
+
# Returns { status: :ok } or { status: :block/:error, error:, recovery: }.
|
|
241
|
+
def assess_removal( workbench, force: false, skip_unpushed: false )
|
|
242
|
+
unless workbench.exists?
|
|
243
|
+
return { status: :ok, missing: true }
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
if agent_at_workbench?( workbench )
|
|
247
|
+
return { status: :block, result_status: "block",
|
|
248
|
+
error: "current working directory is inside this worktree",
|
|
249
|
+
recovery: "cd #{main_worktree_root} && carson worktree remove #{File.basename( workbench.path )}" }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
if workbench_held_by_process?( workbench )
|
|
253
|
+
return { status: :block, result_status: "block",
|
|
254
|
+
error: "another process has its working directory inside this worktree",
|
|
255
|
+
recovery: "wait for the other session to finish, then retry" }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if !force && !workbench.clean?
|
|
259
|
+
return { status: :error, result_status: "error",
|
|
260
|
+
error: "worktree has uncommitted changes",
|
|
261
|
+
recovery: "commit or discard changes first, or use --force to override" }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
unless force || skip_unpushed
|
|
265
|
+
unpushed = workbench_has_unpushed_work?( workbench )
|
|
266
|
+
return unpushed if unpushed
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
{ status: :ok, missing: false }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Sweep stale workbenches. Walk all agent-owned workbenches,
|
|
273
|
+
# check state, tear down those safe to reap. Repair missing ones.
|
|
274
|
+
def sweep_workbenches!
|
|
275
|
+
root = main_worktree_root
|
|
276
|
+
|
|
277
|
+
agent_prefixes = AGENT_DIRS.map do |dir|
|
|
278
|
+
full = File.join( root, dir, "worktrees" )
|
|
279
|
+
File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
|
|
280
|
+
end.compact
|
|
281
|
+
return if agent_prefixes.empty?
|
|
282
|
+
|
|
283
|
+
workbenches.each do |workbench|
|
|
284
|
+
next unless workbench.branch
|
|
285
|
+
next unless agent_prefixes.any? { |prefix| workbench.path.start_with?( prefix ) }
|
|
286
|
+
|
|
287
|
+
# Use the existing classifier if available (transitional).
|
|
288
|
+
if respond_to?( :classify_worktree_cleanup, true )
|
|
289
|
+
classification = classify_worktree_cleanup( worktree: workbench )
|
|
290
|
+
next unless classification.fetch( :action ) == :reap
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
unless workbench.exists?
|
|
294
|
+
repair_missing_workbench!( workbench )
|
|
295
|
+
next
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
_, _, rm_ok = git( "worktree", "remove", workbench.path )
|
|
299
|
+
next unless rm_ok.success?
|
|
300
|
+
|
|
301
|
+
if workbench.branch
|
|
302
|
+
git( "branch", "-D", workbench.branch )
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
# --- Sweep ---
|
|
310
|
+
|
|
311
|
+
# Sweep delivered workbenches — branches absorbed into main, not sealed,
|
|
312
|
+
# not CWD-blocked. Called by checkin! so the Warehouse cleans behind the agent.
|
|
313
|
+
def sweep_delivered_workbenches!
|
|
314
|
+
root = main_worktree_root
|
|
315
|
+
|
|
316
|
+
agent_prefixes = AGENT_DIRS.map do |dir|
|
|
317
|
+
full = File.join( root, dir, "worktrees" )
|
|
318
|
+
File.join( realpath_safe( full ), "" ) if Dir.exist?( full )
|
|
319
|
+
end.compact
|
|
320
|
+
return if agent_prefixes.empty?
|
|
321
|
+
|
|
322
|
+
workbenches.each do |workbench|
|
|
323
|
+
next unless workbench.branch
|
|
324
|
+
next unless agent_prefixes.any? { |prefix| workbench.path.start_with?( prefix ) }
|
|
325
|
+
next unless workbench.exists?
|
|
326
|
+
next unless label_absorbed?( workbench.branch )
|
|
327
|
+
next if agent_at_workbench?( workbench )
|
|
328
|
+
next if workbench_held_by_process?( workbench )
|
|
329
|
+
|
|
330
|
+
# Do not sweep sealed workbenches — parcel still in flight.
|
|
331
|
+
seal_check = Warehouse.new( path: workbench.path )
|
|
332
|
+
next if seal_check.sealed?
|
|
333
|
+
|
|
334
|
+
_, _, rm_ok = git( "worktree", "remove", workbench.path )
|
|
335
|
+
next unless rm_ok.success?
|
|
336
|
+
|
|
337
|
+
git( "branch", "-D", workbench.branch ) if workbench.branch
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# --- Safety checks ---
|
|
342
|
+
|
|
343
|
+
# Is the agent's working directory inside this workbench?
|
|
344
|
+
def agent_at_workbench?( workbench )
|
|
345
|
+
cwd = realpath_safe( Dir.pwd )
|
|
346
|
+
workbench_path = realpath_safe( workbench.path )
|
|
347
|
+
normalised = File.join( workbench_path, "" )
|
|
348
|
+
cwd == workbench_path || cwd.start_with?( normalised )
|
|
349
|
+
rescue StandardError
|
|
350
|
+
false
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Is another process occupying this workbench?
|
|
354
|
+
def workbench_held_by_process?( workbench )
|
|
355
|
+
canonical = realpath_safe( workbench.path )
|
|
356
|
+
return false if canonical.nil? || canonical.empty?
|
|
357
|
+
return false unless Dir.exist?( canonical )
|
|
358
|
+
|
|
359
|
+
stdout, = Open3.capture3( "lsof", "-d", "cwd" )
|
|
360
|
+
return false if stdout.nil? || stdout.empty?
|
|
361
|
+
|
|
362
|
+
normalised = File.join( canonical, "" )
|
|
363
|
+
my_pid = Process.pid
|
|
364
|
+
stdout.lines.drop( 1 ).any? do |line|
|
|
365
|
+
fields = line.strip.split( /\s+/ )
|
|
366
|
+
next false unless fields.length >= 9
|
|
367
|
+
next false if fields[ 1 ].to_i == my_pid
|
|
368
|
+
name = fields[ 8.. ].join( " " )
|
|
369
|
+
name == canonical || name.start_with?( normalised )
|
|
370
|
+
end
|
|
371
|
+
rescue Errno::ENOENT
|
|
372
|
+
false
|
|
373
|
+
rescue StandardError
|
|
374
|
+
false
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Would tearing down lose unpushed work?
|
|
378
|
+
# Content-aware: compares tree content vs main, not SHAs.
|
|
379
|
+
# Returns nil if safe, or { status:, error:, recovery: } if blocked.
|
|
380
|
+
def workbench_has_unpushed_work?( workbench )
|
|
381
|
+
branch = workbench.branch
|
|
382
|
+
return nil unless branch
|
|
383
|
+
|
|
384
|
+
remote_ref = "#{@bureau_address}/#{branch}"
|
|
385
|
+
ahead, _, ahead_status = Open3.capture3(
|
|
386
|
+
"git", "rev-list", "--count", "#{remote_ref}..#{branch}",
|
|
387
|
+
chdir: workbench.path )
|
|
388
|
+
|
|
389
|
+
if !ahead_status.success?
|
|
390
|
+
# Remote ref missing. Only block if branch has unique commits vs main.
|
|
391
|
+
unique, _, unique_status = Open3.capture3(
|
|
392
|
+
"git", "rev-list", "--count", "#{@main_label}..#{branch}",
|
|
393
|
+
chdir: workbench.path )
|
|
394
|
+
if unique_status.success? && unique.strip.to_i > 0
|
|
395
|
+
# Content-aware: if diff is empty, work is on main (squash/rebase merged).
|
|
396
|
+
_, _, diff_ok = Open3.capture3(
|
|
397
|
+
"git", "diff", "--quiet", @main_label, branch,
|
|
398
|
+
chdir: workbench.path )
|
|
399
|
+
unless diff_ok.success?
|
|
400
|
+
return { status: :block, result_status: "block",
|
|
401
|
+
error: "branch has not been pushed to #{@bureau_address}",
|
|
402
|
+
recovery: "git -C #{workbench.path} push -u #{@bureau_address} #{branch}, or use --force to override" }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
elsif ahead.strip.to_i > 0
|
|
406
|
+
return { status: :block, result_status: "block",
|
|
407
|
+
error: "worktree has unpushed commits",
|
|
408
|
+
recovery: "git -C #{workbench.path} push #{@bureau_address} #{branch}, or use --force to override" }
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# --- Repair ---
|
|
415
|
+
|
|
416
|
+
# Handle a missing workbench — prune stale registration, clean up label.
|
|
417
|
+
def repair_missing_workbench!( workbench )
|
|
418
|
+
branch = workbench.branch
|
|
419
|
+
git( "worktree", "prune" )
|
|
420
|
+
|
|
421
|
+
branch_deleted = false
|
|
422
|
+
if branch
|
|
423
|
+
_, _, del_ok = git( "branch", "-D", branch )
|
|
424
|
+
branch_deleted = del_ok.success?
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
{ command: "worktree remove", status: "ok",
|
|
428
|
+
name: File.basename( workbench.path ),
|
|
429
|
+
branch: branch, branch_deleted: branch_deleted }
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# --- Build helpers ---
|
|
433
|
+
|
|
434
|
+
# Verify the workbench was created correctly.
|
|
435
|
+
def workbench_creation_verified?( path:, branch: )
|
|
436
|
+
entry = workbench_at( path: path )
|
|
437
|
+
return false if entry.nil?
|
|
438
|
+
return false if entry.prunable?
|
|
439
|
+
return false unless Dir.exist?( path )
|
|
440
|
+
|
|
441
|
+
_, _, success = git( "show-ref", "--verify", "--quiet", "refs/heads/#{branch}" )
|
|
442
|
+
success.success?
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Clean up partial state from a failed build.
|
|
446
|
+
def cleanup_partial_build!( path:, branch: )
|
|
447
|
+
FileUtils.rm_rf( path ) if Dir.exist?( path )
|
|
448
|
+
git( "worktree", "prune" )
|
|
449
|
+
_, _, ref_ok = git( "show-ref", "--verify", "--quiet", "refs/heads/#{branch}" )
|
|
450
|
+
git( "branch", "-D", branch ) if ref_ok.success?
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Capture diagnostic state for a build verification failure.
|
|
454
|
+
def gather_build_diagnostics( git_stdout:, git_stderr:, name: )
|
|
455
|
+
root = main_worktree_root
|
|
456
|
+
wt_list, = git( "worktree", "list", "--porcelain" )
|
|
457
|
+
branch_list, = git( "branch", "--list", name )
|
|
458
|
+
git_version, = Open3.capture3( "git", "--version" )
|
|
459
|
+
workbench_path = File.join( root, ".claude", "worktrees", name )
|
|
460
|
+
entry = workbench_at( path: workbench_path )
|
|
461
|
+
{
|
|
462
|
+
git_stdout: git_stdout.to_s.strip,
|
|
463
|
+
git_stderr: git_stderr.to_s.strip,
|
|
464
|
+
main_worktree_root: root,
|
|
465
|
+
worktree_list: wt_list.to_s.strip,
|
|
466
|
+
branch_list: branch_list.to_s.strip,
|
|
467
|
+
git_version: git_version.to_s.strip,
|
|
468
|
+
worktree_directory_exists: Dir.exist?( workbench_path ),
|
|
469
|
+
registered_worktree: !entry.nil?,
|
|
470
|
+
prunable_reason: entry&.prunable_reason
|
|
471
|
+
}
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Ensure .claude/ is in .git/info/exclude.
|
|
475
|
+
def ensure_claude_dir_excluded!
|
|
476
|
+
git_dir = File.join( main_worktree_root, ".git" )
|
|
477
|
+
return unless File.directory?( git_dir )
|
|
478
|
+
|
|
479
|
+
info_dir = File.join( git_dir, "info" )
|
|
480
|
+
exclude_path = File.join( info_dir, "exclude" )
|
|
481
|
+
|
|
482
|
+
FileUtils.mkdir_p( info_dir )
|
|
483
|
+
existing = File.exist?( exclude_path ) ? File.read( exclude_path ) : ""
|
|
484
|
+
return if existing.lines.any? { |line| line.strip == ".claude/" }
|
|
485
|
+
|
|
486
|
+
File.open( exclude_path, "a" ) { |file| file.puts ".claude/" }
|
|
487
|
+
rescue StandardError
|
|
488
|
+
# Best-effort — do not block workbench creation.
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Resolve a path to its canonical form, tolerating non-existent paths.
|
|
492
|
+
def realpath_safe( a_path )
|
|
493
|
+
File.realpath( a_path )
|
|
494
|
+
rescue Errno::ENOENT
|
|
495
|
+
expanded = File.expand_path( a_path )
|
|
496
|
+
missing_segments = []
|
|
497
|
+
candidate = expanded
|
|
498
|
+
|
|
499
|
+
until File.exist?( candidate ) || Dir.exist?( candidate )
|
|
500
|
+
parent = File.dirname( candidate )
|
|
501
|
+
break if parent == candidate
|
|
502
|
+
missing_segments.unshift( File.basename( candidate ) )
|
|
503
|
+
candidate = parent
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
base = if File.exist?( candidate ) || Dir.exist?( candidate )
|
|
507
|
+
File.realpath( candidate )
|
|
508
|
+
else
|
|
509
|
+
candidate
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
missing_segments.empty? ? base : File.join( base, *missing_segments )
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|