yamine 0.16.0 → 0.17.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: 66abd82532420b13b18597a4d71b51485f06b09c14d5b0d4c3e777d8056e7145
4
- data.tar.gz: be3bcb4b88ba0eeb444ed33d10c14c217cf145073f2f8cce78e62e4dc138bffd
3
+ metadata.gz: 3987eef1f0bc39291b8d264fd4b569fcb54106c23e78150594f62d86ca2d0351
4
+ data.tar.gz: e98404e9655e78861c289cb320bf544564e07e65e06fe32a496d5a3ff4ee52b9
5
5
  SHA512:
6
- metadata.gz: e03ba4c4510f03424190fdf58f0c5f6db6183425b75c31628df99724bf592edc068ab13cd6bf7abd28b04c577025b4ec913eb87d892b78524aab7aee35e83dbd
7
- data.tar.gz: 9eaa287f5ee45e231cd82f29c8dbf0bf0f3e197cc825dfeccccb4f39ff02c826167d32516c23fef7c2a39804d662a69b417e082b1b9d2e6f1b562ce63597a95e
6
+ metadata.gz: 20018aea0d2229ab0c6c814d15deaeb176649fcdaa81937eb2c6f223607e50f2c34256ed078aca6ef0bb0a114cbd865bd6187dd2f74a1b981d2dbd7c8d7e141d
7
+ data.tar.gz: 808f2b4151de99fa3fe71ba8bbbfce6eb7dbe63298bea1da3c4fd05a5baea9da7f5d797ae792057f8ddd2f5500b19da47fd97af1641481764eb8f051605a0c0f
data/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.17.0] — 2026-09-23
6
+
7
+ ### Added
8
+
9
+ - **Per-worktree `.env` files — hand-run isolation without touching the
10
+ app's config.** `worktree add` writes `.env` and `.env.development`
11
+ (the whole development set: `DATABASE_URL` + one `NAME_DATABASE_URL`
12
+ per configuration) and `.env.test` (`PRIMARY_DATABASE_URL` — the key
13
+ Rails checks *before* `DATABASE_URL` for a flat test config, so the
14
+ test URL wins no matter what order a loader reads the files in).
15
+ Any dotenv loader picks them up — hand-run `rails console`,
16
+ `rails test`, and `db:migrate` land on the worktree's databases with
17
+ zero yamine-specific code in `database.yml`. Mode 0600, git-excluded
18
+ automatically, removed with the worktree.
19
+
20
+ ### Changed
21
+
22
+ - **`.yamine-db-suffix` (0.16.0) is replaced by the `.env` files above.**
23
+ The database.yml suffix hook is no longer needed — apps keep a plain
24
+ boring config. Legacy marker files are deleted on sight.
25
+ - **The conformance check now diagnoses precisely.** `worktree add`
26
+ re-probes after writing `.env` and distinguishes the two ways an app
27
+ can fail to isolate hand-run commands: no dotenv loader loaded the
28
+ file (fix: one Gemfile line), vs `database.yml` supplies `url:` keys,
29
+ which Rails gives *precedence over the entire environment* —
30
+ `merge_db_environment_variables` skips URL-shaped configs, so neither
31
+ injected env nor `.env` can ever redirect them (fix: component form —
32
+ `database:`, `host:`, `username:` — for development/test).
33
+ - **Worktrees copy only the credential keys their own environments
34
+ need**: `config/master.key`, `config/credentials/development.key`,
35
+ `config/credentials/test.key`. Production and staging keys no longer
36
+ travel into throwaway directories.
37
+
38
+ ### Fixed
39
+
40
+ - **`worktree add` could purge the MAIN checkout's test database.**
41
+ The per-worktree test schema preparation ran under
42
+ `RAILS_ENV=development`, where the flat test config resolves to the
43
+ BASE name (the environment-override merge only applies to the current
44
+ environment's configs) — so `db:test:prepare` purged and reloaded
45
+ *main's* `myrr_markdown_test` during a worktree add, observed live.
46
+ It now runs under `RAILS_ENV=test`, where dotenv loads `.env.test`
47
+ and the override lands on the worktree's own test database; a
48
+ regression test pins the environment.
49
+
5
50
  ## [0.16.0] — 2026-09-23
6
51
 
7
52
  ### Added
data/README.md CHANGED
@@ -171,7 +171,7 @@ yamine worktree clean # tear down everything already merged
171
171
 
172
172
  `add` lands the worktree beside the repo, copies the gitignored
173
173
  per-checkout config (`config/local.yml`, `config/local.secrets`,
174
- `config/master.key`, `config/credentials/*.key`) the branch needs, runs
174
+ `config/master.key`, and the development/test credential keys) the branch needs, runs
175
175
  `bundle install`, asks the app what databases it has, and provisions
