copse 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '08369533669364d4fdb1d9f8f3b634392c4ac690dceeecd0b236965bccbffa26'
4
+ data.tar.gz: b19a227b24020e7564a1789840ae0e0401ad0cef97f9c0f2a5f5d95067de62f6
5
+ SHA512:
6
+ metadata.gz: 3543263f409eda9b9dd5960250ab2d3a851af4868bad5d5b493a91b91886bacf6b8ebafbe9c5c4d470b19bce7eeab0423c0ee1710971e6a40a023a1299f14088
7
+ data.tar.gz: 38792c0ba1a9e9155ba9f11acf9ab7b48823953d11a8e60a1101faef7513735bb547aac492f533d42396a353614a33333f601645dca4398cc910fa4a50786921
data/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-07-24)
4
+
5
+ First release.
6
+
7
+ - Derives a hostname per app and per git worktree: `<project>.localhost` for a
8
+ main worktree, `<branch>.<project>.localhost` for a linked one, falling back to
9
+ the directory name on a detached `HEAD` or outside git entirely.
10
+ - Derives a stable port from the hostname — `CRC32` indexed into `3000..9999`
11
+ minus 25 well-known service ports. A pure function, identical across machines,
12
+ terminals, reboots, and Ruby versions, with no stored state and no coordination.
13
+ - Runs the `web` process in the foreground with the terminal's stdin, stdout, and
14
+ stderr, so `binding.irb` and `debug` work as they do under a bare
15
+ `rails server`. Remaining `Procfile.dev` entries run under foreman and are
16
+ terminated when the foreground process exits.
17
+ - Exports `PORT`, `COPSE_PORT`, `COPSE_HOST`, `COPSE_URL`, and `VITE_RUBY_PORT`.
18
+ Non-`web` processes should read `COPSE_PORT`: foreman rewrites `PORT` per child.
19
+ - Sets `default_url_options` for routes and Action Mailer in development, only
20
+ when Copse booted the app and only when the app has not set its own `host`.
21
+ - Gives a linked worktree its own development database, derived from the same slug
22
+ as the hostname: `cora_development` becomes `cora_development_fix_billing`. A
23
+ main worktree keeps the database it already has, and `config/database.yml` is
24
+ never modified — the loaded configuration is renamed at boot. Development only;
25
+ file-backed databases (SQLite) and entries marked `database_tasks: false` are
26
+ left alone, replicas follow their primary, and names are capped at 63 bytes.
27
+ Copse does not create the database: run `bin/rails db:prepare`. The suffix is
28
+ derived from the checkout on disk rather than from Copse's own environment, so
29
+ `db:prepare` and `bin/rails console` reach the same database as `bin/dev`, and
30
+ a stale `COPSE_DATABASE_SUFFIX` cannot rename a main worktree's database.
31
+ - Exports `COPSE_DATABASE_SUFFIX` in a linked worktree, for other processes to
32
+ read; Copse never reads it back. In a main worktree it is removed from the child
33
+ environment, so a stale value inherited from another worktree's session cannot
34
+ be believed there either.
35
+ - `bin/rails generate copse:install` wires up `bin/dev` and creates a minimal
36
+ `Procfile.dev` only when the app has none.
37
+ - Supports Overmind as an alternative supervisor:
38
+ `copse:install --process-manager=overmind` writes a `bin/dev` that hands the
39
+ whole `Procfile.dev`, `web` included, to `overmind start` with the derived
40
+ environment — no split session, because Overmind's per-process pty already
41
+ keeps the debugger working. An app whose `bin/dev` already drives Overmind gets
42
+ that variant by default rather than being downgraded to foreman. Overmind is
43
+ probed at run time, so a teammate without it falls back to the foreman session.
44
+ The derived port is passed to Overmind as `-p` and `OVERMIND_SKIP_ENV=1` is set,
45
+ so no env file can beat it — the same reason foreman is given `--env /dev/null`.
46
+ - Warns when a Procfile entry runs the Tailwind CLI with a bare `--watch`, which
47
+ exits when stdin closes and so ends the whole foreman session with status 0.
48
+
49
+ ### Notes
50
+
51
+ - Zero runtime dependencies. Foreman is invoked as a subprocess, never required,
52
+ and is only needed for apps with non-`web` Procfile entries.
53
+ - Requires Ruby >= 3.2.0.
54
+ - Pipelines and background (`&`) Procfile lines cannot be made signal-transparent;
55
+ Copse warns and leaves them alone. See the README.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Kieran Klaassen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # Copse
2
+
3
+ A hostname and a port for every Rails app and every git worktree — while the debugger keeps working.
4
+
5
+ ```
6
+ ~/code/cora on main → http://cora.localhost:5368
7
+ ~/code/cora-fix-billing on fix-billing → http://fix-billing.cora.localhost:4783
8
+ ~/code/other-app on main → http://other-app.localhost:8119
9
+ ```
10
+
11
+ Derived from the directory name and the branch, so nothing collides and nothing has to be chosen. No daemon, no reverse proxy, no privileged listener, zero runtime dependencies.
12
+
13
+ A linked worktree gets its own development database from the same derivation — `cora_development_fix_billing`. See [Databases](#databases).
14
+
15
+ ## Installation
16
+
17
+ ```ruby
18
+ group :development do
19
+ gem "copse"
20
+ end
21
+ ```
22
+
23
+ ```sh
24
+ bundle install
25
+ bin/rails generate copse:install
26
+ bin/dev
27
+ ```
28
+
29
+ The generator writes a `bin/dev` that calls Copse, and creates `Procfile.dev` only if you don't have one. An existing `Procfile.dev` is never modified; an existing `bin/dev` is copied to `bin/dev.before-copse` first.
30
+
31
+ Already on [Overmind](https://github.com/DarthSim/overmind)? The generator sees it in your `bin/dev` and keeps it. Ask for either supervisor explicitly with `--process-manager=overmind` or `--process-manager=foreman`.
32
+
33
+ ## Why
34
+
35
+ Two apps both want port 3000, so you hand-assign `-p 3001` and then forget which app owns which number — and your teammates picked different ones. Worse, everything lands on `localhost`, so every app shares one cookie jar and one `localStorage`: signing into one signs you out of another.
36
+
37
+ The hostname is not routing traffic. It gives each app a distinct **browser origin**, which is the half that fixes the cookie jar, and that needs no listener.
38
+
39
+ ### Versus puma-dev
40
+
41
+ [puma-dev](https://github.com/puma/puma-dev) gives you a bare `https://app.test` with no port. It buys that by running your app as a background daemon, which takes the web process off your terminal — and `binding.irb` with it. Rails' own `bin/dev` stays local but multiplexes stdin through foreman, which breaks the debugger too ([rails/rails#52459](https://github.com/rails/rails/issues/52459)).
42
+
43
+ Copse runs `web` in the foreground, holding your terminal's stdin and process group; everything else in `Procfile.dev` goes to foreman. The trade is explicit: **you keep the `:5368`.** If a bare hostname matters more than your debugger, use puma-dev.
44
+
45
+ ## Names
46
+
47
+ | Where you are | Hostname |
48
+ |---|---|
49
+ | Main worktree | `<project>.localhost` |
50
+ | Linked worktree | `<branch>.<project>.localhost` |
51
+ | Linked worktree, detached `HEAD` | `<directory>.<project>.localhost` |
52
+ | Not a git repository | `<directory>.localhost` |
53
+
54
+ `<project>` is always the **main** worktree's directory name, so `~/code/cora-fix-billing` on `fix-billing` is `fix-billing.cora.localhost`, not `cora-fix-billing.cora.localhost`.
55
+
56
+ Names reduce to one DNS label: lowercased, anything outside `[a-z0-9]` collapsed to `-`, capped at 63 characters. `feat/billing-v2` → `feat-billing-v2`. It's a whitelist, not a substitution — git permits `;`, `$(`, and quotes in branch names, and the result reaches your environment and a generated Procfile.
57
+
58
+ ## Ports
59
+
60
+ `CRC32(hostname)` indexed into `3000..9999` minus 25 well-known service ports. A pure function: same hostname, same port, on every machine and across reboots and Ruby versions, with no stored state and no coordination.
61
+
62
+ Uncoordinated means collisions are possible. Measured over 20,000 seeded trials against 6,975 available ports:
63
+
64
+ | Worktrees at once | Chance two share a port |
65
+ |---|---|
66
+ | 10 | ~0.6% |
67
+ | 10, using Vite | ~2.5% |
68
+ | 40 | ~10% |
69
+
70
+ Vite doubles it because a Vite app draws two ports. On a collision Copse names the port and the hostname instead of leaving you with Puma's bare `Address already in use`; rename a worktree or branch to fix it.
71
+
72
+ Changing the range or the reserved list would move nearly every derived port, so it's a breaking change gated by a test that pins the whole set.
73
+
74
+ ## Environment
75
+
76
+ | Variable | Value |
77
+ |---|---|
78
+ | `COPSE_HOST` | `cora.localhost` |
79
+ | `COPSE_URL` | `http://cora.localhost:5368` |
80
+ | `COPSE_PORT` | the derived port |
81
+ | `PORT` | the derived port — **`web` process only** |
82
+ | `VITE_RUBY_PORT` | the derived companion port |
83
+ | `COPSE_DATABASE_SUFFIX` | `fix_billing` — **linked worktrees only**, actively unset in a main one |
84
+
85
+ `COPSE_DATABASE_SUFFIX` is exported for your processes to read; Copse never reads it back. In a main worktree it's *removed* from the child environment rather than merely left unset, so a copy inherited from another worktree's session can't be believed. And the rename below is derived from the checkout on disk, so no stale copy of this variable — a shell opened from a linked worktree, a leftover line in `.env` — can rename a main worktree's database.
86
+
87
+ **In a non-`web` process, read `COPSE_PORT`, not `PORT`.** Foreman assigns its children `base_port + index * 100`, so a secondary's `PORT` is a number Copse never derived. Copse deliberately hands foreman an offset base, so no secondary is ever given the `web` process's own port.
88
+
89
+ In development Copse also sets `default_url_options` for routes and Action Mailer. It stays out of the way otherwise: not in other environments, not under a plain `bin/rails server`, and not when your app set its own `host`. Puma still prints `Listening on http://127.0.0.1:5368` — that's the address it bound; Copse's line is the name to visit.
90
+
91
+ `VITE_RUBY_PORT` is picked up by `vite_ruby` automatically. Vite is the only bundler that needs it: esbuild via `jsbundling-rails`, `cssbundling-rails`, `tailwindcss-rails`, Propshaft, and importmap have no port at all.
92
+
93
+ ## Databases
94
+
95
+ A hostname and a port keep two worktrees from colliding in the browser; they still share one schema. So in **development**, a linked worktree also gets its own database — named after the same slug the hostname uses, with `_` instead of `-`:
96
+
97
+ ```
98
+ ~/code/cora on main → cora_development
99
+ ~/code/cora-fix-billing on fix-billing → cora_development_fix_billing
100
+ ```
101
+
102
+ **A main worktree keeps the database it already has**, which is what makes this safe to add to an existing app: the database you've been using never moves. `config/database.yml` is not modified — Copse renames the loaded configuration at boot.
103
+
104
+ Copse doesn't create it. Rails already tells you when a database is missing, and creating one is not something `bin/dev` should do behind your back:
105
+
106
+ ```sh
107
+ bin/rails db:prepare
108
+ ```
109
+
110
+ That works because the rename is derived from the checkout on disk, not from the environment `bin/dev` exports — so `db:prepare`, `bin/rails console`, and `bin/dev` all reach the same database, whether or not Copse started them.
111
+
112
+ What is left alone:
113
+
114
+ - **Every environment but development.** Production has one checkout, and Rails already partitions the test database per parallel worker.
115
+ - **SQLite and any other file-backed database.** A linked worktree is its own directory, so `storage/development.sqlite3` is already a separate database; renaming would only move the file.
116
+ - **Databases marked `database_tasks: false`** — that's how an app says Rails doesn't own it. Replicas *are* renamed, since they point at the same database as their primary.
117
+
118
+ Every development entry in a multi-database `database.yml` is renamed, not just `primary`. Names are capped at 63 bytes (PostgreSQL truncates past that with only a notice, MySQL rejects past 64); the suffix is what gets trimmed, so two very long branch names can share a database the same way they can share a hostname.
119
+
120
+ ## foreman
121
+
122
+ Needed only if `Procfile.dev` has anything besides a `web` line:
123
+
124
+ ```ruby
125
+ gem "foreman", group: :development
126
+ ```
127
+
128
+ Teardown is verified against **0.90.0+**; older versions warn. Under a version manager gems are per-Ruby-version, so switching Ruby can make foreman vanish with no change to your code.
129
+
130
+ ## overmind
131
+
132
+ ```sh
133
+ bin/rails generate copse:install --process-manager=overmind
134
+ ```
135
+
136
+ Overmind fixes the debugger the other way: every process gets its own tmux pty, so stdin is never multiplexed and you attach with `overmind connect web`. That makes the foreground-`web` split pointless — under Overmind, Copse contributes only the environment and hands the whole `Procfile.dev` over:
137
+
138
+ ```ruby
139
+ exit Copse.start(process_manager: :overmind, args: ARGV)
140
+ ```
141
+
142
+ Consequences of Overmind supervising everything, `web` included:
143
+
144
+ - **`PORT` is Overmind's, `base + index * 100`.** So `web` must be the **first** entry in `Procfile.dev` to get the derived port. Copse warns if it isn't. Every process still gets the derived port under `COPSE_PORT`.
145
+ - **The derived port is passed as `-p`, and `OVERMIND_SKIP_ENV=1` is set.** Overmind applies env files *over* the environment it's handed, so a stale `PORT` in one would otherwise win. `-p` is what defeats that — including in `.overmind.env`, which `OVERMIND_SKIP_ENV` doesn't skip. Neither touches your app's own loading: `dotenv-rails` still reads `.env` inside Rails, exactly as on the foreman path.
146
+ - `bin/dev`'s arguments go to `overmind start` *after* Copse's, so `bin/dev -l web` works and `bin/dev -p 4000` still overrides the derived port.
147
+ - **An explicit `--port` on the `web` line wins, and Copse only warns.** `rails server` honours the flag over `PORT`. The foreman path strips it, but that's Copse building the command itself; here Overmind runs your Procfile, and rewriting it into a copy would mean the file you edit and the file `overmind restart` reloads were different things. Remove the flag.
148
+ - No pty problems, so none of the stdin traps below apply.
149
+
150
+ `bin/dev` is committed, and Overmind is a binary rather than a gem, so it's checked at run time, not generate time: a teammate without Overmind falls back to the foreman session automatically. Which is a reason to keep `foreman` in the Gemfile anyway if `Procfile.dev` has non-`web` entries — that machine will need it.
151
+
152
+ ## Where `*.localhost` resolves
153
+
154
+ RFC 6761 §6.3 makes `.localhost` special with a *SHOULD*, not a MUST, so support is uneven. Two separate questions: does the client resolve the name, and does it then reach a server bound to IPv4 loopback — which is what Rails binds, while `*.localhost` resolves `::1` first.
155
+
156
+ Measured on macOS 26.5.2, no `/etc/hosts` entry:
157
+
158
+ | Client | Resolves | Connects | |
159
+ |---|---|---|---|
160
+ | Chromium / Chrome / Edge | ✅ | ✅ | **verified here**, nested labels included |
161
+ | `curl` 8.2.1 | ✅ | ✅ | **verified here** — falls back past `::1` |
162
+ | System resolver | ✅ | — | **verified here** — `["::1", "127.0.0.1"]` |
163
+ | Firefox | ✅ | ✅ | *unverified* — built in since Firefox 84 |
164
+ | Safari, macOS 26+ | ✅ | ✅ | *unverified* — fix is in the OS resolver, which **is** verified |
165
+ | Safari / `curl`, macOS ≤ 15 | ❌ | — | *unverified* — [WebKit #160504](https://bugs.webkit.org/show_bug.cgi?id=160504) |
166
+ | Linux + `systemd-resolved` | ✅ | — | *unverified* — `systemd-resolved.service(8)` |
167
+ | Linux, bare glibc | ❌ | — | *unverified* — no `.localhost` case in glibc |
168
+
169
+ Rows marked *unverified* come from primary sources, not from this machine.
170
+
171
+ For a ❌ row, add a hosts entry — with two caveats:
172
+
173
+ ```
174
+ 127.0.0.1 fix-billing.cora.localhost
175
+ ```
176
+
177
+ Neither glibc nor the macOS resolver supports wildcards, so that's **one line per hostname, meaning one per branch**. And it does nothing for Chrome, which hardcodes `.localhost` and [ignores your hosts file](https://issues.chromium.org/issues/41175806) for these names.
178
+
179
+ On macOS ≤ 15 or bare-glibc Linux and need Safari or `curl`? puma-dev's resolver approach doesn't have this gap. That's a real reason to prefer it.
180
+
181
+ ## Requirements
182
+
183
+ - Ruby >= 3.2.0 (Rails 8.1's own floor; note 3.2 reached EOL 2026-04-01)
184
+ - Rails 7.1+ for the generator and URL options — the derivation itself needs neither Rails nor git
185
+ - `foreman` >= 0.90.0, only for apps with non-`web` Procfile entries — or Overmind instead, on the `--process-manager=overmind` path
186
+
187
+ Rails 8's development host allowlist already includes `.localhost`, so no `config.hosts` change is needed.
188
+
189
+ ## Notes
190
+
191
+ - `Ctrl-C` can take up to foreman's shutdown timeout (5s default) if a watcher doesn't exit promptly.
192
+ - **Coming from Overmind, a bare `tailwindcss --watch` will take your whole session down.** The Tailwind CLI exits when stdin closes; Overmind's pty kept it open, foreman gives it none. The watcher exits **0** a second or two after boot and foreman's cascade ends `web` with it, so a CSS problem reads as an intentional shutdown. Use `--watch=always`. Copse warns when it sees the bare flag.
193
+ - Some Procfile lines cannot be made signal-transparent, so Copse warns and leaves them exactly as written rather than rewriting them into something subtly different. Their children may survive teardown; split them into separate entries to fix it. The shapes are a **pipeline** (`a | b`), a **background `&`**, a **command substitution** (`$(...)` or backticks), and a **subshell** (`(...)`). Each keeps a shell in front of the real process, and no `exec` placement collapses that into one signalable pid — `exec (cd x && y)` is not even valid shell syntax.
194
+ - Out of scope: reverse proxy, daemon, TLS, port registry, and `/etc/hosts` management.
195
+
196
+ ## History
197
+
198
+ See [CHANGELOG.md](CHANGELOG.md).
199
+
200
+ ## Contributing
201
+
202
+ Issues and pull requests are welcome. Especially useful:
203
+
204
+ - **A `.localhost` row you can verify.** The resolution table above marks rows *unverified* when they come from primary sources rather than a machine — replacing one with a measurement is a real contribution.
205
+ - **A Procfile line Copse handles wrongly.** Include the line verbatim.
206
+
207
+ To get set up:
208
+
209
+ ```sh
210
+ git clone https://github.com/kieranklaassen/copse.git
211
+ cd copse
212
+ bundle install
213
+ bundle exec rake test
214
+ ```
215
+
216
+ The suite drives real processes rather than mocking them, so it needs `foreman` (a dev dependency) and takes about a minute. Two tests bind a derived port; they fail if something else on your machine already holds it. `BUNDLE_GEMFILE=gemfiles/rails71.gemfile bundle exec rake test` checks the oldest supported Rails.
217
+
218
+ ## License
219
+
220
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "worktree"
4
+
5
+ module Copse
6
+ # Decides whether Copse renames the development database, and to what.
7
+ #
8
+ # Same shape as UrlOptions and for the same reason: the decisions are
9
+ # Rails-free so they can be tested without booting an application, and the
10
+ # railtie is only the wiring.
11
+ #
12
+ # The suffix is derived from the checkout on disk rather than from the
13
+ # environment Copse exports, so `bin/rails db:prepare` and `bin/rails console`
14
+ # in a linked worktree reach the same database `bin/dev` does. A database that
15
+ # only existed when Copse booted the app would be a database no rake task could
16
+ # create.
17
+ module Database
18
+ # A main worktree keeps its plain database name, so only a linked worktree is
19
+ # renamed. That is what makes this safe to add to an existing app: the
20
+ # database you already have is never the one that moves.
21
+ #
22
+ # PostgreSQL silently truncates identifiers past 63 bytes and MySQL rejects
23
+ # them past 64, so the composed name is capped here rather than left to the
24
+ # server. The cap trims the suffix, never the app's own database name --
25
+ # which can make two very long branch names share a database, exactly as the
26
+ # 63-character DNS label cap can make them share a hostname.
27
+ NAME_LIMIT = 63
28
+
29
+ # Adapters whose "database" is a path rather than a name on a shared server.
30
+ # A linked worktree is a separate directory, so these are already separate
31
+ # databases; renaming would only move the file inside the worktree.
32
+ FILE_BACKED_ADAPTERS = %w[sqlite3 sqlite].freeze
33
+
34
+ # Development only. The point of the rename is that several worktrees of one
35
+ # app can run at once; production has one, and a test database is already
36
+ # partitioned per worker by Rails itself.
37
+ def self.apply?(rails_env:)
38
+ rails_env.to_s == "development"
39
+ end
40
+
41
+ # The suffix for this checkout, or nil in a main worktree.
42
+ #
43
+ # The checkout on disk is the only authority, never the exported
44
+ # COPSE_DATABASE_SUFFIX -- which is deliberate rather than just simple. That
45
+ # variable can outlive the session that set it: a shell opened from a linked
46
+ # worktree's `bin/dev`, or a stale line in the app's own `.env` that
47
+ # dotenv-rails loads. Reading it here would let either one rename a *main*
48
+ # worktree's database, which is the one promise this feature makes.
49
+ def self.suffix(root: Dir.pwd)
50
+ Worktree.new(root).database_suffix
51
+ end
52
+
53
+ # The renamed database, or nil when this configuration is left alone.
54
+ def self.rename(database:, adapter:, suffix:)
55
+ name = database.to_s
56
+ return nil if suffix.to_s.empty? || name.empty?
57
+ return nil if FILE_BACKED_ADAPTERS.include?(adapter.to_s) || name.include?("/")
58
+ # Idempotent: the railtie can run again in the same process (a reload, a
59
+ # second `db:prepare` in one rake invocation) and must not keep appending.
60
+ return nil if name.end_with?("_#{suffix}")
61
+
62
+ compose(name, suffix.to_s)
63
+ end
64
+
65
+ # Renames every development database in an
66
+ # ActiveRecord::DatabaseConfigurations, in place.
67
+ #
68
+ # In place is the load-bearing part. Active Record's own
69
+ # `active_record.initialize_database` initializer calls `establish_connection`
70
+ # before this runs, so a *replacement* configuration object would be ignored
71
+ # by the pool that already exists. Mutating the object the pool holds works
72
+ # because the adapter is built from it lazily, at first checkout.
73
+ #
74
+ # Returns the names that changed, for reporting.
75
+ def self.apply(configurations, suffix:, env_name: "development")
76
+ configurations.configs_for(env_name: env_name, include_hidden: true).filter_map do |config|
77
+ # `database_tasks: false` is how an app says Rails does not own this
78
+ # database -- a legacy or shared server it only reads. Copse does not get
79
+ # to rename something the app cannot create.
80
+ #
81
+ # The key is read directly rather than through `database_tasks?`, which is
82
+ # also false for every replica. A replica points at the same database as
83
+ # its primary, so leaving it behind would send reads to another worktree's
84
+ # data.
85
+ next unless config.configuration_hash.fetch(:database_tasks, true)
86
+
87
+ renamed = rename(database: config.database, adapter: config.adapter, suffix: suffix)
88
+ next if renamed.nil?
89
+
90
+ config._database = renamed
91
+ renamed
92
+ end
93
+ end
94
+
95
+ # Composes the name, or nil when the cap leaves no room for a suffix at all
96
+ # -- in which case the database is left alone rather than renamed to
97
+ # something that cannot be told apart from another worktree's.
98
+ def self.compose(database, suffix)
99
+ room = NAME_LIMIT - database.bytesize - 1
100
+ return nil if room < 1
101
+
102
+ "#{database}_#{suffix[0, room]}"
103
+ end
104
+ private_class_method :compose
105
+ end
106
+ end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Copse
4
+ # Reads a Procfile and splits it into the foreground `web` process and the rest.
5
+ #
6
+ # Parsing only. The lifecycle -- spawning, teardown, signal handling -- lives in
7
+ # Session, so everything here is a pure function that can be tested without a
8
+ # process tree.
9
+ class Procfile
10
+ Entry = Struct.new(:name, :command, keyword_init: true)
11
+
12
+ WEB = "web"
13
+
14
+ # Control operators that make Ruby's Process.spawn hand the string to
15
+ # /bin/sh, which then becomes the pid foreman records.
16
+ FIXABLE_SEPARATORS = [";", "&&", "||"].freeze
17
+
18
+ # Shapes no `exec` placement can make signal-transparent.
19
+ #
20
+ # A pipeline or a background `&` keeps a shell waiting on the whole thing. A
21
+ # command substitution or subshell is worse: splicing `exec` before the last
22
+ # operator would put it *inside* the parentheses, so the outer command is never
23
+ # exec'd at all -- and `exec (cd x && y)` is not even valid shell syntax, so
24
+ # there is no prefix form to fall back to. Refusing to rewrite what cannot be
25
+ # reasoned about beats producing something subtly wrong.
26
+ UNFIXABLE_SEPARATORS = ["|", "&"].freeze
27
+ UNFIXABLE_GROUPINGS = ["$(", "`", "("].freeze
28
+ UNFIXABLE = (UNFIXABLE_SEPARATORS + UNFIXABLE_GROUPINGS).freeze
29
+
30
+ # The characters Ruby itself treats as requiring a shell (mirrors
31
+ # rb_exec_fillarg). A command containing any of these is spawned via
32
+ # `/bin/sh -c` even when it is a single command.
33
+ SHELL_REQUIRED = /[*?{}\[\]<>()~&|\\$;'"`\n#]/.freeze
34
+
35
+ # Matches an explicit port flag on a command line: `-p 3000`, `--port 3000`,
36
+ # or `--port=3000`.
37
+ PORT_FLAG = /\s+(?:-p|--port)(?:=|\s+)\d+\b/.freeze
38
+
39
+ attr_reader :entries
40
+
41
+ def initialize(entries)
42
+ @entries = entries
43
+ end
44
+
45
+ def self.parse(text)
46
+ entries = text.to_s.lines.filter_map do |line|
47
+ stripped = line.strip
48
+ next if stripped.empty? || stripped.start_with?("#")
49
+
50
+ name, command = stripped.split(":", 2)
51
+ next if command.nil?
52
+
53
+ command = command.strip
54
+ next if command.empty?
55
+
56
+ Entry.new(name: name.strip, command: command)
57
+ end
58
+ new(entries)
59
+ end
60
+
61
+ def self.load(path)
62
+ File.exist?(path) ? parse(File.read(path)) : nil
63
+ end
64
+
65
+ # The `web` entry, matched by name. Real templates ship
66
+ # `web: env RUBY_DEBUG_OPEN=true bin/rails server`, so never assume a bare
67
+ # `bin/rails server`.
68
+ def web
69
+ entries.find { |entry| entry.name == WEB }
70
+ end
71
+
72
+ # Everything that is not `web`. These go to foreman.
73
+ def secondaries
74
+ entries.reject { |entry| entry.name == WEB }
75
+ end
76
+
77
+ # Removes an explicit port flag from a command.
78
+ #
79
+ # An explicit `--port` beats the PORT environment variable in `rails server`,
80
+ # so a Procfile shipping `web: bin/rails s --port 3000` -- which vite_ruby's
81
+ # own example does -- would silently boot on 3000 and defeat the derived
82
+ # port. Stripping the flag is what makes the stock template work unchanged.
83
+ def self.strip_port_flag(command)
84
+ command.gsub(PORT_FLAG, "")
85
+ end
86
+
87
+ def self.port_flag?(command)
88
+ command.match?(PORT_FLAG)
89
+ end
90
+
91
+ # Rewrites a command so the process foreman records is the real one, not a
92
+ # `/bin/sh` that will not forward SIGTERM.
93
+ #
94
+ # Returns [rewritten_command, warning_or_nil].
95
+ #
96
+ # Three nearby forms are wrong and were measured wrong:
97
+ # `exec sh -c "a; b"` -> recorded pid is still sh; the orphan survives
98
+ # `exec a; b` -> exec replaces the shell with `a`; `b` never runs
99
+ # `a | exec b` -> the recorded pid is the shell awaiting the pipeline
100
+ # What works is inserting `exec` before the *final* command of a chain.
101
+ #
102
+ # A pipeline or a background `&` cannot be collapsed into one pid by any exec
103
+ # placement, so those are left alone and warned about instead of silently
104
+ # "fixed".
105
+ def self.signal_transparent(command)
106
+ # A trailing separator would otherwise splice a bare `exec ` with nothing
107
+ # after it -- a silent no-op that leaves the shell in front of the process.
108
+ command = command.sub(/[\s;]+\z/, "")
109
+ operators = top_level_operators(command)
110
+
111
+ if operators.any? { |op| UNFIXABLE.include?(op[:token]) }
112
+ return [command, unfixable_warning(command, operators)]
113
+ end
114
+
115
+ return [command, nil] if operators.empty? && !command.match?(SHELL_REQUIRED)
116
+
117
+ # A single command that still needs a shell (a redirect, a glob, a quoted
118
+ # argument): prefixing exec replaces the shell with it, which is correct.
119
+ return [exec_prefixed(command), nil] if operators.empty?
120
+
121
+ # A chain: exec the last command. A Procfile entry whose final command is
122
+ # short-lived would exit immediately and stop being a long-running process
123
+ # at all, so "the process we care about is last" is forced by what a
124
+ # Procfile entry is, not merely a convention.
125
+ last = operators.last
126
+ cut = last[:at] + last[:token].length
127
+ head = command[0, cut]
128
+ tail = command[cut..].to_s
129
+ indent = tail[/\A\s*/]
130
+ ["#{head}#{indent}#{exec_prefixed(tail.lstrip)}", nil]
131
+ end
132
+
133
+ # `exec` cannot take a leading VAR=value assignment: `exec FOO=1 cmd` makes the
134
+ # shell look for a program literally named `FOO=1` and fail with exit 127. Going
135
+ # through env(1) preserves the assignment and still replaces the shell, so the
136
+ # recorded pid is the real process either way.
137
+ ASSIGNMENT_PREFIX = /\A[A-Za-z_][A-Za-z0-9_]*=/.freeze
138
+
139
+ def self.exec_prefixed(command)
140
+ return "exec env #{command}" if command.match?(ASSIGNMENT_PREFIX)
141
+
142
+ "exec #{command}"
143
+ end
144
+
145
+ # Finds control operators outside quotes.
146
+ #
147
+ # Quote-awareness matters: `bin/rails runner 'A.watch; B.watch'` is a single
148
+ # command with a quoted semicolon, not a chain, and treating it as one would
149
+ # rewrite a working line.
150
+ def self.top_level_operators(command)
151
+ operators = []
152
+ in_single = false
153
+ in_double = false
154
+ index = 0
155
+
156
+ while index < command.length
157
+ char = command[index]
158
+
159
+ if in_single
160
+ in_single = false if char == "'"
161
+ elsif in_double
162
+ if char == "\\"
163
+ index += 1
164
+ elsif char == '"'
165
+ in_double = false
166
+ end
167
+ else
168
+ case char
169
+ when "'" then in_single = true
170
+ when '"' then in_double = true
171
+ when "\\" then index += 1
172
+ when "`" then operators << { token: "`", at: index }
173
+ when "$"
174
+ if command[index + 1] == "("
175
+ operators << { token: "$(", at: index }
176
+ index += 1
177
+ end
178
+ when "("
179
+ operators << { token: "(", at: index }
180
+ when ";", "\n" then operators << { token: ";", at: index }
181
+ when "&"
182
+ if command[index + 1] == "&"
183
+ operators << { token: "&&", at: index }
184
+ index += 1
185
+ elsif redirect_ampersand?(command, index)
186
+ # Part of a redirect (`2>&1`, `>&2`, `&>file`), not a control
187
+ # operator. Reading it as a background `&` made a perfectly ordinary
188
+ # `yarn build --watch 2>&1` skip the exec transform and keep a shell
189
+ # in front of the process -- a real orphan on any /bin/sh that forks.
190
+ nil
191
+ else
192
+ operators << { token: "&", at: index }
193
+ end
194
+ when "|"
195
+ if command[index + 1] == "|"
196
+ operators << { token: "||", at: index }
197
+ index += 1
198
+ else
199
+ operators << { token: "|", at: index }
200
+ end
201
+ end
202
+ end
203
+
204
+ index += 1
205
+ end
206
+
207
+ operators
208
+ end
209
+
210
+ # True when the `&` at `index` belongs to a redirect rather than being a
211
+ # control operator: `2>&1` and `>&2` have `>` (or `<`) before it, `&>file` has
212
+ # `>` after it.
213
+ def self.redirect_ampersand?(command, index)
214
+ return true if command[index + 1] == ">"
215
+
216
+ before = command[0, index].rstrip
217
+ before.end_with?(">", "<")
218
+ end
219
+
220
+ # A bare `--watch` on the Tailwind CLI, which is fatal under foreman.
221
+ #
222
+ # `tailwindcss --watch` exits when stdin closes. Overmind gives every process a
223
+ # pty so stdin stays open; foreman does not, so the watcher exits **0** a
224
+ # second or two after boot and foreman's cascade takes `web` down with it. The
225
+ # zero status makes it read like an intentional shutdown with nothing linking
226
+ # it to CSS, which is why this is worth a line of output. `--watch=always` is
227
+ # the fix.
228
+ TAILWIND_BARE_WATCH = /\btailwindcss\b/.freeze
229
+ BARE_WATCH_FLAG = /--watch(?![=\w])/.freeze
230
+
231
+ def self.stdin_sensitive_warning(command)
232
+ return nil unless command.match?(TAILWIND_BARE_WATCH) && command.match?(BARE_WATCH_FLAG)
233
+
234
+ "runs the tailwindcss CLI with a bare `--watch`, which exits when stdin closes. " \
235
+ "Under foreman that ends the whole session a moment after boot, with exit status 0. " \
236
+ "Use `--watch=always`."
237
+ end
238
+
239
+ def self.unfixable_warning(_command, operators)
240
+ tokens = operators.map { |op| op[:token] }
241
+ # Groupings are named before separators: a pipe inside `$( )` is incidental,
242
+ # and the substitution is the reason the line cannot be rewritten.
243
+ kind =
244
+ if tokens.include?("$(") || tokens.include?("`") then "a command substitution"
245
+ elsif tokens.include?("(") then "a subshell"
246
+ elsif tokens.include?("|") then "a pipeline"
247
+ else "a background &"
248
+ end
249
+
250
+ "uses #{kind}, which keeps a shell in front of it -- its child processes may " \
251
+ "survive teardown. Consider splitting it into separate Procfile entries."
252
+ end
253
+ end
254
+ end