yamine 0.4.0 → 0.5.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: de59fb684553be7e33ba1a9ea4688bb075a3147ae6225cdc634818c869900163
4
- data.tar.gz: 920ac5409e8e0803ed3e3d8ba01663ea03f1543db30e4921585ef6bfdf0d33be
3
+ metadata.gz: 0c28579a9f53f3078412006a6f697d366cd7f0b2f897e7cf52025b742b2d5ba2
4
+ data.tar.gz: e7c826ffece34cf08ac88fcb31d1613418a9f3f5713badb08f6725937d906409
5
5
  SHA512:
6
- metadata.gz: 8f0cb59209724dd41946c4e0e82c6e2aa01ac9e49575bf461610ed525d98d50fb1566260cf83f7a0b50e19cffd4ab9d6658e372d1c094529e12276e256541161
7
- data.tar.gz: 8bb784a546f91a27c5a9b00584fbe14560022d655acd861d5bfdba13db8f187b70913fd129b46b73948068d17c1affca556fd0eb4a67690074d74dbcfdb96214
6
+ metadata.gz: e1a77d9dad1424f4cdbd0fa351224f626a858e986c6300063a70650dc7c02007f8ea8cbb83e2658a5f06e1a3ff8ba091986ce1480c28e510d9a096c287cc596d
7
+ data.tar.gz: 9194bc35c52acdb669a177bb758af0539c2983056c8e7e4dbd700d937ea850a2947708fef0315d8e2d6281b33850bb96a2de4f26abb0878a2b1b8d3db91cd777
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] — Unreleased
4
+
5
+ Per-worktree databases: every worktree directory gets its own database
6
+ so concurrent agents never share tables.
7
+
8
+ ### Added
9
+
10
+ - `Yamine::Database`: `<sanitized-dir-basename>_<env>` naming
11
+ (`/code/myapp-fix` → `myapp_fix_development`; main keeps the bare
12
+ name), 63-byte Postgres cap with hash-suffix truncation, collision
13
+ guard via state map (silent sharing would be the bug this kills).
14
+ - Boot resolves the DB once, creates it (`createdb`/`mysqladmin`,
15
+ never raises — warns and shares template on failure), runs the app's
16
+ schema-load (`db.schema_load` in config, Rails default, else nothing),
17
+ and injects `DATABASE_URL` into every process. SQLite left alone.
18
+ - `yamine db list|create|drop` and `yamine worktree list|clean`
19
+ (orphan reaping for deleted worktrees). Branch plays no part —
20
+ directories are stable, branches hop.
21
+
3
22
  ## [0.3.0] — Unreleased
4
23
 
5
24
  Renamed from `ask-local` to **`yamine`** (يمين, "right hand" — the local
@@ -62,10 +62,21 @@ module Yamine
62
62
  puts "yamine (#{service})"
63
63
  puts "--"
64
64
 
65
+ # Per-worktree database: one database per directory so concurrent
66
+ # agents never share tables. Resolved once, injected into every
67
+ # process as DATABASE_URL, created on demand with the app's own
68
+ # schema-load command. SQLite needs nothing (relative paths
69
+ # already isolate); the branch plays no part (dirs are stable,
70
+ # branches hop).
71
+ db_name = Database.name_for(Dir.pwd, env: rails_env,
72
+ state_dir: ctx.store.dir)
73
+ db_url = setup_database(ctx, resolved, db_name)
74
+ puts " [db] #{db_name}" if db_url
75
+
65
76
  processes.each do |proc_name, entry|
66
77
  hostname = Resolver.hostname_for(resolved, proc_name)
67
78
  if entry["proxy"] == false
68
- boot_background(ctx, runner, resolved, proc_name, entry, opts, children)
79
+ boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
69
80
  next
70
81
  end
71
82
  next unless hostname
@@ -87,9 +98,11 @@ module Yamine
87
98
  shell_cmd = ["sh", "-c", cmd]
88
99
  # Always allow the proxied hostname in Rails dev (Rails ignores
89
100
  # this env var when not a Rails app — safe for every framework).
101
+ # DATABASE_URL points at this worktree's own database; non-Ruby
102
+ # frameworks that honor it get isolation for free, others ignore it.
90
103
  app = runner.boot_run(name: proc_name, hostname: hostname, url: url,
91
104
  dir: Dir.pwd, command: shell_cmd, port: port, force: opts[:force],
92
- rails_dev_host: hostname)
105
+ rails_dev_host: hostname, database_url: db_url)
93
106
  register_all(ctx, hostnames, app, force: opts[:force],
94
107
  spec: { "dir" => File.expand_path(Dir.pwd), "proc" => proc_name })