176
176
  the whole set with schema — the next step is just `yamine start` in
177
177
  it.
@@ -200,12 +200,12 @@ guessed. `worktree add` boots `bin/rails runner` once inside the app so
200
200
  database.yml and credentials resolve exactly as the app would resolve
201
201
  them (yamine never parses config or touches a key), suffixes every
202
202
  database name with a collision-guarded per-worktree token, creates and
203
- schema-loads them, and records names plus server coordinates in the
204
- claim. Boot injects `DATABASE_URL` and one `NAME_DATABASE_URL` per
205
- configuration (Rails' own convention), so supervised processes are
206
- isolated even in an app that never heard of yamine. `remove`/`clean`
207
- drop the entire set as a unit — including from an orphaned claim whose
208
- directory is already gone, without the app booting.
203
+ schema-loads them, prepares the test database, and records names plus
204
+ server coordinates in the claim. Boot injects `DATABASE_URL` and one
205
+ `NAME_DATABASE_URL` per configuration (Rails' own convention).
206
+ `remove`/`clean` drop the entire set as a unit — including from an
207
+ orphaned claim whose directory is already gone, without the app
208
+ booting.
209
209
 
210
210
  ```bash
211
211
  yamine db describe # what THIS checkout resolves to (passwords masked)
@@ -213,54 +213,71 @@ yamine db list # every claim, every database under it
213
213
  yamine db create # re-probe + provision (run after the app grows a database)
214
214
  ```
215
215
 
216
- **Hand-run commands.** Env injection only reaches processes yamine
217
- spawns; a `rails console`, `rails test`, or `db:migrate` you run by
218
- hand reads the environment you gave it. For those, opt the app in with
219
- the suffix hook — a one-time addition at the top of
220
- `config/database.yml`:
221
-
222
- ```erb
223
- <%
224
- yamine_suffix = begin
225
- f = Rails.root.join(".yamine-db-suffix")
226
- f.exist? ? f.read.strip : ""
227
- rescue StandardError
228
- ""
229
- end
230
- yamine_db_url = lambda do |url|
231
- next url if yamine_suffix.empty? || url.nil? || url.to_s.empty?
232
- require "uri"
233
- begin
234
- uri = URI.parse(url.to_s)
235
- uri.path = "#{uri.path}_#{yamine_suffix}" if uri.path && uri.path != "/"
236
- uri.to_s
237
- rescue URI::InvalidURIError
238
- url
239
- end
240
- end
241
- %>
242
- ```
243
-
244
- Then wrap each database URL (and a bare test database name) with it:
245
-
246
- ```yaml
247
- development:
248
- primary:
249
- url: <%= yamine_db_url.call(Rails.application.credentials.dig(:database, :primary, :url)) %>
250
- test:
251
- database: <%= ENV.fetch("TEST_DATABASE_NAME") { "myapp_test#{yamine_suffix.empty? ? "" : "_#{yamine_suffix}"}" } %>
252
- ```
253
-
254
- `worktree add` writes the `.yamine-db-suffix` token (git-excluded
255
- automatically) and then *proves* the hook works — you'll see
256
- `database.yml reads .yamine-db-suffix — hand-run commands are isolated
257
- too`. Without the hook, supervised boots are still fully isolated via
258
- env; `add` says exactly what the hand-run gap is. The test database is
259
- part of the claim too: created and schema-prepared on first add (so
260
- `rails test` runs as-is), dropped at teardown — never left behind.
216
+ **Two one-time app requirements, both boring:**
217
+
218
+ 1. *Component-form development/test config.* Environment overrides —
219
+ injected `DATABASE_URL`/`NAME_DATABASE_URL` and the per-worktree
220
+ `.env` — only apply to component keys (`database:`, `host:`). A
221
+ `url:` key (the usual credentials-driven style) takes precedence
222
+ over the *entire* environment: Rails skips URL-shaped configs when
223
+ merging environment variables, so nothing injected or loaded can
224
+ redirect them. Development and test should read like a plain Rails
225
+ file:
226
+
227
+ ```yaml
228
+ default: &default
229
+ adapter: postgresql
230
+ encoding: unicode
231
+ host: <%= ENV.fetch("DB_HOST", "localhost") %>
232
+ username: postgres
233
+
234
+ development:
235
+ primary:
236
+ <<: *default
237
+ database: myapp_development
238
+ cache:
239
+ <<: *default
240
+ database: myapp_development_cache
241
+ migrations_paths: db/cache_migrate
242
+
243
+ staging:
244
+ primary: &primary_staging
245
+ <<: *default
246
+ url: <%= Rails.application.credentials.dig(:database, :primary, :url) %>
247
+ ```
248
+
249
+ Staging/production keep doing whatever they do — yamine only ever
250
+ redirects development and test.
251
+
252
+ 2. *A dotenv loader* — `gem "dotenv-rails", groups: [:development, :test]`
253
+ (any dotenv loader works). That is what reads the files
254
+ `worktree add` writes into the worktree:
255
+
256
+ - `.env` / `.env.development` — the whole development set
257
+ (`DATABASE_URL` plus one `NAME_DATABASE_URL` per configuration),
258
+ - `.env.test` — the test URL under `PRIMARY_DATABASE_URL`, the key
259
+ Rails checks *before* `DATABASE_URL` for a flat test config, so it
260
+ wins regardless of the order a loader reads the files in.
261
+
262
+ Mode 0600, git-excluded automatically, removed with the worktree.
263
+ A hand-run `rails console`, `rails test`, or `db:migrate` in the
264
+ worktree therefore lands on the worktree's own databases — with
265
+ **zero yamine-specific code in `database.yml`, ever**.
266
+
267
+ `worktree add` proves both after writing the files: you will see
268
+ `.env loaded — hand-run commands are isolated too`. A warning instead
269
+ names which requirement is missing — no loader ran, or the config is
270
+ `url:`-shaped — and the exact fix. Supervised boots are isolated via
271
+ injected env either way (component form permitting).
272
+
273
+ Credential keys travel too: `config/master.key`,
274
+ `config/credentials/development.key`, and
275
+ `config/credentials/test.key` are copied at mode 0600. Production and
276
+ staging keys stay in the main checkout where they belong.
277
+
278
+ The main checkout never gets `.env` files or a suffix: its databases
279
+ are its databases, untouched.
261
280
 
262
- The main checkout has no marker file, so nothing changes there: its
263
- databases are its databases.
264
281
 
265
282
  ## Subdomains are opt-in
266
283
 
@@ -109,33 +109,39 @@ yamine worktree clean [--dry-run]
109
109
 
110
110
  `add` creates the git worktree beside the repo, copies the gitignored
111
111
  per-checkout config (`config/local.yml`, `config/local.secrets`,
112
- credential keys), runs `bundle install`, **asks the app what databases
113
- it has** (one `bin/rails runner` probe — database.yml and credentials
114
- resolve inside the app; yamine never parses them), and provisions the
115
- whole set with schema — every database of a multi-database app gets a
116
- per-worktree suffix, the test database included and schema-prepared, so
117
- `rails test` runs as-is. Then boot with `yamine start` inside it.
118
- `remove` and `clean` drop the entire set as a unit — including from an
119
- orphaned claim whose directory is gone, without booting the app — and
120
- `clean` never touches uncommitted work; unmerged branches survive
121
- everything except `remove --force`. Prefer `clean --dry-run` first, and
122
- `clean` over `rm -rf` — a removed worktree leaves no routes, databases,
123
- or stale hosts entries behind.
112
+ `config/master.key`, and the development/test credential keys — never
113
+ production/staging keys), runs `bundle install`, **asks the app what
114
+ databases it has** (one `bin/rails runner` probe — database.yml and
115
+ credentials resolve inside the app; yamine never parses them), and
116
+ provisions the whole set with schema — every database of a
117
+ multi-database app gets a per-worktree suffix, the test database
118
+ included and schema-prepared, so `rails test` runs as-is. It writes
119
+ `.env` / `.env.development` (the development set) and `.env.test`
120
+ (`PRIMARY_DATABASE_URL` — the key Rails checks first for a flat test
121
+ config) into the worktree, git-excluded automatically. Then boot with
122
+ `yamine start` inside it. `remove` and `clean` drop the entire set as
123
+ a unit — including from an orphaned claim whose directory is gone,
124
+ without booting the app — and `clean` never touches uncommitted work;
125
+ unmerged branches survive everything except `remove --force`. Prefer
126
+ `clean --dry-run` first, and `clean` over `rm -rf` — a removed
127
+ worktree leaves no routes, databases, or stale hosts entries behind.
124
128
 
125
129
  `yamine db describe` shows what the current checkout resolves to
126
130
  (passwords masked); `yamine db list` shows every database under every
127
131
  claim; `yamine db create` re-probes and self-heals (run it in each
128
132
  worktree after the app grows a database).
129
133
 
130
- Supervised boots are isolated via injected `DATABASE_URL` /
131
- `NAME_DATABASE_URL` env vars. **Hand-run commands are not** — unless
132
- the app carries the `.yamine-db-suffix` hook from the yamine README at
133
- the top of `config/database.yml`. `worktree add` writes that token
134
- file and prints `database.yml reads .yamine-db-suffix` when the hook
135
- is in place; if instead you see a warning that database.yml does not
136
- read it, hand-run `rails console` / `rails test` / `db:migrate` in
137
- that worktree would use the main checkout's databases — add the hook
138
- from the README, then `yamine db create`.
134
+ Isolation reaches two places: supervised boots via injected
135
+ `DATABASE_URL` / `NAME_DATABASE_URL` env vars, and hand-run commands
136
+ (`rails console`, `rails test`, `db:migrate`) via the `.env` files —
137
+ which require a dotenv loader in the app (`gem "dotenv-rails",
138
+ groups: [:development, :test]`) and **component-form development/test
139
+ config** (`database:` keys, never `url:` — a `url:` key takes
140
+ precedence over the entire environment, so nothing injected or loaded
141
+ can redirect it; staging/production URLs are fine, yamine never
142
+ touches those envs). `worktree add` verifies both and prints
143
+ `.env loaded — hand-run commands are isolated too`; its warning names
144
+ exactly which requirement is missing and the fix.
139
145
 
140
146
  ## Subdomains are opt-in
141
147
 
@@ -586,26 +586,25 @@ module Yamine
586
586
  # Worktree-add (and `yamine db create` in a worktree): turn a
587
587
  # probe of the app's real databases into a provisioned,
588
588
  # self-describing claim. Names come from the app, the suffix
589
- # from the directory; claim + marker are written BEFORE any
589
+ # from the directory; claim + .env files are written BEFORE any
590
590
  # database is created, so a failure mid-way leaves a state the
591
591
  # next `yamine db create` or boot can finish from — never a
592
592
  # half-named set with no record of it.
593
593
  #
594
594
  # Returns the final names; raises Error only for conditions the
595
- # user must fix (name budget, server down, app broken by its own
596
- # suffix hook) — those keep the worktree and the claim.
595
+ # user must fix (name budget, server down, .env breaking the
596
+ # app's boot) — those keep the worktree and the claim.
597
597
  def provision_multidb(ctx, dir, claim_key, rows)
598
598
  env_name = rails_env
599
- # A marker from a previous run means the probe answered with
600
- # names the app already suffixed — strip that suffix before
601
- # fitting, or this pass would double it and yamine would
602
- # provision a set the app never connects to.
603
- marker = Database.read_marker(dir)
604
- if marker && !marker.empty?
605
- rows = strip_marker_suffix(rows, marker)
606
- end
599
+ # Re-provisioning a worktree whose .env exists: the app (via
600
+ # dotenv) resolves SUFFIXED names — strip the claim's stored
601
+ # suffix before fitting, or this pass would double it and
602
+ # yamine would provision a set the app never connects to.
603
+ prior = Database.load_map(ctx.store.dir)[claim_key]
604
+ prior_suffix = prior.is_a?(Hash) ? prior["suffix"] : nil
605
+ rows = strip_suffix(rows, prior_suffix)
607
606
  # The test environment's database joins the claim: worktree
608
- # tests run against it (the same marker suffixes it), and
607
+ # tests run against it (.env.test carries its URL), and
609
608
  # teardown must drop it — a leaked test database per worktree
610
609
  # is exactly the leftover this exists to prevent. Test configs
611
610
  # are flat (implicitly named "primary"), so the claim key is
@@ -613,7 +612,7 @@ module Yamine
613
612
  # A test env the app cannot answer (missing test credentials,
614
613
  # say) degrades to a dev-only claim; `yamine db create` heals.
615
614
  test_rows = Probe.rails_databases(dir, env: "test")
616
- test_rows = strip_marker_suffix(test_rows, marker) if test_rows && marker && !marker.empty?
615
+ test_rows = strip_suffix(test_rows, prior_suffix)
617
616
  test_pairs = (test_rows && Probe.server_backed(test_rows) || [])
618
617
  .map { |r| [env_namespaced_key(r["name"], "test"), r] }
619
618
 
@@ -638,8 +637,14 @@ module Yamine
638
637
  "claimed_at" => Time.now.utc.iso8601,
639
638
  "suffix" => fitted, "names" => names, "bases" => bases)
