rails-worktrees 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de8cd30214f81e827aee0e2fabba426a1c0cfca6f172d03fb36fad408fc8bfe7
4
- data.tar.gz: 604eb3468c743e91b3b5ad19f9943fa94d9fe891b45732de491ddc8219c4b943
3
+ metadata.gz: 36b6c2a6e2e481803b035ba28c575c1619ba5fb1d158f0e4c6e4180d69aeafee
4
+ data.tar.gz: 9265e0459e03d34cbc4962b181bdd62836ddb4433c392d8aac43b65f445992a1
5
5
  SHA512:
6
- metadata.gz: 8f33f141355afb0dbc9ee06cdaab2e2f242c7d49b7ac72b6c2aa21202aabc8fb709cf79aa88a0b43f2357240b19b88dab37cc284f3fd4f712cc2b6c1057b743b
7
- data.tar.gz: c9bc8caa8f38b496e2d7dd4bc0693251ec18606863b349cf949398e2cc131c448277869e04ac873e80f8b3dfff3f1fc59604e7ba2207fff21a97c255376524f3
6
+ metadata.gz: 7cfecdcffdaeb1e229332c1a8a20ed80f2600886a58deb4f2f9b186c70957f2b70224a950e1c9109b3d9d234db3c18fe7789074ba1109e58d6e1da35236e2923
7
+ data.tar.gz: 977ebc76e219d9e31d05595852ab830355c6ca30f2617d2ead869b4d5ccce18b0bbc0360dd4e533aff6ffab53c7eee7c20480ec2f380881c69d5387e859526b0
@@ -1 +1 @@
1
- {".":"0.6.0"}
1
+ {".":"0.7.1"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1](https://github.com/asjer/rails-worktrees/compare/v0.7.0...v0.7.1) (2026-07-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **wt:** bootstrap mise before bundler ([24720a1](https://github.com/asjer/rails-worktrees/commit/24720a1cf51ce55676277c478f3b86bbb98e72eb))
9
+
10
+ ## [0.7.0](https://github.com/asjer/rails-worktrees/compare/v0.6.0...v0.7.0) (2026-04-05)
11
+
12
+
13
+ ### Features
14
+
15
+ * **wt:** add rerunnable setup workflow ([a609fcc](https://github.com/asjer/rails-worktrees/commit/a609fcc3c463da1d2ad9922b620f9c517dc6ed74))
16
+
3
17
  ## [0.6.0](https://github.com/asjer/rails-worktrees/compare/v0.5.1...v0.6.0) (2026-04-03)
4
18
 
5
19
 
data/README.md CHANGED
@@ -42,8 +42,12 @@ With `--yolo`, the installer also:
42
42
  ```bash
43
43
  bin/wt # auto-pick a name from bundled *.txt lists
44
44
  bin/wt my-feature # use an explicit worktree name
45
+ bin/wt --skip-setup my-feature # create now, run setup later
45
46
  bin/wt --dry-run my-feature # preview the full setup without changing anything
46
47
  bin/wt --print-env my-feature # preview DEV_PORT and WORKTREE_DATABASE_SUFFIX
48
+ bin/wt setup # rerun setup for the current checkout/worktree
49
+ bin/wt setup my-feature # rerun setup for a managed worktree by name
50
+ bin/wt setup ../my-project.worktrees/my-feature # setup a specific checkout without cd-ing into it
47
51
  bin/wt doctor # audit install/config drift and basic worktree health
48
52
  bin/wt update --dry-run # preview safe maintenance fixes
49
53
  bin/wt update # apply safe maintenance fixes for managed files
@@ -65,6 +69,7 @@ bin/ob --print-url '?from=nav' # print the resolved URL without opening a brows
65
69
  | `-h`, `--help` | Show the help message |
66
70
  | `-v`, `--version` | Show the script version |
67
71
  | `--dry-run [name]` | Preview worktree creation or cleanup without changing anything |
72
+ | `--skip-setup` | Create a worktree without running setup steps |
68
73
  | `--force` | Force branch deletion for `bin/wt remove` / `bin/wt delete` |
69
74
  | `--env`, `--print-env <name>` | Preview `DEV_PORT` and `WORKTREE_DATABASE_SUFFIX` |
70
75
 
@@ -78,6 +83,7 @@ By default `bin/wt`:
78
83
  - auto-picks names from bundled `.txt` files when no explicit name is given
79
84
  - retires bundled names so they are not picked twice
80
85
  - bootstraps a worktree-local `.env` with deterministic `DEV_PORT` and `WORKTREE_DATABASE_SUFFIX` values
86
+ - runs setup automatically after creation: credential linking, `bundle install`, `yarn install` when applicable, both `db:prepare` steps, test asset precompile, and a final `bin/rails assets:clobber`
81
87
 
82
88
  ```text
83
89
  workspace/
@@ -90,6 +96,32 @@ workspace/
90
96
 
91
97
  `WT_WORKSPACES_ROOT` or `config.workspace_root` overrides the destination root and uses the layout `<root>/<project>/<name>`.
92
98
 
99
+ ### Setup command
100
+
101
+ `bin/wt setup` reruns setup for the **current checkout**. Run it from inside a linked worktree created by `bin/wt`, a worktree created manually with `git worktree`, or a checkout prepared by another tool.
102
+
103
+ If the checkout was created by `bin/wt`, you can also point at it by managed worktree name from the main app checkout:
104
+
105
+ ```bash
106
+ bin/wt setup my-feature
107
+ ```
108
+
109
+ If you do not want to `cd` first, pass an explicit checkout path:
110
+
111
+ ```bash
112
+ bin/wt setup ../my-project.worktrees/my-feature
113
+ ```
114
+
115
+ This is the recovery path when you want to create first and bootstrap later:
116
+
117
+ ```bash
118
+ bin/wt --skip-setup my-feature
119
+ cd ../my-project.worktrees/my-feature
120
+ bin/wt setup
121
+ ```
122
+
123
+ `bin/wt setup --dry-run` previews the same steps without changing files.
124
+
93
125
  ### Cleanup commands
94
126
 
95
127
  `bin/wt` also supports cleanup commands for worktrees it manages:
@@ -181,7 +213,7 @@ If your `database.yml` is too custom to patch safely, the installer leaves it al
181
213
  When `bin/wt` creates a worktree it writes a worktree-local `.env` with:
182
214
 
183
215
  - `DEV_PORT` — deterministic port derived from the worktree name via CRC32, rotated through `dev_port_range`, skipping ports already claimed by peer worktrees
184
- - `WORKTREE_DATABASE_SUFFIX` — derived from the worktree name so the `database.yml` ERB works immediately
216
+ - `WORKTREE_DATABASE_SUFFIX` — derived from the best available worktree identity (managed name when known, otherwise the current branch or checkout basename). When a readable suffix is already claimed by a peer checkout, `bin/wt` appends a short `DEV_PORT`-based token to keep the databases isolated.
185
217
 
186
218
  Existing `.env` values are never overwritten.
187
219
 
@@ -3,6 +3,8 @@ module Rails
3
3
  module Generators
4
4
  # Detects common mise.toml setups and suggests loading the worktree-local .env.
5
5
  module MiseFollowUp
6
+ FILE_ENCODING = 'UTF-8'.freeze
7
+
6
8
  private
7
9
 
8
10
  def follow_up_notes_text
@@ -26,7 +28,7 @@ module Rails
26
28
  def mise_env_file_configured?
27
29
  return false unless mise_toml_path
28
30
 
29
- File.read(mise_toml_path).match?(/^\s*_.file\s*=\s*["']\.env["']\s*(?:#.*)?\s*$/)
31
+ File.read(mise_toml_path, encoding: FILE_ENCODING).match?(/^\s*_.file\s*=\s*["']\.env["']\s*(?:#.*)?\s*$/)
30
32
  end
31
33
 
32
34
  def mise_toml_path
@@ -3,6 +3,8 @@ module Rails
3
3
  module Generators
4
4
  # Detects config/puma.rb setups that should prefer the worktree-local DEV_PORT.
5
5
  module PumaFollowUp
6
+ FILE_ENCODING = 'UTF-8'.freeze
7
+
6
8
  private
7
9
 
8
10
  def puma_follow_up_notes_text
@@ -24,7 +26,9 @@ module Rails
24
26
  def puma_update_result
25
27
  return missing_puma_update_result unless existing_puma_config_path
26
28
 
27
- ::Rails::Worktrees::PumaConfigUpdater.new(content: File.read(existing_puma_config_path)).call
29
+ ::Rails::Worktrees::PumaConfigUpdater.new(
30
+ content: File.read(existing_puma_config_path, encoding: FILE_ENCODING)
31
+ ).call
28
32
  end
29
33
 
30
34
  def missing_puma_update_result
@@ -1,5 +1,65 @@
1
- #!/usr/bin/env ruby
2
- ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
1
+ #!/usr/bin/env bash
2
+ set -e
3
3
 
4
- require 'bundler/setup'
5
- load Gem.bin_path('rails-worktrees', 'wt')
4
+ wt_prepend_path() {
5
+ case ":${PATH:-}:" in
6
+ *":$1:"*) ;;
7
+ *) PATH="$1${PATH:+:$PATH}" ;;
8
+ esac
9
+ }
10
+
11
+ wt_prepend_path "/usr/local/bin"
12
+ wt_prepend_path "/opt/homebrew/bin"
13
+ if [ -n "${HOME:-}" ]; then
14
+ wt_prepend_path "$HOME/.local/bin"
15
+ fi
16
+ export PATH
17
+
18
+ wt_locale_needs_utf8() {
19
+ case "${1:-}" in
20
+ ""|C|POSIX) return 0 ;;
21
+ *) return 1 ;;
22
+ esac
23
+ }
24
+
25
+ wt_utf8_locale() {
26
+ if command -v locale >/dev/null 2>&1 && locale -a 2>/dev/null | grep -Eiq '^(C|c)[_.-]UTF-?8$'; then
27
+ printf '%s' 'C.UTF-8'
28
+ else
29
+ printf '%s' 'en_US.UTF-8'
30
+ fi
31
+ }
32
+
33
+ if wt_locale_needs_utf8 "${LANG:-}" || { [ -n "${LC_ALL:-}" ] && wt_locale_needs_utf8 "${LC_ALL:-}"; }; then
34
+ WT_UTF8_LOCALE="${LANG:-}"
35
+ if wt_locale_needs_utf8 "$WT_UTF8_LOCALE"; then
36
+ WT_UTF8_LOCALE="$(wt_utf8_locale)"
37
+ fi
38
+
39
+ if wt_locale_needs_utf8 "${LANG:-}"; then
40
+ export LANG="$WT_UTF8_LOCALE"
41
+ fi
42
+ if [ -n "${LC_ALL:-}" ] && wt_locale_needs_utf8 "${LC_ALL:-}"; then
43
+ export LC_ALL="$WT_UTF8_LOCALE"
44
+ fi
45
+ fi
46
+
47
+ WT_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
48
+ WT_SCRIPT_PATH="$WT_SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")"
49
+ WT_APP_ROOT="$(cd "$WT_SCRIPT_DIR/.." && pwd -P)"
50
+ WT_MISE_CONFIG=""
51
+
52
+ if [ -f "$WT_APP_ROOT/mise.toml" ]; then
53
+ WT_MISE_CONFIG="$WT_APP_ROOT/mise.toml"
54
+ elif [ -f "$WT_APP_ROOT/.mise.toml" ]; then
55
+ WT_MISE_CONFIG="$WT_APP_ROOT/.mise.toml"
56
+ fi
57
+
58
+ if [ -n "$WT_MISE_CONFIG" ] && [ -z "${RAILS_WORKTREES_BOOTSTRAPPED:-}" ] && command -v mise >/dev/null 2>&1; then
59
+ mise trust "$WT_MISE_CONFIG" >/dev/null || true
60
+ export RAILS_WORKTREES_BOOTSTRAPPED=1
61
+ exec mise exec -- "$WT_SCRIPT_PATH" "$@"
62
+ fi
63
+
64
+ export BUNDLE_GEMFILE="${BUNDLE_GEMFILE:-$WT_APP_ROOT/Gemfile}"
65
+ exec ruby -rbundler/setup -e 'load Gem.bin_path("rails-worktrees", "wt")' -- "$@"
@@ -36,6 +36,8 @@ module Worktrees
36
36
  'follow-up edits when safe; also generate bin/ob'
37
37
  ].join(' ')
38
38
 
39
+ FILE_ENCODING = 'UTF-8'.freeze
40
+
39
41
  FOLLOW_UP_TEMPLATE = <<~TEXT.freeze
40
42
  ============================================
41
43
  rails-worktrees installed successfully! 🚂
@@ -130,7 +132,7 @@ module Worktrees
130
132
 
131
133
  def database_update_result
132
134
  result = ::Rails::Worktrees::DatabaseConfigUpdater.new(
133
- content: File.read(database_config_path)
135
+ content: File.read(database_config_path, encoding: FILE_ENCODING)
134
136
  ).call
135
137
 
136
138
  File.write(database_config_path, result.content) if result.changed?
@@ -181,7 +183,9 @@ module Worktrees
181
183
  return
182
184
  end
183
185
 
184
- result = ::Rails::Worktrees::ProcfileUpdater.new(content: File.read(procfile_path)).call
186
+ result = ::Rails::Worktrees::ProcfileUpdater.new(
187
+ content: File.read(procfile_path, encoding: FILE_ENCODING)
188
+ ).call
185
189
  File.write(procfile_path, result.content) if result.changed?
186
190
  announce_updater_result('Procfile.dev', result)
187
191
  end
@@ -193,7 +197,9 @@ module Worktrees
193
197
  return
194
198
  end
195
199
 
196
- result = ::Rails::Worktrees::PumaConfigUpdater.new(content: File.read(puma_config_path)).call
200
+ result = ::Rails::Worktrees::PumaConfigUpdater.new(
201
+ content: File.read(puma_config_path, encoding: FILE_ENCODING)
202
+ ).call
197
203
  File.write(puma_config_path, result.content) if result.changed?
198
204
  announce_updater_result('config/puma.rb', result)
199
205
  end
@@ -203,7 +209,7 @@ module Worktrees
203
209
  return announce_missing_mise_toml unless path
204
210
 
205
211
  result = ::Rails::Worktrees::MiseTomlUpdater.new(
206
- content: File.read(path),
212
+ content: File.read(path, encoding: FILE_ENCODING),
207
213
  file_name: File.basename(path)
208
214
  ).call
209
215
 
@@ -249,13 +255,19 @@ module Worktrees
249
255
 
250
256
  def git_repo?
251
257
  _stdout_str, _stderr_str, status = Open3.capture3(
252
- 'git', 'rev-parse', '--is-inside-work-tree', chdir: destination_root
258
+ git_process_env, 'git', 'rev-parse', '--is-inside-work-tree',
259
+ chdir: destination_root,
260
+ unsetenv_others: true
253
261
  )
254
262
  status.success?
255
263
  rescue Errno::ENOENT
256
264
  false
257
265
  end
258
266
 
267
+ def git_process_env
268
+ ENV.to_h.slice('HOME', 'PATH')
269
+ end
270
+
259
271
  def conductor_workspace_root
260
272
  "File.expand_path('~/Sites/conductor/workspaces')"
261
273
  end
@@ -8,6 +8,7 @@ module Rails
8
8
  class BrowserCommand
9
9
  APP_ROOT_ENV_KEY = 'RAILS_WORKTREES_APP_ROOT'.freeze
10
10
  ENV_FILE_NAME = '.env'.freeze
11
+ FILE_ENCODING = 'UTF-8'.freeze
11
12
 
12
13
  def initialize(argv:, io:, env:, cwd:, host_os: RbConfig::CONFIG['host_os'])
13
14
  @argv = argv.dup
@@ -127,7 +128,7 @@ module Rails
127
128
  def dev_port_from_env_file
128
129
  return unless File.file?(env_path)
129
130
 
130
- lines = File.readlines(env_path, chomp: true)
131
+ lines = File.readlines(env_path, chomp: true, encoding: FILE_ENCODING)
131
132
  env_value(lines, 'DEV_PORT')
132
133
  rescue StandardError => e
133
134
  raise Error, "Could not read #{env_path}: #{e.message}"
@@ -3,7 +3,8 @@ module Rails
3
3
  # Shell entrypoint for the wt executable.
4
4
  class CLI
5
5
  LOADER_OPTIONAL_COMMANDS = %w[doctor update -h --help -v --version].freeze
6
- LOADER_IGNORED_FLAGS = %w[--dry-run --force].freeze
6
+ LOADER_IGNORED_FLAGS = %w[--dry-run --force --skip-setup].freeze
7
+ SETUP_SUBCOMMAND = 'setup'.freeze
7
8
 
8
9
  def initialize(
9
10
  argv: ARGV,
@@ -29,11 +30,17 @@ module Rails
29
30
  private
30
31
 
31
32
  def load_project_configuration(configuration)
32
- ::Rails::Worktrees::ProjectConfigurationLoader.new(root: @cwd, configuration: configuration).call
33
+ ::Rails::Worktrees::ProjectConfigurationLoader.new(root: configuration_root, configuration: configuration).call
33
34
  rescue StandardError, ScriptError => e
34
35
  raise ::Rails::Worktrees::Error, "Failed to load worktrees configuration: #{e.class}: #{e.message}"
35
36
  end
36
37
 
38
+ def configuration_root
39
+ return expand_setup_target_path if explicit_setup_path_target?
40
+
41
+ @cwd
42
+ end
43
+
37
44
  def should_load_project_configuration?
38
45
  argv_without_flags.empty? || !loader_optional_command?(argv_without_flags.first)
39
46
  end
@@ -46,6 +53,19 @@ module Rails
46
53
  LOADER_OPTIONAL_COMMANDS.include?(command)
47
54
  end
48
55
 
56
+ def explicit_setup_path_target?
57
+ argv_without_flags.first == SETUP_SUBCOMMAND && argv_without_flags.length == 2 &&
58
+ path_like_setup_target?(argv_without_flags.last)
59
+ end
60
+
61
+ def expand_setup_target_path
62
+ File.expand_path(argv_without_flags.last, @cwd)
63
+ end
64
+
65
+ def path_like_setup_target?(value)
66
+ value.start_with?('/', '.', '~') || value.include?(File::SEPARATOR)
67
+ end
68
+
49
69
  def command_for(configuration)
50
70
  Command.new(argv: @argv, io: @io, env: @env, cwd: @cwd, configuration: configuration)
51
71
  end
@@ -20,6 +20,7 @@ module Rails
20
20
  EnvBootstrapper.new(
21
21
  target_dir: context[:target_dir],
22
22
  worktree_name: context[:worktree_name],
23
+ peer_roots: context.fetch(:peer_roots) { peer_roots_excluding(context[:target_dir]) },
23
24
  configuration: @configuration
24
25
  )
25
26
  end
@@ -7,6 +7,26 @@ module Rails
7
7
  # Shell-level git helpers, branch/worktree queries, and worktree creation.
8
8
  # rubocop:disable Metrics/ModuleLength
9
9
  module GitOperations
10
+ GIT_REPOSITORY_ENV_KEYS = %w[
11
+ GIT_ALTERNATE_OBJECT_DIRECTORIES
12
+ GIT_COMMON_DIR
13
+ GIT_CONFIG
14
+ GIT_CONFIG_COUNT
15
+ GIT_CONFIG_PARAMETERS
16
+ GIT_DIR
17
+ GIT_GRAFT_FILE
18
+ GIT_IMPLICIT_WORK_TREE
19
+ GIT_INDEX_FILE
20
+ GIT_NAMESPACE
21
+ GIT_NO_REPLACE_OBJECTS
22
+ GIT_OBJECT_DIRECTORY
23
+ GIT_PREFIX
24
+ GIT_QUARANTINE_PATH
25
+ GIT_REPLACE_REF_BASE
26
+ GIT_SHALLOW_FILE
27
+ GIT_WORK_TREE
28
+ ].freeze
29
+
10
30
  private
11
31
 
12
32
  def require_git_repo
@@ -162,24 +182,42 @@ module Rails
162
182
  end
163
183
 
164
184
  def git!(*)
165
- stdout_str, stderr_str, status = Open3.capture3(@env.to_h, 'git', *, chdir: @cwd)
185
+ stdout_str, stderr_str, status = Open3.capture3(
186
+ git_process_env, 'git', *, chdir: @cwd, unsetenv_others: true
187
+ )
188
+ stdout_str = command_output(stdout_str)
189
+ stderr_str = command_output(stderr_str)
166
190
  return stdout_str if status.success?
167
191
 
168
192
  raise Error, combined_output(stdout_str, stderr_str)
169
193
  end
170
194
 
171
195
  def git_capture(*, allow_failure: false)
172
- stdout_str, stderr_str, status = Open3.capture3(@env.to_h, 'git', *, chdir: @cwd)
196
+ stdout_str, stderr_str, status = Open3.capture3(
197
+ git_process_env, 'git', *, chdir: @cwd, unsetenv_others: true
198
+ )
199
+ stdout_str = command_output(stdout_str)
200
+ stderr_str = command_output(stderr_str)
173
201
  return stdout_str if status.success? || allow_failure
174
202
 
175
203
  raise Error, combined_output(stdout_str, stderr_str)
176
204
  end
177
205
 
178
206
  def git_success?(*)
179
- _stdout_str, _stderr_str, status = Open3.capture3(@env.to_h, 'git', *, chdir: @cwd)
207
+ _stdout_str, _stderr_str, status = Open3.capture3(
208
+ git_process_env, 'git', *, chdir: @cwd, unsetenv_others: true
209
+ )
180
210
  status.success?
181
211
  end
182
212
 
213
+ def git_process_env
214
+ @env.to_h.except(*GIT_REPOSITORY_ENV_KEYS)
215
+ end
216
+
217
+ def command_output(output)
218
+ output.to_s.dup.force_encoding(Encoding::UTF_8).scrub
219
+ end
220
+
183
221
  def combined_output(stdout_str, stderr_str)
184
222
  output = [stdout_str, stderr_str].map(&:strip).reject(&:empty?).join("\n")
185
223
  return output unless output.empty?
@@ -4,6 +4,8 @@ module Rails
4
4
  # Auto-picking names from bundled .txt files and tracking retired names.
5
5
  # rubocop:disable Metrics/ModuleLength
6
6
  module NamePicking
7
+ FILE_ENCODING = 'UTF-8'.freeze
8
+
7
9
  private
8
10
 
9
11
  def resolve_worktree_name(project_name, workspaces)
@@ -48,7 +50,7 @@ module Rails
48
50
  end
49
51
 
50
52
  def source_names(source_file)
51
- File.readlines(source_file, chomp: true).filter_map do |line|
53
+ File.readlines(source_file, chomp: true, encoding: FILE_ENCODING).filter_map do |line|
52
54
  name = line.delete_suffix("\r").strip
53
55
  next if name.empty? || name.start_with?('#')
54
56
 
@@ -74,7 +76,7 @@ module Rails
74
76
  state_file = retired_names_file
75
77
  return false unless state_file
76
78
 
77
- File.foreach(state_file).any? { |line| line.split("\t", 2).first == candidate }
79
+ File.foreach(state_file, encoding: FILE_ENCODING).any? { |line| line.split("\t", 2).first == candidate }
78
80
  end
79
81
 
80
82
  def record_retired_name(worktree_name, project_name)
@@ -30,9 +30,11 @@ module Rails
30
30
  Create and clean up Git worktrees for the current repository.
31
31
 
32
32
  Usage: wt [worktree-name]
33
+ wt [--skip-setup] [worktree-name]
33
34
  wt --dry-run [worktree-name]
34
35
  wt --print-env <worktree-name>
35
36
  wt doctor
37
+ wt setup [--dry-run] [path|name]
36
38
  wt update [--dry-run]
37
39
  wt remove [--dry-run] [--force] <worktree-name>
38
40
  wt delete [--dry-run] [--force] <worktree-name>
@@ -42,6 +44,7 @@ module Rails
42
44
  -h, --help Show this help message
43
45
  -v, --version Show the script version
44
46
  --dry-run [name] Preview worktree creation or cleanup without changing anything
47
+ --skip-setup Create the worktree without running setup steps
45
48
  --force Delete an unmerged local branch with wt remove/delete
46
49
  --env, --print-env <name> Preview DEV_PORT and WORKTREE_DATABASE_SUFFIX
47
50
 
@@ -49,8 +52,12 @@ module Rails
49
52
  wt Auto-pick a name from a bundled *.txt list
50
53
  wt my-feature Use an explicit worktree name
51
54
  wt --dry-run my-feature
55
+ wt --skip-setup my-feature
52
56
  wt --print-env my-feature
53
57
  wt doctor
58
+ wt setup
59
+ wt setup my-feature
60
+ wt setup ../my-project.worktrees/my-feature
54
61
  wt update --dry-run
55
62
  wt remove my-feature
56
63
  wt remove --force my-feature
@@ -61,6 +68,8 @@ module Rails
61
68
  - when workspace_root or WT_WORKSPACES_ROOT is set, creates worktrees in <root>/<project>/<name>
62
69
  - always uses the branch name #{@configuration.branch_prefix}/<name>
63
70
  - bases new branches on the repository's origin default branch
71
+ - by default wt <name> both creates the worktree and runs setup automatically
72
+ - wt setup reruns setup for the current checkout, a managed worktree name, or a specific checkout path, including manually-created worktrees
64
73
  - wt doctor audits install/config drift plus basic worktree health without changing files
65
74
  - wt update applies safe file-based fixes for managed installer artifacts and config hints
66
75
  - wt remove/delete can run from the main checkout or any sibling worktree, but never remove the worktree you're currently in
@@ -5,19 +5,20 @@ module Rails
5
5
  module PostCreateSupport
6
6
  private
7
7
 
8
- def run_post_create_steps(context)
9
- post_create_runner_for(context).call(dry_run: false)
8
+ def run_post_create_steps(context, bootstrapped_env: nil)
9
+ post_create_runner_for(context, bootstrapped_env: bootstrapped_env).call(dry_run: false)
10
10
  end
11
11
 
12
- def preview_post_create_steps(context)
13
- post_create_runner_for(context).call(dry_run: true)
12
+ def preview_post_create_steps(context, bootstrapped_env: nil)
13
+ post_create_runner_for(context, bootstrapped_env: bootstrapped_env).call(dry_run: true)
14
14
  end
15
15
 
16
- def post_create_runner_for(context)
16
+ def post_create_runner_for(context, bootstrapped_env: nil)
17
17
  PostCreateRunner.new(
18
18
  target_dir: context[:target_dir],
19
- peer_roots: peer_roots_excluding(context[:target_dir]),
19
+ peer_roots: context.fetch(:peer_roots) { peer_roots_excluding(context[:target_dir]) },
20
20
  configuration: @configuration,
21
+ bootstrapped_env: bootstrapped_env,
21
22
  io: { stdout: @stdout, stderr: @stderr }
22
23
  )
23
24
  end
@@ -7,7 +7,19 @@ module Rails
7
7
 
8
8
  def resolve_repository_context
9
9
  current_root = canonical_path(git_capture('rev-parse', '--show-toplevel').strip)
10
- common_dir = expand_git_path(git_capture('rev-parse', '--git-common-dir').strip)
10
+ common_dir = expand_git_path(git_capture('rev-parse', '--git-common-dir').strip, base_dir: @cwd)
11
+ primary_root = primary_checkout_root_for(current_root, common_dir)
12
+
13
+ repository_context_for(current_root, primary_root)
14
+ end
15
+
16
+ def resolve_repository_context_for(path)
17
+ expanded_path = File.expand_path(path, @cwd)
18
+ current_root = canonical_path(git_capture('-C', expanded_path, 'rev-parse', '--show-toplevel').strip)
19
+ common_dir = expand_git_path(
20
+ git_capture('-C', expanded_path, 'rev-parse', '--git-common-dir').strip,
21
+ base_dir: expanded_path
22
+ )
11
23
  primary_root = primary_checkout_root_for(current_root, common_dir)
12
24
 
13
25
  repository_context_for(current_root, primary_root)
@@ -66,10 +78,10 @@ module Rails
66
78
  canonical_path(File.dirname(common_dir))
67
79
  end
68
80
 
69
- def expand_git_path(path)
81
+ def expand_git_path(path, base_dir: @cwd)
70
82
  return path if path.start_with?('/')
71
83
 
72
- File.expand_path(path, @cwd)
84
+ File.expand_path(path, base_dir)
73
85
  end
74
86
 
75
87
  def present_path?(path)
@@ -13,6 +13,7 @@ module Rails
13
13
  class Command
14
14
  REMOVE_SUBCOMMANDS = %w[remove delete].freeze
15
15
  DOCTOR_SUBCOMMAND = 'doctor'.freeze
16
+ SETUP_SUBCOMMAND = 'setup'.freeze
16
17
  UPDATE_SUBCOMMAND = 'update'.freeze
17
18
 
18
19
  include GitOperations
@@ -49,9 +50,12 @@ module Rails
49
50
 
50
51
  def force? = @force
51
52
 
53
+ def skip_setup? = @skip_setup
54
+
52
55
  def extract_flags!
53
56
  @dry_run = extract_flag!('--dry-run')
54
57
  @force = extract_flag!('--force')
58
+ @skip_setup = extract_flag!('--skip-setup')
55
59
  end
56
60
 
57
61
  def extract_flag!(flag)
@@ -77,20 +81,37 @@ module Rails
77
81
  @argv.first == DOCTOR_SUBCOMMAND
78
82
  end
79
83
 
84
+ def setup_subcommand?
85
+ @argv.first == SETUP_SUBCOMMAND
86
+ end
87
+
80
88
  def update_subcommand?
81
89
  @argv.first == UPDATE_SUBCOMMAND
82
90
  end
83
91
 
84
92
  def execute_requested_command
85
- return execute_doctor_command if doctor_subcommand?
86
- return execute_update_command if update_subcommand?
87
- return execute_remove_command if remove_subcommand?
88
- return execute_prune_command if prune_subcommand?
89
- return usage_error if @argv.length > 1 || force?
93
+ handler = requested_command_handler
94
+ return handler.call if handler
95
+ return usage_error if invalid_worktree_command?
90
96
 
91
97
  execute_worktree_command
92
98
  end
93
99
 
100
+ def requested_command_handler
101
+ case @argv.first
102
+ when DOCTOR_SUBCOMMAND then -> { execute_doctor_command }
103
+ when SETUP_SUBCOMMAND then -> { execute_setup_command }
104
+ when UPDATE_SUBCOMMAND then -> { execute_update_command }
105
+ when 'prune' then -> { execute_prune_command }
106
+ else
107
+ -> { execute_remove_command } if remove_subcommand?
108
+ end
109
+ end
110
+
111
+ def invalid_worktree_command?
112
+ @argv.length > 1 || force?
113
+ end
114
+
94
115
  def execute_worktree_command
95
116
  require_git_repo
96
117
  announce_dry_run if dry_run?
@@ -107,6 +128,16 @@ module Rails
107
128
  finish(context)
108
129
  end
109
130
 
131
+ def execute_setup_command
132
+ validate_setup_args!
133
+ announce_dry_run if dry_run?
134
+
135
+ context = resolve_setup_context(target_reference: setup_target_reference)
136
+ validate_setup_target!(context)
137
+
138
+ complete_setup_flow(context, success_message: 'Setup complete')
139
+ end
140
+
110
141
  def execute_remove_command
111
142
  require_git_repo
112
143
  announce_dry_run if dry_run?
@@ -210,17 +241,26 @@ module Rails
210
241
  def validate_prune_args!
211
242
  raise Error, 'Usage: wt prune' unless @argv.length == 1
212
243
  raise Error, 'The --force flag is only supported with wt remove.' if force?
244
+ raise Error, 'The --skip-setup flag is only supported when creating a worktree.' if skip_setup?
213
245
  end
214
246
 
215
247
  def validate_doctor_args!
216
248
  raise Error, 'Usage: wt doctor' unless @argv.length == 1
217
249
  raise Error, 'wt doctor does not support --dry-run.' if dry_run?
218
250
  raise Error, 'The --force flag is only supported with wt remove.' if force?
251
+ raise Error, 'The --skip-setup flag is only supported when creating a worktree.' if skip_setup?
252
+ end
253
+
254
+ def validate_setup_args!
255
+ raise Error, 'Usage: wt setup [--dry-run] [path|name]' unless [1, 2].include?(@argv.length)
256
+ raise Error, 'The --force flag is only supported with wt remove.' if force?
257
+ raise Error, 'The --skip-setup flag is only supported when creating a worktree.' if skip_setup?
219
258
  end
220
259
 
221
260
  def validate_update_args!
222
261
  raise Error, 'Usage: wt update [--dry-run]' unless @argv.length == 1
223
262
  raise Error, 'The --force flag is only supported with wt remove.' if force?
263
+ raise Error, 'The --skip-setup flag is only supported when creating a worktree.' if skip_setup?
224
264
  end
225
265
 
226
266
  def announce_prune_candidates(candidates)
@@ -258,6 +298,60 @@ module Rails
258
298
  )
259
299
  end
260
300
 
301
+ def resolve_setup_context(target_reference: nil, repository: nil)
302
+ if setup_target_name?(target_reference)
303
+ return resolve_named_setup_context(target_reference, repository: repository)
304
+ end
305
+
306
+ resolve_path_setup_context(target_reference, repository: repository)
307
+ end
308
+
309
+ def resolve_path_setup_context(target_reference, repository: nil)
310
+ target_dir = resolved_setup_target_path(target_reference)
311
+ repository ||= target_reference ? resolve_repository_context_for(target_dir) : resolve_repository_context
312
+ branch_name = current_checkout_branch_at(target_dir)
313
+
314
+ repository.merge(
315
+ worktree_name: setup_identity_for(repository[:current_root], branch_name),
316
+ branch_name: display_branch_name(branch_name, target_dir: repository[:current_root]),
317
+ target_dir: repository[:current_root],
318
+ peer_roots: peer_roots_for(repository[:current_root])
319
+ )
320
+ end
321
+
322
+ def resolve_named_setup_context(worktree_name, repository: nil)
323
+ repository ||= resolve_repository_context
324
+ context = resolve_worktree_context(explicit_worktree_name: worktree_name, repository: repository)
325
+ branch_name = resolved_named_setup_branch_name(context)
326
+
327
+ context.merge(
328
+ branch_name: display_branch_name(branch_name, target_dir: context[:target_dir]),
329
+ peer_roots: peer_roots_for(context[:target_dir])
330
+ )
331
+ end
332
+
333
+ def resolved_named_setup_branch_name(context)
334
+ return context[:branch_name] unless File.directory?(context[:target_dir])
335
+
336
+ current_checkout_branch_at(context[:target_dir])
337
+ end
338
+
339
+ def setup_target_reference = @argv[1]
340
+
341
+ def setup_target_name?(target_reference)
342
+ !target_reference.nil? && !path_like_setup_target?(target_reference)
343
+ end
344
+
345
+ def resolved_setup_target_path(target_path)
346
+ return @cwd if target_path.nil?
347
+
348
+ File.expand_path(target_path, @cwd)
349
+ end
350
+
351
+ def path_like_setup_target?(target_reference)
352
+ target_reference.start_with?('/', '.', '~') || target_reference.include?(File::SEPARATOR)
353
+ end
354
+
261
355
  def resolved_worktree_name(project_name, workspaces, explicit_worktree_name)
262
356
  return validate_worktree_name(explicit_worktree_name) if explicit_worktree_name
263
357
 
@@ -285,27 +379,52 @@ module Rails
285
379
 
286
380
  def finish(context)
287
381
  settle_retired_name(context[:worktree_name], context[:project_name], dry_run: dry_run?)
288
- bootstrap_result = bootstrap_worktree_environment(context)
289
-
290
- return complete_dry_run_after_setup(context, bootstrap_result) if dry_run?
291
-
292
- complete_created_worktree(context, bootstrap_result)
382
+ complete_setup_flow(
383
+ context,
384
+ success_message: skip_setup? ? 'Worktree created' : 'Worktree ready',
385
+ run_post_create: !skip_setup?
386
+ )
293
387
  end
294
388
 
295
- def complete_dry_run_after_setup(context, bootstrap_result)
296
- preview_post_create_steps(context)
389
+ def complete_dry_run_after_setup(context, bootstrap_result, run_post_create: true)
390
+ preview_post_create_steps(context, bootstrapped_env: bootstrap_result&.values) if run_post_create
391
+ info('Would skip setup steps; run `wt setup` inside the checkout when you are ready.') unless run_post_create
297
392
  complete_dry_run(context, env_values: bootstrap_result&.values)
298
393
  end
299
394
 
300
- def complete_created_worktree(context, bootstrap_result)
301
- result = run_post_create_steps(context)
395
+ def complete_created_worktree(context, bootstrap_result, success_message:, run_post_create: true)
396
+ unless run_post_create
397
+ return complete_created_worktree_without_setup(context, bootstrap_result, success_message)
398
+ end
399
+
400
+ result = run_post_create_steps(context, bootstrapped_env: bootstrap_result&.values)
302
401
  return result unless result.zero?
303
402
 
304
- success('Worktree ready')
403
+ success(success_message)
305
404
  print_context_summary(context, env_values: bootstrap_result&.values)
306
405
  0
307
406
  end
308
407
 
408
+ def complete_created_worktree_without_setup(context, bootstrap_result, success_message)
409
+ success(success_message)
410
+ print_context_summary(context, env_values: bootstrap_result&.values)
411
+ info('Setup skipped. Run `wt setup` inside the checkout when you are ready.')
412
+ 0
413
+ end
414
+
415
+ def complete_setup_flow(context, success_message:, run_post_create: true)
416
+ bootstrap_result = bootstrap_worktree_environment(context)
417
+
418
+ return complete_dry_run_after_setup(context, bootstrap_result, run_post_create: run_post_create) if dry_run?
419
+
420
+ complete_created_worktree(
421
+ context,
422
+ bootstrap_result,
423
+ success_message: success_message,
424
+ run_post_create: run_post_create
425
+ )
426
+ end
427
+
309
428
  def finish_reuse(context)
310
429
  target, branch = context.values_at(:target_dir, :branch_name)
311
430
 
@@ -338,6 +457,78 @@ module Rails
338
457
  "#{@configuration.branch_prefix}/#{worktree_name}"
339
458
  end
340
459
 
460
+ def validate_setup_target!(context)
461
+ raise Error, "Setup target does not exist: #{context[:target_dir]}" unless File.directory?(context[:target_dir])
462
+
463
+ rails_bin = File.join(context[:target_dir], 'bin', 'rails')
464
+
465
+ unless File.exist?(rails_bin)
466
+ raise Error,
467
+ "Setup target does not look like a Rails app. Expected #{rails_bin} to exist before running wt setup."
468
+ end
469
+
470
+ return if registered_worktree_path_for_setup?(context[:target_dir])
471
+
472
+ warning(
473
+ 'Current checkout is not registered in git worktree list; peer credential key discovery may be limited.'
474
+ )
475
+ end
476
+
477
+ def current_checkout_branch_at(target_dir)
478
+ git_capture('-C', target_dir, 'branch', '--show-current', allow_failure: true).strip
479
+ end
480
+
481
+ def registered_worktree_path_for_setup?(target_dir)
482
+ normalized_target = canonical_path(target_dir)
483
+
484
+ worktree_entries_for_checkout(target_dir).any? { |entry| entry[:path] == normalized_target }
485
+ end
486
+
487
+ def peer_roots_for(target_dir)
488
+ normalized_target = canonical_path(target_dir)
489
+
490
+ worktree_entries_for_checkout(target_dir)
491
+ .map { |entry| entry[:path] }
492
+ .reject { |path| path == normalized_target }
493
+ end
494
+
495
+ def worktree_entries_for_checkout(target_dir)
496
+ git_capture('-C', target_dir, 'worktree', 'list', '--porcelain', allow_failure: true)
497
+ .split("\n\n")
498
+ .filter_map do |block|
499
+ entry = parse_worktree_entry(block)
500
+ entry[:path] ? entry : nil
501
+ end
502
+ end
503
+
504
+ def setup_identity_for(target_dir, branch_name)
505
+ stripped_branch = worktree_identity_from_branch(branch_name)
506
+ return stripped_branch unless stripped_branch.empty?
507
+
508
+ File.basename(target_dir)
509
+ end
510
+
511
+ def worktree_identity_from_branch(branch_name)
512
+ branch_name = branch_name.to_s.strip
513
+ return '' if branch_name.empty?
514
+
515
+ worktree_name_for_branch(branch_name)
516
+ end
517
+
518
+ def display_branch_name(branch_name, target_dir: nil)
519
+ branch_name = branch_name.to_s.strip
520
+ return branch_name unless branch_name.empty?
521
+
522
+ git_args = if target_dir.to_s.strip.empty?
523
+ ['rev-parse', '--short', 'HEAD']
524
+ else
525
+ ['-C', target_dir, 'rev-parse', '--short', 'HEAD']
526
+ end
527
+
528
+ short_sha = git_capture(*git_args, allow_failure: true).strip
529
+ short_sha.empty? ? '(detached HEAD)' : "(detached HEAD at #{short_sha})"
530
+ end
531
+
341
532
  def ensure_removable!(context, worktree_exists:, branch_exists:)
342
533
  ensure_remove_target_exists!(context, worktree_exists: worktree_exists, branch_exists: branch_exists)
343
534
  ensure_not_removing_protected_checkout!(context)
@@ -13,11 +13,13 @@ module Rails
13
13
  end
14
14
 
15
15
  ENV_FILE_NAME = '.env'.freeze
16
+ FILE_ENCODING = 'UTF-8'.freeze
16
17
 
17
- def initialize(target_dir:, worktree_name:, configuration:)
18
+ def initialize(target_dir:, worktree_name:, configuration:, peer_roots: nil)
18
19
  @target_dir = target_dir
19
20
  @worktree_name = worktree_name
20
21
  @configuration = configuration
22
+ @peer_roots = peer_roots.nil? ? nil : Array(peer_roots).compact
21
23
  end
22
24
 
23
25
  def call(dry_run: false)
@@ -43,13 +45,17 @@ module Rails
43
45
  result(false, [message], values)
44
46
  end
45
47
 
46
- def existing_env_lines = File.exist?(env_path) ? File.readlines(env_path, chomp: true) : []
48
+ def existing_env_lines
49
+ File.exist?(env_path) ? File.readlines(env_path, chomp: true, encoding: FILE_ENCODING) : []
50
+ end
47
51
 
48
52
  def resolved_values(lines)
53
+ dev_port = (env_value(lines, 'DEV_PORT') || allocate_dev_port).to_s
54
+
49
55
  {
50
- 'DEV_PORT' => (env_value(lines, 'DEV_PORT') || allocate_dev_port).to_s,
56
+ 'DEV_PORT' => dev_port,
51
57
  'WORKTREE_DATABASE_SUFFIX' => env_value(lines, 'WORKTREE_DATABASE_SUFFIX') ||
52
- format_worktree_database_suffix(@worktree_name)
58
+ allocate_worktree_database_suffix(dev_port: dev_port)
53
59
  }
54
60
  end
55
61
 
@@ -108,8 +114,7 @@ module Rails
108
114
  end
109
115
 
110
116
  def claimed_peer_ports
111
- Dir.glob(File.join(peers_root, '*')).filter_map do |path|
112
- next unless File.directory?(path)
117
+ peer_paths.filter_map do |path|
113
118
  next if File.expand_path(path) == File.expand_path(@target_dir)
114
119
 
115
120
  port = env_value(peer_env_lines(path), 'DEV_PORT')
@@ -121,13 +126,26 @@ module Rails
121
126
 
122
127
  def peer_env_lines(path)
123
128
  env_file = File.join(path, ENV_FILE_NAME)
124
- File.exist?(env_file) ? File.readlines(env_file, chomp: true) : []
129
+ File.exist?(env_file) ? File.readlines(env_file, chomp: true, encoding: FILE_ENCODING) : []
125
130
  end
126
131
 
127
132
  def peers_root = File.dirname(@target_dir)
128
133
 
134
+ def peer_paths
135
+ return @peer_roots unless @peer_roots.nil?
136
+
137
+ Dir.glob(File.join(peers_root, '*')).select { |path| File.directory?(path) }
138
+ end
139
+
129
140
  def configured_port_range = @configuration.dev_port_range
130
141
 
142
+ def allocate_worktree_database_suffix(dev_port:)
143
+ base_candidate = format_worktree_database_suffix(@worktree_name)
144
+ return base_candidate unless claimed_peer_suffixes.include?(base_candidate)
145
+
146
+ suffix_with_token(@worktree_name, dev_port.to_s)
147
+ end
148
+
131
149
  def format_worktree_database_suffix(value)
132
150
  suffix = value.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '').squeeze('_')
