git_worktree_manager 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b65284335c00fd4d1c52d5ec1502638ffa09124b1287d8530dcb6b5771bbc77c
4
+ data.tar.gz: 9601148263bd10cd38311c94de945be980fa0f8fc8f3848317ee5e307aa87d9c
5
+ SHA512:
6
+ metadata.gz: 91881dfa53a2277f76e2994132ab28ba083d3bb433ddd607fbed542a14a803010a483c6b9f79903cfc6a4b1de96a0f039f7b2c7b3c63389af4003de05d771d47
7
+ data.tar.gz: 9751f6b855a7ccc289c506ea4d9c4fe41929a27e9849de47673f894120e2ef5aa3185427bc0065b924d7148d404423f16fe92718a1f94998a9994dc768900871
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Franck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # Git Worktree Manager
2
+
3
+ A shell-agnostic Ruby gem for managing git worktrees with isolated databases, unique ports for Rails and Vite servers, and separate environments.
4
+
5
+ ## Features
6
+
7
+ - ✅ **Shell Agnostic** - Works with bash, zsh, fish, or any POSIX shell
8
+ - ✅ **Isolated Databases** - Each worktree gets its own PostgreSQL database
9
+ - ✅ **Automatic Port Assignment** - Rails (3001+) and Vite (5174+) ports
10
+ - ✅ **Environment Management** - Copies and configures .env files
11
+ - ✅ **Dependency Installation** - Auto-runs bundle/yarn install
12
+ - ✅ **Easy Cleanup** - Remove worktrees and databases with one command
13
+
14
+ ## Installation
15
+
16
+ Add to your `Gemfile`:
17
+
18
+ ```ruby
19
+ gem 'git_worktree_manager'
20
+ ```
21
+
22
+ Then run:
23
+
24
+ ```bash
25
+ bundle install
26
+ rails generate git_worktree_manager:install
27
+ ```
28
+
29
+ This will install the `worktree` command in your project's `bin/` directory.
30
+
31
+ ### Shell Function (for cd functionality)
32
+
33
+ Add this to your shell config file:
34
+
35
+ **For Bash/Zsh (`~/.bashrc` or `~/.zshrc`):**
36
+
37
+ ```bash
38
+ worktree() {
39
+ ./bin/worktree "$@"
40
+ if [ -f /tmp/worktree_cd_$$ ]; then
41
+ cd "$(cat /tmp/worktree_cd_$$)"
42
+ rm /tmp/worktree_cd_$$
43
+ fi
44
+ }
45
+ ```
46
+
47
+ **For Fish (`~/.config/fish/config.fish`):**
48
+
49
+ ```fish
50
+ function worktree
51
+ ./bin/worktree $argv
52
+ if test -f /tmp/worktree_cd_(echo %self)
53
+ cd (cat /tmp/worktree_cd_(echo %self))
54
+ rm /tmp/worktree_cd_(echo %self)
55
+ end
56
+ end
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ On first use, the gem will auto-detect your main database name from `config/database.yml` or `.env` files. You can view or change the configuration:
62
+
63
+ ```bash
64
+ worktree config
65
+ worktree config --main-database=myapp_development
66
+ worktree config --database-prefix=myapp
67
+ ```
68
+
69
+ The configuration is stored in `.worktree_config.yml` in your project root.
70
+
71
+ ## Usage
72
+
73
+ ```bash
74
+ worktree create feature-branch-name
75
+ worktree list
76
+ worktree status feature-branch-name
77
+ worktree start feature-branch-name
78
+ worktree remove feature-branch-name
79
+ worktree cleanup
80
+ worktree config
81
+ ```
82
+
83
+ ### Options
84
+
85
+ **Create:**
86
+ - `--copy-data` - Copy data from main database to worktree database
87
+ - `--no-install` - Skip bundle/yarn install
88
+
89
+ ## Requirements
90
+
91
+ - Ruby >= 3.0.0
92
+ - Git
93
+ - PostgreSQL (optional, only if using database features)
94
+ - Rails project
95
+
96
+ ## Recommendations
97
+
98
+ Add `.worktree_config.yml` to your `.gitignore` if you don't want to share worktree configuration with your team:
99
+
100
+ ```bash
101
+ echo ".worktree_config.yml" >> .gitignore
102
+ ```
103
+
104
+ ## License
105
+
106
+ MIT License
data/bin/worktree ADDED
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'bundler/setup'
5
+ require 'git_worktree_manager'
6
+ rescue LoadError => e
7
+ $stderr.puts "Error loading git_worktree_manager: #{e.message}"
8
+ $stderr.puts "Make sure the gem is installed: bundle install"
9
+ exit 1
10
+ end
11
+
12
+ begin
13
+ GitWorktreeManager::CLI.start(ARGV)
14
+ rescue Interrupt
15
+ puts "\n"
16
+ puts "Interrupted"
17
+ exit 130
18
+ rescue => e
19
+ puts "Error: #{e.message}"
20
+ puts e.backtrace if ENV['DEBUG']
21
+ exit 1
22
+ end
@@ -0,0 +1,34 @@
1
+ require 'rails/generators'
2
+
3
+ module GitWorktreeManager
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ source_root File.expand_path('../../../..', __dir__)
7
+
8
+ def copy_executable
9
+ template 'bin/worktree', 'bin/worktree'
10
+ chmod 'bin/worktree', 0o755
11
+ end
12
+
13
+ def show_readme
14
+ readme 'INSTALL_README' if behavior == :invoke
15
+ end
16
+
17
+ private
18
+
19
+ def readme(_filename)
20
+ say "\n" + '=' * 80
21
+ say 'Git Worktree Manager installed successfully!'
22
+ say '=' * 80
23
+ say "\nThe 'worktree' command has been added to your project's bin/ directory."
24
+ say "\nYou can now use it with:"
25
+ say ' ./bin/worktree', :green
26
+ say "\nOr add ./bin to your PATH to use it directly:"
27
+ say ' export PATH="./bin:$PATH"', :cyan
28
+ say ' worktree', :green
29
+ say "\nRun './bin/worktree help' to see available commands."
30
+ say '=' * 80 + "\n"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,53 @@
1
+ require 'thor'
2
+
3
+ module GitWorktreeManager
4
+ class CLI < Thor
5
+ desc 'create BRANCH', 'Create a new worktree for a branch'
6
+ option :copy_data, type: :boolean, default: false, desc: 'Copy database from main worktree'
7
+ option :no_install, type: :boolean, default: false, desc: 'Skip bundle/yarn install'
8
+ def create(branch)
9
+ Worktree.new.create(
10
+ branch,
11
+ copy_data: options[:copy_data],
12
+ no_install: options[:no_install]
13
+ )
14
+ end
15
+
16
+ desc 'list', 'List all worktrees with their info'
17
+ def list
18
+ Worktree.new.list
19
+ end
20
+
21
+ desc 'status BRANCH', 'Show status of a specific worktree'
22
+ def status(branch)
23
+ Worktree.new.status(branch)
24
+ end
25
+
26
+ desc 'start BRANCH', 'Start tmux session with servers for a worktree'
27
+ def start(branch)
28
+ Worktree.new.start(branch)
29
+ end
30
+
31
+ desc 'remove BRANCH', 'Remove a worktree and its database'
32
+ option :force, type: :boolean, default: false, aliases: '-f', desc: 'Force removal without confirmation'
33
+ def remove(branch)
34
+ Worktree.new.remove(branch, force: options[:force])
35
+ end
36
+
37
+ desc 'cleanup', 'Clean up stale worktree references'
38
+ def cleanup
39
+ Worktree.new.cleanup
40
+ end
41
+
42
+ desc 'config', 'Show or set configuration'
43
+ option :main_database, type: :string, desc: 'Set the main database name'
44
+ option :database_prefix, type: :string, desc: 'Set the database prefix for worktrees'
45
+ def config
46
+ Worktree.new.config(options)
47
+ end
48
+
49
+ def self.exit_on_failure?
50
+ true
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,139 @@
1
+ require 'yaml'
2
+ require 'fileutils'
3
+ require 'time'
4
+
5
+ module GitWorktreeManager
6
+ class Config
7
+ attr_reader :config_file, :root_path
8
+
9
+ def initialize(root_path)
10
+ @root_path = root_path
11
+ @config_file = File.join(root_path, '.worktree_config.yml')
12
+ @config = load_config
13
+ ensure_settings
14
+ end
15
+
16
+ def main_database
17
+ @config['settings']&.dig('main_database') || detect_main_database
18
+ end
19
+
20
+ def database_prefix
21
+ @config['settings']&.dig('database_prefix') || File.basename(@root_path)
22
+ end
23
+
24
+ def save_settings(main_database: nil, database_prefix: nil)
25
+ @config['settings'] ||= {}
26
+ @config['settings']['main_database'] = main_database if main_database
27
+ @config['settings']['database_prefix'] = database_prefix if database_prefix
28
+ save_config
29
+ end
30
+
31
+ def save_worktree(branch, path:, rails_port:, vite_port:, database:)
32
+ @config[normalize_branch_name(branch)] = {
33
+ 'path' => path,
34
+ 'rails_port' => rails_port,
35
+ 'vite_port' => vite_port,
36
+ 'database' => database,
37
+ 'created_at' => Time.now.iso8601
38
+ }
39
+ save_config
40
+ end
41
+
42
+ def get_worktree(branch)
43
+ @config[normalize_branch_name(branch)]
44
+ end
45
+
46
+ def remove_worktree(branch)
47
+ @config.delete(normalize_branch_name(branch))
48
+ save_config
49
+ end
50
+
51
+ def all_worktrees
52
+ @config.select { |_k, v| v.is_a?(Hash) && v['path'] }
53
+ end
54
+
55
+ def next_ports
56
+ return [3001, 5174] if @config.empty?
57
+
58
+ rails_ports = @config.values.map { |v| v['rails_port'] }.compact
59
+ vite_ports = @config.values.map { |v| v['vite_port'] }.compact
60
+
61
+ max_rails = rails_ports.max || 3000
62
+ max_vite = vite_ports.max || 5173
63
+
64
+ rails_port = max_rails >= 3001 ? max_rails + 1 : 3001
65
+ vite_port = max_vite >= 5174 ? max_vite + 1 : 5174
66
+
67
+ [rails_port, vite_port]
68
+ end
69
+
70
+ private
71
+
72
+ def normalize_branch_name(branch)
73
+ branch.to_s.gsub(/[^a-zA-Z0-9_]/, '_')
74
+ end
75
+
76
+ def load_config
77
+ return {} unless File.exist?(@config_file)
78
+
79
+ YAML.load_file(@config_file) || {}
80
+ rescue StandardError => e
81
+ warn "Warning: Could not load config file: #{e.message}"
82
+ {}
83
+ end
84
+
85
+ def save_config
86
+ FileUtils.mkdir_p(File.dirname(@config_file))
87
+ File.write(@config_file, YAML.dump(@config))
88
+ end
89
+
90
+ def ensure_settings
91
+ return if @config['settings'] && @config['settings']['main_database'] && @config['settings']['database_prefix']
92
+
93
+ @config['settings'] ||= {}
94
+ @config['settings']['main_database'] ||= detect_main_database
95
+ @config['settings']['database_prefix'] ||= File.basename(@root_path)
96
+ save_config
97
+ end
98
+
99
+ def detect_main_database
100
+ db_config_path = File.join(@root_path, 'config', 'database.yml')
101
+
102
+ if File.exist?(db_config_path)
103
+ require 'yaml'
104
+ db_config = YAML.load_file(db_config_path)
105
+ return db_config.dig('development', 'database') if db_config.dig('development', 'database')
106
+ end
107
+
108
+ env_local = File.join(@root_path, '.env.local')
109
+ env_file = File.join(@root_path, '.env')
110
+
111
+ [env_local, env_file].each do |env_path|
112
+ next unless File.exist?(env_path)
113
+
114
+ File.readlines(env_path).each do |line|
115
+ line = line.strip
116
+ next if line.empty? || line.start_with?('#')
117
+
118
+ return ::Regexp.last_match(1).strip.gsub(/^["']|["']$/, '') if line =~ /^DB_NAME=(.+)$/
119
+ end
120
+ end
121
+
122
+ "#{File.basename(@root_path)}_development"
123
+ end
124
+
125
+ def load_config
126
+ return {} unless File.exist?(@config_file)
127
+
128
+ YAML.load_file(@config_file) || {}
129
+ rescue StandardError => e
130
+ warn "Warning: Could not load config file: #{e.message}"
131
+ {}
132
+ end
133
+
134
+ def save_config
135
+ FileUtils.mkdir_p(File.dirname(@config_file))
136
+ File.write(@config_file, YAML.dump(@config))
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,81 @@
1
+ require 'shellwords'
2
+
3
+ module GitWorktreeManager
4
+ class Database
5
+ def initialize(credentials, logger)
6
+ @credentials = credentials
7
+ @logger = logger
8
+ end
9
+
10
+ def create(database_name)
11
+ run_rails_command("db:create", database_name)
12
+ end
13
+
14
+ def migrate(database_name)
15
+ run_rails_command("db:migrate", database_name)
16
+ end
17
+
18
+ def drop(database_name)
19
+ cmd = build_dropdb_command(database_name)
20
+ env = { 'PGPASSWORD' => @credentials[:password].to_s }
21
+
22
+ success = system(env, cmd, out: File::NULL, err: File::NULL)
23
+
24
+ if success
25
+ @logger.success("Database dropped")
26
+ else
27
+ @logger.warning("Failed to drop database (may not exist)")
28
+ end
29
+ end
30
+
31
+ def copy(source_db, target_db)
32
+ dump_cmd = build_pg_dump_command(source_db)
33
+ psql_cmd = build_psql_command(target_db)
34
+
35
+ env = { 'PGPASSWORD' => @credentials[:password].to_s }
36
+ full_cmd = "#{dump_cmd} | #{psql_cmd}"
37
+
38
+ success = system(env, full_cmd)
39
+
40
+ if success
41
+ @logger.success("Database copied successfully")
42
+ else
43
+ @logger.warning("Failed to copy database data (this is optional)")
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def run_rails_command(task, database_name)
50
+ env = {
51
+ 'RAILS_ENV' => 'development',
52
+ 'DB_NAME' => database_name
53
+ }
54
+ system(env, "bundle exec rails #{task}")
55
+ end
56
+
57
+ def build_dropdb_command(database_name)
58
+ cmd = "dropdb"
59
+ cmd += " -h #{Shellwords.escape(@credentials[:host])}" if @credentials[:host]
60
+ cmd += " -U #{Shellwords.escape(@credentials[:user])}" if @credentials[:user]
61
+ cmd += " #{Shellwords.escape(database_name)}"
62
+ cmd
63
+ end
64
+
65
+ def build_pg_dump_command(database_name)
66
+ cmd = "pg_dump"
67
+ cmd += " -h #{Shellwords.escape(@credentials[:host])}" if @credentials[:host]
68
+ cmd += " -U #{Shellwords.escape(@credentials[:user])}" if @credentials[:user]
69
+ cmd += " #{Shellwords.escape(database_name)}"
70
+ cmd
71
+ end
72
+
73
+ def build_psql_command(database_name)
74
+ cmd = "psql"
75
+ cmd += " -h #{Shellwords.escape(@credentials[:host])}" if @credentials[:host]
76
+ cmd += " -U #{Shellwords.escape(@credentials[:user])}" if @credentials[:user]
77
+ cmd += " #{Shellwords.escape(database_name)}"
78
+ cmd
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,74 @@
1
+ require 'fileutils'
2
+
3
+ module GitWorktreeManager
4
+ class Environment
5
+ def initialize(project_root)
6
+ @project_root = project_root
7
+ end
8
+
9
+ def setup_env_files(worktree_path, rails_port:, vite_port:, database:, db_credentials:)
10
+ copy_env_files(worktree_path)
11
+ append_worktree_config(worktree_path, rails_port, vite_port, database, db_credentials)
12
+ end
13
+
14
+ def read_db_credentials
15
+ credentials = {}
16
+
17
+ ['.env.local', '.env'].each do |env_file|
18
+ env_path = File.join(@project_root, env_file)
19
+ next unless File.exist?(env_path)
20
+
21
+ File.readlines(env_path).each do |line|
22
+ line = line.strip
23
+ next if line.empty? || line.start_with?('#')
24
+
25
+ if line =~ /^DB_USER=(.+)$/
26
+ credentials[:user] ||= unquote($1)
27
+ elsif line =~ /^DB_PASSWORD=(.+)$/
28
+ credentials[:password] ||= unquote($1)
29
+ elsif line =~ /^DB_HOST=(.+)$/
30
+ credentials[:host] ||= unquote($1)
31
+ end
32
+ end
33
+ end
34
+
35
+ credentials[:user] ||= 'postgres'
36
+ credentials[:password] ||= 'postgres'
37
+ credentials[:host] ||= 'localhost'
38
+
39
+ credentials
40
+ end
41
+
42
+ private
43
+
44
+ def copy_env_files(worktree_path)
45
+ ['.env', '.env.local'].each do |env_file|
46
+ source = File.join(@project_root, env_file)
47
+ target = File.join(worktree_path, env_file)
48
+
49
+ FileUtils.cp(source, target) if File.exist?(source)
50
+ end
51
+ end
52
+
53
+ def append_worktree_config(worktree_path, rails_port, vite_port, database, db_credentials)
54
+ env_local = File.join(worktree_path, '.env.local')
55
+
56
+ config = <<~CONFIG
57
+
58
+ # Worktree-specific configuration
59
+ DB_NAME=#{database}
60
+ DB_USER=#{db_credentials[:user]}
61
+ DB_PASSWORD=#{db_credentials[:password]}
62
+ DB_HOST=#{db_credentials[:host]}
63
+ RAILS_PORT=#{rails_port}
64
+ VITE_PORT=#{vite_port}
65
+ CONFIG
66
+
67
+ File.open(env_local, 'a') { |f| f.write(config) }
68
+ end
69
+
70
+ def unquote(value)
71
+ value.strip.gsub(/^["']|["']$/, '')
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,63 @@
1
+ require 'open3'
2
+ require 'shellwords'
3
+ require 'fileutils'
4
+
5
+ module GitWorktreeManager
6
+ class GitOperations
7
+ def initialize(project_root, logger)
8
+ @project_root = project_root
9
+ @logger = logger
10
+ end
11
+
12
+ def worktree_exists?(path)
13
+ Dir.exist?(path)
14
+ end
15
+
16
+ def branch_exists?(branch)
17
+ cmd = "git rev-parse --verify #{Shellwords.escape(branch)}"
18
+ system(cmd, out: File::NULL, err: File::NULL)
19
+ end
20
+
21
+ def create_worktree(path, branch)
22
+ Dir.chdir(@project_root) do
23
+ if branch_exists?(branch)
24
+ @logger.info("Checking out existing branch: #{branch}")
25
+ system("git worktree add #{Shellwords.escape(path)} #{Shellwords.escape(branch)}")
26
+ else
27
+ @logger.info("Creating new branch: #{branch}")
28
+ system("git worktree add -b #{Shellwords.escape(branch)} #{Shellwords.escape(path)}")
29
+ end
30
+ end
31
+ end
32
+
33
+ def remove_worktree(path)
34
+ Dir.chdir(@project_root) do
35
+ system("git worktree remove #{Shellwords.escape(path)} --force 2>/dev/null")
36
+
37
+ if Dir.exist?(path)
38
+ FileUtils.rm_rf(path)
39
+ end
40
+ end
41
+ end
42
+
43
+ def list_worktrees
44
+ Dir.chdir(@project_root) do
45
+ output, _status = Open3.capture2("git worktree list")
46
+ output
47
+ end
48
+ end
49
+
50
+ def prune_worktrees
51
+ Dir.chdir(@project_root) do
52
+ system("git worktree prune")
53
+ end
54
+ end
55
+
56
+ def git_status(worktree_path)
57
+ Dir.chdir(worktree_path) do
58
+ output, _status = Open3.capture2("git status -sb")
59
+ output
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,29 @@
1
+ require 'pastel'
2
+
3
+ module GitWorktreeManager
4
+ class Logger
5
+ def initialize
6
+ @pastel = Pastel.new
7
+ end
8
+
9
+ def info(message)
10
+ puts "#{@pastel.blue('ℹ')} #{message}"
11
+ end
12
+
13
+ def success(message)
14
+ puts "#{@pastel.green('✓')} #{message}"
15
+ end
16
+
17
+ def warning(message)
18
+ puts "#{@pastel.yellow('⚠')} #{message}"
19
+ end
20
+
21
+ def error(message)
22
+ puts "#{@pastel.red('✗')} #{message}"
23
+ end
24
+
25
+ def plain(message)
26
+ puts message
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,13 @@
1
+ module GitWorktreeManager
2
+ class Ports
3
+ def self.check_port_available(port)
4
+ cmd = "lsof -i :#{port}"
5
+ !system(cmd, out: File::NULL, err: File::NULL)
6
+ end
7
+
8
+ def self.find_process_on_port(port)
9
+ output = `lsof -i :#{port} 2>/dev/null | grep LISTEN`
10
+ output.strip.empty? ? nil : output
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ require 'shellwords'
2
+
3
+ module GitWorktreeManager
4
+ class Shell
5
+ def self.change_directory(path)
6
+ marker_file = "/tmp/worktree_cd_#{Process.pid}"
7
+ File.write(marker_file, path)
8
+
9
+ Dir.chdir(path) if Dir.exist?(path)
10
+ end
11
+
12
+ def self.spawn_shell_in_directory(path)
13
+ Dir.chdir(path) if Dir.exist?(path)
14
+
15
+ shell = ENV['SHELL'] || '/bin/bash'
16
+
17
+ change_directory(path)
18
+
19
+ exec(shell)
20
+ end
21
+
22
+ def self.current_shell
23
+ ENV['SHELL'] || '/bin/bash'
24
+ end
25
+
26
+ def self.in_directory?(current_path, target_path)
27
+ current_path.start_with?(target_path)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,3 @@
1
+ module GitWorktreeManager
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,345 @@
1
+ require 'fileutils'
2
+ require 'tty-prompt'
3
+ require 'tty-table'
4
+
5
+ module GitWorktreeManager
6
+ class Worktree
7
+ def initialize(project_root: nil)
8
+ @project_root = project_root || detect_project_root
9
+ @project_name = File.basename(@project_root)
10
+ @parent_dir = File.dirname(@project_root)
11
+
12
+ @logger = Logger.new
13
+ @config = Config.new(@project_root)
14
+ @env = Environment.new(@project_root)
15
+ @git = GitOperations.new(@project_root, @logger)
16
+ end
17
+
18
+ def create(branch, copy_data: false, no_install: false)
19
+ worktree_name = "#{@project_name}-#{branch}"
20
+ worktree_path = File.join(@parent_dir, worktree_name)
21
+
22
+ if @git.worktree_exists?(worktree_path)
23
+ @logger.error("Worktree already exists at #{worktree_path}")
24
+ exit 1
25
+ end
26
+
27
+ @logger.info("Creating worktree for branch: #{branch}")
28
+
29
+ unless @git.create_worktree(worktree_path, branch)
30
+ @logger.error('Failed to create worktree')
31
+ exit 1
32
+ end
33
+
34
+ @logger.success("Worktree created at #{worktree_path}")
35
+
36
+ rails_port, vite_port = @config.next_ports
37
+ db_prefix = @config.database_prefix
38
+ db_name = "#{db_prefix}_development_#{branch.gsub(/[^a-zA-Z0-9]/, '_')}"
39
+
40
+ @logger.info('Setting up environment (.env and .env.local)')
41
+
42
+ db_credentials = @env.read_db_credentials
43
+ @env.setup_env_files(
44
+ worktree_path,
45
+ rails_port: rails_port,
46
+ vite_port: vite_port,
47
+ database: db_name,
48
+ db_credentials: db_credentials
49
+ )
50
+
51
+ @logger.success("Environment configured (Rails: #{rails_port}, Vite: #{vite_port}, DB: #{db_name})")
52
+
53
+ @config.save_worktree(
54
+ branch,
55
+ path: worktree_path,
56
+ rails_port: rails_port,
57
+ vite_port: vite_port,
58
+ database: db_name
59
+ )
60
+
61
+ Dir.chdir(worktree_path)
62
+
63
+ install_dependencies unless no_install
64
+
65
+ setup_database(db_name, db_credentials, copy_data)
66
+
67
+ @logger.success('Worktree setup complete!')
68
+ @logger.plain('')
69
+ @logger.info('Environment configured:')
70
+ @logger.plain(" Rails port: #{rails_port}")
71
+ @logger.plain(" Vite port: #{vite_port}")
72
+ @logger.plain(" Database: #{db_name}")
73
+ @logger.plain('')
74
+ @logger.info('To start servers, run: ./.tmux')
75
+
76
+ Shell.spawn_shell_in_directory(worktree_path)
77
+ end
78
+
79
+ def list
80
+ @logger.info('Git Worktrees:')
81
+ @logger.plain('')
82
+ @logger.plain(@git.list_worktrees)
83
+
84
+ @logger.plain('')
85
+ @logger.info('Configured Worktrees:')
86
+ @logger.plain('')
87
+
88
+ worktrees = @config.all_worktrees
89
+
90
+ if worktrees.empty?
91
+ @logger.warning('No worktree configuration found')
92
+ return
93
+ end
94
+
95
+ rows = worktrees.map do |branch, info|
96
+ next unless Dir.exist?(info['path'])
97
+
98
+ [
99
+ branch,
100
+ "#{info['rails_port']}:#{info['vite_port']}",
101
+ info['database'],
102
+ info['path']
103
+ ]
104
+ end.compact
105
+
106
+ table = TTY::Table.new(
107
+ header: ['BRANCH', 'RAILS:VITE', 'DATABASE', 'PATH'],
108
+ rows: rows
109
+ )
110
+
111
+ puts table.render(:basic)
112
+ end
113
+
114
+ def status(branch)
115
+ info = @config.get_worktree(branch)
116
+
117
+ unless info
118
+ @logger.error("Worktree not found for branch: #{branch}")
119
+ exit 1
120
+ end
121
+
122
+ @logger.plain("Branch: #{branch}")
123
+ @logger.plain("Path: #{info['path']}")
124
+ @logger.plain("Rails Port: #{info['rails_port']}")
125
+ @logger.plain("Vite Port: #{info['vite_port']}")
126
+ @logger.plain("Database: #{info['database']}")
127
+ @logger.plain('')
128
+
129
+ if Dir.exist?(info['path'])
130
+ @logger.success('Worktree directory exists')
131
+ else
132
+ @logger.error('Worktree directory not found')
133
+ end
134
+
135
+ @logger.plain('')
136
+ @logger.info('Git status:')
137
+ @logger.plain(@git.git_status(info['path']))
138
+
139
+ @logger.plain('')
140
+ @logger.info('Server status:')
141
+
142
+ check_port_status(info['rails_port'], 'Rails')
143
+ check_port_status(info['vite_port'], 'Vite')
144
+ end
145
+
146
+ def start(branch)
147
+ info = @config.get_worktree(branch)
148
+
149
+ unless info
150
+ @logger.error("Worktree not found for branch: #{branch}")
151
+ exit 1
152
+ end
153
+
154
+ worktree_path = info['path']
155
+
156
+ unless Dir.exist?(worktree_path)
157
+ @logger.error("Worktree directory not found: #{worktree_path}")
158
+ exit 1
159
+ end
160
+
161
+ @logger.info("Starting tmux session for branch: #{branch}")
162
+ @logger.info("Path: #{worktree_path}")
163
+ @logger.info("Rails port: #{info['rails_port']}, Vite port: #{info['vite_port']}")
164
+ @logger.plain('')
165
+
166
+ Dir.chdir(worktree_path)
167
+
168
+ tmux_script = File.join(worktree_path, '.tmux')
169
+
170
+ if File.exist?(tmux_script)
171
+ exec(tmux_script)
172
+ else
173
+ @logger.error('.tmux script not found')
174
+ @logger.info('Start servers manually:')
175
+ @logger.plain(" rails s -p #{info['rails_port']}")
176
+ @logger.plain(" VITE_RUBY_PORT=#{info['vite_port']} bin/vite dev")
177
+ exit 1
178
+ end
179
+ end
180
+
181
+ def remove(branch, force: false)
182
+ info = @config.get_worktree(branch)
183
+
184
+ unless info
185
+ worktree_name = "#{@project_name}-#{branch}"
186
+ worktree_path = File.join(@parent_dir, worktree_name)
187
+
188
+ if Dir.exist?(worktree_path) && @git.worktree_exists?(worktree_path)
189
+ @logger.warning("Worktree exists but not configured. Attempting to remove: #{worktree_path}")
190
+
191
+ unless force
192
+ prompt = TTY::Prompt.new
193
+ return unless prompt.yes?('Remove unconfigured worktree?')
194
+ end
195
+
196
+ current_dir = Dir.pwd
197
+ Shell.in_directory?(current_dir, worktree_path)
198
+
199
+ @git.remove_worktree(worktree_path)
200
+ @logger.success('Worktree removed')
201
+
202
+ @logger.info('Navigating to main worktree...')
203
+ Shell.spawn_shell_in_directory(@project_root)
204
+ return
205
+ end
206
+
207
+ @logger.error("Worktree not found for branch: #{branch}")
208
+ exit 1
209
+ end
210
+
211
+ worktree_path = info['path']
212
+ db_name = info['database']
213
+
214
+ unless force
215
+ @logger.warning('This will remove:')
216
+ @logger.plain(" - Worktree at: #{worktree_path}")
217
+ @logger.plain(" - Database: #{db_name}")
218
+ @logger.plain('')
219
+
220
+ prompt = TTY::Prompt.new
221
+ return unless prompt.yes?('Are you sure?')
222
+ end
223
+
224
+ @logger.info('Removing worktree...')
225
+
226
+ current_dir = Dir.pwd
227
+ Shell.in_directory?(current_dir, worktree_path)
228
+
229
+ @git.remove_worktree(worktree_path)
230
+ @logger.success('Worktree removed')
231
+
232
+ if db_name
233
+ @logger.info("Dropping database: #{db_name}")
234
+ db_credentials = @env.read_db_credentials
235
+ db = Database.new(db_credentials, @logger)
236
+ db.drop(db_name)
237
+ end
238
+
239
+ @config.remove_worktree(branch)
240
+ @logger.success('Cleanup complete')
241
+
242
+ @logger.info('Navigating to main worktree...')
243
+ Shell.spawn_shell_in_directory(@project_root)
244
+ end
245
+
246
+ def cleanup
247
+ @logger.info('Cleaning up stale worktree references...')
248
+
249
+ @git.prune_worktrees
250
+
251
+ worktrees = @config.all_worktrees
252
+ cleaned = 0
253
+
254
+ worktrees.each do |branch, info|
255
+ unless Dir.exist?(info['path'])
256
+ @logger.info("Removing stale config for: #{branch}")
257
+ @config.remove_worktree(branch)
258
+ cleaned += 1
259
+ end
260
+ end
261
+
262
+ @logger.success("Cleaned up #{cleaned} stale configuration(s)")
263
+ end
264
+
265
+ def config(options)
266
+ if options[:main_database] || options[:database_prefix]
267
+ @config.save_settings(
268
+ main_database: options[:main_database],
269
+ database_prefix: options[:database_prefix]
270
+ )
271
+ @logger.success('Configuration updated')
272
+ end
273
+
274
+ @logger.plain('Current configuration:')
275
+ @logger.plain('')
276
+ @logger.plain(" Main database: #{@config.main_database}")
277
+ @logger.plain(" Database prefix: #{@config.database_prefix}")
278
+ @logger.plain('')
279
+ @logger.info('To change settings, use:')
280
+ @logger.plain(' worktree config --main-database=mydb_development')
281
+ @logger.plain(' worktree config --database-prefix=myproject')
282
+ end
283
+
284
+ @logger.success("Cleaned up #{cleaned} stale configuration(s)")
285
+ end
286
+
287
+ private
288
+
289
+ def detect_project_root
290
+ current_dir = Dir.pwd
291
+
292
+ current_dir = File.dirname(current_dir) until current_dir == '/' || File.exist?(File.join(current_dir, '.git'))
293
+
294
+ if current_dir == '/'
295
+ puts 'Error: Not in a git repository'
296
+ exit 1
297
+ end
298
+
299
+ current_dir
300
+ end
301
+
302
+ def install_dependencies
303
+ @logger.info('Installing dependencies...')
304
+
305
+ if File.exist?('Gemfile')
306
+ @logger.info('Running bundle install...')
307
+ system('bundle install')
308
+ end
309
+
310
+ if File.exist?('package.json')
311
+ @logger.info('Running yarn install...')
312
+ system('yarn install')
313
+ end
314
+
315
+ @logger.success('Dependencies installed')
316
+ end
317
+
318
+ def setup_database(db_name, db_credentials, copy_data)
319
+ db = Database.new(db_credentials, @logger)
320
+
321
+ @logger.info("Creating database: #{db_name}")
322
+ db.create(db_name)
323
+
324
+ @logger.info('Running migrations...')
325
+ db.migrate(db_name)
326
+
327
+ return unless copy_data
328
+
329
+ main_db = @config.main_database
330
+ @logger.info("Copying data from #{main_db} to #{db_name}...")
331
+ db.copy(main_db, db_name)
332
+ end
333
+
334
+ def check_port_status(port, server_name)
335
+ process_info = Ports.find_process_on_port(port)
336
+
337
+ if process_info
338
+ @logger.success("#{server_name} server is running on port #{port}")
339
+ @logger.plain(process_info)
340
+ else
341
+ @logger.info("#{server_name} server is not running on port #{port}")
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,15 @@
1
+ require_relative 'git_worktree_manager/version'
2
+ require_relative 'git_worktree_manager/logger'
3
+ require_relative 'git_worktree_manager/config'
4
+ require_relative 'git_worktree_manager/environment'
5
+ require_relative 'git_worktree_manager/database'
6
+ require_relative 'git_worktree_manager/shell'
7
+ require_relative 'git_worktree_manager/git_operations'
8
+ require_relative 'git_worktree_manager/ports'
9
+ require_relative 'git_worktree_manager/worktree'
10
+ require_relative 'git_worktree_manager/cli'
11
+
12
+ require_relative 'generators/git_worktree_manager/install_generator' if defined?(Rails::Railtie)
13
+
14
+ module GitWorktreeManager
15
+ end
metadata ADDED
@@ -0,0 +1,156 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: git_worktree_manager
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Franck
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dotenv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.8'
26
+ - !ruby/object:Gem::Dependency
27
+ name: pastel
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: thor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: tty-prompt
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.23'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.23'
68
+ - !ruby/object:Gem::Dependency
69
+ name: tty-table
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.12'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.12'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rspec
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.12'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.12'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rubocop
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.50'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.50'
110
+ description: Shell-agnostic tool for managing Rails git worktrees with separate databases,
111
+ unique ports for Rails and Vite, and isolated environments
112
+ email:
113
+ - franck.dagostini@gmail.com
114
+ executables:
115
+ - worktree
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - LICENSE
120
+ - README.md
121
+ - bin/worktree
122
+ - lib/generators/git_worktree_manager/install_generator.rb
123
+ - lib/git_worktree_manager.rb
124
+ - lib/git_worktree_manager/cli.rb
125
+ - lib/git_worktree_manager/config.rb
126
+ - lib/git_worktree_manager/database.rb
127
+ - lib/git_worktree_manager/environment.rb
128
+ - lib/git_worktree_manager/git_operations.rb
129
+ - lib/git_worktree_manager/logger.rb
130
+ - lib/git_worktree_manager/ports.rb
131
+ - lib/git_worktree_manager/shell.rb
132
+ - lib/git_worktree_manager/version.rb
133
+ - lib/git_worktree_manager/worktree.rb
134
+ homepage: https://github.com/franck/git_worktree_manager
135
+ licenses:
136
+ - MIT
137
+ metadata:
138
+ rubygems_mfa_required: 'true'
139
+ rdoc_options: []
140
+ require_paths:
141
+ - lib
142
+ required_ruby_version: !ruby/object:Gem::Requirement
143
+ requirements:
144
+ - - ">="
145
+ - !ruby/object:Gem::Version
146
+ version: 3.0.0
147
+ required_rubygems_version: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ requirements: []
153
+ rubygems_version: 3.6.9
154
+ specification_version: 4
155
+ summary: Manage git worktrees with isolated databases and ports
156
+ test_files: []