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