133
151
  suffix = suffix[0, @configuration.worktree_database_suffix_max_length]
@@ -136,6 +154,35 @@ module Rails
136
154
  "_#{suffix}"
137
155
  end
138
156
 
157
+ def suffix_with_token(value, token)
158
+ slug = normalized_suffix_slug(value)
159
+ normalized_token = token.to_s.downcase.gsub(/[^a-z0-9]+/, '')
160
+ max_length = @configuration.worktree_database_suffix_max_length
161
+ available_slug_length = max_length - normalized_token.length - 1
162
+
163
+ composed_slug = if available_slug_length.positive?
164
+ [slug[0, available_slug_length], normalized_token].reject(&:empty?).join('_')
165
+ else
166
+ normalized_token[0, max_length]
167
+ end
168
+
169
+ format_worktree_database_suffix(composed_slug)
170
+ end
171
+
172
+ def normalized_suffix_slug(value)
173
+ slug = value.to_s.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '').squeeze('_')
174
+ slug.empty? ? 'worktree' : slug
175
+ end
176
+
177
+ def claimed_peer_suffixes
178
+ peer_paths.each_with_object(Set.new) do |path, suffixes|
179
+ next if File.expand_path(path) == File.expand_path(@target_dir)
180
+
181
+ suffix = env_value(peer_env_lines(path), 'WORKTREE_DATABASE_SUFFIX')
182
+ suffixes << suffix if suffix
183
+ end
184
+ end
185
+
139
186
  def formatted_updates(updates) = updates.map { |key, value| "#{key}=#{value}" }.join(', ')
