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.
@@ -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
- # Launch the main instance
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
- puts " Directory: #{main_instance[:directory]}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeSwarm
4
- VERSION = "0.1.16"
4
+ VERSION = "0.1.18"
5
5
  end
@@ -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