rails-worktrees 0.5.1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 698795e04a67284039d3beba2286f3a9aa156a5856832a345f22ffa57b6177e3
4
- data.tar.gz: 0a57ef25c66c3226798498476abf8caf033c6a2989ae170b30a161fde66fddcf
3
+ metadata.gz: de8cd30214f81e827aee0e2fabba426a1c0cfca6f172d03fb36fad408fc8bfe7
4
+ data.tar.gz: 604eb3468c743e91b3b5ad19f9943fa94d9fe891b45732de491ddc8219c4b943
5
5
  SHA512:
6
- metadata.gz: 06c8ae396186a19c08e45445e031a28198d48f7ea5125c02e63b2df39977f5399f75421e7f1897071d14b00d042051616ed4e447b83b570852d54756d8689377
7
- data.tar.gz: 800a19ef60af420e03280b8a30c02c9012c4ded0d3eaecc421ed5e42e072b8024a0cbc93e7ba7aaf7d806dc972deacacbb0c5edfe45da40b2ab05659b1ccc5c5
6
+ metadata.gz: 8f33f141355afb0dbc9ee06cdaab2e2f242c7d49b7ac72b6c2aa21202aabc8fb709cf79aa88a0b43f2357240b19b88dab37cc284f3fd4f712cc2b6c1057b743b
7
+ data.tar.gz: c9bc8caa8f38b496e2d7dd4bc0693251ec18606863b349cf949398e2cc131c448277869e04ac873e80f8b3dfff3f1fc59604e7ba2207fff21a97c255376524f3
@@ -1 +1 @@
1
- {".":"0.5.1"}
1
+ {".":"0.6.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0](https://github.com/asjer/rails-worktrees/compare/v0.5.1...v0.6.0) (2026-04-03)
4
+
5
+
6
+ ### Features
7
+
8
+ * **post-create:** bootstrap new worktrees after creation ([b429105](https://github.com/asjer/rails-worktrees/commit/b42910507d21c1d048d45cfdc4d7311a1ec8a96b))
9
+
3
10
  ## [0.5.1](https://github.com/asjer/rails-worktrees/compare/v0.5.0...v0.5.1) (2026-04-02)
4
11
 
5
12
 
@@ -16,4 +16,15 @@ Rails.application.config.x.rails_worktrees.tap do |config|
16
16
  # 'rails-worktrees',
17
17
  # 'used-names.tsv'
18
18
  # )
19
+
20
+ # Post-create setup steps (run automatically after a new worktree is created)
21
+ # config.post_create_command = nil # nil = built-in steps; false = skip all; 'script/worktree-setup' = custom command
22
+ # config.run_bundle_install = true
23
+ # config.run_yarn_install = true # auto-skipped when yarn.lock is absent
24
+ # config.run_db_prepare = true
25
+ # config.run_test_db_prepare = true
26
+ # config.run_test_assets_precompile = true
27
+ # config.link_credential_keys = true # link development.key from a sibling worktree (opt-out)
28
+ # config.link_test_credential_key = false
29
+ # config.link_production_credential_key = false
19
30
  end
@@ -0,0 +1,33 @@
1
+ module Rails
2
+ module Worktrees
3
+ class Command
4
+ # Delegates post-create setup steps to PostCreateRunner.
5
+ module PostCreateSupport
6
+ private
7
+
8
+ def run_post_create_steps(context)
9
+ post_create_runner_for(context).call(dry_run: false)
10
+ end
11
+
12
+ def preview_post_create_steps(context)
13
+ post_create_runner_for(context).call(dry_run: true)
14
+ end
15
+
16
+ def post_create_runner_for(context)
17
+ PostCreateRunner.new(
18
+ target_dir: context[:target_dir],
19
+ peer_roots: peer_roots_excluding(context[:target_dir]),
20
+ configuration: @configuration,
21
+ io: { stdout: @stdout, stderr: @stderr }
22
+ )
23
+ end
24
+
25
+ def peer_roots_excluding(target_dir)
26
+ worktree_entries
27
+ .map { |entry| entry[:path] }
28
+ .reject { |path| path == target_dir }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -3,6 +3,7 @@ require_relative 'command/environment_support'
3
3
  require_relative 'command/git_operations'
4
4
  require_relative 'command/name_picking'
5
5
  require_relative 'command/output'
6
+ require_relative 'command/post_create_support'
6
7
  require_relative 'command/workspace_paths'
7
8
 
8
9
  module Rails
@@ -18,6 +19,7 @@ module Rails
18
19
  include EnvironmentSupport
19
20
  include NamePicking
20
21
  include Output
22
+ include PostCreateSupport
21
23
  include WorkspacePaths
22
24
 
23
25
  def initialize(argv:, io:, env:, cwd:, configuration:)
@@ -284,7 +286,20 @@ module Rails
284
286
  def finish(context)
285
287
  settle_retired_name(context[:worktree_name], context[:project_name], dry_run: dry_run?)
286
288
  bootstrap_result = bootstrap_worktree_environment(context)
287
- return complete_dry_run(context, env_values: bootstrap_result&.values) if dry_run?
289
+
290
+ return complete_dry_run_after_setup(context, bootstrap_result) if dry_run?
291
+
292
+ complete_created_worktree(context, bootstrap_result)
293
+ end
294
+
295
+ def complete_dry_run_after_setup(context, bootstrap_result)
296
+ preview_post_create_steps(context)
297
+ complete_dry_run(context, env_values: bootstrap_result&.values)
298
+ end
299
+
300
+ def complete_created_worktree(context, bootstrap_result)
301
+ result = run_post_create_steps(context)
302
+ return result unless result.zero?
288
303
 
289
304
  success('Worktree ready')
290
305
  print_context_summary(context, env_values: bootstrap_result&.values)
@@ -10,6 +10,15 @@ module Rails
10
10
  name_sources_path
11
11
  used_names_file
12
12
  worktree_database_suffix_max_length
13
+ post_create_command
14
+ run_bundle_install
15
+ run_yarn_install
16
+ run_db_prepare
17
+ run_test_db_prepare
18
+ run_test_assets_precompile
19
+ link_credential_keys
20
+ link_test_credential_key
21
+ link_production_credential_key
13
22
  ].freeze
14
23
 
15
24
  DEFAULT_BOOTSTRAP_ENV = true
@@ -25,9 +34,20 @@ module Rails
25
34
 
26
35
  attr_accessor :bootstrap_env, :branch_prefix, :dev_port_range, :legacy_used_names_files,
27
36
  :name_sources_path, :used_names_file, :workspace_root,
28
- :worktree_database_suffix_max_length
37
+ :worktree_database_suffix_max_length,
38
+ :post_create_command,
39
+ :run_bundle_install, :run_yarn_install,
40
+ :run_db_prepare, :run_test_db_prepare, :run_test_assets_precompile,
41
+ :link_credential_keys, :link_test_credential_key, :link_production_credential_key
29
42
 
30
43
  def initialize
44
+ assign_core_defaults
45
+ assign_post_create_defaults
46
+ end
47
+
48
+ private
49
+
50
+ def assign_core_defaults
31
51
  @bootstrap_env = DEFAULT_BOOTSTRAP_ENV
32
52
  @workspace_root = nil
33
53
  @branch_prefix = DEFAULT_BRANCH_PREFIX
@@ -38,7 +58,17 @@ module Rails
38
58
  @legacy_used_names_files = default_legacy_used_names_files
39
59
  end
40
60
 
41
- private
61
+ def assign_post_create_defaults
62
+ @post_create_command = nil
63
+ @run_bundle_install = true
64
+ @run_yarn_install = true
65
+ @run_db_prepare = true
66
+ @run_test_db_prepare = true
67
+ @run_test_assets_precompile = true
68
+ @link_credential_keys = true
69
+ @link_test_credential_key = false
70
+ @link_production_credential_key = false
71
+ end
42
72
 
43
73
  def default_legacy_used_names_files
44
74
  state_home = ENV.fetch('XDG_STATE_HOME', File.join(Dir.home, '.local/state'))
@@ -0,0 +1,93 @@
1
+ require 'fileutils'
2
+
3
+ module Rails
4
+ module Worktrees
5
+ # Links credential key files from a sibling worktree into the new worktree.
6
+ class CredentialKeyLinker
7
+ Result = Struct.new(:messages)
8
+
9
+ CREDENTIALS_DIR = 'config/credentials'.freeze
10
+
11
+ KEY_TYPES = [
12
+ { name: 'development', config_attr: :link_credential_keys },
13
+ { name: 'test', config_attr: :link_test_credential_key },
14
+ { name: 'production', config_attr: :link_production_credential_key }
15
+ ].freeze
16
+
17
+ def initialize(target_dir:, peer_roots:, configuration:)
18
+ @target_dir = target_dir
19
+ @peer_roots = peer_roots
20
+ @configuration = configuration
21
+ end
22
+
23
+ def call(dry_run: false)
24
+ messages = []
25
+
26
+ KEY_TYPES.each do |key_type|
27
+ next unless @configuration.public_send(key_type[:config_attr])
28
+
29
+ message = process_key(key_type[:name], dry_run: dry_run)
30
+ messages << message if message
31
+ end
32
+
33
+ Result.new(messages)
34
+ end
35
+
36
+ private
37
+
38
+ def process_key(key_name, dry_run:)
39
+ destination = destination_path_for(key_name)
40
+ existing_message = existing_destination_message(destination, key_name)
41
+ return existing_message if existing_message
42
+
43
+ replace_existing_symlink = File.symlink?(destination)
44
+ return "#{key_name}.key already exists; leaving as-is" if File.exist?(destination)
45
+
46
+ source = find_source(key_name)
47
+ return "Could not find source for #{key_name}.key" unless source
48
+
49
+ link_key(destination, key_name, source, dry_run: dry_run, replace_existing_symlink: replace_existing_symlink)
50
+ end
51
+
52
+ def find_source(key_name)
53
+ @peer_roots.each do |peer_root|
54
+ candidate = File.join(peer_root, CREDENTIALS_DIR, "#{key_name}.key")
55
+ next if File.expand_path(peer_root) == File.expand_path(@target_dir)
56
+
57
+ return candidate if File.file?(candidate)
58
+ end
59
+
60
+ nil
61
+ end
62
+
63
+ def destination_path_for(key_name)
64
+ File.join(@target_dir, CREDENTIALS_DIR, "#{key_name}.key")
65
+ end
66
+
67
+ def existing_destination_message(destination, key_name)
68
+ return unless File.symlink?(destination) && File.exist?(destination)
69
+
70
+ "#{key_name}.key already linked"
71
+ end
72
+
73
+ def link_key(destination, key_name, source, dry_run:, replace_existing_symlink: false)
74
+ return dry_run_link_message(key_name, source, replace_existing_symlink) if dry_run
75
+
76
+ FileUtils.mkdir_p(File.dirname(destination))
77
+ FileUtils.rm_f(destination) if replace_existing_symlink
78
+ File.symlink(source, destination)
79
+ linked_key_message(key_name, source, replace_existing_symlink)
80
+ end
81
+
82
+ def dry_run_link_message(key_name, source, replace_existing_symlink)
83
+ verb = replace_existing_symlink ? 'Would relink' : 'Would link'
84
+ "#{verb} #{key_name}.key → #{source}"
85
+ end
86
+
87
+ def linked_key_message(key_name, source, replace_existing_symlink)
88
+ verb = replace_existing_symlink ? 'Relinked' : 'Linked'
89
+ "#{verb} #{key_name}.key → #{source}"
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,201 @@
1
+ require 'open3'
2
+
3
+ module Rails
4
+ module Worktrees
5
+ # Runs post-create setup steps in a newly created worktree.
6
+ # Steps run in order: credential linking, bundle install, yarn install,
7
+ # db:prepare (development), db:prepare (test), assets:precompile (test).
8
+ # rubocop:disable Metrics/ClassLength
9
+ class PostCreateRunner
10
+ STEPS = [
11
+ { id: :bundle, argv: %w[bundle install],
12
+ header: '📦 Installing gems...' },
13
+ { id: :yarn, argv: %w[yarn install],
14
+ header: '🧶 Installing JS dependencies...' },
15
+ { id: :db_prepare, argv: %w[bin/rails db:prepare],
16
+ header: '🗄️ Preparing development database...', env: { 'RAILS_ENV' => 'development' } },
17
+ { id: :test_db_prepare, argv: %w[bin/rails db:prepare],
18
+ header: '🗄️ Preparing test database...', env: { 'RAILS_ENV' => 'test' } },
19
+ { id: :test_assets_precompile, argv: %w[bin/rails assets:precompile],
20
+ header: '🎨 Precompiling test assets...', env: { 'RAILS_ENV' => 'test' } }
21
+ ].freeze
22
+
23
+ STEP_CONFIG = {
24
+ bundle: :run_bundle_install,
25
+ yarn: :run_yarn_install,
26
+ db_prepare: :run_db_prepare,
27
+ test_db_prepare: :run_test_db_prepare,
28
+ test_assets_precompile: :run_test_assets_precompile
29
+ }.freeze
30
+
31
+ def initialize(target_dir:, peer_roots:, configuration:, io:)
32
+ @target_dir = target_dir
33
+ @peer_roots = peer_roots
34
+ @configuration = configuration
35
+ @stdout = io.fetch(:stdout)
36
+ @stderr = io.fetch(:stderr)
37
+ end
38
+
39
+ def call(dry_run: false)
40
+ return 0 if @configuration.post_create_command == false
41
+
42
+ return run_custom_command(dry_run: dry_run) if custom_command?
43
+
44
+ run_built_in_steps(dry_run: dry_run)
45
+ end
46
+
47
+ private
48
+
49
+ def custom_command?
50
+ @configuration.post_create_command.is_a?(String) && !@configuration.post_create_command.empty?
51
+ end
52
+
53
+ def run_custom_command(dry_run:)
54
+ command = @configuration.post_create_command
55
+
56
+ if dry_run
57
+ info("Would run: #{command}")
58
+ return 0
59
+ end
60
+
61
+ info("Running: #{command}")
62
+ stream_shell_command(command)
63
+ end
64
+
65
+ def run_built_in_steps(dry_run:)
66
+ result = run_credential_linking(dry_run: dry_run)
67
+ return result unless result.zero?
68
+
69
+ STEPS.each do |step|
70
+ config_attr = STEP_CONFIG[step[:id]]
71
+ next unless @configuration.public_send(config_attr)
72
+ next if step[:id] == :yarn && !yarn_lock_present?
73
+
74
+ result = run_step(step, dry_run: dry_run)
75
+ return result unless result.zero?
76
+ end
77
+
78
+ 0
79
+ end
80
+
81
+ def run_credential_linking(dry_run:)
82
+ # Missing peer key files are intentionally best-effort and should not abort
83
+ # worktree setup; the linker reports those cases in its messages while real
84
+ # filesystem errors still raise out of `credential_key_linker.call`.
85
+ return 0 unless credential_linking_enabled?
86
+
87
+ info('🔑 Linking credential keys...')
88
+
89
+ result = credential_key_linker.call(dry_run: dry_run)
90
+ print_credential_linking_messages(result)
91
+
92
+ 0
93
+ end
94
+
95
+ def run_step(step, dry_run:)
96
+ if dry_run
97
+ info("Would run: #{display_command(step)}")
98
+ return 0
99
+ end
100
+
101
+ info(step[:header])
102
+ stream_step_command(step)
103
+ end
104
+
105
+ def stream_shell_command(command)
106
+ exit_status = capture_shell_command_exit_status(command)
107
+ report_failed_command(command, exit_status) unless exit_status.zero?
108
+ exit_status
109
+ end
110
+
111
+ def stream_step_command(step)
112
+ command = display_command(step)
113
+ exit_status = capture_step_command_exit_status(step)
114
+ report_failed_command(command, exit_status) unless exit_status.zero?
115
+ exit_status
116
+ end
117
+
118
+ def capture_shell_command_exit_status(command)
119
+ exit_status = nil
120
+
121
+ Open3.popen2e(base_env, command, chdir: @target_dir) do |_stdin, output, wait_thread|
122
+ output.each_line { |line| @stdout.print(line) }
123
+ exit_status = wait_thread.value.exitstatus
124
+ end
125
+
126
+ exit_status
127
+ rescue StandardError => e
128
+ report_failed_command_start(command, e)
129
+ 1
130
+ end
131
+
132
+ def capture_step_command_exit_status(step)
133
+ exit_status = nil
134
+
135
+ Open3.popen2e(*step_command(step), chdir: @target_dir) do |_stdin, output, wait_thread|
136
+ output.each_line { |line| @stdout.print(line) }
137
+ exit_status = wait_thread.value.exitstatus
138
+ end
139
+
140
+ exit_status
141
+ rescue StandardError => e
142
+ report_failed_command_start(display_command(step), e)
143
+ 1
144
+ end
145
+
146
+ def base_env
147
+ # Pass through only the essentials so subprocess tools work correctly.
148
+ ENV.to_h.slice('PATH', 'HOME', 'LANG', 'TERM', 'SHELL',
149
+ 'BUNDLE_GEMFILE', 'BUNDLE_PATH', 'GEM_HOME', 'GEM_PATH',
150
+ 'RUBY_VERSION', 'RAILS_ENV', 'NODE_ENV',
151
+ 'XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
152
+ 'DEV_PORT', 'WORKTREE_DATABASE_SUFFIX')
153
+ end
154
+
155
+ def yarn_lock_present?
156
+ File.exist?(File.join(@target_dir, 'yarn.lock'))
157
+ end
158
+
159
+ def display_command(step)
160
+ env_prefix = step.fetch(:env, {}).map { |key, value| "#{key}=#{value}" }.join(' ')
161
+ command = step.fetch(:argv).join(' ')
162
+ [env_prefix, command].reject(&:empty?).join(' ')
163
+ end
164
+
165
+ def step_command(step)
166
+ [base_env.merge(step.fetch(:env, {})), *step.fetch(:argv)]
167
+ end
168
+
169
+ def info(message)
170
+ @stdout.puts("→ #{message}")
171
+ end
172
+
173
+ def report_failed_command(command, exit_status)
174
+ @stderr.puts("❌ Command failed (exit #{exit_status}): #{command}")
175
+ end
176
+
177
+ def report_failed_command_start(command, error)
178
+ @stderr.puts("❌ Command failed to start: #{command} (#{error.message})")
179
+ end
180
+
181
+ def credential_linking_enabled?
182
+ @configuration.link_credential_keys ||
183
+ @configuration.link_test_credential_key ||
184
+ @configuration.link_production_credential_key
185
+ end
186
+
187
+ def credential_key_linker
188
+ CredentialKeyLinker.new(
189
+ target_dir: @target_dir,
190
+ peer_roots: @peer_roots,
191
+ configuration: @configuration
192
+ )
193
+ end
194
+
195
+ def print_credential_linking_messages(result)
196
+ result.messages.each { |message| info(" #{message}") }
197
+ end
198
+ end
199
+ # rubocop:enable Metrics/ClassLength
200
+ end
201
+ end
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Worktrees
3
- VERSION = '0.5.1'.freeze
3
+ VERSION = '0.6.0'.freeze
4
4
  end
5
5
  end
@@ -4,6 +4,8 @@ require_relative 'worktrees/version'
4
4
  require_relative 'worktrees/configuration'
5
5
  require_relative 'worktrees/application_configuration'
6
6
  require_relative 'worktrees/env_bootstrapper'
7
+ require_relative 'worktrees/credential_key_linker'
8
+ require_relative 'worktrees/post_create_runner'
7
9
  require_relative 'worktrees/command'
8
10
  require_relative 'worktrees/cli'
9
11
  require_relative 'worktrees/browser_command'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-worktrees
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asjer Querido
@@ -63,13 +63,16 @@ files:
63
63
  - lib/rails/worktrees/command/git_operations.rb
64
64
  - lib/rails/worktrees/command/name_picking.rb
65
65
  - lib/rails/worktrees/command/output.rb
66
+ - lib/rails/worktrees/command/post_create_support.rb
66
67
  - lib/rails/worktrees/command/workspace_paths.rb
67
68
  - lib/rails/worktrees/configuration.rb
69
+ - lib/rails/worktrees/credential_key_linker.rb
68
70
  - lib/rails/worktrees/database_config_updater.rb
69
71
  - lib/rails/worktrees/env_bootstrapper.rb
70
72
  - lib/rails/worktrees/initializer_updater.rb
71
73
  - lib/rails/worktrees/mise_toml_updater.rb
72
74
  - lib/rails/worktrees/names/cities.txt
75
+ - lib/rails/worktrees/post_create_runner.rb
73
76
  - lib/rails/worktrees/procfile_updater.rb
74
77
  - lib/rails/worktrees/project_configuration_loader.rb
75
78
  - lib/rails/worktrees/project_maintenance.rb