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.
@@ -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