rails-worktree 0.2.0 → 0.3.1

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.
@@ -1,182 +1,299 @@
1
1
  require "fileutils"
2
+ require "json"
3
+ require "open3"
2
4
 
3
5
  module RailsWorktree
4
6
  module Commands
7
+ # Provisions the current checkout at a level: minimal (configuration files),
8
+ # test (minimal plus an isolated test setup) or full (minimal plus own
9
+ # development and test databases). Safe to repeat.
5
10
  class Init
6
- def initialize(args, skip_seeds: false)
7
- @worktree_name = args[0]
8
- @skip_seeds = skip_seeds
11
+ ENV_FILES = {"development" => ".env.development.local", "test" => ".env.test.local"}.freeze
12
+
13
+ attr_reader :root, :main_worktree, :name, :level
14
+
15
+ def initialize(args, level: nil, seed: false, base: nil, force: false, skip_seeds: nil)
16
+ @requested_name = args[0]
17
+ @requested_level = level
18
+ @seed = seed
19
+ @base = base
20
+ @force = force
9
21
  end
10
22
 
11
23
  def run
12
- unless @worktree_name
13
- puts "Error: Worktree name is required"
14
- puts "Usage: worktree --init <worktree-name>"
15
- exit 1
24
+ @context = Context.new
25
+ @root = @context.root
26
+ @main_worktree = main_worktree_path
27
+ raise Error, "Run init inside a linked worktree, not the main checkout #{@main_worktree}." if @root == @main_worktree
28
+
29
+ @config = Config.load(File.exist?(File.join(@root, Config::FILE)) ? @root : @main_worktree)
30
+ @name = @requested_name || File.basename(@root)
31
+ @level = @requested_level || @config.level
32
+ raise Error, "Unknown level #{@level}; use #{Config::LEVELS.join(", ")}." unless Config::LEVELS.include?(@level)
33
+
34
+ puts "Provisioning #{@root} (#{@level}) from #{@main_worktree}"
35
+ Dir.chdir(@root) do
36
+ record_base if @base
37
+ copy_files
38
+ create_directories
39
+ touch_files
40
+ link_node_modules
41
+ case @level
42
+ when "test" then setup_test
43
+ when "full" then setup_databases
44
+ end
16
45
  end
46
+ puts "✓ Worktree #{@name} provisioned (#{@level})"
47
+ end
48
+
49
+ def development_database = "#{database_prefix}_#{database_slug}_development"
50
+ def test_database = "#{database_prefix}_#{database_slug}_test"
17
51
 
18
- @main_worktree = get_main_worktree
19
- @db_prefix = get_db_prefix
20
- @dev_database_name = "#{@db_prefix}_#{@worktree_name}_development"
21
- @test_database_name = "#{@db_prefix}_#{@worktree_name}_test"
52
+ private
22
53
 
23
- puts "Initializing worktree '#{@worktree_name}'..."
24
- puts "Main worktree: #{@main_worktree}"
25
- puts "Development database: #{@dev_database_name}"
26
- puts "Test database: #{@test_database_name}"
54
+ def main_worktree_path
55
+ output = @context.git(@root, "worktree", "list", "--porcelain")
56
+ File.realpath(output.lines.grep(/^worktree /).first.delete_prefix("worktree ").strip)
57
+ end
27
58
 
28
- @context = Context.new
29
- @context_environment = @context.environment
59
+ def record_base
60
+ output, error, status = Open3.capture3(Context::GIT_ENVIRONMENT, "git", "-C", @root, "rev-parse", "--verify", "--end-of-options", "#{@base}^{commit}")
61
+ raise Error, "Invalid base #{@base}: #{error.strip}" unless status.success?
62
+ branch = @context.git(@root, "branch", "--show-current").strip
63
+ raise Error, "Cannot record a base on a detached HEAD." if branch.empty?
64
+ @context.record(@base, output.strip, branch)
65
+ @context.verify
66
+ Launcher.install(@root, replace: true)
67
+ PushHook.install(@root)
68
+ puts "Recorded base #{@base} for branch #{branch}"
69
+ end
30
70
 
