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,976 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe WorktreeManager::CLI do
4
+ let(:cli) { WorktreeManager::CLI.new }
5
+
6
+ # Stub exit for tests that expect SystemExit
7
+ def stub_exit
8
+ allow(cli).to receive(:exit) do |code|
9
+ raise SystemExit.new(code)
10
+ end
11
+ end
12
+
13
+ describe '#version' do
14
+ it 'displays the version' do
15
+ expect { cli.version }.to output("#{WorktreeManager::VERSION}\n").to_stdout
16
+ end
17
+ end
18
+
19
+ describe '#init' do
20
+ let(:example_file) { File.expand_path('../../.worktree.yml.example', __dir__) }
21
+
22
+ context 'when running from a worktree' do
23
+ before do
24
+ allow(cli).to receive(:main_repository?).and_return(false)
25
+ allow(cli).to receive(:find_main_repository_path).and_return('/path/to/main')
26
+ stub_exit
27
+ end
28
+
29
+ it 'exits with error message' do
30
+ expect { cli.init }.to output(/Error: This command can only be run from the main Git repository/).to_stdout
31
+ .and raise_error(SystemExit)
32
+ end
33
+ end
34
+
35
+ context 'when running from main repository' do
36
+ before do
37
+ allow(cli).to receive(:main_repository?).and_return(true)
38
+ allow(cli).to receive(:find_example_file).and_return(example_file)
39
+ end
40
+
41
+ context 'when .worktree.yml already exists' do
42
+ before do
43
+ allow(File).to receive(:exist?).with('.worktree.yml').and_return(true)
44
+ stub_exit
45
+ end
46
+
47
+ it 'exits with error message' do
48
+ expect { cli.init }.to output(/Error: .worktree.yml already exists. Use --force to overwrite./).to_stdout
49
+ .and raise_error(SystemExit)
50
+ end
51
+
52
+ context 'with --force option' do
53
+ before do
54
+ allow(cli).to receive(:options).and_return({ force: true })
55
+ allow(FileUtils).to receive(:cp)
56
+ end
57
+
58
+ it 'overwrites existing file' do
59
+ expect(FileUtils).to receive(:cp).with(example_file, '.worktree.yml')
60
+ expect { cli.init }.to output(/Created .worktree.yml from example/).to_stdout
61
+ end
62
+ end
63
+ end
64
+
65
+ context 'when .worktree.yml does not exist' do
66
+ before do
67
+ allow(File).to receive(:exist?).with('.worktree.yml').and_return(false)
68
+ allow(FileUtils).to receive(:cp)
69
+ end
70
+
71
+ it 'creates configuration file from example' do
72
+ expect(FileUtils).to receive(:cp).with(example_file, '.worktree.yml')
73
+ expect { cli.init }.to output(/Created .worktree.yml from example/).to_stdout
74
+ end
75
+ end
76
+
77
+ context 'when example file is not found' do
78
+ before do
79
+ allow(cli).to receive(:find_example_file).and_return(nil)
80
+ stub_exit
81
+ end
82
+
83
+ it 'exits with error message' do
84
+ expect { cli.init }.to output(/Error: Could not find .worktree.yml.example file./).to_stdout
85
+ .and raise_error(SystemExit)
86
+ end
87
+ end
88
+
89
+ context 'when file copy fails' do
90
+ before do
91
+ allow(File).to receive(:exist?).with('.worktree.yml').and_return(false)
92
+ allow(FileUtils).to receive(:cp).and_raise(StandardError.new('Permission denied'))
93
+ stub_exit
94
+ end
95
+
96
+ it 'exits with error message' do
97
+ expect { cli.init }.to output(/Error: Failed to create configuration file: Permission denied/).to_stdout
98
+ .and raise_error(SystemExit)
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ describe '#list' do
105
+ context 'when not in a git repository' do
106
+ before do
107
+ allow(cli).to receive(:find_main_repository_path).and_return(nil)
108
+ stub_exit
109
+ end
110
+
111
+ it 'exits with error message' do
112
+ expect { cli.list }.to output(/Error: Not in a Git repository/).to_stdout
113
+ .and raise_error(SystemExit)
114
+ end
115
+ end
116
+
117
+ context 'when in a worktree' do
118
+ let(:manager) { instance_double(WorktreeManager::Manager) }
119
+ let(:worktree) { instance_double(WorktreeManager::Worktree, to_s: '/path/to/worktree main abcd123') }
120
+ let(:main_repo_path) { '/path/to/main/repo' }
121
+
122
+ before do
123
+ allow(cli).to receive(:main_repository?).and_return(false)
124
+ allow(cli).to receive(:find_main_repository_path).and_return(main_repo_path)
125
+ allow(WorktreeManager::Manager).to receive(:new).with(main_repo_path).and_return(manager)
126
+ allow(manager).to receive(:list).and_return([worktree])
127
+ end
128
+
129
+ it 'displays main repository path and cd command' do
130
+ expected_output = <<~OUTPUT
131
+ Running from worktree. Main repository: #{main_repo_path}
132
+ To enter the main repository, run:
133
+ cd #{main_repo_path}
134
+
135
+ /path/to/worktree main abcd123
136
+ OUTPUT
137
+ expect { cli.list }.to output(expected_output).to_stdout
138
+ end
139
+ end
140
+
141
+ context 'when in a main repository' do
142
+ let(:manager) { instance_double(WorktreeManager::Manager) }
143
+ let(:worktree) { instance_double(WorktreeManager::Worktree, to_s: '/path/to/worktree main abcd123') }
144
+ let(:main_repo_path) { '/path/to/main/repo' }
145
+
146
+ before do
147
+ allow(cli).to receive(:main_repository?).and_return(true)
148
+ allow(cli).to receive(:find_main_repository_path).and_return(main_repo_path)
149
+ allow(WorktreeManager::Manager).to receive(:new).with(main_repo_path).and_return(manager)
150
+ end
151
+
152
+ context 'when there are worktrees' do
153
+ before do
154
+ allow(manager).to receive(:list).and_return([worktree])
155
+ end
156
+
157
+ it 'displays the worktree list' do
158
+ expect { cli.list }.to output("/path/to/worktree main abcd123\n").to_stdout
159
+ end
160
+ end
161
+
162
+ context 'when there are no worktrees' do
163
+ before do
164
+ allow(manager).to receive(:list).and_return([])
165
+ end
166
+
167
+ it 'displays no worktrees message' do
168
+ expect { cli.list }.to output("No worktrees found.\n").to_stdout
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ describe '#add' do
175
+ let(:manager) { instance_double(WorktreeManager::Manager) }
176
+ let(:hook_manager) { instance_double(WorktreeManager::HookManager) }
177
+ let(:config_manager) { instance_double(WorktreeManager::ConfigManager) }
178
+ let(:worktree) { instance_double(WorktreeManager::Worktree, path: '/test/path', branch: 'main') }
179
+
180
+ context 'when running from a worktree' do
181
+ let(:main_repo_path) { '/path/to/main/repo' }
182
+
183
+ before do
184
+ allow(cli).to receive(:main_repository?).and_return(false)
185
+ allow(cli).to receive(:find_main_repository_path).and_return(main_repo_path)
186
+ end
187
+
188
+ it 'exits with error message and shows cd command' do
189
+ expected_output = <<~OUTPUT
190
+ Error: This command can only be run from the main Git repository (not from a worktree).
191
+ To enter the main repository, run:
192
+ cd #{main_repo_path}
193
+ OUTPUT
194
+ expect { cli.add('/test/path') }.to output(expected_output).to_stdout
195
+ .and raise_error(SystemExit)
196
+ end
197
+ end
198
+
199
+ context 'when running from main repository' do
200
+ before do
201
+ allow(cli).to receive(:main_repository?).and_return(true)
202
+ allow(WorktreeManager::Manager).to receive(:new).and_return(manager)
203
+ allow(WorktreeManager::HookManager).to receive(:new).and_return(hook_manager)
204
+ allow(WorktreeManager::ConfigManager).to receive(:new).and_return(config_manager)
205
+ allow(config_manager).to receive(:resolve_worktree_path).with('/test/path').and_return('/test/path')
206
+ end
207
+
208
+ context 'when hooks succeed' do
209
+ before do
210
+ allow(hook_manager).to receive(:execute_hook).and_return(true)
211
+ allow(manager).to receive(:add).and_return(worktree)
212
+ allow(manager).to receive(:list).and_return([])
213
+ allow(File).to receive(:expand_path).and_call_original
214
+ allow(Dir).to receive(:exist?).and_return(false)
215
+ allow(Open3).to receive(:capture2e).and_return(['', double(success?: true)])
216
+ end
217
+
218
+ it 'creates a worktree successfully' do
219
+ expected_output = %r{Worktree created: /test/path \(main\)\n\nTo enter the worktree, run:\n cd /test/path}
220
+ expect { cli.add('/test/path') }.to output(expected_output).to_stdout
221
+ end
222
+
223
+ it 'executes pre_add and post_add hooks' do
224
+ expect(hook_manager).to receive(:execute_hook).with(:pre_add, anything)
225
+ expect(hook_manager).to receive(:execute_hook).with(:post_add, anything)
226
+ cli.add('/test/path')
227
+ end
228
+ end
229
+
230
+ context 'when pre_add hook fails' do
231
+ before do
232
+ allow(hook_manager).to receive(:execute_hook).with(:pre_add, anything).and_return(false)
233
+ allow(manager).to receive(:list).and_return([])
234
+ allow(File).to receive(:expand_path).and_call_original
235
+ allow(Dir).to receive(:exist?).and_return(false)
236
+ allow(Open3).to receive(:capture2e).and_return(['', double(success?: true)])
237
+ end
238
+
239
+ it 'exits with error message' do
240
+ expect { cli.add('/test/path') }.to output(/Error: pre_add hook failed/).to_stdout
241
+ .and raise_error(SystemExit)
242
+ end
243
+ end
244
+
245
+ context 'when worktree creation fails' do
246
+ before do
247
+ allow(hook_manager).to receive(:execute_hook).and_return(true)
248
+ allow(manager).to receive(:add).and_raise(WorktreeManager::Error, 'Creation failed')
249
+ allow(manager).to receive(:list).and_return([])
250
+ allow(File).to receive(:expand_path).and_call_original
251
+ allow(Dir).to receive(:exist?).and_return(false)
252
+ allow(Open3).to receive(:capture2e).and_return(['', double(success?: true)])
253
+ end
254
+
255
+ it 'executes post_add hook with error context' do
256
+ expect(hook_manager).to receive(:execute_hook).with(:post_add,
257
+ hash_including(success: false, error: 'Creation failed'))
258
+ expect { cli.add('/test/path') }.to raise_error(SystemExit)
259
+ end
260
+ end
261
+
262
+ context 'with branch option' do
263
+ before do
264
+ allow(hook_manager).to receive(:execute_hook).and_return(true)
265
+ allow(manager).to receive(:add_with_new_branch).and_return(worktree)
266
+ allow(manager).to receive(:list).and_return([])
267
+ allow(File).to receive(:expand_path).and_call_original
268
+ allow(Dir).to receive(:exist?).and_return(false)
269
+ allow(Open3).to receive(:capture2e).and_return(['', double(success?: true)])
270
+ end
271
+
272
+ it 'creates worktree with new branch' do
273
+ cli.invoke(:add, ['/test/path'], { branch: 'feature' })
274
+ expect(manager).to have_received(:add_with_new_branch).with('/test/path', 'feature', force: nil)
275
+ end
276
+ end
277
+
278
+ context 'with no_hooks option' do
279
+ before do
280
+ allow(manager).to receive(:add).and_return(worktree)
281
+ allow(manager).to receive(:list).and_return([])
282
+ allow(File).to receive(:expand_path).and_call_original
283
+ allow(Dir).to receive(:exist?).and_return(false)
284
+ allow(Open3).to receive(:capture2e).and_return(['', double(success?: true)])
285
+ end
286
+
287
+ it 'skips hook execution' do
288
+ expect(hook_manager).not_to receive(:execute_hook)
289
+ cli.invoke(:add, ['/test/path'], { no_hooks: true })
290
+ end
291
+ end
292
+
293
+ context 'with track option' do
294
+ before do
295
+ allow(hook_manager).to receive(:execute_hook).and_return(true)
296
+ allow(manager).to receive(:add_tracking_branch).and_return(worktree)
297
+ allow(manager).to receive(:list).and_return([])
298
+ allow(File).to receive(:expand_path).and_call_original
299
+ allow(Dir).to receive(:exist?).and_return(false)
300
+ allow(Open3).to receive(:capture2e).and_return(['', double(success?: true)])
301
+ end
302
+
303
+ it 'creates worktree tracking remote branch' do
304
+ cli.invoke(:add, ['/test/path'], { track: 'origin/feature' })
305
+ expect(manager).to have_received(:add_tracking_branch).with('/test/path', 'feature', 'origin/feature',
306
+ force: nil)
307
+ end
308
+ end
309
+
310
+ context 'with auto-detected remote branch' do
311
+ before do
312
+ allow(hook_manager).to receive(:execute_hook).and_return(true)
313
+ allow(manager).to receive(:add_tracking_branch).and_return(worktree)
314
+ allow(manager).to receive(:list).and_return([])
315
+ allow(File).to receive(:expand_path).and_call_original
316
+ allow(Dir).to receive(:exist?).and_return(false)
317
+ allow(Open3).to receive(:capture2e).and_return(['', double(success?: true)])
318
+ end
319
+
320
+ it 'detects and tracks remote branch when branch contains /' do
321
+ cli.invoke(:add, ['/test/path', 'origin/pr-123'])
322
+ expect(manager).to have_received(:add_tracking_branch).with('/test/path', 'pr-123', 'origin/pr-123',
323
+ force: nil)
324
+ end
325
+ end
326
+ end
327
+ end
328
+
329
+ describe '#jump' do
330
+ let(:manager) { instance_double(WorktreeManager::Manager) }
331
+ let(:worktree1) { instance_double(WorktreeManager::Worktree, path: '/path/to/main', branch: 'main') }
332
+ let(:worktree2) { instance_double(WorktreeManager::Worktree, path: '/path/to/feature', branch: 'feature') }
333
+ let(:main_repo_path) { '/path/to/main' }
334
+
335
+ before do
336
+ allow(cli).to receive(:find_main_repository_path).and_return(main_repo_path)
337
+ allow(WorktreeManager::Manager).to receive(:new).with(main_repo_path).and_return(manager)
338
+ end
339
+
340
+ context 'when not in a git repository' do
341
+ before do
342
+ allow(cli).to receive(:find_main_repository_path).and_return(nil)
343
+ end
344
+
345
+ it 'exits with error message to stderr' do
346
+ expect { cli.jump }.to output('').to_stdout
347
+ .and output(/Error: Not in a Git repository/).to_stderr
348
+ .and raise_error(SystemExit)
349
+ end
350
+ end
351
+
352
+ context 'when no worktrees exist' do
353
+ before do
354
+ allow(manager).to receive(:list).and_return([])
355
+ end
356
+
357
+ it 'exits with error message to stderr' do
358
+ expect { cli.jump }.to output('').to_stdout
359
+ .and output(/Error: No worktrees found/).to_stderr
360
+ .and raise_error(SystemExit)
361
+ end
362
+ end
363
+
364
+ context 'with worktree name argument' do
365
+ before do
366
+ allow(manager).to receive(:list).and_return([worktree1, worktree2])
367
+ end
368
+
369
+ it 'outputs worktree path to stdout when found by name' do
370
+ expect { cli.jump('feature') }.to output("/path/to/feature\n").to_stdout
371
+ .and output('').to_stderr
372
+ end
373
+
374
+ it 'outputs worktree path to stdout when found by basename' do
375
+ expect { cli.jump('main') }.to output("/path/to/main\n").to_stdout
376
+ .and output('').to_stderr
377
+ end
378
+
379
+ it 'exits with error when worktree not found' do
380
+ expect { cli.jump('nonexistent') }.to output('').to_stdout
381
+ .and output(/Error: Worktree 'nonexistent' not found/).to_stderr
382
+ .and raise_error(SystemExit)
383
+ end
384
+ end
385
+
386
+ context 'without argument (interactive mode)' do
387
+ let(:prompt) { instance_double(TTY::Prompt) }
388
+
389
+ before do
390
+ allow(manager).to receive(:list).and_return([worktree1, worktree2])
391
+ allow(cli).to receive(:interactive_mode_available?).and_return(true)
392
+ allow(Dir).to receive(:pwd).and_return('/path/to/feature')
393
+ allow(TTY::Prompt).to receive(:new).and_return(prompt)
394
+ end
395
+
396
+ it 'shows interactive selection and outputs selected path' do
397
+ expected_choices = [
398
+ { name: 'main - main', value: worktree1, hint: '/path/to/main' },
399
+ { name: 'feature - feature (current)', value: worktree2, hint: '/path/to/feature' }
400
+ ]
401
+
402
+ allow(prompt).to receive(:select).with('Select a worktree:', expected_choices,
403
+ per_page: 10).and_return(worktree2)
404
+
405
+ expect { cli.jump }.to output("/path/to/feature\n").to_stdout
406
+ end
407
+
408
+ it 'exits when user cancels' do
409
+ expected_choices = [
410
+ { name: 'main - main', value: worktree1, hint: '/path/to/main' },
411
+ { name: 'feature - feature (current)', value: worktree2, hint: '/path/to/feature' }
412
+ ]
413
+
414
+ allow(prompt).to receive(:select).with('Select a worktree:', expected_choices, per_page: 10).and_raise(TTY::Reader::InputInterrupt)
415
+
416
+ expect { cli.jump }.to output('').to_stdout
417
+ .and output(/Cancelled/).to_stderr
418
+ .and raise_error(SystemExit) { |error|
419
+ expect(error.status).to eq(0)
420
+ }
421
+ end
422
+
423
+ context 'when not in TTY' do
424
+ before do
425
+ allow(cli).to receive(:interactive_mode_available?).and_return(false)
426
+ end
427
+
428
+ it 'exits with error message' do
429
+ expect { cli.jump }.to output('').to_stdout
430
+ .and output(/Error: Interactive mode requires a TTY/).to_stderr
431
+ .and raise_error(SystemExit)
432
+ end
433
+ end
434
+ end
435
+ end
436
+
437
+ describe '#remove' do
438
+ let(:manager) { instance_double(WorktreeManager::Manager) }
439
+ let(:hook_manager) { instance_double(WorktreeManager::HookManager) }
440
+ let(:worktree) { instance_double(WorktreeManager::Worktree, path: '/test/path', branch: 'feature') }
441
+
442
+ context 'when running from a worktree' do
443
+ let(:main_repo_path) { '/path/to/main/repo' }
444
+
445
+ before do
446
+ allow(cli).to receive(:main_repository?).and_return(false)
447
+ allow(cli).to receive(:find_main_repository_path).and_return(main_repo_path)
448
+ end
449
+
450
+ it 'exits with error message and shows cd command' do
451
+ expected_output = <<~OUTPUT
452
+ Error: This command can only be run from the main Git repository (not from a worktree).
453
+ To enter the main repository, run:
454
+ cd #{main_repo_path}
455
+ OUTPUT
456
+ expect { cli.remove('/test/path') }.to output(expected_output).to_stdout
457
+ .and raise_error(SystemExit)
458
+ end
459
+ end
460
+
461
+ context 'when running from main repository' do
462
+ before do
463
+ allow(cli).to receive(:main_repository?).and_return(true)
464
+ allow(WorktreeManager::Manager).to receive(:new).and_return(manager)
465
+ allow(WorktreeManager::HookManager).to receive(:new).and_return(hook_manager)
466
+ allow(File).to receive(:expand_path).and_call_original
467
+ allow(File).to receive(:expand_path).with('/test/path').and_return('/test/path')
468
+ allow(File).to receive(:expand_path).with('.').and_return('/current/dir')
469
+ end
470
+
471
+ context 'when worktree exists and hooks succeed' do
472
+ before do
473
+ allow(manager).to receive(:list).and_return([worktree])
474
+ allow(hook_manager).to receive(:execute_hook).and_return(true)
475
+ allow(manager).to receive(:remove).and_return(true)
476
+ end
477
+
478
+ it 'removes worktree successfully' do
479
+ expect { cli.remove('/test/path') }.to output(%r{Worktree removed: /test/path}).to_stdout
480
+ end
481
+
482
+ it 'executes pre_remove and post_remove hooks' do
483
+ expect(hook_manager).to receive(:execute_hook).with(:pre_remove, anything)
484
+ expect(hook_manager).to receive(:execute_hook).with(:post_remove, anything)
485
+ cli.remove('/test/path')
486
+ end
487
+ end
488
+
489
+ context 'when worktree does not exist' do
490
+ before do
491
+ allow(manager).to receive(:list).and_return([])
492
+ end
493
+
494
+ it 'exits with error message' do
495
+ expect { cli.remove('/test/path') }.to output(/Error: Worktree not found at path/).to_stdout
496
+ .and raise_error(SystemExit)
497
+ end
498
+ end
499
+
500
+ context 'when pre_remove hook fails' do
501
+ before do
502
+ allow(manager).to receive(:list).and_return([worktree])
503
+ allow(hook_manager).to receive(:execute_hook).with(:pre_remove, anything).and_return(false)
504
+ end
505
+
506
+ it 'exits with error message' do
507
+ expect { cli.remove('/test/path') }.to output(/Error: pre_remove hook failed/).to_stdout
508
+ .and raise_error(SystemExit)
509
+ end
510
+ end
511
+
512
+ context 'with no_hooks option' do
513
+ before do
514
+ allow(manager).to receive(:list).and_return([worktree])
515
+ allow(manager).to receive(:remove)
516
+ end
517
+
518
+ it 'skips hook execution' do
519
+ expect(hook_manager).not_to receive(:execute_hook)
520
+ cli.invoke(:remove, ['/test/path'], { no_hooks: true })
521
+ end
522
+ end
523
+
524
+ context 'when trying to remove main repository' do
525
+ let(:config_manager) { instance_double(WorktreeManager::ConfigManager) }
526
+
527
+ before do
528
+ allow(WorktreeManager::ConfigManager).to receive(:new).and_return(config_manager)
529
+ allow(cli).to receive(:is_main_repository?).with('/main/repo').and_return(true)
530
+ allow(config_manager).to receive(:resolve_worktree_path).and_return('/main/repo')
531
+ end
532
+
533
+ it 'prevents removal of main repository' do
534
+ expect { cli.remove('/main/repo') }.to output(/Error: Cannot remove the main repository/).to_stdout
535
+ .and raise_error(SystemExit)
536
+ end
537
+ end
538
+
539
+ context 'with --all option' do
540
+ let(:main_worktree) { instance_double(WorktreeManager::Worktree, path: '/main/repo', branch: 'main') }
541
+ let(:feature_worktree) { instance_double(WorktreeManager::Worktree, path: '/feature', branch: 'feature') }
542
+
543
+ before do
544
+ allow(manager).to receive(:list).and_return([main_worktree, worktree, feature_worktree])
545
+ allow(cli).to receive(:is_main_repository?).with('/main/repo').and_return(true)
546
+ allow(cli).to receive(:is_main_repository?).with('/test/path').and_return(false)
547
+ allow(cli).to receive(:is_main_repository?).with('/feature').and_return(false)
548
+ allow(manager).to receive(:remove)
549
+ allow(hook_manager).to receive(:execute_hook).and_return(true)
550
+ end
551
+
552
+ it 'filters out main repository from removal' do
553
+ expect do
554
+ cli.invoke(:remove, [], { all: true, force: true })
555
+ end.to output(%r{Removing worktree: /test/path.*Removing worktree: /feature}m).to_stdout
556
+ .and output(satisfy { |text|
557
+ !text.include?('Removing worktree: /main/repo')
558
+ }).to_stdout
559
+
560
+ # Verify remove was not called for main repository
561
+ expect(manager).not_to have_received(:remove).with('/main/repo', anything)
562
+ expect(manager).to have_received(:remove).with('/test/path', anything)
563
+ expect(manager).to have_received(:remove).with('/feature', anything)
564
+ end
565
+
566
+ it 'shows message when only main repository exists' do
567
+ allow(manager).to receive(:list).and_return([main_worktree])
568
+ expect do
569
+ cli.invoke(:remove, [], { all: true })
570
+ end.to output(/No worktrees to remove \(only main repository found\)/).to_stdout
571
+ .and raise_error(SystemExit)
572
+ end
573
+ end
574
+
575
+ context 'with interactive selection' do
576
+ let(:main_worktree) { instance_double(WorktreeManager::Worktree, path: '/main/repo', branch: 'main') }
577
+ let(:feature_worktree) { instance_double(WorktreeManager::Worktree, path: '/feature', branch: 'feature') }
578
+
579
+ before do
580
+ allow(manager).to receive(:list).and_return([main_worktree, worktree, feature_worktree])
581
+ allow(cli).to receive(:is_main_repository?).with('/main/repo').and_return(true)
582
+ allow(cli).to receive(:is_main_repository?).with('/test/path').and_return(false)
583
+ allow(cli).to receive(:is_main_repository?).with('/feature').and_return(false)
584
+ allow(cli).to receive(:interactive_mode_available?).and_return(true)
585
+ end
586
+
587
+ it 'filters out main repository from interactive selection' do
588
+ allow(cli).to receive(:select_worktree_interactive).with([worktree, feature_worktree]).and_return(worktree)
589
+ allow(manager).to receive(:remove)
590
+ allow(hook_manager).to receive(:execute_hook).and_return(true)
591
+
592
+ cli.remove
593
+ expect(cli).to have_received(:select_worktree_interactive).with([worktree, feature_worktree])
594
+ end
595
+
596
+ it 'shows error when only main repository exists' do
597
+ allow(manager).to receive(:list).and_return([main_worktree])
598
+ expect do
599
+ cli.remove
600
+ end.to output(/Error: No removable worktrees found \(only main repository exists\)/).to_stdout
601
+ .and raise_error(SystemExit)
602
+ end
603
+ end
604
+
605
+ context 'when removal fails due to uncommitted changes' do
606
+ before do
607
+ allow(manager).to receive(:list).and_return([worktree])
608
+ allow(hook_manager).to receive(:execute_hook).and_return(true)
609
+ allow(cli).to receive(:interactive_mode_available?).and_return(true)
610
+ end
611
+
612
+ it 'prompts for force removal and retries when user confirms' do
613
+ prompt = instance_double(TTY::Prompt)
614
+ allow(TTY::Prompt).to receive(:new).and_return(prompt)
615
+
616
+ # First attempt fails
617
+ allow(manager).to receive(:remove).with('/test/path', force: nil)
618
+ .and_raise(WorktreeManager::Error, "fatal: '/test/path' contains modified or untracked files, use --force to delete it")
619
+
620
+ # User confirms force removal
621
+ allow(prompt).to receive(:yes?).and_return(true)
622
+
623
+ # Second attempt succeeds with force
624
+ allow(manager).to receive(:remove).with('/test/path', force: true)
625
+
626
+ expect do
627
+ cli.remove('/test/path')
628
+ end.to output(/Error:.*contains modified or untracked files.*Worktree removed/m).to_stdout
629
+
630
+ expect(manager).to have_received(:remove).with('/test/path', force: nil).once
631
+ expect(manager).to have_received(:remove).with('/test/path', force: true).once
632
+ end
633
+
634
+ it 'cancels removal when user declines force removal' do
635
+ prompt = instance_double(TTY::Prompt)
636
+ allow(TTY::Prompt).to receive(:new).and_return(prompt)
637
+
638
+ # First attempt fails
639
+ allow(manager).to receive(:remove).with('/test/path', force: nil)
640
+ .and_raise(WorktreeManager::Error, "fatal: '/test/path' contains modified or untracked files, use --force to delete it")
641
+
642
+ # User declines force removal
643
+ allow(prompt).to receive(:yes?).and_return(false)
644
+
645
+ expect do
646
+ cli.remove('/test/path')
647
+ end.to output(/Error:.*contains modified or untracked files.*Removal cancelled/m).to_stdout
648
+ .and raise_error(SystemExit)
649
+
650
+ expect(manager).to have_received(:remove).with('/test/path', force: nil).once
651
+ expect(manager).not_to have_received(:remove).with('/test/path', force: true)
652
+ end
653
+
654
+ it 'does not prompt when force option is already set' do
655
+ allow(manager).to receive(:remove).with('/test/path', force: true)
656
+ allow(TTY::Prompt).to receive(:new).and_call_original
657
+
658
+ cli.invoke(:remove, ['/test/path'], { force: true })
659
+
660
+ expect(TTY::Prompt).not_to have_received(:new)
661
+ end
662
+
663
+ it 'does not prompt in non-interactive mode' do
664
+ allow(cli).to receive(:interactive_mode_available?).and_return(false)
665
+ allow(TTY::Prompt).to receive(:new).and_call_original
666
+
667
+ allow(manager).to receive(:remove).with('/test/path', force: nil)
668
+ .and_raise(WorktreeManager::Error, "fatal: '/test/path' contains modified or untracked files, use --force to delete it")
669
+
670
+ expect { cli.remove('/test/path') }.to output(/Error:.*contains modified or untracked files/m).to_stdout
671
+ .and raise_error(SystemExit)
672
+
673
+ expect(TTY::Prompt).not_to have_received(:new)
674
+ end
675
+ end
676
+ end
677
+ end
678
+
679
+ describe '#is_main_repository?' do
680
+ subject { cli.send(:is_main_repository?, path) }
681
+
682
+ context 'when .git is a directory' do
683
+ let(:path) { '/test/main' }
684
+
685
+ before do
686
+ allow(File).to receive(:exist?).with('/test/main/.git').and_return(true)
687
+ allow(File).to receive(:directory?).with('/test/main/.git').and_return(true)
688
+ end
689
+
690
+ it { is_expected.to be true }
691
+ end
692
+
693
+ context 'when .git is a file' do
694
+ let(:path) { '/test/worktree' }
695
+
696
+ before do
697
+ allow(File).to receive(:exist?).with('/test/worktree/.git').and_return(true)
698
+ allow(File).to receive(:directory?).with('/test/worktree/.git').and_return(false)
699
+ end
700
+
701
+ it { is_expected.to be false }
702
+ end
703
+
704
+ context 'when .git does not exist' do
705
+ let(:path) { '/test/notgit' }
706
+
707
+ before do
708
+ allow(File).to receive(:exist?).with('/test/notgit/.git').and_return(false)
709
+ end
710
+
711
+ it { is_expected.to be false }
712
+ end
713
+ end
714
+
715
+ describe '#main_repository?' do
716
+ subject { cli.send(:main_repository?) }
717
+
718
+ context 'when .git is a directory' do
719
+ before do
720
+ allow(File).to receive(:exist?).with(File.join(Dir.pwd, '.git')).and_return(true)
721
+ allow(File).to receive(:directory?).with(File.join(Dir.pwd, '.git')).and_return(true)
722
+ end
723
+
724
+ it 'returns true' do
725
+ expect(subject).to be true
726
+ end
727
+ end
728
+
729
+ context 'when .git is a file with gitdir content' do
730
+ before do
731
+ allow(File).to receive(:exist?).with(File.join(Dir.pwd, '.git')).and_return(true)
732
+ allow(File).to receive(:directory?).with(File.join(Dir.pwd, '.git')).and_return(false)
733
+ allow(File).to receive(:file?).with(File.join(Dir.pwd, '.git')).and_return(true)
734
+ allow(File).to receive(:read).with(File.join(Dir.pwd,
735
+ '.git')).and_return('gitdir: /path/to/main/.git/worktrees/branch')
736
+ end
737
+
738
+ it 'returns false' do
739
+ expect(subject).to be false
740
+ end
741
+ end
742
+
743
+ context 'when .git is a file without gitdir content' do
744
+ before do
745
+ allow(File).to receive(:exist?).with(File.join(Dir.pwd, '.git')).and_return(true)
746
+ allow(File).to receive(:directory?).with(File.join(Dir.pwd, '.git')).and_return(false)
747
+ allow(File).to receive(:file?).with(File.join(Dir.pwd, '.git')).and_return(true)
748
+ allow(File).to receive(:read).with(File.join(Dir.pwd, '.git')).and_return('some other content')
749
+ end
750
+
751
+ it 'returns true' do
752
+ expect(subject).to be true
753
+ end
754
+ end
755
+
756
+ context 'when .git does not exist' do
757
+ before do
758
+ allow(File).to receive(:exist?).with(File.join(Dir.pwd, '.git')).and_return(false)
759
+ end
760
+
761
+ it 'returns false' do
762
+ expect(subject).to be false
763
+ end
764
+ end
765
+ end
766
+
767
+ describe '#find_main_repository_path' do
768
+ subject { cli.send(:find_main_repository_path) }
769
+
770
+ context 'when git rev-parse returns a .git directory' do
771
+ before do
772
+ allow(Open3).to receive(:capture3)
773
+ .with('git rev-parse --path-format=absolute --git-common-dir')
774
+ .and_return(["/path/to/repo/.git\n", '', double(success?: true)])
775
+ end
776
+
777
+ it 'returns the parent directory' do
778
+ expect(subject).to eq '/path/to/repo'
779
+ end
780
+ end
781
+
782
+ context 'when git rev-parse returns a directory without .git suffix' do
783
+ before do
784
+ allow(Open3).to receive(:capture3)
785
+ .with('git rev-parse --path-format=absolute --git-common-dir')
786
+ .and_return(["/path/to/repo\n", '', double(success?: true)])
787
+ allow(File).to receive(:exist?).with('/path/to/repo/.git').and_return(true)
788
+ allow(File).to receive(:directory?).with('/path/to/repo/.git').and_return(true)
789
+ end
790
+
791
+ it 'returns the directory itself' do
792
+ expect(subject).to eq '/path/to/repo'
793
+ end
794
+ end
795
+
796
+ context 'when git commands fail but worktree list works' do
797
+ before do
798
+ allow(Open3).to receive(:capture3)
799
+ .with('git rev-parse --path-format=absolute --git-common-dir')
800
+ .and_return(['', 'error', double(success?: false)])
801
+ allow(Open3).to receive(:capture3)
802
+ .with('git worktree list --porcelain')
803
+ .and_return(["worktree /path/to/main/repo\nHEAD abcd1234\n", '', double(success?: true)])
804
+ end
805
+
806
+ it 'returns the main worktree path' do
807
+ expect(subject).to eq '/path/to/main/repo'
808
+ end
809
+ end
810
+
811
+ context 'when not in a git repository' do
812
+ before do
813
+ allow(Open3).to receive(:capture3)
814
+ .with('git rev-parse --path-format=absolute --git-common-dir')
815
+ .and_return(['', 'fatal: not a git repository', double(success?: false)])
816
+ allow(Open3).to receive(:capture3)
817
+ .with('git worktree list --porcelain')
818
+ .and_return(['', 'fatal: not a git repository', double(success?: false)])
819
+ end
820
+
821
+ it 'returns nil' do
822
+ expect(subject).to be_nil
823
+ end
824
+ end
825
+ end
826
+
827
+ describe '#reset' do
828
+ context 'when running from main repository' do
829
+ before do
830
+ allow(cli).to receive(:main_repository?).and_return(true)
831
+ stub_exit
832
+ end
833
+
834
+ it 'exits with error message' do
835
+ expect { cli.reset }.to output(/Error: Cannot run reset from the main repository/).to_stdout
836
+ .and raise_error(SystemExit)
837
+ end
838
+ end
839
+
840
+ context 'when running from a worktree' do
841
+ let(:config_manager) { instance_double(WorktreeManager::ConfigManager) }
842
+
843
+ before do
844
+ allow(cli).to receive(:main_repository?).and_return(false)
845
+ allow(WorktreeManager::ConfigManager).to receive(:new).and_return(config_manager)
846
+ allow(config_manager).to receive(:main_branch_name).and_return('main')
847
+ end
848
+
849
+ context 'when on the main branch' do
850
+ before do
851
+ allow(Open3).to receive(:capture2).with('git symbolic-ref --short HEAD')
852
+ .and_return(["main\n", double(success?: true)])
853
+ stub_exit
854
+ end
855
+
856
+ it 'exits with error message' do
857
+ expect { cli.reset }.to output(/Error: Cannot reset the main branch 'main'/).to_stdout
858
+ .and raise_error(SystemExit)
859
+ end
860
+ end
861
+
862
+ context 'when branch detection fails' do
863
+ before do
864
+ allow(Open3).to receive(:capture2).with('git symbolic-ref --short HEAD')
865
+ .and_return(['', double(success?: false)])
866
+ stub_exit
867
+ end
868
+
869
+ it 'exits with error message' do
870
+ expect { cli.reset }.to output(/Error: Could not determine current branch/).to_stdout
871
+ .and raise_error(SystemExit)
872
+ end
873
+ end
874
+
875
+ context 'when on a feature branch' do
876
+ before do
877
+ allow(Open3).to receive(:capture2).with('git symbolic-ref --short HEAD')
878
+ .and_return(["feature/test\n", double(success?: true)])
879
+ end
880
+
881
+ context 'with uncommitted changes and no force flag' do
882
+ before do
883
+ allow(Open3).to receive(:capture2).with('git status --porcelain')
884
+ .and_return([" M file.txt\n", double(success?: true)])
885
+ stub_exit
886
+ end
887
+
888
+ it 'exits with error message' do
889
+ expect { cli.reset }.to output(/Error: You have uncommitted changes. Use --force to discard them/).to_stdout
890
+ .and raise_error(SystemExit)
891
+ end
892
+ end
893
+
894
+ context 'with uncommitted changes and force flag' do
895
+ before do
896
+ allow(cli).to receive(:options).and_return({ force: true })
897
+ allow(Open3).to receive(:capture2e).with('git fetch origin main')
898
+ .and_return(['', double(success?: true)])
899
+ allow(Open3).to receive(:capture2e).with('git reset --hard origin/main')
900
+ .and_return(["HEAD is now at abc123 Latest commit\n",
901
+ double(success?: true)])
902
+ end
903
+
904
+ it 'performs hard reset to origin/main' do
905
+ expect { cli.reset }.to output(%r{Successfully reset 'feature/test' to origin/main}).to_stdout
906
+ end
907
+ end
908
+
909
+ context 'with clean working directory' do
910
+ before do
911
+ allow(Open3).to receive(:capture2).with('git status --porcelain')
912
+ .and_return(['', double(success?: true)])
913
+ allow(Open3).to receive(:capture2e).with('git fetch origin main')
914
+ .and_return(['', double(success?: true)])
915
+ allow(Open3).to receive(:capture2e).with('git reset --hard origin/main')
916
+ .and_return(["Resetting to origin/main\n", double(success?: true)])
917
+ end
918
+
919
+ it 'performs reset to origin/main' do
920
+ expect { cli.reset }.to output(%r{Successfully reset 'feature/test' to origin/main}).to_stdout
921
+ end
922
+ end
923
+
924
+ context 'when fetch fails' do
925
+ before do
926
+ allow(Open3).to receive(:capture2).with('git status --porcelain')
927
+ .and_return(['', double(success?: true)])
928
+ allow(Open3).to receive(:capture2e).with('git fetch origin main')
929
+ .and_return(["fatal: couldn't find remote ref main",
930
+ double(success?: false)])
931
+ stub_exit
932
+ end
933
+
934
+ it 'exits with error message' do
935
+ expect { cli.reset }.to output(%r{Error: Failed to fetch origin/main}).to_stdout
936
+ .and raise_error(SystemExit)
937
+ end
938
+ end
939
+
940
+ context 'when reset fails' do
941
+ before do
942
+ allow(Open3).to receive(:capture2).with('git status --porcelain')
943
+ .and_return(['', double(success?: true)])
944
+ allow(Open3).to receive(:capture2e).with('git fetch origin main')
945
+ .and_return(['', double(success?: true)])
946
+ allow(Open3).to receive(:capture2e).with('git reset --hard origin/main')
947
+ .and_return(["fatal: ambiguous argument 'origin/main'",
948
+ double(success?: false)])
949
+ stub_exit
950
+ end
951
+
952
+ it 'exits with error message' do
953
+ expect { cli.reset }.to output(/Error: Failed to reset/).to_stdout
954
+ .and raise_error(SystemExit)
955
+ end
956
+ end
957
+
958
+ context 'with custom main branch name' do
959
+ before do
960
+ allow(config_manager).to receive(:main_branch_name).and_return('master')
961
+ allow(Open3).to receive(:capture2).with('git status --porcelain')
962
+ .and_return(['', double(success?: true)])
963
+ allow(Open3).to receive(:capture2e).with('git fetch origin master')
964
+ .and_return(['', double(success?: true)])
965
+ allow(Open3).to receive(:capture2e).with('git reset --hard origin/master')
966
+ .and_return(['', double(success?: true)])
967
+ end
968
+
969
+ it 'uses the configured main branch name' do
970
+ expect { cli.reset }.to output(%r{Successfully reset 'feature/test' to origin/master}).to_stdout
971
+ end
972
+ end
973
+ end
974
+ end
975
+ end
976
+ end