95
108
  routes_registered << { hostnames: hostnames, app: app }
@@ -114,7 +127,7 @@ module Yamine
114
127
 
115
128
  # A background process (proxy: false) is spawned, logged, and
116
129
  # supervised exactly like an HTTP one — it just gets no route.
117
- def boot_background(ctx, runner, resolved, proc_name, entry, opts, children)
130
+ def boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
118
131
  cmd = entry["cmd"].to_s
119
132
  if cmd.match?(Yamine::Procfile::COMPOUND)
120
133
  $stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
@@ -124,7 +137,8 @@ module Yamine
124
137
  url = "background://#{resolved.app}/#{proc_name}"
125
138
  app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
126
139
  url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
127
- force: opts[:force], rails_dev_host: nil, register: false)
140
+ force: opts[:force], rails_dev_host: nil, register: false,
141
+ database_url: db_url)
128
142
  children << { name: proc_name, pid: app.pid }
129
143
  puts " [#{proc_name}] background (pid #{app.pid})"
130
144
  end
@@ -166,6 +180,64 @@ module Yamine
166
180
  env
167
181
  end
168
182
 
183
+ # Rails env for database naming: RAILS_ENV when set, else development.
184
+ # Non-Rails apps ignore it (their DATABASE_URL template decides).
185
+ def rails_env
186
+ ENV.fetch("RAILS_ENV", "development")
187
+ end
188
+
189
+ # Resolve this worktree's database, create it if missing, and run
190
+ # the app's schema-load command on first creation. Returns the
191
+ # DATABASE_URL for injection, or nil when there's nothing to do
192
+ # (no template URL, sqlite, or the app opted out via db: false).
193
+ def setup_database(ctx, resolved, db_name)
194
+ return nil if resolved.processes["db"] == false
195
+
196
+ template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
197
+ return nil if template.nil? || template.strip.empty?
198
+
199
+ url = Database.url_for(db_name, template)
200
+ return nil unless url
201
+
202
+ if Database.ensure_exists(db_name, template)
203
+ puts " [db] created #{db_name}" unless database_exists?(db_name, template)
204
+ run_schema_load(resolved, url)
205
+ else
206
+ $stderr.puts " [db] WARNING: could not create #{db_name} — processes share the template database."
207
+ return nil
208
+ end
209
+ url
210
+ end
211
+
212
+ def database_exists?(name, template)
213
+ # ensure_exists is idempotent; this second call is cheap (psql -lqt)
214
+ # and tells us whether to print "created" vs nothing.
215
+ Database.ensure_exists(name, template)
216
+ end
217
+
218
+ def database_template_from_config(resolved)
219
+ env = resolved.processes.values.map { |e| e["env"] || {} }
220
+ clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
221
+ clear["DATABASE_URL"]
222
+ end
223
+
224
+ # Schema-load on first boot uses the app's own command when declared
225
+ # (db.schema_load in config/local.yml), else a Rails default when a
226
+ # Rails app is detected, else nothing (frameworks without a
227
+ # schema concept need no step).
228
+ def run_schema_load(resolved, url)
229
+ cmd = resolved.processes.dig("db", "schema_load") ||
230
+ ("bin/rails db:schema:load" if rails_app?)
231
+ return unless cmd
232
+
233
+ system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
234
+ "sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
235
+ end
236
+
237
+ def rails_app?
238
+ File.file?(File.join(Dir.pwd, "config", "application.rb"))
239
+ end
240
+
169
241
  def supervise_tree(ctx, hostnames, pids)
170
242
  loop do
171
243
  sleep 0.5
@@ -763,6 +763,134 @@ module Yamine
763
763
  puts " - #{app}-#{slug}.#{domain}"
764
764
  puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
765
765
  end
