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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f0a2cb7e1644d4ec842daf14fcdf96d6b53d810e548d908a6ea651df4ebaface
4
- data.tar.gz: 05ae0690974fed85a0862b0fc296681d7de1b037c03d973bef746a1872b62f41
3
+ metadata.gz: 6f0667430464dedbea994c715a32e64f226048502f6037186f3c90692c5df6e5
4
+ data.tar.gz: 1f47027f6cdc16431356c43e4809d801a7537db1558d3ab9b25c1299874d1389
5
5
  SHA512:
6
- metadata.gz: d23f7e9e0e070624ebff11c04f050a88e11ea43b61b893139d337c3df992a4ac3642db5344c491923ee36e94b6864d903dec72cbe8bbbf16c899dd3714bad6c7
7
- data.tar.gz: 919e046dd0c73ad18ae90a9dc6745dbe5484f00766c8c58302f4981fa44b9e0a730455fa9eb5c7c07b929d02abb1c448e150e587495941ac4c8a9dcc20c5dc73
6
+ metadata.gz: 81a835017246e1cab5fd6acf6ef311b4f9beef54912675602fd454c8ed2aa56904b514be908f69cdc2b84110ec8c38d9d49c9abbc974eea1b309aec2fed3c3f9
7
+ data.tar.gz: 5aad32825be11a64a0095233ed4068eda727da0af5ca0456657e5a4f0aa33f8aa04c0a6a48929a0e7abe97cbbbff9197aa49845c5b6a4acdf207d6c48e8e0796
@@ -1 +1 @@
1
- {".":"0.8.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 remove my-feature # remove a worktree and delete its local branch
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 remove <name>` — remove the named worktree and delete its local branch
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
- If you want to preview a single removal first, use `--dry-run` with `bin/wt remove`:
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, so add that directory to `.gitignore`.
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
@@ -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
- load_project_configuration(configuration) if should_load_project_configuration?
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
- valid_auto_name?(candidate) && available_for_auto_pick?(candidate, project_name, workspaces)
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 Show this help message
45
- -v, --version Show the script 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 explicit workspace path layouts.
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"), uses_default_root: true }
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 expand_home(config_root) if present_path?(config_root)
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
- nil
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[:uses_default_root]
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
- prepare_target_parent(context[:target_dir])
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
- repository = resolve_repository_context
216
- checks = project_maintenance_report(repository[:current_root]).checks + doctor_worktree_checks(repository)
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(repository)
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 #{repository[:current_root]}."
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 if step[:id] == :yarn && !yarn_lock_present?
84
+ next unless step_applicable?(step)
82
85
 
83
- result = run_step(step, dry_run: dry_run)
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
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Worktrees
3
- VERSION = '0.8.0'.freeze
3
+ VERSION = '0.9.0'.freeze
4
4
  end
5
5
  end
@@ -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'
@@ -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.8.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