Class: WorktreeManager::CLI

Inherits:
Thor
  • Object
show all
Defined in:
lib/worktree_manager/cli.rb

Constant Summary collapse

HELP_ALIASES =
%w[--help -h -? --usage].freeze

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.start(given_args = ARGV, config = {}) ⇒ Object

Override Thor’s start method to handle help requests properly



17
18
19
20
21
22
23
24
25
26
27
# File 'lib/worktree_manager/cli.rb', line 17

def self.start(given_args = ARGV, config = {})
  # Intercept "wm command --help" style requests
  if given_args.size >= 2 && (given_args.detect { |a| HELP_ALIASES.include?(a) })
    # Extract the command (first argument)
    command = given_args.first
    # Replace the entire args with just "help command" to avoid Thor validation issues
    given_args = ['help', command]
  end

  super(given_args, config)
end

Instance Method Details

#add(name_or_path, branch = nil) ⇒ Object



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/worktree_manager/cli.rb', line 138

def add(name_or_path, branch = nil)
  validate_main_repository!

  # Validate input
  if name_or_path.nil? || name_or_path.strip.empty?
    puts 'Error: Name or path cannot be empty'
    exit(1)
  end

  # Load configuration and resolve path
  config_manager = ConfigManager.new
  path = config_manager.resolve_worktree_path(name_or_path)

  # Get branch name from options (options take precedence over arguments)
  target_branch = options[:branch] || branch

  # Handle remote branch tracking
  remote_branch = nil
  if options[:track]
    remote_branch = options[:track]
    # If --track is used without specifying remote branch, use branch argument
    remote_branch = branch if remote_branch == true && branch

    # If target_branch is not set, derive it from remote branch
    if !target_branch && remote_branch
      # Extract branch name from remote (e.g., origin/feature -> feature)
      target_branch = remote_branch.split('/', 2).last
    end
  elsif branch && branch.include?('/')
    # Auto-detect remote branch (e.g., origin/feature)
    remote_branch = branch
    # Override target_branch for auto-detected remote branches
    target_branch = branch.split('/', 2).last
  end

  # Validate branch name
  if target_branch && !valid_branch_name?(target_branch)
    puts "Error: Invalid branch name '#{target_branch}'. Branch names cannot contain spaces or special characters."
    exit(1)
  end

  # Check for conflicts with existing worktrees
  validate_no_conflicts!(path, target_branch)

  manager = Manager.new
  hook_manager = HookManager.new('.', verbose: options[:verbose])

  # Execute pre-add hook
  context = {
    path: path,
    branch: target_branch,
    force: options[:force]
  }

  if !options[:no_hooks] && !hook_manager.execute_hook(:pre_add, context)
    puts 'Error: pre_add hook failed. Aborting worktree creation.'
    exit(1)
  end

  begin
    # Create worktree
    result = if remote_branch
               # Track remote branch
               manager.add_tracking_branch(path, target_branch, remote_branch, force: options[:force])
             elsif target_branch
               if options[:branch]
                 # Create new branch
                 manager.add_with_new_branch(path, target_branch, force: options[:force])
               else
                 # Use existing branch
                 manager.add(path, target_branch, force: options[:force])
               end
             else
               manager.add(path, force: options[:force])
             end

    puts "Worktree created: #{result.path} (#{result.branch || 'detached'})"
    puts "\nTo enter the worktree, run:"
    puts "  cd #{result.path}"

    # Execute post-add hook
    unless options[:no_hooks]
      context[:success] = true
      context[:worktree_path] = result.path
      hook_manager.execute_hook(:post_add, context)
    end
  rescue WorktreeManager::Error => e
    puts "Error: #{e.message}"

    # Execute post-add hook with error context on failure
    unless options[:no_hooks]
      context[:success] = false
      context[:error] = e.message
      hook_manager.execute_hook(:post_add, context)
    end

    exit(1)
  end
end

#initObject



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/worktree_manager/cli.rb', line 46