766
+
767
+ # Per-worktree databases: list/create/drop, plus orphan cleanup.
768
+ # `db list` shows every claimed database with its worktree dir.
769
+ # `db create` resolves this worktree's name and creates it.
770
+ # `db drop <name>` drops one database (refuses without --force
771
+ # when its worktree still exists).
772
+ def db(ctx, args)
773
+ sub = args.first
774
+ case sub
775
+ when "list", nil then db_list(ctx)
776
+ when "create" then db_create(ctx)
777
+ when "drop" then db_drop(ctx, args[1..] || [])
778
+ else raise Error, "Usage: yamine db [list|create|drop <name> [--force]]"
779
+ end
780
+ end
781
+
782
+ def db_list(ctx)
783
+ map = Database.load_map(ctx.store.dir)
784
+ if map.empty?
785
+ puts "No worktree databases claimed yet."
786
+ puts "Boot an app with a DATABASE_URL template to create one."
787
+ return
788
+ end
789
+ map.each do |name, info|
790
+ alive = File.directory?(info["dir"]) ? "" : " (worktree gone)"
791
+ puts " #{name} -> #{info["dir"]}#{alive}"
792
+ end
793
+ end
794
+
795
+ def db_create(ctx)
796
+ resolved = Resolver.resolve(Dir.pwd)
797
+ name = Database.name_for(Dir.pwd, env: boot_env_name,
798
+ state_dir: ctx.store.dir)
799
+ template = ENV["DATABASE_URL"]
800
+ unless template && !template.strip.empty?
801
+ $stderr.puts "Error: set DATABASE_URL (template with host/user; database segment is replaced)."
802
+ exit 1
803
+ end
804
+ if Database.ensure_exists(name, template)
805
+ puts "Database #{name} ready."
806
+ else
807
+ $stderr.puts "Error: could not create #{name}. Check the database server is running."
808
+ exit 1
809
+ end
810
+ end
811
+
812
+ def db_drop(ctx, args)
813
+ force = args.delete("--force")
814
+ name = args.first
815
+ raise Error, "Usage: yamine db drop <name> [--force]" unless name
816
+
817
+ map = Database.load_map(ctx.store.dir)
818
+ info = map[name]
819
+ if info && File.directory?(info["dir"]) && !force
820
+ $stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
821
+ exit 1
822
+ end
823
+ if drop_database(ctx, name)
824
+ map.delete(name)
825
+ Database.save_map(ctx.store.dir, map)
826
+ puts "Dropped #{name}."
827
+ else
828
+ $stderr.puts "Error: could not drop #{name}."
829
+ exit 1
830
+ end
831
+ end
832
+
833
+ def drop_database(_ctx, name)
834
+ template = ENV["DATABASE_URL"]
835
+ return false unless template && !template.strip.empty?
836
+
837
+ case Database.adapter_for(template)
838
+ when :postgres
839
+ uri = URI.parse(template)
840
+ _out, status = Open3.capture2("dropdb", name, env: Database.pg_env(uri))
841
+ status.success?
842
+ when :mysql
843
+ uri = URI.parse(template)
844
+ args = ["-h", uri.host || "127.0.0.1", "-u",
845
+ URI.decode_www_form_component(uri.user || "root")]
846
+ _out, status = Open3.capture2("mysqladmin", *args, "drop", name, "-f")
847
+ status.success?
848
+ else
849
+ false
850
+ end
851
+ rescue SystemCallError
852
+ false
853
+ end
854
+
855
+ def boot_env_name
856
+ ENV.fetch("RAILS_ENV", "development")
857
+ end
858
+
859
+ # Worktree helpers: list claimed databases with liveness, clean
860
+ # drops databases whose worktree dirs are gone.
861
+ def worktree(ctx, args)
862
+ sub = args.first
863
+ case sub
864
+ when "list", nil
865
+ map = Database.load_map(ctx.store.dir)
866
+ if map.empty?
867
+ puts "No worktree databases claimed yet."
868
+ return
869
+ end
870
+ map.each do |name, info|
871
+ alive = File.directory?(info["dir"]) ? "alive" : "gone"
872
+ puts " #{name} #{info["dir"]} (#{alive})"
873
+ end
874
+ when "clean"
875
+ orphaned = Database.orphaned(ctx.store.dir)
876
+ if orphaned.empty?
877
+ puts "No orphaned worktree databases."
878
+ return
879
+ end
880
+ orphaned.each do |name, _info|
881
+ if drop_database(ctx, name)
882
+ puts "Dropped orphaned #{name}."
883
+ else
884
+ $stderr.puts "Could not drop #{name} — remove manually."
885
+ end
886
+ end
887
+ map = Database.load_map(ctx.store.dir)
888
+ orphaned.each_key { |name| map.delete(name) }
889
+ Database.save_map(ctx.store.dir, map)
890
+ else
891
+ raise Error, "Usage: yamine worktree [list|clean]"
892
+ end
893
+ end
766
894
  end
