rails-worktrees 0.6.0 → 0.7.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: de8cd30214f81e827aee0e2fabba426a1c0cfca6f172d03fb36fad408fc8bfe7
4
- data.tar.gz: 604eb3468c743e91b3b5ad19f9943fa94d9fe891b45732de491ddc8219c4b943
3
+ metadata.gz: e86a789b2e56f07d95a626b67c9fafbf01862e9c8496043107914b4446e13702
4
+ data.tar.gz: 9f0dce854a6c7a20dfedb00dd181f7ecc5699ae6cf86ff61d24d60bce80be11e
5
5
  SHA512:
6
- metadata.gz: 8f33f141355afb0dbc9ee06cdaab2e2f242c7d49b7ac72b6c2aa21202aabc8fb709cf79aa88a0b43f2357240b19b88dab37cc284f3fd4f712cc2b6c1057b743b
7
- data.tar.gz: c9bc8caa8f38b496e2d7dd4bc0693251ec18606863b349cf949398e2cc131c448277869e04ac873e80f8b3dfff3f1fc59604e7ba2207fff21a97c255376524f3
6
+ metadata.gz: 16513989c4364e4cf37d4eb0cf1fbde3fc95d8aa246223b5e9cca20da0035266d8f33e81a115d83b95b359ff93292796ba88889f06495c9d8b39fde11eb8d5b4
7
+ data.tar.gz: 1bcdfe219ef78dadeeaf7a8de8cc71662de4e0066b6bebf9941aa59d86c9349a38e3caf0671693dc321b54b672618e7b9b772994efb48511628f77718345aceb
@@ -1 +1 @@
1
- {".":"0.6.0"}
1
+ {".":"0.7.0"}
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/asjer/rails-worktrees/compare/v0.6.0...v0.7.0) (2026-04-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * **wt:** add rerunnable setup workflow ([a609fcc](https://github.com/asjer/rails-worktrees/commit/a609fcc3c463da1d2ad9922b620f9c517dc6ed74))
9
+
3
10
  ## [0.6.0](https://github.com/asjer/rails-worktrees/compare/v0.5.1...v0.6.0) (2026-04-03)
4
11
 
5
12
 
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,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
@@ -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)
@@ -14,10 +14,11 @@ module Rails
14
14
 
15
15
  ENV_FILE_NAME = '.env'.freeze
16
16
 
17
- def initialize(target_dir:, worktree_name:, configuration:)
17
+ def initialize(target_dir:, worktree_name:, configuration:, peer_roots: nil)
18
18
  @target_dir = target_dir
19
19
  @worktree_name = worktree_name
20
20
  @configuration = configuration
21
+ @peer_roots = peer_roots.nil? ? nil : Array(peer_roots).compact
21
22
  end
22
23
 
23
24
  def call(dry_run: false)
@@ -46,10 +47,12 @@ module Rails
46
47
  def existing_env_lines = File.exist?(env_path) ? File.readlines(env_path, chomp: true) : []
47
48
 
48
49
  def resolved_values(lines)
50
+ dev_port = (env_value(lines, 'DEV_PORT') || allocate_dev_port).to_s
51
+
49
52
  {
50
- 'DEV_PORT' => (env_value(lines, 'DEV_PORT') || allocate_dev_port).to_s,
53
+ 'DEV_PORT' => dev_port,
51
54
  'WORKTREE_DATABASE_SUFFIX' => env_value(lines, 'WORKTREE_DATABASE_SUFFIX') ||
52
- format_worktree_database_suffix(@worktree_name)
55
+ allocate_worktree_database_suffix(dev_port: dev_port)
53
56
  }
54
57
  end
55
58
 
@@ -108,8 +111,7 @@ module Rails
108
111
  end
109
112
 
110
113
  def claimed_peer_ports
111
- Dir.glob(File.join(peers_root, '*')).filter_map do |path|
112
- next unless File.directory?(path)
114
+ peer_paths.filter_map do |path|
113
115
  next if File.expand_path(path) == File.expand_path(@target_dir)
114
116
 
115
117
  port = env_value(peer_env_lines(path), 'DEV_PORT')
@@ -126,8 +128,21 @@ module Rails
126
128
 
127
129
  def peers_root = File.dirname(@target_dir)
128
130
 
131
+ def peer_paths
132
+ return @peer_roots unless @peer_roots.nil?
133
+
134
+ Dir.glob(File.join(peers_root, '*')).select { |path| File.directory?(path) }
135
+ end
136
+
129
137
  def configured_port_range = @configuration.dev_port_range
130
138
 
139
+ def allocate_worktree_database_suffix(dev_port:)
140
+ base_candidate = format_worktree_database_suffix(@worktree_name)
141
+ return base_candidate unless claimed_peer_suffixes.include?(base_candidate)
142
+
143
+ suffix_with_token(@worktree_name, dev_port.to_s)
144
+ end
145
+
131
146
  def format_worktree_database_suffix(value)
132
147
  suffix = value.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '').squeeze('_')
133
148
  suffix = suffix[0, @configuration.worktree_database_suffix_max_length]
@@ -136,6 +151,35 @@ module Rails
136
151
  "_#{suffix}"
137
152
  end
138
153
 
154
+ def suffix_with_token(value, token)
155
+ slug = normalized_suffix_slug(value)
156
+ normalized_token = token.to_s.downcase.gsub(/[^a-z0-9]+/, '')
157
+ max_length = @configuration.worktree_database_suffix_max_length
158
+ available_slug_length = max_length - normalized_token.length - 1
159
+
160
+ composed_slug = if available_slug_length.positive?
161
+ [slug[0, available_slug_length], normalized_token].reject(&:empty?).join('_')
162
+ else
163
+ normalized_token[0, max_length]
164
+ end
165
+
166
+ format_worktree_database_suffix(composed_slug)
167
+ end
168
+
169
+ def normalized_suffix_slug(value)
170
+ slug = value.to_s.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '').squeeze('_')
171
+ slug.empty? ? 'worktree' : slug
172
+ end
173
+
174
+ def claimed_peer_suffixes
175
+ peer_paths.each_with_object(Set.new) do |path, suffixes|
176
+ next if File.expand_path(path) == File.expand_path(@target_dir)
177
+
178
+ suffix = env_value(peer_env_lines(path), 'WORKTREE_DATABASE_SUFFIX')
179
+ suffixes << suffix if suffix
180
+ end
181
+ end
182
+
139
183
  def formatted_updates(updates) = updates.map { |key, value| "#{key}=#{value}" }.join(', ')
140
184
 
141
185
  def display_path(path)
@@ -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)
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Worktrees
3
- VERSION = '0.6.0'.freeze
3
+ VERSION = '0.7.0'.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.0
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