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.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +52 -0
- data/.gitignore +37 -0
- data/.mise.toml +9 -0
- data/.rspec +3 -0
- data/.version +1 -0
- data/.worktree.yml.example +45 -0
- data/CHANGELOG.md +50 -0
- data/CLAUDE.md +17 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +53 -0
- data/LICENSE +9 -0
- data/README.md +391 -0
- data/Rakefile +6 -0
- data/bin/wm +6 -0
- data/bin/worktree_manager +6 -0
- data/lib/worktree_manager/cli.rb +794 -0
- data/lib/worktree_manager/config_manager.rb +64 -0
- data/lib/worktree_manager/hook_manager.rb +269 -0
- data/lib/worktree_manager/manager.rb +126 -0
- data/lib/worktree_manager/version.rb +3 -0
- data/lib/worktree_manager/worktree.rb +56 -0
- data/lib/worktree_manager.rb +11 -0
- data/mise/release.sh +120 -0
- data/pkg/worktree_manager-0.1.0.gem +0 -0
- data/spec/integration/worktree_integration_spec.rb +288 -0
- data/spec/spec_helper.rb +34 -0
- data/spec/worktree_manager/cli_help_spec.rb +154 -0
- data/spec/worktree_manager/cli_spec.rb +976 -0
- data/spec/worktree_manager/config_manager_spec.rb +172 -0
- data/spec/worktree_manager/hook_manager_spec.rb +581 -0
- data/spec/worktree_manager/manager_spec.rb +95 -0
- data/spec/worktree_manager/worktree_spec.rb +83 -0
- data/spec/worktree_manager_spec.rb +14 -0
- data/worktree_manager.gemspec +33 -0
- metadata +136 -0
@@ -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
|