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 +7 -0
- data/CHANGELOG.md +55 -0
- data/LICENSE.txt +21 -0
- data/README.md +220 -0
- data/lib/copse/database.rb +106 -0
- data/lib/copse/procfile.rb +254 -0
- data/lib/copse/railtie.rb +72 -0
- data/lib/copse/session.rb +558 -0
- data/lib/copse/url_options.rb +38 -0
- data/lib/copse/version.rb +5 -0
- data/lib/copse/worktree.rb +133 -0
- data/lib/copse.rb +102 -0
- data/lib/generators/copse/install_generator.rb +142 -0
- data/lib/generators/copse/templates/Procfile.dev.tt +1 -0
- data/lib/generators/copse/templates/dev.tt +7 -0
- metadata +63 -0
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
|