31
- copy_config_files
32
- set_database_names
33
- update_database_yml
34
- copy_node_modules
35
- setup_database
71
+ def copy_files
72
+ @config.copy_patterns.each do |pattern|
73
+ Dir.glob(pattern, base: @main_worktree).sort.each do |relative|
74
+ source = File.join(@main_worktree, relative)
75
+ next unless File.file?(source)
76
+ next if File.exist?(relative) && !@force
36
77
 
37
- puts ""
38
- puts "✓ Worktree initialized successfully!"
39
- puts " Development database: #{@dev_database_name}"
40
- puts " Test database: #{@test_database_name}"
41
- puts " Configuration files copied"
78
+ FileUtils.mkdir_p(File.dirname(relative))
79
+ FileUtils.cp(source, relative)
80
+ puts " copied #{relative}"
81
+ end
82
+ end
42
83
  end
43
84
 
44
- private
85
+ def create_directories
86
+ @config.directories.each { |directory| FileUtils.mkdir_p(directory) }
87
+ end
45
88
 
46
- def get_main_worktree
47
- output = `git worktree list --porcelain`
48
- output.lines.grep(/^worktree /).first&.split(" ", 2)&.last&.strip
89
+ def touch_files
90
+ @config.touch.each do |file|
91
+ FileUtils.mkdir_p(File.dirname(file))
92
+ FileUtils.touch(file)
93
+ end
49
94
  end
50
95
 
51
- def get_db_prefix
52
- # Try to get prefix from database.yml first
53
- database_yml = File.join(@main_worktree, "config/database.yml")
54
- if File.exist?(database_yml)
55
- content = File.read(database_yml)
56
- match = content.match(/database:\s*(\w+)_development/)
57
- return match[1] if match
96
+ def link_node_modules
97
+ source = File.join(@main_worktree, "node_modules")
98
+ return if @config.node_modules == "skip" || !Dir.exist?(source) || File.exist?("node_modules") || File.symlink?("node_modules")
99
+
100
+ if @config.node_modules == "link"
101
+ File.symlink(source, "node_modules")
102
+ puts " linked node_modules to the main checkout"
103
+ else
104
+ FileUtils.cp_r(source, "node_modules")
105
+ puts " copied node_modules"
58
106
  end
107
+ end
59
108
 
60
- # Fall back to using the Rails app name (directory name of main worktree)
61
- File.basename(@main_worktree)
109
+ def setup_test
110
+ if @config.test_command
111
+ puts "Running #{@config.test_command}..."
112
+ run_command(@context.environment, *@config.test_command.split(" ")) || raise(Error, "#{@config.test_command} failed")
113
+ else
114
+ case database_mode(%w[test])
115
+ when :files
116
+ prepare_schema("test", nil)
117
+ when :variable
118
+ write_env_file("test", test_database)
119
+ prepare_schema("test", test_database)
120
+ end
121
+ end
62
122
  end
63
123
 
64
- def copy_config_files
65
- puts "Copying configuration files..."
124
+ def setup_databases
125
+ case database_mode(%w[development test])
126
+ when :files then setup_sqlite_databases
127
+ when :variable then setup_named_databases
128
+ end
129
+ end
66
130
 
67
- files_to_copy = [
68
- ".env",
69
- "config/database.yml",
70
- "Procfile.dev",
71
- "config/master.key"
72
- ]
131
+ # How this checkout gets its own databases: :files when config/database.yml
132
+ # points at SQLite files inside the checkout, :variable when it reads the
133
+ # configured variable and dotenv loads .env.*.local. Otherwise nil, and the
134
+ # checkout keeps sharing the main databases.
135
+ def database_mode(environments)
136
+ unless File.executable?("bin/rails")
137
+ puts "Warning: bin/rails not found; skipping database setup"
138
+ return
139
+ end
73
140
 
74
- files_to_copy += Dir.glob("config/credentials/*", base: @main_worktree)
141
+ @probe = probe_databases(environments)
142
+ unless @probe
143
+ puts "Warning: could not read config/database.yml (see above); skipping database setup"
144
+ return
145
+ end
75
146
 