140
187
 
141
188
  def display_path(path)
@@ -12,6 +12,7 @@ module Rails
12
12
  end
13
13
 
14
14
  TEMPLATE_PATH = File.expand_path('../../generators/rails/worktrees/templates/rails_worktrees.rb.tt', __dir__)
15
+ FILE_ENCODING = 'UTF-8'.freeze
15
16
  CURRENT_WRAPPER_CALL = 'Rails.application.config.x.rails_worktrees.tap do |config|'.freeze
16
17
  CONFIGURE_CALL = 'Rails::Worktrees.configure do |config|'.freeze
17
18
  KNOWN_GUARD_FRAGMENTS = [
@@ -135,7 +136,9 @@ module Rails
135
136
  end
136
137
 
137
138
  def render_default_template
138
- ERB.new(File.read(TEMPLATE_PATH), trim_mode: '-').result(template_context.instance_eval { binding })
139
+ ERB.new(File.read(TEMPLATE_PATH, encoding: FILE_ENCODING), trim_mode: '-').result(
140
+ template_context.instance_eval { binding }
141
+ )
139
142
  end
140
143
 
141
144
  def template_context
@@ -0,0 +1,87 @@
1
+ require 'json'
2
+ require 'open3'
3
+
4
+ module Rails
5
+ module Worktrees
6
+ # Resolves runtime environment variables from mise for setup subprocesses.
7
+ class MiseEnvironment
8
+ Result = Struct.new(:env, :messages)
9
+
10
+ CONFIG_FILES = %w[mise.toml .mise.toml].freeze
11
+
12
+ def initialize(target_dir:, env:)
13
+ @target_dir = target_dir
14
+ @env = env
15
+ end
16
+
17
+ def call
18
+ return Result.new(env: {}, messages: []) unless mise_available?
19
+
20
+ messages = []
21
+ trust_config!(messages)
22
+ env = resolved_env
23
+ messages << '🧰 Activating mise toolchain...' if project_config_file || !env.empty?
24
+
25
+ Result.new(env: env, messages: messages.uniq)
26
+ end
27
+
28
+ private
29
+
30
+ def mise_available?
31
+ _stdout_str, _stderr_str, status = Open3.capture3(@env, 'mise', '--version', chdir: @target_dir)
32
+ status.success?
33
+ rescue Errno::ENOENT
34
+ false
35
+ end
36
+
37
+ def trust_config!(messages)
38
+ config_file = project_config_file
39
+ return unless config_file
40
+
41
+ messages << '🔐 Trusting mise config...'
42
+
43
+ ensure_trusted!(config_file)
44
+ end
45
+
46
+ def ensure_trusted!(config_file)
47
+ _stdout_str, stderr_str, status = Open3.capture3(
48
+ @env,
49
+ 'mise',
50
+ 'trust',
51
+ config_file,
52
+ chdir: @target_dir
53
+ )
54
+
55
+ return if status.success?
56
+
57
+ raise Error, "mise trust failed: #{command_error(stderr_str)}"
58
+ end
59
+
60
+ def resolved_env
61
+ @resolved_env ||= begin
62
+ stdout_str, stderr_str, status = Open3.capture3(@env, 'mise', 'env', '--json', chdir: @target_dir)
63
+ raise Error, "mise env --json failed: #{command_error(stderr_str)}" unless status.success?
64
+
65
+ JSON.parse(stdout_str).each_with_object({}) do |(key, value), env|
66
+ next if value.nil?
67
+
68
+ env[key] = value.to_s
69
+ end
70
+ rescue JSON::ParserError => e
71
+ raise Error, "mise env --json returned invalid JSON: #{e.message}"
72
+ end
73
+ end
74
+
75
+ def project_config_file
76
+ @project_config_file ||= CONFIG_FILES
77
+ .map { |file_name| File.join(@target_dir, file_name) }
78
+ .find { |path| File.file?(path) }
79
+ end
80
+
81
+ def command_error(stderr_str)
82
+ message = stderr_str.to_s.strip
83
+ message.empty? ? 'unknown error' : message
84
+ end
85
+ end
86
+ end
87
+ end
@@ -4,7 +4,7 @@ module Rails
4
4
  module Worktrees
5
5
  # Runs post-create setup steps in a newly created worktree.
6
6
  # Steps run in order: credential linking, bundle install, yarn install,
7
- # db:prepare (development), db:prepare (test), assets:precompile (test).
7
+ # db:prepare (development), db:prepare (test), assets:precompile (test), assets:clobber.
8
8
  # rubocop:disable Metrics/ClassLength
9
9
  class PostCreateRunner
10
10
  STEPS = [
@@ -17,7 +17,9 @@ module Rails
17
17
  { id: :test_db_prepare, argv: %w[bin/rails db:prepare],
18
18
  header: '🗄️ Preparing test database...', env: { 'RAILS_ENV' => 'test' } },
19
19
  { id: :test_assets_precompile, argv: %w[bin/rails assets:precompile],
20
- header: '🎨 Precompiling test assets...', env: { 'RAILS_ENV' => 'test' } }
20
+ header: '🎨 Precompiling test assets...', env: { 'RAILS_ENV' => 'test' } },
21
+ { id: :assets_clobber, argv: %w[bin/rails assets:clobber],
22
+ header: '🧹 Clobbering compiled assets...' }
21
23
  ].freeze