767
895
  end
768
896
  end
data/lib/yamine/cli.rb CHANGED
@@ -10,7 +10,7 @@ module Yamine
10
10
  # In non-interactive environments (no TTY or CI=1) we fail early with
11
11
  # a clear message instead of prompting (portless lesson).
12
12
  class CLI
13
- SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init].freeze
13
+ SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init db worktree].freeze
14
14
 
15
15
  def self.run(argv)
16
16
  new.run(argv)
@@ -47,6 +47,8 @@ module Yamine
47
47
  when "setup" then SystemCommand.setup(ctx, args)
48
48
  when "init" then SystemCommand.init(ctx, args)
49
49
  when "start" then SystemCommand.start(ctx, args)
50
+ when "db" then SystemCommand.db(ctx, args)
51
+ when "worktree" then SystemCommand.worktree(ctx, args)
50
52
  when "kamal" then SystemCommand.kamal(ctx, args)
51
53
  when "stop"
52
54
  exit RoutesCommand.stop(ctx, args)
@@ -81,6 +83,8 @@ module Yamine
81
83
  yamine trust Add local CA to trust store
82
84
  yamine clean Remove state and hosts entries
83
85
  yamine prune Remove stale routes
86
+ yamine db list|create|drop Per-worktree databases
87
+ yamine worktree list|clean Worktree databases + orphan cleanup
84
88
  yamine proxy start|stop Control the proxy
85
89
  yamine service install|status|uninstall OS startup service