76
- files_to_copy.each do |file|
77
- source = File.join(@main_worktree, file)
78
- if File.exist?(source)
79
- FileUtils.mkdir_p(File.dirname(file)) unless File.directory?(File.dirname(file))
80
- FileUtils.cp(source, file)
81
- else
82
- puts "Warning: #{file} not found, skipping"
83
- end
147
+ configs = environments.to_h { |environment| [environment, @probe[environment]] }
148
+ return :files if configs.values.all? { |config| sqlite_in_checkout?(config) }
149
+
150
+ environment, config = configs.find { |name, config| config.nil? || config["override"] != database_for(name) }
151
+ if environment
152
+ puts "Warning: config/database.yml does not read #{variable_for(environment)} for the #{environment} database, " \
153
+ "so this checkout would use #{config ? config["database"] : "no database"} like the main checkout. " \
154
+ "Skipping database setup; see Databases in the rails-worktree README."
155
+ elsif !@probe["dotenv"]
156
+ puts "Warning: dotenv is not loaded, so Rails would ignore .env.development.local and .env.test.local " \
157
+ "and use the main databases. Add gem \"dotenv-rails\" to the Gemfile. Skipping database setup."
158
+ else
159
+ :variable
84
160
  end
85
161
  end
86
162
 
87
- def set_database_names
88
- puts "Setting database names in .env..."
163
+ def probe_databases(environments)
164
+ script = File.expand_path("../database_probe.rb", __dir__)
165
+ arguments = environments.map { |environment| "#{environment}:#{variable_for(environment)}=#{database_for(environment)}" }
166
+ env = @context.environment.merge("RAILS_ENV" => "development")
167
+ output, status = Open3.capture2(env, *@context.command(["bin/rails", "runner", script, *arguments]), unsetenv_others: true)
168
+ line = output.lines.grep(/\ARAILS_WORKTREE_PROBE /).last
169
+ JSON.parse(line.delete_prefix("RAILS_WORKTREE_PROBE ")) if status.success? && line
170
+ end
89
171
 
90
- env_file = ".env"
91
- return unless File.exist?(env_file)
172
+ def sqlite_in_checkout?(config)
173
+ return false unless config && config["adapter"].include?("sqlite")
174
+ return false if config["database"].empty? || config["database"].start_with?(":memory:", "file:")
92
175
 
93
- content = File.read(env_file)
176
+ File.expand_path(config["database"], @root).start_with?(@root + File::SEPARATOR)
177
+ end
94
178
 
95
- # Set development database name
96
- if content.match?(/^DATABASE_NAME_DEVELOPMENT=/)
97
- content.gsub!(/^DATABASE_NAME_DEVELOPMENT=.*$/, "DATABASE_NAME_DEVELOPMENT=#{@dev_database_name}")
179
+ # SQLite files resolve inside each checkout, so the databases are separate
180
+ # already. The development database starts as a copy of the main checkout's.
181
+ def setup_sqlite_databases
182
+ if @config.template? && copy_sqlite_database(@probe.dig("development", "database"))
183
+ rails("development", nil, "db:migrate") || puts("Warning: migrations failed")
98
184
  else
99
- content += "\nDATABASE_NAME_DEVELOPMENT=#{@dev_database_name}\n"
185
+ prepare_schema("development", nil)
100
186
  end
187
+ prepare_schema("test", nil)
188
+ seed(nil)
189
+ end
101
190
 
102
- # Set test database name
103
- if content.match?(/^DATABASE_NAME_TEST=/)
104
- content.gsub!(/^DATABASE_NAME_TEST=.*$/, "DATABASE_NAME_TEST=#{@test_database_name}")
105
- else
106
- content += "DATABASE_NAME_TEST=#{@test_database_name}\n"
191
+ # Returns true when the checkout has its development database, copied now or before.
192
+ def copy_sqlite_database(database)
193
+ source = File.join(@main_worktree, database)
194
+ target = File.expand_path(database, @root)
195
+ return true if File.exist?(target)
196
+ return false unless File.file?(source)
197
+ if File.size(source) > @config.template_max_bytes
198
+ puts "Warning: #{database} in the main checkout is above database.template_max_gb; loading the schema instead"
199
+ return false
107
200
  end