22
24
 
23
25
  STEP_CONFIG = {
@@ -25,13 +27,15 @@ module Rails
25
27
  yarn: :run_yarn_install,
26
28
  db_prepare: :run_db_prepare,
27
29
  test_db_prepare: :run_test_db_prepare,
28
- test_assets_precompile: :run_test_assets_precompile
30
+ test_assets_precompile: :run_test_assets_precompile,
31
+ assets_clobber: :run_test_assets_precompile
29
32
  }.freeze
30
33
 
31
- def initialize(target_dir:, peer_roots:, configuration:, io:)
34
+ def initialize(target_dir:, peer_roots:, configuration:, io:, bootstrapped_env: nil)
32
35
  @target_dir = target_dir
33
36
  @peer_roots = peer_roots
34
37
  @configuration = configuration
38
+ @bootstrapped_env = (bootstrapped_env || {}).transform_values(&:to_s)
35
39
  @stdout = io.fetch(:stdout)
36
40
  @stderr = io.fetch(:stderr)
37
41
  end
@@ -39,9 +43,14 @@ module Rails
39
43
  def call(dry_run: false)
40
44
  return 0 if @configuration.post_create_command == false
41
45
 
46
+ @runtime_env = resolved_runtime_env(dry_run: dry_run)
47
+
42
48
  return run_custom_command(dry_run: dry_run) if custom_command?