640
639
  Database.save_map(ctx.store.dir, map)
641
- Database.write_marker(dir, fitted)
642
- Database.exclude_marker(dir)
640
+ # The environment files: what hand-run commands read. Written
641
+ # before creation so the verify probe below can see them.
642
+ dev_env = db_env_for(
643
+ names.reject { |cfg, _| cfg == "test" || cfg.start_with?("test_") }, bases)
644
+ test_url = names["test"] &&
645
+ Database.url_for(names["test"], base_for(bases, "test"))
646
+ Database.write_env_files(dir, dev_env, test_url: test_url)
647
+ Database.exclude_files(dir, Database::ENV_FILES)
643
648
 
644
649
  # An empty test database is worse than none: Rails won't
645
650
  # auto-load schema into it (verified — first `rails test`
@@ -659,8 +664,14 @@ module Yamine
659
664
  end
660
665
 
661
666
  if test_needs_schema
667
+ # RAILS_ENV=test, not development: dotenv must load .env.test
668
+ # (PRIMARY_DATABASE_URL = this worktree's test URL) AND the
669
+ # environment-override merge only applies to the CURRENT
670
+ # env's configs — under development the flat test config
671
+ # would resolve to the base name and purge+load the MAIN
672
+ # checkout's test database instead of this worktree's.
662
673
  prepared = Dir.chdir(dir) do