108
201
 
109
- File.write(env_file, content)
202
+ puts "Copying the development database #{database} from the main checkout..."
203
+ FileUtils.mkdir_p(File.dirname(target))
204
+ script = File.expand_path("../copy_sqlite.rb", __dir__)
205
+ return true if run_command(@context.environment, "bundle", "exec", "ruby", script, source, target)
206
+
207
+ puts "Warning: could not copy #{database} (see above); loading the schema instead"
208
+ false
209
+ end
210
+
211
+ def setup_named_databases
212
+ cloned = @config.template? && clone_development_database
213
+ write_env_file("development", development_database)
214
+ write_env_file("test", test_database)
215
+ if cloned
216
+ rails("development", development_database, "db:migrate") || puts("Warning: migrations failed")
217
+ else
218
+ prepare_schema("development", development_database)
219
+ end
220
+ prepare_schema("test", test_database)
221
+ seed(development_database)
110
222
  end
111
223
 
112
- def update_database_yml
113
- puts "Updating database.yml to use separate database names..."
224
+ def seed(database)
225
+ return unless @seed
114
226
 
115
- database_yml = "config/database.yml"
116
- return unless File.exist?(database_yml)
227
+ puts "Seeding #{database || "the development database"}..."
228
+ rails("development", database, "db:seed") || puts("Warning: seeding failed")
229
+ end
117
230
 
118
- content = File.read(database_yml)
231
+ # Runs without the worktree database override so the configured development
232
+ # database is the main checkout's, then clones it. Returns false on failure.
233
+ def clone_development_database
234
+ return false if File.exist?(ENV_FILES["development"]) && !@force
119
235
 
120
- # Use a single-pass replacement to avoid one substitution creating
121
- # a substring that matches the other pattern (e.g., replacing
122
- # "app_development" first produces "app_test-branch_development",
123
- # which contains "app_test" and would be wrongly matched by a
124
- # second gsub for the test pattern).
125
- replacements = {
126
- "#{@db_prefix}_development" => @dev_database_name,
127
- "#{@db_prefix}_test" => @test_database_name
128
- }
129
- pattern = Regexp.union(replacements.keys)
130
- content.gsub!(pattern) { |match| replacements[match] }
236
+ puts "Cloning the development database into #{development_database}..."
237
+ script = File.expand_path("../clone_database.rb", __dir__)
238
+ env = @context.environment.merge("RAILS_ENV" => "development", "RAILS_WORKTREE_TEMPLATE_MAX_BYTES" => @config.template_max_bytes.to_s)
239
+ return true if run_command(env, "bin/rails", "runner", script, development_database)
131
240
 
132
- File.write(database_yml, content)
241
+ puts "Warning: the template clone did not run (see above; a database in use or above template_max_gb); loading the schema instead"
242
+ false
133
243
  end
134
244
 
135
- def copy_node_modules
136
- source = File.join(@main_worktree, "node_modules")
137
- dest = "node_modules"
245
+ # Creates the database and loads the schema, or runs the migrations in an
246
+ # application without a schema file yet. A nil database uses database.yml as is.
247
+ def prepare_schema(environment, database)
248
+ label = database || "the #{environment} database"
249
+ puts "Preparing #{label}..."
250
+ rails(environment, database, "db:create") || puts("Warning: could not create #{label}")
251
+ if %w[db/schema.rb db/structure.sql].any? { |file| File.file?(file) }
252
+ rails(environment, database, "db:schema:load") || puts("Warning: could not load the schema into #{label}")
253
+ else
254
+ rails(environment, database, "db:migrate") || puts("Warning: could not migrate #{label}")
255
+ end
256
+ end
138
257
 
