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.
- checksums.yaml +7 -0
- data/.claude/commands/plan.md +42 -0
- data/.claude/commands/specify.md +12 -0
- data/.claude/commands/tasks.md +60 -0
- data/.cursor/commands/plan.md +42 -0
- data/.cursor/commands/specify.md +12 -0
- data/.cursor/commands/tasks.md +60 -0
- data/.cursor/rules +81 -0
- data/.specify/memory/constitution-v1.0.1-formal.md +90 -0
- data/.specify/memory/constitution.md +153 -0
- data/.specify/memory/constitution_update_checklist.md +88 -0
- data/.specify/scripts/bash/check-task-prerequisites.sh +15 -0
- data/.specify/scripts/bash/common.sh +37 -0
- data/.specify/scripts/bash/create-new-feature.sh +58 -0
- data/.specify/scripts/bash/get-feature-paths.sh +7 -0
- data/.specify/scripts/bash/setup-plan.sh +17 -0
- data/.specify/scripts/bash/update-agent-context.sh +57 -0
- data/.specify/templates/agent-file-template.md +23 -0
- data/.specify/templates/plan-template.md +254 -0
- data/.specify/templates/spec-template.md +116 -0
- data/.specify/templates/tasks-template.md +152 -0
- data/CLAUDE.md +145 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +150 -0
- data/README.md +163 -0
- data/Rakefile +52 -0
- data/exe/worktrees +52 -0
- data/lib/worktrees/cli.rb +36 -0
- data/lib/worktrees/commands/create.rb +74 -0
- data/lib/worktrees/commands/list.rb +87 -0
- data/lib/worktrees/commands/remove.rb +62 -0
- data/lib/worktrees/commands/status.rb +95 -0
- data/lib/worktrees/commands/switch.rb +57 -0
- data/lib/worktrees/git_operations.rb +106 -0
- data/lib/worktrees/models/feature_worktree.rb +92 -0
- data/lib/worktrees/models/repository.rb +75 -0
- data/lib/worktrees/models/worktree_config.rb +74 -0
- data/lib/worktrees/version.rb +5 -0
- data/lib/worktrees/worktree_manager.rb +238 -0
- data/lib/worktrees.rb +27 -0
- data/specs/001-build-a-tool/GEMINI.md +20 -0
- data/specs/001-build-a-tool/contracts/cli-contracts.md +43 -0
- data/specs/001-build-a-tool/contracts/openapi.yaml +135 -0
- data/specs/001-build-a-tool/data-model.md +51 -0
- data/specs/001-build-a-tool/plan.md +241 -0
- data/specs/001-build-a-tool/quickstart.md +67 -0
- data/specs/001-build-a-tool/research.md +76 -0
- data/specs/001-build-a-tool/spec.md +153 -0
- data/specs/001-build-a-tool/tasks.md +209 -0
- 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
|