rails-worktrees 0.5.1 → 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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/README.md +33 -1
- data/lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt +11 -0
- data/lib/rails/worktrees/cli.rb +22 -2
- data/lib/rails/worktrees/command/environment_support.rb +1 -0
- data/lib/rails/worktrees/command/output.rb +9 -0
- data/lib/rails/worktrees/command/post_create_support.rb +34 -0
- data/lib/rails/worktrees/command/workspace_paths.rb +15 -3
- data/lib/rails/worktrees/command.rb +214 -8
- data/lib/rails/worktrees/configuration.rb +32 -2
- data/lib/rails/worktrees/credential_key_linker.rb +93 -0
- data/lib/rails/worktrees/env_bootstrapper.rb +49 -5
- data/lib/rails/worktrees/mise_environment.rb +87 -0
- data/lib/rails/worktrees/post_create_runner.rb +225 -0
- data/lib/rails/worktrees/version.rb +1 -1
- data/lib/rails/worktrees.rb +3 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e86a789b2e56f07d95a626b67c9fafbf01862e9c8496043107914b4446e13702
|
|
4
|
+
data.tar.gz: 9f0dce854a6c7a20dfedb00dd181f7ecc5699ae6cf86ff61d24d60bce80be11e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16513989c4364e4cf37d4eb0cf1fbde3fc95d8aa246223b5e9cca20da0035266d8f33e81a115d83b95b359ff93292796ba88889f06495c9d8b39fde11eb8d5b4
|
|
7
|
+
data.tar.gz: 1bcdfe219ef78dadeeaf7a8de8cc71662de4e0066b6bebf9941aa59d86c9349a38e3caf0671693dc321b54b672618e7b9b772994efb48511628f77718345aceb
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{".":"0.
|
|
1
|
+
{".":"0.7.0"}
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.6.0](https://github.com/asjer/rails-worktrees/compare/v0.5.1...v0.6.0) (2026-04-03)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **post-create:** bootstrap new worktrees after creation ([b429105](https://github.com/asjer/rails-worktrees/commit/b42910507d21c1d048d45cfdc4d7311a1ec8a96b))
|
|
16
|
+
|
|
3
17
|
## [0.5.1](https://github.com/asjer/rails-worktrees/compare/v0.5.0...v0.5.1) (2026-04-02)
|
|
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
|
|
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
|
|
|
@@ -16,4 +16,15 @@ Rails.application.config.x.rails_worktrees.tap do |config|
|
|
|
16
16
|
# 'rails-worktrees',
|
|
17
17
|
# 'used-names.tsv'
|
|
18
18
|
# )
|
|
19
|
+
|
|
20
|
+
# Post-create setup steps (run automatically after a new worktree is created)
|
|
21
|
+
# config.post_create_command = nil # nil = built-in steps; false = skip all; 'script/worktree-setup' = custom command
|
|
22
|
+
# config.run_bundle_install = true
|
|
23
|
+
# config.run_yarn_install = true # auto-skipped when yarn.lock is absent
|
|
24
|
+
# config.run_db_prepare = true
|
|
25
|
+
# config.run_test_db_prepare = true
|
|
26
|
+
# config.run_test_assets_precompile = true
|
|
27
|
+
# config.link_credential_keys = true # link development.key from a sibling worktree (opt-out)
|
|
28
|
+
# config.link_test_credential_key = false
|
|
29
|
+
# config.link_production_credential_key = false
|
|
19
30
|
end
|
data/lib/rails/worktrees/cli.rb
CHANGED
|
@@ -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:
|
|
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
|
|
@@ -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
|
|
@@ -0,0 +1,34 @@
|
|
|
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, bootstrapped_env: nil)
|
|
9
|
+
post_create_runner_for(context, bootstrapped_env: bootstrapped_env).call(dry_run: false)
|
|
10
|
+
end
|
|
11
|
+
|
|
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
|
+
end
|
|
15
|
+
|
|
16
|
+
def post_create_runner_for(context, bootstrapped_env: nil)
|
|
17
|
+
PostCreateRunner.new(
|
|
18
|
+
target_dir: context[:target_dir],
|
|
19
|
+
peer_roots: context.fetch(:peer_roots) { peer_roots_excluding(context[:target_dir]) },
|
|
20
|
+
configuration: @configuration,
|
|
21
|
+
bootstrapped_env: bootstrapped_env,
|
|
22
|
+
io: { stdout: @stdout, stderr: @stderr }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def peer_roots_excluding(target_dir)
|
|
27
|
+
worktree_entries
|
|
28
|
+
.map { |entry| entry[:path] }
|
|
29
|
+
.reject { |path| path == target_dir }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
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,
|
|
84
|
+
File.expand_path(path, base_dir)
|
|
73
85
|
end
|
|
74
86
|
|
|
75
87
|
def present_path?(path)
|
|
@@ -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
|
|
@@ -12,12 +13,14 @@ module Rails
|
|
|
12
13
|
class Command
|
|
13
14
|
REMOVE_SUBCOMMANDS = %w[remove delete].freeze
|
|
14
15
|
DOCTOR_SUBCOMMAND = 'doctor'.freeze
|
|
16
|
+
SETUP_SUBCOMMAND = 'setup'.freeze
|
|
15
17
|
UPDATE_SUBCOMMAND = 'update'.freeze
|
|
16
18
|
|
|
17
19
|
include GitOperations
|
|
18
20
|
include EnvironmentSupport
|
|
19
21
|
include NamePicking
|
|
20
22
|
include Output
|
|
23
|
+
include PostCreateSupport
|
|
21
24
|
include WorkspacePaths
|
|
22
25
|
|
|
23
26
|
def initialize(argv:, io:, env:, cwd:, configuration:)
|
|
@@ -47,9 +50,12 @@ module Rails
|
|
|
47
50
|
|
|
48
51
|
def force? = @force
|
|
49
52
|
|
|
53
|
+
def skip_setup? = @skip_setup
|
|
54
|
+
|
|
50
55
|
def extract_flags!
|
|
51
56
|
@dry_run = extract_flag!('--dry-run')
|
|
52
57
|
@force = extract_flag!('--force')
|
|
58
|
+
@skip_setup = extract_flag!('--skip-setup')
|
|
53
59
|
end
|
|
54
60
|
|
|
55
61
|
def extract_flag!(flag)
|
|
@@ -75,20 +81,37 @@ module Rails
|
|
|
75
81
|
@argv.first == DOCTOR_SUBCOMMAND
|
|
76
82
|
end
|
|
77
83
|
|
|
84
|
+
def setup_subcommand?
|
|
85
|
+
@argv.first == SETUP_SUBCOMMAND
|
|
86
|
+
end
|
|
87
|
+
|
|
78
88
|
def update_subcommand?
|
|
79
89
|
@argv.first == UPDATE_SUBCOMMAND
|
|
80
90
|
end
|
|
81
91
|
|
|
82
92
|
def execute_requested_command
|
|
83
|
-
|
|
84
|
-
return
|
|
85
|
-
return
|
|
86
|
-
return execute_prune_command if prune_subcommand?
|
|
87
|
-
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?
|
|
88
96
|
|
|
89
97
|
execute_worktree_command
|
|
90
98
|
end
|
|
91
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
|
+
|
|
92
115
|
def execute_worktree_command
|
|
93
116
|
require_git_repo
|
|
94
117
|
announce_dry_run if dry_run?
|
|
@@ -105,6 +128,16 @@ module Rails
|
|
|
105
128
|
finish(context)
|
|
106
129
|
end
|
|
107
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
|
+
|
|
108
141
|
def execute_remove_command
|
|
109
142
|
require_git_repo
|
|
110
143
|
announce_dry_run if dry_run?
|
|
@@ -208,17 +241,26 @@ module Rails
|
|
|
208
241
|
def validate_prune_args!
|
|
209
242
|
raise Error, 'Usage: wt prune' unless @argv.length == 1
|
|
210
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?
|
|
211
245
|
end
|
|
212
246
|
|
|
213
247
|
def validate_doctor_args!
|
|
214
248
|
raise Error, 'Usage: wt doctor' unless @argv.length == 1
|
|
215
249
|
raise Error, 'wt doctor does not support --dry-run.' if dry_run?
|
|
216
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?
|
|
217
258
|
end
|
|
218
259
|
|
|
219
260
|
def validate_update_args!
|
|
220
261
|
raise Error, 'Usage: wt update [--dry-run]' unless @argv.length == 1
|
|
221
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?
|
|
222
264
|
end
|
|
223
265
|
|
|
224
266
|
def announce_prune_candidates(candidates)
|
|
@@ -256,6 +298,60 @@ module Rails
|
|
|
256
298
|
)
|
|
257
299
|
end
|
|
258
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
|
+
|
|
259
355
|
def resolved_worktree_name(project_name, workspaces, explicit_worktree_name)
|
|
260
356
|
return validate_worktree_name(explicit_worktree_name) if explicit_worktree_name
|
|
261
357
|
|
|
@@ -283,14 +379,52 @@ module Rails
|
|
|
283
379
|
|
|
284
380
|
def finish(context)
|
|
285
381
|
settle_retired_name(context[:worktree_name], context[:project_name], dry_run: dry_run?)
|
|
286
|
-
|
|
287
|
-
|
|
382
|
+
complete_setup_flow(
|
|
383
|
+
context,
|
|
384
|
+
success_message: skip_setup? ? 'Worktree created' : 'Worktree ready',
|
|
385
|
+
run_post_create: !skip_setup?
|
|
386
|
+
)
|
|
387
|
+
end
|
|
288
388
|
|
|
289
|
-
|
|
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
|
|
392
|
+
complete_dry_run(context, env_values: bootstrap_result&.values)
|
|
393
|
+
end
|
|
394
|
+
|
|
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)
|
|
401
|
+
return result unless result.zero?
|
|
402
|
+
|
|
403
|
+
success(success_message)
|
|
290
404
|
print_context_summary(context, env_values: bootstrap_result&.values)
|
|
291
405
|
0
|
|
292
406
|
end
|
|
293
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
|
+
|
|
294
428
|
def finish_reuse(context)
|
|
295
429
|
target, branch = context.values_at(:target_dir, :branch_name)
|
|
296
430
|
|
|
@@ -323,6 +457,78 @@ module Rails
|
|
|
323
457
|
"#{@configuration.branch_prefix}/#{worktree_name}"
|
|
324
458
|
end
|
|
325
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
|
+
|
|
326
532
|
def ensure_removable!(context, worktree_exists:, branch_exists:)
|
|
327
533
|
ensure_remove_target_exists!(context, worktree_exists: worktree_exists, branch_exists: branch_exists)
|
|
328
534
|
ensure_not_removing_protected_checkout!(context)
|
|
@@ -10,6 +10,15 @@ module Rails
|
|
|
10
10
|
name_sources_path
|
|
11
11
|
used_names_file
|
|
12
12
|
worktree_database_suffix_max_length
|
|
13
|
+
post_create_command
|
|
14
|
+
run_bundle_install
|
|
15
|
+
run_yarn_install
|
|
16
|
+
run_db_prepare
|
|
17
|
+
run_test_db_prepare
|
|
18
|
+
run_test_assets_precompile
|
|
19
|
+
link_credential_keys
|
|
20
|
+
link_test_credential_key
|
|
21
|
+
link_production_credential_key
|
|
13
22
|
].freeze
|
|
14
23
|
|
|
15
24
|
DEFAULT_BOOTSTRAP_ENV = true
|
|
@@ -25,9 +34,20 @@ module Rails
|
|
|
25
34
|
|
|
26
35
|
attr_accessor :bootstrap_env, :branch_prefix, :dev_port_range, :legacy_used_names_files,
|
|
27
36
|
:name_sources_path, :used_names_file, :workspace_root,
|
|
28
|
-
:worktree_database_suffix_max_length
|
|
37
|
+
:worktree_database_suffix_max_length,
|
|
38
|
+
:post_create_command,
|
|
39
|
+
:run_bundle_install, :run_yarn_install,
|
|
40
|
+
:run_db_prepare, :run_test_db_prepare, :run_test_assets_precompile,
|
|
41
|
+
:link_credential_keys, :link_test_credential_key, :link_production_credential_key
|
|
29
42
|
|
|
30
43
|
def initialize
|
|
44
|
+
assign_core_defaults
|
|
45
|
+
assign_post_create_defaults
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def assign_core_defaults
|
|
31
51
|
@bootstrap_env = DEFAULT_BOOTSTRAP_ENV
|
|
32
52
|
@workspace_root = nil
|
|
33
53
|
@branch_prefix = DEFAULT_BRANCH_PREFIX
|
|
@@ -38,7 +58,17 @@ module Rails
|
|
|
38
58
|
@legacy_used_names_files = default_legacy_used_names_files
|
|
39
59
|
end
|
|
40
60
|
|
|
41
|
-
|
|
61
|
+
def assign_post_create_defaults
|
|
62
|
+
@post_create_command = nil
|
|
63
|
+
@run_bundle_install = true
|
|
64
|
+
@run_yarn_install = true
|
|
65
|
+
@run_db_prepare = true
|
|
66
|
+
@run_test_db_prepare = true
|
|
67
|
+
@run_test_assets_precompile = true
|
|
68
|
+
@link_credential_keys = true
|
|
69
|
+
@link_test_credential_key = false
|
|
70
|
+
@link_production_credential_key = false
|
|
71
|
+
end
|
|
42
72
|
|
|
43
73
|
def default_legacy_used_names_files
|
|
44
74
|
state_home = ENV.fetch('XDG_STATE_HOME', File.join(Dir.home, '.local/state'))
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Worktrees
|
|
5
|
+
# Links credential key files from a sibling worktree into the new worktree.
|
|
6
|
+
class CredentialKeyLinker
|
|
7
|
+
Result = Struct.new(:messages)
|
|
8
|
+
|
|
9
|
+
CREDENTIALS_DIR = 'config/credentials'.freeze
|
|
10
|
+
|
|
11
|
+
KEY_TYPES = [
|
|
12
|
+
{ name: 'development', config_attr: :link_credential_keys },
|
|
13
|
+
{ name: 'test', config_attr: :link_test_credential_key },
|
|
14
|
+
{ name: 'production', config_attr: :link_production_credential_key }
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def initialize(target_dir:, peer_roots:, configuration:)
|
|
18
|
+
@target_dir = target_dir
|
|
19
|
+
@peer_roots = peer_roots
|
|
20
|
+
@configuration = configuration
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(dry_run: false)
|
|
24
|
+
messages = []
|
|
25
|
+
|
|
26
|
+
KEY_TYPES.each do |key_type|
|
|
27
|
+
next unless @configuration.public_send(key_type[:config_attr])
|
|
28
|
+
|
|
29
|
+
message = process_key(key_type[:name], dry_run: dry_run)
|
|
30
|
+
messages << message if message
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Result.new(messages)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def process_key(key_name, dry_run:)
|
|
39
|
+
destination = destination_path_for(key_name)
|
|
40
|
+
existing_message = existing_destination_message(destination, key_name)
|
|
41
|
+
return existing_message if existing_message
|
|
42
|
+
|
|
43
|
+
replace_existing_symlink = File.symlink?(destination)
|
|
44
|
+
return "#{key_name}.key already exists; leaving as-is" if File.exist?(destination)
|
|
45
|
+
|
|
46
|
+
source = find_source(key_name)
|
|
47
|
+
return "Could not find source for #{key_name}.key" unless source
|
|
48
|
+
|
|
49
|
+
link_key(destination, key_name, source, dry_run: dry_run, replace_existing_symlink: replace_existing_symlink)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def find_source(key_name)
|
|
53
|
+
@peer_roots.each do |peer_root|
|
|
54
|
+
candidate = File.join(peer_root, CREDENTIALS_DIR, "#{key_name}.key")
|
|
55
|
+
next if File.expand_path(peer_root) == File.expand_path(@target_dir)
|
|
56
|
+
|
|
57
|
+
return candidate if File.file?(candidate)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def destination_path_for(key_name)
|
|
64
|
+
File.join(@target_dir, CREDENTIALS_DIR, "#{key_name}.key")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def existing_destination_message(destination, key_name)
|
|
68
|
+
return unless File.symlink?(destination) && File.exist?(destination)
|
|
69
|
+
|
|
70
|
+
"#{key_name}.key already linked"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def link_key(destination, key_name, source, dry_run:, replace_existing_symlink: false)
|
|
74
|
+
return dry_run_link_message(key_name, source, replace_existing_symlink) if dry_run
|
|
75
|
+
|
|
76
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
77
|
+
FileUtils.rm_f(destination) if replace_existing_symlink
|
|
78
|
+
File.symlink(source, destination)
|
|
79
|
+
linked_key_message(key_name, source, replace_existing_symlink)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def dry_run_link_message(key_name, source, replace_existing_symlink)
|
|
83
|
+
verb = replace_existing_symlink ? 'Would relink' : 'Would link'
|
|
84
|
+
"#{verb} #{key_name}.key → #{source}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def linked_key_message(key_name, source, replace_existing_symlink)
|
|
88
|
+
verb = replace_existing_symlink ? 'Relinked' : 'Linked'
|
|
89
|
+
"#{verb} #{key_name}.key → #{source}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -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' =>
|
|
53
|
+
'DEV_PORT' => dev_port,
|
|
51
54
|
'WORKTREE_DATABASE_SUFFIX' => env_value(lines, 'WORKTREE_DATABASE_SUFFIX') ||
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,225 @@
|
|
|
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), assets:clobber.
|
|
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
|
+
{ id: :assets_clobber, argv: %w[bin/rails assets:clobber],
|
|
22
|
+
header: '🧹 Clobbering compiled assets...' }
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
STEP_CONFIG = {
|
|
26
|
+
bundle: :run_bundle_install,
|
|
27
|
+
yarn: :run_yarn_install,
|
|
28
|
+
db_prepare: :run_db_prepare,
|
|
29
|
+
test_db_prepare: :run_test_db_prepare,
|
|
30
|
+
test_assets_precompile: :run_test_assets_precompile,
|
|
31
|
+
assets_clobber: :run_test_assets_precompile
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def initialize(target_dir:, peer_roots:, configuration:, io:, bootstrapped_env: nil)
|
|
35
|
+
@target_dir = target_dir
|
|
36
|
+
@peer_roots = peer_roots
|
|
37
|
+
@configuration = configuration
|
|
38
|
+
@bootstrapped_env = (bootstrapped_env || {}).transform_values(&:to_s)
|
|
39
|
+
@stdout = io.fetch(:stdout)
|
|
40
|
+
@stderr = io.fetch(:stderr)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(dry_run: false)
|
|
44
|
+
return 0 if @configuration.post_create_command == false
|
|
45
|
+
|
|
46
|
+
@runtime_env = resolved_runtime_env(dry_run: dry_run)
|
|
47
|
+
|
|
48
|
+
return run_custom_command(dry_run: dry_run) if custom_command?
|
|
49
|
+
|
|
50
|
+
run_built_in_steps(dry_run: dry_run)
|
|
51
|
+
rescue Error => e
|
|
52
|
+
@stderr.puts("❌ #{e.message}")
|
|
53
|
+
1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def custom_command?
|
|
59
|
+
@configuration.post_create_command.is_a?(String) && !@configuration.post_create_command.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run_custom_command(dry_run:)
|
|
63
|
+
command = @configuration.post_create_command
|
|
64
|
+
|
|
65
|
+
if dry_run
|
|
66
|
+
info("Would run: #{command}")
|
|
67
|
+
return 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
info("Running: #{command}")
|
|
71
|
+
stream_shell_command(command)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def run_built_in_steps(dry_run:)
|
|
75
|
+
result = run_credential_linking(dry_run: dry_run)
|
|
76
|
+
return result unless result.zero?
|
|
77
|
+
|
|
78
|
+
STEPS.each do |step|
|
|
79
|
+
config_attr = STEP_CONFIG[step[:id]]
|
|
80
|
+
next unless @configuration.public_send(config_attr)
|
|
81
|
+
next if step[:id] == :yarn && !yarn_lock_present?
|
|
82
|
+
|
|
83
|
+
result = run_step(step, dry_run: dry_run)
|
|
84
|
+
return result unless result.zero?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def run_credential_linking(dry_run:)
|
|
91
|
+
# Missing peer key files are intentionally best-effort and should not abort
|
|
92
|
+
# worktree setup; the linker reports those cases in its messages while real
|
|
93
|
+
# filesystem errors still raise out of `credential_key_linker.call`.
|
|
94
|
+
return 0 unless credential_linking_enabled?
|
|
95
|
+
|
|
96
|
+
info('🔑 Linking credential keys...')
|
|
97
|
+
|
|
98
|
+
result = credential_key_linker.call(dry_run: dry_run)
|
|
99
|
+
print_credential_linking_messages(result)
|
|
100
|
+
|
|
101
|
+
0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def run_step(step, dry_run:)
|
|
105
|
+
if dry_run
|
|
106
|
+
info("Would run: #{display_command(step)}")
|
|
107
|
+
return 0
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
info(step[:header])
|
|
111
|
+
stream_step_command(step)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def stream_shell_command(command)
|
|
115
|
+
exit_status = capture_shell_command_exit_status(command)
|
|
116
|
+
report_failed_command(command, exit_status) unless exit_status.zero?
|
|
117
|
+
exit_status
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def stream_step_command(step)
|
|
121
|
+
command = display_command(step)
|
|
122
|
+
exit_status = capture_step_command_exit_status(step)
|
|
123
|
+
report_failed_command(command, exit_status) unless exit_status.zero?
|
|
124
|
+
exit_status
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def capture_shell_command_exit_status(command)
|
|
128
|
+
exit_status = nil
|
|
129
|
+
|
|
130
|
+
Open3.popen2e(runtime_env, command, chdir: @target_dir) do |_stdin, output, wait_thread|
|
|
131
|
+
output.each_line { |line| @stdout.print(line) }
|
|
132
|
+
exit_status = wait_thread.value.exitstatus
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
exit_status
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
report_failed_command_start(command, e)
|
|
138
|
+
1
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def capture_step_command_exit_status(step)
|
|
142
|
+
exit_status = nil
|
|
143
|
+
|
|
144
|
+
Open3.popen2e(*step_command(step), chdir: @target_dir) do |_stdin, output, wait_thread|
|
|
145
|
+
output.each_line { |line| @stdout.print(line) }
|
|
146
|
+
exit_status = wait_thread.value.exitstatus
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
exit_status
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
report_failed_command_start(display_command(step), e)
|
|
152
|
+
1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def shell_env
|
|
156
|
+
# Pass through only the essentials so subprocess tools work correctly.
|
|
157
|
+
ENV.to_h.slice('PATH', 'HOME', 'LANG', 'TERM', 'SHELL',
|
|
158
|
+
'BUNDLE_GEMFILE', 'BUNDLE_PATH', 'GEM_HOME', 'GEM_PATH',
|
|
159
|
+
'RUBY_VERSION', 'RAILS_ENV', 'NODE_ENV',
|
|
160
|
+
'XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
|
|
161
|
+
'DEV_PORT', 'WORKTREE_DATABASE_SUFFIX')
|
|
162
|
+
end
|
|
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
|
+
|
|
179
|
+
def yarn_lock_present?
|
|
180
|
+
File.exist?(File.join(@target_dir, 'yarn.lock'))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def display_command(step)
|
|
184
|
+
env_prefix = step.fetch(:env, {}).map { |key, value| "#{key}=#{value}" }.join(' ')
|
|
185
|
+
command = step.fetch(:argv).join(' ')
|
|
186
|
+
[env_prefix, command].reject(&:empty?).join(' ')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def step_command(step)
|
|
190
|
+
[runtime_env.merge(step.fetch(:env, {})), *step.fetch(:argv)]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def info(message)
|
|
194
|
+
@stdout.puts("→ #{message}")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def report_failed_command(command, exit_status)
|
|
198
|
+
@stderr.puts("❌ Command failed (exit #{exit_status}): #{command}")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def report_failed_command_start(command, error)
|
|
202
|
+
@stderr.puts("❌ Command failed to start: #{command} (#{error.message})")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def credential_linking_enabled?
|
|
206
|
+
@configuration.link_credential_keys ||
|
|
207
|
+
@configuration.link_test_credential_key ||
|
|
208
|
+
@configuration.link_production_credential_key
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def credential_key_linker
|
|
212
|
+
CredentialKeyLinker.new(
|
|
213
|
+
target_dir: @target_dir,
|
|
214
|
+
peer_roots: @peer_roots,
|
|
215
|
+
configuration: @configuration
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def print_credential_linking_messages(result)
|
|
220
|
+
result.messages.each { |message| info(" #{message}") }
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
# rubocop:enable Metrics/ClassLength
|
|
224
|
+
end
|
|
225
|
+
end
|
data/lib/rails/worktrees.rb
CHANGED
|
@@ -3,7 +3,10 @@ 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'
|
|
8
|
+
require_relative 'worktrees/credential_key_linker'
|
|
9
|
+
require_relative 'worktrees/post_create_runner'
|
|
7
10
|
require_relative 'worktrees/command'
|
|
8
11
|
require_relative 'worktrees/cli'
|
|
9
12
|
require_relative 'worktrees/browser_command'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-worktrees
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asjer Querido
|
|
@@ -63,13 +63,17 @@ files:
|
|
|
63
63
|
- lib/rails/worktrees/command/git_operations.rb
|
|
64
64
|
- lib/rails/worktrees/command/name_picking.rb
|
|
65
65
|
- lib/rails/worktrees/command/output.rb
|
|
66
|
+
- lib/rails/worktrees/command/post_create_support.rb
|
|
66
67
|
- lib/rails/worktrees/command/workspace_paths.rb
|
|
67
68
|
- lib/rails/worktrees/configuration.rb
|
|
69
|
+
- lib/rails/worktrees/credential_key_linker.rb
|
|
68
70
|
- lib/rails/worktrees/database_config_updater.rb
|
|
69
71
|
- lib/rails/worktrees/env_bootstrapper.rb
|
|
70
72
|
- lib/rails/worktrees/initializer_updater.rb
|
|
73
|
+
- lib/rails/worktrees/mise_environment.rb
|
|
71
74
|
- lib/rails/worktrees/mise_toml_updater.rb
|
|
72
75
|
- lib/rails/worktrees/names/cities.txt
|
|
76
|
+
- lib/rails/worktrees/post_create_runner.rb
|
|
73
77
|
- lib/rails/worktrees/procfile_updater.rb
|
|
74
78
|
- lib/rails/worktrees/project_configuration_loader.rb
|
|
75
79
|
- lib/rails/worktrees/project_maintenance.rb
|