139
- if Dir.exist?(source) && !Dir.exist?(dest)
140
- puts "Copying node_modules from main worktree..."
141
- FileUtils.cp_r(source, dest)
142
- puts "Note: node_modules copied."
258
+ def write_env_file(environment, database)
259
+ variable = variable_for(environment)
260
+ path = ENV_FILES.fetch(environment)
261
+ content = File.exist?(path) ? File.read(path) : "# Written by bin/worktree init; dotenv loads it in the #{environment} environment only.\n"
262
+ line = "#{variable}=#{database}"
263
+ if content.match?(/^#{Regexp.escape(variable)}=/)
264
+ content = content.gsub(/^#{Regexp.escape(variable)}=.*$/, line)
265
+ else
266
+ content += "\n" unless content.end_with?("\n")
267
+ content += "#{line}\n"
143
268
  end
269
+ File.write(path, content)
144
270
  end
145
271
 
146
- def database_env
147
- (@context_environment || {}).merge(
148
- "BUNDLE_GEMFILE" => File.expand_path("Gemfile"),
149
- "DATABASE_NAME_DEVELOPMENT" => @dev_database_name,
150
- "DATABASE_NAME_TEST" => @test_database_name
151
- )
272
+ def rails(environment, database, *arguments)
273
+ env = @context.environment.merge("RAILS_ENV" => environment, "DISABLE_DATABASE_ENVIRONMENT_CHECK" => "1")
274
+ env[variable_for(environment)] = database if database
275
+ run_command(env, "bin/rails", *arguments)
152
276
  end
153
277
 
278
+ def variable_for(environment) = environment == "test" ? @config.test_variable : @config.development_variable
279
+ def database_for(environment) = environment == "test" ? test_database : development_database
280
+
154
281
  def run_command(environment, *arguments)
155
- command = @context ? @context.command(arguments) : arguments
156
- system(environment, *command, unsetenv_others: !!@context_environment)
282
+ command = @context.command(arguments)
283
+ system(environment, *command, unsetenv_others: true)
157
284
  end
158
285
 
159
- def setup_database
160
- if File.executable?("bin/setup")
161
- puts "Running bin/setup..."
162
- run_command(database_env, "bin/setup") || puts("Warning: bin/setup failed")
163
- else
164
- puts "Creating databases..."
165
- run_command(database_env.merge("RAILS_ENV" => "development"), "bin/rails", "db:create") || puts("Warning: Could not create development database")
166
- run_command(database_env.merge("RAILS_ENV" => "test"), "bin/rails", "db:create") || puts("Warning: Could not create test database")
167
-
168
- puts "Running migrations..."
169
- run_command(database_env.merge("RAILS_ENV" => "development"), "bin/rails", "db:migrate") || puts("Warning: Could not run migrations")
170
- run_command(database_env.merge("RAILS_ENV" => "test"), "bin/rails", "db:migrate") || puts("Warning: Could not run test migrations")
171
-
172
- unless @skip_seeds
173
- puts "Seeding development database..."
174
- run_command(database_env.merge("RAILS_ENV" => "development"), "bin/rails", "db:seed") || puts("Warning: Could not seed database")
175
- else
176
- puts "Skipping database seeding..."
177
- end
286
+ def database_prefix
287
+ @database_prefix ||= begin
288
+ database_yml = File.join(@main_worktree, "config/database.yml")
289
+ match = File.exist?(database_yml) && File.read(database_yml).match(/(\w+)_development\b/)
290
+ match ? match[1] : File.basename(@main_worktree).gsub(/[^A-Za-z0-9_]/, "_")
178
291
  end
179
292
  end
293
+
294
+ def database_slug
295
+ @name.gsub(/[^A-Za-z0-9_]/, "_")
296
+ end
180
297
  end
181
298
  end
182
299
  end
@@ -0,0 +1,114 @@
1
+ require "yaml"
2
+
3
+ module RailsWorktree
4
+ # Project configuration from .worktree.yml in the repository root.
5
+ class Config
6
+ FILE = ".worktree.yml".freeze
7
+ LEVELS = %w[minimal test full].freeze
8
+ NODE_MODULES_MODES = %w[link copy skip].freeze
9
+
10
+ DEFAULTS = {
11
+ "level" => "full",
12
+ "copy" => [
13
+ ".env",
14
+ ".env.*",
15
+ "config/database.yml",
16
+ "config/master.key",
17
+ "config/credentials/*.key",
18
+ "Procfile.dev"
19
+ ],
20
+ "directories" => [],
21
+ "touch" => [],
22
+ "node_modules" => "link",
23
+ "database" => {
24
+ "development_variable" => "DATABASE_NAME",
25
+ "test_variable" => "DATABASE_NAME",
26
+ "template" => true,
27
+ "template_max_gb" => 5
28
+ },
29
+ "test_command" => nil
30
+ }.freeze
31
+
32
+ TEMPLATE = <<~YAML.freeze
33
+ # rails-worktree configuration. Every key is optional; these are the defaults.
34
+ # Docs: https://github.com/FastTravelAS/rails-worktree
35
+
36
+ # Provisioning level used by `bin/worktree create` and `bin/worktree init`
37
+ # when --level is not given: minimal, test or full.
38
+ level: full
39
+
40
+ # Gitignored files to copy from the main checkout. Globs are relative to the
41
+ # repository root. The same list generates .worktreeinclude for Claude Code.
42
+ copy:
43
+ - .env
44
+ - .env.*
45
+ - config/database.yml
46
+ - config/master.key
47
+ - config/credentials/*.key
48
+ - Procfile.dev
49
+
50
+ # Directories to create and files to touch in every provisioned checkout.
51
+ directories: []
52
+ touch: []
53
+
54
+ # node_modules handling: link (symlink to the main checkout), copy or skip.
55
+ node_modules: link
56
+
57
+ database:
58
+ # Environment variables that config/database.yml reads for the database
59
+ # name. They are written to .env.development.local and .env.test.local.
60
+ development_variable: DATABASE_NAME
61
+ test_variable: DATABASE_NAME
62
+ # Clone the main development database with CREATE DATABASE ... TEMPLATE
63
+ # (PostgreSQL) or by copying the SQLite file, instead of loading the schema. Falls back when it fails or
64
+ # when the source database is larger than template_max_gb.
65
+ template: true
66
+ template_max_gb: 5
67
+
68
+ # Command for `--level test`, for example bin/agent-test-setup. Without it
69
+ # the test level creates an own test database and loads the schema.
70
+ test_command:
71
+ YAML
72
+
73
+ attr_reader :root, :data
74
+
75
+ def self.load(root)
76
+ path = File.join(root, FILE)
77
+ data = File.file?(path) ? YAML.safe_load(File.read(path)) : nil
78
+ raise Error, "#{FILE} must be a YAML mapping" if data && !data.is_a?(Hash)
79
+ new(root, data || {})
80
+ end
81
+
82
+ def initialize(root, data = {})
83
+ @root = root
84
+ @data = deep_merge(DEFAULTS, data)
85
+ validate
86
+ end
87
+
88
+ def level = @data["level"]
89
+ def copy_patterns = Array(@data["copy"]).map(&:to_s)
90
+ def directories = Array(@data["directories"]).map(&:to_s)
91
+ def touch = Array(@data["touch"]).map(&:to_s)
92
+ def node_modules = @data["node_modules"].to_s
93
+ def development_variable = @data.dig("database", "development_variable").to_s
94
+ def test_variable = @data.dig("database", "test_variable").to_s
95
+ def template? = @data.dig("database", "template") ? true : false
96
+ def template_max_bytes = (@data.dig("database", "template_max_gb").to_f * 1024**3).to_i
97
+ def test_command = @data["test_command"]&.to_s
98
+
99
+ private
100
+
101
+ def validate
102
+ raise Error, "#{FILE}: level must be one of #{LEVELS.join(", ")}" unless LEVELS.include?(level)
103
+ raise Error, "#{FILE}: node_modules must be one of #{NODE_MODULES_MODES.join(", ")}" unless NODE_MODULES_MODES.include?(node_modules)
104
+ raise Error, "#{FILE}: database variables must be set" if development_variable.empty? || test_variable.empty?
105
+ raise Error, "#{FILE}: database.template_max_gb must be a positive number" unless template_max_bytes.positive?
106
+ end
107
+
108
+ def deep_merge(base, overrides)
109
+ base.merge(overrides) do |_key, old, new|
110
+ old.is_a?(Hash) && new.is_a?(Hash) ? deep_merge(old, new) : new
111
+ end
112
+ end
113
+ end
114
+ end
@@ -18,6 +18,7 @@ module RailsWorktree
18
18
  end
19
19
 
20
20
  def verify(push: false)
21
+ return if push && !File.file?(metadata_path)
21
22
  data = JSON.parse(File.read(metadata_path))
22
23
  raise Error, "Invalid worktree context in #{metadata_path}; expected an object with base, commit and branch." unless data.is_a?(Hash)
23
24
  verify_branch(data)
@@ -57,6 +58,12 @@ module RailsWorktree
57
58
  File.expand_path(git(root, "rev-parse", "--git-path", "rails-worktree.json").strip, root)
58
59
  end
59
60
 
61
+ def git(directory, *arguments)
62
+ output, error, status = Open3.capture3(GIT_ENVIRONMENT, "git", "-C", directory, *arguments)
63
+ raise Error, "Git context check failed (#{arguments.join(" ")}): #{error.strip}" unless status.success?
64
+ output
65
+ end
66
+
60
67
  private
61
68
 
62
69
  def executable_path(path)
@@ -122,11 +129,5 @@ module RailsWorktree
122
129
  _output, _error, status = Open3.capture3(GIT_ENVIRONMENT, "git", "-C", File.dirname(resolved), "rev-parse", "--show-toplevel")
123
130
  raise Error, "Binstub resolves outside this worktree: #{resolved}. Use this checkout's binstub through bin/worktree exec." if status.success?
124
131
  end
125
-
126
- def git(directory, *arguments)
127
- output, error, status = Open3.capture3(GIT_ENVIRONMENT, "git", "-C", directory, *arguments)
128
- raise Error, "Git context check failed (#{arguments.join(" ")}): #{error.strip}" unless status.success?
129
- output
130
- end
131
132
  end
132
133
  end
@@ -0,0 +1,7 @@
1
+ # Copies a SQLite database consistently, also while it is in use.
2
+ # Usage: ruby copy_sqlite.rb SOURCE TARGET
3
+ require "sqlite3"
4
+
5
+ source, target = ARGV.fetch(0), ARGV.fetch(1)
6
+ SQLite3::Database.new(source, readonly: true) { |database| database.execute("VACUUM INTO ?", [target]) }
7
+ puts "Copied #{source} into #{target}"
@@ -0,0 +1,27 @@
1
+ # Runs inside `bin/rails runner` in the worktree. Prints how config/database.yml
2
+ # resolves the primary database of each environment, without the worktree
3
+ # variable and with it set, and whether dotenv is loaded to read .env.*.local.
4
+ # Usage: bin/rails runner database_probe.rb ENVIRONMENT:VARIABLE=VALUE ...
5
+ require "json"
6
+
7
+ def resolve(environment, variable, value)
8
+ saved = ENV.to_h.slice(variable)
9
+ ENV.delete(variable)
10
+ ENV[variable] = value if value
11
+ configurations = ActiveRecord::DatabaseConfigurations.new(Rails.application.config.database_configuration)
12
+ config = configurations.configs_for(env_name: environment, name: "primary") || configurations.configs_for(env_name: environment).first
13
+ config && {"adapter" => config.adapter.to_s, "database" => config.database.to_s}
14
+ ensure
15
+ ENV.delete(variable)
16
+ ENV.update(saved)
17
+ end
18
+
19
+ result = {"dotenv" => defined?(Dotenv) ? true : false}
20
+ ARGV.each do |argument|
21
+ environment, assignment = argument.split(":", 2)
22
+ variable, value = assignment.split("=", 2)
23
+ default = resolve(environment, variable, nil)
24
+ override = resolve(environment, variable, value)
25
+ result[environment] = default && default.merge("override" => override && override["database"])
26
+ end
27
+ puts "RAILS_WORKTREE_PROBE #{JSON.generate(result)}"