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.
@@ -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,3 @@
1
+ module WorktreeManager
2
+ VERSION = File.read(File.join(File.dirname(__FILE__), '../../.version')).strip
3
+ 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