rails-worktrees 0.8.0 → 0.9.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 +64 -8
- data/lib/generators/rails/worktrees/templates/bin/wt +2 -2
- data/lib/generators/rails/worktrees/templates/rails_worktrees.rb.tt +8 -0
- data/lib/rails/worktrees/cli.rb +19 -2
- data/lib/rails/worktrees/command/in_repo_worktrees.rb +85 -0
- data/lib/rails/worktrees/command/name_picking.rb +2 -1
- data/lib/rails/worktrees/command/output.rb +12 -4
- data/lib/rails/worktrees/command/workspace_paths.rb +40 -9
- data/lib/rails/worktrees/command.rb +45 -8
- data/lib/rails/worktrees/configuration.rb +6 -2
- data/lib/rails/worktrees/playwright_browser_check.rb +56 -0
- data/lib/rails/worktrees/post_create_runner.rb +45 -4
- data/lib/rails/worktrees/teardown_runner.rb +84 -0
- data/lib/rails/worktrees/teardown_script.rb +115 -0
- data/lib/rails/worktrees/version.rb +1 -1
- data/lib/rails/worktrees.rb +2 -0
- data/sig/rails/worktrees.rbs +1 -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: 6f0667430464dedbea994c715a32e64f226048502f6037186f3c90692c5df6e5
|
|
4
|
+
data.tar.gz: 1f47027f6cdc16431356c43e4809d801a7537db1558d3ab9b25c1299874d1389
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81a835017246e1cab5fd6acf6ef311b4f9beef54912675602fd454c8ed2aa56904b514be908f69cdc2b84110ec8c38d9d49c9abbc974eea1b309aec2fed3c3f9
|
|
7
|
+
data.tar.gz: 5aad32825be11a64a0095233ed4068eda727da0af5ca0456657e5a4f0aa33f8aa04c0a6a48929a0e7abe97cbbbff9197aa49845c5b6a4acdf207d6c48e8e0796
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{".":"0.
|
|
1
|
+
{".":"0.9.0"}
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0](https://github.com/asjer/rails-worktrees/compare/v0.8.0...v0.9.0) (2026-11-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **config:** support worktrees inside the repo via worktrees_dir ([#61](https://github.com/asjer/rails-worktrees/issues/61)) ([681f325](https://github.com/asjer/rails-worktrees/commit/681f325584b69fc675f7fce045c5cd8baf36eb83))
|
|
9
|
+
* **setup:** install Playwright Chromium for the pinned version ([#66](https://github.com/asjer/rails-worktrees/issues/66)) ([cdbef7c](https://github.com/asjer/rails-worktrees/commit/cdbef7c24f738674dd209af35da25d4f85e038ed))
|
|
10
|
+
* **wt:** add guarded worktree teardown with dry-run ([#63](https://github.com/asjer/rails-worktrees/issues/63)) ([5592830](https://github.com/asjer/rails-worktrees/commit/5592830849b098e0d904d70cf96390dc55083356))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **wt:** reserve help and version instead of using them as names ([#60](https://github.com/asjer/rails-worktrees/issues/60)) ([d52dba8](https://github.com/asjer/rails-worktrees/commit/d52dba83525ef21f11840db0b691174ccf896a33))
|
|
16
|
+
|
|
3
17
|
## [0.8.0](https://github.com/asjer/rails-worktrees/compare/v0.7.4...v0.8.0) (2026-10-24)
|
|
4
18
|
|
|
5
19
|
|
data/README.md
CHANGED
|
@@ -55,10 +55,14 @@ bin/wt doctor # audit install/config drift and basic worktree
|
|
|
55
55
|
bin/wt update --dry-run # preview safe maintenance fixes
|
|
56
56
|
bin/wt update # apply safe maintenance fixes for managed files
|
|
57
57
|
bin/wt update --pi # also add the pi-worktree hook in .pi/worktree.json
|
|
58
|
-
bin/wt
|
|
58
|
+
bin/wt teardown --dry-run # preview this worktree's Overmind and development/test databases
|
|
59
|
+
bin/wt teardown # stop Overmind and drop worktree databases; keep checkout/branch
|
|
60
|
+
bin/wt remove my-feature # teardown a worktree, remove it and delete its local branch
|
|
59
61
|
bin/wt delete my-feature # alias for `bin/wt remove`
|
|
60
62
|
bin/wt remove --force my-feature # also delete an unmerged local branch
|
|
61
63
|
bin/wt prune # remove merged worktrees created by wt
|
|
64
|
+
bin/wt help # same as --help; never creates a worktree
|
|
65
|
+
bin/wt version # same as --version
|
|
62
66
|
|
|
63
67
|
bin/ob # open http://localhost:$DEV_PORT/
|
|
64
68
|
bin/ob contact # open http://localhost:$DEV_PORT/contact
|
|
@@ -70,8 +74,8 @@ bin/ob --print-url '?from=nav' # print the resolved URL without opening a brows
|
|
|
70
74
|
|
|
71
75
|
| Flag | Description |
|
|
72
76
|
|------|-------------|
|
|
73
|
-
| `-h`, `--help` | Show the help message |
|
|
74
|
-
| `-v`, `--version` | Show the script version |
|
|
77
|
+
| `-h`, `--help`, `help` | Show the help message |
|
|
78
|
+
| `-v`, `--version`, `version` | Show the script version |
|
|
75
79
|
| `--dry-run [name]` | Preview worktree creation or cleanup without changing anything |
|
|
76
80
|
| `--skip-setup` | Create a worktree without running setup steps |
|
|
77
81
|
| `--force` | Force branch deletion for `bin/wt remove` / `bin/wt delete` |
|
|
@@ -88,7 +92,7 @@ By default `bin/wt`:
|
|
|
88
92
|
- auto-picks names from bundled `.txt` files when no explicit name is given
|
|
89
93
|
- retires bundled names so they are not picked twice
|
|
90
94
|
- bootstraps a worktree-local `.env` with deterministic `DEV_PORT` and `WORKTREE_DATABASE_SUFFIX` values
|
|
91
|
-
- 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`
|
|
95
|
+
- runs setup automatically after creation: credential linking, `bundle install`, `yarn install` when applicable, the Playwright Chromium build for the pinned `playwright` package when it is missing, both `db:prepare` steps, test asset precompile, and a final `bin/rails assets:clobber`
|
|
92
96
|
|
|
93
97
|
```text
|
|
94
98
|
workspace/
|
|
@@ -101,6 +105,32 @@ workspace/
|
|
|
101
105
|
|
|
102
106
|
`WT_WORKSPACES_ROOT` or `config.workspace_root` overrides the destination root and uses the layout `<root>/<project>/<name>`.
|
|
103
107
|
|
|
108
|
+
### Worktrees inside the repository
|
|
109
|
+
|
|
110
|
+
Set `config.worktrees_dir` to keep worktrees inside the main checkout instead, in `<main checkout>/<worktrees_dir>/<name>`. This is the layout the [pi-worktree](https://www.npmjs.com/package/pi-worktree) extension uses, so `bin/wt` and Pi share one place for worktrees:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
Rails.application.config.x.rails_worktrees.tap do |config|
|
|
114
|
+
config.worktrees_dir = '.worktrees'
|
|
115
|
+
config.branch_prefix = 'worktree' # wt and pi-worktree both create worktree/<name>
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
my-project/
|
|
121
|
+
├── app/
|
|
122
|
+
├── config/
|
|
123
|
+
└── .worktrees/
|
|
124
|
+
├── feature-auth/
|
|
125
|
+
└── bugfix-123/
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- `worktrees_dir` must be a relative path inside the repository, such as `.worktrees` or `tmp/worktrees`. It always resolves against the main checkout, also when you run `bin/wt` from inside a worktree.
|
|
129
|
+
- If `.gitignore` does not already ignore the directory, `bin/wt` adds `/.worktrees/` to `.git/info/exclude` when it creates a worktree, so `git status` in the main checkout stays clean. When it cannot write that file it prints a warning; add the directory to `.gitignore` yourself.
|
|
130
|
+
- `bin/wt remove`, `bin/wt prune` and `bin/wt doctor` work in this layout too. `bin/wt doctor` warns when the directory is not ignored.
|
|
131
|
+
- `worktrees_dir` cannot be combined with `workspace_root`. `WT_WORKSPACES_ROOT` still overrides both for a single run.
|
|
132
|
+
- Tools that scan the main checkout's file tree (for example RuboCop, file watchers or Tailwind content globs) can pick up files from the worktrees. Exclude the directory there if needed.
|
|
133
|
+
|
|
104
134
|
### Setup command
|
|
105
135
|
|
|
106
136
|
`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.
|
|
@@ -131,7 +161,8 @@ bin/wt setup
|
|
|
131
161
|
|
|
132
162
|
`bin/wt` also supports cleanup commands for worktrees it manages:
|
|
133
163
|
|
|
134
|
-
- `bin/wt
|
|
164
|
+
- `bin/wt teardown` — stop the current worktree's Overmind and drop its development/test databases, keeping the checkout and branch
|
|
165
|
+
- `bin/wt remove <name>` — run the same teardown, remove the named worktree and delete its local branch
|
|
135
166
|
- `bin/wt delete <name>` — alias for `bin/wt remove <name>`
|
|
136
167
|
- `bin/wt prune` — remove merged worktrees created by `bin/wt` in bulk
|
|
137
168
|
|
|
@@ -141,7 +172,17 @@ bin/wt setup
|
|
|
141
172
|
|
|
142
173
|
`bin/wt prune` only targets linked worktrees created by `bin/wt` whose local branches are already merged into `origin`'s default branch. It skips the main checkout, skips the checkout you're currently in, and asks for confirmation before deleting the batch.
|
|
143
174
|
|
|
144
|
-
|
|
175
|
+
### Teardown command
|
|
176
|
+
|
|
177
|
+
Run `bin/wt teardown` **inside a linked worktree** (including manually created or Pi worktrees). It boots that app separately in test and development (so environment-specific files and database URLs are resolved correctly), checks **all** task-enabled development/test database configurations and Rails' protected-environment metadata, stops Overmind with `overmind quit --socket .overmind.sock` if that local socket exists, and drops only those validated databases through Rails' database tasks. Production databases and the checkout/branch are left alone. Multi-database configurations (primary, cache, queue, cable, etc.) are supported; replicas and configurations with `database_tasks: false` are not dropped.
|
|
178
|
+
|
|
179
|
+
Teardown refuses the main checkout, a missing/blank `WORKTREE_DATABASE_SUFFIX`, and any database whose name does not contain that suffix. For SQLite, the suffix must be in the filename, not merely a parent directory. **All names are validated before Overmind or any database is touched.** A working app bundle for both environments and `bin/rails` are required; install missing gems or fix unsafe database configuration before retrying. Both environments are booted even for a dry-run, so application initializers still execute. Rails' production/environment-mismatch protection cannot be bypassed with `DISABLE_DATABASE_ENVIRONMENT_CHECK`. The resolved test configuration is passed privately to the development process; all safety checks complete before Overmind is stopped or any database is dropped.
|
|
180
|
+
|
|
181
|
+
The worktree's `.env` may define a literal `WORKTREE_DATABASE_SUFFIX` (unquoted or single/double quoted, optionally with `export`). Teardown never generates a suffix. When run inside the worktree it can also use the current environment if `.env` does not define one. `remove` reads the **target's** `.env` and starts Rails with only the caller's OS/Ruby runtime variables, never its suffix, database URLs, application settings or bundle. Any other required application settings/credentials must therefore be loadable by the target app itself (for example with dotenv or Rails credentials). Ensure that target file contains the actual suffix used to create its databases.
|
|
182
|
+
|
|
183
|
+
`bin/wt remove` / `delete` run this same teardown **before** deleting the worktree or branch. If validation, Overmind shutdown, or a database drop fails, removal aborts and keeps the checkout and branch. Database drops are not transactional: databases already dropped before a later failure cannot be restored by this command. If only the branch remains (the worktree directory is already gone), removal only deletes the branch. `prune` retains its existing Git-only behavior; run teardown on its candidates first if they have databases.
|
|
184
|
+
|
|
185
|
+
Use `bin/wt teardown --dry-run` to list the local Overmind socket and exact development/test database names without stopping or dropping them. To preview both teardown and checkout/branch removal, use `--dry-run` with `bin/wt remove`:
|
|
145
186
|
|
|
146
187
|
```bash
|
|
147
188
|
bin/wt remove --dry-run feature-auth
|
|
@@ -188,7 +229,7 @@ Both commands create or update `.pi/worktree.json`:
|
|
|
188
229
|
}
|
|
189
230
|
```
|
|
190
231
|
|
|
191
|
-
`.pi/worktree.json` is read by the [pi-worktree](https://www.npmjs.com/package/pi-worktree) npm extension (`pi install pi-worktree`), **not** by Pi core. Without that extension, the file has no effect. pi-worktree places worktrees in `.worktrees/` inside the repository by default
|
|
232
|
+
`.pi/worktree.json` is read by the [pi-worktree](https://www.npmjs.com/package/pi-worktree) npm extension (`pi install pi-worktree`), **not** by Pi core. Without that extension, the file has no effect. pi-worktree places worktrees in `.worktrees/` inside the repository by default. Set `config.worktrees_dir = '.worktrees'` and `config.branch_prefix = 'worktree'` to have `bin/wt` use the same directory and branch names; see [Worktrees inside the repository](#worktrees-inside-the-repository).
|
|
192
233
|
|
|
193
234
|
`linkEnvFiles: false` matters. By default, pi-worktree symlinks the main checkout's gitignored `.env*` files into a new worktree before `postCreate` runs. `bin/wt setup` keeps existing `.env` values, so every Pi worktree would then reuse the main checkout's `DEV_PORT` and `WORKTREE_DATABASE_SUFFIX`, and setup would write through the symlink into the main checkout. With linking disabled, each worktree gets its own `.env` and database identity.
|
|
194
235
|
|
|
@@ -196,7 +237,19 @@ Existing configuration is merged, not replaced:
|
|
|
196
237
|
|
|
197
238
|
- other keys (`dir`, `branchPrefix`, `preRemove`, …) and custom `postCreate` hooks are preserved; `bin/wt setup` is appended once and never duplicated
|
|
198
239
|
- `linkEnvFiles` is only added when it is absent; an explicit `"linkEnvFiles": true`, invalid JSON, or a non-list `postCreate` is left untouched with a message explaining what to change by hand
|
|
199
|
-
- no `preRemove` hook is added, and the worktree directory and branch prefix stay under your control
|
|
240
|
+
- no `preRemove` hook is added automatically, and the worktree directory and branch prefix stay under your control
|
|
241
|
+
|
|
242
|
+
To opt into environment cleanup when Pi's pi-worktree extension destroys a worktree, add `bin/wt teardown` to `preRemove` (preserve any existing hooks):
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
{
|
|
246
|
+
"linkEnvFiles": false,
|
|
247
|
+
"postCreate": ["bin/wt setup"],
|
|
248
|
+
"preRemove": ["bin/wt teardown"]
|
|
249
|
+
}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The extension runs `preRemove` from inside the worktree. Teardown only cleans the environment; the calling tool remains responsible for removing the checkout and branch. Keep `linkEnvFiles: false` so a worktree does not share the main checkout's `.env`. Preview the cleanup inside the worktree with `bin/wt teardown --dry-run` first.
|
|
200
253
|
|
|
201
254
|
pi-worktree runs each `postCreate` step with a 120-second timeout. If a first `bundle install` or `db:prepare` takes longer, run `bin/wt setup` again inside the worktree.
|
|
202
255
|
|
|
@@ -247,6 +300,8 @@ New installs and repaired apps use a bash bootstrap wrapper that activates mise
|
|
|
247
300
|
|
|
248
301
|
Worktree names must not contain `/` or whitespace, must not be `.` or `..`, and must be a valid Git ref component.
|
|
249
302
|
|
|
303
|
+
These reserved words are commands, so `bin/wt <word>` never creates or reuses a worktree with that name and auto-picking skips them: `help`, `version`, `doctor`, `setup`, `update`, `prune`, `remove` and `delete`. `bin/wt help` and `bin/wt version` behave like `--help` and `--version`. An existing worktree with such a name (for example one created by `bin/wt help` in an older version) can still be cleaned up with `bin/wt remove help` or set up with `bin/wt setup ../<project>.worktrees/help`.
|
|
304
|
+
|
|
250
305
|
### Configuration
|
|
251
306
|
|
|
252
307
|
The installer generates `config/initializers/rails_worktrees.rb` where you can override:
|
|
@@ -257,6 +312,7 @@ The initializer stays app-owned and gem-agnostic; `rails-worktrees` reads those
|
|
|
257
312
|
|--------|---------|-------------|
|
|
258
313
|
| `bootstrap_env` | `true` | Write `.env` when creating a worktree |
|
|
259
314
|
| `workspace_root` | `nil` | Override the destination root (sibling layout when `nil`) |
|
|
315
|
+
| `worktrees_dir` | `nil` | Create worktrees inside the main checkout, e.g. `'.worktrees'`; see [Worktrees inside the repository](#worktrees-inside-the-repository) |
|
|
260
316
|
| `dev_port_range` | `3000..3999` | Port range for deterministic `DEV_PORT` allocation |
|
|
261
317
|
| `branch_prefix` | `🚂` | Prefix for worktree branch names |
|
|
262
318
|
| `name_sources_path` | bundled `names/` | Directory containing `.txt` name lists |
|
|
@@ -77,14 +77,14 @@ exec ruby /dev/fd/3 "$@" 3<<'RUBY'
|
|
|
77
77
|
def rails_worktrees_bootstrap_safe_invocation?(argv)
|
|
78
78
|
args = argv.map(&:to_s)
|
|
79
79
|
return true if args.empty?
|
|
80
|
-
return true if %w[-h --help -v --version].include?(args.first)
|
|
80
|
+
return true if %w[-h --help help -v --version version].include?(args.first)
|
|
81
81
|
return true if %w[--env --print-env].include?(args.first)
|
|
82
82
|
|
|
83
83
|
command_args = args.reject { |arg| %w[--dry-run --skip-setup --pi --env --print-env].include?(arg) }
|
|
84
84
|
command = command_args.first
|
|
85
85
|
|
|
86
86
|
return true if %w[setup doctor update].include?(command)
|
|
87
|
-
return false if %w[remove delete prune].include?(command)
|
|
87
|
+
return false if %w[remove delete prune teardown].include?(command)
|
|
88
88
|
|
|
89
89
|
command_args.length <= 1
|
|
90
90
|
end
|
|
@@ -5,6 +5,13 @@ Rails.application.config.x.rails_worktrees.tap do |config|
|
|
|
5
5
|
# By default, worktrees go in a sibling "<project>.worktrees" directory.
|
|
6
6
|
# Uncomment to override with a custom parent directory that uses <root>/<project>/<name>.
|
|
7
7
|
# config.workspace_root = File.expand_path('~/worktrees')
|
|
8
|
+
#
|
|
9
|
+
# Or keep worktrees inside this checkout in <app>/.worktrees/<name>, like Pi's pi-worktree
|
|
10
|
+
# extension. wt adds the directory to .git/info/exclude unless .gitignore already covers it.
|
|
11
|
+
# Combine with branch_prefix 'worktree' so wt and Pi share worktree/<name> branch names.
|
|
12
|
+
# Cannot be combined with workspace_root.
|
|
13
|
+
# config.worktrees_dir = '.worktrees'
|
|
14
|
+
# config.branch_prefix = 'worktree'
|
|
8
15
|
<% end -%>
|
|
9
16
|
# config.bootstrap_env = false
|
|
10
17
|
# config.dev_port_range = 3000..3999
|
|
@@ -21,6 +28,7 @@ Rails.application.config.x.rails_worktrees.tap do |config|
|
|
|
21
28
|
# config.post_create_command = nil # nil = built-in steps; false = skip all; 'script/worktree-setup' = custom command
|
|
22
29
|
# config.run_bundle_install = true
|
|
23
30
|
# config.run_yarn_install = true # auto-skipped when yarn.lock is absent
|
|
31
|
+
# config.run_playwright_install = true # installs Playwright Chromium when package.json uses playwright
|
|
24
32
|
# config.run_db_prepare = true
|
|
25
33
|
# config.run_test_db_prepare = true
|
|
26
34
|
# config.run_test_assets_precompile = true
|
data/lib/rails/worktrees/cli.rb
CHANGED
|
@@ -2,7 +2,9 @@ module Rails
|
|
|
2
2
|
module Worktrees
|
|
3
3
|
# Shell entrypoint for the wt executable.
|
|
4
4
|
class CLI
|
|
5
|
-
LOADER_OPTIONAL_COMMANDS = %w[doctor update -h --help -v --version].freeze
|
|
5
|
+
LOADER_OPTIONAL_COMMANDS = %w[doctor update -h --help help -v --version version].freeze
|
|
6
|
+
# Commands that use project configuration when it loads, but must keep working when it does not.
|
|
7
|
+
LOADER_BEST_EFFORT_COMMANDS = %w[doctor].freeze
|
|
6
8
|
LOADER_IGNORED_FLAGS = %w[--dry-run --force --skip-setup --pi].freeze
|
|
7
9
|
SETUP_SUBCOMMAND = 'setup'.freeze
|
|
8
10
|
|
|
@@ -20,7 +22,11 @@ module Rails
|
|
|
20
22
|
|
|
21
23
|
def start
|
|
22
24
|
configuration = ::Rails::Worktrees.configuration
|
|
23
|
-
|
|
25
|
+
if should_load_project_configuration?
|
|
26
|
+
load_project_configuration(configuration)
|
|
27
|
+
elsif best_effort_configuration_command?
|
|
28
|
+
load_project_configuration_best_effort(configuration)
|
|
29
|
+
end
|
|
24
30
|
command_for(configuration).run
|
|
25
31
|
rescue ::Rails::Worktrees::Error => e
|
|
26
32
|
@io.fetch(:stderr).puts("Error: #{e.message}")
|
|
@@ -35,6 +41,17 @@ module Rails
|
|
|
35
41
|
raise ::Rails::Worktrees::Error, "Failed to load worktrees configuration: #{e.class}: #{e.message}"
|
|
36
42
|
end
|
|
37
43
|
|
|
44
|
+
# A broken initializer must not block recovery; wt doctor then reports with default settings.
|
|
45
|
+
def load_project_configuration_best_effort(configuration)
|
|
46
|
+
load_project_configuration(configuration)
|
|
47
|
+
rescue ::Rails::Worktrees::Error
|
|
48
|
+
configuration
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def best_effort_configuration_command?
|
|
52
|
+
LOADER_BEST_EFFORT_COMMANDS.include?(argv_without_flags.first)
|
|
53
|
+
end
|
|
54
|
+
|
|
38
55
|
def configuration_root
|
|
39
56
|
return expand_setup_target_path if explicit_setup_path_target?
|
|
40
57
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Worktrees
|
|
5
|
+
class Command
|
|
6
|
+
# Keeps an in-repo worktrees_dir out of the main checkout's untracked files.
|
|
7
|
+
module InRepoWorktrees
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def in_repo_layout?(workspaces) = workspaces[:layout] == :in_repo
|
|
11
|
+
|
|
12
|
+
def ensure_worktrees_dir_ignored(context)
|
|
13
|
+
workspaces = context[:workspaces]
|
|
14
|
+
return unless in_repo_layout?(workspaces)
|
|
15
|
+
return if worktrees_dir_ignored?(context[:primary_root], workspaces)
|
|
16
|
+
|
|
17
|
+
entry = worktrees_dir_exclude_entry(workspaces)
|
|
18
|
+
exclude_path = info_exclude_path(context[:primary_root])
|
|
19
|
+
return info("Would add '#{entry}' to #{exclude_path}") if dry_run?
|
|
20
|
+
|
|
21
|
+
append_exclude_entry(exclude_path, entry)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def worktrees_dir_ignored?(primary_root, workspaces)
|
|
25
|
+
git_success?('-C', primary_root, 'check-ignore', '-q', "#{workspaces[:relative_dir]}/")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Escapes gitignore metacharacters so the entry matches only the literal directory.
|
|
29
|
+
def worktrees_dir_exclude_entry(workspaces)
|
|
30
|
+
escaped = workspaces[:relative_dir]
|
|
31
|
+
.gsub(/[\\*?\[\]]/) { |char| "\\#{char}" }
|
|
32
|
+
.sub(/ +\z/) { |spaces| spaces.gsub(' ', '\ ') }
|
|
33
|
+
"/#{escaped}/"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def info_exclude_path(primary_root)
|
|
37
|
+
git_path = git_capture('-C', primary_root, 'rev-parse', '--git-path', 'info/exclude').strip
|
|
38
|
+
expand_git_path(git_path, base_dir: primary_root)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def append_exclude_entry(exclude_path, entry)
|
|
42
|
+
FileUtils.mkdir_p(File.dirname(exclude_path))
|
|
43
|
+
separator = missing_trailing_newline?(exclude_path) ? "\n" : ''
|
|
44
|
+
File.open(exclude_path, 'a') { |file| file.write("#{separator}#{entry}\n") }
|
|
45
|
+
info("Added '#{entry}' to #{exclude_path} so the main checkout ignores in-repo worktrees")
|
|
46
|
+
rescue SystemCallError => e
|
|
47
|
+
warning(
|
|
48
|
+
"Could not update #{exclude_path} (#{e.class}); add '#{entry}' to .gitignore " \
|
|
49
|
+
'so the main checkout does not show the worktrees as untracked files.'
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def missing_trailing_newline?(path)
|
|
54
|
+
File.file?(path) && !File.empty?(path) && !File.read(path).end_with?("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def in_repo_worktrees_doctor_check(repository)
|
|
58
|
+
workspaces = repository[:workspaces]
|
|
59
|
+
return unless in_repo_layout?(workspaces)
|
|
60
|
+
|
|
61
|
+
if worktrees_dir_ignored?(repository[:primary_root], workspaces)
|
|
62
|
+
ignored_worktrees_dir_check(workspaces)
|
|
63
|
+
else
|
|
64
|
+
unignored_worktrees_dir_check(workspaces)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def ignored_worktrees_dir_check(workspaces)
|
|
69
|
+
doctor_check(category: :worktree, status: :ok,
|
|
70
|
+
headline: "In-repo worktrees directory #{workspaces[:root]} is ignored by Git.")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def unignored_worktrees_dir_check(workspaces)
|
|
74
|
+
doctor_check(
|
|
75
|
+
category: :worktree,
|
|
76
|
+
status: :warning,
|
|
77
|
+
headline: "In-repo worktrees directory #{workspaces[:root]} is not ignored by Git.",
|
|
78
|
+
messages: ["Add '#{worktrees_dir_exclude_entry(workspaces)}' to .gitignore, " \
|
|
79
|
+
'or create a worktree with wt to add it to .git/info/exclude.']
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -45,7 +45,8 @@ module Rails
|
|
|
45
45
|
|
|
46
46
|
def list_available_names(source_file, project_name, workspaces)
|
|
47
47
|
source_names(source_file).select do |candidate|
|
|
48
|
-
|
|
48
|
+
!RESERVED_WORDS.include?(candidate) && valid_auto_name?(candidate) &&
|
|
49
|
+
available_for_auto_pick?(candidate, project_name, workspaces)
|
|
49
50
|
end
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -8,10 +8,10 @@ module Rails
|
|
|
8
8
|
|
|
9
9
|
def handle_meta_command
|
|
10
10
|
case @argv.first
|
|
11
|
-
when '-h', '--help'
|
|
11
|
+
when '-h', '--help', 'help'
|
|
12
12
|
@stdout.print(usage)
|
|
13
13
|
0
|
|
14
|
-
when '-v', '--version'
|
|
14
|
+
when '-v', '--version', 'version'
|
|
15
15
|
@stdout.puts("wt #{Rails::Worktrees::VERSION}")
|
|
16
16
|
0
|
|
17
17
|
when '--env', '--print-env'
|
|
@@ -30,19 +30,21 @@ module Rails
|
|
|
30
30
|
Create and clean up Git worktrees for the current repository.
|
|
31
31
|
|
|
32
32
|
Usage: wt [worktree-name]
|
|
33
|
+
wt help | wt version
|
|
33
34
|
wt [--skip-setup] [worktree-name]
|
|
34
35
|
wt --dry-run [worktree-name]
|
|
35
36
|
wt --print-env <worktree-name>
|
|
36
37
|
wt doctor
|
|
37
38
|
wt setup [--dry-run] [path|name]
|
|
38
39
|
wt update [--dry-run] [--pi]
|
|
40
|
+
wt teardown [--dry-run]
|
|
39
41
|
wt remove [--dry-run] [--force] <worktree-name>
|
|
40
42
|
wt delete [--dry-run] [--force] <worktree-name>
|
|
41
43
|
wt prune [--dry-run]
|
|
42
44
|
|
|
43
45
|
Options:
|
|
44
|
-
-h, --help
|
|
45
|
-
-v, --version
|
|
46
|
+
-h, --help, help Show this help message
|
|
47
|
+
-v, --version, version Show the script version
|
|
46
48
|
--dry-run [name] Preview worktree creation or cleanup without changing anything
|
|
47
49
|
--skip-setup Create the worktree without running setup steps
|
|
48
50
|
--force Delete an unmerged local branch with wt remove/delete
|
|
@@ -60,6 +62,8 @@ module Rails
|
|
|
60
62
|
wt setup my-feature
|
|
61
63
|
wt setup ../my-project.worktrees/my-feature
|
|
62
64
|
wt update --dry-run
|
|
65
|
+
wt teardown --dry-run
|
|
66
|
+
wt teardown
|
|
63
67
|
wt remove my-feature
|
|
64
68
|
wt remove --force my-feature
|
|
65
69
|
wt prune
|
|
@@ -67,6 +71,8 @@ module Rails
|
|
|
67
71
|
How it works:
|
|
68
72
|
- by default creates worktrees beside the repo in ../<project>.worktrees/<name>
|
|
69
73
|
- when workspace_root or WT_WORKSPACES_ROOT is set, creates worktrees in <root>/<project>/<name>
|
|
74
|
+
- when worktrees_dir is set (for example '.worktrees'), creates worktrees inside the main checkout in <repo>/<worktrees_dir>/<name>
|
|
75
|
+
- reserved words are commands, never new worktree names: #{RESERVED_WORDS.join(', ')}
|
|
70
76
|
- always uses the branch name #{@configuration.branch_prefix}/<name>
|
|
71
77
|
- bases new branches on the repository's origin default branch
|
|
72
78
|
- by default wt <name> both creates the worktree and runs setup automatically
|
|
@@ -74,6 +80,8 @@ module Rails
|
|
|
74
80
|
- wt doctor audits install/config drift plus basic worktree health without changing files
|
|
75
81
|
- wt update applies safe file-based fixes for managed installer artifacts and config hints
|
|
76
82
|
- wt update --pi also writes .pi/worktree.json for the pi-worktree extension (not Pi core)
|
|
83
|
+
- wt teardown stops local Overmind and drops suffix-validated development/test databases, keeping the checkout
|
|
84
|
+
- wt remove/delete run teardown before removing the checkout and local branch
|
|
77
85
|
- wt remove/delete can run from the main checkout or any sibling worktree, but never remove the worktree you're currently in
|
|
78
86
|
- wt prune removes merged worktrees created by wt while skipping the main checkout and the checkout you're in
|
|
79
87
|
- auto-discovers bundled *.txt files from #{@configuration.name_sources_path}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
module Rails
|
|
2
2
|
module Worktrees
|
|
3
3
|
class Command
|
|
4
|
-
# Resolves the default and
|
|
4
|
+
# Resolves the sibling (default), workspace_root and in-repo worktrees_dir path layouts.
|
|
5
5
|
module WorkspacePaths
|
|
6
|
+
INVALID_WORKTREES_DIR_MESSAGE =
|
|
7
|
+
"worktrees_dir must be a relative path inside the repository, like '.worktrees' (got %p).".freeze
|
|
8
|
+
|
|
6
9
|
private
|
|
7
10
|
|
|
8
11
|
def resolve_repository_context
|
|
@@ -41,9 +44,16 @@ module Rails
|
|
|
41
44
|
|
|
42
45
|
def resolve_workspaces(repo_root, project_name)
|
|
43
46
|
explicit_root = configured_workspaces_root
|
|
44
|
-
return { root: explicit_root, uses_default_root: false } if explicit_root
|
|
47
|
+
return { root: explicit_root, layout: :workspace_root, uses_default_root: false } if explicit_root
|
|
48
|
+
|
|
49
|
+
in_repo_dir = configured_worktrees_dir
|
|
50
|
+
if in_repo_dir
|
|
51
|
+
return { root: File.join(repo_root, in_repo_dir), layout: :in_repo, relative_dir: in_repo_dir,
|
|
52
|
+
uses_default_root: false }
|
|
53
|
+
end
|
|
45
54
|
|
|
46
|
-
{ root: File.join(File.dirname(repo_root), "#{project_name}.worktrees"),
|
|
55
|
+
{ root: File.join(File.dirname(repo_root), "#{project_name}.worktrees"), layout: :sibling,
|
|
56
|
+
uses_default_root: true }
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
def configured_workspaces_root
|
|
@@ -51,16 +61,37 @@ module Rails
|
|
|
51
61
|
return expand_home(env_root) if present_path?(env_root)
|
|
52
62
|
|
|
53
63
|
config_root = @configuration.workspace_root
|
|
54
|
-
return
|
|
64
|
+
return unless present_path?(config_root)
|
|
65
|
+
|
|
66
|
+
if present_path?(@configuration.worktrees_dir)
|
|
67
|
+
raise Error, 'Set either workspace_root or worktrees_dir, not both.'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
expand_home(config_root)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def configured_worktrees_dir
|
|
74
|
+
value = @configuration.worktrees_dir
|
|
75
|
+
return unless present_path?(value)
|
|
55
76
|
|
|
56
|
-
|
|
77
|
+
normalized = Pathname(value.to_s).cleanpath
|
|
78
|
+
raise Error, format(INVALID_WORKTREES_DIR_MESSAGE, value) unless valid_worktrees_dir?(normalized)
|
|
79
|
+
|
|
80
|
+
normalized.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def valid_worktrees_dir?(path)
|
|
84
|
+
first_component = path.each_filename.first
|
|
85
|
+
|
|
86
|
+
path.relative? && !path.to_s.start_with?('~') &&
|
|
87
|
+
!path.each_filename.include?('..') && !['.', '.git'].include?(first_component)
|
|
57
88
|
end
|
|
58
89
|
|
|
59
90
|
def target_dir_for(project_name, worktree_name, workspaces)
|
|
60
|
-
if workspaces[:
|
|
61
|
-
File.join(workspaces[:root], worktree_name)
|
|
62
|
-
else
|
|
91
|
+
if workspaces[:layout] == :workspace_root
|
|
63
92
|
File.join(workspaces[:root], project_name, worktree_name)
|
|
93
|
+
else
|
|
94
|
+
File.join(workspaces[:root], worktree_name)
|
|
64
95
|
end
|
|
65
96
|
end
|
|
66
97
|
|
|
@@ -85,7 +116,7 @@ module Rails
|
|
|
85
116
|
end
|
|
86
117
|
|
|
87
118
|
def present_path?(path)
|
|
88
|
-
!path.nil? && !path.empty?
|
|
119
|
+
!path.nil? && !path.to_s.empty?
|
|
89
120
|
end
|
|
90
121
|
|
|
91
122
|
def expand_home(path)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require 'fileutils'
|
|
2
2
|
require_relative 'command/environment_support'
|
|
3
3
|
require_relative 'command/git_operations'
|
|
4
|
+
require_relative 'command/in_repo_worktrees'
|
|
4
5
|
require_relative 'command/name_picking'
|
|
5
6
|
require_relative 'command/output'
|
|
6
7
|
require_relative 'command/post_create_support'
|
|
@@ -15,10 +16,13 @@ module Rails
|
|
|
15
16
|
DOCTOR_SUBCOMMAND = 'doctor'.freeze
|
|
16
17
|
SETUP_SUBCOMMAND = 'setup'.freeze
|
|
17
18
|
UPDATE_SUBCOMMAND = 'update'.freeze
|
|
18
|
-
PI_FLAG_EXEMPT_META_COMMANDS = %w[-h --help -v --version].freeze
|
|
19
|
+
PI_FLAG_EXEMPT_META_COMMANDS = %w[-h --help help -v --version version].freeze
|
|
20
|
+
# Command words that wt never treats as a worktree name.
|
|
21
|
+
RESERVED_WORDS = %w[help version doctor setup update teardown prune remove delete].freeze
|
|
19
22
|
|
|
20
23
|
include GitOperations
|
|
21
24
|
include EnvironmentSupport
|
|
25
|
+
include InRepoWorktrees
|
|
22
26
|
include NamePicking
|
|
23
27
|
include Output
|
|
24
28
|
include PostCreateSupport
|
|
@@ -115,6 +119,7 @@ module Rails
|
|
|
115
119
|
when SETUP_SUBCOMMAND then -> { execute_setup_command }
|
|
116
120
|
when UPDATE_SUBCOMMAND then -> { execute_update_command }
|
|
117
121
|
when 'prune' then -> { execute_prune_command }
|
|
122
|
+
when 'teardown' then -> { execute_teardown_command }
|
|
118
123
|
else
|
|
119
124
|
-> { execute_remove_command } if remove_subcommand?
|
|
120
125
|
end
|
|
@@ -135,11 +140,16 @@ module Rails
|
|
|
135
140
|
confirm_and_remove_target(context[:target_dir])
|
|
136
141
|
end
|
|
137
142
|
|
|
138
|
-
|
|
143
|
+
prepare_worktree_location(context)
|
|
139
144
|
attach_or_create_worktree(context)
|
|
140
145
|
finish(context)
|
|
141
146
|
end
|
|
142
147
|
|
|
148
|
+
def prepare_worktree_location(context)
|
|
149
|
+
ensure_worktrees_dir_ignored(context)
|
|
150
|
+
prepare_target_parent(context[:target_dir])
|
|
151
|
+
end
|
|
152
|
+
|
|
143
153
|
def execute_setup_command
|
|
144
154
|
validate_setup_args!
|
|
145
155
|
announce_dry_run if dry_run?
|
|
@@ -150,6 +160,25 @@ module Rails
|
|
|
150
160
|
complete_setup_flow(context, success_message: 'Setup complete')
|
|
151
161
|
end
|
|
152
162
|
|
|
163
|
+
def execute_teardown_command
|
|
164
|
+
raise Error, 'Usage: wt teardown [--dry-run]' unless @argv.length == 1 && !force? && !skip_setup?
|
|
165
|
+
|
|
166
|
+
require_git_repo
|
|
167
|
+
repository = resolve_repository_context
|
|
168
|
+
if repository[:current_root] == repository[:primary_root]
|
|
169
|
+
raise Error, 'Cannot teardown the main checkout. Run wt teardown inside a linked worktree.'
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
teardown_worktree(repository[:current_root], use_current_env: true)
|
|
173
|
+
success(dry_run? ? 'Dry run complete' : 'Teardown complete')
|
|
174
|
+
0
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def teardown_worktree(target_dir, use_current_env: false)
|
|
178
|
+
TeardownRunner.new(target_dir: target_dir, env: @env, io: { stdout: @stdout },
|
|
179
|
+
use_current_env: use_current_env).call(dry_run: dry_run?)
|
|
180
|
+
end
|
|
181
|
+
|
|
153
182
|
def execute_remove_command
|
|
154
183
|
require_git_repo
|
|
155
184
|
announce_dry_run if dry_run?
|
|
@@ -159,6 +188,7 @@ module Rails
|
|
|
159
188
|
removal_status = removal_status_for(context)
|
|
160
189
|
|
|
161
190
|
ensure_removable!(context, **removal_status)
|
|
191
|
+
teardown_worktree(context[:target_dir]) if removal_status[:worktree_exists]
|
|
162
192
|
return complete_remove_dry_run(context, **removal_status) if dry_run?
|
|
163
193
|
|
|
164
194
|
perform_remove(context, **removal_status)
|
|
@@ -212,8 +242,8 @@ module Rails
|
|
|
212
242
|
return 1
|
|
213
243
|
end
|
|
214
244
|
|
|
215
|
-
|
|
216
|
-
checks = project_maintenance_report(
|
|
245
|
+
current_root = canonical_path(git_capture('rev-parse', '--show-toplevel').strip)
|
|
246
|
+
checks = project_maintenance_report(current_root).checks + doctor_worktree_checks(current_root)
|
|
217
247
|
print_doctor_report(checks)
|
|
218
248
|
|
|
219
249
|
checks.any? { |check| check.fixable? || check.warning? } ? 1 : 0
|
|
@@ -653,16 +683,23 @@ module Rails
|
|
|
653
683
|
::Rails::Worktrees::ProjectMaintenance.new(root: root, pi_worktree: pi?).call
|
|
654
684
|
end
|
|
655
685
|
|
|
656
|
-
def doctor_worktree_checks(
|
|
686
|
+
def doctor_worktree_checks(current_root)
|
|
657
687
|
[
|
|
658
688
|
doctor_check(
|
|
659
689
|
category: :git,
|
|
660
690
|
status: :ok,
|
|
661
|
-
headline: "Git repository detected at #{
|
|
691
|
+
headline: "Git repository detected at #{current_root}."
|
|
662
692
|
),
|
|
663
693
|
default_branch_doctor_check,
|
|
664
|
-
stale_worktree_doctor_check
|
|
665
|
-
|
|
694
|
+
stale_worktree_doctor_check,
|
|
695
|
+
worktree_layout_doctor_check
|
|
696
|
+
].compact
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def worktree_layout_doctor_check
|
|
700
|
+
in_repo_worktrees_doctor_check(resolve_repository_context)
|
|
701
|
+
rescue Error => e
|
|
702
|
+
doctor_check(category: :worktree, status: :warning, headline: e.message)
|
|
666
703
|
end
|
|
667
704
|
|
|
668
705
|
def default_branch_doctor_check
|
|
@@ -5,6 +5,7 @@ module Rails
|
|
|
5
5
|
CONFIGURABLE_ATTRIBUTES = %i[
|
|
6
6
|
bootstrap_env
|
|
7
7
|
workspace_root
|
|
8
|
+
worktrees_dir
|
|
8
9
|
dev_port_range
|
|
9
10
|
branch_prefix
|
|
10
11
|
name_sources_path
|
|
@@ -13,6 +14,7 @@ module Rails
|
|
|
13
14
|
post_create_command
|
|
14
15
|
run_bundle_install
|
|
15
16
|
run_yarn_install
|
|
17
|
+
run_playwright_install
|
|
16
18
|
run_db_prepare
|
|
17
19
|
run_test_db_prepare
|
|
18
20
|
run_test_assets_precompile
|
|
@@ -33,10 +35,10 @@ module Rails
|
|
|
33
35
|
DEFAULT_WORKTREE_DATABASE_SUFFIX_MAX_LENGTH = 18
|
|
34
36
|
|
|
35
37
|
attr_accessor :bootstrap_env, :branch_prefix, :dev_port_range, :legacy_used_names_files,
|
|
36
|
-
:name_sources_path, :used_names_file, :workspace_root,
|
|
38
|
+
:name_sources_path, :used_names_file, :workspace_root, :worktrees_dir,
|
|
37
39
|
:worktree_database_suffix_max_length,
|
|
38
40
|
:post_create_command,
|
|
39
|
-
:run_bundle_install, :run_yarn_install,
|
|
41
|
+
:run_bundle_install, :run_yarn_install, :run_playwright_install,
|
|
40
42
|
:run_db_prepare, :run_test_db_prepare, :run_test_assets_precompile,
|
|
41
43
|
:link_credential_keys, :link_test_credential_key, :link_production_credential_key
|
|
42
44
|
|
|
@@ -50,6 +52,7 @@ module Rails
|
|
|
50
52
|
def assign_core_defaults
|
|
51
53
|
@bootstrap_env = DEFAULT_BOOTSTRAP_ENV
|
|
52
54
|
@workspace_root = nil
|
|
55
|
+
@worktrees_dir = nil
|
|
53
56
|
@branch_prefix = DEFAULT_BRANCH_PREFIX
|
|
54
57
|
@dev_port_range = DEFAULT_DEV_PORT_RANGE
|
|
55
58
|
@name_sources_path = DEFAULT_NAME_SOURCES_PATH
|
|
@@ -62,6 +65,7 @@ module Rails
|
|
|
62
65
|
@post_create_command = nil
|
|
63
66
|
@run_bundle_install = true
|
|
64
67
|
@run_yarn_install = true
|
|
68
|
+
@run_playwright_install = true
|
|
65
69
|
@run_db_prepare = true
|
|
66
70
|
@run_test_db_prepare = true
|
|
67
71
|
@run_test_assets_precompile = true
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'open3'
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Worktrees
|
|
6
|
+
# Decides whether a worktree needs the Playwright Chromium build that matches
|
|
7
|
+
# the playwright package pinned in its package.json.
|
|
8
|
+
class PlaywrightBrowserCheck
|
|
9
|
+
PACKAGES = %w[playwright @playwright/test].freeze
|
|
10
|
+
DEPENDENCY_SECTIONS = %w[dependencies devDependencies].freeze
|
|
11
|
+
CLI_PATH = 'node_modules/.bin/playwright'.freeze
|
|
12
|
+
BROWSER = 'chromium'.freeze
|
|
13
|
+
INSTALL_LOCATION = /^\s*Install location:\s*(.+?)\s*$/
|
|
14
|
+
# Playwright writes this marker once a browser download has been fully extracted.
|
|
15
|
+
INSTALLATION_MARKER = 'INSTALLATION_COMPLETE'.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(target_dir:, env:)
|
|
18
|
+
@target_dir = target_dir
|
|
19
|
+
@env = env
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def applicable?
|
|
23
|
+
package = JSON.parse(File.read(File.join(@target_dir, 'package.json')))
|
|
24
|
+
DEPENDENCY_SECTIONS.any? do |section|
|
|
25
|
+
dependencies = package[section]
|
|
26
|
+
dependencies.is_a?(Hash) && PACKAGES.any? { |name| dependencies.key?(name) }
|
|
27
|
+
end
|
|
28
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cli_present?
|
|
33
|
+
File.exist?(File.join(@target_dir, CLI_PATH))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns false whenever the installed state cannot be proven.
|
|
37
|
+
def installed?
|
|
38
|
+
locations = install_locations
|
|
39
|
+
!locations.empty? && locations.all? { |location| File.file?(File.join(location, INSTALLATION_MARKER)) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def install_locations
|
|
45
|
+
return [] unless cli_present?
|
|
46
|
+
|
|
47
|
+
output, status = Open3.capture2e(@env, CLI_PATH, 'install', '--dry-run', BROWSER, chdir: @target_dir)
|
|
48
|
+
return [] unless status.success?
|
|
49
|
+
|
|
50
|
+
output.scan(INSTALL_LOCATION).flatten
|
|
51
|
+
rescue SystemCallError
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -3,7 +3,7 @@ require 'open3'
|
|
|
3
3
|
module Rails
|
|
4
4
|
module Worktrees
|
|
5
5
|
# Runs post-create setup steps in a newly created worktree.
|
|
6
|
-
# Steps run in order: credential linking, bundle install, yarn install,
|
|
6
|
+
# Steps run in order: credential linking, bundle install, yarn install, Playwright Chromium install,
|
|
7
7
|
# db:prepare (development), db:prepare (test), assets:precompile (test), assets:clobber.
|
|
8
8
|
# rubocop:disable Metrics/ClassLength
|
|
9
9
|
class PostCreateRunner
|
|
@@ -12,6 +12,8 @@ module Rails
|
|
|
12
12
|
header: '📦 Installing gems...' },
|
|
13
13
|
{ id: :yarn, argv: %w[yarn install],
|
|
14
14
|
header: '🧶 Installing JS dependencies...' },
|
|
15
|
+
{ id: :playwright, argv: [PlaywrightBrowserCheck::CLI_PATH, 'install', PlaywrightBrowserCheck::BROWSER],
|
|
16
|
+
header: '🎭 Installing Playwright Chromium...' },
|
|
15
17
|
{ id: :db_prepare, argv: %w[bin/rails db:prepare],
|
|
16
18
|
header: '🗄️ Preparing development database...', env: { 'RAILS_ENV' => 'development' } },
|
|
17
19
|
{ id: :test_db_prepare, argv: %w[bin/rails db:prepare],
|
|
@@ -25,6 +27,7 @@ module Rails
|
|
|
25
27
|
STEP_CONFIG = {
|
|
26
28
|
bundle: :run_bundle_install,
|
|
27
29
|
yarn: :run_yarn_install,
|
|
30
|
+
playwright: :run_playwright_install,
|
|
28
31
|
db_prepare: :run_db_prepare,
|
|
29
32
|
test_db_prepare: :run_test_db_prepare,
|
|
30
33
|
test_assets_precompile: :run_test_assets_precompile,
|
|
@@ -78,15 +81,52 @@ module Rails
|
|
|
78
81
|
STEPS.each do |step|
|
|
79
82
|
config_attr = STEP_CONFIG[step[:id]]
|
|
80
83
|
next unless @configuration.public_send(config_attr)
|
|
81
|
-
next
|
|
84
|
+
next unless step_applicable?(step)
|
|
82
85
|
|
|
83
|
-
result =
|
|
86
|
+
result = run_built_in_step(step, dry_run: dry_run)
|
|
84
87
|
return result unless result.zero?
|
|
85
88
|
end
|
|
86
89
|
|
|
87
90
|
0
|
|
88
91
|
end
|
|
89
92
|
|
|
93
|
+
def step_applicable?(step)
|
|
94
|
+
case step[:id]
|
|
95
|
+
when :yarn then yarn_lock_present?
|
|
96
|
+
when :playwright then yarn_lock_present? && playwright_browser_check.applicable?
|
|
97
|
+
else true
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def run_built_in_step(step, dry_run:)
|
|
102
|
+
return run_playwright_step(step, dry_run: dry_run) if step[:id] == :playwright
|
|
103
|
+
|
|
104
|
+
run_step(step, dry_run: dry_run)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Always runs the real installer: it skips cached downloads itself and registers this
|
|
108
|
+
# worktree in the shared browser cache, so a later cleanup keeps its browsers.
|
|
109
|
+
def run_playwright_step(step, dry_run:)
|
|
110
|
+
check = playwright_browser_check
|
|
111
|
+
return preview_playwright_step(step, check) if dry_run
|
|
112
|
+
|
|
113
|
+
unless check.cli_present?
|
|
114
|
+
info("⚠️ Skipping Playwright Chromium: #{PlaywrightBrowserCheck::CLI_PATH} was not found.")
|
|
115
|
+
return 0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
run_step(step, dry_run: false)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def preview_playwright_step(step, check)
|
|
122
|
+
info('🎭 Playwright Chromium is already installed; no download needed.') if check.installed?
|
|
123
|
+
run_step(step, dry_run: true)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def playwright_browser_check
|
|
127
|
+
PlaywrightBrowserCheck.new(target_dir: @target_dir, env: runtime_env)
|
|
128
|
+
end
|
|
129
|
+
|
|
90
130
|
def run_credential_linking(dry_run:)
|
|
91
131
|
# Missing peer key files are intentionally best-effort and should not abort
|
|
92
132
|
# worktree setup; the linker reports those cases in its messages while real
|
|
@@ -158,7 +198,8 @@ module Rails
|
|
|
158
198
|
'BUNDLE_GEMFILE', 'BUNDLE_PATH', 'GEM_HOME', 'GEM_PATH',
|
|
159
199
|
'RUBY_VERSION', 'RAILS_ENV', 'NODE_ENV',
|
|
160
200
|
'XDG_STATE_HOME', 'XDG_DATA_HOME', 'XDG_CONFIG_HOME',
|
|
161
|
-
'DEV_PORT', 'WORKTREE_DATABASE_SUFFIX'
|
|
201
|
+
'DEV_PORT', 'WORKTREE_DATABASE_SUFFIX',
|
|
202
|
+
'PLAYWRIGHT_BROWSERS_PATH')
|
|
162
203
|
end
|
|
163
204
|
|
|
164
205
|
attr_reader :runtime_env
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
require 'open3'
|
|
2
|
+
require 'tempfile'
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Worktrees
|
|
6
|
+
# Runs teardown in the target app, without borrowing the caller's suffix or bundle.
|
|
7
|
+
class TeardownRunner
|
|
8
|
+
RUNTIME_KEYS = %w[PATH HOME USER LOGNAME SHELL LANG LANGUAGE LC_ALL LC_CTYPE TMPDIR TMP TEMP
|
|
9
|
+
GEM_HOME GEM_PATH LD_LIBRARY_PATH SSL_CERT_FILE SSL_CERT_DIR].freeze
|
|
10
|
+
RUBY_LOADER_KEYS = %w[RUBYOPT RUBYLIB].freeze
|
|
11
|
+
SUFFIX_KEY = 'WORKTREE_DATABASE_SUFFIX'.freeze
|
|
12
|
+
SCRIPT = File.expand_path('teardown_script.rb', __dir__).freeze
|
|
13
|
+
|
|
14
|
+
def initialize(target_dir:, env:, io:, use_current_env: false)
|
|
15
|
+
@target_dir = target_dir
|
|
16
|
+
@env = env.to_h
|
|
17
|
+
@stdout = io.fetch(:stdout)
|
|
18
|
+
@use_current_env = use_current_env
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(dry_run: false)
|
|
22
|
+
env = runtime_env
|
|
23
|
+
validate_environment!(env)
|
|
24
|
+
Tempfile.create('rails-worktrees-teardown') do |plan|
|
|
25
|
+
run_teardown(env, plan.path, dry_run: dry_run)
|
|
26
|
+
end
|
|
27
|
+
rescue SystemCallError => e
|
|
28
|
+
raise Error, "Could not start teardown: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def run_teardown(env, plan_path, dry_run:)
|
|
34
|
+
run(env.merge('RAILS_ENV' => 'test', 'RACK_ENV' => 'test'),
|
|
35
|
+
[rails_bin, 'runner', SCRIPT, '--export-test-configs', plan_path])
|
|
36
|
+
argv = [rails_bin, 'runner', SCRIPT, '--test-configs', plan_path]
|
|
37
|
+
argv << '--dry-run' if dry_run
|
|
38
|
+
run(env, argv)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_environment!(env)
|
|
42
|
+
if env[SUFFIX_KEY].to_s.strip.empty?
|
|
43
|
+
raise Error, 'WORKTREE_DATABASE_SUFFIX must not be empty; refusing teardown.'
|
|
44
|
+
end
|
|
45
|
+
raise Error, "Expected #{@target_dir}/bin/rails for teardown." unless File.file?(rails_bin)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run(env, argv)
|
|
49
|
+
Open3.popen2e(env, *argv, chdir: @target_dir, unsetenv_others: true) do |stdin, output, wait|
|
|
50
|
+
stdin.close
|
|
51
|
+
output.each_line { |line| @stdout.print(line) }
|
|
52
|
+
raise Error, 'Teardown failed; worktree and branch were kept.' unless wait.value.success?
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def rails_bin = File.join(@target_dir, 'bin', 'rails')
|
|
57
|
+
|
|
58
|
+
def runtime_env
|
|
59
|
+
source = @use_current_env ? @env : @env.slice(*RUNTIME_KEYS)
|
|
60
|
+
env = source.reject { |key, _| key.start_with?('BUNDLE_', 'GIT_') || RUBY_LOADER_KEYS.include?(key) }
|
|
61
|
+
suffix = file_suffix
|
|
62
|
+
env[SUFFIX_KEY] = suffix unless suffix.nil?
|
|
63
|
+
env.merge('RAILS_ENV' => 'development', 'RACK_ENV' => 'development',
|
|
64
|
+
'BUNDLE_GEMFILE' => File.join(@target_dir, 'Gemfile'))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def file_suffix
|
|
68
|
+
path = File.join(@target_dir, '.env')
|
|
69
|
+
return unless File.file?(path)
|
|
70
|
+
|
|
71
|
+
line = File.readlines(path, chomp: true).reverse.find do |entry|
|
|
72
|
+
entry.match?(/\A\s*(?:export\s+)?WORKTREE_DATABASE_SUFFIX\s*=/)
|
|
73
|
+
end
|
|
74
|
+
return unless line
|
|
75
|
+
|
|
76
|
+
value = line.split('=', 2).last.strip
|
|
77
|
+
match = value.match(/\A(?:'([^']*)'|"([^"]*)"|([^\s#'"$`]*))\s*(?:#.*)?\z/)
|
|
78
|
+
raise Error, 'Cannot safely read WORKTREE_DATABASE_SUFFIX from .env; use a literal value.' unless match
|
|
79
|
+
|
|
80
|
+
match.captures.compact.first
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
# Executed by the target app's Rails runner, first in test, then in development.
|
|
4
|
+
module Rails
|
|
5
|
+
module Worktrees
|
|
6
|
+
# Validates the complete Rails database plan before applying any destructive effects.
|
|
7
|
+
class DatabaseTeardown
|
|
8
|
+
class SafetyError < StandardError; end
|
|
9
|
+
|
|
10
|
+
def call(test_plan:, export: false, dry_run: false)
|
|
11
|
+
suffix = ENV.fetch('WORKTREE_DATABASE_SUFFIX', '')
|
|
12
|
+
raise SafetyError, 'WORKTREE_DATABASE_SUFFIX must not be empty; refusing teardown.' if suffix.strip.empty?
|
|
13
|
+
|
|
14
|
+
return export_test_configs(test_plan, suffix) if export
|
|
15
|
+
|
|
16
|
+
development = database_configs('development')
|
|
17
|
+
databases = development + test_configs(test_plan, suffix)
|
|
18
|
+
validate_databases!(databases, suffix)
|
|
19
|
+
check_protection!(development)
|
|
20
|
+
stop_overmind(dry_run: dry_run)
|
|
21
|
+
drop_databases(databases, dry_run: dry_run)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def database_configs(environment)
|
|
27
|
+
unless defined?(ActiveRecord::Base)
|
|
28
|
+
raise SafetyError, 'ActiveRecord is required to inspect development/test databases.'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
configs = ActiveRecord::Base.configurations.configs_for(env_name: environment)
|
|
32
|
+
raise SafetyError, "No database configurations found for #{environment}; refusing teardown." if configs.empty?
|
|
33
|
+
|
|
34
|
+
configs
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def export_test_configs(path, suffix)
|
|
38
|
+
configs = database_configs('test')
|
|
39
|
+
validate_databases!(configs, suffix)
|
|
40
|
+
check_protection!(configs)
|
|
41
|
+
File.write(path, JSON.generate('suffix' => suffix, 'configs' => config_hash(configs)))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_configs(path, suffix)
|
|
45
|
+
plan = JSON.parse(File.read(path))
|
|
46
|
+
if plan['suffix'] != suffix
|
|
47
|
+
raise SafetyError,
|
|
48
|
+
'Test environment changed WORKTREE_DATABASE_SUFFIX; refusing teardown.'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
ActiveRecord::DatabaseConfigurations.new('test' => plan.fetch('configs')).configs_for(env_name: 'test')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def config_hash(configs)
|
|
55
|
+
configs.to_h { |config| [config.name, config.configuration_hash] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_protection!(databases)
|
|
59
|
+
original = ActiveRecord::Base.configurations
|
|
60
|
+
existing = existing_databases(databases)
|
|
61
|
+
# Keep the resolved objects: rebuilding hashes would reapply environment URLs to explicit YAML URLs.
|
|
62
|
+
ActiveRecord::Base.configurations = existing
|
|
63
|
+
ENV.delete('DISABLE_DATABASE_ENVIRONMENT_CHECK')
|
|
64
|
+
existing.map(&:env_name).uniq.each do |environment|
|
|
65
|
+
ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!(environment)
|
|
66
|
+
end
|
|
67
|
+
ensure
|
|
68
|
+
ActiveRecord::Base.configurations = original if original
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def existing_databases(databases)
|
|
72
|
+
# SQLite opens would create absent files even in a dry-run. Absent files have no protected metadata.
|
|
73
|
+
databases.reject { |config| config.adapter == 'sqlite3' && !File.exist?(config.database) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_databases!(databases, suffix)
|
|
77
|
+
databases.each do |config|
|
|
78
|
+
# A suffix in a SQLite directory does not protect an unsuffixed database file.
|
|
79
|
+
next if File.basename(config.database.to_s).include?(suffix)
|
|
80
|
+
|
|
81
|
+
raise SafetyError,
|
|
82
|
+
"Database '#{config.database}' (#{config.env_name}/#{config.name}) does not contain " \
|
|
83
|
+
"WORKTREE_DATABASE_SUFFIX '#{suffix}'; refusing teardown."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def drop_databases(databases, dry_run:)
|
|
88
|
+
databases.each do |config|
|
|
89
|
+
puts "#{dry_run ? 'Would drop' : 'Dropping'} database '#{config.database}' " \
|
|
90
|
+
"(#{config.env_name}/#{config.name})"
|
|
91
|
+
ActiveRecord::Tasks::DatabaseTasks.drop(config) unless dry_run
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def stop_overmind(dry_run:)
|
|
96
|
+
return unless File.exist?('.overmind.sock')
|
|
97
|
+
|
|
98
|
+
puts "#{dry_run ? 'Would stop' : 'Stopping'} Overmind at #{File.expand_path('.overmind.sock')}"
|
|
99
|
+
return if dry_run
|
|
100
|
+
return if system('overmind', 'quit', '--socket', '.overmind.sock')
|
|
101
|
+
|
|
102
|
+
raise SafetyError, 'Could not stop Overmind; databases were kept.'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
begin
|
|
109
|
+
export = ARGV.first == '--export-test-configs'
|
|
110
|
+
Rails::Worktrees::DatabaseTeardown.new.call(test_plan: ARGV.fetch(1), export: export,
|
|
111
|
+
dry_run: ARGV.include?('--dry-run'))
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
warn "Teardown failed: #{e.message}"
|
|
114
|
+
exit 1
|
|
115
|
+
end
|
data/lib/rails/worktrees.rb
CHANGED
|
@@ -6,7 +6,9 @@ require_relative 'worktrees/application_configuration'
|
|
|
6
6
|
require_relative 'worktrees/mise_environment'
|
|
7
7
|
require_relative 'worktrees/env_bootstrapper'
|
|
8
8
|
require_relative 'worktrees/credential_key_linker'
|
|
9
|
+
require_relative 'worktrees/playwright_browser_check'
|
|
9
10
|
require_relative 'worktrees/post_create_runner'
|
|
11
|
+
require_relative 'worktrees/teardown_runner'
|
|
10
12
|
require_relative 'worktrees/command'
|
|
11
13
|
require_relative 'worktrees/cli'
|
|
12
14
|
require_relative 'worktrees/browser_command'
|
data/sig/rails/worktrees.rbs
CHANGED
|
@@ -8,6 +8,7 @@ module Rails
|
|
|
8
8
|
class Configuration
|
|
9
9
|
attr_accessor bootstrap_env: bool
|
|
10
10
|
attr_accessor workspace_root: String?
|
|
11
|
+
attr_accessor worktrees_dir: String?
|
|
11
12
|
attr_accessor branch_prefix: String
|
|
12
13
|
attr_accessor dev_port_range: ::Range[Integer]
|
|
13
14
|
attr_accessor name_sources_path: String
|
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.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Asjer Querido
|
|
@@ -61,6 +61,7 @@ files:
|
|
|
61
61
|
- lib/rails/worktrees/command.rb
|
|
62
62
|
- lib/rails/worktrees/command/environment_support.rb
|
|
63
63
|
- lib/rails/worktrees/command/git_operations.rb
|
|
64
|
+
- lib/rails/worktrees/command/in_repo_worktrees.rb
|
|
64
65
|
- lib/rails/worktrees/command/name_picking.rb
|
|
65
66
|
- lib/rails/worktrees/command/output.rb
|
|
66
67
|
- lib/rails/worktrees/command/post_create_support.rb
|
|
@@ -75,12 +76,15 @@ files:
|
|
|
75
76
|
- lib/rails/worktrees/names/cities.txt
|
|
76
77
|
- lib/rails/worktrees/pi_worktree_config_path.rb
|
|
77
78
|
- lib/rails/worktrees/pi_worktree_config_updater.rb
|
|
79
|
+
- lib/rails/worktrees/playwright_browser_check.rb
|
|
78
80
|
- lib/rails/worktrees/post_create_runner.rb
|
|
79
81
|
- lib/rails/worktrees/procfile_updater.rb
|
|
80
82
|
- lib/rails/worktrees/project_configuration_loader.rb
|
|
81
83
|
- lib/rails/worktrees/project_maintenance.rb
|
|
82
84
|
- lib/rails/worktrees/puma_config_updater.rb
|
|
83
85
|
- lib/rails/worktrees/railtie.rb
|
|
86
|
+
- lib/rails/worktrees/teardown_runner.rb
|
|
87
|
+
- lib/rails/worktrees/teardown_script.rb
|
|
84
88
|
- lib/rails/worktrees/version.rb
|
|
85
89
|
- mise.toml
|
|
86
90
|
- release-please-config.json
|