86
90
  yamine sudoers Print scoped passwordless-sudo rules for port 443
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+
8
+ module Yamine
9
+ # Per-worktree databases. Every worktree directory gets its own database
10
+ # so concurrent agents never share tables: no migration races, no seed
11
+ # pollution, no schema drift between branches.
12
+ #
13
+ # Naming: <sanitized-dir-basename>_<env> — e.g. /Users/kaka/code/myapp-fix
14
+ # becomes myapp_fix_development. The branch a worktree has checked out is
15
+ # irrelevant and mutable, so it plays no part; the directory is stable.
16
+ # The main checkout keeps the bare <basename>_<env> name. Postgres
17
+ # identifiers cap at 63 bytes; overlong names truncate with a hash
18
+ # suffix (same rule as hostname labels).
19
+ #
20
+ # Collision guard: two directories can sanitize identically
21
+ # (myapp-fix vs myapp_fix). A state file records which directory
22
+ # claimed which name first; on collision the newcomer gets a short
23
+ # hash suffix. Silent sharing would be exactly the bug this exists to
24
+ # kill, so the check is mandatory, not best-effort.
25
+ #
26
+ # Framework-agnostic: yamine injects DATABASE_URL and never touches
27
+ # framework files. SQLite needs nothing (relative paths already isolate
28
+ # per worktree). Creation runs the app's own schema-load command from
29
+ # config/local.yml — never assumed.
30
+ module Database
31
+ module_function
32
+
33
+ MAX_IDENTIFIER_BYTES = 63
34
+ STATE_FILE = "databases.json"
35
+
36
+ # Database name for a worktree dir + env (development/test).
37
+ # Main checkout (no worktree marker) keeps the bare name.
38
+ def name_for(dir, env: "development", state_dir: nil)
39
+ base = sanitize(File.basename(File.expand_path(dir)))
40
+ name = "#{base}_#{env}"
41
+ name = truncate(name)
42
+ claimed = claim(name, dir, state_dir: state_dir)
43
+ claimed || "#{truncate("#{base}_#{short_hash(dir)}")}_#{env}"
44
+ end
45
+
46
+ # The main checkout is the one whose .git is a directory (linked
47
+ # worktrees have a .git FILE pointing into /worktrees/). Main keeps
48
+ # the bare name; only linked worktrees get suffixed names... in
49
+ # practice every dir gets the mapping, but main's mapping is itself.
50
+ def main_checkout?(dir)
51
+ File.directory?(File.join(File.expand_path(dir), ".git"))
52
+ end
53
+
54
+ def sanitize(name)
55
+ name.downcase.gsub(/[^a-z0-9_]/, "_").gsub(/_+/, "_").gsub(/\A_+|_+\z/, "")
56
+ end
57
+
58
+ def truncate(name)
59
+ return name if name.bytesize <= MAX_IDENTIFIER_BYTES
60
+
61
+ hash = Digest::SHA256.hexdigest(name)[0, 6]
62
+ prefix = name.byteslice(0, MAX_IDENTIFIER_BYTES - 7).gsub(/_+\z/, "")
63
+ "#{prefix}_#{hash}"
64
+ end
65
+
66
+ def short_hash(dir)
67
+ Digest::SHA256.hexdigest(File.expand_path(dir))[0, 6]
68
+ end
69
+
70
+ # Record dir -> name claim; returns the name if ours (or unclaimed),
71
+ # nil if another dir claimed it first (caller must suffix).
72
+ def claim(name, dir, state_dir: nil)
73
+ return name unless state_dir
74
+
75
+ dir = File.expand_path(dir)
76
+ map = load_map(state_dir)
77
+ if map.key?(name) && map[name]["dir"] != dir
78
+ return nil
79
+ end
80
+ map[name] = { "dir" => dir, "claimed_at" => Time.now.utc.iso8601 }
81
+ save_map(state_dir, map)
82
+ name
83
+ rescue SystemCallError
84
+ name
85
+ end
86
+
87
+ def load_map(state_dir)
88
+ path = File.join(state_dir, STATE_FILE)
89
+ return {} unless File.file?(path)
90
+
91
+ JSON.parse(File.read(path))
92
+ rescue JSON::ParserError, SystemCallError
93
+ {}
94
+ end
95
+
96
+ def save_map(state_dir, map)
97
+ FileUtils.mkdir_p(state_dir)
98
+ File.write(File.join(state_dir, STATE_FILE), JSON.pretty_generate(map))
99
+ rescue SystemCallError
100
+ nil
101
+ end
102
+
103
+ # Databases whose worktree dirs no longer exist (for `worktree clean`).
104
+ def orphaned(state_dir)
105
+ load_map(state_dir).select { |_, v| !File.directory?(v["dir"]) }
106
+ end
107
+
108
+ # Adapter detection from DATABASE_URL scheme. Returns :postgres,
109
+ # :mysql, :sqlite, or :unknown. Only postgres/mysql get create +
110
+ # DATABASE_URL treatment; sqlite is left alone (relative paths
111
+ # already isolate per worktree).
112
+ def adapter_for(database_url)
113
+ str = database_url.to_s.strip
114
+ return :sqlite if str.empty?
115
+
116
+ scheme = str[/\A[a-z0-9+]+/i].to_s.downcase
117
+ case scheme
118
+ when "postgres", "postgresql" then :postgres
119
+ when "mysql", "mysql2" then :mysql
120
+ when "sqlite", "sqlite3" then :sqlite
121
+ else :unknown
122
+ end
123
+ end
124
+
125
+ # Build a DATABASE_URL for name from a template URL (host, port, user
126
+ # preserved; database segment replaced). Returns nil when the template
127
+ # is absent or sqlite.
128
+ def url_for(name, template_url)
129
+ return nil if template_url.nil? || template_url.strip.empty?
130
+ return nil if adapter_for(template_url) == :sqlite
131
+
132
+ uri = URI.parse(template_url)
133
+ uri.path = "/#{name}"
134
+ uri.to_s
135
+ rescue URI::InvalidURIError
136
+ nil
137
+ end
138
+
139
+ # Create the database if missing. Uses createdb/mysqladmin when
140
+ # available; returns true/false, never raises (boot reports, not dies).
141
+ def ensure_exists(name, database_url)
142
+ case adapter_for(database_url)
143
+ when :postgres then ensure_postgres(name, database_url)
144
+ when :mysql then ensure_mysql(name, database_url)
145
+ else false
146
+ end
147
+ end
148
+
149
+ def ensure_postgres(name, database_url)
150
+ uri = URI.parse(database_url)
151
+ env = pg_env(uri)
152
+ _out, status = Open3.capture2(env, "psql", "-lqt")
153
+ return false unless status.success?
154
+
155
+ exists = Open3.capture2(env, "psql", "-lqt")[0].split("\n").any? do |line|
156
+ line.split("|").first.to_s.strip == name
157
+ end
158
+ return true if exists
159
+
160
+ _out, status = Open3.capture2(env, "createdb", name)
161
+ status.success?
162
+ rescue SystemCallError
163
+ false
164
+ end
165
+
166
+ def ensure_mysql(name, database_url)
167
+ uri = URI.parse(database_url)
168
+ args = ["-h", uri.host || "127.0.0.1", "-P", (uri.port || 3306).to_s,
169
+ "-u", URI.decode_www_form_component(uri.user || "root")]
170
+ args += ["-p#{URI.decode_www_form_component(uri.password)}"] if uri.password
171
+ _out, status = Open3.capture2("mysqladmin", *args, "create", name)
172
+ status.success? || already_exists_mysql(args, name)
173
+ rescue SystemCallError
174
+ false
175
+ end
176
+
177
+ def already_exists_mysql(args, name)
178
+ out, status = Open3.capture2("mysql", *args, "-e", "SHOW DATABASES;")
179
+ status.success? && out.split("\n").include?(name)
180
+ rescue SystemCallError
181
+ false
182
+ end
183
+
184
+ def pg_env(uri)
185
+ {
186
+ "PGHOST" => uri.host || "127.0.0.1",
187
+ "PGPORT" => (uri.port || 5432).to_s,
188
+ "PGUSER" => URI.decode_www_form_component(uri.user || ENV["USER"].to_s),
189
+ "PGPASSWORD" => uri.password ? URI.decode_www_form_component(uri.password) : nil
190
+ }.compact
191
+ end
192
+ end
193
+ end
data/lib/yamine/runner.rb CHANGED
@@ -92,9 +92,10 @@ module Yamine
92
92
  # Run an arbitrary command with PORT + YAMINE_URL. Returns App.