663
- system({ "RAILS_ENV" => rails_env }, "sh", "-c", "bin/rails db:test:prepare",
674
+ system({ "RAILS_ENV" => "test" }, "sh", "-c", "bin/rails db:test:prepare",
664
675
  out: File::NULL, err: File::NULL)
665
676
  end
666
677
  if prepared
@@ -670,16 +681,16 @@ module Yamine
670
681
  end
671
682
  end
672
683
 
673
- verify_marker_conformance(dir, names)
684
+ verify_env_loading(dir, names)
674
685
  names
675
686
  end
676
687
 
677
- def strip_marker_suffix(rows, marker)
678
- return rows if marker.nil? || marker.empty?
688
+ def strip_suffix(rows, suffix)
689
+ return rows if rows.nil? || suffix.nil? || suffix.empty?
679
690
 
680
691
  rows.map do |r|
681
- if r["database"].end_with?("_#{marker}")
682
- r.merge("database" => r["database"].delete_suffix("_#{marker}"))
692
+ if r["database"].end_with?("_#{suffix}")
693
+ r.merge("database" => r["database"].delete_suffix("_#{suffix}"))
683
694
  else
684
695
  r
685
696
  end
@@ -692,20 +703,21 @@ module Yamine
692
703
  cfg_name == "primary" ? env : "#{env}_#{cfg_name}"
693
704
  end
694
705
 
695
- # The proof, not the promise: re-probe now that the marker file
696
- # is in place. Matching names mean database.yml reads it —
697
- # hand-run console/test/migrate commands are isolated too.
698
- # Mismatch is not fatal (supervised boots still isolate via
699
- # env), but it is the one gap worth shouting about at the exact
700
- # moment of creation. A probe that cannot run at all after
701
- # writing the marker IS fatal: the app no longer boots, and the
702
- # marker's own authorship is the likely reason.
703
- def verify_marker_conformance(dir, names)
706
+ # The proof, not the promise: re-probe now that .env exists.
707
+ # Matching names mean something inside the app loads .env
708
+ # (dotenv-rails or any dotenv loader) — hand-run console/test/
709
+ # migrate commands are isolated too. Mismatch is not fatal
710
+ # (supervised boots isolate via injected env regardless), but
711
+ # it is the one gap worth shouting about at the exact moment of
712
+ # creation. A probe that cannot run at all after writing .env
713
+ # IS fatal: the app no longer boots, and .env's own authorship
714
+ # is the likely reason.
715
+ def verify_env_loading(dir, names)
704
716
  rows = Probe.rails_databases(dir)
705
717
  unless rows
706
718
  raise Error,
707
- "the app no longer boots after writing #{Database::MARKER_FILE} — " \
708
- "check the suffix hook at the top of config/database.yml"
719
+ "the app no longer boots after writing .env — " \
720
+ "check the generated .env files in #{dir}"
709
721
  end
710
722
 
711
723
  actual = rows.to_h { |r| [r["name"], r["database"]] }
@@ -714,20 +726,37 @@ module Yamine
714
726
  # for them either way; skip, don't flag.
715
727
  mismatched = names.reject { |cfg, want| !actual.key?(cfg) || actual[cfg] == want }
716
728
  if mismatched.empty?
717
- puts " database.yml reads #{Database::MARKER_FILE} — hand-run commands are isolated too"
729
+ puts " .env loaded — hand-run commands are isolated too"
718
730
  else
719
731
  cfg, want = mismatched.first
720
- warn " WARNING: config/database.yml does not read #{Database::MARKER_FILE} " \
721
- "(#{cfg} resolves to #{actual[cfg].inspect}, expected #{want.inspect})."
722
- warn " Supervised boots are still isolated via env, but `rails console`, " \
723
- "`rails test`, and `db:migrate` run by hand in this worktree would use the main checkout's databases."
724
- warn " Add the suffix hook from the yamine README to database.yml, then re-run `yamine db create`."
732
+ env_url = rows.first["env_database_url"]
733
+ if env_url && !env_url.empty?
734
+ # The loader DID run — DATABASE_URL reached the app — yet
735
+ # resolution ignored it. database.yml supplies `url:` keys
736
+ # (credentials-style), and for those Rails gives the FILE
737
+ # precedence: merge_db_environment_variables skips configs
738
+ # that are already URL-shaped. No environment channel,
739
+ # spawned or loaded, can redirect this app in that form.
740
+ warn " WARNING: #{cfg} resolves to #{actual[cfg].inspect} even though " \
741
+ "DATABASE_URL says #{env_url.split("/").last.inspect}."
742
+ warn " database.yml's `url:` keys take precedence over the environment in this configuration —"
743
+ warn " neither injected env nor .env can redirect them. Switch development/test to"
744
+ warn " component form (database:, host:, username:) so DATABASE_URL and .env apply —"
745
+ warn " see the yamine README's multi-database section."
746
+ else
747
+ warn " WARNING: this app does not load .env " \
748
+ "(#{cfg} resolves to #{actual[cfg].inspect}, expected #{want.inspect})."
749
+ warn " Supervised boots are still isolated via injected env, but `rails console`, " \
750
+ "`rails test`, and `db:migrate` run by hand in this worktree would use the main checkout's databases."
751
+ warn " Fix once: add `gem \"dotenv-rails\", groups: [:development, :test]` to the Gemfile — " \
752
+ "any dotenv loader works."
753
+ end
725
754
  end
726
755
  rescue StandardError => e
727
756
  raise Error, e.message if e.is_a?(Error)
728
757
 
729
758
  raise Error,
730
- "the app no longer boots after writing #{Database::MARKER_FILE}: #{e.message}"
759
+ "the app no longer boots after writing .env: #{e.message}"
731
760
  end
732
761
 
733
762
  # Top-level `db: false` opts out of per-worktree databases.
@@ -930,7 +930,7 @@ module Yamine
930
930
  end
931
931
 
932
932
  # Main checkout: the databases ARE the app's own — no suffix, no
933
- # marker, no claim names. Just make sure they exist.
933
+ # .env files, no claim names. Just make sure they exist.
934
934
  def db_create_main(rows)
935
935
  missing = rows.reject { |r| Database.exists?(r["database"], r["url"]) }
936
936
  failed = missing.filter_map do |r|
@@ -29,19 +29,23 @@ module Yamine
29
29
  # credentials apps) no decryption key, which stops Rails before it
30
30
  # can even read database.yml. The single largest follow-up cost of
31
31
  # a new worktree, now carried automatically.
32
- LOCAL_CONFIG_FILES = %w[config/local.yml config/local.secrets config/master.key].freeze
33
- # Rails 7.1+ per-env credentials: development.key & co are
34
- # gitignored exactly like master.key and equally fatal without.
35
- CREDENTIALS_KEY_GLOB = "config/credentials/*.key"
36
-
37
- # The static list plus whatever key files this checkout has, plus
38
- # the worktree marker: everything yamine writes or copies that
39
- # must never read as uncommitted work.
40
- def local_config_files(dir)
41
- keys = Dir.glob(File.join(dir, CREDENTIALS_KEY_GLOB)).map do |path|
42
- path.sub(%r{\A#{Regexp.escape(dir.chomp("/"))}/?}, "")
43
- end
44
- LOCAL_CONFIG_FILES + keys + [Database::MARKER_FILE]
32
+ LOCAL_CONFIG_FILES = %w[config/local.yml config/local.secrets].freeze
33
+ # Only the credential keys the worktree's own environments need:
34
+ # master (multi-env credentials), development (boots + probe),
35
+ # test (test runs). Production and staging keys have no business
36
+ # sitting in a throwaway worktree directory.
37
+ CREDENTIAL_KEY_FILES = %w[
38
+ config/master.key
39
+ config/credentials/development.key
40
+ config/credentials/test.key
41
+ ].freeze
42
+
43
+ # Everything yamine copies or writes that must never read as
44
+ # uncommitted work: the per-checkout config, the three allowed
45
+ # keys, and the generated environment files.
46
+ def local_config_files(_dir)
47
+ LOCAL_CONFIG_FILES + CREDENTIAL_KEY_FILES + Database::ENV_FILES +
48
+ [Database::LEGACY_MARKER_FILE]
45
49
  end
46
50
 
47
51
  def run(ctx, args)
@@ -198,7 +202,7 @@ module Yamine
198
202
  # app's schema) so server problems surface at creation time, not
199
203
  # at first boot. Rails apps are ASKED what they have (the probe
200
204
  # resolves database.yml + credentials inside the app itself) and
201
- # get the whole suffixed set — claim, marker file, databases,
205
+ # get the whole suffixed set — claim, .env files, databases,
202
206
  # schema. Boot reuses an existing set idempotently, so this is
203
207
  # never wasted work. SQLite and non-Rails apps fall through to
204
208
  # the single-database path, which setup_database narrates.
@@ -32,12 +32,17 @@ module Yamine
32
32
 
33
33
  MAX_IDENTIFIER_BYTES = 63
34
34
  STATE_FILE = "databases.json"
35
- # Written into a worktree at add time; the app's database.yml
36
- # reads it and suffixes every database URL, so hand-run commands
37
- # (console, test, db:migrate) land on the worktree's own
38
- # databases too — env injection only ever reaches processes
39
- # yamine spawns.
40
- MARKER_FILE = ".yamine-db-suffix"
35
+ # The per-worktree environment files yamine writes: `.env` carries
36
+ # the development set for ANY dotenv-style loader (framework-agnostic),
37
+ # `.env.test` carries PRIMARY_DATABASE_URL — the key Rails checks
38
+ # *before* DATABASE_URL for a flat test config, so the test URL wins
39
+ # no matter which order a loader reads the two files in. Hand-run
40
+ # commands (`rails console`, `rails test`, `db:migrate`) pick these
41
+ # up; env injection still covers processes yamine spawns itself.
42
+ ENV_FILES = %w[.env .env.development .env.test].freeze
43
+ # 0.16.0's marker file — removed in favor of .env; deleted on sight
44
+ # so upgraded worktrees don't accumulate dead files.
45
+ LEGACY_MARKER_FILE = ".yamine-db-suffix"
41
46
 
42
47
  # Database name for a worktree dir + env (development/test).
43
48
  # Main checkout (no worktree marker) keeps the bare name.
@@ -171,28 +176,63 @@ module Yamine
171
176
  name
172
177
  end
173
178
 
174
- def write_marker(dir, suffix)
175
- File.write(File.join(dir, MARKER_FILE), "#{suffix}\n")
179
+ # Write the worktree's environment files: `.env` with the whole
180
+ # development set (dotenv-compatible — any framework or plain Ruby
181
+ # app that loads .env gets hand-run isolation for free) and
182
+ # `.env.test` with the test URL under PRIMARY_DATABASE_URL.
183
+ # Private (URLs carry whatever the app's config carries), and the
184
+ # 0.16.0 marker file is removed on the way past.
185
+ def write_env_files(dir, dev_env, test_url: nil)
186
+ FileUtils.rm_f(File.join(dir, LEGACY_MARKER_FILE))
187
+ return if dev_env.nil? && test_url.nil?
188
+
189
+ unless dev_env.nil? || dev_env.empty?
190
+ body = dotenv_body(dev_env.reject { |k, _v| k == "PRIMARY_DATABASE_URL" })
191
+ write_env_file(File.join(dir, ".env"), body)
192
+ # Same content under the env-specific name: a loader that
193
+ # prefers .env.development over .env finds identical values.
194
+ write_env_file(File.join(dir, ".env.development"), body)
195
+ end
196
+ return if test_url.nil? || test_url.to_s.empty?
197
+
198
+ write_env_file(File.join(dir, ".env.test"),
199
+ dotenv_body({ "PRIMARY_DATABASE_URL" => test_url }))
200
+ end
201
+
202
+ def write_env_file(path, body)
203
+ File.write(path, body)
204
+ File.chmod(0o600, path)
176
205
  end
177
206
 
178
- def read_marker(dir)
179
- path = File.join(dir, MARKER_FILE)
180
- File.file?(path) ? File.read(path).strip : nil
207
+ # dotenv format: single-quoted values (literal — no interpolation
208
+ # of a password's `$` or `#`), with the two escapes the dotenv
209
+ # grammar allows inside them.
210
+ def dotenv_body(env)
211
+ header = "# Per-worktree databases, written by yamine (worktree add).\n" \
212
+ "# Loaded by dotenv-rails / any dotenv loader; removed with this worktree.\n"
213
+ lines = env.map { |k, v| "#{k}='#{dotenv_escape(v.to_s)}'" }
214
+ "#{header}#{lines.join("\n")}\n"
181
215
  end
182
216
 
183
- # Keep the marker out of `git status` without touching the
184
- # committed .gitignore: git's exclude file lives in the shared
185
- # git dir, so one entry covers every worktree of the repo.
186
- def exclude_marker(dir)
217
+ def dotenv_escape(value)
218
+ value.gsub("\\") { "\\\\" }.gsub("'") { "\\'" }
219
+ end
220
+
221
+ # Keep files out of `git status` without touching the committed
222
+ # .gitignore: git's exclude file lives in the shared git dir, so
223
+ # entries cover every worktree of the repo. Idempotent per line.
224
+ def exclude_files(dir, names)
187
225
  out, status = Open3.capture2("git", "-C", dir, "rev-parse", "--git-path", "info/exclude")
188
226
  return unless status.success?
189
227
 
190
228
  path = out.strip
191
229
  path = File.expand_path(path, dir) unless path.start_with?("/")
192
- return if File.file?(path) && File.read(path).lines.any? { |l| l.strip == MARKER_FILE }
193
-
194
230
  FileUtils.mkdir_p(File.dirname(path))
195
- File.open(path, "a") { |f| f.puts MARKER_FILE }
231
+ existing = File.file?(path) ? File.read(path).lines.map(&:strip) : []
232
+ additions = Array(names).reject { |n| existing.include?(n) }
233
+ return if additions.empty?
234
+
235
+ File.open(path, "a") { |f| additions.each { |n| f.puts n } }
196
236
  rescue SystemCallError, IOError
197
237
  nil
198
238
  end
data/lib/yamine/probe.rb CHANGED
@@ -65,7 +65,11 @@ module Yamine
65
65
  url = "#{h[:adapter]}:#{db}"
66
66
  end
67
67
  end
68
- { "name" => c.name, "database" => db, "url" => url }
68
+ { "name" => c.name, "database" => db, "url" => url,
69
+ # What DATABASE_URL held INSIDE the app — the discriminator
70
+ # between "no .env loader ran" (nil) and "loader ran but
71
+ # database.yml's url: keys took precedence over it".
72
+ "env_database_url" => ENV["DATABASE_URL"] }
69
73
  end
70
74
  puts "YAMINE_DBS=#{JSON.generate(rows)}"
71
75
  RUBY
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.16.0"
4
+ VERSION = "0.17.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yamine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto