rails-worktrees 0.5.0 → 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: 9f11c54fe45c6061774b7181c80c7a25ba1c824d04fe06668ca0d6fe0bc3c453
4
- data.tar.gz: 9470d8a967f77f0188679e8f9b50fd5a78c4aebe6f10e268cba00ffaff1ebea0
3
+ metadata.gz: de8cd30214f81e827aee0e2fabba426a1c0cfca6f172d03fb36fad408fc8bfe7
4
+ data.tar.gz: 604eb3468c743e91b3b5ad19f9943fa94d9fe891b45732de491ddc8219c4b943
5
5
  SHA512:
6
- metadata.gz: c3775b72de293d0cf888f9b94cbbeae5b6031f001c641a1365f8f2662caa38de8b04bd4235a4e5c3f7b81279f6a23ce4637f4b2cfa6d6e1ba9d0fd0619daede2
7
- data.tar.gz: 0c07249ae8bad61d6ae0cdad68f58752579145175e0d43005772e0d91f2f04b8d672f99a1952ce168e881d04e0b88ab4bbb8603183d4a9a2ace12f5c1767584e
6
+ metadata.gz: 8f33f141355afb0dbc9ee06cdaab2e2f242c7d49b7ac72b6c2aa21202aabc8fb709cf79aa88a0b43f2357240b19b88dab37cc284f3fd4f712cc2b6c1057b743b
7
+ data.tar.gz: c9bc8caa8f38b496e2d7dd4bc0693251ec18606863b349cf949398e2cc131c448277869e04ac873e80f8b3dfff3f1fc59604e7ba2207fff21a97c255376524f3
@@ -1 +1 @@
1
- {".":"0.5.0"}
1
+ {".":"0.6.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
10
+ ## [0.5.1](https://github.com/asjer/rails-worktrees/compare/v0.5.0...v0.5.1) (2026-04-02)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **initializer:** simplify generated config wiring ([bd7fd50](https://github.com/asjer/rails-worktrees/commit/bd7fd506f2c0106a0bd188e36b31329a9de2acbf))
16
+
3
17
  ## [0.5.0](https://github.com/asjer/rails-worktrees/compare/v0.4.0...v0.5.0) (2026-04-02)
4
18
 
5
19
 
data/README.md CHANGED
@@ -19,7 +19,7 @@ bin/rails generate worktrees:install --browser
19
19
  bin/rails generate worktrees:install --yolo
20
20
  ```
21
21
 
22
- The generated initializer checks that `rails-worktrees` is actually loaded before it calls `Rails::Worktrees.configure`, so the app can still boot in environments like `test` when the gem is only bundled for `:development`.
22
+ The generated initializer writes app config under `Rails.application.config.x.rails_worktrees`, so the file stays safe to load even in environments like `test` where the gem is only bundled for `:development`. When `rails-worktrees` is loaded, the gem applies those settings for both the CLI and in-process Rails usage.
23
23
 
24
24
  The installer adds:
25
25
 
@@ -148,7 +148,7 @@ Worktree names must not contain `/` or whitespace, must not be `.` or `..`, and
148
148
 
149
149
  The installer generates `config/initializers/rails_worktrees.rb` where you can override:
150
150
 
151
- The initializer becomes a no-op whenever `rails-worktrees` is not loaded in the current bundle groups.
151
+ The initializer stays app-owned and gem-agnostic; `rails-worktrees` reads those settings whenever the gem is present in the current bundle groups.
152
152
 
153
153
  | Option | Default | Description |
154
154
  |--------|---------|-------------|
@@ -1,23 +1,30 @@
1
- if Gem.loaded_specs.key?('rails-worktrees') &&
2
- defined?(Rails::Worktrees) &&
3
- Rails::Worktrees.respond_to?(:configure)
4
- Rails::Worktrees.configure do |config|
1
+ Rails.application.config.x.rails_worktrees.tap do |config|
5
2
  <% if options['conductor'] -%>
6
- config.workspace_root = <%= conductor_workspace_root %>
3
+ config.workspace_root = <%= conductor_workspace_root %>
7
4
  <% else -%>
8
- # By default, worktrees go in a sibling "<project>.worktrees" directory.
9
- # Uncomment to override with a custom parent directory that uses <root>/<project>/<name>.
10
- # config.workspace_root = File.expand_path('~/worktrees')
5
+ # By default, worktrees go in a sibling "<project>.worktrees" directory.
6
+ # Uncomment to override with a custom parent directory that uses <root>/<project>/<name>.
7
+ # config.workspace_root = File.expand_path('~/worktrees')
11
8
  <% end -%>
12
- # config.bootstrap_env = false
13
- # config.dev_port_range = 3000..3999
14
- # config.worktree_database_suffix_max_length = 18
15
- # config.branch_prefix = '🚂'
16
- # config.name_sources_path = Rails.root.join('config/worktree_names').to_s
17
- # config.used_names_file = File.join(
18
- # ENV.fetch('XDG_STATE_HOME', File.expand_path('~/.local/state')),
19
- # 'rails-worktrees',
20
- # 'used-names.tsv'
21
- # )
22
- end
23
- end
9
+ # config.bootstrap_env = false
10
+ # config.dev_port_range = 3000..3999
11
+ # config.worktree_database_suffix_max_length = 18
12
+ # config.branch_prefix = '🚂'
13
+ # config.name_sources_path = Rails.root.join('config/worktree_names').to_s
14
+ # config.used_names_file = File.join(
15
+ # ENV.fetch('XDG_STATE_HOME', File.expand_path('~/.local/state')),
16
+ # 'rails-worktrees',
17
+ # 'used-names.tsv'
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
30
+ end
@@ -0,0 +1,36 @@
1
+ module Rails
2
+ module Worktrees
3
+ # Copies explicit app configuration values onto the runtime configuration object.
4
+ module ApplicationConfiguration
5
+ module_function
6
+
7
+ def apply(source, configuration:)
8
+ return configuration unless source
9
+
10
+ Configuration::CONFIGURABLE_ATTRIBUTES.each do |attribute|
11
+ next unless assigned?(source, attribute)
12
+
13
+ configuration.public_send("#{attribute}=", value_for(source, attribute))
14
+ end
15
+
16
+ configuration
17
+ end
18
+
19
+ def assigned?(source, attribute)
20
+ key = attribute.to_sym
21
+ hash = source.is_a?(Hash) ? source : source.to_h if source.respond_to?(:to_h)
22
+ return hash.key?(key) || hash.key?(attribute.to_s) if hash
23
+
24
+ source.respond_to?(:key?) && (source.key?(key) || source.key?(attribute.to_s))
25
+ end
26
+
27
+ def value_for(source, attribute)
28
+ key = attribute.to_sym
29
+ hash = source.is_a?(Hash) ? source : source.to_h if source.respond_to?(:to_h)
30
+ return hash.fetch(key) { hash[attribute.to_s] } if hash
31
+
32
+ source.public_send(attribute)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -2,6 +2,9 @@ module Rails
2
2
  module Worktrees
3
3
  # Shell entrypoint for the wt executable.
4
4
  class CLI
5
+ LOADER_OPTIONAL_COMMANDS = %w[doctor update -h --help -v --version].freeze
6
+ LOADER_IGNORED_FLAGS = %w[--dry-run --force].freeze
7
+
5
8
  def initialize(
6
9
  argv: ARGV,
7
10
  io: { stdin: $stdin, stdout: $stdout, stderr: $stderr },
@@ -15,13 +18,36 @@ module Rails
15
18
  end
16
19
 
17
20
  def start
18
- Command.new(
19
- argv: @argv,
20
- io: @io,
21
- env: @env,
22
- cwd: @cwd,
23
- configuration: ::Rails::Worktrees.configuration
24
- ).run
21
+ configuration = ::Rails::Worktrees.configuration
22
+ load_project_configuration(configuration) if should_load_project_configuration?
23
+ command_for(configuration).run
24
+ rescue ::Rails::Worktrees::Error => e
25
+ @io.fetch(:stderr).puts("Error: #{e.message}")
26
+ 1
27
+ end
28
+
29
+ private
30
+
31
+ def load_project_configuration(configuration)
32
+ ::Rails::Worktrees::ProjectConfigurationLoader.new(root: @cwd, configuration: configuration).call
33
+ rescue StandardError, ScriptError => e
34
+ raise ::Rails::Worktrees::Error, "Failed to load worktrees configuration: #{e.class}: #{e.message}"
35
+ end
36
+
37
+ def should_load_project_configuration?
38
+ argv_without_flags.empty? || !loader_optional_command?(argv_without_flags.first)
39
+ end
40
+
41
+ def argv_without_flags
42
+ @argv.reject { |arg| LOADER_IGNORED_FLAGS.include?(arg) }
43
+ end
44
+
45
+ def loader_optional_command?(command)
46
+ LOADER_OPTIONAL_COMMANDS.include?(command)
47
+ end
48
+
49
+ def command_for(configuration)
50
+ Command.new(argv: @argv, io: @io, env: @env, cwd: @cwd, configuration: configuration)
25
51
  end
26
52
  end
27
53
  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)
@@ -2,6 +2,25 @@ module Rails
2
2
  module Worktrees
3
3
  # Stores application-level settings for the wt command.
4
4
  class Configuration
5
+ CONFIGURABLE_ATTRIBUTES = %i[
6
+ bootstrap_env
7
+ workspace_root
8
+ dev_port_range
9
+ branch_prefix
10
+ name_sources_path
11
+ used_names_file
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
22
+ ].freeze
23
+
5
24
  DEFAULT_BOOTSTRAP_ENV = true
6
25
  DEFAULT_BRANCH_PREFIX = '🚂'.freeze
7
26
  DEFAULT_DEV_PORT_RANGE = (3000..3999)
@@ -15,9 +34,20 @@ module Rails
15
34
 
16
35
  attr_accessor :bootstrap_env, :branch_prefix, :dev_port_range, :legacy_used_names_files,
17
36
  :name_sources_path, :used_names_file, :workspace_root,
18
- :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
19
42
 
20
43
  def initialize
44
+ assign_core_defaults
45
+ assign_post_create_defaults
46
+ end
47
+
48
+ private
49
+
50
+ def assign_core_defaults
21
51
  @bootstrap_env = DEFAULT_BOOTSTRAP_ENV
22
52
  @workspace_root = nil
23
53
  @branch_prefix = DEFAULT_BRANCH_PREFIX
@@ -28,7 +58,17 @@ module Rails
28
58
  @legacy_used_names_files = default_legacy_used_names_files
29
59
  end
30
60
 
31
- 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
32
72
 
33
73
  def default_legacy_used_names_files
34
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
@@ -2,7 +2,7 @@ require 'erb'
2
2
 
3
3
  module Rails
4
4
  module Worktrees
5
- # Safely updates the generated initializer to use the current gem-loading guard.
5
+ # Safely updates the generated initializer to use the current managed app-config format.
6
6
  # rubocop:disable Metrics/ClassLength
7
7
  class InitializerUpdater
8
8
  Result = Struct.new(:content, :changed, :status, :messages) do
@@ -12,18 +12,13 @@ module Rails
12
12
  end
13
13
 
14
14
  TEMPLATE_PATH = File.expand_path('../../generators/rails/worktrees/templates/rails_worktrees.rb.tt', __dir__)
15
- CURRENT_GUARD_LINES = [
16
- "if Gem.loaded_specs.key?('rails-worktrees') &&",
17
- ' defined?(Rails::Worktrees) &&',
18
- ' Rails::Worktrees.respond_to?(:configure)'
19
- ].freeze
15
+ CURRENT_WRAPPER_CALL = 'Rails.application.config.x.rails_worktrees.tap do |config|'.freeze
16
+ CONFIGURE_CALL = 'Rails::Worktrees.configure do |config|'.freeze
20
17
  KNOWN_GUARD_FRAGMENTS = [
21
18
  "Gem.loaded_specs.key?('rails-worktrees')",
22
19
  'defined?(Rails::Worktrees)',
23
20
  'Rails::Worktrees.respond_to?(:configure)'
24
21
  ].freeze
25
- CONFIGURE_CALL = 'Rails::Worktrees.configure do |config|'.freeze
26
- LEGACY_GUARD = /\Aif defined\?\(Rails::Worktrees\)\n(?<body>.*)\nend\z/m
27
22
 
28
23
  def self.default_content = new(content: '').send(:render_default_template)
29
24
 
@@ -46,7 +41,7 @@ module Rails
46
41
  @content,
47
42
  false,
48
43
  :identical,
49
- ['config/initializers/rails_worktrees.rb already uses the current safety guard.']
44
+ ['config/initializers/rails_worktrees.rb already uses the current managed initializer format.']
50
45
  )
51
46
  end
52
47
 
@@ -55,7 +50,7 @@ module Rails
55
50
  content,
56
51
  content != @content,
57
52
  :updated,
58
- ['Updated config/initializers/rails_worktrees.rb to use the current safety guard.']
53
+ ['Updated config/initializers/rails_worktrees.rb to use the current managed initializer format.']
59
54
  )
60
55
  end
61
56
 
@@ -70,19 +65,20 @@ module Rails
70
65
 
71
66
  def blank_content? = @content.to_s.strip.empty?
72
67
 
73
- def current_guard_present?
74
- !extract_known_guard_body(@content.to_s.strip.lines, required_guard_lines: CURRENT_GUARD_LINES).nil?
68
+ def current_wrapper_present?
69
+ !extract_current_wrapper_body(@content.to_s.strip.lines).nil?
75
70
  end
76
71
 
77
- def wrapped_body = extract_existing_body&.then { |body| normalize_body(body) }
72
+ def normalized_body = extract_existing_body&.then { |body| normalize_body(body) }
78
73
 
79
74
  def extract_existing_body
80
75
  stripped = @content.to_s.strip
81
76
  return if stripped.empty?
82
77
 
83
- return Regexp.last_match[:body] if stripped.match(LEGACY_GUARD)
78
+ body = extract_current_wrapper_body(stripped.lines)
79
+ return body if body
84
80
 
85
- body = extract_known_guard_body(stripped.lines)
81
+ body = extract_guarded_configure_body(stripped.lines)
86
82
  return body if body
87
83
 
88
84
  body = extract_plain_configure_body(stripped.lines)
@@ -91,25 +87,25 @@ module Rails
91
87
  nil
92
88
  end
93
89
 
94
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
95
- def extract_known_guard_body(lines, required_guard_lines: nil)
96
- return if lines.empty? || lines.last.strip != 'end'
90
+ def extract_current_wrapper_body(lines)
91
+ return unless lines.first&.strip == CURRENT_WRAPPER_CALL && lines.last&.strip == 'end'
97
92
 
98
- configure_index = lines.index { |line| line.strip == CONFIGURE_CALL }
99
- return unless configure_index
93
+ lines[1...-1].join.rstrip
94
+ end
95
+
96
+ def extract_guarded_configure_body(lines)
97
+ return unless guarded_configure_block?(lines)
100
98
 
101
- guard_lines = lines[0...configure_index].reject { |line| line.strip.empty? }.map(&:rstrip)
102
- return unless guard_lines.all? { |line| known_guard_line?(line) }
103
- return if required_guard_lines && guard_lines != required_guard_lines
99
+ configure_index = lines.index { |line| line.strip == CONFIGURE_CALL }
100
+ return unless guarded_configure_lines(lines, configure_index).all? { |line| known_guard_line?(line) }
104
101
 
105
- lines[configure_index...-1].join.rstrip
102
+ lines[(configure_index + 1)...-2].join.rstrip
106
103
  end
107
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
108
104
 
109
105
  def extract_plain_configure_body(lines)
110
106
  return unless lines.first&.strip == CONFIGURE_CALL && lines.last&.strip == 'end'
111
107
 
112
- lines.join.rstrip
108
+ lines[1...-1].join.rstrip
113
109
  end
114
110
 
115
111
  def known_guard_line?(line)
@@ -124,13 +120,17 @@ module Rails
124
120
  body_lines = body.rstrip.lines
125
121
  return '' if body_lines.empty?
126
122
 
127
- return body.rstrip if body_lines.first.start_with?(' ')
123
+ minimum_indent = body_indent(body_lines)
128
124
 
129
- body_lines.map { |line| line.strip.empty? ? line : " #{line}" }.join.rstrip
125
+ body_lines.map do |line|
126
+ next line if line.strip.empty?
127
+
128
+ normalize_body_line(line, minimum_indent)
129
+ end.join.rstrip
130
130
  end
131
131
 
132
132
  def rebuild_content(body)
133
- content = [CURRENT_GUARD_LINES.join("\n"), body, 'end'].join("\n")
133
+ content = [CURRENT_WRAPPER_CALL, body, 'end'].join("\n")
134
134
  @content.end_with?("\n") || @content.empty? ? "#{content}\n" : content
135
135
  end
136
136
 
@@ -142,6 +142,29 @@ module Rails
142
142
  Struct.new(:options, :conductor_workspace_root).new({ 'conductor' => false },
143
143
  "File.expand_path('~/Sites/conductor/workspaces')")
144
144
  end
145
+
146
+ def guarded_configure_block?(lines)
147
+ lines.last(2).map(&:strip) == %w[end end] && lines.any? { |line| line.strip == CONFIGURE_CALL }
148
+ end
149
+
150
+ def guarded_configure_lines(lines, configure_index)
151
+ lines[0...configure_index].reject { |line| line.strip.empty? }.map(&:rstrip)
152
+ end
153
+
154
+ def body_indent(body_lines)
155
+ body_lines.reject { |line| line.strip.empty? }
156
+ .map { |line| line[/\A\s*/].length }
157
+ .min || 0
158
+ end
159
+
160
+ def normalize_body_line(line, minimum_indent)
161
+ trimmed = line.sub(/\A\s{0,#{minimum_indent}}/, '')
162
+ " #{trimmed}"
163
+ end
164
+
165
+ def current_guard_present? = current_wrapper_present?
166
+
167
+ def wrapped_body = normalized_body
145
168
  end
146
169
  # rubocop:enable Metrics/ClassLength
147
170
  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
@@ -0,0 +1,205 @@
1
+ require 'pathname'
2
+
3
+ module Rails
4
+ module Worktrees
5
+ # Loads project-level configuration from the generated initializer without booting the full app.
6
+ class ProjectConfigurationLoader
7
+ CURRENT_WRAPPER_CALL = 'Rails.application.config.x.rails_worktrees.tap do |config|'.freeze
8
+ CONFIGURE_CALL = 'Rails::Worktrees.configure do |config|'.freeze
9
+ INITIALIZER_RELATIVE_PATH = 'config/initializers/rails_worktrees.rb'.freeze
10
+ KNOWN_GUARD_FRAGMENTS = [
11
+ "Gem.loaded_specs.key?('rails-worktrees')",
12
+ 'defined?(Rails::Worktrees)',
13
+ 'Rails::Worktrees.respond_to?(:configure)'
14
+ ].freeze
15
+ TEMP_RAILS_ROOT_MUTEX = Mutex.new
16
+
17
+ def initialize(root:, configuration: Rails::Worktrees.configuration)
18
+ @root = root
19
+ @configuration = configuration
20
+ end
21
+
22
+ def call
23
+ return configuration unless initializer_path && File.file?(initializer_path)
24
+
25
+ body = extract_configuration_body(File.read(initializer_path))
26
+ return configuration unless body
27
+
28
+ recorder = AssignmentRecorder.new(configuration)
29
+
30
+ with_temporary_rails_root do
31
+ evaluate_configuration_body(body, recorder)
32
+ end
33
+
34
+ Rails::Worktrees.apply_application_configuration(recorder.values, configuration: configuration)
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :configuration, :root
40
+
41
+ def initializer_path
42
+ return @initializer_path if defined?(@initializer_path)
43
+
44
+ @initializer_path = project_root&.join(INITIALIZER_RELATIVE_PATH)&.to_s
45
+ end
46
+
47
+ def project_root
48
+ return @project_root if defined?(@project_root)
49
+
50
+ @project_root = discover_project_root
51
+ end
52
+
53
+ def discover_project_root
54
+ current = Pathname(root).expand_path
55
+
56
+ current.ascend do |path|
57
+ return path if File.file?(path.join(INITIALIZER_RELATIVE_PATH))
58
+ end
59
+
60
+ nil
61
+ end
62
+
63
+ def evaluate_configuration_body(body, recorder)
64
+ # Evaluates a managed initializer body like:
65
+ #
66
+ # proc do |config|
67
+ # config.branch_prefix = '🌿'
68
+ # end
69
+ # rubocop:disable Security/Eval, Style/DocumentDynamicEvalDefinition, Style/EvalWithLocation
70
+ Kernel.eval(
71
+ "proc do |config|\n#{body.rstrip}\nend",
72
+ TOPLEVEL_BINDING,
73
+ initializer_path,
74
+ 1
75
+ ).call(recorder)
76
+ # rubocop:enable Security/Eval, Style/DocumentDynamicEvalDefinition, Style/EvalWithLocation
77
+ end
78
+
79
+ def extract_configuration_body(content)
80
+ stripped = content.to_s.strip
81
+ return if stripped.empty?
82
+
83
+ lines = stripped.lines
84
+
85
+ extract_single_block_body(lines, CURRENT_WRAPPER_CALL) ||
86
+ extract_guarded_configure_body(lines) ||
87
+ extract_single_block_body(lines, CONFIGURE_CALL)
88
+ end
89
+
90
+ def extract_single_block_body(lines, opening_line)
91
+ return unless lines.first&.strip == opening_line && lines.last&.strip == 'end'
92
+
93
+ lines[1...-1].join
94
+ end
95
+
96
+ def extract_guarded_configure_body(lines)
97
+ return unless guarded_configure_block?(lines)
98
+
99
+ configure_index = lines.index { |line| line.strip == CONFIGURE_CALL }
100
+ return unless guarded_configure_lines(lines, configure_index).all? { |line| known_guard_line?(line) }
101
+
102
+ lines[(configure_index + 1)...-2].join
103
+ end
104
+
105
+ def known_guard_line?(line)
106
+ normalized = line.strip.delete_suffix('&&').strip.sub(/\Aif\s+/, '')
107
+ KNOWN_GUARD_FRAGMENTS.include?(normalized)
108
+ end
109
+
110
+ def guarded_configure_block?(lines)
111
+ lines.last(2).map(&:strip) == %w[end end] && lines.any? { |line| line.strip == CONFIGURE_CALL }
112
+ end
113
+
114
+ def guarded_configure_lines(lines, configure_index)
115
+ lines[0...configure_index].reject { |line| line.strip.empty? }.map(&:rstrip)
116
+ end
117
+
118
+ def with_temporary_rails_root
119
+ self.class::TEMP_RAILS_ROOT_MUTEX.synchronize do
120
+ override_state = build_rails_root_override_state
121
+
122
+ apply_temporary_rails_root(override_state, project_root)
123
+ yield
124
+ ensure
125
+ restore_rails_root(override_state)
126
+ end
127
+ end
128
+
129
+ def build_rails_root_override_state
130
+ had_root = Rails.respond_to?(:root)
131
+ { singleton_class: Rails.singleton_class, had_root: had_root,
132
+ previous_root: (Rails.method(:root) if had_root), overridden: false }
133
+ end
134
+
135
+ def apply_temporary_rails_root(override_state, resolved_project_root)
136
+ override_state[:singleton_class].send(:define_method, :root) { resolved_project_root }
137
+ override_state[:overridden] = true
138
+ end
139
+
140
+ def restore_rails_root(override_state)
141
+ return unless override_state
142
+
143
+ override_state[:singleton_class].send(:remove_method, :root) if override_state[:overridden]
144
+ return unless override_state[:had_root]
145
+
146
+ override_state[:singleton_class].send(:define_method, :root, override_state[:previous_root])
147
+ end
148
+
149
+ # Records config.<name> = value assignments without raising on unknown keys.
150
+ class AssignmentRecorder
151
+ attr_reader :values
152
+
153
+ def initialize(configuration = nil)
154
+ @configuration = configuration
155
+ @values = {}
156
+ end
157
+
158
+ def method_missing(method_name, *args)
159
+ name = method_name.to_s
160
+
161
+ if setter_call?(name, args)
162
+ values[name.delete_suffix('=').to_sym] = args.first
163
+ elsif getter_call?(name, args)
164
+ values.fetch(name.to_sym) { configuration_value_for(method_name) }
165
+ else
166
+ super
167
+ end
168
+ end
169
+
170
+ def respond_to_missing?(method_name, include_private = false)
171
+ name = method_name.to_s
172
+
173
+ setter_name?(name) || getter_name?(name) || super
174
+ end
175
+
176
+ private
177
+
178
+ attr_reader :configuration
179
+
180
+ def setter_call?(name, args)
181
+ setter_name?(name) && args.length == 1
182
+ end
183
+
184
+ def getter_call?(name, args)
185
+ getter_name?(name) && args.empty?
186
+ end
187
+
188
+ def setter_name?(name)
189
+ name.end_with?('=')
190
+ end
191
+
192
+ def getter_name?(name)
193
+ !setter_name?(name)
194
+ end
195
+
196
+ def configuration_value_for(method_name)
197
+ fallback_configuration = configuration
198
+ return unless fallback_configuration.respond_to?(method_name)
199
+
200
+ fallback_configuration.public_send(method_name)
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -151,7 +151,8 @@ module Rails
151
151
  identifier: :initializer,
152
152
  category: :install,
153
153
  relative_path: 'config/initializers/rails_worktrees.rb',
154
- identical_headline: 'config/initializers/rails_worktrees.rb already uses the current safety guard.',
154
+ identical_headline: 'config/initializers/rails_worktrees.rb already uses the current managed initializer ' \
155
+ 'format.',
155
156
  fixable_headline: 'config/initializers/rails_worktrees.rb can be updated automatically.',
156
157
  warning_headline: 'config/initializers/rails_worktrees.rb needs manual review.'
157
158
  }
@@ -162,7 +163,8 @@ module Rails
162
163
  config,
163
164
  "#{config.fetch(:relative_path)} is missing.",
164
165
  updated_content: InitializerUpdater.default_content,
165
- apply_messages: ['Created config/initializers/rails_worktrees.rb with the current safety guard.']
166
+ apply_messages: ['Created config/initializers/rails_worktrees.rb in the current managed initializer ' \
167
+ 'format.']
166
168
  )
167
169
  end
168
170
 
@@ -5,6 +5,10 @@ module Rails
5
5
  initializer 'rails_worktrees.installation_hint' do
6
6
  Rails::Worktrees.warn_about_missing_installation
7
7
  end
8
+
9
+ initializer 'rails_worktrees.apply_application_config', after: :load_config_initializers do |app|
10
+ Rails::Worktrees.apply_application_configuration(app.config.x.rails_worktrees)
11
+ end
8
12
  end
9
13
  end
10
14
  end
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Worktrees
3
- VERSION = '0.5.0'.freeze
3
+ VERSION = '0.6.0'.freeze
4
4
  end
5
5
  end
@@ -2,7 +2,10 @@ require 'pathname'
2
2
 
3
3
  require_relative 'worktrees/version'
4
4
  require_relative 'worktrees/configuration'
5
+ require_relative 'worktrees/application_configuration'
5
6
  require_relative 'worktrees/env_bootstrapper'
7
+ require_relative 'worktrees/credential_key_linker'
8
+ require_relative 'worktrees/post_create_runner'
6
9
  require_relative 'worktrees/command'
7
10
  require_relative 'worktrees/cli'
8
11
  require_relative 'worktrees/browser_command'
@@ -11,6 +14,7 @@ require_relative 'worktrees/initializer_updater'
11
14
  require_relative 'worktrees/procfile_updater'
12
15
  require_relative 'worktrees/mise_toml_updater'
13
16
  require_relative 'worktrees/puma_config_updater'
17
+ require_relative 'worktrees/project_configuration_loader'
14
18
  require_relative 'worktrees/project_maintenance'
15
19
 
16
20
  module Rails
@@ -39,6 +43,10 @@ module Rails
39
43
  @configuration = Configuration.new
40
44
  end
41
45
 
46
+ def apply_application_configuration(source, configuration: self.configuration)
47
+ ApplicationConfiguration.apply(source, configuration: configuration)
48
+ end
49
+
42
50
  def installation_complete?(root = resolve_root)
43
51
  return false unless root
44
52
 
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.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asjer Querido
@@ -55,6 +55,7 @@ files:
55
55
  - lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt
56
56
  - lib/generators/worktrees/install/install_generator.rb
57
57
  - lib/rails/worktrees.rb
58
+ - lib/rails/worktrees/application_configuration.rb
58
59
  - lib/rails/worktrees/browser_command.rb
59
60
  - lib/rails/worktrees/cli.rb
60
61
  - lib/rails/worktrees/command.rb
@@ -62,14 +63,18 @@ files:
62
63
  - lib/rails/worktrees/command/git_operations.rb
63
64
  - lib/rails/worktrees/command/name_picking.rb
64
65
  - lib/rails/worktrees/command/output.rb
66
+ - lib/rails/worktrees/command/post_create_support.rb
65
67
  - lib/rails/worktrees/command/workspace_paths.rb
66
68
  - lib/rails/worktrees/configuration.rb
69
+ - lib/rails/worktrees/credential_key_linker.rb
67
70
  - lib/rails/worktrees/database_config_updater.rb
68
71
  - lib/rails/worktrees/env_bootstrapper.rb
69
72
  - lib/rails/worktrees/initializer_updater.rb
70
73
  - lib/rails/worktrees/mise_toml_updater.rb
71
74
  - lib/rails/worktrees/names/cities.txt
75
+ - lib/rails/worktrees/post_create_runner.rb
72
76
  - lib/rails/worktrees/procfile_updater.rb
77
+ - lib/rails/worktrees/project_configuration_loader.rb
73
78
  - lib/rails/worktrees/project_maintenance.rb
74
79
  - lib/rails/worktrees/puma_config_updater.rb
75
80
  - lib/rails/worktrees/railtie.rb