worktree_manager 0.2.1

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,794 @@
1
+ require 'thor'
2
+ require 'open3'
3
+ require 'tty-prompt'
4
+ require 'fileutils'
5
+ require_relative 'version'
6
+ require_relative 'manager'
7
+ require_relative 'hook_manager'
8
+ require_relative 'config_manager'
9
+
10
+ module WorktreeManager
11
+ class CLI < Thor
12
+ class_option :verbose, aliases: '-v', type: :boolean, desc: 'Enable verbose output for debugging'
13
+
14
+ HELP_ALIASES = %w[--help -h -? --usage].freeze
15
+
16
+ # Override Thor's start method to handle help requests properly
17
+ def self.start(given_args = ARGV, config = {})
18
+ # Intercept "wm command --help" style requests
19
+ if given_args.size >= 2 && (given_args.detect { |a| HELP_ALIASES.include?(a) })
20
+ # Extract the command (first argument)
21
+ command = given_args.first
22
+ # Replace the entire args with just "help command" to avoid Thor validation issues
23
+ given_args = ['help', command]
24
+ end
25
+
26
+ super(given_args, config)
27
+ end
28
+
29
+ desc 'version', 'Show version'
30
+ def version
31
+ puts VERSION
32
+ end
33
+
34
+ desc 'init', 'Initialize worktree configuration file'
35
+ long_desc <<-LONGDESC
36
+ `wm init` creates a .worktree.yml configuration file in your repository.
37
+
38
+ This command copies the example configuration file to your current directory,
39
+ allowing you to customize worktree settings and hooks.
40
+
41
+ Examples:
42
+ wm init # Create .worktree.yml from example
43
+ wm init --force # Overwrite existing .worktree.yml
44
+ LONGDESC
45
+ method_option :force, aliases: '-f', type: :boolean, desc: 'Force overwrite existing .worktree.yml'
46
+ def init
47
+ validate_main_repository!
48
+
49
+ # Check if .worktree.yml already exists
50
+ config_file = '.worktree.yml'
51
+ if File.exist?(config_file) && !options[:force]
52
+ puts "Error: #{config_file} already exists. Use --force to overwrite."
53
+ exit(1)
54
+ end
55
+
56
+ # Find example file
57
+ example_file = find_example_file
58
+ unless example_file
59
+ puts 'Error: Could not find .worktree.yml.example file.'
60
+ exit(1)
61
+ end
62
+
63
+ # Copy example file
64
+ begin
65
+ FileUtils.cp(example_file, config_file)
66
+ puts "Created #{config_file} from example."
67
+ puts 'Edit this file to customize your worktree configuration.'
68
+ rescue StandardError => e
69
+ puts "Error: Failed to create configuration file: #{e.message}"
70
+ exit(1)
71
+ end
72
+ end
73
+
74
+ desc 'list', 'List all worktrees'
75
+ long_desc <<-LONGDESC
76
+ `wm list` displays all git worktrees in the current repository.
77
+
78
+ This command can be run from either the main repository or any worktree.
79
+ When run from a worktree, it shows the path to the main repository.
80
+
81
+ Example:
82
+ wm list # List all worktrees
83
+ LONGDESC
84
+ def list
85
+ # list command can be used from worktree
86
+ main_repo_path = find_main_repository_path
87
+ if main_repo_path.nil?
88
+ puts 'Error: Not in a Git repository.'
89
+ exit(1)
90
+ end
91
+
92
+ # Show main repository path if running from a worktree
93
+ unless main_repository?
94
+ puts "Running from worktree. Main repository: #{main_repo_path}"
95
+ puts 'To enter the main repository, run:'
96
+ puts " cd #{main_repo_path}"
97
+ puts
98
+ end
99
+
100
+ manager = Manager.new(main_repo_path)
101
+ worktrees = manager.list
102
+
103
+ if worktrees.empty?
104
+ puts 'No worktrees found.'
105
+ else
106
+ worktrees.each do |worktree|
107
+ puts worktree
108
+ end
109
+ end
110
+ end
111
+
112
+ desc 'add NAME_OR_PATH [BRANCH]', 'Create a new worktree'
113
+ long_desc <<-LONGDESC
114
+ `wm add` creates a new git worktree at the specified location.
115
+
116
+ The NAME_OR_PATH can be:
117
+ - A simple name (e.g., 'feature'): Creates in configured worktrees_dir
118
+ - A relative path (e.g., '../projects/feature'): Creates at that path
119
+ - An absolute path: Creates at the exact location
120
+
121
+ Options:
122
+ -b, --branch: Create a new branch for the worktree
123
+ -t, --track: Create a branch tracking a remote branch
124
+ -f, --force: Force creation even if directory exists
125
+ --no-hooks: Skip pre_add and post_add hooks
126
+
127
+ Examples:
128
+ wm add feature # Create worktree at ../feature using existing branch
129
+ wm add feature main # Create worktree using 'main' branch
130
+ wm add feature -b new # Create worktree with new branch 'new'
131
+ wm add feature -t origin/develop # Track remote branch
132
+ wm add ../custom/path # Create at specific path
133
+ LONGDESC
134
+ method_option :branch, aliases: '-b', desc: 'Create a new branch for the worktree'
135
+ method_option :track, aliases: '-t', desc: 'Track a remote branch'
136
+ method_option :force, aliases: '-f', type: :boolean, desc: 'Force creation even if directory exists'
137
+ method_option :no_hooks, type: :boolean, desc: 'Skip hook execution'
138
+ def add(name_or_path, branch = nil)
139
+ validate_main_repository!
140
+
141
+ # Validate input
142
+ if name_or_path.nil? || name_or_path.strip.empty?
143
+ puts 'Error: Name or path cannot be empty'
144
+ exit(1)
145
+ end
146
+
147
+ # Load configuration and resolve path
148
+ config_manager = ConfigManager.new
149
+ path = config_manager.resolve_worktree_path(name_or_path)
150
+
151
+ # Get branch name from options (options take precedence over arguments)
152
+ target_branch = options[:branch] || branch
153
+
154
+ # Handle remote branch tracking
155
+ remote_branch = nil
156
+ if options[:track]
157
+ remote_branch = options[:track]
158
+ # If --track is used without specifying remote branch, use branch argument
159
+ remote_branch = branch if remote_branch == true && branch
160
+
161
+ # If target_branch is not set, derive it from remote branch
162
+ if !target_branch && remote_branch
163
+ # Extract branch name from remote (e.g., origin/feature -> feature)
164
+ target_branch = remote_branch.split('/', 2).last
165
+ end
166
+ elsif branch && branch.include?('/')
167
+ # Auto-detect remote branch (e.g., origin/feature)
168
+ remote_branch = branch
169
+ # Override target_branch for auto-detected remote branches
170
+ target_branch = branch.split('/', 2).last
171
+ end
172
+
173
+ # Validate branch name
174
+ if target_branch && !valid_branch_name?(target_branch)
175
+ puts "Error: Invalid branch name '#{target_branch}'. Branch names cannot contain spaces or special characters."
176
+ exit(1)
177
+ end
178
+
179
+ # Check for conflicts with existing worktrees
180
+ validate_no_conflicts!(path, target_branch)
181
+
182
+ manager = Manager.new
183
+ hook_manager = HookManager.new('.', verbose: options[:verbose])
184
+
185
+ # Execute pre-add hook
186
+ context = {
187
+ path: path,
188
+ branch: target_branch,
189
+ force: options[:force]
190
+ }
191
+
192
+ if !options[:no_hooks] && !hook_manager.execute_hook(:pre_add, context)
193
+ puts 'Error: pre_add hook failed. Aborting worktree creation.'
194
+ exit(1)
195
+ end
196
+
197
+ begin
198
+ # Create worktree
199
+ result = if remote_branch
200
+ # Track remote branch
201
+ manager.add_tracking_branch(path, target_branch, remote_branch, force: options[:force])
202
+ elsif target_branch
203
+ if options[:branch]
204
+ # Create new branch
205
+ manager.add_with_new_branch(path, target_branch, force: options[:force])
206
+ else
207
+ # Use existing branch
208
+ manager.add(path, target_branch, force: options[:force])
209
+ end
210
+ else
211
+ manager.add(path, force: options[:force])
212
+ end
213
+
214
+ puts "Worktree created: #{result.path} (#{result.branch || 'detached'})"
215
+ puts "\nTo enter the worktree, run:"
216
+ puts " cd #{result.path}"
217
+
218
+ # Execute post-add hook
219
+ unless options[:no_hooks]
220
+ context[:success] = true
221
+ context[:worktree_path] = result.path
222
+ hook_manager.execute_hook(:post_add, context)
223
+ end
224
+ rescue WorktreeManager::Error => e
225
+ puts "Error: #{e.message}"
226
+
227
+ # Execute post-add hook with error context on failure
228
+ unless options[:no_hooks]
229
+ context[:success] = false
230
+ context[:error] = e.message
231
+ hook_manager.execute_hook(:post_add, context)
232
+ end
233
+
234
+ exit(1)
235
+ end
236
+ end
237
+
238
+ desc 'jump [WORKTREE]', 'Navigate to a worktree directory'
239
+ def jump(worktree_name = nil)
240
+ main_repo_path = find_main_repository_path
241
+ if main_repo_path.nil?
242
+ warn 'Error: Not in a Git repository.'
243
+ exit(1)
244
+ end
245
+
246
+ manager = Manager.new(main_repo_path)
247
+ worktrees = manager.list
248
+
249
+ if worktrees.empty?
250
+ warn 'Error: No worktrees found.'
251
+ exit(1)
252
+ end
253
+
254
+ # If no argument provided, show interactive selection
255
+ if worktree_name.nil?
256
+ target = select_worktree_interactive(worktrees)
257
+ else
258
+ # Find worktree by name or path
259
+ target = worktrees.find do |w|
260
+ w.path.include?(worktree_name) ||
261
+ (w.branch && w.branch.include?(worktree_name)) ||
262
+ File.basename(w.path) == worktree_name
263
+ end
264
+
265
+ if target.nil?
266
+ warn "Error: Worktree '#{worktree_name}' not found."
267
+ warn "\nAvailable worktrees:"
268
+ worktrees.each do |w|
269
+ warn " - #{File.basename(w.path)} (#{w.branch || 'detached'})"
270
+ end
271
+ exit(1)
272
+ end
273
+ end
274
+
275
+ # Output only the path to stdout for cd command
276
+ puts target.path
277
+ end
278
+
279
+ desc 'reset', 'Reset current worktree branch to origin/main'
280
+ method_option :force, aliases: '-f', type: :boolean, desc: 'Force reset even if there are uncommitted changes'
281
+ def reset
282
+ # Check if we're in a worktree (not main repository)
283
+ if main_repository?
284
+ puts 'Error: Cannot run reset from the main repository. This command must be run from a worktree.'
285
+ exit(1)
286
+ end
287
+
288
+ # Get current branch name
289
+ current_branch_output, status = Open3.capture2('git symbolic-ref --short HEAD')
290
+ unless status.success?
291
+ puts 'Error: Could not determine current branch.'
292
+ exit(1)
293
+ end
294
+
295
+ current_branch = current_branch_output.strip
296
+
297
+ # Check if we're on the main branch
298
+ config_manager = ConfigManager.new
299
+ main_branch_name = config_manager.main_branch_name
300
+
301
+ if current_branch == main_branch_name
302
+ puts "Error: Cannot reset the main branch '#{main_branch_name}'."
303
+ exit(1)
304
+ end
305
+
306
+ # Check for uncommitted changes if not forcing
307
+ unless options[:force]
308
+ status_output, status = Open3.capture2('git status --porcelain')
309
+ if status.success? && !status_output.strip.empty?
310
+ puts 'Error: You have uncommitted changes. Use --force to discard them.'
311
+ exit(1)
312
+ end
313
+ end
314
+
315
+ puts "Resetting branch '#{current_branch}' to origin/#{main_branch_name}..."
316
+
317
+ # Fetch origin/main
318
+ fetch_output, fetch_status = Open3.capture2e("git fetch origin #{main_branch_name}")
319
+ unless fetch_status.success?
320
+ puts "Error: Failed to fetch origin/#{main_branch_name}: #{fetch_output}"
321
+ exit(1)
322
+ end
323
+
324
+ # Reset current branch to origin/main
325
+ # Always use --hard reset to ensure clean working directory
326
+ reset_command = "git reset --hard origin/#{main_branch_name}"
327
+ reset_output, reset_status = Open3.capture2e(reset_command)
328
+
329
+ if reset_status.success?
330
+ puts "Successfully reset '#{current_branch}' to origin/#{main_branch_name}"
331
+ else
332
+ puts "Error: Failed to reset: #{reset_output}"
333
+ exit(1)
334
+ end
335
+ end
336
+
337
+ desc 'remove [NAME_OR_PATH]', 'Remove an existing worktree'
338
+ method_option :force, aliases: '-f', type: :boolean, desc: 'Force removal even if worktree has changes'
339
+ method_option :all, type: :boolean, desc: 'Remove all worktrees at once'
340
+ method_option :no_hooks, type: :boolean, desc: 'Skip hook execution'
341
+ def remove(name_or_path = nil)
342
+ validate_main_repository!
343
+
344
+ manager = Manager.new
345
+
346
+ # Handle --all option
347
+ if options[:all]
348
+ if name_or_path
349
+ puts 'Error: Cannot specify both --all and a specific worktree'
350
+ exit(1)
351
+ end
352
+
353
+ worktrees = manager.list
354
+ if worktrees.empty?
355
+ puts 'Error: No worktrees found.'
356
+ exit(1)
357
+ end
358
+
359
+ remove_all_worktrees(worktrees)
360
+ return
361
+ end
362
+
363
+ # If no argument provided, show interactive selection
364
+ if name_or_path.nil?
365
+ worktrees = manager.list
366
+
367
+ # Filter out main repository
368
+ removable_worktrees = worktrees.reject { |worktree| is_main_repository?(worktree.path) }
369
+
370
+ if removable_worktrees.empty?
371
+ puts 'Error: No removable worktrees found (only main repository exists).'
372
+ exit(1)
373
+ end
374
+
375
+ target_worktree = select_worktree_interactive(removable_worktrees)
376
+ path = target_worktree.path
377
+ else
378
+ # Load configuration and resolve path
379
+ config_manager = ConfigManager.new
380
+ path = config_manager.resolve_worktree_path(name_or_path)
381
+ end
382
+
383
+ # Prevent deletion of main repository
384
+ if is_main_repository?(path)
385
+ puts 'Error: Cannot remove the main repository'
386
+ exit(1)
387
+ end
388
+
389
+ hook_manager = HookManager.new('.', verbose: options[:verbose])
390
+
391
+ # Normalize path
392
+ normalized_path = File.expand_path(path)
393
+
394
+ # Find worktree information to remove if not already selected
395
+ if name_or_path.nil?
396
+ # We already have target_worktree from interactive selection
397
+ else
398
+ worktrees = manager.list
399
+ target_worktree = worktrees.find { |wt| File.expand_path(wt.path) == normalized_path }
400
+
401
+ unless target_worktree
402
+ puts "Error: Worktree not found at path: #{path}"
403
+ exit(1)
404
+ return # Prevent further execution in test environment
405
+ end
406
+ end
407
+
408
+ # Execute pre-remove hook
409
+ context = {
410
+ path: target_worktree.path,
411
+ branch: target_worktree.branch,
412
+ force: options[:force]
413
+ }
414
+
415
+ if !options[:no_hooks] && !hook_manager.execute_hook(:pre_remove, context)
416
+ puts 'Error: pre_remove hook failed. Aborting worktree removal.'
417
+ exit(1)
418
+ end
419
+
420
+ begin
421
+ # Remove worktree
422
+ manager.remove(path, force: options[:force])
423
+
424
+ puts "Worktree removed: #{target_worktree.path}"
425
+
426
+ # Execute post-remove hook
427
+ unless options[:no_hooks]
428
+ context[:success] = true
429
+ hook_manager.execute_hook(:post_remove, context)
430
+ end
431
+ rescue WorktreeManager::Error => e
432
+ puts "Error: #{e.message}"
433
+
434
+ # Check if error is due to modified/untracked files and offer force removal
435
+ if e.message.include?('contains modified or untracked files') &&
436
+ !options[:force] &&
437
+ interactive_mode_available?
438
+
439
+ prompt = TTY::Prompt.new
440
+ if prompt.yes?("\nWould you like to force remove the worktree? This will delete all uncommitted changes.",
441
+ default: false)
442
+ begin
443
+ # Retry with force option
444
+ manager.remove(path, force: true)
445
+ puts "Worktree removed: #{target_worktree.path}"
446
+
447
+ # Execute post-remove hook with success
448
+ unless options[:no_hooks]
449
+ context[:success] = true
450
+ hook_manager.execute_hook(:post_remove, context)
451
+ end
452
+
453
+ return # Successfully removed with force
454
+ rescue WorktreeManager::Error => force_error
455
+ puts "Error: #{force_error.message}"
456
+ # Fall through to regular error handling
457
+ end
458
+ else
459
+ puts 'Removal cancelled.'
460
+ end
461
+ end
462
+
463
+ # Execute post-remove hook with error context on failure
464
+ unless options[:no_hooks]
465
+ context[:success] = false
466
+ context[:error] = e.message
467
+ hook_manager.execute_hook(:post_remove, context)
468
+ end
469
+
470
+ exit(1)
471
+ end
472
+ end
473
+
474
+ private
475
+
476
+ def find_example_file
477
+ # First check in the current project (for development)
478
+ local_example = File.expand_path('.worktree.yml.example', File.dirname(__FILE__) + '/../..')
479
+ return local_example if File.exist?(local_example)
480
+
481
+ # Then check in the gem installation path
482
+ gem_spec = begin
483
+ Gem::Specification.find_by_name('worktree_manager')
484
+ rescue StandardError
485
+ nil
486
+ end
487
+ if gem_spec
488
+ gem_example = File.join(gem_spec.gem_dir, '.worktree.yml.example')
489
+ return gem_example if File.exist?(gem_example)
490
+ end
491
+
492
+ nil
493
+ end
494
+
495
+ def is_main_repository?(path)
496
+ # Main repository has .git as a directory, worktrees have .git as a file
497
+ git_path = File.join(path, '.git')
498
+ File.exist?(git_path) && File.directory?(git_path)
499
+ end
500
+
501
+ def validate_main_repository!
502
+ return if main_repository?
503
+
504
+ main_repo_path = find_main_repository_path
505
+ puts 'Error: This command can only be run from the main Git repository (not from a worktree).'
506
+ if main_repo_path
507
+ puts 'To enter the main repository, run:'
508
+ puts " cd #{main_repo_path}"
509
+ end
510
+ exit(1)
511
+ end
512
+
513
+ def remove_all_worktrees(worktrees)
514
+ # Filter out the main repository
515
+ removable_worktrees = worktrees.reject { |worktree| is_main_repository?(worktree.path) }
516
+
517
+ if removable_worktrees.empty?
518
+ puts 'No worktrees to remove (only main repository found).'
519
+ exit(0)
520
+ end
521
+
522
+ # Show confirmation prompt
523
+ if interactive_mode_available?
524
+ prompt = TTY::Prompt.new
525
+
526
+ puts 'The following worktrees will be removed:'
527
+ removable_worktrees.each do |worktree|
528
+ puts " - #{worktree.path} (#{worktree.branch || 'detached'})"
529
+ end
530
+ puts
531
+
532
+ unless prompt.yes?("Are you sure you want to remove all #{removable_worktrees.size} worktrees?", default: false)
533
+ puts 'Cancelled.'
534
+ exit(0)
535
+ end
536
+ else
537
+ # In non-interactive mode, require --force for safety
538
+ unless options[:force]
539
+ puts 'Error: Removing all worktrees requires confirmation.'
540
+ puts 'Use --force to remove all worktrees without confirmation.'
541
+ exit(1)
542
+ end
543
+ end
544
+
545
+ manager = Manager.new
546
+ hook_manager = HookManager.new('.', verbose: options[:verbose])
547
+
548
+ removed_count = 0
549
+ failed_count = 0
550
+ failed_worktrees = []
551
+ force_removable_worktrees = []
552
+
553
+ removable_worktrees.each do |worktree|
554
+ puts "\nRemoving worktree: #{worktree.path}"
555
+
556
+ # Execute pre-remove hook
557
+ context = {
558
+ path: worktree.path,
559
+ branch: worktree.branch,
560
+ force: options[:force]
561
+ }
562
+
563
+ if !options[:no_hooks] && !hook_manager.execute_hook(:pre_remove, context)
564
+ puts ' Error: pre_remove hook failed. Skipping this worktree.'
565
+ failed_count += 1
566
+ next
567
+ end
568
+
569
+ begin
570
+ # Remove worktree
571
+ manager.remove(worktree.path, force: options[:force])
572
+
573
+ puts " Worktree removed: #{worktree.path}"
574
+ removed_count += 1
575
+
576
+ # Execute post-remove hook
577
+ unless options[:no_hooks]
578
+ context[:success] = true
579
+ hook_manager.execute_hook(:post_remove, context)
580
+ end
581
+ rescue WorktreeManager::Error => e
582
+ puts " Error: #{e.message}"
583
+ failed_count += 1
584
+ failed_worktrees << worktree
585
+
586
+ # Track worktrees that can be force removed
587
+ force_removable_worktrees << worktree if e.message.include?('contains modified or untracked files')
588
+
589
+ # Execute post-remove hook with error context on failure
590
+ unless options[:no_hooks]
591
+ context[:success] = false
592
+ context[:error] = e.message
593
+ hook_manager.execute_hook(:post_remove, context)
594
+ end
595
+ end
596
+ end
597
+
598
+ puts "\nSummary:"
599
+ puts " Removed: #{removed_count} worktrees"
600
+ puts " Failed: #{failed_count} worktrees" if failed_count > 0
601
+
602
+ # Offer force removal for worktrees with uncommitted changes
603
+ if force_removable_worktrees.any? && !options[:force] && interactive_mode_available?
604
+ puts "\nThe following worktrees contain uncommitted changes:"
605
+ force_removable_worktrees.each do |worktree|
606
+ puts " - #{worktree.path} (#{worktree.branch || 'detached'})"
607
+ end
608
+
609
+ prompt = TTY::Prompt.new
610
+ if prompt.yes?("\nWould you like to force remove these worktrees? This will delete all uncommitted changes.",
611
+ default: false)
612
+ puts "\nForce removing worktrees with uncommitted changes..."
613
+
614
+ force_removable_worktrees.each do |worktree|
615
+ puts "\nRemoving worktree: #{worktree.path}"
616
+
617
+ begin
618
+ # Remove with force
619
+ manager.remove(worktree.path, force: true)
620
+
621
+ puts " Worktree removed: #{worktree.path}"
622
+ removed_count += 1
623
+ failed_count -= 1
624
+
625
+ # Execute post-remove hook
626
+ unless options[:no_hooks]
627
+ context = {
628
+ path: worktree.path,
629
+ branch: worktree.branch,
630
+ force: true,
631
+ success: true
632
+ }
633
+ hook_manager.execute_hook(:post_remove, context)
634
+ end
635
+ rescue WorktreeManager::Error => e
636
+ puts " Error: #{e.message}"
637
+ end
638
+ end
639
+
640
+ puts "\nUpdated Summary:"
641
+ puts " Removed: #{removed_count} worktrees"
642
+ puts " Failed: #{failed_count} worktrees" if failed_count > 0
643
+ end
644
+ end
645
+
646
+ exit(failed_count > 0 ? 1 : 0)
647
+ end
648
+
649
+ def validate_no_conflicts!(path, branch_name)
650
+ manager = Manager.new
651
+
652
+ # Check for path conflicts
653
+ normalized_path = File.expand_path(path)
654
+ existing_worktrees = manager.list
655
+
656
+ existing_worktrees.each do |worktree|
657
+ next unless File.expand_path(worktree.path) == normalized_path
658
+
659
+ puts "Error: A worktree already exists at path '#{path}'"
660
+ puts " Existing worktree: #{worktree.path} (#{worktree.branch})"
661
+ puts ' Use --force to override or choose a different path'
662
+ exit(1)
663
+ end
664
+
665
+ # Check for branch conflicts (when not creating a new branch)
666
+ if branch_name && !options[:branch]
667
+ existing_branch = existing_worktrees.find { |wt| wt.branch == branch_name }
668
+ if existing_branch
669
+ puts "Error: Branch '#{branch_name}' is already checked out in another worktree"
670
+ puts " Existing worktree: #{existing_branch.path} (#{existing_branch.branch})"
671
+ puts ' Use a different branch name or -b option to create a new branch'
672
+ exit(1)
673
+ end
674
+ end
675
+
676
+ # Check for branch name duplication when creating new branch
677
+ if options[:branch]
678
+ output, status = Open3.capture2e('git', 'branch', '--list', branch_name)
679
+ if status.success? && !output.strip.empty?
680
+ puts "Error: Branch '#{branch_name}' already exists"
681
+ puts ' Use a different branch name or checkout the existing branch'
682
+ exit(1)
683
+ end
684
+ end
685
+
686
+ # Check directory existence (when force option is not used)
687
+ return unless !options[:force] && Dir.exist?(normalized_path) && !Dir.empty?(normalized_path)
688
+
689
+ puts "Error: Directory '#{path}' already exists and is not empty"
690
+ puts ' Use --force to override or choose a different path'
691
+ exit(1)
692
+ end
693
+
694
+ def valid_branch_name?(branch_name)
695
+ return false if branch_name.nil? || branch_name.strip.empty?
696
+
697
+ # Check basic Git branch name rules
698
+ invalid_patterns = [
699
+ /\s/, # Contains spaces
700
+ /\.\./, # Consecutive dots
701
+ /^[.-]/, # Starts with dot or dash
702
+ /[.-]$/, # Ends with dot or dash
703
+ /[~^:?*\[\]\\]/ # Special characters
704
+ ]
705
+
706
+ invalid_patterns.none? { |pattern| branch_name.match?(pattern) }
707
+ end
708
+
709
+ def main_repository?
710
+ git_dir = File.join(Dir.pwd, '.git')
711
+ return false unless File.exist?(git_dir)
712
+
713
+ if File.directory?(git_dir)
714
+ true
715
+ elsif File.file?(git_dir)
716
+ git_content = File.read(git_dir).strip
717
+ !git_content.start_with?('gitdir:')
718
+ else
719
+ false
720
+ end
721
+ end
722
+
723
+ def select_worktree_interactive(worktrees)
724
+ # Check if running in interactive mode
725
+ unless interactive_mode_available?
726
+ warn 'Error: Interactive mode requires a TTY. Please specify a worktree name.'
727
+ exit(1)
728
+ end
729
+
730
+ prompt = TTY::Prompt.new(output: $stderr)
731
+
732
+ # Get current directory to highlight current worktree
733
+ current_path = Dir.pwd
734
+
735
+ # Build choices for prompt
736
+ choices = worktrees.map do |worktree|
737
+ is_current = File.expand_path(current_path).start_with?(File.expand_path(worktree.path))
738
+ branch_info = worktree.branch || 'detached'
739
+ name = File.basename(worktree.path)
740
+ label = "#{name} - #{branch_info}"
741
+ label += ' (current)' if is_current
742
+
743
+ {
744
+ name: label,
745
+ value: worktree,
746
+ hint: worktree.path
747
+ }
748
+ end
749
+
750
+ # Show selection prompt
751
+ begin
752
+ prompt.select('Select a worktree:', choices, per_page: 10)
753
+ rescue TTY::Reader::InputInterrupt
754
+ warn "\nCancelled."
755
+ exit(0)
756
+ end
757
+ end
758
+
759
+ def interactive_mode_available?
760
+ $stdin.tty? && $stderr.tty?
761
+ end
762
+
763
+ def find_main_repository_path
764
+ # Try to find the main repository path using git command
765
+ output, _, status = Open3.capture3('git rev-parse --path-format=absolute --git-common-dir')
766
+
767
+ if status.success?
768
+ git_common_dir = output.strip
769
+ return nil if git_common_dir.empty?
770
+
771
+ # If it ends with .git, get parent directory
772
+ return File.dirname(git_common_dir) if git_common_dir.end_with?('/.git')
773
+
774
+ # In some cases, git-common-dir might return the directory itself
775
+ # Check if this is the main repository
776
+ test_dir = git_common_dir.end_with?('.git') ? File.dirname(git_common_dir) : git_common_dir
777
+ git_file = File.join(test_dir, '.git')
778
+
779
+ return test_dir if File.exist?(git_file) && File.directory?(git_file)
780
+
781
+ end
782
+
783
+ # Fallback: try to get worktree list from current directory
784
+ output, _, status = Open3.capture3('git worktree list --porcelain')
785
+ return nil unless status.success?
786
+
787
+ # First line should be the main worktree
788
+ first_line = output.lines.first
789
+ return unless first_line && first_line.start_with?('worktree ')
790
+
791
+ first_line.sub('worktree ', '').strip
792
+ end
793
+ end
794
+ end