93
93
  # register: false spawns without a route (background processes).
94
94
  def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
95
- rails_dev_host: nil, register: true)
95
+ rails_dev_host: nil, register: true, database_url: nil)
96
96
  port ||= Ports.find_free
97
- env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host)
97
+ env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
98
+ database_url: database_url)
98
99
  pid = with_clean_env { spawn(env, *command, chdir: dir) }
99
100
  Process.detach(pid)
100
101
  target = "127.0.0.1:#{port}"
@@ -106,7 +107,7 @@ module Yamine
106
107
  target: target, kind: "tcp", command: command)
107
108
  end
108
109
 
109
- def child_env(dir, url:, port:, rails_dev_host: nil)
110
+ def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil)
110
111
  env = { "YAMINE_URL" => url }
111
112
  env["PORT"] = port.to_s if port
112
113
  env["HOST"] = "127.0.0.1"
@@ -116,6 +117,10 @@ module Yamine
116
117
  # proxied hostname so Rails apps boot behind yamine with zero
117
118
  # config — this replaces the yamine-rails hosts patch.
118
119
  env["RAILS_DEVELOPMENT_HOSTS"] = rails_dev_host if rails_dev_host
120
+ # Per-worktree database: every process in this worktree shares one
121
+ # isolated database. Frameworks that honor DATABASE_URL (Rails,
122
+ # Django, most Node) get isolation for free; others ignore it.
123
+ env["DATABASE_URL"] = database_url if database_url
119
124
  env
120
125
  end
121
126
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/yamine.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "yamine/version"
4
4
  require_relative "yamine/errors"
5
5
  require_relative "yamine/agent"
6
+ require_relative "yamine/database"
6
7
  require_relative "yamine/sanitize"
7
8
  require_relative "yamine/hostname"
8
9
  require_relative "yamine/inference"
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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -91,6 +91,7 @@ files:
91
91
  - lib/yamine/cli/system.rb
92
92
  - lib/yamine/command.rb
93
93
  - lib/yamine/config.rb
94
+ - lib/yamine/database.rb
94
95
  - lib/yamine/doctor.rb
95
96
  - lib/yamine/errors.rb
96
97
  - lib/yamine/framework.rb