43
49
 
44
50
  run_built_in_steps(dry_run: dry_run)
51
+ rescue Error => e
52
+ @stderr.puts("❌ #{e.message}")
53
+ 1
45
54
  end
46
55
 
47
56
  private
@@ -118,7 +127,7 @@ module Rails
118
127
  def capture_shell_command_exit_status(command)
119
128
  exit_status = nil
120
129
 
121
- Open3.popen2e(base_env, command, chdir: @target_dir) do |_stdin, output, wait_thread|
130
+ Open3.popen2e(runtime_env, command, chdir: @target_dir) do |_stdin, output, wait_thread|
122
131
  output.each_line { |line| @stdout.print(line) }
123
132
  exit_status = wait_thread.value.exitstatus
124
133
  end
@@ -143,7 +152,7 @@ module Rails
143
152
  1
144
153
  end
145
154
 
146
- def base_env
155
+ def shell_env
147
156
  # Pass through only the essentials so subprocess tools work correctly.
148
157
  ENV.to_h.slice('PATH', 'HOME', 'LANG', 'TERM', 'SHELL',
149
158
  'BUNDLE_GEMFILE', 'BUNDLE_PATH', 'GEM_HOME', 'GEM_PATH',
@@ -152,6 +161,21 @@ module Rails
152
161
  'DEV_PORT', 'WORKTREE_DATABASE_SUFFIX')
153
162
  end
154
163
 
164
+ attr_reader :runtime_env
165
+
166
+ def resolved_runtime_env(dry_run:)
167
+ env = shell_env.merge(@bootstrapped_env)
168
+ return env if dry_run
169
+
170
+ env.merge(toolchain_env)
171
+ end
172
+
173
+ def toolchain_env
174
+ result = MiseEnvironment.new(target_dir: @target_dir, env: shell_env.merge(@bootstrapped_env)).call
175
+ result.messages.each { |message| info(message) }
176
+ result.env
177
+ end
178
+
155
179
  def yarn_lock_present?
156
180
  File.exist?(File.join(@target_dir, 'yarn.lock'))
157
181
  end
@@ -163,7 +187,7 @@ module Rails
163
187
  end
164
188
 
165
189
  def step_command(step)
166
- [base_env.merge(step.fetch(:env, {})), *step.fetch(:argv)]
190
+ [runtime_env.merge(step.fetch(:env, {})), *step.fetch(:argv)]
167
191
  end
168
192
 
169
193
  def info(message)
@@ -22,7 +22,7 @@ module Rails
22
22
  def call
23
23
  return configuration unless initializer_path && File.file?(initializer_path)
24
24
 
25
- body = extract_configuration_body(File.read(initializer_path))
25
+ body = extract_configuration_body(File.read(initializer_path, encoding: 'UTF-8'))
26
26
  return configuration unless body
27
27
 
28
28
  recorder = AssignmentRecorder.new(configuration)
@@ -49,6 +49,7 @@ module Rails
49
49
  # rubocop:enable Style/RedundantStructKeywordInit
50
50
 
51
51
  TEMPLATE_ROOT = File.expand_path('../../generators/rails/worktrees/templates', __dir__)
52
+ FILE_ENCODING = 'UTF-8'.freeze
52
53
 
53
54
  def initialize(root:)
54
55
  @root = root
@@ -100,10 +101,10 @@ module Rails
100
101
  path = absolute_path(config.fetch(:relative_path))
101
102
  return nil if config.fetch(:optional) && !File.exist?(path)
102
103
 
103
- desired_content = File.read(config.fetch(:template_path))
104
+ desired_content = read_text(config.fetch(:template_path))
104
105
  return template_missing_check(config, desired_content) unless File.exist?(path)
105
106
 
106
- current_content = File.read(path)
107
+ current_content = read_text(path)
107
108
  if current_content == desired_content
108
109
  if executable_mode_current?(config, path)
109
110
  return ok_check(config, "#{config.fetch(:relative_path)} is up to date.")
@@ -168,7 +169,7 @@ module Rails
168
169
  )
169
170
  end
170
171
 
171
- updater_result_check(config, InitializerUpdater.new(content: File.read(path)).call)
172
+ updater_result_check(config, InitializerUpdater.new(content: read_text(path)).call)
172
173
  end
173
174
 
174
175
  def database_check
@@ -187,7 +188,7 @@ module Rails
187
188
  )
188
189
  end
189
190
 
190
- result = DatabaseConfigUpdater.new(content: File.read(path)).call
191
+ result = DatabaseConfigUpdater.new(content: read_text(path)).call
191
192
  return updated_database_check(config, result) if result.changed?
192
193
 
193
194
  if database_configured?(result)
@@ -244,7 +245,7 @@ module Rails
244
245
  path = absolute_path(config.fetch(:relative_path))
245
246
  return unless File.exist?(path)
246
247
 
247
- updater_result_check(config, yield(File.read(path)))
248
+ updater_result_check(config, yield(read_text(path)))
248
249
  end
249
250
 
250
251
  def mise_check
@@ -265,7 +266,7 @@ module Rails
265
266
 
266
267
  def mise_updater_result(relative_path)
267
268
  MiseTomlUpdater.new(
268
- content: File.read(absolute_path(relative_path)),
269
+ content: read_text(absolute_path(relative_path)),
269
270
  file_name: File.basename(relative_path)
270
271
  ).call
271
272
  end
@@ -319,6 +320,10 @@ module Rails
319
320
  )
320
321
  end
321
322
 
323
+ def read_text(path)
324
+ File.read(path, encoding: FILE_ENCODING)
325
+ end
326
+
322
327
  def absolute_path(relative_path)
323
328
  File.join(root, relative_path)
324
329
  end
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Worktrees
3
- VERSION = '0.6.0'.freeze
3
+ VERSION = '0.7.1'.freeze
4
4
  end
5
5
  end
@@ -3,6 +3,7 @@ require 'pathname'
3
3
  require_relative 'worktrees/version'
4
4
  require_relative 'worktrees/configuration'
5
5
  require_relative 'worktrees/application_configuration'
6
+ require_relative 'worktrees/mise_environment'
6
7
  require_relative 'worktrees/env_bootstrapper'
7
8
  require_relative 'worktrees/credential_key_linker'
8
9
  require_relative 'worktrees/post_create_runner'
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.6.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Asjer Querido
@@ -70,6 +70,7 @@ files:
70
70
  - lib/rails/worktrees/database_config_updater.rb
71
71
  - lib/rails/worktrees/env_bootstrapper.rb
72
72
  - lib/rails/worktrees/initializer_updater.rb
73
+ - lib/rails/worktrees/mise_environment.rb
73
74
  - lib/rails/worktrees/mise_toml_updater.rb
74
75
  - lib/rails/worktrees/names/cities.txt
75
76
  - lib/rails/worktrees/post_create_runner.rb