rails-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.
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Worktrees
5
+ # Safely patches common database.yml layouts for worktree suffixes.
6
+ class DatabaseConfigUpdater
7
+ Result = Struct.new(:content, :changed, :messages) do
8
+ def changed?
9
+ changed
10
+ end
11
+ end
12
+
13
+ SUFFIX_TEMPLATE = "<%= ENV.fetch('WORKTREE_DATABASE_SUFFIX', '') %>"
14
+ SUPPORTED_ENVIRONMENTS = %w[development test].freeze
15
+ DATABASE_LINE_PATTERN = /\A(\s*database:\s*)(.+?)(\s*(?:#.*)?\n?)\z/
16
+ SECTION_PATTERN = /\A([A-Za-z0-9_]+):(?:\s|$)/
17
+
18
+ def initialize(content:)
19
+ @content = content
20
+ end
21
+
22
+ def call
23
+ state = { found: Hash.new(0), patched: 0, unsupported: [], environment: nil }
24
+ lines = @content.lines.map.with_index { |line, index| patch_line(line, index, state) }
25
+
26
+ Result.new(
27
+ lines.join,
28
+ state[:patched].positive?,
29
+ build_messages(state[:found], state[:patched], state[:unsupported])
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def patch_line(line, index, state)
36
+ state[:environment] = next_environment_for(line, state[:environment])
37
+ return line unless state[:environment]
38
+
39
+ match = line.match(DATABASE_LINE_PATTERN)
40
+ return line unless match
41
+
42
+ state[:found][state[:environment]] += 1
43
+ apply_patch(match, index, state)
44
+ end
45
+
46
+ def apply_patch(match, index, state)
47
+ patched_value = patch_database_value(match[2].strip, state[:environment])
48
+
49
+ if patched_value
50
+ state[:patched] += 1 unless patched_value == match[2].strip
51
+ "#{match[1]}#{patched_value}#{match[3]}"
52
+ else
53
+ state[:unsupported] << { environment: state[:environment], line: index + 1, value: match[2].strip }
54
+ match.string
55
+ end
56
+ end
57
+
58
+ def next_environment_for(line, current_environment)
59
+ section_name = top_level_section_name(line)
60
+ return current_environment unless section_name
61
+ return section_name if SUPPORTED_ENVIRONMENTS.include?(section_name)
62
+
63
+ nil
64
+ end
65
+
66
+ def top_level_section_name(line)
67
+ return unless line.lstrip == line
68
+
69
+ match = line.match(SECTION_PATTERN)
70
+ match && match[1]
71
+ end
72
+
73
+ def patch_database_value(value, environment)
74
+ return value if value.include?('WORKTREE_DATABASE_SUFFIX')
75
+ return if value.include?('<%')
76
+
77
+ quote = value[/\A['"]/]
78
+ raw_value = quote ? value.delete_prefix(quote).delete_suffix(quote) : value
79
+ patched = patch_segmented_database_name(raw_value, environment) ||
80
+ patch_database_file_path(raw_value, environment)
81
+ return unless patched
82
+
83
+ quote ? "#{quote}#{patched}#{quote}" : patched
84
+ end
85
+
86
+ def patch_segmented_database_name(raw_value, environment)
87
+ return unless raw_value.match?(/\A[\w-]+\z/)
88
+
89
+ segments = raw_value.split('_')
90
+ environment_index = segments.index(environment)
91
+ return unless environment_index
92
+
93
+ segments[environment_index] = "#{segments[environment_index]}#{SUFFIX_TEMPLATE}"
94
+ segments.join('_')
95
+ end
96
+
97
+ def patch_database_file_path(raw_value, environment)
98
+ match = raw_value.match(
99
+ /\A(?<prefix>.*?)(?<environment>development|test)(?<tail>(?:[_-][\w-]+)*)(?<extension>\.[A-Za-z0-9.]+)\z/
100
+ )
101
+ return unless match
102
+ return unless match[:environment] == environment
103
+
104
+ [match[:prefix], match[:environment], SUFFIX_TEMPLATE, match[:tail], match[:extension]].join
105
+ end
106
+
107
+ def build_messages(found_entries, patched_entries, unsupported_entries)
108
+ messages = [status_message(found_entries, patched_entries, unsupported_entries)].compact
109
+ return messages if unsupported_entries.empty?
110
+
111
+ line_numbers = unsupported_entries.map { |entry| entry[:line] }.join(', ')
112
+ messages << "Could not safely rewrite config/database.yml on line(s) #{line_numbers}. " \
113
+ "Add #{SUFFIX_TEMPLATE} to the development/test database name(s) manually."
114
+ end
115
+
116
+ def status_message(found_entries, patched_entries, unsupported_entries)
117
+ total = found_entries.values.sum
118
+ if patched_entries.positive?
119
+ noun = patched_entries == 1 ? 'entry' : 'entries'
120
+ "Updated #{patched_entries} development/test database #{noun} to include WORKTREE_DATABASE_SUFFIX."
121
+ elsif total.positive? && unsupported_entries.empty?
122
+ 'Development/test database names already include WORKTREE_DATABASE_SUFFIX.'
123
+ elsif total.zero?
124
+ 'No development/test database entries matched a supported auto-update layout.'
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'zlib'
5
+
6
+ module Rails
7
+ module Worktrees
8
+ # Creates or updates a worktree-local .env with deterministic defaults.
9
+ # rubocop:disable Metrics/ClassLength
10
+ class EnvBootstrapper
11
+ Result = Struct.new(:changed, :env_path, :messages) do
12
+ def changed? = changed
13
+
14
+ attr_accessor :values
15
+ end
16
+
17
+ ENV_FILE_NAME = '.env'
18
+
19
+ def initialize(target_dir:, worktree_name:, configuration:)
20
+ @target_dir = target_dir
21
+ @worktree_name = worktree_name
22
+ @configuration = configuration
23
+ end
24
+
25
+ def call(dry_run: false)
26
+ return disabled_result unless @configuration.bootstrap_env
27
+
28
+ lines = existing_env_lines
29
+ write_missing_updates(lines, resolved_values(lines), dry_run: dry_run)
30
+ end
31
+
32
+ def preview = result(false, [], resolved_values(existing_env_lines))
33
+
34
+ private
35
+
36
+ def disabled_result = result(false, [], {})
37
+
38
+ def unchanged_result(values, dry_run: false)
39
+ message = if dry_run
40
+ "Would not change #{display_path(env_path)}; it already defines " \
41
+ 'DEV_PORT and WORKTREE_DATABASE_SUFFIX'
42
+ else
43
+ "#{display_path(env_path)} already defines DEV_PORT and WORKTREE_DATABASE_SUFFIX"
44
+ end
45
+ result(false, [message], values)
46
+ end
47
+
48
+ def existing_env_lines = File.exist?(env_path) ? File.readlines(env_path, chomp: true) : []
49
+
50
+ def resolved_values(lines)
51
+ {
52
+ 'DEV_PORT' => (env_value(lines, 'DEV_PORT') || allocate_dev_port).to_s,
53
+ 'WORKTREE_DATABASE_SUFFIX' => env_value(lines, 'WORKTREE_DATABASE_SUFFIX') ||
54
+ format_worktree_database_suffix(@worktree_name)
55
+ }
56
+ end
57
+
58
+ def write_missing_updates(existing_lines, values, dry_run: false)
59
+ updates = missing_updates(existing_lines, values)
60
+ return unchanged_result(values, dry_run: dry_run) if updates.empty?
61
+ return dry_run_bootstrap_result(values, updates) if dry_run
62
+
63
+ File.write(env_path, with_missing_entries(existing_lines, updates))
64
+ result(true, ["Bootstrapped #{display_path(env_path)} (#{formatted_updates(updates)})"], values)
65
+ end
66
+
67
+ def missing_updates(lines, values)
68
+ values.each_with_object({}) do |(key, value), updates|
69
+ updates[key] = value unless env_value(lines, key)
70
+ end
71
+ end
72
+
73
+ def result(changed, messages, values)
74
+ Result.new(changed, env_path, messages).tap do |bootstrap_result|
75
+ bootstrap_result.values = values
76
+ end
77
+ end
78
+
79
+ def env_path = @env_path ||= File.join(@target_dir, ENV_FILE_NAME)
80
+
81
+ def with_missing_entries(lines, updates)
82
+ output = lines.dup
83
+ output << '' if output.any? && !output.last.empty?
84
+ updates.each { |key, value| output << "#{key}=#{value}" }
85
+ "#{output.join("\n")}\n"
86
+ end
87
+
88
+ def env_value(lines, key)
89
+ line = lines.reverse.find { |entry| entry.start_with?("#{key}=") }
90
+ value = line&.split('=', 2)&.last
91
+ value unless value&.empty?
92
+ end
93
+
94
+ def allocate_dev_port
95
+ ports = configured_port_range.to_a
96
+ raise ArgumentError, 'dev_port_range must contain at least one port' if ports.empty?
97
+
98
+ claimed_ports = claimed_peer_ports
99
+ candidate_ports(ports).find { |port| !claimed_ports.include?(port) } ||
100
+ raise(ArgumentError, "No available DEV_PORT values remain in #{configured_port_range}")
101
+ end
102
+
103
+ def dry_run_bootstrap_result(values, updates)
104
+ result(false, ["Would bootstrap #{display_path(env_path)} (#{formatted_updates(updates)})"], values)
105
+ end
106
+
107
+ def candidate_ports(ports)
108
+ start_index = Zlib.crc32(@worktree_name) % ports.length
109
+ ports.rotate(start_index)
110
+ end
111
+
112
+ def claimed_peer_ports
113
+ Dir.glob(File.join(peers_root, '*')).filter_map do |path|
114
+ next unless File.directory?(path)
115
+ next if File.expand_path(path) == File.expand_path(@target_dir)
116
+
117
+ port = env_value(peer_env_lines(path), 'DEV_PORT')
118
+ next unless port&.match?(/\A\d+\z/)
119
+
120
+ port.to_i
121
+ end.to_set
122
+ end
123
+
124
+ def peer_env_lines(path)
125
+ env_file = File.join(path, ENV_FILE_NAME)
126
+ File.exist?(env_file) ? File.readlines(env_file, chomp: true) : []
127
+ end
128
+
129
+ def peers_root = File.dirname(@target_dir)
130
+
131
+ def configured_port_range = @configuration.dev_port_range
132
+
133
+ def format_worktree_database_suffix(value)
134
+ suffix = value.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '').squeeze('_')
135
+ suffix = suffix[0, @configuration.worktree_database_suffix_max_length]
136
+ suffix = suffix.to_s.sub(/_+\z/, '')
137
+ suffix = 'worktree' if suffix.empty?
138
+ "_#{suffix}"
139
+ end
140
+
141
+ def formatted_updates(updates) = updates.map { |key, value| "#{key}=#{value}" }.join(', ')
142
+
143
+ def display_path(path)
144
+ target_path = Pathname.new(path)
145
+ root_path = Pathname.new(@target_dir)
146
+ target_path.relative_path_from(root_path).to_s
147
+ rescue ArgumentError
148
+ path
149
+ end
150
+ end
151
+ # rubocop:enable Metrics/ClassLength
152
+ end
153
+ end
@@ -0,0 +1,96 @@
1
+ # One worktree name per line. Keep names shell-safe: lowercase, hyphenated, no spaces.
2
+ amsterdam
3
+ athens
4
+ auckland
5
+ bangkok
6
+ barcelona
7
+ beijing
8
+ beirut
9
+ belgrade
10
+ berlin
11
+ bogota
12
+ boston
13
+ bratislava
14
+ brisbane
15
+ brussels
16
+ bucharest
17
+ budapest
18
+ busan
19
+ cairo
20
+ calgary
21
+ cape-town
22
+ casablanca
23
+ chicago
24
+ copenhagen
25
+ dakar
26
+ delhi
27
+ dubai
28
+ dublin
29
+ edinburgh
30
+ florence
31
+ geneva
32
+ helsinki
33
+ hong-kong
34
+ honolulu
35
+ istanbul
36
+ jakarta
37
+ johannesburg
38
+ kyoto
39
+ lagos
40
+ lima
41
+ lisbon
42
+ ljubljana
43
+ london
44
+ luxor
45
+ madrid
46
+ manila
47
+ marrakesh
48
+ melbourne
49
+ mexico-city
50
+ miami
51
+ milan
52
+ montreal
53
+ mumbai
54
+ munich
55
+ nairobi
56
+ naples
57
+ osaka
58
+ oslo
59
+ ottawa
60
+ palermo
61
+ paris
62
+ perth
63
+ porto
64
+ prague
65
+ quebec-city
66
+ reykjavik
67
+ riga
68
+ rio-de-janeiro
69
+ rome
70
+ rotterdam
71
+ salzburg
72
+ santiago
73
+ sao-paulo
74
+ seattle
75
+ seoul
76
+ shanghai
77
+ singapore
78
+ sofia
79
+ stockholm
80
+ sydney
81
+ taipei
82
+ tallinn
83
+ tbilisi
84
+ tokyo
85
+ toronto
86
+ turin
87
+ uppsala
88
+ utrecht
89
+ valencia
90
+ vancouver
91
+ venice
92
+ vienna
93
+ vilnius
94
+ warsaw
95
+ wellington
96
+ zurich
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Worktrees
5
+ class Railtie < ::Rails::Railtie
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rails
4
+ module Worktrees
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'worktrees/version'
4
+ require_relative 'worktrees/configuration'
5
+ require_relative 'worktrees/env_bootstrapper'
6
+ require_relative 'worktrees/command'
7
+ require_relative 'worktrees/cli'
8
+ require_relative 'worktrees/database_config_updater'
9
+
10
+ module Rails
11
+ # Rails-specific git worktree helpers and installer support.
12
+ module Worktrees
13
+ class Error < StandardError; end
14
+
15
+ class << self
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield(configuration)
22
+ configuration
23
+ end
24
+
25
+ def reset_configuration!
26
+ @configuration = Configuration.new
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ require_relative 'worktrees/railtie' if defined?(Rails::Railtie)
data/mise.toml ADDED
@@ -0,0 +1,5 @@
1
+ [tools]
2
+ ruby = "3.4.8"
3
+
4
+ [env]
5
+ _.path = ["{{cwd}}/bin"]
@@ -0,0 +1,12 @@
1
+ {
2
+ "include-component-in-tag": false,
3
+ "packages": {
4
+ ".": {
5
+ "initial-version": "0.1.0",
6
+ "release-type": "ruby",
7
+ "package-name": "rails-worktrees",
8
+ "version-file": "lib/rails/worktrees/version.rb",
9
+ "changelog-path": "CHANGELOG.md"
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,23 @@
1
+ module Rails
2
+ module Worktrees
3
+ VERSION: String
4
+
5
+ class Error < StandardError
6
+ end
7
+
8
+ class Configuration
9
+ attr_accessor bootstrap_env: bool
10
+ attr_accessor workspace_root: String?
11
+ attr_accessor branch_prefix: String
12
+ attr_accessor dev_port_range: ::Range[Integer]
13
+ attr_accessor name_sources_path: String
14
+ attr_accessor used_names_file: String
15
+ attr_accessor legacy_used_names_files: Array[String]
16
+ attr_accessor worktree_database_suffix_max_length: Integer
17
+ end
18
+
19
+ def self.configuration: () -> Configuration
20
+ def self.configure: () { (Configuration) -> void } -> Configuration
21
+ def self.reset_configuration!: () -> Configuration
22
+ end
23
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-worktrees
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Asjer Querido
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8.2'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8.2'
32
+ description: Rails::Worktrees is a Ruby gem intended to support working with git worktrees
33
+ in Rails development workflows.
34
+ email:
35
+ - asjer@johnyontherun.com
36
+ executables:
37
+ - wt
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - ".release-please-manifest.json"
42
+ - CHANGELOG.md
43
+ - LICENSE.txt
44
+ - README.md
45
+ - Rakefile
46
+ - exe/wt
47
+ - lefthook.yml
48
+ - lib/generators/rails/worktrees/install_generator.rb
49
+ - lib/generators/rails/worktrees/mise_follow_up.rb
50
+ - lib/generators/rails/worktrees/templates/Procfile.dev.worktree.example.tt
51
+ - lib/generators/rails/worktrees/templates/bin/wt
52
+ - lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt
53
+ - lib/rails/worktrees.rb
54
+ - lib/rails/worktrees/cli.rb
55
+ - lib/rails/worktrees/command.rb
56
+ - lib/rails/worktrees/command/environment_support.rb
57
+ - lib/rails/worktrees/command/git_operations.rb
58
+ - lib/rails/worktrees/command/name_picking.rb
59
+ - lib/rails/worktrees/command/output.rb
60
+ - lib/rails/worktrees/command/workspace_paths.rb
61
+ - lib/rails/worktrees/configuration.rb
62
+ - lib/rails/worktrees/database_config_updater.rb
63
+ - lib/rails/worktrees/env_bootstrapper.rb
64
+ - lib/rails/worktrees/names/cities.txt
65
+ - lib/rails/worktrees/railtie.rb
66
+ - lib/rails/worktrees/version.rb
67
+ - mise.toml
68
+ - release-please-config.json
69
+ - sig/rails/worktrees.rbs
70
+ homepage: https://github.com/asjer/rails-worktrees
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ homepage_uri: https://github.com/asjer/rails-worktrees
75
+ source_code_uri: https://github.com/asjer/rails-worktrees
76
+ changelog_uri: https://github.com/asjer/rails-worktrees/blob/main/CHANGELOG.md
77
+ rubygems_mfa_required: 'true'
78
+ post_install_message: "\n============================================\n Thank you
79
+ for installing rails-worktrees! \U0001F389\n\n Run the installer:\n $ bin/rails
80
+ generate rails:worktrees:install\n\n Docs: https://github.com/asjer/rails-worktrees\n============================================\n\n"
81
+ rdoc_options: []
82
+ require_paths:
83
+ - lib
84
+ required_ruby_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 3.2.0
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubygems_version: 3.6.9
96
+ specification_version: 4
97
+ summary: Helpers for managing Rails application git worktrees.
98
+ test_files: []