worktrees 0.1.0

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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/plan.md +42 -0
  3. data/.claude/commands/specify.md +12 -0
  4. data/.claude/commands/tasks.md +60 -0
  5. data/.cursor/commands/plan.md +42 -0
  6. data/.cursor/commands/specify.md +12 -0
  7. data/.cursor/commands/tasks.md +60 -0
  8. data/.cursor/rules +81 -0
  9. data/.specify/memory/constitution-v1.0.1-formal.md +90 -0
  10. data/.specify/memory/constitution.md +153 -0
  11. data/.specify/memory/constitution_update_checklist.md +88 -0
  12. data/.specify/scripts/bash/check-task-prerequisites.sh +15 -0
  13. data/.specify/scripts/bash/common.sh +37 -0
  14. data/.specify/scripts/bash/create-new-feature.sh +58 -0
  15. data/.specify/scripts/bash/get-feature-paths.sh +7 -0
  16. data/.specify/scripts/bash/setup-plan.sh +17 -0
  17. data/.specify/scripts/bash/update-agent-context.sh +57 -0
  18. data/.specify/templates/agent-file-template.md +23 -0
  19. data/.specify/templates/plan-template.md +254 -0
  20. data/.specify/templates/spec-template.md +116 -0
  21. data/.specify/templates/tasks-template.md +152 -0
  22. data/CLAUDE.md +145 -0
  23. data/Gemfile +15 -0
  24. data/Gemfile.lock +150 -0
  25. data/README.md +163 -0
  26. data/Rakefile +52 -0
  27. data/exe/worktrees +52 -0
  28. data/lib/worktrees/cli.rb +36 -0
  29. data/lib/worktrees/commands/create.rb +74 -0
  30. data/lib/worktrees/commands/list.rb +87 -0
  31. data/lib/worktrees/commands/remove.rb +62 -0
  32. data/lib/worktrees/commands/status.rb +95 -0
  33. data/lib/worktrees/commands/switch.rb +57 -0
  34. data/lib/worktrees/git_operations.rb +106 -0
  35. data/lib/worktrees/models/feature_worktree.rb +92 -0
  36. data/lib/worktrees/models/repository.rb +75 -0
  37. data/lib/worktrees/models/worktree_config.rb +74 -0
  38. data/lib/worktrees/version.rb +5 -0
  39. data/lib/worktrees/worktree_manager.rb +238 -0
  40. data/lib/worktrees.rb +27 -0
  41. data/specs/001-build-a-tool/GEMINI.md +20 -0
  42. data/specs/001-build-a-tool/contracts/cli-contracts.md +43 -0
  43. data/specs/001-build-a-tool/contracts/openapi.yaml +135 -0
  44. data/specs/001-build-a-tool/data-model.md +51 -0
  45. data/specs/001-build-a-tool/plan.md +241 -0
  46. data/specs/001-build-a-tool/quickstart.md +67 -0
  47. data/specs/001-build-a-tool/research.md +76 -0
  48. data/specs/001-build-a-tool/spec.md +153 -0
  49. data/specs/001-build-a-tool/tasks.md +209 -0
  50. metadata +138 -0
