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,64 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module WorktreeManager
|
4
|
+
class ConfigManager
|
5
|
+
DEFAULT_CONFIG_FILES = [
|
6
|
+
'.worktree.yml',
|
7
|
+
'.git/.worktree.yml'
|
8
|
+
].freeze
|
9
|
+
|
10
|
+
DEFAULT_WORKTREES_DIR = '../'
|
11
|
+
DEFAULT_MAIN_BRANCH_NAME = 'main'
|
12
|
+
|
13
|
+
def initialize(repository_path = '.')
|
14
|
+
@repository_path = File.expand_path(repository_path)
|
15
|
+
@config = load_config
|
16
|
+
end
|
17
|
+
|
18
|
+
def worktrees_dir
|
19
|
+
@config['worktrees_dir'] || DEFAULT_WORKTREES_DIR
|
20
|
+
end
|
21
|
+
|
22
|
+
def hooks
|
23
|
+
@config['hooks'] || {}
|
24
|
+
end
|
25
|
+
|
26
|
+
def main_branch_name
|
27
|
+
@config['main_branch_name'] || DEFAULT_MAIN_BRANCH_NAME
|
28
|
+
end
|
29
|
+
|
30
|
+
def resolve_worktree_path(name_or_path)
|
31
|
+
# If it's an absolute path, return as is
|
32
|
+
return name_or_path if name_or_path.start_with?('/')
|
33
|
+
|
34
|
+
# If it contains a path separator, treat it as a relative path
|
35
|
+
return File.expand_path(name_or_path, @repository_path) if name_or_path.include?('/')
|
36
|
+
|
37
|
+
# Otherwise, use worktrees_dir as the base
|
38
|
+
base_dir = File.expand_path(worktrees_dir, @repository_path)
|
39
|
+
File.join(base_dir, name_or_path)
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def load_config
|
45
|
+
config_file = find_config_file
|
46
|
+
return {} unless config_file
|
47
|
+
|
48
|
+
begin
|
49
|
+
YAML.load_file(config_file) || {}
|
50
|
+
rescue StandardError => e
|
51
|
+
puts "Warning: Failed to load config file #{config_file}: #{e.message}"
|
52
|
+
{}
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def find_config_file
|
57
|
+
DEFAULT_CONFIG_FILES.each do |file|
|
58
|
+
path = File.join(@repository_path, file)
|
59
|
+
return path if File.exist?(path)
|
60
|
+
end
|
61
|
+
nil
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,269 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'open3'
|
3
|
+
|
4
|
+
module WorktreeManager
|
5
|
+
class HookManager
|
6
|
+
HOOK_TYPES = %w[pre_add post_add pre_remove post_remove].freeze
|
7
|
+
DEFAULT_HOOK_FILES = [
|
8
|
+
'.worktree.yml',
|
9
|
+
'.git/.worktree.yml'
|
10
|
+
].freeze
|
11
|
+
|
12
|
+
def initialize(repository_path = '.', verbose: false)
|
13
|
+
@repository_path = File.expand_path(repository_path)
|
14
|
+
@verbose = verbose
|
15
|
+
@hooks = load_hooks
|
16
|
+
end
|
17
|
+
|
18
|
+
def execute_hook(hook_type, context = {})
|
19
|
+
log_debug("🪝 Starting hook execution: #{hook_type}")
|
20
|
+
|
21
|
+
return true unless HOOK_TYPES.include?(hook_type.to_s)
|
22
|
+
return true unless @hooks.key?(hook_type.to_s)
|
23
|
+
|
24
|
+
hook_config = @hooks[hook_type.to_s]
|
25
|
+
return true if hook_config.nil? || hook_config.empty?
|
26
|
+
|
27
|
+
log_debug("📋 Hook configuration: #{hook_config.inspect}")
|
28
|
+
log_debug("🔧 Context: #{context.inspect}")
|
29
|
+
|
30
|
+
result = case hook_config
|
31
|
+
when String
|
32
|
+
execute_command(hook_config, context, hook_type)
|
33
|
+
when Array
|
34
|
+
hook_config.all? { |command| execute_command(command, context, hook_type) }
|
35
|
+
when Hash
|
36
|
+
execute_hook_hash(hook_config, context, hook_type)
|
37
|
+
else
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
log_debug("✅ Hook execution completed: #{hook_type} (result: #{result})")
|
42
|
+
result
|
43
|
+
end
|
44
|
+
|
45
|
+
def has_hook?(hook_type)
|
46
|
+
HOOK_TYPES.include?(hook_type.to_s) &&
|
47
|
+
@hooks.key?(hook_type.to_s) &&
|
48
|
+
!@hooks[hook_type.to_s].nil?
|
49
|
+
end
|
50
|
+
|
51
|
+
def list_hooks
|
52
|
+
@hooks.select { |type, config| HOOK_TYPES.include?(type) && !config.nil? }
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def load_hooks
|
58
|
+
hook_file = find_hook_file
|
59
|
+
return {} unless hook_file
|
60
|
+
|
61
|
+
begin
|
62
|
+
config = YAML.load_file(hook_file) || {}
|
63
|
+
# Support new structure: read configuration under hooks key
|
64
|
+
if config.key?('hooks')
|
65
|
+
config['hooks']
|
66
|
+
else
|
67
|
+
# Support top-level keys for backward compatibility
|
68
|
+
config
|
69
|
+
end
|
70
|
+
rescue StandardError => e
|
71
|
+
puts "Warning: Failed to load hook file #{hook_file}: #{e.message}"
|
72
|
+
{}
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def find_hook_file
|
77
|
+
DEFAULT_HOOK_FILES.each do |file|
|
78
|
+
path = File.join(@repository_path, file)
|
79
|
+
return path if File.exist?(path)
|
80
|
+
end
|
81
|
+
nil
|
82
|
+
end
|
83
|
+
|
84
|
+
def execute_command(command, context, hook_type = nil, working_dir = nil)
|
85
|
+
log_debug("🚀 Executing command: #{command}")
|
86
|
+
|
87
|
+
env = build_env_vars(context)
|
88
|
+
log_debug("🌍 Environment variables: #{env.select { |k, _| k.start_with?('WORKTREE_') }}")
|
89
|
+
|
90
|
+
# Determine working directory
|
91
|
+
chdir = working_dir || default_working_directory(hook_type, context)
|
92
|
+
log_debug("📂 Working directory: #{chdir}")
|
93
|
+
|
94
|
+
start_time = Time.now
|
95
|
+
status = nil
|
96
|
+
|
97
|
+
# Execute command with environment variables and stream output
|
98
|
+
begin
|
99
|
+
if env && !env.empty?
|
100
|
+
# Verify environment variable is a Hash
|
101
|
+
log_debug("🔍 Environment variable type: #{env.class}")
|
102
|
+
log_debug("🔍 Environment variable sample: #{env.first(3).to_h}")
|
103
|
+
|
104
|
+
# Use popen3 for streaming output
|
105
|
+
Open3.popen3(env, 'sh', '-c', command, chdir: chdir) do |stdin, stdout, stderr, wait_thr|
|
106
|
+
stdin.close
|
107
|
+
|
108
|
+
# Create threads to read both stdout and stderr concurrently
|
109
|
+
threads = []
|
110
|
+
|
111
|
+
threads << Thread.new do
|
112
|
+
stdout.each_line do |line|
|
113
|
+
print line
|
114
|
+
STDOUT.flush
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
threads << Thread.new do
|
119
|
+
stderr.each_line do |line|
|
120
|
+
STDERR.print line
|
121
|
+
STDERR.flush
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
# Wait for all threads to complete
|
126
|
+
threads.each(&:join)
|
127
|
+
|
128
|
+
# Wait for the process to complete
|
129
|
+
status = wait_thr.value
|
130
|
+
end
|
131
|
+
else
|
132
|
+
Open3.popen3(command, chdir: chdir) do |stdin, stdout, stderr, wait_thr|
|
133
|
+
stdin.close
|
134
|
+
|
135
|
+
# Create threads to read both stdout and stderr concurrently
|
136
|
+
threads = []
|
137
|
+
|
138
|
+
threads << Thread.new do
|
139
|
+
stdout.each_line do |line|
|
140
|
+
print line
|
141
|
+
STDOUT.flush
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
threads << Thread.new do
|
146
|
+
stderr.each_line do |line|
|
147
|
+
STDERR.print line
|
148
|
+
STDERR.flush
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# Wait for all threads to complete
|
153
|
+
threads.each(&:join)
|
154
|
+
|
155
|
+
# Wait for the process to complete
|
156
|
+
status = wait_thr.value
|
157
|
+
end
|
158
|
+
end
|
159
|
+
rescue StandardError => e
|
160
|
+
log_debug("❌ Open3.popen3 error: #{e.class} - #{e.message}")
|
161
|
+
raise
|
162
|
+
end
|
163
|
+
|
164
|
+
duration = Time.now - start_time
|
165
|
+
log_debug("⏱️ Execution time: #{(duration * 1000).round(2)}ms")
|
166
|
+
|
167
|
+
unless status.success?
|
168
|
+
puts "Hook failed: #{command}"
|
169
|
+
log_debug("❌ Command execution failed: exit code #{status.exitstatus}")
|
170
|
+
return false
|
171
|
+
end
|
172
|
+
|
173
|
+
log_debug('✅ Command executed successfully')
|
174
|
+
true
|
175
|
+
end
|
176
|
+
|
177
|
+
def execute_hook_hash(hook_config, context, hook_type)
|
178
|
+
# Support new structure
|
179
|
+
commands = hook_config['commands'] || hook_config[:commands]
|
180
|
+
single_command = hook_config['command'] || hook_config[:command]
|
181
|
+
pwd = hook_config['pwd'] || hook_config[:pwd]
|
182
|
+
stop_on_error = hook_config.fetch('stop_on_error', true)
|
183
|
+
|
184
|
+
# Substitute environment variables if pwd is set
|
185
|
+
if pwd
|
186
|
+
pwd = pwd.gsub(/\$([A-Z_]+)/) do |match|
|
187
|
+
var_name = ::Regexp.last_match(1)
|
188
|
+
# First look for environment variables
|
189
|
+
if var_name == 'WORKTREE_ABSOLUTE_PATH' && context[:path]
|
190
|
+
path = context[:path]
|
191
|
+
path.start_with?('/') ? path : File.expand_path(path, @repository_path)
|
192
|
+
elsif %w[WORKTREE_MAIN WORKTREE_MANAGER_ROOT].include?(var_name)
|
193
|
+
@repository_path
|
194
|
+
elsif var_name.start_with?('WORKTREE_')
|
195
|
+
context_key = var_name.sub('WORKTREE_', '').downcase.to_sym
|
196
|
+
context[context_key]
|
197
|
+
else
|
198
|
+
ENV[var_name] || match
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
if commands && commands.is_a?(Array)
|
204
|
+
# Process commands array
|
205
|
+
commands.each do |cmd|
|
206
|
+
result = execute_command(cmd, context, hook_type, pwd)
|
207
|
+
return false if !result && stop_on_error
|
208
|
+
end
|
209
|
+
true
|
210
|
+
elsif single_command
|
211
|
+
# Process single command (backward compatibility)
|
212
|
+
execute_command(single_command, context, hook_type, pwd)
|
213
|
+
else
|
214
|
+
true
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def build_env_vars(context)
|
219
|
+
# Copy only necessary environment variables instead of ENV.to_h
|
220
|
+
env = {}
|
221
|
+
|
222
|
+
# Copy only basic environment variables (PATH, etc.)
|
223
|
+
%w[PATH HOME USER SHELL].each do |key|
|
224
|
+
env[key] = ENV[key] if ENV[key]
|
225
|
+
end
|
226
|
+
|
227
|
+
# Default environment variables
|
228
|
+
env['WORKTREE_MANAGER_ROOT'] = @repository_path
|
229
|
+
env['WORKTREE_MAIN'] = @repository_path # Main repository path
|
230
|
+
|
231
|
+
# Context-based environment variables
|
232
|
+
context.each do |key, value|
|
233
|
+
env_key = "WORKTREE_#{key.to_s.upcase}"
|
234
|
+
env[env_key] = value.to_s
|
235
|
+
end
|
236
|
+
|
237
|
+
# Add worktree absolute path
|
238
|
+
if context[:path]
|
239
|
+
path = context[:path]
|
240
|
+
abs_path = path.start_with?('/') ? path : File.expand_path(path, @repository_path)
|
241
|
+
env['WORKTREE_ABSOLUTE_PATH'] = abs_path
|
242
|
+
end
|
243
|
+
|
244
|
+
env
|
245
|
+
end
|
246
|
+
|
247
|
+
def default_working_directory(hook_type, context)
|
248
|
+
# post_add and pre_remove run in worktree directory by default
|
249
|
+
if %w[post_add pre_remove].include?(hook_type.to_s) && context[:path]
|
250
|
+
# Convert relative path to absolute path
|
251
|
+
path = context[:path]
|
252
|
+
if path.start_with?('/')
|
253
|
+
path
|
254
|
+
else
|
255
|
+
File.expand_path(path, @repository_path)
|
256
|
+
end
|
257
|
+
else
|
258
|
+
@repository_path
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
def log_debug(message)
|
263
|
+
return unless @verbose
|
264
|
+
|
265
|
+
timestamp = Time.now.strftime('%H:%M:%S.%3N')
|
266
|
+
puts "[#{timestamp}] [DEBUG] #{message}"
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
require 'open3'
|
2
|
+
require_relative 'worktree'
|
3
|
+
|
4
|
+
module WorktreeManager
|
5
|
+
class Manager
|
6
|
+
attr_reader :repository_path
|
7
|
+
|
8
|
+
def initialize(repository_path = '.')
|
9
|
+
@repository_path = File.expand_path(repository_path)
|
10
|
+
validate_git_repository!
|
11
|
+
end
|
12
|
+
|
13
|
+
def list
|
14
|
+
output, status = execute_git_command('worktree list --porcelain')
|
15
|
+
return [] unless status.success?
|
16
|
+
|
17
|
+
parse_worktree_list(output)
|
18
|
+
end
|
19
|
+
|
20
|
+
def add(path, branch = nil, force: false)
|
21
|
+
command = %w[worktree add]
|
22
|
+
command << '--force' if force
|
23
|
+
command << path
|
24
|
+
command << branch if branch
|
25
|
+
|
26
|
+
output, status = execute_git_command(command.join(' '))
|
27
|
+
raise Error, output unless status.success?
|
28
|
+
|
29
|
+
# Return created worktree information
|
30
|
+
worktree_info = { path: path }
|
31
|
+
worktree_info[:branch] = branch if branch
|
32
|
+
Worktree.new(worktree_info)
|
33
|
+
end
|
34
|
+
|
35
|
+
def add_with_new_branch(path, branch, force: false)
|
36
|
+
command = %w[worktree add]
|
37
|
+
command << '--force' if force
|
38
|
+
command << '-b' << branch
|
39
|
+
command << path
|
40
|
+
|
41
|
+
output, status = execute_git_command(command.join(' '))
|
42
|
+
raise Error, output unless status.success?
|
43
|
+
|
44
|
+
# Return created worktree information
|
45
|
+
Worktree.new(path: path, branch: branch)
|
46
|
+
end
|
47
|
+
|
48
|
+
def add_tracking_branch(path, local_branch, remote_branch, force: false)
|
49
|
+
# Fetch the remote branch first
|
50
|
+
remote, branch_name = remote_branch.split('/', 2)
|
51
|
+
fetch_output, fetch_status = execute_git_command("fetch #{remote} #{branch_name}")
|
52
|
+
raise Error, "Failed to fetch remote branch: #{fetch_output}" unless fetch_status.success?
|
53
|
+
|
54
|
+
# Create worktree with new branch tracking the remote
|
55
|
+
command = %w[worktree add]
|
56
|
+
command << '--force' if force
|
57
|
+
command << '-b' << local_branch
|
58
|
+
command << path
|
59
|
+
command << "#{remote_branch}"
|
60
|
+
|
61
|
+
output, status = execute_git_command(command.join(' '))
|
62
|
+
raise Error, output unless status.success?
|
63
|
+
|
64
|
+
# Return created worktree information
|
65
|
+
Worktree.new(path: path, branch: local_branch)
|
66
|
+
end
|
67
|
+
|
68
|
+
def remove(path, force: false)
|
69
|
+
command = %w[worktree remove]
|
70
|
+
command << '--force' if force
|
71
|
+
command << path
|
72
|
+
|
73
|
+
output, status = execute_git_command(command.join(' '))
|
74
|
+
raise Error, output unless status.success?
|
75
|
+
|
76
|
+
true
|
77
|
+
end
|
78
|
+
|
79
|
+
def prune
|
80
|
+
output, status = execute_git_command('worktree prune')
|
81
|
+
raise Error, output unless status.success?
|
82
|
+
|
83
|
+
true
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def validate_git_repository!
|
89
|
+
return if File.directory?(File.join(@repository_path, '.git'))
|
90
|
+
|
91
|
+
raise Error, "Not a git repository: #{@repository_path}"
|
92
|
+
end
|
93
|
+
|
94
|
+
def execute_git_command(command)
|
95
|
+
full_command = "git -C #{@repository_path} #{command}"
|
96
|
+
Open3.capture2e(full_command)
|
97
|
+
end
|
98
|
+
|
99
|
+
def parse_worktree_list(output)
|
100
|
+
worktrees = []
|
101
|
+
current_worktree = {}
|
102
|
+
|
103
|
+
output.lines.each do |line|
|
104
|
+
line.strip!
|
105
|
+
next if line.empty?
|
106
|
+
|
107
|
+
case line
|
108
|
+
when /^worktree (.+)$/
|
109
|
+
worktrees << Worktree.new(current_worktree) unless current_worktree.empty?
|
110
|
+
current_worktree = { path: ::Regexp.last_match(1) }
|
111
|
+
when /^HEAD (.+)$/
|
112
|
+
current_worktree[:head] = ::Regexp.last_match(1)
|
113
|
+
when /^branch (.+)$/
|
114
|
+
current_worktree[:branch] = ::Regexp.last_match(1)
|
115
|
+
when /^detached$/
|
116
|
+
current_worktree[:detached] = true
|
117
|
+
when /^bare$/
|
118
|
+
current_worktree[:bare] = true
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
worktrees << Worktree.new(current_worktree) unless current_worktree.empty?
|
123
|
+
worktrees
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module WorktreeManager
|
2
|
+
class Worktree
|
3
|
+
attr_reader :path, :branch, :head, :detached, :bare
|
4
|
+
|
5
|
+
def initialize(attributes = {})
|
6
|
+
case attributes
|
7
|
+
when Hash
|
8
|
+
@path = attributes[:path]
|
9
|
+
@branch = attributes[:branch]
|
10
|
+
@head = attributes[:head]
|
11
|
+
@detached = attributes[:detached] || false
|
12
|
+
@bare = attributes[:bare] || false
|
13
|
+
when String
|
14
|
+
@path = attributes
|
15
|
+
@branch = nil
|
16
|
+
@head = nil
|
17
|
+
@detached = false
|
18
|
+
@bare = false
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def detached?
|
23
|
+
@detached
|
24
|
+
end
|
25
|
+
|
26
|
+
def bare?
|
27
|
+
@bare
|
28
|
+
end
|
29
|
+
|
30
|
+
def main?
|
31
|
+
@branch == 'main' || @branch == 'master'
|
32
|
+
end
|
33
|
+
|
34
|
+
def exists?
|
35
|
+
File.directory?(@path)
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_s
|
39
|
+
if @branch
|
40
|
+
"#{@path} (#{@branch})"
|
41
|
+
else
|
42
|
+
"#{@path} (#{@head})"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def to_h
|
47
|
+
{
|
48
|
+
path: @path,
|
49
|
+
branch: @branch,
|
50
|
+
head: @head,
|
51
|
+
detached: @detached,
|
52
|
+
bare: @bare
|
53
|
+
}
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
require_relative 'worktree_manager/version'
|
2
|
+
require_relative 'worktree_manager/manager'
|
3
|
+
require_relative 'worktree_manager/worktree'
|
4
|
+
|
5
|
+
module WorktreeManager
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
def self.new(repository_path = '.')
|
9
|
+
Manager.new(repository_path)
|
10
|
+
end
|
11
|
+
end
|
data/mise/release.sh
ADDED
@@ -0,0 +1,120 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
|
3
|
+
set -e
|
4
|
+
|
5
|
+
# Set default version increment if not provided
|
6
|
+
VERSION_INCREMENT="${1:-0.0.1}"
|
7
|
+
|
8
|
+
# Validate version increment format
|
9
|
+
if [[ ! "$VERSION_INCREMENT" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
10
|
+
echo "Error: Version increment must be in format X.Y.Z (e.g., 0.0.1)"
|
11
|
+
exit 1
|
12
|
+
fi
|
13
|
+
|
14
|
+
# Change to the project root directory
|
15
|
+
cd "$(dirname "$0")/.."
|
16
|
+
|
17
|
+
# Check if git repository is clean
|
18
|
+
if [ -n "$(git status --porcelain)" ]; then
|
19
|
+
echo "Error: Git repository is not clean. Please commit or stash your changes."
|
20
|
+
git status --short
|
21
|
+
exit 1
|
22
|
+
fi
|
23
|
+
|
24
|
+
# Clean up old gem files
|
25
|
+
echo "Cleaning up old gem files..."
|
26
|
+
rm -f worktree_manager-*.gem
|
27
|
+
|
28
|
+
# Read current version from .version file
|
29
|
+
if [ ! -f .version ]; then
|
30
|
+
echo "Error: .version file not found"
|
31
|
+
exit 1
|
32
|
+
fi
|
33
|
+
|
34
|
+
CURRENT_VERSION=$(cat .version)
|
35
|
+
echo "Current version: $CURRENT_VERSION"
|
36
|
+
|
37
|
+
# Parse version components
|
38
|
+
IFS='.' read -ra CURRENT_PARTS <<< "$CURRENT_VERSION"
|
39
|
+
IFS='.' read -ra INCREMENT_PARTS <<< "$VERSION_INCREMENT"
|
40
|
+
|
41
|
+
# Validate current version format
|
42
|
+
if [ ${#CURRENT_PARTS[@]} -ne 3 ]; then
|
43
|
+
echo "Error: Current version must be in format X.Y.Z"
|
44
|
+
exit 1
|
45
|
+
fi
|
46
|
+
|
47
|
+
# Calculate new version
|
48
|
+
MAJOR=$((CURRENT_PARTS[0] + INCREMENT_PARTS[0]))
|
49
|
+
MINOR=$((CURRENT_PARTS[1] + INCREMENT_PARTS[1]))
|
50
|
+
PATCH=$((CURRENT_PARTS[2] + INCREMENT_PARTS[2]))
|
51
|
+
|
52
|
+
# Handle carry-over
|
53
|
+
if [ $PATCH -ge 10 ]; then
|
54
|
+
MINOR=$((MINOR + PATCH / 10))
|
55
|
+
PATCH=$((PATCH % 10))
|
56
|
+
fi
|
57
|
+
|
58
|
+
if [ $MINOR -ge 10 ]; then
|
59
|
+
MAJOR=$((MAJOR + MINOR / 10))
|
60
|
+
MINOR=$((MINOR % 10))
|
61
|
+
fi
|
62
|
+
|
63
|
+
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
64
|
+
echo "New version: $NEW_VERSION"
|
65
|
+
|
66
|
+
# Update .version file FIRST
|
67
|
+
echo "$NEW_VERSION" > .version
|
68
|
+
|
69
|
+
# Update Gemfile.lock with new version
|
70
|
+
echo "Updating Gemfile.lock..."
|
71
|
+
bundle install
|
72
|
+
|
73
|
+
# Build the gem with new version
|
74
|
+
echo "Building gem with version $NEW_VERSION..."
|
75
|
+
if gem build worktree_manager.gemspec; then
|
76
|
+
echo "Gem built successfully: worktree_manager-$NEW_VERSION.gem"
|
77
|
+
|
78
|
+
# Git operations
|
79
|
+
echo "Committing version bump..."
|
80
|
+
git add .version Gemfile.lock
|
81
|
+
git commit -m "Bump version to $NEW_VERSION"
|
82
|
+
|
83
|
+
echo "Creating git tag..."
|
84
|
+
git tag -a "v$NEW_VERSION" -m "Release version $NEW_VERSION"
|
85
|
+
|
86
|
+
echo "Pushing to remote..."
|
87
|
+
git push origin
|
88
|
+
git push origin "v$NEW_VERSION"
|
89
|
+
|
90
|
+
# Ask about publishing to RubyGems.org
|
91
|
+
echo ""
|
92
|
+
read -p "Publish to RubyGems.org? (y/N): " publish_confirm
|
93
|
+
|
94
|
+
if [[ "$publish_confirm" =~ ^[Yy]$ ]]; then
|
95
|
+
echo "Publishing to RubyGems.org..."
|
96
|
+
if gem push worktree_manager-$NEW_VERSION.gem; then
|
97
|
+
echo "✓ Gem published successfully to RubyGems.org"
|
98
|
+
echo ""
|
99
|
+
echo "View your gem at: https://rubygems.org/gems/worktree_manager"
|
100
|
+
else
|
101
|
+
echo "Error: RubyGems.org push failed"
|
102
|
+
echo "You can manually publish with:"
|
103
|
+
echo " gem push worktree_manager-$NEW_VERSION.gem"
|
104
|
+
exit 1
|
105
|
+
fi
|
106
|
+
else
|
107
|
+
echo "Skipping RubyGems.org publishing."
|
108
|
+
echo "You can manually publish later with:"
|
109
|
+
echo " gem push worktree_manager-$NEW_VERSION.gem"
|
110
|
+
fi
|
111
|
+
|
112
|
+
echo "Release complete!"
|
113
|
+
echo "Version $NEW_VERSION has been released."
|
114
|
+
else
|
115
|
+
echo "Error: Gem build failed with new version"
|
116
|
+
# Revert version change
|
117
|
+
echo "$CURRENT_VERSION" > .version
|
118
|
+
bundle install # Revert Gemfile.lock
|
119
|
+
exit 1
|
120
|
+
fi
|
Binary file
|