def init
  validate_main_repository!

  # Check if .worktree.yml already exists
  config_file = '.worktree.yml'
  if File.exist?(config_file) && !options[:force]
    puts "Error: #{config_file} already exists. Use --force to overwrite."
    exit(1)
  end

  # Find example file
  example_file = find_example_file
  unless example_file
    puts 'Error: Could not find .worktree.yml.example file.'
    exit(1)
  end

  # Copy example file
  begin
    FileUtils.cp(example_file, config_file)
    puts "Created #{config_file} from example."
    puts 'Edit this file to customize your worktree configuration.'
  rescue StandardError => e
    puts "Error: Failed to create configuration file: #{e.message}"
    exit(1)
  end
end

#jump(worktree_name = nil) ⇒ Object



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/worktree_manager/cli.rb', line 239

def jump(worktree_name = nil)
  main_repo_path = find_main_repository_path
  if main_repo_path.nil?
    warn 'Error: Not in a Git repository.'
    exit(1)
  end

  manager = Manager.new(main_repo_path)
  worktrees = manager.list

  if worktrees.empty?
    warn 'Error: No worktrees found.'
    exit(1)
  end

  # If no argument provided, show interactive selection
  if worktree_name.nil?
    target = select_worktree_interactive(worktrees)
  else
    # Find worktree by name or path
    target = worktrees.find do |w|
      w.path.include?(worktree_name) ||
        (w.branch && w.branch.include?(worktree_name)) ||
        File.basename(w.path) == worktree_name
    end

    if target.nil?
      warn "Error: Worktree '#{worktree_name}' not found."
      warn "\nAvailable worktrees:"
      worktrees.each do |w|
        warn "  - #{File.basename(w.path)} (#{w.branch || 'detached'})"
      end
      exit(1)
    end
  end

  # Output only the path to stdout for cd command
  puts target.path
end

#listObject



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/worktree_manager/cli.rb', line 84

def list
  # list command can be used from worktree
  main_repo_path = find_main_repository_path
  if main_repo_path.nil?
    puts 'Error: Not in a Git repository.'
    exit(1)
  end

  # Show main repository path if running from a worktree
  unless main_repository?
    puts "Running from worktree. Main repository: #{main_repo_path}"
    puts 'To enter the main repository, run:'
    puts "  cd #{main_repo_path}"
    puts
  end

  manager = Manager.new(main_repo_path)
  worktrees = manager.list

  if worktrees.empty?
    puts 'No worktrees found.'
  else
    worktrees.each do |worktree|
      puts worktree
    end
  end
end

#remove(name_or_path = nil) ⇒ Object



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
# File 'lib/worktree_manager/cli.rb', line 341

