claude_swarm 0.1.16 → 0.1.18
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/CHANGELOG.md +46 -0
- data/CLAUDE.md +48 -0
- data/README.md +228 -9
- data/claude-swarm.yml +8 -36
- data/examples/monitoring-demo.yml +26 -0
- data/examples/multi-directory.yml +26 -0
- data/examples/with-before-commands.yml +30 -0
- data/lib/claude_swarm/claude_code_executor.rb +8 -1
- data/lib/claude_swarm/claude_mcp_server.rb +2 -1
- data/lib/claude_swarm/cli.rb +102 -3
- data/lib/claude_swarm/commands/ps.rb +148 -0
- data/lib/claude_swarm/commands/show.rb +158 -0
- data/lib/claude_swarm/configuration.rb +34 -4
- data/lib/claude_swarm/mcp_generator.rb +3 -0
- data/lib/claude_swarm/orchestrator.rb +222 -4
- data/lib/claude_swarm/version.rb +1 -1
- data/lib/claude_swarm/worktree_manager.rb +353 -0
- data/llms.txt +2 -2
- metadata +7 -1
@@ -1,15 +1,19 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "English"
|
3
4
|
require "shellwords"
|
4
5
|
require "json"
|
5
6
|
require "fileutils"
|
6
7
|
require_relative "session_path"
|
7
8
|
require_relative "process_tracker"
|
9
|
+
require_relative "worktree_manager"
|
8
10
|
|
9
11
|
module ClaudeSwarm
|
10
12
|
class Orchestrator
|
13
|
+
RUN_DIR = File.expand_path("~/.claude-swarm/run")
|
14
|
+
|
11
15
|
def initialize(configuration, mcp_generator, vibe: false, prompt: nil, stream_logs: false, debug: false,
|
12
|
-
restore_session_path: nil)
|
16
|
+
restore_session_path: nil, worktree: nil)
|
13
17
|
@config = configuration
|
14
18
|
@generator = mcp_generator
|
15
19
|
@vibe = vibe
|
@@ -17,6 +21,16 @@ module ClaudeSwarm
|
|
17
21
|
@stream_logs = stream_logs
|
18
22
|
@debug = debug
|
19
23
|
@restore_session_path = restore_session_path
|
24
|
+
@session_path = nil
|
25
|
+
# Store worktree option for later use
|
26
|
+
@worktree_option = worktree
|
27
|
+
@needs_worktree_manager = worktree.is_a?(String) || worktree == "" ||
|
28
|
+
configuration.instances.values.any? { |inst| !inst[:worktree].nil? }
|
29
|
+
# Store modified instances after worktree setup
|
30
|
+
@modified_instances = nil
|
31
|
+
|
32
|
+
# Set environment variable for prompt mode to suppress output
|
33
|
+
ENV["CLAUDE_SWARM_PROMPT"] = "1" if @prompt
|
20
34
|
end
|
21
35
|
|
22
36
|
def start
|
@@ -29,9 +43,13 @@ module ClaudeSwarm
|
|
29
43
|
|
30
44
|
# Use existing session path
|
31
45
|
session_path = @restore_session_path
|
46
|
+
@session_path = session_path
|
32
47
|
ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
|
33
48
|
ENV["CLAUDE_SWARM_START_DIR"] = Dir.pwd
|
34
49
|
|
50
|
+
# Create run symlink for restored session
|
51
|
+
create_run_symlink
|
52
|
+
|
35
53
|
unless @prompt
|
36
54
|
puts "📝 Using existing session: #{session_path}/"
|
37
55
|
puts
|
@@ -43,6 +61,9 @@ module ClaudeSwarm
|
|
43
61
|
# Set up signal handlers to clean up child processes
|
44
62
|
setup_signal_handlers
|
45
63
|
|
64
|
+
# Check if the original session used worktrees
|
65
|
+
restore_worktrees_if_needed(session_path)
|
66
|
+
|
46
67
|
# Regenerate MCP configurations with session IDs for restoration
|
47
68
|
@generator.generate_all
|
48
69
|
unless @prompt
|
@@ -59,10 +80,17 @@ module ClaudeSwarm
|
|
59
80
|
# Generate and set session path for all instances
|
60
81
|
session_path = SessionPath.generate(working_dir: Dir.pwd)
|
61
82
|
SessionPath.ensure_directory(session_path)
|
83
|
+
@session_path = session_path
|
84
|
+
|
85
|
+
# Extract session ID from path (the timestamp part)
|
86
|
+
@session_id = File.basename(session_path)
|
62
87
|
|
63
88
|
ENV["CLAUDE_SWARM_SESSION_PATH"] = session_path
|
64
89
|
ENV["CLAUDE_SWARM_START_DIR"] = Dir.pwd
|
65
90
|
|
91
|
+
# Create run symlink for new session
|
92
|
+
create_run_symlink
|
93
|
+
|
66
94
|
unless @prompt
|
67
95
|
puts "📝 Session files will be saved to: #{session_path}/"
|
68
96
|
puts
|
@@ -74,6 +102,24 @@ module ClaudeSwarm
|
|
74
102
|
# Set up signal handlers to clean up child processes
|
75
103
|
setup_signal_handlers
|
76
104
|
|
105
|
+
# Create WorktreeManager if needed with session ID
|
106
|
+
if @needs_worktree_manager
|
107
|
+
cli_option = @worktree_option.is_a?(String) && !@worktree_option.empty? ? @worktree_option : nil
|
108
|
+
@worktree_manager = WorktreeManager.new(cli_option, session_id: @session_id)
|
109
|
+
puts "🌳 Setting up Git worktrees..." unless @prompt
|
110
|
+
|
111
|
+
# Get all instances for worktree setup
|
112
|
+
# Note: instances.values already includes the main instance
|
113
|
+
all_instances = @config.instances.values
|
114
|
+
|
115
|
+
@worktree_manager.setup_worktrees(all_instances)
|
116
|
+
|
117
|
+
unless @prompt
|
118
|
+
puts "✓ Worktrees created with branch: #{@worktree_manager.worktree_name}"
|
119
|
+
puts
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
77
123
|
# Generate all MCP configuration files
|
78
124
|
@generator.generate_all
|
79
125
|
unless @prompt
|
@@ -85,12 +131,40 @@ module ClaudeSwarm
|
|
85
131
|
save_swarm_config_path(session_path)
|
86
132
|
end
|
87
133
|
|
88
|
-
#
|
134
|
+
# Execute before commands if specified
|
135
|
+
before_commands = @config.before_commands
|
136
|
+
if before_commands.any? && !@restore_session_path
|
137
|
+
unless @prompt
|
138
|
+
puts "⚙️ Executing before commands..."
|
139
|
+
puts
|
140
|
+
end
|
141
|
+
|
142
|
+
success = execute_before_commands(before_commands)
|
143
|
+
unless success
|
144
|
+
puts "❌ Before commands failed. Aborting swarm launch." unless @prompt
|
145
|
+
cleanup_processes
|
146
|
+
cleanup_run_symlink
|
147
|
+
cleanup_worktrees
|
148
|
+
exit 1
|
149
|
+
end
|
150
|
+
|
151
|
+
unless @prompt
|
152
|
+
puts "✓ Before commands completed successfully"
|
153
|
+
puts
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Launch the main instance (fetch after worktree setup to get modified paths)
|
89
158
|
main_instance = @config.main_instance_config
|
90
159
|
unless @prompt
|
91
160
|
puts "🚀 Launching main instance: #{@config.main_instance}"
|
92
161
|
puts " Model: #{main_instance[:model]}"
|
93
|
-
|
162
|
+
if main_instance[:directories].size == 1
|
163
|
+
puts " Directory: #{main_instance[:directory]}"
|
164
|
+
else
|
165
|
+
puts " Directories:"
|
166
|
+
main_instance[:directories].each { |dir| puts " - #{dir}" }
|
167
|
+
end
|
94
168
|
puts " Allowed tools: #{main_instance[:allowed_tools].join(", ")}" if main_instance[:allowed_tools].any?
|
95
169
|
puts " Disallowed tools: #{main_instance[:disallowed_tools].join(", ")}" if main_instance[:disallowed_tools]&.any?
|
96
170
|
puts " Connections: #{main_instance[:connections].join(", ")}" if main_instance[:connections].any?
|
@@ -119,12 +193,69 @@ module ClaudeSwarm
|
|
119
193
|
log_thread.join
|
120
194
|
end
|
121
195
|
|
122
|
-
# Clean up child processes
|
196
|
+
# Clean up child processes and run symlink
|
123
197
|
cleanup_processes
|
198
|
+
cleanup_run_symlink
|
199
|
+
cleanup_worktrees
|
124
200
|
end
|
125
201
|
|
126
202
|
private
|
127
203
|
|
204
|
+
def execute_before_commands(commands)
|
205
|
+
log_file = File.join(@session_path, "session.log") if @session_path
|
206
|
+
|
207
|
+
commands.each_with_index do |command, index|
|
208
|
+
# Log the command execution to session log
|
209
|
+
if @session_path
|
210
|
+
File.open(log_file, "a") do |f|
|
211
|
+
f.puts "[#{Time.now.strftime("%Y-%m-%d %H:%M:%S")}] Executing before command #{index + 1}/#{commands.size}: #{command}"
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
# Execute the command and capture output
|
216
|
+
begin
|
217
|
+
puts "Debug: Executing command #{index + 1}/#{commands.size}: #{command}" if @debug && !@prompt
|
218
|
+
|
219
|
+
# Use system with output capture
|
220
|
+
output = `#{command} 2>&1`
|
221
|
+
success = $CHILD_STATUS.success?
|
222
|
+
|
223
|
+
# Log the output
|
224
|
+
if @session_path
|
225
|
+
File.open(log_file, "a") do |f|
|
226
|
+
f.puts "Command output:"
|
227
|
+
f.puts output
|
228
|
+
f.puts "Exit status: #{$CHILD_STATUS.exitstatus}"
|
229
|
+
f.puts "-" * 80
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# Show output if in debug mode or if command failed
|
234
|
+
if (@debug || !success) && !@prompt
|
235
|
+
puts "Command #{index + 1} output:"
|
236
|
+
puts output
|
237
|
+
puts "Exit status: #{$CHILD_STATUS.exitstatus}"
|
238
|
+
end
|
239
|
+
|
240
|
+
unless success
|
241
|
+
puts "❌ Before command #{index + 1} failed: #{command}" unless @prompt
|
242
|
+
return false
|
243
|
+
end
|
244
|
+
rescue StandardError => e
|
245
|
+
puts "Error executing before command #{index + 1}: #{e.message}" unless @prompt
|
246
|
+
if @session_path
|
247
|
+
File.open(log_file, "a") do |f|
|
248
|
+
f.puts "Error: #{e.message}"
|
249
|
+
f.puts "-" * 80
|
250
|
+
end
|
251
|
+
end
|
252
|
+
return false
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
true
|
257
|
+
end
|
258
|
+
|
128
259
|
def save_swarm_config_path(session_path)
|
129
260
|
# Copy the YAML config file to the session directory
|
130
261
|
config_copy_path = File.join(session_path, "config.yml")
|
@@ -133,6 +264,20 @@ module ClaudeSwarm
|
|
133
264
|
# Save the original working directory
|
134
265
|
start_dir_file = File.join(session_path, "start_directory")
|
135
266
|
File.write(start_dir_file, Dir.pwd)
|
267
|
+
|
268
|
+
# Save session metadata
|
269
|
+
metadata = {
|
270
|
+
"start_directory" => Dir.pwd,
|
271
|
+
"timestamp" => Time.now.utc.iso8601,
|
272
|
+
"swarm_name" => @config.swarm_name,
|
273
|
+
"claude_swarm_version" => VERSION
|
274
|
+
}
|
275
|
+
|
276
|
+
# Add worktree info if applicable
|
277
|
+
metadata["worktree"] = @worktree_manager.session_metadata if @worktree_manager
|
278
|
+
|
279
|
+
metadata_file = File.join(session_path, "session_metadata.json")
|
280
|
+
File.write(metadata_file, JSON.pretty_generate(metadata))
|
136
281
|
end
|
137
282
|
|
138
283
|
def setup_signal_handlers
|
@@ -140,6 +285,8 @@ module ClaudeSwarm
|
|
140
285
|
Signal.trap(signal) do
|
141
286
|
puts "\n🛑 Received #{signal} signal, cleaning up..."
|
142
287
|
cleanup_processes
|
288
|
+
cleanup_run_symlink
|
289
|
+
cleanup_worktrees
|
143
290
|
exit
|
144
291
|
end
|
145
292
|
end
|
@@ -152,6 +299,43 @@ module ClaudeSwarm
|
|
152
299
|
puts "⚠️ Error during cleanup: #{e.message}"
|
153
300
|
end
|
154
301
|
|
302
|
+
def cleanup_worktrees
|
303
|
+
return unless @worktree_manager
|
304
|
+
|
305
|
+
@worktree_manager.cleanup_worktrees
|
306
|
+
rescue StandardError => e
|
307
|
+
puts "⚠️ Error during worktree cleanup: #{e.message}"
|
308
|
+
end
|
309
|
+
|
310
|
+
def create_run_symlink
|
311
|
+
return unless @session_path
|
312
|
+
|
313
|
+
FileUtils.mkdir_p(RUN_DIR)
|
314
|
+
|
315
|
+
# Session ID is the last part of the session path
|
316
|
+
session_id = File.basename(@session_path)
|
317
|
+
symlink_path = File.join(RUN_DIR, session_id)
|
318
|
+
|
319
|
+
# Remove stale symlink if exists
|
320
|
+
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
321
|
+
|
322
|
+
# Create new symlink
|
323
|
+
File.symlink(@session_path, symlink_path)
|
324
|
+
rescue StandardError => e
|
325
|
+
# Don't fail the process if symlink creation fails
|
326
|
+
puts "⚠️ Warning: Could not create run symlink: #{e.message}" unless @prompt
|
327
|
+
end
|
328
|
+
|
329
|
+
def cleanup_run_symlink
|
330
|
+
return unless @session_path
|
331
|
+
|
332
|
+
session_id = File.basename(@session_path)
|
333
|
+
symlink_path = File.join(RUN_DIR, session_id)
|
334
|
+
File.unlink(symlink_path) if File.symlink?(symlink_path)
|
335
|
+
rescue StandardError
|
336
|
+
# Ignore errors during cleanup
|
337
|
+
end
|
338
|
+
|
155
339
|
def start_log_streaming
|
156
340
|
Thread.new do
|
157
341
|
session_log_path = File.join(ENV.fetch("CLAUDE_SWARM_SESSION_PATH", nil), "session.log")
|
@@ -238,6 +422,14 @@ module ClaudeSwarm
|
|
238
422
|
|
239
423
|
parts << "--debug" if @debug
|
240
424
|
|
425
|
+
# Add additional directories with --add-dir
|
426
|
+
if instance[:directories].size > 1
|
427
|
+
instance[:directories][1..].each do |additional_dir|
|
428
|
+
parts << "--add-dir"
|
429
|
+
parts << additional_dir
|
430
|
+
end
|
431
|
+
end
|
432
|
+
|
241
433
|
mcp_config_path = @generator.mcp_config_path(@config.main_instance)
|
242
434
|
parts << "--mcp-config"
|
243
435
|
parts << mcp_config_path
|
@@ -249,5 +441,31 @@ module ClaudeSwarm
|
|
249
441
|
parts << "#{instance[:prompt]}\n\nNow just say 'I am ready to start'"
|
250
442
|
end
|
251
443
|
end
|
444
|
+
|
445
|
+
def restore_worktrees_if_needed(session_path)
|
446
|
+
metadata_file = File.join(session_path, "session_metadata.json")
|
447
|
+
return unless File.exist?(metadata_file)
|
448
|
+
|
449
|
+
metadata = JSON.parse(File.read(metadata_file))
|
450
|
+
worktree_data = metadata["worktree"]
|
451
|
+
return unless worktree_data && worktree_data["enabled"]
|
452
|
+
|
453
|
+
unless @prompt
|
454
|
+
puts "🌳 Restoring Git worktrees..."
|
455
|
+
puts
|
456
|
+
end
|
457
|
+
|
458
|
+
# Restore worktrees using the saved configuration
|
459
|
+
@worktree_manager = WorktreeManager.new(worktree_data["shared_name"])
|
460
|
+
|
461
|
+
# Get all instances and restore their worktree paths
|
462
|
+
all_instances = @config.instances.values
|
463
|
+
@worktree_manager.setup_worktrees(all_instances)
|
464
|
+
|
465
|
+
return if @prompt
|
466
|
+
|
467
|
+
puts "✓ Worktrees restored with branch: #{@worktree_manager.worktree_name}"
|
468
|
+
puts
|
469
|
+
end
|
252
470
|
end
|
253
471
|
end
|
data/lib/claude_swarm/version.rb
CHANGED
@@ -0,0 +1,353 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "fileutils"
|
5
|
+
require "json"
|
6
|
+
require "pathname"
|
7
|
+
require "securerandom"
|
8
|
+
|
9
|
+
module ClaudeSwarm
|
10
|
+
class WorktreeManager
|
11
|
+
attr_reader :shared_worktree_name, :created_worktrees
|
12
|
+
|
13
|
+
def initialize(cli_worktree_option = nil, session_id: nil)
|
14
|
+
@cli_worktree_option = cli_worktree_option
|
15
|
+
@session_id = session_id
|
16
|
+
# Generate a name based on session ID if no option given, empty string, or default "worktree" from Thor
|
17
|
+
@shared_worktree_name = if cli_worktree_option.nil? || cli_worktree_option.empty? || cli_worktree_option == "worktree"
|
18
|
+
generate_worktree_name
|
19
|
+
else
|
20
|
+
cli_worktree_option
|
21
|
+
end
|
22
|
+
@created_worktrees = {} # Maps "repo_root:worktree_name" to worktree_path
|
23
|
+
@instance_worktree_configs = {} # Stores per-instance worktree settings
|
24
|
+
end
|
25
|
+
|
26
|
+
def setup_worktrees(instances)
|
27
|
+
# First pass: determine worktree configuration for each instance
|
28
|
+
instances.each do |instance|
|
29
|
+
worktree_config = determine_worktree_config(instance)
|
30
|
+
@instance_worktree_configs[instance[:name]] = worktree_config
|
31
|
+
end
|
32
|
+
|
33
|
+
# Second pass: create necessary worktrees
|
34
|
+
worktrees_to_create = collect_worktrees_to_create(instances)
|
35
|
+
worktrees_to_create.each do |repo_root, worktree_name|
|
36
|
+
create_worktree(repo_root, worktree_name)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Third pass: map instance directories to worktree paths
|
40
|
+
instances.each do |instance|
|
41
|
+
worktree_config = @instance_worktree_configs[instance[:name]]
|
42
|
+
|
43
|
+
if ENV["CLAUDE_SWARM_DEBUG"]
|
44
|
+
puts "Debug [WorktreeManager]: Processing instance #{instance[:name]}"
|
45
|
+
puts "Debug [WorktreeManager]: Worktree config: #{worktree_config.inspect}"
|
46
|
+
end
|
47
|
+
|
48
|
+
next if worktree_config[:skip]
|
49
|
+
|
50
|
+
worktree_name = worktree_config[:name]
|
51
|
+
original_dirs = instance[:directories] || [instance[:directory]]
|
52
|
+
mapped_dirs = original_dirs.map { |dir| map_to_worktree_path(dir, worktree_name) }
|
53
|
+
|
54
|
+
if ENV["CLAUDE_SWARM_DEBUG"]
|
55
|
+
puts "Debug [WorktreeManager]: Original dirs: #{original_dirs.inspect}"
|
56
|
+
puts "Debug [WorktreeManager]: Mapped dirs: #{mapped_dirs.inspect}"
|
57
|
+
end
|
58
|
+
|
59
|
+
if instance[:directories]
|
60
|
+
instance[:directories] = mapped_dirs
|
61
|
+
# Also update the single directory field for backward compatibility
|
62
|
+
instance[:directory] = mapped_dirs.first
|
63
|
+
else
|
64
|
+
instance[:directory] = mapped_dirs.first
|
65
|
+
end
|
66
|
+
|
67
|
+
puts "Debug [WorktreeManager]: Updated instance[:directory] to: #{instance[:directory]}" if ENV["CLAUDE_SWARM_DEBUG"]
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def map_to_worktree_path(original_path, worktree_name)
|
72
|
+
return original_path unless original_path
|
73
|
+
|
74
|
+
expanded_path = File.expand_path(original_path)
|
75
|
+
repo_root = find_git_root(expanded_path)
|
76
|
+
|
77
|
+
if ENV["CLAUDE_SWARM_DEBUG"]
|
78
|
+
puts "Debug [map_to_worktree_path]: Original path: #{original_path}"
|
79
|
+
puts "Debug [map_to_worktree_path]: Expanded path: #{expanded_path}"
|
80
|
+
puts "Debug [map_to_worktree_path]: Repo root: #{repo_root}"
|
81
|
+
end
|
82
|
+
|
83
|
+
return original_path unless repo_root
|
84
|
+
|
85
|
+
# Check if we have a worktree for this repo and name
|
86
|
+
worktree_key = "#{repo_root}:#{worktree_name}"
|
87
|
+
worktree_path = @created_worktrees[worktree_key]
|
88
|
+
|
89
|
+
if ENV["CLAUDE_SWARM_DEBUG"]
|
90
|
+
puts "Debug [map_to_worktree_path]: Worktree key: #{worktree_key}"
|
91
|
+
puts "Debug [map_to_worktree_path]: Worktree path: #{worktree_path}"
|
92
|
+
puts "Debug [map_to_worktree_path]: Created worktrees: #{@created_worktrees.inspect}"
|
93
|
+
end
|
94
|
+
|
95
|
+
return original_path unless worktree_path
|
96
|
+
|
97
|
+
# Calculate relative path from repo root
|
98
|
+
relative_path = Pathname.new(expanded_path).relative_path_from(Pathname.new(repo_root)).to_s
|
99
|
+
|
100
|
+
# Return the equivalent path in the worktree
|
101
|
+
result = if relative_path == "."
|
102
|
+
worktree_path
|
103
|
+
else
|
104
|
+
File.join(worktree_path, relative_path)
|
105
|
+
end
|
106
|
+
|
107
|
+
puts "Debug [map_to_worktree_path]: Result: #{result}" if ENV["CLAUDE_SWARM_DEBUG"]
|
108
|
+
|
109
|
+
result
|
110
|
+
end
|
111
|
+
|
112
|
+
def cleanup_worktrees
|
113
|
+
@created_worktrees.each do |worktree_key, worktree_path|
|
114
|
+
repo_root = worktree_key.split(":", 2).first
|
115
|
+
next unless File.exist?(worktree_path)
|
116
|
+
|
117
|
+
# Check for uncommitted changes
|
118
|
+
if has_uncommitted_changes?(worktree_path)
|
119
|
+
puts "⚠️ Warning: Worktree has uncommitted changes, skipping cleanup: #{worktree_path}" unless ENV["CLAUDE_SWARM_PROMPT"]
|
120
|
+
next
|
121
|
+
end
|
122
|
+
|
123
|
+
# Check for unpushed commits
|
124
|
+
if has_unpushed_commits?(worktree_path)
|
125
|
+
puts "⚠️ Warning: Worktree has unpushed commits, skipping cleanup: #{worktree_path}" unless ENV["CLAUDE_SWARM_PROMPT"]
|
126
|
+
next
|
127
|
+
end
|
128
|
+
|
129
|
+
puts "Removing worktree: #{worktree_path}" unless ENV["CLAUDE_SWARM_PROMPT"]
|
130
|
+
|
131
|
+
# Remove the worktree
|
132
|
+
output, status = Open3.capture2e("git", "-C", repo_root, "worktree", "remove", worktree_path)
|
133
|
+
next if status.success?
|
134
|
+
|
135
|
+
puts "Warning: Failed to remove worktree: #{output}"
|
136
|
+
# Try force remove
|
137
|
+
output, status = Open3.capture2e("git", "-C", repo_root, "worktree", "remove", "--force", worktree_path)
|
138
|
+
puts "Force remove result: #{output}" unless status.success?
|
139
|
+
end
|
140
|
+
rescue StandardError => e
|
141
|
+
puts "Error during worktree cleanup: #{e.message}"
|
142
|
+
end
|
143
|
+
|
144
|
+
def session_metadata
|
145
|
+
{
|
146
|
+
enabled: true,
|
147
|
+
shared_name: @shared_worktree_name,
|
148
|
+
created_paths: @created_worktrees.dup,
|
149
|
+
instance_configs: @instance_worktree_configs.dup
|
150
|
+
}
|
151
|
+
end
|
152
|
+
|
153
|
+
# Deprecated method for backward compatibility
|
154
|
+
def worktree_name
|
155
|
+
@shared_worktree_name
|
156
|
+
end
|
157
|
+
|
158
|
+
private
|
159
|
+
|
160
|
+
def generate_worktree_name
|
161
|
+
# Use session ID if available, otherwise generate a random suffix
|
162
|
+
if @session_id
|
163
|
+
"worktree-#{@session_id}"
|
164
|
+
else
|
165
|
+
# Fallback to random suffix for tests or when session ID is not available
|
166
|
+
random_suffix = SecureRandom.alphanumeric(5).downcase
|
167
|
+
"worktree-#{random_suffix}"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def determine_worktree_config(instance)
|
172
|
+
# Check instance-level worktree setting
|
173
|
+
instance_worktree = instance[:worktree]
|
174
|
+
|
175
|
+
if instance_worktree.nil?
|
176
|
+
# No instance-level setting, follow CLI behavior
|
177
|
+
if @cli_worktree_option.nil?
|
178
|
+
{ skip: true }
|
179
|
+
else
|
180
|
+
{ skip: false, name: @shared_worktree_name }
|
181
|
+
end
|
182
|
+
elsif instance_worktree == false
|
183
|
+
# Explicitly disabled for this instance
|
184
|
+
{ skip: true }
|
185
|
+
elsif instance_worktree == true
|
186
|
+
# Use shared worktree (either from CLI or auto-generated)
|
187
|
+
{ skip: false, name: @shared_worktree_name }
|
188
|
+
elsif instance_worktree.is_a?(String)
|
189
|
+
# Use custom worktree name
|
190
|
+
{ skip: false, name: instance_worktree }
|
191
|
+
else
|
192
|
+
raise Error, "Invalid worktree configuration for instance '#{instance[:name]}': #{instance_worktree.inspect}"
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
def collect_worktrees_to_create(instances)
|
197
|
+
worktrees_needed = {}
|
198
|
+
|
199
|
+
instances.each do |instance|
|
200
|
+
worktree_config = @instance_worktree_configs[instance[:name]]
|
201
|
+
next if worktree_config[:skip]
|
202
|
+
|
203
|
+
worktree_name = worktree_config[:name]
|
204
|
+
directories = instance[:directories] || [instance[:directory]]
|
205
|
+
|
206
|
+
directories.each do |dir|
|
207
|
+
next unless dir
|
208
|
+
|
209
|
+
expanded_dir = File.expand_path(dir)
|
210
|
+
repo_root = find_git_root(expanded_dir)
|
211
|
+
next unless repo_root
|
212
|
+
|
213
|
+
# Track unique repo_root:worktree_name combinations
|
214
|
+
worktrees_needed[repo_root] ||= Set.new
|
215
|
+
worktrees_needed[repo_root].add(worktree_name)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Convert to array of [repo_root, worktree_name] pairs
|
220
|
+
result = []
|
221
|
+
worktrees_needed.each do |repo_root, worktree_names|
|
222
|
+
worktree_names.each do |worktree_name|
|
223
|
+
result << [repo_root, worktree_name]
|
224
|
+
end
|
225
|
+
end
|
226
|
+
result
|
227
|
+
end
|
228
|
+
|
229
|
+
def find_git_root(path)
|
230
|
+
current = File.expand_path(path)
|
231
|
+
|
232
|
+
while current != "/"
|
233
|
+
return current if File.exist?(File.join(current, ".git"))
|
234
|
+
|
235
|
+
current = File.dirname(current)
|
236
|
+
end
|
237
|
+
|
238
|
+
nil
|
239
|
+
end
|
240
|
+
|
241
|
+
def create_worktree(repo_root, worktree_name)
|
242
|
+
worktree_key = "#{repo_root}:#{worktree_name}"
|
243
|
+
# Create worktrees inside the repository in a .worktrees directory
|
244
|
+
worktree_base_dir = File.join(repo_root, ".worktrees")
|
245
|
+
worktree_path = File.join(worktree_base_dir, worktree_name)
|
246
|
+
|
247
|
+
# Check if worktree already exists
|
248
|
+
if File.exist?(worktree_path)
|
249
|
+
puts "Using existing worktree: #{worktree_path}" unless ENV["CLAUDE_SWARM_PROMPT"]
|
250
|
+
@created_worktrees[worktree_key] = worktree_path
|
251
|
+
return
|
252
|
+
end
|
253
|
+
|
254
|
+
# Ensure .worktrees directory exists
|
255
|
+
FileUtils.mkdir_p(worktree_base_dir)
|
256
|
+
|
257
|
+
# Create .gitignore inside .worktrees to ignore all contents
|
258
|
+
gitignore_path = File.join(worktree_base_dir, ".gitignore")
|
259
|
+
File.write(gitignore_path, "# Ignore all worktree contents\n*\n") unless File.exist?(gitignore_path)
|
260
|
+
|
261
|
+
# Get current branch
|
262
|
+
output, status = Open3.capture2e("git", "-C", repo_root, "rev-parse", "--abbrev-ref", "HEAD")
|
263
|
+
raise Error, "Failed to get current branch in #{repo_root}: #{output}" unless status.success?
|
264
|
+
|
265
|
+
current_branch = output.strip
|
266
|
+
|
267
|
+
# Create worktree with a new branch based on current branch
|
268
|
+
branch_name = worktree_name
|
269
|
+
puts "Creating worktree: #{worktree_path} with branch: #{branch_name}" unless ENV["CLAUDE_SWARM_PROMPT"]
|
270
|
+
|
271
|
+
# Create worktree with a new branch
|
272
|
+
output, status = Open3.capture2e("git", "-C", repo_root, "worktree", "add", "-b", branch_name, worktree_path, current_branch)
|
273
|
+
|
274
|
+
# If branch already exists, try without -b flag
|
275
|
+
if !status.success? && output.include?("already exists")
|
276
|
+
puts "Branch #{branch_name} already exists, using existing branch" unless ENV["CLAUDE_SWARM_PROMPT"]
|
277
|
+
output, status = Open3.capture2e("git", "-C", repo_root, "worktree", "add", worktree_path, branch_name)
|
278
|
+
end
|
279
|
+
|
280
|
+
raise Error, "Failed to create worktree: #{output}" unless status.success?
|
281
|
+
|
282
|
+
@created_worktrees[worktree_key] = worktree_path
|
283
|
+
end
|
284
|
+
|
285
|
+
def has_uncommitted_changes?(worktree_path)
|
286
|
+
# Check if there are any uncommitted changes (staged or unstaged)
|
287
|
+
output, status = Open3.capture2e("git", "-C", worktree_path, "status", "--porcelain")
|
288
|
+
return false unless status.success?
|
289
|
+
|
290
|
+
# If output is not empty, there are changes
|
291
|
+
!output.strip.empty?
|
292
|
+
end
|
293
|
+
|
294
|
+
def has_unpushed_commits?(worktree_path)
|
295
|
+
# Get the current branch
|
296
|
+
branch_output, branch_status = Open3.capture2e("git", "-C", worktree_path, "rev-parse", "--abbrev-ref", "HEAD")
|
297
|
+
return false unless branch_status.success?
|
298
|
+
|
299
|
+
current_branch = branch_output.strip
|
300
|
+
|
301
|
+
# Check if the branch has an upstream
|
302
|
+
_, upstream_status = Open3.capture2e("git", "-C", worktree_path, "rev-parse", "--abbrev-ref", "#{current_branch}@{upstream}")
|
303
|
+
|
304
|
+
# If no upstream, check if there are any commits on this branch
|
305
|
+
unless upstream_status.success?
|
306
|
+
# Get the base branch (usually main or master)
|
307
|
+
base_branch = find_base_branch(worktree_path)
|
308
|
+
|
309
|
+
# If we can't find a base branch or this IS the base branch, check if there are any commits at all
|
310
|
+
if base_branch.nil? || current_branch == base_branch
|
311
|
+
# Check if this branch has any commits
|
312
|
+
commits_output, commits_status = Open3.capture2e("git", "-C", worktree_path, "rev-list", "--count", "HEAD")
|
313
|
+
return false unless commits_status.success?
|
314
|
+
|
315
|
+
# If there's more than 0 commits and no upstream, they're unpushed
|
316
|
+
return commits_output.strip.to_i.positive?
|
317
|
+
end
|
318
|
+
|
319
|
+
# Check if this branch has any commits not on the base branch
|
320
|
+
commits_output, commits_status = Open3.capture2e("git", "-C", worktree_path, "rev-list", "HEAD", "^#{base_branch}")
|
321
|
+
return false unless commits_status.success?
|
322
|
+
|
323
|
+
# If there are commits, they're unpushed (no upstream set)
|
324
|
+
return !commits_output.strip.empty?
|
325
|
+
end
|
326
|
+
|
327
|
+
# Check for unpushed commits
|
328
|
+
unpushed_output, unpushed_status = Open3.capture2e("git", "-C", worktree_path, "rev-list", "HEAD", "^#{current_branch}@{upstream}")
|
329
|
+
return false unless unpushed_status.success?
|
330
|
+
|
331
|
+
# If output is not empty, there are unpushed commits
|
332
|
+
!unpushed_output.strip.empty?
|
333
|
+
end
|
334
|
+
|
335
|
+
def find_base_branch(repo_path)
|
336
|
+
# Try to find the base branch - check for main, master, or the default branch
|
337
|
+
%w[main master].each do |branch|
|
338
|
+
_, status = Open3.capture2e("git", "-C", repo_path, "rev-parse", "--verify", "refs/heads/#{branch}")
|
339
|
+
return branch if status.success?
|
340
|
+
end
|
341
|
+
|
342
|
+
# Try to get the default branch from HEAD
|
343
|
+
output, status = Open3.capture2e("git", "-C", repo_path, "symbolic-ref", "refs/remotes/origin/HEAD")
|
344
|
+
if status.success?
|
345
|
+
# Extract branch name from refs/remotes/origin/main
|
346
|
+
branch_match = output.strip.match(%r{refs/remotes/origin/(.+)$})
|
347
|
+
return branch_match[1] if branch_match
|
348
|
+
end
|
349
|
+
|
350
|
+
nil
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|