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.
- checksums.yaml +4 -4
- data/README.md +113 -98
- data/lib/rails-worktree.rb +2 -0
- data/lib/worktree/cli.rb +76 -55
- data/lib/worktree/clone_database.rb +23 -0
- data/lib/worktree/commands/close.rb +71 -56
- data/lib/worktree/commands/create.rb +9 -24
- data/lib/worktree/commands/init.rb +240 -123
- data/lib/worktree/config.rb +114 -0
- data/lib/worktree/context.rb +7 -6
- data/lib/worktree/copy_sqlite.rb +7 -0
- data/lib/worktree/database_probe.rb +27 -0
- data/lib/worktree/installer.rb +151 -4
- data/lib/worktree/managed_block.rb +32 -0
- data/lib/worktree/push_hook.rb +15 -2
- data/lib/worktree/railtie.rb +3 -5
- data/lib/worktree/tasks.rb +5 -4
- data/lib/worktree/version.rb +1 -1
- metadata +7 -2
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
85
|
+
def create_directories
|
|
86
|
+
@config.directories.each { |directory| FileUtils.mkdir_p(directory) }
|
|
87
|
+
end
|
|
45
88
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
return unless
|
|
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
|
-
|
|
176
|
+
File.expand_path(config["database"], @root).start_with?(@root + File::SEPARATOR)
|
|
177
|
+
end
|
|
94
178
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
185
|
+
prepare_schema("development", nil)
|
|
100
186
|
end
|
|
187
|
+
prepare_schema("test", nil)
|
|
188
|
+
seed(nil)
|
|
189
|
+
end
|
|
101
190
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
224
|
+
def seed(database)
|
|
225
|
+
return unless @seed
|
|
114
226
|
|
|
115
|
-
|
|
116
|
-
|
|
227
|
+
puts "Seeding #{database || "the development database"}..."
|
|
228
|
+
rails("development", database, "db:seed") || puts("Warning: seeding failed")
|
|
229
|
+
end
|
|
117
230
|
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
156
|
-
system(environment, *command, unsetenv_others:
|
|
282
|
+
command = @context.command(arguments)
|
|
283
|
+
system(environment, *command, unsetenv_others: true)
|
|
157
284
|
end
|
|
158
285
|
|
|
159
|
-
def
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
data/lib/worktree/context.rb
CHANGED
|
@@ -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)}"
|