def remove(name_or_path = nil)
  validate_main_repository!

  manager = Manager.new

  # Handle --all option
  if options[:all]
    if name_or_path
      puts 'Error: Cannot specify both --all and a specific worktree'
      exit(1)
    end

    worktrees = manager.list
    if worktrees.empty?
      puts 'Error: No worktrees found.'
      exit(1)
    end

    remove_all_worktrees(worktrees)
    return
  end

  # If no argument provided, show interactive selection
  if name_or_path.nil?
    worktrees = manager.list

    # Filter out main repository
    removable_worktrees = worktrees.reject { |worktree| is_main_repository?(worktree.path) }

    if removable_worktrees.empty?
      puts 'Error: No removable worktrees found (only main repository exists).'
      exit(1)
    end

    target_worktree = select_worktree_interactive(removable_worktrees)
    path = target_worktree.path
  else
    # Load configuration and resolve path
    config_manager = ConfigManager.new
    path = config_manager.resolve_worktree_path(name_or_path)
  end

  # Prevent deletion of main repository
  if is_main_repository?(path)
    puts 'Error: Cannot remove the main repository'
    exit(1)
  end

  hook_manager = HookManager.new('.', verbose: options[:verbose])

  # Normalize path
  normalized_path = File.expand_path(path)

  # Find worktree information to remove if not already selected
  if name_or_path.nil?
    # We already have target_worktree from interactive selection
  else
    worktrees = manager.list
    target_worktree = worktrees.find { |wt| File.expand_path(wt.path) == normalized_path }

    unless target_worktree
      puts "Error: Worktree not found at path: #{path}"
      exit(1)
      return # Prevent further execution in test environment
    end
  end

  # Execute pre-remove hook
  context = {
    path: target_worktree.path,
    branch: target_worktree.branch,
    force: options[:force]
  }

  if !options[:no_hooks] && !hook_manager.execute_hook(:pre_remove, context)
    puts 'Error: pre_remove hook failed. Aborting worktree removal.'
    exit(1)
  end

  begin
    # Remove worktree
    manager.remove(path, force: options[:force])

    puts "Worktree removed: #{target_worktree.path}"

    # Execute post-remove hook
    unless options[:no_hooks]
      context[:success] = true
      hook_manager.execute_hook(:post_remove, context)
    end
  rescue WorktreeManager::Error => e
    puts "Error: #{e.message}"

    # Check if error is due to modified/untracked files and offer force removal
    if e.message.include?('contains modified or untracked files') &&
       !options[:force] &&
       interactive_mode_available?

      prompt = TTY::Prompt.new
      if prompt.yes?("\nWould you like to force remove the worktree? This will delete all uncommitted changes.",
                     default: false)
        begin
          # Retry with force option
          manager.remove(path, force: true)
          puts "Worktree removed: #{target_worktree.path}"

          # Execute post-remove hook with success
          unless options[:no_hooks]
            context[:success] = true
            hook_manager.execute_hook(:post_remove, context)
          end

          return # Successfully removed with force
        rescue WorktreeManager::Error => force_error
          puts "Error: #{force_error.message}"
          # Fall through to regular error handling
        end
      else
        puts 'Removal cancelled.'
      end
    end

    # Execute post-remove hook with error context on failure
    unless options[:no_hooks]
      context[:success] = false
      context[:error] = e.message
      hook_manager.execute_hook(:post_remove, context)
    end

    exit(1)
  end
end

#resetObject



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/worktree_manager/cli.rb', line 281

def reset
  # Check if we're in a worktree (not main repository)
  if main_repository?
    puts 'Error: Cannot run reset from the main repository. This command must be run from a worktree.'
    exit(1)
  end

  # Get current branch name
  current_branch_output, status = Open3.capture2('git symbolic-ref --short HEAD')
  unless status.success?
    puts 'Error: Could not determine current branch.'
    exit(1)
  end

  current_branch = current_branch_output.strip

  # Check if we're on the main branch
  config_manager = ConfigManager.new
  main_branch_name = config_manager.main_branch_name

  if current_branch == main_branch_name
    puts "Error: Cannot reset the main branch '#{main_branch_name}'."
    exit(1)
  end

  # Check for uncommitted changes if not forcing
  unless options[:force]
    status_output, status = Open3.capture2('git status --porcelain')
    if status.success? && !status_output.strip.empty?
      puts 'Error: You have uncommitted changes. Use --force to discard them.'
      exit(1)
    end
  end

  puts "Resetting branch '#{current_branch}' to origin/#{main_branch_name}..."

  # Fetch origin/main
  fetch_output, fetch_status = Open3.capture2e("git fetch origin #{main_branch_name}")
  unless fetch_status.success?
    puts "Error: Failed to fetch origin/#{main_branch_name}: #{fetch_output}"
    exit(1)
  end

  # Reset current branch to origin/main
  # Always use --hard reset to ensure clean working directory
  reset_command = "git reset --hard origin/#{main_branch_name}"
  reset_output, reset_status = Open3.capture2e(reset_command)

  if reset_status.success?
    puts "Successfully reset '#{current_branch}' to origin/#{main_branch_name}"
  else
    puts "Error: Failed to reset: #{reset_output}"
    exit(1)
  end
end

#versionObject



30
31
32
# File 'lib/worktree_manager/cli.rb', line 30

def version
  puts VERSION
end