data/exe/worktrees ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Add lib directory to load path
5
+ lib_path = File.expand_path('../lib', __dir__)
6
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
7
+
8
+ require 'worktrees'
9
+
10
+ # Handle version flag
11
+ if ARGV.include?('--version') || ARGV.include?('-v')
12
+ puts "worktrees #{Worktrees::VERSION}"
13
+ exit(0)
14
+ end
15
+
16
+ # Handle help flag
17
+ if ARGV.empty? || ARGV.include?('--help') || ARGV.include?('-h')
18
+ puts <<~HELP
19
+ worktrees #{Worktrees::VERSION}
20
+
21
+ USAGE:
22
+ worktrees [OPTIONS] <COMMAND> [ARGS]
23
+
24
+ OPTIONS:
25
+ -h, --help Show help information
26
+ -v, --version Show version information
27
+ --verbose Enable verbose output
28
+
29
+ COMMANDS:
30
+ create (c) Create a new feature worktree
31
+ list (ls, l) List all worktrees for current repository
32
+ switch (sw, s) Switch to a different worktree
33
+ remove (rm, r) Remove a worktree safely
34
+ status (st) Show current worktree status
35
+
36
+ Use 'worktrees <command> --help' for more information about a command.
37
+
38
+ Examples:
39
+ worktrees create 001-new-feature
40
+ worktrees create 002-bug-fix main
41
+ worktrees list
42
+ worktrees list --format json
43
+ worktrees switch 001-new-feature
44
+ worktrees remove 001-new-feature
45
+ worktrees status
46
+
47
+ HELP
48
+ exit(0)
49
+ end
50
+
51
+ # Start the CLI application
52
+ Worktrees::App.start
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+
5
+ module Worktrees
6
+ module CLI
7
+ extend Dry::CLI::Registry
8
+
9
+ register 'create', Worktrees::Commands::Create, aliases: ['c']
10
+ register 'list', Worktrees::Commands::List, aliases: ['ls', 'l']
11
+ register 'switch', Worktrees::Commands::Switch, aliases: ['sw', 's']
12
+ register 'remove', Worktrees::Commands::Remove, aliases: ['rm', 'r']
13
+ register 'status', Worktrees::Commands::Status, aliases: ['st']
14
+ end
15
+
16
+ class App
17
+ def self.start
18
+ begin
19
+ # Ensure we're in a git repository
20
+ unless Dir.exist?('.git') || system('git rev-parse --git-dir >/dev/null 2>&1')
21
+ warn 'ERROR: Not in a git repository'
22
+ warn 'Run this command from inside a git repository'
23
+ exit(1)
24
+ end
25
+
26
+ Dry::CLI.new(CLI).call
27
+ rescue Interrupt
28
+ warn "\nInterrupted"
29
+ exit(130)
30
+ rescue StandardError => e
31
+ warn "ERROR: #{e.message}"
32
+ exit(1)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+
5
+ module Worktrees
6
+ module Commands
7
+ class Create < Dry::CLI::Command
8
+ desc 'Create a new feature worktree'
9
+
10
+ argument :name, required: true, desc: 'Feature name (NNN-kebab-feature format)'
11
+ argument :base_ref, required: false, desc: 'Base branch/commit (defaults to repository default)'
12
+
13
+ option :worktrees_root, type: :string, desc: 'Override worktrees root directory'
14
+ option :force, type: :boolean, default: false, desc: 'Create even if validation warnings exist'
15
+ option :switch, type: :boolean, default: false, desc: 'Switch to new worktree after creation'
16
+
17
+ def call(name:, base_ref: nil, **options)
18
+ validate_arguments(name, base_ref)
19
+
20
+ begin
21
+ # Create manager with options
22
+ config = Models::WorktreeConfig.load
23
+ if options[:'worktrees-root']
24
+ config = Models::WorktreeConfig.new(
25
+ worktrees_root: options[:'worktrees-root'],
26
+ default_base: config.default_base,
27
+ force_cleanup: config.force_cleanup
28
+ )
29
+ end
30
+
31
+ manager = WorktreeManager.new(nil, config)
32
+ create_options = options.select { |k, _| %i[force].include?(k) }
33
+
34
+ worktree = manager.create_worktree(name, base_ref, create_options)
35
+
36
+ # Display success message
37
+ puts "Created worktree: #{worktree.name}"
38
+ puts " Path: #{worktree.path}"
39
+ puts " Branch: #{worktree.branch}"
40
+ puts " Base: #{worktree.base_ref}"
41
+ puts " Status: #{worktree.status}"
42
+
43
+ # Switch to worktree if requested
44
+ if options[:switch]
45
+ manager.switch_to_worktree(name)
46
+ puts "\nSwitched to worktree: #{name}"
47
+ end
48
+
49
+ rescue ValidationError => e
50
+ warn "ERROR: Validation: #{e.message}"
51
+ exit(2)
52
+ rescue GitError => e
53
+ warn "ERROR: Git: #{e.message}"
54
+ exit(3)
55
+ rescue StateError => e
56
+ warn "ERROR: State: #{e.message}"
57
+ exit(3)
58
+ rescue StandardError => e
59
+ warn "ERROR: #{e.message}"
60
+ exit(1)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def validate_arguments(name, base_ref)
67
+ raise ValidationError, 'Name is required' if name.nil?
68
+ raise ValidationError, 'Name cannot be empty' if name.empty?
69
+
70
+ # Additional argument validation could go here
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require 'json'
5
+
6
+ module Worktrees
7
+ module Commands
8
+ class List < Dry::CLI::Command
9
+ desc 'List all worktrees for current repository'
10
+
11
+ option :format, type: :string, default: 'text', values: %w[text json csv], desc: 'Output format'
12
+ option :status_only, type: :boolean, default: false, desc: 'Show only status information'
13
+ option :filter, type: :string, desc: 'Filter by status (clean, dirty, active)'
14
+
15
+ def call(**options)
16
+ begin
17
+ manager = WorktreeManager.new
18
+
19
+ # Apply status filter
20
+ status_filter = options[:filter] ? options[:filter].to_sym : nil
21
+ worktrees = manager.list_worktrees(status: status_filter)
22
+
23
+ if worktrees.empty?
24
+ puts 'No worktrees found'
25
+ return
26
+ end
27
+
28
+ case options[:format]
29
+ when 'json'
30
+ output_json(worktrees)
31
+ when 'csv'
32
+ output_csv(worktrees)
33
+ else
34
+ output_text(worktrees, options[:status_only])
35
+ end
36
+
37
+ rescue GitError => e
38
+ warn "ERROR: Git: #{e.message}"
39
+ exit(3)
40
+ rescue StandardError => e
41
+ warn "ERROR: #{e.message}"
42
+ exit(1)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def output_text(worktrees, status_only = false)
49
+ worktrees.each do |worktree|
50
+ marker = worktree.active? ? '*' : ' '
51
+
52
+ if status_only
53
+ puts "#{marker} #{worktree.name} #{worktree.status}"
54
+ else
55
+ puts format('%s %-20s %-8s %-40s (from %s)',
56
+ marker,
57
+ worktree.name,
58
+ worktree.status,
59
+ worktree.path,
60
+ worktree.base_ref)
61
+ end
62
+ end
63
+ end
64
+
65
+ def output_json(worktrees)
66
+ data = {
67
+ worktrees: worktrees.map(&:to_h)
68
+ }
69
+ puts JSON.pretty_generate(data)
70
+ end
71
+
72
+ def output_csv(worktrees)
73
+ puts 'name,status,path,branch,base_ref,active'
74
+ worktrees.each do |worktree|
75
+ puts [
76
+ worktree.name,
77
+ worktree.status,
78
+ worktree.path,
79
+ worktree.branch,
80
+ worktree.base_ref,
81
+ worktree.active?
82
+ ].join(',')
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+
5
+ module Worktrees
6
+ module Commands
7
+ class Remove < Dry::CLI::Command
8
+ desc 'Remove a worktree safely'
9
+
10
+ argument :name, required: true, desc: 'Name of worktree to remove'
11
+
12
+ option :delete_branch, type: :boolean, default: false, desc: 'Also delete the associated branch (only if fully merged)'
13
+ option :force_untracked, type: :boolean, default: false, desc: 'Force removal even if untracked files exist'
14
+ option :merge_base, type: :string, desc: 'Specify merge base for branch deletion safety check'
15
+ option :force, type: :boolean, default: false, desc: 'Force removal (dangerous)'
16
+
17
+ def call(name:, **options)
18
+ begin
19
+ manager = WorktreeManager.new
20
+
21
+ # Build options hash
22
+ remove_options = {
23
+ delete_branch: options[:delete_branch],
24
+ force_untracked: options[:force_untracked],
25
+ force: options[:force]
26
+ }
27
+ remove_options[:merge_base] = options[:merge_base] if options[:merge_base]
28
+
29
+ # Remove the worktree
30
+ result = manager.remove_worktree(name, remove_options)
31
+
32
+ if result
33
+ puts "Removed worktree: #{name}"
34
+ puts " Path: (deleted)"
35
+
36
+ if options[:delete_branch]
37
+ puts " Branch: #{name} (deleted)"
38
+ else
39
+ puts " Branch: #{name} (kept)"
40
+ puts ''
41
+ puts 'Note: Use --delete-branch to also remove the branch if fully merged'
42
+ end
43
+ end
44
+
45
+ rescue ValidationError => e
46
+ warn "ERROR: Validation: #{e.message}"
47
+ warn 'Use \'worktrees list\' to see existing worktrees'
48
+ exit(2)
49
+ rescue StateError => e
50
+ warn "ERROR: Precondition: #{e.message}"
51
+ exit(3)
52
+ rescue GitError => e
53
+ warn "ERROR: Git: #{e.message}"
54
+ exit(3)
55
+ rescue StandardError => e
56
+ warn "ERROR: #{e.message}"
57
+ exit(1)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+ require 'json'
5
+
6
+ module Worktrees
7
+ module Commands
8
+ class Status < Dry::CLI::Command
9
+ desc 'Show current worktree status'
10
+
11
+ option :format, type: :string, default: 'text', values: %w[text json], desc: 'Output format'
12
+
13
+ def call(**options)
14
+ begin
15
+ manager = WorktreeManager.new
16
+ current_worktree = manager.current_worktree
17
+
18
+ if current_worktree.nil?
19
+ warn 'ERROR: Not in a worktree'
20
+ show_repository_info(manager, options[:format])
21
+ exit(4)
22
+ end
23
+
24
+ case options[:format]
25
+ when 'json'
26
+ output_json(current_worktree, manager)
27
+ else
28
+ output_text(current_worktree, manager)
29
+ end
30
+
31
+ rescue GitError => e
32
+ warn "ERROR: Git: #{e.message}"
33
+ exit(3)
34
+ rescue StandardError => e
35
+ warn "ERROR: #{e.message}"
36
+ exit(1)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def output_text(worktree, manager)
43
+ puts "Current worktree: #{worktree.name}"
44
+ puts " Path: #{worktree.path}"
45
+ puts " Branch: #{worktree.branch}"
46
+ puts " Base: #{worktree.base_ref}"
47
+
48
+ # Show detailed status
49
+ case worktree.status
50
+ when :dirty
51
+ # Could enhance to show number of modified files
52
+ puts " Status: dirty (modified files)"
53
+ else
54
+ puts " Status: #{worktree.status}"
55
+ end
56
+
57
+ puts ''
58
+ show_repository_info(manager, 'text')
59
+ end
60
+
61
+ def output_json(worktree, manager)
62
+ data = {
63
+ current_worktree: worktree.to_h,
64
+ repository: {
65
+ root_path: manager.repository.root_path,
66
+ worktrees_root: manager.config.expand_worktrees_root,
67
+ default_branch: manager.repository.default_branch,
68
+ remote_url: manager.repository.remote_url
69
+ }
70
+ }
71
+ puts JSON.pretty_generate(data)
72
+ end
73
+
74
+ def show_repository_info(manager, format)
75
+ if format == 'json'
76
+ data = {
77
+ repository: {
78
+ root_path: manager.repository.root_path,
79
+ worktrees_root: manager.config.expand_worktrees_root,
80
+ default_branch: manager.repository.default_branch,
81
+ remote_url: manager.repository.remote_url
82
+ }
83
+ }
84
+ puts JSON.pretty_generate(data)
85
+ else
86
+ puts "Repository: #{manager.repository.root_path}"
87
+ puts "Worktrees root: #{manager.config.expand_worktrees_root}"
88
+ if manager.repository.remote_url
89
+ puts "Remote: #{manager.repository.remote_url}"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/cli'
4
+
5
+ module Worktrees
6
+ module Commands
7
+ class Switch < Dry::CLI::Command
8
+ desc 'Switch to a different worktree'
9
+
10
+ argument :name, required: true, desc: 'Name of worktree to switch to'
11
+
12
+ option :force, type: :boolean, default: false, desc: 'Switch even if current worktree is dirty'
13
+
14
+ def call(name:, **options)
15
+ begin
16
+ manager = WorktreeManager.new
17
+
18
+ # Check if target worktree exists
19
+ target_worktree = manager.find_worktree(name)
20
+ unless target_worktree
21
+ raise ValidationError, "Worktree '#{name}' not found"
22
+ end
23
+
24
+ # Check current state for warnings/blocking
25
+ current = manager.current_worktree
26
+ if current && current.dirty? && !options[:force]
27
+ # Per requirements: allow switch with warning (not blocking)
28
+ warn "Warning: Previous worktree '#{current.name}' has uncommitted changes"
29
+ end
30
+
31
+ # Perform the switch
32
+ switched_worktree = manager.switch_to_worktree(name)
33
+
34
+ puts "Switched to worktree: #{switched_worktree.name}"
35
+ puts " Path: #{switched_worktree.path}"
36
+ puts " Branch: #{switched_worktree.branch}"
37
+ puts " Status: #{switched_worktree.status}"
38
+
39
+ # Show previous worktree warning if applicable
40
+ if current && current.dirty?
41
+ puts "\nWarning: Previous worktree '#{current.name}' has uncommitted changes"
42
+ end
43
+
44
+ rescue ValidationError => e
45
+ warn "ERROR: Validation: #{e.message}"
46
+ exit(2)
47
+ rescue StateError => e
48
+ warn "ERROR: State: #{e.message}"
49
+ exit(3)
50
+ rescue StandardError => e
51
+ warn "ERROR: #{e.message}"
52
+ exit(1)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Worktrees
4
+ module GitOperations
5
+ class << self
6
+ def create_worktree(path, branch, base_ref)
7
+ # Check if branch already exists
8
+ if branch_exists?(branch)
9
+ # Checkout existing branch
10
+ system('git', 'worktree', 'add', path, branch)
11
+ else
12
+ # Create new branch from base_ref
13
+ system('git', 'worktree', 'add', '-b', branch, path, base_ref)
14
+ end
15
+ end
16
+
17
+ def list_worktrees
18
+ begin
19
+ output = `git worktree list --porcelain`
20
+ raise GitError, "Failed to list worktrees: git command failed" unless $?.success?
21
+
22
+ parse_worktree_list(output)
23
+ rescue StandardError => e
24
+ raise GitError, "Failed to list worktrees: #{e.message}"
25
+ end
26
+ end
27
+
28
+ def remove_worktree(path, force: false)
29
+ args = ['git', 'worktree', 'remove']
30
+ args << '--force' if force
31
+ args << path
32
+
33
+ system(*args)
34
+ end
35
+
36
+ def branch_exists?(branch_name)
37
+ system('git', 'show-ref', '--verify', '--quiet', "refs/heads/#{branch_name}")
38
+ end
39
+
40
+ def current_branch
41
+ `git rev-parse --abbrev-ref HEAD`.strip
42
+ end
43
+
44
+ def is_clean?(worktree_path)
45
+ system('git', 'diff-index', '--quiet', 'HEAD', chdir: worktree_path)
46
+ end
47
+
48
+ def has_unpushed_commits?(branch_name)
49
+ begin
50
+ output = `git rev-list @{u}..HEAD`
51
+ $?.success? && !output.strip.empty?
52
+ rescue StandardError
53
+ # No upstream branch or other error - consider as no unpushed commits
54
+ false
55
+ end
56
+ end
57
+
58
+ def fetch_ref(ref)
59
+ system('git', 'fetch', 'origin', ref)
60
+ end
61
+
62
+ def delete_branch(branch_name, force: false)
63
+ flag = force ? '-D' : '-d'
64
+ system('git', 'branch', flag, branch_name)
65
+ end
66
+
67
+ def is_merged?(branch_name, base_branch = 'main')
68
+ # Check if all commits in branch_name are reachable from base_branch
69
+ system('git', 'merge-base', '--is-ancestor', branch_name, base_branch)
70
+ end
71
+
72
+ private
73
+
74
+ def parse_worktree_list(output)
75
+ worktrees = []
76
+ current_worktree = {}
77
+
78
+ output.each_line do |line|
79
+ line.strip!
80
+ next if line.empty?
81
+
82
+ case line
83
+ when /^worktree (.+)$/
84
+ # Save previous worktree if exists
85
+ worktrees << current_worktree unless current_worktree.empty?
86
+ current_worktree = { path: $1 }
87
+ when /^HEAD (.+)$/
88
+ current_worktree[:commit] = $1
89
+ when /^branch (.+)$/
90
+ current_worktree[:branch] = $1.sub('refs/heads/', '')
91
+ when /^detached$/
92
+ current_worktree[:detached] = true
93
+ when /^bare$/
94
+ current_worktree[:bare] = true
95
+ end
96
+ end
97
+
98
+ # Add the last worktree
99
+ worktrees << current_worktree unless current_worktree.empty?
100
+
101
+ # Filter out bare and main repository worktrees
102
+ worktrees.reject { |wt| wt[:bare] || wt[:path]&.end_with?('/.git') }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Worktrees
6
+ module Models
7
+ class FeatureWorktree
8
+ NAME_PATTERN = /^[0-9]{3}-[a-z0-9-]{1,40}$/.freeze
9
+ RESERVED_NAMES = %w[main master].freeze
10
+
11
+ attr_reader :name, :path, :branch, :base_ref, :status, :created_at, :repository_path
12
+
13
+ def initialize(name:, path:, branch:, base_ref:, status:, created_at:, repository_path:)
14
+ @name = name
15
+ @path = path
16
+ @branch = branch
17
+ @base_ref = base_ref
18
+ @status = status
19
+ @created_at = created_at
20
+ @repository_path = repository_path
21
+
22
+ validate!
23
+ end
24
+
25
+ def valid?
26
+ return false unless @name.is_a?(String) && @name.match?(NAME_PATTERN)
27
+
28
+ feature_part = @name.split('-', 2)[1]
29
+ return false if feature_part.nil?
30
+ return false if RESERVED_NAMES.include?(feature_part.downcase)
31
+
32
+ return false if @path.nil? || @path.empty?
33
+ return false if @branch.nil? || @branch.empty?
34
+ return false if @base_ref.nil? || @base_ref.empty?
35
+
36
+ true
37
+ end
38
+
39
+ def active?
40
+ @status == :active
41
+ end
42
+
43
+ def dirty?
44
+ @status == :dirty
45
+ end
46
+
47
+ def to_h
48
+ {
49
+ name: @name,
50
+ path: @path,
51
+ branch: @branch,
52
+ base_ref: @base_ref,
53
+ status: @status,
54
+ created_at: @created_at,
55
+ repository_path: @repository_path,
56
+ active: active?
57
+ }
58
+ end
59
+
60
+ def self.validate_name(name)
61
+ return false unless name.is_a?(String)
62
+ return false unless name.match?(NAME_PATTERN)
63
+
64
+ # Extract feature part after NNN-
65
+ feature_part = name.split('-', 2)[1]
66
+ return false if feature_part.nil?
67
+
68
+ # Check for reserved names
69
+ !RESERVED_NAMES.include?(feature_part.downcase)
70
+ end
71
+
72
+ private
73
+
74
+ def validate!
75
+ # First check basic format
76
+ unless @name.is_a?(String) && @name.match?(NAME_PATTERN)
77
+ raise ValidationError, "Invalid name format '#{@name}'. Names must match pattern: NNN-kebab-feature"
78
+ end
79
+
80
+ # Check for reserved names
81
+ feature_part = @name.split('-', 2)[1]
82
+ if feature_part && RESERVED_NAMES.include?(feature_part.downcase)
83
+ raise ValidationError, "Reserved name '#{feature_part}' not allowed in worktree names"
84
+ end
85
+
86
+ raise ValidationError, 'Path cannot be empty' if @path.nil? || @path.empty?
87
+ raise ValidationError, 'Branch cannot be empty' if @branch.nil? || @branch.empty?
88
+ raise ValidationError, 'Base reference cannot be empty' if @base_ref.nil? || @base_ref.empty?
89
+ end
90
+ end
91
+ end
92
+ end